Skip to content

feat(bookmarks): bookmarks as addressable targets, authored cross-references (REF/PAGEREF/NOTEREF), and internal links #40

Open

Description

@kklimuk

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: _Ref279752133 has 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 add takes --url only.
  • 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 src finds nothing), so our captions neither renumber nor can be targeted by number.
  • raw insert can'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 -->. Add span="S-E" when it doesn't cover the whole paragraph and to="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 flagged broken on 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 resolved target locator on the field issue's REF/PAGEREF/NOTEREF field and on Hyperlink.anchor. The reader stays the sole minter, and Body.bookmarkByName holds live { start, end, startParent, endParent } refs.
  • eval:read: the fixture's References list gains about ten docx:referenced-by hints. That's useful signal, but check the snapshot diff and signalRatio. 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.
  • read already 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 \h
  • number = REF x \r \h (relative context: "4.2" from outside chapter 4, "2" from inside it)
  • full-number = \w
  • page = PAGEREF x \h
  • above-below = REF x \p \h
  • label = REF to the caption's label bookmark
  • note = 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:OFFSET inserts at a point (the footnotes point form).
  • --at pN:S-E replaces a span.
  • --anchor PHRASE finds the phrase and replaces the part of it the field stands for. For number/label/page that is the phrase's trailing number token, so --anchor "Section 4" --show number keeps "Section " as text and makes "4" the live field. For text it 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 word path) also recomputed all three, with identical output. \r to an unnumbered heading gave "0". On-screen display of the stale cache before an update isn't verified.
  • w:dirty="true" on a field's fldChar makes 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's numPr. The Markdown orderedOrdinal is 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 has lvlText="[%1]", and Word cached \r as "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.docx has 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, write 1. 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) and track-changes accept of 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 read and 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 (--markdown and --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. Range delete/cross-paragraph replace shrink 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 add rejects an existing name, and minted _Ref names are random 9-digit like Word's, collision-checked.
    • w:id is numeric and unique across the whole package (body, notes, headers/footers, comments). Mint max + 1 from one scan.
    • The raw gate's id re-mint already exists, and has a pairing bug (below).
  • Tables. A bookmark inside one cell paragraph is ordinary. Read and list cell-spanning and column bookmarks (w:colFirst/w:colLast on a bookmarkStart in a <w:tr>), but refuse to author them in v1.
  • Text boxes / notes / headers. Refuse bookmarks add and cross-refs add --to on a tbxN target 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 _Ref is therefore silent even under tracking.
    • The cross-reference text is an insertion: the field's runs go inside <w:ins> (or --at S-E produces <w:del> of the literal + <w:ins> of the field), so a reviewer sees it.
    • bookmarks add/rename/delete under doc-level tracking follow the hyperlinks precedent, with a [docx-cli] audit comment.
    • hyperlinks add --to under tracking: same audit comment as --url.
    • Verify the tracked field shape against Word with scripts/word-redlines.sh using test files that set neither w:dirty nor updateFields (they block Word with the modal above), and only when someone can dismiss a dialog.
  • rename rewrites every instruction that names the bookmark, in every story (body, notes, comments, headers/footers), plus w:anchors. Refuse when NEW exists.

Internal links

  • hyperlinks add --to TARGET writes <w:hyperlink w:anchor="NAME" w:history="1"> with no relationship, minting a hidden _Ref/reusing a bookmark exactly as cross-refs do.
  • hyperlinks list reports anchor plus the resolved at.
  • hyperlinks replace --at linkN --to TARGET retargets.
  • Fix the importer: [text](#Name) (and #_Toc…) becomes w:anchor, never an external rel. A #Name with 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:AlternateContent branches, and text-box stories (under their tbxN scope). Populate Document.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).
  • Bookmarks lens (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.
  • CrossReferences lens (src/core/cross-refs/): resolves --to, calls Bookmarks.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's rPr). The CLI shells are src/cli/bookmarks/ and src/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-paragraph edit, next to the comment-range handling.
  • Hyperlinks: Hyperlinks.add({ anchor }), replace --to, and the inline.tsx #name fix.

Tests and coverage

  • Unit
    • read hints: user bookmark, referenced-by, nothing for _GoBack, broken for the fixture's dangling REF.
    • AST bookmarks: multi-paragraph, overlapping, body-level end.
    • delete/edit/accept refuse a referenced paragraph, and --unlink-refs freezes the refs (LibreOffice shows no "Error:").
    • Whole-paragraph edit keeps bookmark pairs (both table-2 cases).
    • cross-refs add for each TARGET × --show, with computed results matching Word's cached values in multi-column.docx (\r on [%1] → "1").
    • --anchor "Section 4" makes only "4" the field.
    • hyperlinks add --to and the [x](#Name) import round-trip.
    • rename rewrites 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.docx as-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 exercises images add) gains --caption "Figure 1: …" with a SEQ field, a heading, then cross-refs add --to img0 --show label, --to pN --show page, and hyperlinks 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.md sentence: "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: a REF _Ref… \r \h whose 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 --to forms), and the skill's common workflows.

Open questions

  1. 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.
  2. Hint shape: docx:referenced-by p37 p93 on the target vs a referenced token on the existing docx:p note. Settle it by eval:read signal ratio on multi-column.docx.
  3. Plain-text captions: should cross-refs --to on a caption that was typed by hand ("Figure 1") convert its leading Label N into a SEQ field in place (deterministic, and makes the reference correct), or refuse with a hint?
  4. Page placeholder: a lastRenderedPageBreak estimate vs a constant 1 vs a visible ?. All are replaced at print/export by both renderers; the difference only shows on screen in Word before an update.
  5. 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

  1. kklimuk commented on Nov 6, 2026

    @kklimuk
    OwnerAuthor

    🤖 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).

    • Whole-paragraph edit now keeps the paragraph's bookmarks. On p103 of multi-column.docx, the --markdown edit used to leave 16 of 17 pairs, and --text left 17 starts but only 16 ends. Now both keep all 17 pairs, and so do --track + accept and --track + reject.
    • The raw insert bookmark-pairing bug from the bottom of this issue is fixed. A fresh pair now keeps one shared id.

    Blocked: everything else.

    • Field model (feat(fields): model complex fields — parse, AST, read tokens, offset widths #41). Cross-ref authoring, referenced-by and broken hints, the REFERENCED refusal and --unlink-refs, and rename all need feat(fields): model complex fields — parse, AST, read tokens, offset widths #41's field model to find REF/PAGEREF/NOTEREF targets.
    • Product decisions. These need your call:
      • Open questions 1–5 in the issue body. Q1 (refuse vs auto-unlink) decides how delete/accept behave for existing users.
      • Internal-link import (§3) when #Name has no matching bookmark. The proposed error would break a read → create --from rebuild: a fresh doc has no _Toc…/_Ref… bookmarks, so every TOC line would fail. Options:
        • (a) Error only on edit/insert, and degrade to plain text on create.
        • (b) Degrade everywhere and print a warning.
        • (c) Error everywhere, as proposed.
          The case where the bookmark exists (emit w:anchor) is uncontroversial, and I can build it once you decide this.
      • Whether to build the bookmark parts that don't need fields before feat(fields): model complex fields — parse, AST, read tokens, offset widths #41 lands, or wait and do them together: bookmarks list/add/delete, hyperlinks add --to, and a reader-side Document.bookmarks.

    Note on scope: #49 covers whole-paragraph edit only. Range edit/delete and edit --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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions