Skip to content

fix(diff): keep bookmarks and comment ranges between blocks through compare, accept and reject (#1149) - #1177

Merged
JSv4 merged 8 commits into
mainfrom
fix/issue-1149
Oct 17, 2026
Merged

JSv4 merged 8 commits into
mainfrom
fix/issue-1149

Conversation

@JSv4

@JSv4 JSv4 commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Closes #1149

Third of a three-PR stack for the comparison engine: #1169 (#1157), then #1173 (#1156), then this one. It is based on #1173's branch and will be retargeted to main as the stack merges.

Checked in Word. Word opens a redline with a deleted bookmark tracked directly in the body and an inserted one tracked directly in a table cell, with no repair prompt, and lists both bookmarks. It also opens a three-reviewer consolidated redline of the same shape. Like LibreOffice, Word does not act on revision marks around bookmarks: after Reject All Changes it keeps both bookmarks. So no bookmark is lost in either application; only Docxodus's own accept and reject resolve them per side. This is recorded in docs/ooxml_corner_cases.md.

Why

Word often brackets a bookmark that spans whole paragraphs, or a table, with a w:bookmarkStart/w:bookmarkEnd placed directly in the body or a table cell, as siblings of the paragraphs. The same goes for comment ranges. These markers were lost along the way:

  • Body. The comparison's document model leaves them out of the blocks it aligns (rule N3), and nothing put them back. A DocxDiff.Compare redline dropped them, and any REF or table-of-contents field targeting the bookmark broke.
  • Table cells. The marker survived but was written in front of the cell's paragraphs, not around the ones it bracketed.
  • Accept and reject. RevisionProcessor.AcceptRevisions and RejectRevisions removed them even from a document with no revisions. The DocxCompare front door accepts its inputs before comparing, so it lost them before the comparison even started.

How it works

  • Accept. Accept rebuilt every block stream (body, cell, note, wrapper) from its paragraphs and tables only. LooseChildrenOfBlockStream now keeps every other child in front of the block it preceded. When a deleted paragraph mark merges two paragraphs, a marker between them goes inside the merged paragraph. Anything a paragraph may not hold, such as w:altChunk, stays in front of it. A stream with nothing outside its blocks takes the old path.
  • Reject. It now also reverses w:ins/w:del that sit directly in a block stream or in a block-level w:customXml.
  • Redline placement (new IrBlockLevelMarkers.cs, called from RenderBlockOpsWordShaped, which every block stream goes through):
    • Each document's markers are pinned to a block boundary.
    • A marker that both documents have at the same boundary is matched by kind plus bookmark name or comment id, keeping order. It is written once, unmarked, and survives both accept and reject.
    • Any other marker is written as its document's w:del or w:ins.
    • A bookmark that moved gets one whole copy per document.
  • Containers the comparison sees as unchanged. These are unchanged tables, rows, cells, block wrappers and notes, which the renderer copies from one side. They now reconcile their between-block markers the same way (ReconcileEqualContainerMarkers), because a marker-only change is invisible to the differ.
  • Cells, notes and replaced wrappers. Cell and note renderers no longer copy markers in front of the blocks. A wrapper replaced whole marks its between-block markers as that copy's revision, so each copy keeps its own.
  • Consolidate. It places the base's markers and each reviewer's own markers by the same rule.
  • NormalizeBookmarks. It no longer merges the per-side copies back into one bare pair.

Validation

  • New BlockLevelMarkerDiffTests (75 cases). Every original case failed on the base. Each compare case runs through both DocxCompare and DocxDiff. Accept must equal the revised document and reject the original, marker positions included, and the redline must add no validation errors. The cases:
    • the issue's reproducer;
    • inserted and deleted paragraphs inside a bookmark;
    • bookmarks added, removed, or moved to another boundary;
    • cell bookmarks still bracketing their paragraphs;
    • comment ranges in the body and in cells;
    • replaced w:sdt and w:customXml wrappers;
    • a bookmark around a moved paragraph, and a footnote bookmark;
    • consolidate;
    • marker-only changes in unchanged tables, rows, cells, wrappers, nested tables and notes;
    • accept merging across a bookmark or an altChunk;
    • accept and reject of revision-free documents;
    • a real document with 916 body-level markers (TestFiles/WC/WC-BodyBookmarks-*).
  • Mutation checks. Reverting each layer fails its tests.
  • Existing fixture changed. The golden redline TestFiles/HistoryArchive/charter-collaboration-comparison.docx and its SHA changed. Two bookmarks that Word ended between paragraphs used to be closed zero-width at their start. They now keep their real end.
  • Suites. The full .NET suite passes on the stacked branch: 7,000 passed, 0 failed, 2 skipped. The Release build has no warnings.
  • Allocation versus fix(diff): keep paragraph-spanning fields balanced and entry tabs in place (#1156) #1173's tip. No case is more than 10% above base. Compare cases rise 0.1 to 0.3%. "Redline to HTML with tracked-change markup" rises 5.2%, because the NVCA redline now keeps its two body-level bookmarks.

Limits (follow-ups filed)

Adversarial review

One round of hostile review ran on the first four commits. It confirmed:

  • the shape is schema-legal and validator-clean, LibreOffice reads it, and three-reviewer consolidate works;
  • moves, permission marks and proofing marks behave;
  • the golden-fixture change is justified.

It found two real defects, both fixed in the last three commits:

  • Marker-only changes in containers the comparison sees as unchanged were still lost (the issue's own symptom, in cells). They are now reconciled for tables, rows, cells, wrappers and notes; headers and footers remain, filed as Comparison loses a bookmark-only change in an unchanged header or footer #1174.
  • Accept could put w:altChunk inside a merged paragraph. That produced invalid OOXML and is now fixed.

It also flagged an untested merged-paragraph path, now tested, and allocation work on marker-free documents, now fast-pathed.

@JSv4
JSv4 changed the base branch from fix/issue-1156 to main October 16, 2026 13:05
JSv4 added 7 commits October 16, 2026 08:05
…kers (#1149)

Also regenerates the charter-collaboration golden redline: a bookmark whose
end Word wrote between paragraphs now keeps that end instead of being closed
zero-width at its start.
…d keep altChunk out of merged paragraphs (#1149)

A table, row, cell, block wrapper or note whose only difference is a bookmark
or comment range between its blocks pairs equal (the IR leaves those markers
out) and was emitted as one side's copy, losing the other side's markers.
Equal containers have the same blocks in order, so their markers are now
merged boundary by boundary like a gap boundary. Accepting a deleted
paragraph mark no longer puts a w:altChunk inside the merged paragraph, and
the merge skips its marker bookkeeping for streams without loose children.
@JSv4
JSv4 merged commit bd50366 into main Oct 17, 2026
17 checks passed
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.

Comparison drops bookmarks and comment ranges that sit between blocks

1 participant