Skip to content

feat(docxdiff): Word Compare "Comparison settings" parity (white space, textboxes, fields, tables, character granularity) - #337

Open
rey-ms wants to merge 9 commits into
JSv4:mainfrom
rey-ms:pr/word-compare-parity
Open

feat(docxdiff): Word Compare "Comparison settings" parity (white space, textboxes, fields, tables, character granularity)#337
rey-ms wants to merge 9 commits into
JSv4:mainfrom
rey-ms:pr/word-compare-parity

Conversation

@rey-ms

@rey-ms rey-ms commented Aug 3, 2026

Copy link
Copy Markdown

Adds the missing Word Compare "Comparison settings" options to the DocxDiff engine, plus two rendering-correctness fixes found while building them. Every option defaults to Word's own default, so existing output is byte-identical unless a caller opts in.

docs/architecture/wml_comparer_gaps.md gained a table mapping Word's Compare dialog onto our settings; this series fills in five of its rows.

Added

Word Compare option Setting Default
White space WmlComparerSettings.CompareWhitespace / DocxDiffSettings.CompareWhitespace true
Textboxes DocxDiffSettings.CompareTextboxes true
Fields DocxDiffSettings.CompareFields true
Tables DocxDiffSettings.CompareTables true
Show changes at (Character/Word level) DocxDiffSettings.ChangeGranularity Word
  • White space — off canonicalizes whitespace in both inputs before the diff (shared Internal/WhitespaceCanonicalizer.cs, used by both engines), so whitespace-only differences produce no revisions. A tab is never equated with a space.
  • Textboxes — a granularity switch, not suppression: off, a changed box is deleted-and-reinserted wholesale by its host paragraph instead of inner-diffed, so the change stays in the markup.
  • Fields — governs whether field codes (instruction text, simple-vs-complex form, w:fldChar/w:fldData scaffolding) are compared. Field results are ordinary text either way. On, a code change is one reversible whole-paragraph w:del/w:ins pair carrying w:delInstrText.
  • Tables — governs table content/structure (rows, cells, per-cell text). A whole table added, removed or moved is a block-level operation and is still reported with the box off; a shell-only change (column widths) routes to FormatOnlyBlock so table formatting stays on its own axis (TrackBlockFormatChanges).
  • Show changes atCharacter narrows a change to the differing characters (colourcolor renders retained colo + deleted u + retained r) in both the markup and the revision list. Alignment stays word-grained; the edit script is identical under both values.

Fixed

  • A changed shape or textbox no longer produces a schema-invalid document. A changed drawing renders as a deleted copy plus an inserted copy, both carrying the source's ids, so two revisions of one document produced duplicates (Sem_UniqueAttributeValue) across wp:docPr/@id, the VML shape-id space, v:shapetype/@id and o:spid. A new NormalizeDrawingIds pass re-ids the non-surviving copies, choosing the keeper by revision side so the id lands on the copy that survives accept.
  • A change inside a textbox is now tracked inside the box. Previously the change was detected but not rendered, so two documents differing only in one textbox's text compared as unchanged. The renderer now emits per-box interior revisions, falling back to the conservative whole-block replacement when a box cannot be sliced.

Round-trip contract

accept ≡ right and reject ≡ left hold for every option at its default. Where an option is turned off, the uncompared difference is by definition not reversible — one side's content rides through untracked, and which side depends on the scope (body renders from the right; a header/footer story or note definition keeps its left part). This is stated per option in the XML docs and in docs/architecture/ir_diff_engine.md.

Commits

The series has real internal dependencies and is ordered accordingly:

  1. feat(comparer) White space (CompareWhitespace) — introduces the shared canonicalizer and the parity table
  2. feat(docxdiff) White space on the DocxDiff engine
  3. feat(docxdiff) Textboxes (CompareTextboxes)
  4. fix(docxdiff) unique drawing ids in compare output
  5. feat(docxdiff) track a textbox change inside the box — builds on 4
  6. feat(docxdiff) Fields (CompareFields)
  7. feat(docxdiff) Show changes at (ChangeGranularity) — builds on 4
  8. feat(docxdiff) Tables (CompareTables)

Verification

  • Full suite: 3475 passed. The 7 failures are all pre-existing on main at 44dac09 (DS310DS312, MD018, MD030, IrCorpusTests DB007-Notes.docx, MCP137) — confirmed by running them on an unmodified checkout.
  • dotnet build -c Release Docxodus.sln (warnings-as-errors): 0 errors.
  • ./scripts/build-wasm.sh: exit 0. npx tsc --noEmit in npm/: clean.
  • Each setting is covered by a dedicated test class, and each was mutation-tested — the gate was inverted/removed and the suite confirmed to fail on exactly the intended tests. Where a mutant survived, a discriminating fixture was added.
  • Client surfaces updated in step: DocxDiffOps wire keys, npm/src/types.ts, python/src/docx_scalpel/.

rey-ms added 9 commits August 3, 2026 16:29
WmlComparer had no whitespace toggle, so re-spaced text always produced
revisions. Both inputs are canonicalized before comparison rather than making
the comparison whitespace-blind, because FlattenToComparisonUnitAtomList zips an
Equal sequence's two atom streams and a length mismatch there misaligns the
reassembled content. WASM/npm ripple deliberately deferred; DocxDiff has no
equivalent toggle yet.
DocxDiff is the default engine since v8, so the WmlComparer-only flag was
invisible on the CLI/WASM/npm default paths. Both engines now share one
input canonicalizer, hooked into DocxDiff.PreAccept so every entry point and
every consolidate reviewer folds alike. A token-level fold was rejected:
IrTokenDiffer's edit stream is 1:1 per token, so a length-asymmetric Equal
would ripple through every op consumer.
A granularity switch: off, a changed textbox is del/ins'd wholesale by its
host paragraph instead of inner-diffed, so the change stays in the markup and
the round trip holds. A footnotes/endnotes twin was implemented and reverted —
note references are inline body content, so gating the note-store diff dangles
a right-added note's reference and emits an invalid package.
A changed drawing renders as del+ins copies carrying the source ids, so two
revisions of one document emitted duplicates and the package was schema-invalid
(wp:docPr/@id, the v:shape id space, v:shapetype/@id). NormalizeDrawingIds
re-issues the later copies per story. Which copy keeps a REFERENCED id is chosen
by revision survival, not document order: a bare shapetype reference cannot be
rebound, so the accepted document must keep the original id.
Reported from real documents: two files differing only in one textbox text
compared to an output that looked undetected. It was detected — the edit script
carried textboxDiffs — but the markup renderer bailed out of fine rendering for
textbox-bearing paragraphs, del/ins-ing the whole drawing, which for an anchored
box stacks two boxes at one position. It now rebuilds each emitted txbxContent
from the nested ops, matching Word.
The engine already compared field codes; this adds the off switch, gating
FieldEnvelopeDigest in StructuralCarrierDiffers. Off, an uncompared code
follows the scope's output source - right in the body, left in a
header/footer story or note - so only one round-trip direction is exact.
Character level narrows a change to the differing characters instead of
marking the whole word, as a refinement post-pass over both rendered
surfaces - alignment stays word-grained, and a character shared by the
del and ins texts survives accept and reject wherever it sits, so the
round trip is untouched. Consolidate stays word level.
Gates IrTableDiffer at its single call site. Off, a content-differing
table pair emits EqualBlock flagged Uncompared - the flag matters because
the numbering-evidence zip and the edit-script verifier both assume an
EqualBlock's sides correspond. Shell-only pairs route to FormatOnlyBlock
so table formatting stays on its own axis; moves and add/remove survive.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant