Skip to content

feat(diff): block-format-change family — native w:pPrChange/w:tcPrChange/w:trPrChange/w:tblPrChange/w:tblGridChange/w:sectPrChange#252

Merged
JSv4 merged 6 commits into
mainfrom
feat/diff-block-format-family
Jul 4, 2026
Merged

feat(diff): block-format-change family — native w:pPrChange/w:tcPrChange/w:trPrChange/w:tblPrChange/w:tblGridChange/w:sectPrChange#252
JSv4 merged 6 commits into
mainfrom
feat/diff-block-format-family

Conversation

@JSv4

@JSv4 JSv4 commented Jul 4, 2026

Copy link
Copy Markdown
Owner

What & why

Closes the last "Word compares Formatting, we don't" scope gap in DocxDiff (the IR diff engine). Word's Compare tracks paragraph-and-above property changes; DocxDiff tracked only run-level format changes (w:rPrChange). A change to a paragraph's, table's, cell's, row's, or section's properties (with identical text) was either invisible (silently classified unchanged) or applied untracked — the right-side properties won with no revision, so reject did not restore the left.

Now DocxDiff produces the native markup Word itself renders, gated by the existing FormatComparison policy, with the round-trip contract (accept ≡ right, reject ≡ left) extended to the property-byte level:

Scope Markup
Paragraph w:pPrChange (+ w:pPr/w:rPr/w:rPrChange for a changed paragraph mark)
Table cell w:tcPrChange
Table row w:trPrChange
Table w:tblPrChange + w:tblGridChange
Section (trailing) w:sectPrChange

This makes the cell-shell (w:tcPr) edits that became visible in #250 actually tracked — reject now restores the shell bytes, not just the text.

How

  • Detection at block pairing (no new op payload — the w:rPrChange architecture): IrModeledFormat.BlockSignature gains a paragraph key (direct w:numPr numId/ilvl now modeled); the lumped table-shell digest is split into per-element TblPrDigest/TblGridDigest/TrPrDigest. An internal IrDiffSettings.TrackBlockFormatChanges (default on) governs it.
  • Rendering at the sites that previously cloned right-side properties verbatim; the emitted *PrChange carries the OLD (left) properties (CT_*Base inners).
  • Revisions: DocxDiffRevision.FormatChange.Scope — new DocxDiffFormatChangeScope (Run default + Paragraph/TableCell/TableRow/Table/Section). WmlComparerCompatible excludes non-Run scopes by construction (the oracle produces none — the 179-count parity scoreboard stays meaningful). Additive scope on the revisions wire (Ops/npm/python); edit-script JSON unchanged.
  • Consume-side fix (rode along): RevisionProcessor no longer drops the section's header/footer references (rejecting w:sectPrChange) or an inline w:sectPr (rejecting w:pPrChange) — those live outside the tracked change (CT_SectPrBase / CT_PPrBase). See docs/ooxml_corner_cases.md.

v1 ceilings (documented + pinned)

  • Consolidate does not track block-format changesIrCompositeMerger (and the composite markup/revision renderers) force TrackBlockFormatChanges off; a reviewer's pPr/shell/section-only edit is ignored (explicit, nothing silently dropped); text+pPr edits keep routing to the conflict path.
  • w:tblPrEx (row-level exceptions) and mid-document w:sectPr are untracked-but-visible; split/merge/note/header-footer paragraphs don't emit w:pPrChange.

Verification

  • Full .NET suite green (2585+ passed / 0 failed / 3 skipped); Release build clean (warnings-as-errors); WASM-mode core compiles clean.
  • All ratchets hold: IrParityScoreboardTests (179), IrMarkupParityScoreboardTests (39), ConsolidateParityScoreboardTests (84), IrVsWmlComparerTests differential.
  • New: BlockFormatChangeTests (per-member detection/markup/revision/round-trip, the Consolidate pins, and the 3 code-review-finding regressions), BlockFormatChangeRealDocTests (a real corpus doc mutated across all five family members → all markers present, schema-valid, round-tripping, deterministic, with a headless-LibreOffice load backstop). IrMarkupRendererTests round-trip battery strengthened with per-table shell digests + a reference-normalized trailing-sectPr property digest.
  • An adversarial code-reviewer pass surfaced 3 revision-surface findings (Consolidate revision leak, a trPr-vs-tblPrEx schema-order edge, revision/markup shell-digest consistency) — all fixed with regression tests in the final commit.

Follow-ups (out of scope)

Consolidate block-format merge; w:tblPrExChange; mid-document w:sectPrChange; extending w:pPrChange to split/merge/note/header-footer scopes; WASM/npm/python surfacing of TrackBlockFormatChanges (internal-only in v1).

JSv4 added 6 commits July 3, 2026 17:27
…Pr/trPr/tblPr/tblGrid/tcPr/sectPr untracked today)
Detect paragraph-and-above property deltas at block pairing (ParaKey folded
into IrModeledFormat.BlockSignature under a new internal TrackBlockFormatChanges
flag; direct numId/ilvl now modeled in IrParaFormat). Render native w:pPrChange
(and w:pPr/w:rPr/w:rPrChange for a changed paragraph mark) in the FormatOnly,
Modify, and move-destination paths, carrying the OLD (left) pPr so accept ≡ right
and reject ≡ left hold at the pPr level. Surface a Paragraph-scoped
DocxDiffFormatChange (new DocxDiffFormatChangeScope; WmlComparerCompatible excludes
non-Run scopes by construction). Consolidate forced off (v1 ceiling, pinned).

Also fixes a latent consume-side bug: RejectRevisions dropped an inline w:sectPr
when rebuilding pPr from a w:pPrChange (section break vanished on reject).
…markup

Phase 2 (table family): split the lumped table-shell digest into per-element
TblPrDigest/TblGridDigest (IrTable) + TrPrDigest (IrRow), flattened so empty≡absent.
Render native w:tblPrChange/w:tblGridChange/w:trPrChange (FormatOnly + Modified
table paths) and w:tcPrChange (Modified cells) carrying the OLD shell; reject
restores shell bytes. Report Table/TableRow/TableCell-scope FormatChanged revisions
(digest-grade). This makes the #250-noted cell-shell edits tracked, not just visible.

Phase 3 (section): stamp native w:sectPrChange on the trailing body sectPr when its
PROPERTIES differ (page size/margins/orientation/type/columns), applying the right
properties and preserving the left in the marker; references (header/footer machinery)
untouched. Section-scope FormatChanged revision (modeled fields). Fixes a latent
RevisionProcessor bug: rejecting a sectPrChange dropped header/footer references
(CT_SectPrBase has none) — now the references are preserved across reject.

AssertRoundTrip strengthened with per-table shell digests + a reference-normalized
trailing-sectPr property digest, so the whole renderer battery verifies shell/section
round-trip. Consolidate stays forced-off (v1 ceiling). Golden IR snapshots regenerated
(digest layout only; ContentHash unchanged).
… round-trip, determinism, LibreOffice load backstop
…Ops/npm/python), ir_diff_engine + format_change_detection + ooxml_corner_cases + CLAUDE + CHANGELOG
…PrEx schema order, revision/markup shell-digest consistency

- Gate the table-shell REVISION emitters on TrackBlockFormatChanges so the Consolidate v1
  ceiling holds on GetConsolidatedRevisions too (a table-shell-only reviewer edit was
  reporting a Table/TableRow/TableCell FormatChanged the composite markup never backed).
- InsertShellInSchemaOrder: a freshly-created w:trPr now lands AFTER an existing w:tblPrEx
  (CT_Row orders tblPrEx before trPr) — the AddFirst placement produced schema-invalid rows.
- Add flattened per-element digests IrRow.TrPrShellDigest (w:trPr children only, no tblPrEx)
  and IrCell.TcPrShellDigest (empty≡absent), and drive the revision surface + round-trip
  harness from them, so GetRevisions and Compare's markup agree: a w:tblPrEx-only or
  empty-vs-absent-shell change is untracked in BOTH (was over-reported by the revision only).
  TrPrDigest (full row shell) still drives the fingerprint, so tblPrEx changes stay visible.

Reviewer: pr-review-toolkit:code-reviewer (findings 1/2/3).
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