diff --git a/REFERENCE.md b/REFERENCE.md index 263489d..3e35ee9 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -149,7 +149,9 @@ size="…in" margins="…in" text-width="…in" -->` note when the page deviates headers`/`docx footers`. A header/footer holding a reviewer's **pending tracked changes** names them in every view — `` — so you can `track-changes accept/reject --at - tc2` without guessing; the `text` follows the view: accepted by default, the + tc2` without guessing (text, paragraph marks, paragraph formatting and a + header table's row/cell/property revisions all list and accept/reject, as in + the body); the `text` follows the view: accepted by default, the original under `--baseline`, and the body's CriticMarkup under `--current` (`text="Acme Report{++ (Draft)++}[^tc2]{-- v1--}[^tc3]"`; author/date are in `track-changes list`, not the appendix). The `--` of a deletion is safe inside diff --git a/src/cli/track-changes/CLAUDE.md b/src/cli/track-changes/CLAUDE.md index f4f8cb0..080bced 100644 --- a/src/cli/track-changes/CLAUDE.md +++ b/src/cli/track-changes/CLAUDE.md @@ -23,7 +23,7 @@ Accept/reject **bypass tracking** — they mutate the XML tree directly without - moveFrom/moveTo halves process independently — `--all` handles a complete move; targeting one half by `tcN` leaves the other in place. - Targets are read from `document.trackedChangeReferences` — the single inventory the AST reader builds during `Document.open` (one walk over the body, then the note parts). `collectTrackedChanges` just reconstructs `ChangeFound` records from that map; it does **not** re-walk. Since each `Document` is opened fresh per CLI invocation, the map is current for that invocation. Apply still processes targets in reverse pre-order so nested changes apply before parents. -Paragraph-mark trackings (a self-closing ``/`` inside ``) are full citizens: accept-ins / reject-del removes the marker (paragraph stays); reject-ins removes the entire owning paragraph (the inserted break disappears — for `docx sections` sentinels this collapses the section break too); accept-del merges the owning paragraph with the next (next paragraph's runs append here, next paragraph removed) per ECMA-376 §17.13.5.4. +Paragraph-mark trackings (a self-closing ``/`` inside ``) are full citizens: accept-ins / reject-del removes the marker (paragraph stays); reject-ins removes the entire owning paragraph (the inserted break disappears — for `docx sections` sentinels this collapses the section break too) unless it is its story's ONLY block (a header part, a cell, a text box — `isOnlyBlock`), where it strips the marker and keeps the paragraph, since an empty ``/`` is not a valid story; accept-del merges the owning paragraph with the next (next paragraph's runs append here, next paragraph removed) per ECMA-376 §17.13.5.4. Table-structural trackings are full citizens too. **Which constructs we emit was settled empirically against Microsoft Word (AppleScript accept/reject), not from the schema** — see [src/cli/tables](../tables/CLAUDE.md): `rowIns`/`rowDel` (``/`` — accept/reject acts on the whole ``), `cellIns`/`cellDel` (``/`` — per-cell record of a tracked column insert/delete), `tblGridChange` (`` — grid-width snapshot, restored on reject like `sectPrChange`), and `tcPrChange` (`` — prior-`tcPr` snapshot; `set-widths` pairs one per cell with the `tblGridChange`, since Word's reject of a width change is driven by the per-cell `tcPrChange`). `tblPrChange` is read/accept/rejected (Word authors it) but we don't emit it — Word doesn't revert a hand-authored one. Cell merges and border changes are NOT tracked (Word applies them immediately and warns "won't be marked as a change"), so `merge`/`unmerge`/`borders` apply in place + drop a `[docx-cli]` audit comment. Cell removals (accept-cellDel / reject-cellIns) shrink rows without touching ``, so `runApply` runs `resyncTableGrids` afterward to trim the grid to the widest row. Row markers are ambiguous by tag (`` is also run-level), so the `TrackedChangeReference` carries an explicit `kind` that `list.ts` prefers; `apply.ts` disambiguates by walk context. @@ -31,7 +31,7 @@ Footnote/endnote trackings span two parts. **Empirically validated against Micro - The AST reader (`core/ast/read.ts`) registers note-body revisions into `document.trackedChangeReferences` after the body walk (`readNoteTrackedChanges`), continuing the same `tcN` counter; the body-side ``/`` of a paired note is hidden so add/delete surface as ONE tcN (the reference-side). Body-only revisions (footnote edits) stay visible — they're standalone. - `applyNotePairing` (post-pass after all targets are applied) handles the paired side via the footnote-id linkage: if no live `` remains, GC the entire `` from footnotes.xml (Word does the same — orphan bodies disappear on accept-del / reject-ins); otherwise unwrap any remaining body-side ``/`` and drop the paragraph-mark del marker. The emitters live in [src/core/notes/emit.tsx](../../core/notes/emit.tsx). -**Header/footer revisions** (a reviewer's edit inside `word/header{N}.xml`/`footer{N}.xml` — Word keeps it in that part) register after the notes (`readMarginalTrackedChanges`): run-level ``/``/``/`` only, with `blockId` = the marginal's `hdrN`/`ftrN` (the first reference, when one part serves several sections — registered once). The walk reaches every paragraph in the part — inside block-level `` (Word's page-number gallery), tables, and text boxes — and descends an `` through its chosen branch only, so a text box's Choice/Fallback twin isn't listed twice. Paragraph-mark and property revisions (`pPrChange`/`rPrChange`) in a marginal aren't registered. Accept/reject mutate the part tree in place and `MarginalsView.writeTo` saves it. Registration goes through the same `registerTrackedChange` as the body (one id-minting site), and an equation through `registerMathRevisions`, so Word's per-math-run markers in a note or marginal are ONE revision, exactly as in the body. `list` reads a note or marginal wrapper's text off its own runs (`wrapperRecordText` in [list-view.ts](list-view.ts) — text, plus the `[line break]`/`[tab]`/`[drawing]`/`$…$` tokens `recordText` uses for AST runs, so a deleted header break doesn't read as "¶ paragraph mark"), and `Marginal.text` (`headers list`) reads the accepted view. Each `Marginal` also lists its part's pending revisions (`Marginal.trackedChanges`, collected through the walk's `RevisionScope.registered` sink), which the `docx:header`/`docx:footer` hint names as `pending="tcN …"` in every view; under `--baseline`/`--current` the hint's text comes from `Marginals.text(marginal, view, mark)` (core/marginals), which reads the part in that view and lets `read` wrap each revision in CriticMarkup. Because a note's `""` and a marginal's `hdrN`/`ftrN` name a whole part, not a paragraph, `revisionGroups` pairs a del with an ins there only when they share a container — edits in two header lines stay two decisions. +**Header/footer revisions** (a reviewer's edit inside `word/header{N}.xml`/`footer{N}.xml` — Word keeps it in that part) register after the notes (`readMarginalTrackedChanges`): every kind the body registers except `sectPrChange` (a part has no sections) and `checkboxToggle` — run-level ``/``/``/``, `pPrChange`, paragraph marks, and the table kinds (tblPr → tblGrid → per row: row marker → per cell: cell marker → tcPrChange → content, the body's order) — with `blockId` = the marginal's `hdrN`/`ftrN` (the first reference, when one part serves several sections — registered once). The walk reaches every paragraph in the part — inside block-level `` (Word's page-number gallery), tables, and text boxes — and descends an `` through its chosen branch only, so a text box's Choice/Fallback twin isn't listed twice. A header paragraph isn't in `Body.blockReferences`, so its paragraph-mark reference stores the owning `paragraph`/`paragraphParent` itself (the body derives them from `blockId`), and `applyResolvedTargets` runs the table-grid resync + empty-hyperlink prune over every marginal tree as well as the body. (`rPrChange` isn't modeled anywhere — see below.) Accept/reject mutate the part tree in place and `MarginalsView.writeTo` saves it. Registration goes through the same `registerTrackedChange` as the body (one id-minting site), and an equation through `registerMathRevisions`, so Word's per-math-run markers in a note or marginal are ONE revision, exactly as in the body. `list` reads a note or marginal wrapper's text off its own runs (`wrapperRecordText` in [list-view.ts](list-view.ts) — text, plus the `[line break]`/`[tab]`/`[drawing]`/`$…$` tokens `recordText` uses for AST runs, so a deleted header break doesn't read as "¶ paragraph mark"), and `Marginal.text` (`headers list`) reads the accepted view. Each `Marginal` also lists its part's pending revisions (`Marginal.trackedChanges`, collected through the walk's `RevisionScope.registered` sink), which the `docx:header`/`docx:footer` hint names as `pending="tcN …"` in every view; under `--baseline`/`--current` the hint's text comes from `Marginals.text(marginal, view, mark)` (core/marginals), which reads the part in that view and lets `read` wrap each revision in CriticMarkup. Because a note's `""` and a marginal's `hdrN`/`ftrN` name a whole part, not a paragraph, `revisionGroups` pairs a del with an ins there only when they share a container — edits in two header lines stay two decisions. **Checkbox toggles** in `` content controls surface as the `checkboxToggle` kind. **Empirically validated against Microsoft Word for Mac**: when the user clicks a checkbox under tracking, Word emits an `` (new glyph ☒ or ☐) and `` (old glyph) pair INSIDE ``, and flips the `w14:checked` attribute in place — no `` element exists. The detection + accept/reject live in [src/core/task-list/](../../core/task-list/); the AST reader's `detectTaskListState` calls `findCheckboxToggle` and registers the `checkboxToggle` `tcN` into `document.trackedChangeReferences` (the single source apply reads back). Accept (`applyToggle`) keeps the ins glyph and drops the del; reject (`revertToggle`) drops the ins, unwraps the del, AND flips `w14:checked` back, inferring the prior value from the kept glyph (☐ → "0", ☒ → "1"). Structural inserts/deletes of whole checkboxes (Word wraps the SDT in ``) survive in the XmlNode tree on round-trip but aren't enumerated as a dedicated kind yet; `track-changes accept --all` won't honor them. diff --git a/src/cli/track-changes/list.ts b/src/cli/track-changes/list.ts index e40497c..90f0430 100644 --- a/src/cli/track-changes/list.ts +++ b/src/cli/track-changes/list.ts @@ -53,8 +53,8 @@ handle; paired halves additionally carry "group": "revN". kind is one of: "ins", their blockId is the owning paragraph's pN. Table-structural entries (rowIns/rowDel/cellIns/cellDel and the property revisions tblGridChange/tblPrChange/tcPrChange) have text "" and blockId set to the -owning table's tN. Revisions inside a page header/footer have blockId set to -its hdrN/ftrN. checkboxToggle entries surface a tracked checkbox toggle +owning table's tN. Revisions inside a page header/footer (any of these kinds) +have blockId set to its hdrN/ftrN. checkboxToggle entries surface a tracked checkbox toggle (☐↔☒); reject restores the prior state. (Structural inserts/deletes of a whole checkbox aren't yet enumerated as their own kind.) diff --git a/src/core/ast/document/body.ts b/src/core/ast/document/body.ts index 0e238e8..5b83738 100644 --- a/src/core/ast/document/body.ts +++ b/src/core/ast/document/body.ts @@ -289,13 +289,18 @@ export type TrackedChangeReference = { * run-level changes (kind derived from tag). */ kind?: TrackedChangeKind; /** Set for rowIns/rowDel: the owning `` and the table's children, so - * accept/reject can remove the whole row. (Paragraph-mark scope is NOT - * stored — it's derivable from `blockId` via `Body.blockReferences`.) */ + * accept/reject can remove the whole row. */ tableRow?: XmlNode; tableRowParent?: XmlNode[]; /** Set for cellIns/cellDel: the owning `` and the row's children. */ tableCell?: XmlNode; tableCellParent?: XmlNode[]; + /** Set for a paragraph-mark ``/`` in a header/footer part: + * the owning `` and its parent list. A BODY paragraph mark doesn't + * store it — its scope is derivable from `blockId` via + * `Body.blockReferences`, which a header paragraph isn't in. */ + paragraph?: XmlNode; + paragraphParent?: XmlNode[]; /** Every element of a revision Word spreads over several markers — an * equation edit puts a ``/`` in each math run it touched. * `node`/`parent` are the first of them; accept/reject act on each. */ diff --git a/src/core/ast/read.ts b/src/core/ast/read.ts index 926b87c..f182ed3 100644 --- a/src/core/ast/read.ts +++ b/src/core/ast/read.ts @@ -25,6 +25,7 @@ import type { Document } from "./document"; import { Body, type TrackedChangeMarker, + type TrackedChangeReference, type TrackedEquation, } from "./document/body"; import { numFmtToFormat } from "./document/numbering"; @@ -464,11 +465,14 @@ function readMarginalTrackedChanges( return byPart; } -/** Walk a header/footer part's blocks down to its paragraphs. Unlike a note - * body, a marginal holds more than bare paragraphs: Word's page-number gallery - * wraps the footer paragraph in a block-level ``, letterheads use - * tables, and a block may sit in an `` (its chosen branch - * only, so a Choice/Fallback twin isn't registered twice). */ +/** Walk a header/footer part's blocks, registering every revision kind the + * body walk does, in the body's order: per paragraph run-level → `pPrChange` + * → paragraph mark; per table tblPr → tblGrid → per row: row marker → per + * cell: cell marker → `tcPrChange` → content. Unlike a note body, a marginal + * holds more than bare paragraphs: Word's page-number gallery wraps the + * footer paragraph in a block-level ``, letterheads use tables, and a + * block may sit in an `` (its chosen branch only, so a + * Choice/Fallback twin isn't registered twice). */ function registerMarginalBlockChanges( scope: RevisionScope, container: XmlNode, @@ -476,6 +480,11 @@ function registerMarginalBlockChanges( for (const child of container.children) { if (child.tag === "w:p") { registerStandaloneRunChanges(scope, child); + registerMarginalParagraphProperties(scope, child, container.children); + continue; + } + if (child.tag === "w:tbl") { + registerMarginalTableChanges(scope, child); continue; } if (isAlternateContent(child)) { @@ -483,25 +492,72 @@ function registerMarginalBlockChanges( if (branch) registerMarginalBlockChanges(scope, branch); continue; } - if (MARGINAL_BLOCK_CONTAINERS.has(child.tag)) { + if (child.tag === "w:sdt" || child.tag === "w:sdtContent") { registerMarginalBlockChanges(scope, child); } } } -const MARGINAL_BLOCK_CONTAINERS = new Set([ - "w:tbl", - "w:tr", - "w:tc", - "w:sdt", - "w:sdtContent", -]); +/** A marginal paragraph's ``, then its paragraph-mark + * ``/`` (in ``) — the body's per-paragraph order. + * A header paragraph isn't in `Body.blockReferences`, so a paragraph mark + * carries its owning paragraph on the reference, for accept-del (merge with + * the next line) and reject-ins (remove the inserted line). */ +function registerMarginalParagraphProperties( + scope: RevisionScope, + paragraph: XmlNode, + paragraphParent: XmlNode[], +): void { + const pPr = paragraph.findChild("w:pPr"); + if (!pPr) return; + const change = pPr.findChild("w:pPrChange"); + if (change) + registerTrackedChange( + scope, + "pPrChange", + { node: change, parent: pPr.children }, + undefined, + ); + const rPr = pPr.findChild("w:rPr"); + if (!rPr) return; + for (const mark of rPr.children) { + if (mark.tag !== "w:ins" && mark.tag !== "w:del") continue; + registerTrackedChange( + scope, + TRACKED_CHANGE_KIND_BY_TAG[mark.tag], + { node: mark, parent: rPr.children, paragraph, paragraphParent }, + undefined, + ); + } +} + +/** A marginal table's revisions, through the body's `readTable` helpers and + * in its order: tblPr → tblGrid → per row: row marker → per cell: cell + * marker → `tcPrChange` → content. */ +function registerMarginalTableChanges( + scope: RevisionScope, + table: XmlNode, +): void { + readTablePropertyRevision(scope, table); + readGridRevision(scope, table); + for (const row of table.children) { + if (row.tag !== "w:tr") continue; + readRowRevision(scope, row, table.children); + for (const cell of row.children) { + if (cell.tag !== "w:tc") continue; + readCellRevision(scope, cell, row.children); + readCellPropertyRevision(scope, cell); + registerMarginalBlockChanges(scope, cell); + } + } +} /** Register run-level ``/``/``/`` wrappers * inside a standalone note or header/footer paragraph. blockId is "" for a * note (note bodies have no pN) and the `hdrN`/`ftrN` for a marginal. Skips `` (paragraph-mark trackings on a * standalone note don't occur — those only arise on paired deletes, which are - * hidden) and ``, mirroring the body's `walkRunContainer`. An + * hidden; a marginal's are registered by `registerMarginalParagraphProperties`) + * and ``, mirroring the body's `walkRunContainer`. An * `` (a text box's shape) descends into its chosen branch * only: Choice and Fallback carry the same story, so walking both listed every * revision in it twice, and the save-time Choice→Fallback mirror would undo a @@ -539,6 +595,12 @@ function registerStandaloneRunChanges( if (branch) registerStandaloneRunChanges(scope, branch); continue; } + // A text box's story is a block container of its own: its paragraph + // marks, pPrChanges and tables register as a header's do. + if (child.tag === "w:txbxContent") { + registerMarginalBlockChanges(scope, child); + continue; + } if (child.children.length > 0) registerStandaloneRunChanges(scope, child); } } @@ -640,6 +702,20 @@ type RevisionScope = Pick & { registered?: TrackedChange[]; }; +/** What a revision's reference records beyond its marker: an explicit `kind` + * for an ambiguous tag, and the row / cell / paragraph a structural accept or + * reject acts on. */ +type ReferenceScope = Pick< + TrackedChangeReference, + | "kind" + | "tableRow" + | "tableRowParent" + | "tableCell" + | "tableCellParent" + | "paragraph" + | "paragraphParent" +>; + function walkRunContainer( context: WalkContext, container: XmlNode, @@ -814,7 +890,7 @@ function walkRunContainer( function registerTrackedChange( context: RevisionScope, kind: TrackedChange["kind"], - marker: TrackedChangeMarker, + marker: TrackedChangeMarker & ReferenceScope, outer: TrackedChange | undefined, markers?: TrackedChangeMarker[], equation?: TrackedEquation, @@ -1456,21 +1532,16 @@ function readTable( const styleCellMargins = resolveStyleCellMargins(document, style, state); // Register table-level revisions first, in tree order (tblPr before tblGrid, // both before the rows), so tcN ids land in document order. - readTablePropertyRevision(document, node, id, state); - readGridRevision(document, node, id, state); + const revisionScope: RevisionScope = { document, state, blockId: id }; + readTablePropertyRevision(revisionScope, node); + readGridRevision(revisionScope, node); const rows: TableRow[] = []; let rowIndex = 0; for (const child of node.children) { if (child.tag !== "w:tr") continue; // Register the row-level revision before its cells so tcN ids land in // document order (row marker → per cell: cell marker → content). - const rowChange = readRowRevision( - document, - child, - node.children, - id, - state, - ); + const rowChange = readRowRevision(revisionScope, child, node.children); const cells: TableCell[] = []; let columnIndex = 0; for (const cellNode of child.children) { @@ -1594,50 +1665,24 @@ const TABLE_BORDER_EDGES = [ "w:insideV", ] as const; -function readTablePropertyRevision( - document: Document, - table: XmlNode, - tableId: string, - state: WalkState, -): void { +function readTablePropertyRevision(scope: RevisionScope, table: XmlNode): void { const tblPr = table.findChild("w:tblPr"); const change = tblPr?.findChild("w:tblPrChange"); if (!tblPr || !change) return; - registerTableRevision( - document, - change, - tblPr.children, - "tblPrChange", - tableId, - state, - ); + registerTableRevision(scope, change, tblPr.children, "tblPrChange"); } -function readGridRevision( - document: Document, - table: XmlNode, - tableId: string, - state: WalkState, -): void { +function readGridRevision(scope: RevisionScope, table: XmlNode): void { const tblGrid = table.findChild("w:tblGrid"); const change = tblGrid?.findChild("w:tblGridChange"); if (!tblGrid || !change) return; - registerTableRevision( - document, - change, - tblGrid.children, - "tblGridChange", - tableId, - state, - ); + registerTableRevision(scope, change, tblGrid.children, "tblGridChange"); } function readRowRevision( - document: Document, + scope: RevisionScope, row: XmlNode, tableChildren: XmlNode[], - tableId: string, - state: WalkState, ): TrackedChange | undefined { const trPr = row.findChild("w:trPr"); const marker = trPr?.children.find( @@ -1645,22 +1690,18 @@ function readRowRevision( ); if (!trPr || !marker) return undefined; return registerTableRevision( - document, + scope, marker, trPr.children, marker.tag === "w:ins" ? "rowIns" : "rowDel", - tableId, - state, { tableRow: row, tableRowParent: tableChildren }, ); } function readCellRevision( - document: Document, + scope: RevisionScope, cell: XmlNode, rowChildren: XmlNode[], - tableId: string, - state: WalkState, ): TrackedChange | undefined { const tcPr = cell.findChild("w:tcPr"); const marker = tcPr?.children.find( @@ -1668,46 +1709,33 @@ function readCellRevision( ); if (!tcPr || !marker) return undefined; return registerTableRevision( - document, + scope, marker, tcPr.children, marker.tag === "w:cellIns" ? "cellIns" : "cellDel", - tableId, - state, { tableCell: cell, tableCellParent: rowChildren }, ); } +/** A table revision's tag is ambiguous (a row marker is a ``/`` + * like a run-level one), so its reference carries an explicit `kind`, plus the + * row/cell an accept or reject removes. */ function registerTableRevision( - document: Document, + scope: RevisionScope, marker: XmlNode, parent: XmlNode[], kind: TrackedChange["kind"], - tableId: string, - state: WalkState, - scope?: { - tableRow?: XmlNode; - tableRowParent?: XmlNode[]; - tableCell?: XmlNode; - tableCellParent?: XmlNode[]; - }, + extent?: Pick< + ReferenceScope, + "tableRow" | "tableRowParent" | "tableCell" | "tableCellParent" + >, ): TrackedChange { - const trackedChangeId = `tc${state.trackedChangeIndex++}`; - const change: TrackedChange = { - id: trackedChangeId, + return registerTrackedChange( + scope, kind, - author: marker.getAttribute("w:author") ?? "", - date: marker.getAttribute("w:date") ?? "", - revisionId: marker.getAttribute("w:id") ?? "", - }; - document.trackedChangeReferences.set(trackedChangeId, { - node: marker, - parent, - blockId: tableId, - kind, - ...scope, - }); - return change; + { node: marker, parent, kind, ...extent }, + undefined, + ); } function readTableGrid(table: XmlNode): number[] { @@ -1752,19 +1780,9 @@ function readTableCell( ): TableCell { // Register cell-level revisions before content so tcN ids land in document // order: the cellIns/cellDel marker first, then the tcPrChange. - const cellChange = readCellRevision( - document, - cellNode, - rowChildren, - tableId, - state, - ); - const cellPropertyChange = readCellPropertyRevision( - document, - cellNode, - tableId, - state, - ); + const revisionScope: RevisionScope = { document, state, blockId: tableId }; + const cellChange = readCellRevision(revisionScope, cellNode, rowChildren); + const cellPropertyChange = readCellPropertyRevision(revisionScope, cellNode); const cell: TableCell = { blocks: readCellBlocks( document, @@ -1855,22 +1873,13 @@ const CELL_BORDER_EDGES = [ ] as const; function readCellPropertyRevision( - document: Document, + scope: RevisionScope, cell: XmlNode, - tableId: string, - state: WalkState, ): TrackedChange | undefined { const tcPr = cell.findChild("w:tcPr"); const change = tcPr?.findChild("w:tcPrChange"); if (!tcPr || !change) return undefined; - return registerTableRevision( - document, - change, - tcPr.children, - "tcPrChange", - tableId, - state, - ); + return registerTableRevision(scope, change, tcPr.children, "tcPrChange"); } function readCellBlocks( diff --git a/src/core/track-changes/apply.ts b/src/core/track-changes/apply.ts index 21af655..d9e616f 100644 --- a/src/core/track-changes/apply.ts +++ b/src/core/track-changes/apply.ts @@ -111,8 +111,11 @@ function applyResolvedTargets( // Cell removals (accept-cellDel / reject-cellIns) shrink rows without // touching ; bring the grid back in line with the widest row. - resyncTableGrids(document.documentTree); - pruneEmptyHyperlinks(document.documentTree); + // Header/footer parts hold tables and hyperlinks too. + for (const tree of [document.documentTree, ...marginalTrees(document)]) { + resyncTableGrids(tree); + pruneEmptyHyperlinks(tree); + } // Pair body-side note revisions with the reference-side targets we just // applied. For each affected (kind, noteId): if no live reference to it @@ -125,6 +128,12 @@ function applyResolvedTargets( return records; } +function marginalTrees(document: Document): XmlNode[][] { + return [...(document.marginals?.parts.values() ?? [])].map( + (part) => part.tree, + ); +} + /** A revision spread over several markers resolves as each marker would on its * own — accept-del removes every marked math run, reject-del unwraps each — * so the per-node operations below stay the only ones. */ @@ -228,6 +237,13 @@ function changeFoundFromReference( if (reference.tableCell) found.tableCell = reference.tableCell; if (reference.tableCellParent) found.tableCellParent = reference.tableCellParent; + // A header/footer paragraph isn't in `Body.blockReferences`; the reader + // stored its paragraph-mark scope on the reference instead. + if (reference.paragraph && reference.paragraphParent) { + found.paragraph = reference.paragraph; + found.paragraphParent = reference.paragraphParent; + return found; + } // A run-level / whose parent is the owning paragraph's // children is a paragraph-mark tracking — recover the owning // paragraph + its parent so accept-del (merge with next) / reject-ins @@ -355,7 +371,10 @@ function applyReject(target: ChangeFound): void { // following purely-inserted paragraphs splice themselves out, so it // simply re-absorbs its restored content. Keying on "any non-ins child" // instead would wrongly KEEP a fresh insert's hyperlink as an orphan. - if (paragraphHasRestorableContent(target.paragraph)) { + if ( + paragraphHasRestorableContent(target.paragraph) || + isOnlyBlock(target.paragraph, target.paragraphParent) + ) { deleteNode(target.node, target.parent); } else { const idx = target.paragraphParent.indexOf(target.paragraph); @@ -592,6 +611,18 @@ function paragraphHasRestorableContent(paragraph: XmlNode): boolean { ); } +/** A story (a header/footer part, a table cell, a text box) must keep at least + * one block — `` or a paragraph-less `` is not a valid story — + * so rejecting the inserted mark of its only paragraph strips the marker and + * keeps the (now empty) paragraph, as Word keeps a story's final mark. */ +function isOnlyBlock(paragraph: XmlNode, parent: XmlNode[]): boolean { + return !parent.some( + (sibling) => + sibling !== paragraph && + (sibling.tag === "w:p" || sibling.tag === "w:tbl"), + ); +} + /** Restore section properties from a snapshot. The snapshot * lives in `node.findChild("w:sectPr")` and its children are the prior * properties of the parent sectPr. We replace the live siblings (parent diff --git a/tests/cli/headers-footers.test.ts b/tests/cli/headers-footers.test.ts index 279a810..db13f0b 100644 --- a/tests/cli/headers-footers.test.ts +++ b/tests/cli/headers-footers.test.ts @@ -677,6 +677,136 @@ describe("headers/footers — tracked changes", () => { const table = (await runCli("track-changes", "list", path)).stdout; expect(table).not.toContain("paragraph mark"); }); + + // Issue #37: every revision kind the body registers must register in a + // header too, or `accept --all` reports "no tracked changes" and leaves + // pending markup behind. + const paragraphMark = (tag: "ins" | "del", id: number) => + ``; + + test("a deleted header paragraph mark is listed; accept merges the lines", async () => { + const path = await docWithHeaderBody( + "hf-rev-mark-del", + `${paragraphMark("del", 1001)}OneTwo`, + ); + expect(await listed(path)).toMatchObject([ + { id: "tc0", kind: "del", blockId: "hdr0", text: "" }, + ]); + expect(await readMarkdown(path)).toContain('pending="tc0"'); + await runCli("track-changes", "accept", path, "--all"); + const header = await partText(path, "word/header1.xml"); + expect(header).not.toContain(" { + const path = await docWithHeaderBody( + "hf-rev-mark-ins", + `Keep${paragraphMark("ins", 1002)}${insertion(1003, "Added")}`, + ); + expect(await listed(path)).toMatchObject([ + { id: "tc0", kind: "ins", text: "Added" }, + { id: "tc1", kind: "ins", text: "" }, + ]); + await runCli("track-changes", "reject", path, "--all"); + expect((await list("headers", path))[0]?.text).toBe("Keep"); + expect(await partText(path, "word/header1.xml")).not.toContain(" { + const path = await docWithHeaderBody( + "hf-rev-mark-only", + `${paragraphMark("ins", 1006)}${insertion(1007, "New")}`, + ); + await runCli("track-changes", "reject", path, "--all"); + const header = await partText(path, "word/header1.xml"); + expect(header).not.toContain(""); + }); + + test("a header paragraph-property change is listed and reject restores it", async () => { + const path = await docWithHeaderBody( + "hf-rev-ppr", + `Title`, + ); + expect(await listed(path)).toMatchObject([ + { + kind: "pPrChange", + blockId: "hdr0", + prior: { alignment: "right" }, + current: { alignment: "center" }, + }, + ]); + await runCli("track-changes", "reject", path, "--at", "tc0"); + const header = await partText(path, "word/header1.xml"); + expect(header).not.toContain("pPrChange"); + expect(header).toContain(''); + }); + + test("a paragraph mark in a header text box story is listed once and accepted in both branches", async () => { + const box = wordTextBoxParagraphXml(["One", "Two"]) + .replace(/^]*>/, "") + .replace( + /One/g, + `${paragraphMark("del", 1005)}One`, + ); + const path = await docWithHeaderBody("hf-rev-textbox-mark", box); + expect(await listed(path)).toMatchObject([ + { id: "tc0", kind: "del", blockId: "hdr0", text: "" }, + ]); + await runCli("track-changes", "accept", path, "--all"); + const header = await partText(path, "word/header1.xml"); + expect(header).not.toContain("<\/w:r>]*>Two/g), + ).toHaveLength(2); + }); + + test("table revisions in a header are listed in body order and applied", async () => { + const cell = (text: string, tcPr = "") => + `${tcPr}${text}`; + const path = await docWithHeaderBody( + "hf-rev-table", + `` + + `` + + `${cell("Logo")}${cell("Old col", ``)}` + + `${cell("New row", ``)}${cell("B")}` + + ``, + ); + expect(await listed(path)).toMatchObject([ + { id: "tc0", kind: "tblPrChange", blockId: "hdr0" }, + { id: "tc1", kind: "tblGridChange", blockId: "hdr0" }, + { id: "tc2", kind: "cellDel", blockId: "hdr0" }, + { id: "tc3", kind: "rowIns", blockId: "hdr0" }, + { id: "tc4", kind: "tcPrChange", blockId: "hdr0" }, + ]); + expect(await readMarkdown(path)).toContain('pending="tc0 tc1 tc2 tc3 tc4"'); + + // Reject the row insertion; accept the column deletion (the grid resyncs). + await runCli( + "track-changes", + "apply", + path, + "--accept", + "tc0", + "tc1", + "tc2", + "--reject", + "tc3", + "tc4", + ); + const header = await partText(path, "word/header1.xml"); + expect(header).not.toContain("Change"); + expect(header).not.toContain("cellDel"); + expect(header).not.toContain("New row"); + expect(header).not.toContain("Old col"); + expect(header).toContain(''); + expect(header.match(/ {