Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,12 @@ size="…in" margins="…in" text-width="…in" -->` note when the page deviates
data-bound regions) are transparent: the paragraphs and tables inside one read
as ordinary `pN`/`tN` blocks and edits land inside the control, with a
`content-control="Governing Law"` pair on the block's note so you know Word may
have locked it.
have locked it. An INLINE control (a form's checkbox, a Plain Text or Dropdown
field inside a paragraph) reads as its text in that paragraph β€” a checkbox as its
`☐`/`β˜’` glyph β€” so `find`/`replace`/`comments add` reach it, and a `--text`
rewrite that restates the text keeps it inside the control. `docx tasks
check`/`uncheck` toggle a task-list item's box; a checkbox elsewhere has no
toggle verb yet, and replacing its glyph changes only what is displayed.
- **Images** trail a `<!-- docx:image img0 size="6.2x4.1in" float="yes" wrap="square" align="center" overflow="yes" -->`
note: `size` always (the `![](hash)` alone doesn't say "6in wide"), and
`float`/`wrap`/`align`/`overflow` only when they deviate (an inline, in-bounds
Expand Down
2 changes: 1 addition & 1 deletion src/cli/track-changes/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Footnote/endnote trackings span two parts. **Empirically validated against Micro

**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 `<w:ins>`/`<w:del>`/`<w:moveFrom>`/`<w:moveTo>` 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 `<w:sdt>` (Word's page-number gallery), tables, and text boxes β€” and descends an `<mc:AlternateContent>` 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.

**Checkbox toggles** in `<w14:checkbox>` 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 `<w:ins>` (new glyph β˜’ or ☐) and `<w:del>` (old glyph) pair INSIDE `<w:sdtContent>`, and flips the `w14:checked` attribute in place β€” no `<w14:checkedChange>` 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 `<w:customXmlDel/InsRangeStart/End>`) 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.
**Checkbox toggles** in `<w14:checkbox>` 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 `<w:ins>` (new glyph β˜’ or ☐) and `<w:del>` (old glyph) pair INSIDE `<w:sdtContent>`, and flips the `w14:checked` attribute in place β€” no `<w14:checkedChange>` element exists. The detection + accept/reject live in [src/core/task-list/](../../core/task-list/); the AST reader calls `findCheckboxToggle` β€” `detectTaskListState` for a task item's leading box, `walkInlineContentControl` for any other inline checkbox (#58) β€” and registers the `checkboxToggle` `tcN` into `document.trackedChangeReferences` (the single source apply reads back; `list` takes the kind from there, since the inline box's glyph runs carry the id in the `ins`/`del` roles). 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 `<w:customXmlDel/InsRangeStart/End>`) 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.

`<w:pPrChange>` IS modeled (the paragraph analog of `sectPrChange`): every PARAGRAPH-property edit β€” `edit … --style/--alignment/--space-*/--line-spacing/--indent-*/--first-line/--hanging/--tabs`, on `insert` too β€” snapshots the prior `<w:pPr>` into a `<w:pPrChange>` (its last child) under tracking, so accept drops the marker (keeping the new props) and reject restores the prior pPr. This holds whether the props are set ALONE (`Edit.paragraphProperties` β†’ `wrapPprChange` before `applyParagraphOptionsInPlace`) OR ride along with a content edit: the preserve path (`applyFormattingPreservingEdit` in `core/track-changes/replace.tsx`) `wrapPprChange`s the live pPr before mutating it; the non-preserve path (`Edit.paragraph`, fresh-built paragraph) `injectPprChange`s the OLD pPr snapshot onto the new pPr (since `wrapPprChange` snapshots a node's CURRENT children, already the new props there). `wrapPprChange` (in `core/blocks.tsx`) mirrors `wrapSectPrChange`. **Empirically validated against Microsoft Word for Mac** β€” Word emits one `<w:pPrChange>` per paragraph snapshotting the prior pPr for ANY property change, and Word's accept of our output matches our own. **The snapshot EXCLUDES an inline `<w:sectPr>`**: CT_PPrChange's inner pPr is CT_PPrBase, which forbids sectPr (cloning it = Word "unreadable content" + a duplicated section break); a section change is tracked separately via `<w:sectPrChange>`. Reject reuses the generic `restorePropertySnapshot(node, parent, "w:pPr", ["w:sectPr"])` β€” the snapshot's inner `<w:pPr>` is the prior pPr (sans sectPr) and `parent` is `pPr.children`, so the replace is wholesale EXCEPT the live `<w:sectPr>` is carried forward (else reject would drop the section break). `list` enriches it with `prior`/`current` paragraph-property summaries like `sectPrChange`.

Expand Down
6 changes: 5 additions & 1 deletion src/cli/track-changes/list-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,13 @@ export function collectTrackedChangeRecords(
// Explicit fields, not a spread: `TrackedChange.within` (the enclosing
// wrapper chain of a nested revision) is an AST/view concern and stays
// off the `list` record so its shape is the same for every change.
// A checkbox toggle's glyph runs ride its id in the ins/del ROLES (so
// every view shows the right glyph); the revision's own kind is the
// reader's `checkboxToggle` entry.
byId.set(change.id, {
id: change.id,
kind: change.kind,
kind:
document.trackedChangeReferences.get(change.id)?.kind ?? change.kind,
author: change.author,
date: change.date,
revisionId: change.revisionId,
Expand Down
2 changes: 1 addition & 1 deletion src/core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The typed AST from `read` is a *view* over the parsed XML tree. Mutations operat

## RUN_BEARING_WRAPPER_TAGS β€” the AST↔XML offset bridge

Defined in [parser/run-ops.ts](parser/run-ops.ts). AST text and `find`'s offsets descend into every tag in this set; the XML-side walkers in [comments/markers.tsx](comments/markers.tsx), [find/replace-span.tsx](find/replace-span.tsx), and [hyperlinks/wrap.tsx](hyperlinks/wrap.tsx) all do the same via `isRunBearingWrapper(tag)` / `sumRunBearingTextLength(children)`. They must stay in sync β€” if the AST descends into a wrapper the XML walkers don't (or vice versa), `find β†’ replace` / `find β†’ comments add` misaligns by the wrapper's inner-text length. Current set: `<w:ins>`, `<w:del>`, `<w:moveFrom>`, `<w:moveTo>`, `<w:hyperlink>`, `<w:fldSimple>`, `<w:smartTag>`, `<mc:AlternateContent>`. **A walker reaches a wrapper's runs through `wrapperContent(node)` / `wrapperContentNode(node)`, never `node.children`** β€” for `<mc:AlternateContent>` that is the chosen branch (first Choice, else Fallback; the SAME node the reader walked), for everything else the node itself. A split-off half goes back through `rewrapSplitHalf(wrapper, inner)`: a plain wrapper keeps its tag on both halves, an AlternateContent can't be halved into two Choice/Fallback pairs so its runs come back BARE (the content Word rendered anyway). Any tag NOT in the set is preserved by the catchall `push(child)` in every walker (see `tests/cli/invariants.test.ts`).
Defined in [parser/run-ops.ts](parser/run-ops.ts). AST text and `find`'s offsets descend into every tag in this set; the XML-side walkers in [comments/markers.tsx](comments/markers.tsx), [find/replace-span.tsx](find/replace-span.tsx), and [hyperlinks/wrap.tsx](hyperlinks/wrap.tsx) all do the same via `isRunBearingWrapper(tag)` / `sumRunBearingTextLength(children)`. They must stay in sync β€” if the AST descends into a wrapper the XML walkers don't (or vice versa), `find β†’ replace` / `find β†’ comments add` misaligns by the wrapper's inner-text length. Current set: `<w:ins>`, `<w:del>`, `<w:moveFrom>`, `<w:moveTo>`, `<w:hyperlink>`, `<w:fldSimple>`, `<w:smartTag>`, `<mc:AlternateContent>`, and an inline `<w:sdt>` content control. **A walker reaches a wrapper's runs through `wrapperContent(node)` / `wrapperContentNode(node)`, never `node.children`** β€” for `<mc:AlternateContent>` that is the chosen branch (first Choice, else Fallback; the SAME node the reader walked), for `<w:sdt>` its `<w:sdtContent>`, for everything else the node itself. A split-off half goes back through `rewrapSplitHalf(wrapper, inner)`: a plain wrapper keeps its tag on both halves, an `<w:sdt>` half keeps its `<w:sdtPr>` (minus the must-be-unique `<w:id>`) around a fresh `<w:sdtContent>`, an AlternateContent can't be halved into two Choice/Fallback pairs so its runs come back BARE (the content Word rendered anyway). Any tag NOT in the set is preserved by the catchall `push(child)` in every walker (see `tests/cli/invariants.test.ts`).

## Markup compatibility (`mc:AlternateContent`) and text boxes

Expand Down
2 changes: 1 addition & 1 deletion src/core/ast/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
2. **Build the AST and Body's maps** β€” `walkRunContainer` descends `<w:body>`, building `Paragraph.runs` (TextRun / ImageRun / EquationRun / …) and writing `blockReferences` / `imageById` / `hyperlinkById` / `equationReferences` as it goes.
3. **Register every tracked change into `Document.trackedChangeReferences`** β€” run-level wrappers (`<w:ins>`/`<w:del>`/`<w:moveFrom>`/`<w:moveTo>`), paragraph-mark wrappers in `<w:pPr><w:rPr>` (both top-level AND cell paragraphs), `<w:sectPrChange>`, table-structural revisions (`rowIns`/`rowDel`/`cellIns`/`cellDel`/`tblGridChange`/`tblPrChange`/`tcPrChange`), and `checkboxToggle` SDTs. After the body walk, `readNoteTrackedChanges` appends standalone footnote/endnote edits (paired notes β€” those with a reference-side wrapper in the body β€” are hidden so the change surfaces as one tcN).

**Block-level `<w:sdt>` (Word content controls) are TRANSPARENT to the block walk.** `collectBlocks` descends into `<w:sdtContent>` and reads the `<w:p>`/`<w:tbl>` inside as ordinary blocks β€” same shared `pN`/`tN`/`sN` counters (so ids stay sequential in document order across the boundary), and each `BlockReference.parent` is the sdtContent's own child array, so an `edit`/`insert`/`delete` splices INSIDE the control. Without this an entire control β€” a clause, a form field, a data-bound region β€” is absent from the AST: `read` can't show it, `wc` can't count it, and a `read β†’ create` rebuild silently drops it, while the bytes sit happily on disk. (Our raw gate already refuses to WRITE a block-level `<w:sdt>` for exactly that reason; inbound Word files carry them regardless.) Each such block carries `contentControl` (`alias`/`tag`) so `read` can still say "you're editing inside a control" β€” Word may LOCK one against edit or deletion, so a change that succeeds here can be refused in the UI. **RUN-level `<w:sdt>` stays skipped** by `walkRunContainer`: descending there would surface nested `<w:ins>`/`<w:del>` as run-level `tcN` ids with no sensible accept/reject scope, and the checkbox shape has its own recognizer ([core/task-list](../task-list/CLAUDE.md)).
**Block-level `<w:sdt>` (Word content controls) are TRANSPARENT to the block walk.** `collectBlocks` descends into `<w:sdtContent>` and reads the `<w:p>`/`<w:tbl>` inside as ordinary blocks β€” same shared `pN`/`tN`/`sN` counters (so ids stay sequential in document order across the boundary), and each `BlockReference.parent` is the sdtContent's own child array, so an `edit`/`insert`/`delete` splices INSIDE the control. Without this an entire control β€” a clause, a form field, a data-bound region β€” is absent from the AST: `read` can't show it, `wc` can't count it, and a `read β†’ create` rebuild silently drops it, while the bytes sit happily on disk. (Our raw gate already refuses to WRITE a block-level `<w:sdt>` for exactly that reason; inbound Word files carry them regardless.) Each such block carries `contentControl` (`alias`/`tag`) so `read` can still say "you're editing inside a control" β€” Word may LOCK one against edit or deletion, so a change that succeeds here can be refused in the UI. **RUN-level `<w:sdt>` is a transparent run-bearing wrapper too** (#58 β€” skipping it hid every form checkbox and Plain Text field value from `read`/`find`/`wc`): `walkInlineContentControl` descends its `<w:sdtContent>` (the node `wrapperContent` hands the XML offset walkers), so a revision inside registers a `tcN` whose `parent` is the sdtContent's child list and accept/reject splice there. One exception keeps a checkbox honest: a box Word clicked under tracking (`findCheckboxToggle` β€” ins/del glyph pair + flipped `w14:checked`) registers ONE `checkboxToggle` `tcN`, and its two glyph runs ride that id in the `ins`/`del` roles so every view shows the right glyph (`track-changes list` reads the kind off the reference). A task-list item's LEADING checkbox never reaches the walk β€” `detectTaskListState` skips it and `read` prints `- [ ]`/`- [x]` ([core/task-list](../task-list/CLAUDE.md)). A split half of a control (`rewrapSplitHalf`) keeps its `<w:sdtPr>` minus `<w:id>`, and the `--text` diff rebuilds kept text inside the control (`keptWrapper` in `track-changes/preserve-formatting.tsx`).

**`<mc:AlternateContent>` is transparent at every level too** (see [core/CLAUDE.md](../CLAUDE.md#markup-compatibility-mcalternatecontent-and-text-boxes)): block-level wrappers walk their chosen branch with the branch's child list as `parent` (like `<w:sdtContent>`); paragraph-level ones re-enter `walkRunContainer` with the branch as the container; run-level ones (Word's shape/text-box shape) are flattened inside `readRun`. **Text boxes** β€” any `<w:txbxContent>` under a `<w:drawing>`/`<w:pict>` β€” become a `TextBoxRun` on the anchor paragraph (`readTextBoxes`): each story is walked by `collectScopedBlocks` under a fresh `tbxN` prefix (global document order, `WalkState.textBoxIndex`), its paragraphs land in `blockReferences` with the `<w:txbxContent>` child list as parent, and `Body.textBoxReferences` records the story node + `anchorBlockId` (`WalkState.currentBlockId`, saved/restored around the nested walk). Tracked changes inside a story register `tcN` ids in the same single walk.

Expand Down
Loading
Loading