Render-fidelity round: package tolerance, section defaults, style provenance, and Word-Compare input-revision policy - #644
Merged
Conversation
Word tolerates a package whose relationship targets a part missing from the ZIP (an orphaned docProps/thumbnail.jpeg reference in _rels/.rels is the wild-caught shape) and silently drops the reference on save. The Open XML SDK instead throws from its eager part-tree load the first time the part graph is touched, so comparing such a document failed outright — and came back as an empty result through the WASM bridge, which swallows engine exceptions into a zero-length array. OpenXmlMemoryStreamDocument.GetWordprocessingDocument now removes dangling internal relationships at both the package and part level before handing the package to the SDK, mirroring Word's repair. Relationships whose target URI cannot even be resolved are left untouched so stranger breakage keeps its existing diagnostics.
…dentity shortcut Strict inputs are already normalized to transitional before any diff (Word converts on open), but the identical-package shortcut returned the input bytes untouched — a strict package that LibreOffice renders poorly and python-docx refuses to open, so a no-difference compare of two strict documents produced an output much of the ecosystem could not consume. Word emits a transitional package there regardless of the compare outcome. Both shortcut sites (the engine selector and the comparison's redline build) now normalize a strict left on the way out; byte-identical transitional inputs still return an exact detached clone with no reserialization. The strict self-compare pin is re-pinned from byte-preservation to detached-transitional accordingly.
…efault section, and neutralize docDefaults the revised side leaves at built-ins Two renderer-fidelity gaps with the same root: the output package is built on the LEFT document, so anything the left carries implicitly keeps ruling the accepted view unless the renderer states the right's effective value explicitly. Trailing sectPr: a body with no trailing section properties reads as the default section (Word's rule). Previously a right-only sectPr was dropped entirely — a two-column revision rendered single-column — and a left-only sectPr survived accept. A right-only sectPr is now adopted (properties only; header/footer references belong to the header/footer machinery) under a w:sectPrChange archiving the default section, and a left-only sectPr is replaced by the default section with the left's properties archived. The differ test folds defaults into both sides so a right that merely spells the defaults out is not stamped as a change. docDefaults: the output keeps the left package's docDefaults, and the updated shared style materialized the right's effective formatting only for properties the right declared. A property the left's docDefaults declare and the right leaves at its built-in default — paragraph spacing, kerning, ligatures — leaked through the retained part: a left template with spacing after=160/line=278 rendered every right-sourced paragraph double-spaced. The style's current payload now materializes the built-in default for exactly those properties (w:spacing 0/240/auto attribute-wise, w:kern 0, w14:ligatures none), which is what Word writes there.
…r — pre-accept, not preserve Word's Compare treats tracked changes already present in either input as accepted (its own dialog says so) and re-detects the delta under the compare author: text an input had struck through is absent from the result entirely, and the output collapses to a single revision author. The selector profile instead carried the inputs' markup through under the original authors — Word's Combine behavior, decoded from a batch of oracle documents that turned out to be Combine-shaped. Compare is the operation this surface models, so the pre-accept flatten now runs and preservation stays an explicit opt-in on the raw DocxDiff API (DocxDiffSettings.PreserveInputRevisions).
…tions When the original package has no styles part at all, the compare output carried no style definitions either — inserted content that styles itself through pStyle (a ListParagraph bullet list, header/footer stories) lost its formatting, and the whole document's line metrics drifted off the revised side's. Word's output for this shape carries every style definition from the revised document unchanged while its docDefaults remain the stock backfill, never the revised side's; with no left definitions there is nothing to collide with or preserve, so the wholesale copy is the safest of the style-provenance shapes. Also corrects the diff-engine architecture doc where it attributed input-revision preservation to Word's Compare: the outputs it was verified against were Combine-shaped, and the engine selector now models Compare (see the preceding commit).
…ed style's payload The mirror of the built-in neutralizer: the output package retains the LEFT docDefaults, so paragraph properties the revised document declares in ITS docDefaults (and the original does not) must be stated on the updated shared style's current payload, or the accepted view keeps rendering with the original's absent value — a revised template with spacing after=160/line=278 rendered single-spaced. A key the style's own raw payload already carries wins untouched; identically-declared keys on both sides need nothing. Word writes exactly this materialization.
…und9 # Conflicts: # CHANGELOG.md
…ides Effective-formatting resolution merged rFonts attribute-wise across style layers, so materializing ascii="Times New Roman" from the revised side could leave a lower layer's asciiTheme riding along — and in OOXML a theme attribute outranks the literal in the same slot, so the output still rendered the theme font. Substituted (the theme font is rarely installed where the output renders), that put body text in a different-metric family and repaginated whole documents: the header/footer fixture family rendered 20 pages against Word's 15 from this alone. Each rFonts slot now overrides as a pair — a layer declaring the concrete attribute clears the theme attribute from the accumulation, and vice versa — which is exactly the concrete-only payload Word writes. Also records the measured verdict on the equal-definitions projection gates: removing them projected deltas onto dozens of documents whose oracles carry an EMPTY updated style, so the gates encode observed Word behavior, not caution, and the comment now says so.
A font only the revised document's fontTable declares (a Word alias like "Times New Roman (Body CS)" matches no installed face) was missing from the left-based output's table, leaving LibreOffice no substitution hint for revised-sourced content naming it — it fell back to a different-metric family. Word's compare output carries the union, so right-only declarations are now appended verbatim (minus embedded-font relationship children, whose r:ids belong to the right package). Existing output declarations always win. Also pins that a format-equal blank between paired content keeps its anchor (guard for the blank-anchoring laws).
JSv4
pushed a commit
that referenced
this pull request
Sep 1, 2026
Main's #644 changed the front door's input-revision policy in DocxCompare.cs, the same file this branch rewrites, so the collision is semantic rather than textual and the resolution is a decision, not a merge artifact. This branch preserved PreAcceptInputRevisions + PreserveInputRevisions on the front door, faithfully reproducing what the pre-v11 settings mapping did. #644 established that preserving is wrong there: Word's Compare dialog says it treats existing revisions as accepted, and the preserve behavior had been decoded from oracle documents that turned out to be Combine-shaped. That is a correctness fix and this branch was only carrying old behavior forward, so main's policy wins and this branch's structure carries it: ApplyFrontDoorRevisionPolicy now sets PreAccept alone, keeping the clone so a caller's settings object is untouched. Also taken from main: the identity shortcut normalizes a strict left to transitional on the way out. That coexists with this branch's removal of the math-run guard from CanReturnExactNoOp -- both touch the shortcut, but the guard existed only because WmlComparer's preprocessing repaired that markup as a side effect, and no engine does so now (issue #642). Tests follow the same rule: main's assertions against this branch's API. The strict self-compare pin moves from byte-preservation to detached-transitional and loses its engine Theory. One conflict git did not report: DocxDiffStrictConformanceTests.cs merged cleanly because this branch never touched it, but #644's new selector test calls the removed ComparisonEngine and WmlComparerSettings -- two compile errors the build caught. Rewritten against the front door rather than dropped, since the strict identity assertion is worth keeping. Baselines measured on the merged tree rather than picking a side: library 133 warnings, test project 716 (was 713 here and 782 on main; #644 adds test files). The CHANGELOG entry claiming the front door still preserves is corrected.
JSv4
pushed a commit
that referenced
this pull request
Sep 2, 2026
#643 removes the WmlComparer engine entirely — 81 files, ~20k lines out — and #644 is a render-fidelity round touching the converter. Both land on paths this demo exercises, so neither was taken on trust. Every engine call the demo makes still exists after the removal (docxDiffGetRevisions, docxDiffCompareProducts, convertDocxToHtml, proveRedlineReversibility, openDocxSession); the two exports that went, compareDocumentsWithLog and compareDocumentsToHtmlWithLog, are ones it never used. Nothing in docs/demo/ or the spec named WmlComparer. Rebuilt and re-ran: 14/14 browser assertions pass on the WmlComparer-free engine, including the reversibility proof and the footnote citation check. Re-measured, because #643 and #644 both touch measured paths, and the answer is that nothing moved: controlled medians came back revisions 105 -> 101, redline 140 -> 134, full+HTML 282 -> 278, compareProducts 126 -> 125, and the conversion stage in isolation 143 -> 144 ms. All inside the container's own spread. The published table is therefore left alone rather than churned by a few percent in either direction — the same restraint the README's attribution list asks for. CHANGELOG conflicted again on both sides adding under [Unreleased]; resolved keeping both, library entries first.
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.
Comparing real-world documents surfaced a cluster of fidelity gaps against what Word's own
Compare produces — outright failures on Word-tolerated broken packages, dropped page setup,
leaked template defaults, and an input-revision policy that turned out to model Word's
Combine rather than its Compare. Each fix below was decoded from Word's observed output
for the shape in question, implemented test-first with a synthetic reproducer, and gated on
the full suite (4,247 passing).
Word-tolerated broken packages no longer abort the compare
A package can declare a relationship whose target part is missing from the ZIP (an orphaned
docProps/thumbnail.jpegreference is the wild-caught case). Word opens such a document andsilently drops the dangling reference on save; the Open XML SDK instead throws from its eager
part-tree load, so the compare failed outright — and surfaced as an empty result through the
WASM bridge, which swallows engine exceptions.
OpenXmlMemoryStreamDocumentnow removesdangling internal relationships (package- and part-level) before handing the package to the
SDK. Relationships whose target URI cannot even be resolved are left alone so stranger
breakage keeps its existing diagnostics.
A one-sided trailing
w:sectPrdiffs against the default sectionA body with no trailing section properties reads as the default section — Word's rule.
Previously, when only the revised side had one, its page setup was dropped entirely (a
two-column revision rendered single-column); when only the original had one, the original's
page setup survived accept. Now a right-only sectPr is adopted (properties only —
header/footer references belong to the header/footer machinery) under a
w:sectPrChangearchiving the default section, and a left-only sectPr is replaced by the default section with
the left's properties archived. Accept reproduces the revised page setup; reject restores the
original's.
Identical Strict OOXML inputs return a transitional package
Strict inputs were already normalized to transitional before any diff (Word converts on
open), but the identical-package shortcut returned the input bytes untouched — a strict
package that LibreOffice renders poorly and
python-docxrefuses to open. Both shortcutsites now normalize a strict left on the way out; byte-identical transitional inputs still
return an exact detached clone.
Document-default deltas are stated on the updated style, both ways
The compare output keeps the original package's
docDefaults, so an updated shared stylemust state the revised side's effective formatting explicitly. That held only for properties
the revised side declared. Two mirror-image leaks are closed:
default (paragraph spacing, kerning, ligatures) kept ruling the output through the retained
part — an original template with
spacing after=160 line=278rendered every revisedparagraph double-spaced. The style's current payload now materializes the built-in default
for exactly those properties (
w:spacing0/240/auto attribute-wise,w:kern0,w14:ligaturesnone), which is what Word writes there.lacks is now materialized into the style's current payload — a revised template with
declared spacing rendered single-spaced before.
A style-less original adopts the revised document's style definitions
When the original has no styles part at all, the output carried no style definitions either,
so inserted content that styles itself through
pStyle(bullet lists, header/footer stories)lost its formatting. Word's output for this shape carries every style definition from the
revised document unchanged while document defaults remain the stock backfill; with no
original definitions there is nothing to collide with or preserve, so the wholesale copy is
adopted.
The engine selector models Word's Compare input-revision policy
Word's Compare treats tracked changes already present in either input as accepted — its own
dialog says so — and its outputs confirm it: text an input had struck through is absent from
the result entirely, the surviving delta is re-detected under the compare author, and the
output carries a single revision author. The selector profile (
DocxCompare.Compare, andwith it
compareDocumentson npm, the redline CLI, and the MCP compare tools) insteadcarried the inputs' own markup through under the original authors — Word's Combine
behavior; the oracle outputs the old behavior was verified against turned out to be
Combine-shaped. The pre-accept flatten now runs on the selector path, and preservation stays
an explicit opt-in on the raw
DocxDiffAPI (DocxDiffSettings.PreserveInputRevisions).Callers passing revision-free documents see no change. The diff-engine architecture doc is
corrected accordingly.
A materialized concrete font clears the theme reference it overrides
Effective-formatting resolution merged
rFontsattribute-wise across style layers, somaterializing
ascii="Times New Roman"from the revised side could leave a lower layer'sasciiThemeriding along — and in OOXML a theme attribute outranks the literal in the sameslot, so the output still rendered the theme font. Substituted (the theme font is rarely
installed where the output renders), that put body text in a different-metric family and
repaginated whole documents: one running-header fixture family rendered 20 pages against
Word's 15 from this alone. Each
rFontsslot now overrides as a pair — a layer declaring theconcrete attribute clears the theme attribute from the accumulation, and vice versa — which
is the concrete-only payload Word writes.
An experiment that instead removed the document-defaults projection's consumer gates was
measured and reverted: it projected deltas onto dozens of documents whose Word outputs carry
an EMPTY updated style, so those gates encode observed Word behavior, and the code comment
now says so.
The output fontTable is the union of both inputs' declarations
A font only the revised document's fontTable declares (a Word alias like
"Times New Roman (Body CS)" matches no installed face) was missing from the left-based
output's table, leaving LibreOffice no substitution hint for revised-sourced content naming
it — it fell back to a different-metric family. Word's compare output carries the union, so
right-only declarations are now appended verbatim, minus embedded-font relationship children
whose ids belong to the other package.
Validation
the code change, plus two deliberate re-pins where the old behavior was itself the bug
(strict self-compare byte-preservation; the selector's preserve pin).
every case: the two-column adoption, the spacing materializations, and the style adoption
each reproduce Word's own compare output for the same inputs where they previously
diverged; accept/reject round-trips hold at the property level.