Skip to content
Merged
10 changes: 9 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,10 +248,10 @@ Format change detection produces **native Word format change markup** (`w:rPrCha
- `GetRevisions(left, right, settings?)` → `IReadOnlyList<DocxDiffRevision>` — consumer revisions rendered off the edit script
- `GetEditScriptJson(left, right, settings?)` → `string` — the edit script as data (the differentiator vs `WmlComparer`)
- Accept/reject primitive (the round-trip verifier, surfaced for clients via `DocxDiffOps.AcceptRevisions`/`RejectRevisions` → WASM `DocxDiffBridge.AcceptRevisions`/`RejectRevisions` + npm `docxDiffAcceptRevisions`/`docxDiffRejectRevisions`, stdio `docx_diff_accept_revisions`/`docx_diff_reject_revisions` + docx-scalpel `docx_diff_accept_revisions`/`docx_diff_reject_revisions`): byte→byte materialize the right (accept) / left (reject) side of a redline, so a client can prove `accept(Compare(left,right))` ≡ `right` and `reject` ≡ `left` at the per-block text level — wraps `RevisionProcessor`
- N-way composite / consolidate (closes the last WmlComparer gap): `Consolidate(base, reviewers, settings?)` → `WmlDocument`, plus `GetConsolidatedRevisions`/`GetConsolidatedEditScriptJson`/`GetConflicts` — merge N `DocxDiffReviewer{Document,Author}` (each diffed against ONE shared base) into one multi-author tracked-changes document with per-reviewer attribution, token-granular sub-block merge, and a structured `DocxDiffConflict` report; `DocxDiffConsolidateSettings.ConflictResolution` = `BaseWins`(default)/`FirstReviewerWins`/`StackAll`. Round-trip: reject ≡ base, accept ≡ the policy-resolved composite. Structurally complete: note-scope (footnote/endnote) diffs merge across reviewers (compose/consensus/conflict per block, inserted notes under fresh ids, N-reviewer-aware renumbering), table column add/remove composes with native `w:cellIns`/`w:cellDel` markup, cell-shell (`w:tcPr`) edits are visible and composable, uncontested split/merge/move/row-move ops render natively (colliding ones lower to del/ins with a recorded conflict — never a silent drop), and **reviewers' paragraph-property (`w:pPr`) changes MERGE (sub-project B1: `ComposePPr` by `IrParagraph.PPrDigest` — consensus/conflict, native `w:pPrChange` authored to the winner; table-shell/section composite merge is B2, still a ceiling)**. Wraps `Docxodus/DocxDiffConsolidate.cs` + `Docxodus/Ir/Diff/IrCompositeMerger.cs`; surfaced in WASM/npm (`docxDiffConsolidate*`)/docx-scalpel (`docx_diff_consolidate*`)
- N-way composite / consolidate (closes the last WmlComparer gap): `Consolidate(base, reviewers, settings?)` → `WmlDocument`, plus `GetConsolidatedRevisions`/`GetConsolidatedEditScriptJson`/`GetConflicts` — merge N `DocxDiffReviewer{Document,Author}` (each diffed against ONE shared base) into one multi-author tracked-changes document with per-reviewer attribution, token-granular sub-block merge, and a structured `DocxDiffConflict` report; `DocxDiffConsolidateSettings.ConflictResolution` = `BaseWins`(default)/`FirstReviewerWins`/`StackAll`. Round-trip: reject ≡ base, accept ≡ the policy-resolved composite. Structurally complete: note-scope (footnote/endnote) diffs merge across reviewers (compose/consensus/conflict per block, inserted notes under fresh ids, N-reviewer-aware renumbering), table column add/remove composes with native `w:cellIns`/`w:cellDel` markup, cell-shell (`w:tcPr`) edits are visible and composable, uncontested split/merge/move/row-move ops render natively (colliding ones lower to del/ins with a recorded conflict — never a silent drop), and **reviewers' block-format changes MERGE — paragraph (B1) + table-shell + section (B2), closing the last "Consolidate ignores block-format" ceiling**: `ComposeParagraphFormat` (by full `BlockSignature`) → `w:pPrChange`; per-element table shells via `ComposeTableAndRowShells` (per-cell `tcPr`, per-row `trPr`+`tblPrEx`, per-table `tblPr`+`tblGrid` — disjoint compose, contested conflict) → `w:tcPr/trPr/tblPr/tblGrid/tblPrExChange`; trailing+inline section via `ComposeTrailingSection`/B1's paragraph path → `w:sectPrChange`; each mirrors `ComposeCellShell` (0→base/agree→consensus/≥2→conflict-per-policy/non-stackable) with `reject ≡ base`/`accept ≡ policy-winner` at the property-byte level (strengthened byte-level verifier `Docs.ShellSection`). text+pPr stays conflict-routed (v1 decision; never a silent drop). Wraps `Docxodus/DocxDiffConsolidate.cs` + `Docxodus/Ir/Diff/IrCompositeMerger.cs`; surfaced in WASM/npm (`docxDiffConsolidate*`)/docx-scalpel (`docx_diff_consolidate*`)
- `DocxDiffSettings` mirrors `WmlComparerSettings` defaults (two honest deviations: `Deterministic` revision dates default true; `FormatComparison` defaults `ModeledOnly`). `DocxDiffRevision` adds `LeftAnchor`/`RightAnchor` (`kind:scope:unid`, interoperable with `DocxSession`/markdown projection)
- Header/footer stories are compared like Word Compare's default-on "Headers and footers" granularity (`CompareHeadersFooters`, default true) — stories pair per section ordinal × kind with Word's inheritance rule, changed stories get native markup inside their parts (accept ≡ right / reject ≡ left extends to those scopes), Fine revisions carry `hdr`/`ftr` anchors (compatible mode excludes them), JSON gains `headerFooterOps`. WmlComparer ignores headers/footers entirely; Consolidate doesn't merge them in v1 (forced off, pinned)
- Paragraph-and-above formatting changes are tracked as native markup — the block-format-change family (closes the last "Word compares Formatting, we don't" gap): `w:pPrChange` (paragraph: jc/indent/spacing/style/**numbering**, + `w:pPr/w:rPr/w:rPrChange` for a changed mark), `w:tcPrChange`/`w:trPrChange`/`w:tblPrChange`/`w:tblGridChange`/`w:tblPrExChange` (table shells — the per-element digests `IrCell.ShellDigest`/`IrRow.TrPrShellDigest`/`TrPrExDigest`/`IrTable.TblPrDigest`/`TblGridDigest` drive attribution; makes the #250 cell-shell edits *tracked*, not just visible), and `w:sectPrChange` (trailing section AND mid-document inline `w:pPr/w:sectPr` via `IrParagraph.InlineSectionFormat`). Detected via `FormatComparison` for paragraphs (canonical for shells/section); accept ≡ right / reject ≡ left holds at the property-byte level for every detected change. Note/header/footer-scope `w:pPrChange` works via the shared `RenderBlockOp` dispatch (no per-scope gate). `DocxDiffRevision.FormatChange.Scope` (`DocxDiffFormatChangeScope`: Run/Paragraph/TableCell/TableRow/Table/Section) — `WmlComparerCompatible` excludes non-Run scopes (oracle produces none); additive `scope` on the revisions wire. **`TrackBlockFormatChanges` is a public opt-out** (default true; wire `trackBlockFormatChanges`). **Two v1 ceilings: (a) Consolidate forced off, pinned** (`IrCompositeMerger` sets `TrackBlockFormatChanges=false` — sub-project B is the N-way merge); **(b) split/merge members don't emit pPrChange** (deliberate decline — members are new paragraphs already tracked by the pilcrow mark). Rode-along consume-side fix: `RevisionProcessor` no longer drops header/footer refs (sectPrChange) or an inline sectPr (pPrChange) on reject — CT_*Base inners exclude them (see `docs/ooxml_corner_cases.md`)
- Paragraph-and-above formatting changes are tracked as native markup — the block-format-change family (closes the last "Word compares Formatting, we don't" gap): `w:pPrChange` (paragraph: jc/indent/spacing/style/**numbering**, + `w:pPr/w:rPr/w:rPrChange` for a changed mark), `w:tcPrChange`/`w:trPrChange`/`w:tblPrChange`/`w:tblGridChange`/`w:tblPrExChange` (table shells — the per-element digests `IrCell.ShellDigest`/`IrRow.TrPrShellDigest`/`TrPrExDigest`/`IrTable.TblPrDigest`/`TblGridDigest` drive attribution; makes the #250 cell-shell edits *tracked*, not just visible), and `w:sectPrChange` (trailing section AND mid-document inline `w:pPr/w:sectPr` via `IrParagraph.InlineSectionFormat`). Detected via `FormatComparison` for paragraphs (canonical for shells/section); accept ≡ right / reject ≡ left holds at the property-byte level for every detected change. Note/header/footer-scope `w:pPrChange` works via the shared `RenderBlockOp` dispatch (no per-scope gate). `DocxDiffRevision.FormatChange.Scope` (`DocxDiffFormatChangeScope`: Run/Paragraph/TableCell/TableRow/Table/Section) — `WmlComparerCompatible` excludes non-Run scopes (oracle produces none); additive `scope` on the revisions wire. **`TrackBlockFormatChanges` is a public opt-out** (default true; wire `trackBlockFormatChanges`). **Consolidate MERGES all block-format families** (sub-project B done: `IrCompositeMerger` forces the umbrella `TrackBlockFormatChanges` off but turns the paragraph/table/section slices ON — B1+B2). **Remaining v1 ceiling: split/merge members don't emit pPrChange** (deliberate decline — members are new paragraphs already tracked by the pilcrow mark). Rode-along consume-side fix: `RevisionProcessor` no longer drops header/footer refs (sectPrChange) or an inline sectPr (pPrChange) on reject — CT_*Base inners exclude them (see `docs/ooxml_corner_cases.md`)
- No static state — `AuthorForRevisions` flows per call (multi-author / consolidate-compatible)
- Wraps the internal `Docxodus/Ir/Diff/` pipeline (`IrReader → IrEditScriptBuilder → IrMarkupRenderer/IrRevisionRenderer/IrEditScriptJson`); see `docs/architecture/ir_diff_engine.md`

Expand Down
33 changes: 21 additions & 12 deletions Docxodus.Tests/Ir/Diff/BlockFormatChangeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -783,22 +783,30 @@ public void TableFamily_and_pPr_outputs_are_schema_valid()
}

[Fact]
public void Consolidate_ignores_table_shell_changes_v1_ceiling()
public void Consolidate_merges_table_shell_changes_b2()
{
// Review finding 1: the table-shell REVISION emitters must respect the Consolidate ceiling too — a
// reviewer's tcPr/trPr/tblPr-only edit produces neither markup nor a consolidated revision.
// Sub-project B2 (flips the former ceiling pin): a reviewer's table-shell (tblPr/tblGrid) change now
// MERGES into the consolidated document with native w:tblPrChange/w:tblGridChange authored to that
// reviewer, a non-Run consolidated revision, and a property-byte round-trip (accept ≡ reviewer,
// reject ≡ base).
var baseDoc = IrTestDocuments.FromBodyXml(Table("", "<w:tcW w:w=\"4000\" w:type=\"dxa\"/>"));
var reviewerDoc = IrTestDocuments.FromBodyXml(Table("", "<w:tcW w:w=\"4000\" w:type=\"dxa\"/>",
grid: "<w:gridCol w:w=\"6000\"/>", tblPr: "<w:tblW w:w=\"0\" w:type=\"auto\"/><w:tblBorders><w:top w:val=\"single\" w:sz=\"4\"/></w:tblBorders>"));
var reviewer = new DocxDiffReviewer { Document = reviewerDoc, Author = "Reviewer A" };

var merged = DocxDiff.Consolidate(baseDoc, new[] { reviewer });
var body = BodyOf(merged);
Assert.Empty(body.Descendants(W + "tblPrChange"));
Assert.Empty(body.Descendants(W + "tblGridChange"));
Assert.NotEmpty(body.Descendants(W + "tblPrChange"));
Assert.NotEmpty(body.Descendants(W + "tblGridChange"));
Assert.Equal("Reviewer A", (string?)body.Descendants(W + "tblPrChange").First().Attribute(W + "author"));

// accept ≡ reviewer (grid 6000), reject ≡ base (grid restored).
Assert.Equal("6000", (string?)BodyOf(RevisionProcessor.AcceptRevisions(merged))
.Descendants(W + "gridCol").First().Attribute(W + "w"));
Assert.Empty(BodyOf(RevisionProcessor.RejectRevisions(merged)).Descendants(W + "tblGridChange"));

var revs = DocxDiff.GetConsolidatedRevisions(baseDoc, new[] { reviewer });
Assert.DoesNotContain(revs, r => r.FormatChange is { } fc && fc.Scope != DocxDiffFormatChangeScope.Run);
Assert.Contains(revs, r => r.FormatChange is { } fc && fc.Scope == DocxDiffFormatChangeScope.Table);
}

[Fact]
Expand Down Expand Up @@ -1074,11 +1082,12 @@ public void StyleDefinition_only_difference_with_identical_direct_pPr_stays_Unch
}

[Fact]
public void Consolidate_merges_pPr_but_not_shell_section_v1()
public void Consolidate_merges_pPr_and_table_shell_but_not_section_v1()
{
// Sub-project B1 (flips the former ceiling pin): a reviewer's PARAGRAPH-property (pPr) change now
// MERGES into the consolidated document with native w:pPrChange authored to that reviewer, round-trips
// (accept ≡ reviewer, reject ≡ base). A TABLE-shell reviewer change stays IGNORED (the B2 ceiling).
// Sub-project B1/B2 (flips the former ceiling pin): a reviewer's PARAGRAPH-property (pPr) change (B1)
// and TABLE-shell change (B2) now MERGE into the consolidated document with native markup authored to
// that reviewer. A SECTION-only reviewer change stays the remaining B2 ceiling flipped in Phase 2 —
// see the section tests for its merge.
var reviewer = new DocxDiffReviewer { Document = PPrRight, Author = "Reviewer A" };
var merged = DocxDiff.Consolidate(PPrLeft, new[] { reviewer });
var body = BodyOf(merged);
Expand All @@ -1090,11 +1099,11 @@ public void Consolidate_merges_pPr_but_not_shell_section_v1()
Assert.Equal("center", (string?)BodyOf(RevisionProcessor.AcceptRevisions(merged)).Descendants(W + "jc").Single().Attribute(W + "val"));
Assert.Empty(DocxDiff.GetConflicts(PPrLeft, new[] { reviewer })); // one reviewer → no conflict

// B2 ceiling: a table-shell-only reviewer edit is still ignored by Consolidate.
// B2: a table-shell-only reviewer edit now MERGES (native w:tblGridChange authored to the reviewer).
var tblBase = IrTestDocuments.FromBodyXml(Table("", "<w:tcW w:w=\"4000\" w:type=\"dxa\"/>"));
var tblRev = IrTestDocuments.FromBodyXml(Table("", "<w:tcW w:w=\"4000\" w:type=\"dxa\"/>", grid: "<w:gridCol w:w=\"6000\"/>"));
var tblMerged = DocxDiff.Consolidate(tblBase, new[] { new DocxDiffReviewer { Document = tblRev, Author = "R" } });
Assert.Empty(BodyOf(tblMerged).Descendants(W + "tblGridChange"));
Assert.NotEmpty(BodyOf(tblMerged).Descendants(W + "tblGridChange"));
}

// ------------------------------------------------------------------ the WmlComparer oracle
Expand Down
15 changes: 10 additions & 5 deletions Docxodus.Tests/Ir/Diff/CompositeFuzzTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ public void Composite_round_trips_reject_equals_base(int reviewerCount)
.Select(r => new DocxDiffReviewer { Document = new WmlDocument("r.docx", r.Doc), Author = r.Author })
.ToList();
var merged = DocxDiff.Consolidate(baseDoc, reviewers);
Assert.Equal(Docs.PlainText(baseDoc), Docs.PlainText(RevisionProcessor.RejectRevisions(merged)));
var rejected = RevisionProcessor.RejectRevisions(merged);
Assert.Equal(Docs.PlainText(baseDoc), Docs.PlainText(rejected));
// Byte-level: rejecting all revisions restores every block-format shell/section too (B2). The
// fuzzer mutates no shells, so this is a regression guard — a future shell-drop turns it red.
Assert.Equal(Docs.ShellSection(baseDoc), Docs.ShellSection(rejected));
}
}

Expand All @@ -38,6 +42,7 @@ public void Composite_round_trips_structurally_with_tables_and_footnotes(int rev
// not just the body paragraph text. Docs.StructuralBody walks body w:p AND w:tbl (descending
// into rows/cells), so a consolidate that corrupts or drops a table on the reject path differs.
Assert.Equal(Docs.StructuralBody(baseDoc), Docs.StructuralBody(rejected));
Assert.Equal(Docs.ShellSection(baseDoc), Docs.ShellSection(rejected));
}
}

Expand Down Expand Up @@ -124,10 +129,10 @@ public void Composite_disjoint_table_cells_round_trip(int reviewerCount)
var merged = DocxDiff.Consolidate(
baseDoc, dd, new DocxDiffConsolidateSettings { ConflictResolution = ConflictResolution.BaseWins });

// (a) reject ≡ base, table-aware (StructuralBody descends rows/cells).
Assert.Equal(
Docs.StructuralBody(baseDoc),
Docs.StructuralBody(RevisionProcessor.RejectRevisions(merged)));
// (a) reject ≡ base, table-aware (StructuralBody descends rows/cells) AND byte-level shell/section.
var rejected = RevisionProcessor.RejectRevisions(merged);
Assert.Equal(Docs.StructuralBody(baseDoc), Docs.StructuralBody(rejected));
Assert.Equal(Docs.ShellSection(baseDoc), Docs.ShellSection(rejected));

// (b) table-aware apply-verifier: the composed table's cell ops reconstruct the rendered accept.
IrCompositeVerifier.Verify(baseDoc, revs, ConflictResolution.BaseWins, Docs.AcceptStructuralBody(merged));
Expand Down
Loading
Loading