DocxDiff: Word-parity arrangement of unrelated regions, surplus table cells, imported paired styles, attribute-wise default spacing - #693
Merged
Conversation
…chain pairs empty marks only An empty paragraph has no words, so nothing but the matched content around it can make its paragraph mark "the same paragraph" on both sides. The block aligner nevertheless pinned a unique blank on the exact-match spine (and paired further blanks first-fit in the gap pass), which split what Word treats as one replace region into two halves: a base that ends with an empty paragraph against a next that begins with one came out as all deletions, a live empty line, then all insertions, where Word emits insertions, deletions, then the shared final paragraph mark. A blank pairing now survives only when a neighbouring block pair on both sides is paired in place — a run of blanks borrows its support from the content at either end — and the document-final pair is supported structurally, because Word always pairs the two final marks. The rule runs twice: after the spine, so the gap fill sees the whole region, and after the gap fill, so a blank beside a paragraph the gap fill paired as edited keeps its retained mark. Two trailing-region arrangement rules follow from the same evidence. The backward paragraph-mark chain continues from the final pair only through paragraphs that are empty on both sides, the final pair included; it used to open on any empty/empty candidate and continue while either side was empty, which turned the next document's last wordful paragraph into a shared mark. And a base story ending with a table against a next story ending with an empty paragraph now places that final mark after the deleted table, as the wordful case already did, instead of leaving the document ending with the deleted table. Three synthetic arrangement pins encoded the old blank anchoring and are re-pinned to the single-region shapes; new pins cover an unrelated-content unique blank, a blank beside edited neighbours, and the table-then-blank tail. Accept ≡ right and reject ≡ left hold for every shape.
…deleted cell When two rows pair but the base row has more cells than the next row — a column the next document no longer has, or two unrelated tables paired positionally — the modified-table renderer bailed on the left-only cell and the whole table fell back to a deleted table followed by an inserted one, doubling it on the page. The right-only case (w:cellIns on the accepted grid) was already handled. The surplus base cell is now emitted in its place, whole-marked deleted: w:tcPr/w:cellDel plus struck content, exactly the mark the composite renderer already uses. Accept removes the cell (= next), reject restores it (= base). Word's compare output keeps such a cell in the merged row with its content deleted, so the page shape now follows it. The bail-out that remains is the one that protects reversibility: with table format tracking off, a one-sided cell still lowers to the whole-table pair, because the cloned grid could not be restored on reject.
…content imports Word expresses a paired paragraph's format change within the style universe the output resolves. A right-only paragraph style is brought into the result only by wholly inserted paragraphs — but once one of them has imported it, a retained or edited paragraph naming the same style keeps its w:pStyle too; there is nothing to lower to direct properties. Across the reference outputs, every paired paragraph whose right style an inserted paragraph also uses keeps the reference (17 of 17), and paired paragraphs whose right style nothing inserted uses drop it (5 of 6). The renderer tested only the left document's style definitions, so a whole- document rewrite whose final paragraph pair adopted the right's heading or list style, or an edited title paragraph in a document whose inserted body uses the same title style, rendered with the default paragraph style while the definition it needed sat, imported, in the output styles part. The inserted-style set is collected once from the edit script — inserted blocks, inserted rows of paired tables, and inserted blocks inside paired cells — and both the stamped-pPr drop and the pPrChange comparison consult it alongside the left registry. The existing pin kept its shape (nothing inserted names the style, so it is dropped) and a sibling pin covers the imported case, where both shared-pilcrow paragraphs keep it.
…es from a shared blank Refines the blank-support rule from the previous commit with three findings from Word's compare output. A blank that follows an edited or retained pair now pairs with the blank across from it even when the next document restyled it — a retained mark carrying a w:pPrChange — where the gap passes (which must not let blanks claim slots ahead of content) had left a deleted blank followed by an inserted one. Trailing blanks of a region take support from the pair after them only when the region's paragraph counts balance: across the reference corpus, interior regions ending in a blank on both sides share that blank in 300 of 301 equal-count cases and in 21 of 24 cases where one side is nothing but the blank, and keep the marks on their own sides in 7 of 10 other unequal cases. The renderer's backward paragraph-mark chain applies the same gate to interior regions, and a region the aligner closed with such a shared blank continues the chain from it (the next side's last wordful paragraph fuses at the head, as Word writes it). A wordful member in the story-final structural pair still blocks the chain; the original empty-opening rule otherwise stands, since interior regions with equal counts do chain through a wordful member. Both blank passes now run after crossing normalization: a same-identity blank pair that crossed a table pair was lending support it could not keep, which retained a blank Word marks. Pins: a restyled blank after an edited paragraph keeps its mark with a pPrChange; equal-count and unequal-count interior regions; a one-sided blank region; and the earlier tail-chain pin returns to the fused shape.
…ribute The output keeps the left document's styles part, so an updated style's current payload has to cancel any docDefaults value the right document does not share. That check was per element: a right whose defaults declared only line=276 against a left declaring after=160 line=278 produced a Normal style saying just line=276, and every accepted paragraph inherited the left's 160 twips of space below — the accepted view of a web-authored document came out double-spaced against the reference output. Spacing attributes inherit one by one, and Word's compare output treats them that way: the updated Normal there reads after=0 line=276. The neutralizer now resets each left-declared attribute the right's effective spacing lacks to its built-in on the same spacing element, and the right-defaults materialization merges its attributes into a spacing element the neutralizer already created instead of skipping it. Pinned with a right whose defaults declare only line/lineRule.
The 1:N split/merge containment scan ran the LCS scorer on every adjacent window whose content-token total fell inside the coverage/slack bounds. With short singular paragraphs those bounds admit almost everything, and once the empty-paragraph anchors that used to fragment a whole-document rewrite were released, one 50-by-1100 region (a small template against a 120-page document) scored hundreds of thousands of windows: 49 seconds where the comparison had taken 1.3. TrimAndGate's first gate needs at least two window members that each carry two or more LCS-matched words, and a member can never match more words than its Word-key multiset shares with the singular paragraph. Both facts give exact necessary conditions that cost a cached multiset intersection: a singular with fewer than four words never splits or merges, and a window with fewer than two members sharing two words with it never qualifies. Neither can change a result; the same pair now aligns in 3 seconds and the full-corpus alignment outputs are byte-for-byte the same sequences.
A clean --no-incremental build of Docxodus.Tests on main reports 707 warnings, not the 694 recorded here; the library's 113 is unchanged. Measured on the merge-base of this branch and on the branch itself (no movement).
…le, not a count gate The previous commit gated an interior region's trailing blank chain on equal paragraph counts. That fit the reference corpus only because the miner had counted a fused paragraph on both sides; with correct accounting Word chains a 3-vs-2 region ([title, blank, blank] against [title', blank] before a shared table: title' fuses into the deleted title and both blanks share their marks) and keeps every mark in a 3-vs-3 one. The rule Word's output actually follows — in 331 of the 334 interior regions that end with a blank on both sides — is about what each pair joins: the chain opens on the trailing blank pair and walks backwards while the BASE member is empty (a wordful next member is fine; its runs fuse into the first deleted paragraph), a wordful base member facing an empty next member cancels the whole chain, a wordful-against-wordful stop keeps it only when the paragraphs left before it balance, and a fused next member needs a deleted paragraph to host its runs. The story-end grammar is unchanged. With that rule in the renderer, the aligner no longer needs to take trailing blank pairs on the region's behalf: next-side support is limited to blank runs that reach straight back to the preceding pair on both sides (identical blank runs, which must stay Unchanged), and the seeded-chain hand-off is gone.
…lts declare The attribute-wise neutralizer decided "left-only" from the right style's effective properties, which by design exclude the right document's docDefaults. So a right whose defaults said line=278 against a left saying line=276 had every updated heading's line reset to the built-in 240 instead of materialized at 278, and a heading-heavy document lost most of a page of alignment against the reference output. An attribute the right's docDefaults declare is now excluded from neutralization; the materialization step already carries it over with the right's value when the left values it differently. Pinned with a style that owns before/after while the right's defaults change line.
…t defaults A paragraph style that exists only in the right document is copied into the output styles part, where it lives under the LEFT docDefaults from then on. It was copied raw, so every paragraph in it inherited the left's default spacing: a long inserted document whose own defaults said line=259 after=160 rendered a page longer than Word's output under the left's line=276 after=200, and the drift compounded across a dozen pairs that share that document. Word writes the imported style with its docDefaults delta stated: right defaults the left values differently are materialized (line=259), left defaults the right never declared are reset to their built-ins, and the run side carries the resolved right formatting — exactly what an updated shared style already gets here. The import branch now runs the same two steps, and writes the spacing and indent measures in twips as Word does (the source said line="12.95pt", which a renderer cannot read as an auto line count; 259 it can). Point-unit measures and theme-color fallbacks were also tested and make no rendering difference, so they are left alone. Pinned with a right-only heading imported under differing defaults.
JSv4
marked this pull request as draft
September 3, 2026 11:08
… chain The previous commit stated the delta on every imported right-only paragraph style. Word states it once per chain: when the default paragraph style was itself updated, its payload carries the delta and an import based on it is written raw; only under an untouched Normal does the import restate the values. Restating them under an updated Normal double-applied nothing but did override the chain in the cases where the import's own payload had already narrowed a value (a table body style at after=60 gained a before=0 Word never writes). The import pass now runs after every style's payload is final, parents first, and only adds an attribute that no ancestor in the output chain states. Pinned with an import under an updated Normal (stays raw) beside the existing pin under an untouched Normal (carries the delta).
JSv4
marked this pull request as ready for review
September 4, 2026 00:20
JSv4
added a commit
that referenced
this pull request
Sep 4, 2026
Major, because CommentListEntry gained a `required` init property: the record is positional, so `new CommentListEntry(...)` no longer compiles without Id. v12.0.0 is tagged at 9be2dc0, the last commit whose Playwright run was green, so the six DocxDiff arrangement entries that came in with #693 stay under [Unreleased] — that change is not in this release. It hangs the browser build: comparing WC007-Unmodified against WC007-Moved-into-Table never returns in WASM, while the same comparison takes ~25 ms natively with byte-identical output. Tracked separately. The accumulated entries had also grown a duplicate `### Changed` and `### Fixed` pair from parallel PRs each appending its own heading to [Unreleased]. They are merged into one section of each kind here; no entry text changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SxehJkp2547AyP8LSd6uFS
This was referenced Sep 4, 2026
JSv4
pushed a commit
that referenced
this pull request
Sep 4, 2026
The release cut is the reason this merge needed hands. main emptied [Unreleased] into a 12.0.0 section, and git's auto-merge put this demo's entry back at the position that text now occupies -- inside the released section, where it would have claimed the demo shipped in a version it is not in. Moved it back under [Unreleased], and dropped the Office Math entry that came with it, because that one did ship in 12.0.0 and would otherwise appear twice. Re-pinned docs/demo/redline.html from 11.0.0 to 12.0.0. The release re-pins every sibling demo page but not this one, since this one is not on main yet; what is new is that #654's engine-pin guard globs docs/demo/*.html and requires them all to name one version, so a stale pin here is now a failing check rather than a quiet inconsistency. Confirmed jsDelivr actually serves 12.0.0 before moving it. The README conflict was the same event seen from the other side: seven pages (this branch adds one) at the new pin. #697 fixes the comparison timeout that made the last CI run red. That failure was #693's, bisected and filed as #695; the fix warms the WASM engine before the first real comparison rather than touching the aligner, so my guess at the cause in that issue was wrong even though the bisect was right. Verified here: the test that timed out at 60 seconds now passes. Build and pretest clean, 61 node checks, 14 browser assertions, and the engine-pin guard passing on all seven pages. Not re-measured: #698 changes token-stream arrangement for regions with no block correspondence, which the demo's 3 KB document does not exercise.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Comparing two documents that share little text — a template against a filled-in copy, two fixtures from the same suite, a memo against a contract — is where our redlines diverged most from Word's compare output. The divergence was almost entirely about arrangement, not content: which paragraph marks the two documents share, and therefore where the inserted and deleted blocks land on the page. Six changes here each take one decoded rule from Word's output and put it at the architectural owner of that decision.
What changed
Empty paragraphs no longer anchor unrelated regions (
IrBlockAligner). A blank has no words, so the only thing that can make its paragraph mark "the same paragraph" on both sides is the matched content around it. The aligner nevertheless pinned a blank that was unique on each side as an exact-match anchor, which split what Word treats as one replace region into halves with the deletions scattered between them. A blank pairing now survives only when the pair before it is in place, when it is the document-final pair, or when a run of blanks reaches straight back to the preceding pair on both sides (identical blank runs). Blanks between edited paragraphs pair even when the next document restyled them (retained mark plusw:pPrChange, as Word writes it); trailing blanks are left to the chain below.The trailing paragraph-mark chain follows Word's base-empty rule (
IrMarkupRenderer.EmitGapArranged). Inside the document, the chain opens on a trailing blank on both sides and walks backwards while the base member is empty (a wordful next member fuses its runs into the first deleted paragraph); a wordful base member facing an empty next member cancels the whole chain, a wordful-against-wordful stop keeps it only when the paragraphs left before it balance, and a fused next member needs a deleted paragraph to host its runs. At the story end, a wordful member in the structural final pair blocks any further pairing. A base story that ends with a table against a next story that ends with an empty paragraph now places that final mark after the deleted table, as the wordful case already did.A paired table row keeps a surplus base cell in place (
RenderModifyRow). Two unrelated tables paired positionally, or a row that lost a column, used to lower the whole table to a deleted table followed by an inserted one. The surplus base cell now stays where it was withw:cellDeland struck content, mirroring the right-onlyw:cellInscase; accept removes it, reject restores it.A paired paragraph keeps a right-side style that inserted content imports. The renderer tested only the left document's style definitions, so an edited title paragraph in a document whose inserted body uses the same title style rendered as Normal while the definition sat, imported, in the output styles part.
Default paragraph spacing is neutralized attribute by attribute. A right document whose defaults declared only
lineagainst a left declaringafter=160 line=278produced a Normal style saying justline=276, and every accepted paragraph inherited the left's space below. Word writesafter=0 line=276there; so do we now.Default paragraph spacing attributes the right document declares are materialized, never reset. A first cut of the neutralizer decided "left-only" from the right style's effective properties, which exclude the right's docDefaults, and reset every heading's
lineto the built-in; the right's own declared value is carried over instead.An imported right-only paragraph style is expressed under the left document's defaults. A style that exists only in the right document is copied into the output, where it then lives under the left
docDefaults, so it was rendering every paragraph in it with the left's spacing: a long inserted document whose own defaults saidline=259 after=160came out a page longer than Word's under the left'sline=276 after=200. The imported style now carries the same docDefaults delta an updated shared style gets, stated once along thebasedOnchain (an import under a Normal that was itself updated inherits it and stays raw, as Word writes it), with measures written in twips.Split/merge detection prefilters its windows. Releasing the blank anchors exposed a whole-document rewrite region (a template against a 120-page document) in which the 1:N containment scan scored hundreds of thousands of windows — 49 seconds where the comparison had taken 1.3. An exact necessary condition (two members must each share at least two words with the singular paragraph) now skips windows that cannot qualify; outputs are byte-for-byte unchanged and the pair aligns in 3 seconds.
How it was decided
Every rule is decoded from Word's own compare output rather than tuned by hand. The chain rule was checked against every interior region in the reference outputs that ends with a blank on both sides: it predicts Word's choice in 331 of 334 (the three misses are pure blank runs of unequal length between paired tables). The paired-style rule holds in 17 of 17 imported cases and 5 of 6 non-imported ones. Same-slot paragraph pairing (one shared content word) and displaced pairing (three or more) were also measured and found to already agree with the aligner's decisions, so nothing there was touched.
Validation
dotnet test Docxodus.Tests: 3964 passed, 0 failed, 3 skipped, run on the final build.DocxDiffGapArrangementTests(unrelated-content blank, restyled blank beside edited neighbours, interior regions that chain and that cancel, one-sided blank region, wordful final pair blocking the chain, base-ends-with-table tail, surplus base cell) andDocxDiffSeamDisciplineTests/DocxDiffStyleProvenanceTests(imported paired style kept, non-imported dropped, attribute-wise spacing both ways). Three older arrangement pins that encoded the previous blank anchoring are re-pinned to the single-region shapes; every pin keeps accept ≡ right and reject ≡ left.dotnet test Docxodus.Tests: 3966 passed, 0 failed, 3 skipped, on the final build.123 pairs improved and 24 regressed. The largest movers are whole-rewrite comparisons whose deletions had been scattered around a spurious blank anchor, several going from the 40s to 100.
What is left
Substituting Word's own body into our package and re-rendering lifts the remaining sub-70 pairs from 57 to 88, with 25 of 35 reaching 90+. So the parts we build — styles, numbering, fonts, section geometry — are essentially right, and what remains is the arrangement of the body itself: on documents that share almost no text, Word runs one word-level diff across the whole story with paragraph marks as ordinary tokens, which interleaves insertions and deletions where we emit them in blocks. Reproducing that is a second alignment mode rather than another rule, so it is left for its own change.
Two things were measured and deliberately not done, because each turned out to be worth nothing: normalising the font table to Word's (+0.1 over 32 affected pairs) and leaving the body's final paragraph mark unmarked as Word does (0.0 over 57 pairs — a paragraph mark's revision flag does not affect layout).
Known exceptions
Three reference outputs keep both sides' marks on pure blank runs of unequal length between paired tables, where the chain rule would share one; they are single pairs against hundreds and the rule follows the majority. One pair loses ground for a reason the styles part does not explain (its updated Title style now matches Word's payload byte for byte, yet LibreOffice lays the title out differently); it is recorded for a follow-up rather than patched around.
🤖 Generated with Claude Code