Repository navigation
Description
Depends on #41 (basic field handling); field preservation in edit is #42. That issue owns parsing fldChar/instrText/fldSimple into a field model, how a field reads in Markdown and the AST, and keeping edit from flattening fields to frozen text. This issue builds on it and covers three things: bookmarks as addressable targets, authoring cross-references (REF / PAGEREF / NOTEREF), and internal links (<w:hyperlink w:anchor>). The field model is not redesigned here.
Problem
Word uses bookmarks (<w:bookmarkStart w:id w:name> … <w:bookmarkEnd w:id>) for a lot of everyday features: cross-references ("see Section 4.2", "Table 3", "on page 12"), caption numbering (SEQ fields plus hidden _Ref… bookmarks), the TOC (_Toc… bookmarks plus PAGEREF), and clickable internal links. docx-cli can't see them, so it breaks them without warning, and it can't author them.
All of the following was verified on main @ e176d62 (0.29.0). Renders used LibreOffice except where marked.
1. Bookmarks and cross-references are invisible
tests/fixtures/multi-column.docx (Word-authored ACM template) has 17 bookmarks (_GoBack plus 16 _Ref…), 19 REF _Ref… \r \h cross-reference fields and 3 SEQ caption fields. read and read --ast contain none of them: the AST JSON has no bookmark, no _Ref and no field. p34's "referenced by numbers in brackets [1, 3, 4]" is three live REF fields. It reads as plain text, so nothing tells an agent the "4" will renumber, or that p97–p106 are being pointed at.
The real-world shapes in that one file:
- Several bookmarks on one paragraph.
- Bookmarks that start in one paragraph and end in the next (ids 3/4).
- Overlapping and nested bookmarks (ids 8/10, 9/11/12).
- A body-level
<w:bookmarkEnd>sitting between two<w:p>s (the "Table 1" caption's bookmark). - A pre-existing dangling REF:
_Ref279752133has no bookmark anywhere. LibreOffice renders the pristine fixture with 2 × "Error: Reference source not found".
Google Docs exports add hidden _xxxxxxxx heading bookmarks (scripts/data/analysis.docx has 27).
2. Deleting or editing a referenced paragraph silently breaks every reference to it
Reference [8] (p103, "Psy. 2012. Gangnam Style…") holds bookmark _Ref279752219, which p37 and p93 cite.
| Command | Exit | validate |
Bookmark afterwards | LibreOffice render |
|---|---|---|---|---|
delete --at p103 |
0 | valid | gone | "Error: Reference source not found" count goes 2 → 4 ("online videos Error: Reference source not found") |
edit --at p103 "Psy. 2013. *Gangnam Style*. Video." (markdown) |
0 | valid | gone, so the same dangling REFs | same |
edit --at p103 --text "Psy. 2013. Gangnam Style. Video." |
0 | valid | bookmarkStart kept, bookmarkEnd dropped (it sat inside the <w:hyperlink> the rebuild removed), leaving an unpaired start |
— |
replace "Psy. 2012. Gangnam" "PSY 2012 Gangnam" |
0 | valid | intact | fine |
read keeps showing the stale cached "[8]", so the agent's write-read loop says everything is fine. Word shows "Error! Bookmark not defined." in the printed or exported document. I verified that with a minimal PAGEREF to a missing bookmark through docx render --engine word, which goes through Word's PDF export.
3. Internal links don't survive the write-read loop
read prints a w:anchor link as [text](#name) (cli/read/markdown.ts). The Markdown importer then turns [the intro](#Introduction) into an external relationship: <Relationship … Target="#Introduction" TargetMode="External"/> (verified through create --from; edit/insert use the same wrapHyperlink in core/markdown/inline.tsx). Copying a TOC line or an internal link out of read and passing it back through edit turns a working in-document jump into a broken external URL.
4. None of it can be authored
hyperlinks addtakes--urlonly.- There's no cross-reference verb.
images add --caption "Figure 1: Revenue"writes a Caption-style paragraph with the literal text "Figure 1" and no SEQ field (grep -rn SEQ srcfinds nothing), so our captions neither renumber nor can be targeted by number.raw insertcan't author a bookmark either (see the related bug at the bottom).
Proposal
Read: show what's addressable, and what's referenced
Word's own rule decides visibility: a name starting with _ is a hidden bookmark (_GoBack, _Toc…, _Ref…, Google's _xxxx), shown in Word's Bookmark dialog only with "Hidden bookmarks" ticked.
- User-named bookmark → a deviation-only
docx:hint after the locator of the paragraph where it starts:<!-- docx:bookmark Methods -->. Addspan="S-E"when it doesn't cover the whole paragraph andto="pM"when it ends in a later block. - Hidden bookmark that something points at (a REF/PAGEREF/NOTEREF instruction, a
w:anchor, a TOC\b) → don't print the machine name; say what matters to an agent about to delete or rewrite the paragraph:<!-- docx:referenced-by p37 p93 -->. - Hidden bookmark nothing points at (
_GoBack, orphan_Refs, Google ids) → nothing. That's noise. - A reference whose target doesn't exist (the fixture's
_Ref279752133) must be flaggedbrokenon whatever hint the field issue gives a REF, because it's the one state where the cached text is a lie. - AST:
Document.bookmarks: Bookmark[]={ name, hidden, start: { block, offset }, end: { block, offset }, referencedBy: string[] }, plus a resolvedtargetlocator on the field issue's REF/PAGEREF/NOTEREF field and onHyperlink.anchor. The reader stays the sole minter, andBody.bookmarkByNameholds live{ start, end, startParent, endParent }refs. eval:read: the fixture's References list gains about tendocx:referenced-byhints. That's useful signal, but check the snapshot diff andsignalRatio. If one paragraph carries several hidden bookmarks, merge them into one hint.
Handle: the bookmark NAME, via flags, not a new --at shape
Use the name as the handle rather than a positional bkN:
- It is spec-unique.
- It is what a Word user sees and types (Insert › Bookmark).
- It doesn't shift after structural edits, unlike every positional id. That is exactly the property a cross-reference target needs.
readalready prints it in(#Name)link targets.
Keep it out of the --at grammar. A legal Word bookmark name can be p3 or t0, so it would collide with locators. #Name would mirror the Markdown, but an unquoted # starts a shell comment, so --at #Methods turns into --at with no value. Pass the name through dedicated flags (--name, and a name accepted by --to). bookmarks list items carry name (the handle) plus at (the resolved current locator).
Validate names the way Word does: they start with a letter, contain only letters, digits and _, and are at most 40 characters. Reject "Section 4" with a hint suggesting Section_4. A user-facing name may not start with _; that prefix is reserved for the hidden ones we mint.
CLI surface
docx bookmarks list FILE [--hidden] [--json]
docx bookmarks add FILE --at LOCATOR --name NAME # pN | pN-pM | pN:S-E | tN:rRcC:pK[:S-E]
docx bookmarks rename FILE --name OLD --to NEW # rewrites every REF/PAGEREF/NOTEREF/TOC \b instr and w:anchor that names OLD
docx bookmarks delete FILE --name NAME [--unlink-refs] # refuses while referenced (see Integrity)
docx cross-refs add FILE (--at pN:OFFSET | --at pN:S-E | --anchor PHRASE) --to TARGET
[--show text|number|full-number|page|label|above-below|note]
[--no-link] [--track] [--author NAME] [--batch FILE.jsonl]
docx hyperlinks add FILE --at pN:S-E --to TARGET # internal link (w:anchor); --url stays the external form
--to TARGET takes content, not bookmark plumbing. This is the core ergonomics decision. The natural weak-agent request is "add 'see Section 3' that links to that heading", and the agent already holds that heading as p12 from read/outline. So --to accepts:
| TARGET | What we do (mirrors Word's Insert › Cross-reference) | Default --show |
|---|---|---|
pN heading or numbered paragraph (any depth: tN:rRcC:pK) |
Reuse a bookmark that exactly covers its text; otherwise mint a hidden _Ref<9 digits> around the paragraph's text (not the list label, which isn't text) |
number if the paragraph is numbered, else text |
pN caption with a SEQ field |
bookmark the label+number ("Table 1", as Word does in the fixture) for label, the whole caption for text |
label |
imgN / tN |
resolve to its caption paragraph; refuse with a hint pointing at images add --caption when there isn't one |
label |
fnN / enN |
_Ref bookmark around the reference mark; emit NOTEREF |
note |
| a bookmark NAME | that bookmark (locator grammar wins on a clash, with a warning) | text |
The agent never invents or tracks a bookmark name. bookmarks add exists for the "make a named anchor" request and for rename/delete hygiene.
--show maps to Word's switches:
text=REF x \hnumber=REF x \r \h(relative context: "4.2" from outside chapter 4, "2" from inside it)full-number=\wpage=PAGEREF x \habove-below=REF x \p \hlabel= REF to the caption's label bookmarknote=NOTEREF x \h
\h (click to jump) is on by default, as it is in Word; --no-link drops it. "See Section 4 on page 12" is two entries, so --batch ({ "at", "to", "show" }, locators as read) is the normal path.
Placement:
--at pN:OFFSETinserts at a point (the footnotes point form).--at pN:S-Ereplaces a span.--anchor PHRASEfinds the phrase and replaces the part of it the field stands for. Fornumber/label/pagethat is the phrase's trailing number token, so--anchor "Section 4" --show numberkeeps "Section " as text and makes "4" the live field. Fortextit is the whole phrase.
Converting an existing literal ("Section 4", typed by hand) into a live reference is the most common real request, so this path has to work without hand-counted offsets. The ack echoes what became the field and its computed value, e.g. cross-ref p40:31-32 → p12 "4", so a stale literal ("Section 3" while the clause is now 4) is visible in the exit output.
Add a top-level docx --help one-liner for cross-refs (discoverability), and mention it in the insert/edit "for a … use" pointer lists.
Cached results: compute what we know; never ask Word to prompt
A new field needs a cached result, because read shows it and Word displays it on open until fields update. Empirical renderer behavior, from a scratch doc with REF/PAGEREF/SEQ given deliberately wrong cached values ("WRONGTEXT", "99", "7"):
- LibreOffice ignores the cache entirely and recomputes REF, PAGEREF and SEQ on load. It made no difference whether
<w:updateFields>was set: output was "Methods and Materials", "2", "Figure 1 and Figure 2", and a dangling REF became "Error: Reference source not found". - Word's PDF export (our
render --engine wordpath) also recomputed all three, with identical output.\rto an unnumbered heading gave "0". On-screen display of the stale cache before an update isn't verified. w:dirty="true"on a field'sfldCharmakes Word pop a modal on open: "This document contains fields that may refer to other files. Do you want to update the fields in this document?" It blocked Word for Mac (our render automation hit an AppleEvent timeout) until a human clicked.<w:updateFields w:val="true"/>in settings is the document-wide form of the same prompt, and it asks EVERY person who opens the document. Neither may be our default. At most, an explicit opt-in (--update-on-open) whose help names the cost.
So:
- text / number / full-number / label / above-below / note: compute them ourselves, since we have the data. This needs a real list-label formatter: evaluate
lvlText(%1.%2) with per-level counters, including heading numbering inherited through a style'snumPr. The MarkdownorderedOrdinalis a flat counter and can't do "4.2(a)". It also needs SEQ counters (per identifier, document order, honoring\r/\c/\h) and footnote ordinals. Match Word's cached value, not LibreOffice's: the fixture's references list haslvlText="[%1]", and Word cached\ras "1" (brackets stripped), while LibreOffice renders "[1]" (its output reads "[[1], …"). - page: can't be known without layout. Best effort: count
w:lastRenderedPageBreak(Word's last-saved pagination;multi-column.docxhas 3), explicit page breaks and page-starting section breaks before the target. That is right for an unedited Word file, and stale after edits. When there's no pagination data, write1. Mark page results as estimated in the hint (page≈). Both renderers recompute at print/export, so this only shows up on screen in Word before an update. - Re-computation on later edits (a renumbered list, a retitled heading) belongs to the field issue's "refresh results" story. Note it there.
Integrity
- Never create a dangling reference.
delete(untracked),edit(whole-paragraph),move(feat(move): relocate blocks in place — untracked splice + tracked w:moveFrom/w:moveTo #38) andtrack-changes acceptof a deletion check whether the removed range contains a bookmark that something references.- If it does: refuse with
REFERENCED(exit 1), and a hint listing the referencing locators plus the one-flag cure--unlink-refs. That converts each referencing field to its cached text (Word's Ctrl+Shift+F9), so no "Error!" is ever printed. - Why refuse rather than auto-cure: the damage is invisible in
readand only shows in print, and deleting a cited reference usually means the citations need rethinking. Exit code is the signal weak agents act on. (Open question 1.) - Pre-existing dangling REFs (the fixture has one) must not block anything. The check is "don't ADD one".
- Edits keep bookmarks. Whole-paragraph
edit(--markdownand--text) lifts the paragraph's bookmark markers and re-attaches them the way it already handles comment ranges. A bookmark that covered the whole paragraph still covers it; a partial one clamps to the surviving text. This fixes the dropped/unpaired cases in table 2 even for unreferenced bookmarks. Rangedelete/cross-paragraphreplaceshrink a partially covered bookmark to what survives. Remove it only when it ends up empty and unreferenced. - Body-level markers. A
<w:bookmarkStart>/<w:bookmarkEnd>can be a direct child of<w:body>,<w:tc>or<w:sdtContent>between blocks (verified in the fixture). Splices that delete or move blocks must treat an adjacent body-level marker as part of the block it closes or opens, not leave it stranded or delete it by accident. - Uniqueness.
- Names must be unique.
bookmarks addrejects an existing name, and minted_Refnames are random 9-digit like Word's, collision-checked. w:idis numeric and unique across the whole package (body, notes, headers/footers, comments). Mintmax + 1from one scan.- The raw gate's id re-mint already exists, and has a pairing bug (below).
- Names must be unique.
- Tables. A bookmark inside one cell paragraph is ordinary. Read and list cell-spanning and column bookmarks (
w:colFirst/w:colLaston abookmarkStartin a<w:tr>), but refuse to author them in v1. - Text boxes / notes / headers. Refuse
bookmarks addandcross-refs add --toon atbxNtarget in v1 until we know whether Word keeps a bookmark inside a box: it discards comments and notes there, per the root CLAUDE.md. A REF placed inside a footnote to a body heading is common and should work. Header/footer REFs are out of scope. - Tracked changes. OOXML has no bookmark revision, and Word inserts bookmarks untracked.
- Minting the hidden
_Refis therefore silent even under tracking. - The cross-reference text is an insertion: the field's runs go inside
<w:ins>(or--at S-Eproduces<w:del>of the literal +<w:ins>of the field), so a reviewer sees it. bookmarks add/rename/deleteunder doc-level tracking follow the hyperlinks precedent, with a[docx-cli]audit comment.hyperlinks add --tounder tracking: same audit comment as--url.- Verify the tracked field shape against Word with
scripts/word-redlines.shusing test files that set neitherw:dirtynorupdateFields(they block Word with the modal above), and only when someone can dismiss a dialog.
- Minting the hidden
renamerewrites every instruction that names the bookmark, in every story (body, notes, comments, headers/footers), plusw:anchors. Refuse when NEW exists.
Internal links
hyperlinks add --to TARGETwrites<w:hyperlink w:anchor="NAME" w:history="1">with no relationship, minting a hidden_Ref/reusing a bookmark exactly as cross-refs do.hyperlinks listreportsanchorplus the resolvedat.hyperlinks replace --at linkN --to TARGETretargets.- Fix the importer:
[text](#Name)(and#_Toc…) becomesw:anchor, never an external rel. A#Namewith no such bookmark is an error that names the available bookmarks, never a silent external link. That restores the read → edit round-trip for TOCs and internal links.
Captions (scope boundary)
Cross-references to figures and tables need SEQ-numbered captions. Make images add --caption "Figure 1: Revenue" (and a table caption, if one lands) emit Figure {SEQ Figure \* ARABIC} when the caption starts with Label N. It then renumbers and can be targeted with --show label. Whether cross-refs --to a plain-text caption ("Figure 1" typed by hand) should convert the leading Label N into a SEQ field on the fly is open question 3. TOC authoring stays out of scope (raw only), but it reuses this bookmark + PAGEREF machinery.
Implementation
- Reader (
core/ast/read.ts): collect bookmark markers in the existing single walk, including body/cell-level markers, markers inside<w:hyperlink>/<w:ins>/<w:sdtContent>/mc:AlternateContentbranches, and text-box stories (under theirtbxNscope). PopulateDocument.bookmarks+Body.bookmarkByName. Resolve REF/PAGEREF/NOTEREF targets once the field issue's model exists. Markers stay zero-width in every offset walker; they're already in the inert sets (markdown-as-text-edit.ts,replace-across.tsx,groups.ts). Bookmarkslens (src/core/bookmarks/): add / rename / delete /ensureFor(target)(reuse or mint_Ref). It spans body + every story for rename, so it's a lens, not a view.CrossReferenceslens (src/core/cross-refs/): resolves--to, callsBookmarks.ensureFor, computes the result, and emits the field through the field issue's shared complex-field emitter (begin/instrText/separate/result runs/end, result runs inheriting the surrounding run'srPr). The CLI shells aresrc/cli/bookmarks/andsrc/cli/cross-refs/.- Result computation: a shared list-label formatter (
core/lists/), SEQ counting, footnote ordinals, pagination estimate. The field issue's "refresh cached results" verb would reuse these. - Integrity: a
referencedBookmarks(range)check, shared by delete / edit / move / accept. Lift/reattach bookmark markers in whole-paragraphedit, next to the comment-range handling. - Hyperlinks:
Hyperlinks.add({ anchor }),replace --to, and theinline.tsx#namefix.
Tests and coverage
- Unit
readhints: user bookmark,referenced-by, nothing for_GoBack,brokenfor the fixture's dangling REF.- AST bookmarks: multi-paragraph, overlapping, body-level end.
delete/edit/accept refuse a referenced paragraph, and--unlink-refsfreezes the refs (LibreOffice shows no "Error:").- Whole-paragraph
editkeeps bookmark pairs (both table-2 cases). cross-refs addfor each TARGET ×--show, with computed results matching Word's cached values inmulti-column.docx(\ron[%1]→ "1").--anchor "Section 4"makes only "4" the field.hyperlinks add --toand the[x](#Name)import round-trip.renamerewrites every instruction.- Tracked cross-ref insert accepts and rejects cleanly.
- The raw-gate pairing regression.
- Fixture: the field tests use the committed Word-authored
multi-column.docxas-is. It isn't built by a setup script, which is why it's the ground truth for "what Word writes". For dogfooding, fold into an existing builder:tests/fixtures/setup/images.ts(it already exercisesimages add) gains--caption "Figure 1: …"with a SEQ field, a heading, thencross-refs add --to img0 --show label,--to pN --show page, andhyperlinks add --to pN. Then it round-trips through LibreOffice. Check the LibreOffice text shows the computed values and no "Error: Reference source not found". - Weak-agent scenario: fold into
contract-markup. Its clauses are numbered list items ("2. Services and Scope."), the textbook "paragraph number" cross-reference.task.mdsentence: "Where the revised indemnification clause mentions the liability cap, refer to that clause by its section number as a live cross-reference, so it stays right if the clauses get renumbered."criteria.md: aREF _Ref… \r \hwhose bookmark covers the Limitation of Liability paragraph, the cached result equals its list number, it's inside the tracked insertion, and no literal-only "Section N".- The second-best fit is
eliot-journal("in the editor's note, refer to the frontispiece by its figure number").
- Docs: REFERENCE.md (bookmarks, cross-refs,
hyperlinks add --to),src/cli/CLAUDE.md,docx info locators(the name handle and--toforms), and the skill's common workflows.
Open questions
- Deleting a referenced target: refuse +
--unlink-refs(proposed), or "fix, don't hint" and auto-unlink with a printed note? Auto-unlinking freezes "[8]" pointing at nothing, while Word would have renumbered on update. That argues for making the agent decide. - Hint shape:
docx:referenced-by p37 p93on the target vs areferencedtoken on the existingdocx:pnote. Settle it byeval:readsignal ratio onmulti-column.docx. - Plain-text captions: should
cross-refs --toon a caption that was typed by hand ("Figure 1") convert its leadingLabel Ninto a SEQ field in place (deterministic, and makes the reference correct), or refuse with a hint? - Page placeholder: a
lastRenderedPageBreakestimate vs a constant1vs a visible?. All are replaced at print/export by both renderers; the difference only shows on screen in Word before an update. - Bookmarks inside text boxes: does Word keep them on save? Check with a test file that has no dirty fields, then lift the v1 refusal if so.
Related bug (should be its own issue): raw insert splits every fresh bookmark pair
remintBookmarkIds in src/core/raw/references.ts records the first sight of a non-colliding id in context.bookmarkIds but not in remapped. When the matching bookmarkEnd arrives, the id "collides" with its own start, so the end gets re-minted.
Repro: raw insert --xml '<w:p><w:bookmarkStart w:id="100" w:name="_Ref100"/><w:r><w:t>Methods</w:t></w:r><w:bookmarkEnd w:id="100"/></w:p>' lands as start w:id="100" + end w:id="0". validate passes, and a REF to _Ref100 renders "Error: Reference source not found" in LibreOffice. The existing test (tests/core/raw.test.ts "colliding bookmark ids re-map as a linked pair") only covers the colliding path.
Fix: on first sight, record remapped.set(id, id) so the partner reuses it. A whole-block raw edit/raw replace happens to re-pair correctly, because the replaced block's ids are pre-registered and both markers go through the remap.
Activity
kklimuk commentedon Nov 6, 2026
🤖 docx-cli triage
I reproduced the problems on
main@ e830c0c and split the issue into what can ship now and what can't.Shipped as a first slice: #49 (ready for review, not a full fix).
editnow keeps the paragraph's bookmarks. On p103 ofmulti-column.docx, the--markdownedit used to leave 16 of 17 pairs, and--textleft 17 starts but only 16 ends. Now both keep all 17 pairs, and so do--track+ accept and--track+ reject.raw insertbookmark-pairing bug from the bottom of this issue is fixed. A fresh pair now keeps one shared id.Blocked: everything else.
referenced-byandbrokenhints, theREFERENCEDrefusal and--unlink-refs, andrenameall need feat(fields): model complex fields — parse, AST, read tokens, offset widths #41's field model to find REF/PAGEREF/NOTEREF targets.delete/acceptbehave for existing users.#Namehas no matching bookmark. The proposed error would break aread → create --fromrebuild: a fresh doc has no_Toc…/_Ref…bookmarks, so every TOC line would fail. Options:edit/insert, and degrade to plain text oncreate.The case where the bookmark exists (emit
w:anchor) is uncontroversial, and I can build it once you decide this.bookmarks list/add/delete,hyperlinks add --to, and a reader-sideDocument.bookmarks.Note on scope: #49 covers whole-paragraph
editonly. Rangeedit/deleteandedit --text ""line removal still splice bookmarks away. That is part of the Integrity section, and the reference check it needs depends on #41.Action needed: routine blocked — review/merge #49, then answer the internal-link question above and open question 1 (refuse vs auto-unlink), and say whether to build the field-independent bookmark parts before #41.
Generated by Claude Code