Skip to content

fix(docx-compare): pair header/footer text-box stories across an added or removed section break - #1492

Merged
stevenobiajulu merged 4 commits into
mainfrom
1487-section-break-hf-textbox-pairing-20261115
Nov 15, 2026
Merged

stevenobiajulu merged 4 commits into
mainfrom
1487-section-break-hf-textbox-pairing-20261115

Conversation

@stevenobiajulu

@stevenobiajulu stevenobiajulu commented Nov 15, 2026 •

Copy link
Copy Markdown
Member

Problem

compareDocuments aborted with unpaired ancillary text-box story is not owned exclusively by a removed section slot when the revised copy added (or removed) a section break and a header or footer holding a text box changed its ordinary content: text beside the box, a table row, or a box resize. It aborted whether the break restated the header/footer references or not. The cumulative dogfood smoke hit this on all three documents and stopped the full recipe at F2.

Cause: header/footer text-box stories paired by binding closure (section ordinal × kind × role), and only while the section topology matched. Once the section count changes, ordinals stop naming the same pages, and the content/scaffold fallbacks cannot pair a story whose ordinary content changed. A box-text-only change already passed, because the scaffold fallback blanks box content.

Approach

textBoxRevisionSafety.ts, sectionAlignedTextBoxStoryPairs: when the topology changed, header/footer stories (at least one side holding a text box) are paired through the section alignment that Reject All and Accept All produce.

  • The body-level w:sectPr is the last section on both sides. The body comparison records a paragraph-level break on paragraph marks (docx-compare: reject-all cannot restore a tracked section-break change (header/footer bindings lost; added empty break survives) #1144), so the last sections correspond. The exception is when what the sections show places one side's last section elsewhere (a final section added or removed outright); then the content alignment is used. The other sections align by what they show. Between two aligned sections, a run of unmatched sections of equal length aligns in order, and runs of unequal length align as a whole, so a story pairs only when every section of both runs shows it (review round 1).
  • An original story A and a revised part B are paired when every displayed slot of an aligned section that shows A shows B, and every one that shows B showed A. A section's references after Reject All are the revised ones, because a w:sectPrChange snapshot cannot carry them.
  • A part shared by several sections is one story, so it is compared once.
  • A story shown only by a section with no counterpart (a genuinely removed or inserted section's own header/footer) is not paired here. It stays with the existing inserted/deleted lifecycle checks.
  • An aligned pair gets the same ordinary-content and box-lifecycle story comparisons as a topology-matched pair (storiesShareSlots). The assembled package is still held page by page to both inputs by assertAncillaryTextBoxStoryProjection, so a wrong pairing fails closed instead of publishing.

A resized box is listed with the drawing scope, as before (#1382). After a section count change, the outer comparison leaves positional header/footer entries at the other section index. These are now judged against the original with only the drawing-accounted parts' drawings aligned (drawingAccountedOriginal), which is how the projection check already judges them. So they are dropped when the story is carried. A drawing that draws more than its box (a group, a second shape, a shared template) keeps the part's own entry, and the existing #1382 tests pin that.

Representation

Evidence

  • New pipeline-section-break-header-footer-text-box.test.ts, 105 cases:
    • section break added and removed × header and footer × VML and DrawingML box × {text beside the box, a table row, a box resize, the box text} × {references restated on both breaks, only on the paragraph break (inherited), only on the body-level break};
    • a footer shared by two sections when a third, inheriting break is added (compared once: exactly one w:ins and one w:del outside the box);
    • added and removed breaks whose section names a new plain footer or a new footer with its own text box;
    • an edited footer of a retained first section beside an added or removed inheriting section; an edited first footer beside an added final section with its own footer; and, as a characterization, an unchanged first footer kept paired when the final section and its footer are removed.
  • Every case checks:
    • Accept All == revised and Reject All == original, page by page (every section, header and footer, first/odd/even page, with box content and table rows), with both the docx-compare acceptor and docx-core acceptChanges/rejectChanges;
    • the body text and live section count round-trip;
    • the emitted-document schema gate (document plus each header/footer part as a body);
    • the break is a paragraph-mark revision;
    • only a resize lists anything, and only drawing entries.
  • RED on base 0c3b6ee: 79 of the 103 cases written then failed (24 box-text cases already passed). GREEN: all pass.
  • Full docx-compare suite in pre-submit: 2068 passed.
  • Dogfood isolated repro (iso-resize-synthetic variants none footer|header beside|resize|text|none 0|1|2): every variant now passes. The resize cases list only the drawing.
  • LibreOffice evidence (opt-in SAFE_DOCX_SECTION_BREAK_TEXT_BOX_READER_REQUIRED=1, run through the heavy-run gate). Shape: a footer with text beside a DrawingML box, plus a box-text edit.
    • Break added: Accept All and Reject All give exactly the revised and original body and footer text.
    • Break removed: the body and the accepted footer are exact. Reject All restores the removed section, and LibreOffice gives that section a page style of its own (the docx-compare: adding or removing a section break on a paragraph with a pending pPrChange aborts #1465 family of tracked-section deviations). That copy of the footer repeats the box text. Every footer LibreOffice shows after Reject All is still the original story, with none of the revised text, and that is what the test asserts for this direction. A control run to separate LibreOffice from the redline could not complete: LibreOffice on the host stopped launching its macro midway through the session.
  • Pre-submit chain (build, lint, root test:run, spec-coverage, conformance citations and doc) passed, and check:real-corpus passed, after merging origin/main @ 1b1149e.
  • Word: staged, deferred. The compared, original and revised files for five repro variants are in the agent scratchpad and ~/.cache/safe-docx-compare-fanout/word-staged/1487/. The staged compared files pass the schema gate.

Left out and why

  • A Word-style "unlink" shape is a separate failure and is not fixed here. In that shape, the section break leaves the old footer part unchanged on the first section, and the body-level section names a new edited copy. When the break is added, the outer body comparison's formatting-fidelity check compares the surviving section's w:footerReference r:id literally (rIdStory vs rIdCopy) and fails. This happens for plain footers with no text box too, before any story pairing, so it is outside this lane. When the break is removed and a box is present, the removed section's restored reference resolves to the part that now carries the aligned redline, and the projection check refuses it (fail closed, no corruption). Both are follow-ups.
  • Dogfood full recipe: C1 (compare succeeds) now passes on all three documents, and so do C3–C6 and C4b. C2 still lists positional header/footer entries at the shifted section indexes. This is not specific to text boxes: it reproduces with every text box switched off. subtractRepresentedPackageChanges (docx-compare: unrepresentedChanges lists section and header/footer changes the redline already represents #1427) never drops a header/footer entry in a package whose header/footer fields read other stories, such as NUMPAGES in the recipe's "Page X of Y" row. That is a listing-precision follow-up; C4 confirms the redline itself carries those stories.
  • A final section removed outright together with its own footer ([A, B] → [A]): the removed footer stays listed (scope: footer, kind: removed), as on main. After Reject All the final section shows the revised body-level references (a w:sectPrChange snapshot is CT_SectPrBase, §17.13.5.32), so B cannot be shown again. When A is edited as well, the comparison aborts on main and here alike (the projection guard). Follow-up.
  • Box resize: the drawing change is listed with its short drawing note (allowed by the full-redline policy for shape changes). The box text and the part's ordinary content are tracked.

Fixes #1487

…d or removed section break

A section break added or removed beside a header or footer that holds a
text box aborted the comparison whenever the part's ordinary content
changed (text beside the box, a table row, a box resize), with "unpaired
ancillary text-box story is not owned exclusively by a removed section
slot". Text-box stories paired by their binding closure (section ordinal x
kind x role), which only works while the section topology matches; once a
section is added or removed the ordinals stop naming the same pages, and
the content and scaffold fallbacks cannot pair a story whose ordinary
content changed.

Pair such stories through the section alignment Reject All and Accept All
produce instead. The body-level sectPr is the last section on both sides
(the body comparison records a paragraph-level break on paragraph marks,
#1144), the other sections align by what they show, and a part is paired
with the original story every aligned page showing it showed, in both
directions. A shared part is one story however many sections show it, and
a story shown only by a section with no counterpart stays with the
inserted/deleted lifecycle checks. An aligned pair gets the same ordinary
and lifecycle story comparisons as a topology-matched one; the assembled
package is still held page by page to both inputs.

A resized box is listed with the drawing scope, as before. The positional
header/footer entry a changed section count leaves at another index is
now judged against the original with the listed drawings aligned, as the
projection check already judges it, so it is dropped when the story is
carried; a drawing that draws more than its box keeps the part's entry.

Fixes: #1487
… own listed drawings aligned

Positional header/footer entries left by a changed section count are
judged against the original with the drawings of parts whose listed
drawings describe only their boxes aligned, whether or not the part's
ordinary content also changed. Adds opt-in LibreOffice evidence for a
footer text-box redline across an added and a removed section break.

Refs: #1487
@vercel

vercel Bot commented Nov 15, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
site Ready Ready Preview Nov 15, 2026 2:20pm UTC

Request Review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-15T10:36:12.417530Z bd40c32 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions github-actions Bot added the fix PR type: bug fix label Nov 15, 2026
@usejunior-llm-gate

usejunior-llm-gate Bot commented Nov 15, 2026 •

Copy link
Copy Markdown

LLM gate (advisory)

All evaluated rules passed - 6 pass, 0 warn, 0 error, 10 skipped, 16 total

Findings

None.

All 16 rules (6 evaluated, 10 skipped)
Rule Verdict Detail
read_file response metadata parity SKIPPED paths not touched by this PR
Live DOM namespace-safe OOXML writes SKIPPED paths not touched by this PR
Complex-field revisions preserve complete accept/reject state machines PASS The PR touches header/footer text box story comparison and section break alignment in packages/docx-compare/src/tagged/textBoxRevisionSafety.ts and pipeline.ts, and does not modify field atomization, validateFieldStructure, w:fldChar, w:instrText, w:delInstrText, or collapsed-field comparison logic.
Field validation per story, not global PASS The PR modifies packages/docx-compare/src/tagged/pipeline.ts:1913 only to adjust drawing-accounted package changes, and does not alter field validation, splitStories, or footnote/endnote field handling, which remain independently run per story via validateFieldStructure at lines 1343–1344.
Revision IDs seeded from all revision-bearing side parts SKIPPED paths not touched by this PR
Accept/reject sweep side parts and caches PASS The PR modifies section-break header and footer text-box story alignment in docx-compare and does not touch DocxDocument.acceptChanges, DocxDocument.rejectChanges, REVISION_STORY_PART_PATHS, accept_changes, reject_changes, or side-part revision markup.
DocumentViewNode.heading stays canonical SKIPPED paths not touched by this PR
AI-author parity across entry points SKIPPED paths not touched by this PR
Property-change wrapper discipline SKIPPED paths not touched by this PR
SUPPORT.md Table A drift vs. implementation SKIPPED paths not touched by this PR
Table A / Table B boundary on side-part revisions SKIPPED paths not touched by this PR
Canonical-emission surface completeness SKIPPED paths not touched by this PR
Unit-test quality (avoid tautological / change-detector tests) PASS The test suite in packages/docx-compare/src/tagged/pipeline-section-break-header-footer-text-box.test.ts constructs expected document states from independent original and revised fixtures, avoids mocking the system under test, makes concrete semantic assertions on revision counts and page story bindings, and directly exercises issue #1487.
Re-derived facts vs canonical sources PASS The PR consumes canonical story-binding derivations (resolveEffectiveStoryBindings and effectiveStorySlot from effectiveStoryBindings.ts) and reuses unmatchedSequenceOrdinals in packages/docx-compare/src/tagged/textBoxRevisionSafety.ts for section alignment, avoiding duplicate derivations.
.openspec tag ↔ test-assertion drift PASS The PR adds packages/docx-compare/src/tagged/pipeline-section-break-header-footer-text-box.test.ts using ECMA-376 conformance annotations and does not add, move, or change any .openspec() tags.
Library stays general (no downstream-domain leakage) SKIPPED paths not touched by this PR

@codecov

codecov Bot commented Nov 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.87179% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...s/docx-compare/src/tagged/textBoxRevisionSafety.ts 94.80% 0 Missing and 4 partials ⚠️

📢 Thoughts on this report? Let us know!

… section

Review of #1492 found two pairing defects in the predicted section
alignment for header/footer text-box stories:

- A retained leading section whose footer changed beside its box, next
  to an added or removed inheriting section, left the story unpaired: the
  leading runs shared no section signature and a gap of unequal length
  was discarded, so the comparison aborted. Unequal runs between two
  aligned sections now align as a whole; the two-way uniqueness check
  still pairs a story only when every section of both runs shows it.
- Pinning last section to last section paired a removed final footer
  with the unchanged first one before the exact pair could form, turning
  a listed removal into a projection-guard abort. The pin now yields when
  the sections' content places one side's final section elsewhere.

Refs: #1487
@stevenobiajulu

Copy link
Copy Markdown
Member Author

Codex review round 1 (bd40c32): VERDICT: CHANGES_REQUIRED, adjudication (fixes in 6cdc896)

I reproduced both blocking findings with the reviewer's probes at bd40c32. Both are real, and both come from alignedSectionOrdinals in packages/docx-compare/src/tagged/textBoxRevisionSafety.ts.

1. Edited footer of a retained leading section aborts (blocking): ACCEPTED, fixed.

  • Repro: sections [A, C] → [A′, inherited A′, C], with only the legend beside A's box changed.
  • Cause: the leading runs [A] and [A′, A′] share no section signature, and a gap of unequal length on the two sides was thrown away. A stayed unpaired, and the comparison aborted with "unpaired ancillary text-box story is not owned exclusively by a removed section slot".
  • Fix: between two aligned sections, unmatched runs of unequal length now align as a whole. Runs of equal length still align in order. The existing two-way uniqueness check still applies, so a story is paired only when every section of both runs shows the same single story in that slot. A run containing a second distinct story pairs nothing, as before.
  • Tests (new): the break added, and the reverse with the break removed. Both pass the full page-by-page round trip with both appliers, the schema gate and unrepresentedChanges === undefined. Both were RED at bd40c32.

2. Pinning the final sections preempts an unchanged exact pair (blocking): ACCEPTED, fixed (no longer a regression).

  • Repro: [A, B] → [A], with A unchanged. The final section and its footer are removed outright.
  • Cause: pinning last↔last paired original B with revised A before exact matching could keep A↔A. That turned base's listed outcome into a projection-guard abort.
  • Fix: the final sections stay pinned unless what the sections show places one side's final section elsewhere. That happens when a content alignment of all sections matches one side's last section with a non-last section on the other side. In that case the content anchors are used instead of the pin.
    • For this input, A↔A pairs again, and the output now matches base exactly: the same {scope: footer, kind: removed, sectionIndex: 1} entry and the same Accept/Reject projections, compared as JSON.
  • Test (new): a characterization test.
    • Accept All gives the revised document with both appliers.
    • Reject All restores the body text and the section count, and the first section's pages show the original A.
    • The removed final footer is listed. Its pages show the revised body-level references after Reject All too (a w:sectPrChange snapshot cannot carry references, §17.6.18 / §17.13.5.32), so B cannot be shown again. That is a limitation that exists on main, not part of docx-compare: an added section break aborts when a header/footer holding a text box changes #1487.
    • RED at bd40c32 (it threw).
  • Related probe: the same removal with A also edited ([A, B] → [A2]) aborts on base and on this PR alike. Base reports "unpaired … inserted section"; here the projection guard catches it. The cause is the same body-level-reference limitation, reported as a follow-up.

Other probes run while fixing

  • [A] → [A2, B] (final section with its own footer added, A edited): base aborts; this PR round-trips exactly with nothing listed. Pinned as a test.
  • [A] → [A, B] (A unchanged): aborts in formattingFidelity on base and on this PR alike. This is the separate last-section-names-a-different-part shape (literal footer reference ids) the supervisor is tracking. Not caused by this PR.

Reviewer timeouts: the 5-second timeouts in sectionBreakParagraphMark.test.ts and textBoxRevisionSafety-effective-bindings.test.ts came from host load (1-minute load above 300 at the time). Both pass when isolated, as the reviewer also found. No timeout was changed.

@stevenobiajulu

Copy link
Copy Markdown
Member Author

Codex confirmation round 2 (6cdc896): VERDICT: APPROVE

Codex asserted HEAD 6cdc896 before and after the review and found no new findings, blocking or non-blocking.

  • The four-file targeted run (--testTimeout=120000) passed: 162 passed, 4 skipped.
  • Five further lifecycle/package regression files passed: 123 passed, 1 skipped.
  • 178 base-versus-HEAD probes passed, covering:
    • repeated section signatures and unequal runs;
    • differing first and even slots, and settings switches;
    • final sections added or removed with both headers and footers.
  • No case that succeeded on base aborts here, and no projection mismatch is published unlisted.
  • The final-section removal limitation is unchanged from base and stays listed.

After the round, the worktree is clean and HEAD and the branch are unchanged.

Pre-submit at 6cdc896 (through the shared heavy-run gate)

@stevenobiajulu
stevenobiajulu enabled auto-merge (squash) November 15, 2026 14:21
@stevenobiajulu
stevenobiajulu merged commit 2672d14 into main Nov 15, 2026
29 of 30 checks passed
@stevenobiajulu
stevenobiajulu deleted the 1487-section-break-hf-textbox-pairing-20261115 branch November 15, 2026 14:51
@stevenobiajulu

Copy link
Copy Markdown
Member Author

Post-merge smoke at 2672d14 (squash on origin/main): REGRESSION FOUND on one real document

The build is a detached worktree at 2672d14 after npm install && npm run build. The squash tree differs from the PR head because #1501 landed in between.

(a) Issue repro variants: all PASS

variant result
none footer beside 2 compare OK, nothing listed
none footer beside 1 compare OK, nothing listed
none header resize 2 compare OK, one drawing entry only (header1 "Text Box 1")
ins footer beside 2 compare OK, nothing listed

(b) Full dogfood recipe (full, no skips)

criterion nvca-spa nvca-va common-paper-csa
C1 compare succeeds PASS PASS FAIL
C2 unrepresented empty/expected FAIL (known, separate) FAIL (known, separate) -
C3 schema gate PASS PASS -
C4 accept/reject faithful (all parts, both appliers) PASS PASS -
C4b comparison-layer-only projections keep pending records PASS PASS -
C5 revision ids unique PASS PASS -
C6 each change redlined in place PASS PASS -
C8 Word DEFERRED DEFERRED -

Pre-merge at bd40c32, common-paper-csa passed C1 and C3–C6.

Cause, bisected by patching the built JS in the PR worktree: the round-1 fix 6cdc896, specifically the alignment of unequal-length section runs as a whole.

  • With that branch disabled, common-paper-csa passes C1 and C3–C6 again.
  • With only the final-section un-pinning disabled, it still fails.

The document's sections:

  • Original: [h1, h3, h5]. Revised: [h1, h3, h5, h5], where the added section inherits h5.
  • footer1.xml is shared by every section, holds a text box, and changes.
  • Because footer1 changes, every section signature differs between the sides, so the leading runs [s0, s1] and [s0, s1, s2] share no match.
  • The gap of unequal length is now aligned as a whole. Revised h5 (shown by revised section 2) then picks up original h1 and h3 as backward sources, so the two-way check refuses h5↔h5, which bd40c32 paired through the pinned final sections.

C7 LibreOffice: not run. LibreOffice does not launch on this host. Even docx-core's generic "headless PDF conversion succeeds" scenario times out after 120 s in the pre-submit reruns.

Per the run rules I am not reverting. A narrower fix is proposed in the next comment.

@stevenobiajulu

Copy link
Copy Markdown
Member Author

Proposed follow-up fix for the post-merge regression (branch pushed, no PR opened)

Branch 1487-unequal-run-alignment-regression-20261115, commit 95be65b, based on origin/main 2672d14.

Change: unequal section runs are no longer added to the aligned sections' two-way map. alignedSectionOrdinals now returns them separately, as unequalRuns.

  • A run pairs only a story that no aligned section shows and that has no unchanged copy on the other side, so a run can never contend with an aligned pair or an exact pair.
  • Within the runs, the two-way uniqueness check still applies.

Evidence:

  • New test: three sections with plain, plain and boxed headers; one boxed footer shared by every section; the revision adds an inheriting final section and edits the boxed header and the footer.
    • RED on main with the same error as common-paper-csa: word/header5.xml#w:txbxContent[0]: unpaired … removed section slot.
    • GREEN with the fix: full page-by-page round trip with both appliers, schema gate, nothing listed.
  • Targeted files (section-break text-box, effective bindings, sectionBreakParagraphMark, drawing shape): 163 passed, 4 skipped. This includes both round-1 findings.
  • Full dogfood on the fix build:
criterion nvca-spa nvca-va common-paper-csa
C1 PASS PASS PASS
C2 FAIL (known, separate) FAIL (known, separate) FAIL (known, separate)
C3–C6, C4b PASS PASS PASS
C8 Word DEFERRED DEFERRED DEFERRED
  • The C2 details are byte-identical to the pre-merge run at bd40c32.
  • All four issue repro variants pass.

Not yet done: the full pre-submit chain, a Codex review and a PR. These are waiting on the supervisor's decision under the "smoke failed, stop" rule.

This branch was successfully deployed

1 active deployment
Preview — 6cdc8961 Deployed Nov 15, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix PR type: bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docx-compare: an added section break aborts when a header/footer holding a text box changes

1 participant