Repository navigation
Description
Companion bug, data loss in every mutator that rebuilds runs: #42. It lands its first step before this issue and introduces the parser from §1.
Problem
Word writes nearly every field as a complex field: a sequence of runs <w:fldChar w:fldCharType="begin"/> … <w:instrText> REF _Ref… \h </w:instrText> … <w:fldChar w:fldCharType="separate"/> … cached result runs … <w:fldChar w:fldCharType="end"/>. The reader has no model of it. Only <w:fldSimple> is recognized (as a transparent run-bearing wrapper), and only marginalText turns that into a token ({page}, {date}, …). Everywhere else a complex field reads as its cached result in plain text: no hint in read and nothing in read --ast. instrText is dropped, fldChar is ignored, and the result runs become ordinary TextRuns.
Verified on main @ e176d62 (0.29.0) against committed fixtures:
| Where | Field(s) | What the agent sees |
|---|---|---|
multi-column.docx p34 |
3× REF _Ref… \r \h (citation numbers) |
…in brackets [1, 3, 4]. as plain text. The third field's cached result is 4], so the closing bracket is inside the field. |
multi-column.docx p56 |
REF _Ref279755490 \h + \* MERGEFORMAT, the instruction split across two instrText runs |
~150 characters of caption text ("Table 1. Table captions should be placed…") that is really a cross-reference to the caption |
multi-column.docx, tables-and-lists.docx p62 |
SEQ Figure \* ARABIC, SEQ Table \* ARABIC (caption numbers), TIME \@ "d MMMM yyyy" |
Table 3.1 – …, 28 January 2020 as plain text |
resume-styling.docx p5/p39 |
HYPERLINK "http://www.linkedin.com/in/jennifer-smith-of9e839" |
LinkedIn Profile: linkedin.com/in/jane-smith: not a link, missing from hyperlinks list, and the target URL (a different profile from the display text) is invisible |
academic-paper.docx |
2× HYPERLINK, PAGE \* MERGEFORMAT in a page-number content control |
URL text without a link; the header reads "" |
tables-and-lists.docx footer |
complex PAGE / NUMPAGES |
Experiment #1 Page 2 of 4: the cached values from whichever page Word last laid out |
contract-markup/contract.docx footer (weak-agent scenario) |
begin+instrText PAGE+separate+end all in one <w:r>, empty result |
… Page with no number |
Consequences:
- An agent can't tell computed text from typed text. It hand-edits citation or caption numbers, which the next field update in Word overwrites. It retypes "Page 2 of 4", which freezes that number on every page. And it can't see where a cross-reference points.
- HYPERLINK fields aren't hyperlinks to any verb:
hyperlinks list/replacecan't see the résumé's LinkedIn link. - Every mutation path that rebuilds runs treats the field as its result text, which is the data-loss bug in bug(edit): whole-paragraph edits silently flatten fields to frozen text; span replace leaves orphan fldChar #42 (fields silently flattened to frozen text, and an orphan
fldChar endleft behind). - It violates "everything we parse must be in the AST": the instruction is in the XML and missing from the AST.
Scope note: this issue is the field model (parse, AST, read, offsets, discovery). #42 makes every mutator preserve fields. Cross-reference authoring (REF/PAGEREF/NOTEREF + bookmark locators + internal links) is #40, which builds on this model. Header/footer page numbers (#39, blocked on #37) consume this model instead of extending marginalText.
Proposal
1. Parse: one field parser, run-child granularity, a stack per story
New module src/core/fields/ (reader-side .ts, no JSX):
- Tokenize at run-CHILD level, not run level.
fldChar/instrText/delInstrTextcan share a run with each other and with text. The contract footer has begin, instr, separate and end inside one<w:r>. - Walk in document order through every run-bearing wrapper (
wrapperContent, somc:AlternateContentcounts once). A field's begin and end may sit in different wrappers: the begin outside a<w:ins>and the result inside it, or a wholePAGEREFfield inside a TOC entry's<w:hyperlink>. - Keep a stack, so nested fields work, e.g.
IF { MERGEFIELD x } = "y" …, or thePAGEREFs inside a TOC result. A field nested inside another's instruction contributes its result to the parent's instruction string (that is how Word evaluates it). A field nested inside a result is an ordinary child field. - Instruction = the concatenation of every
instrText/delInstrTextbetween begin and separate (or end). Empty runs in between are ignored. Covers the splitREF … \h+\* MERGEFORMAT. - Parse the instruction once:
kindfrom the leading keyword (page,numpages,sectionpages,section,date,time,createdate,savedate,printdate,seq,ref,pageref,noteref,styleref,hyperlink,toc,index,mergefield,formtext,formcheckbox,formdropdown,docproperty,filename,author,title,if, elseother),args(the bookmark name, SEQ identifier or URL, with quotes handled),switches(\h,\r,\* MERGEFORMAT,\@ "…", …).fieldTokeninmarginals/text.tsmoves here and becomes the one instruction-to-token map, so<w:fldSimple>and complex fields share it. - No
separateis legal: the field has no cached result. Word computes it on render (verified: Word and LibreOffice both render[1]for a PAGE field with no separate, whichreadshows as[]today). - Flags from the begin
fldChar:w:dirty,w:fldLock, andw:ffData(legacy form field).<w:fldSimple>is folded into the same model as a one-node field (simple: true). - Scope: the parser stack is per block container: the body flow, each table cell, each text-box story, each footnote/endnote, each comment, each header/footer part. A field may span paragraphs within its container (TOC, INDEX, and long REF results do), and the parser state therefore lives in
WalkState, not per paragraph. It never crosses a cell wall or a story boundary. - Malformed (an
endwith nobegin, abeginnever closed before its container ends, a strayseparate): no field semantics, and today's plain-text rendering, so a broken file reads no worse than now. Recordmalformed: truein the AST, and add avalidatepackage-consistency check, "unbalanced field characters in pN" (warning). Todayvalidatereports the file our ownreplaceproduces below asvalid. Note thatfldCharbalance is invisible to the XSDs.
2. AST
Two shapes, decided by what the cached result means:
- Volatile fields (
page,numpages,sectionpages,section,date,time,savedate,printdate,createdate,edittime): the cached result is a snapshot of one layout or one moment and is misleading as text. These become an atomic run, likeEquationRun:The result runs are NOT also emitted astype FieldRun = { type: "field"; id: string /* fldN */; kind: FieldKind; instruction: string; token: string /* "{page}" */; result: string /* cached, lossless */; trackedChange?: TrackedChange; comments?: string[] };
TextRuns. - Content fields (everything else:
ref,seq,noteref,pageref,hyperlink,mergefield,formtext,toc,docproperty,if, …): the cached result is the document's words, and agents need to read and find them ("Figure 1", "[1, 3, 4]"). The result stays as ordinary runs (formatting, comments and revisions intact, soparagraphText/wc/finddon't change). Each result run is decorated withfield?: { id; kind }, the same pattern asTextRun.hyperlink. A containerFieldRuncan't work here because a field can span paragraphs and wrappers. - Every field, either shape, gets a record in
Body.fieldReferences(id → begin node and parent, end node and parent, block ids spanned, record), andread --astgains a top-levelfields: Field[]:type Field = { id; kind; instruction; args: string[]; switches: string[]; result: string; blockId: string; endBlockId?: string; parent?: string /* nesting */; simple?: true; dirty?: true; locked?: true; malformed?: true; trackedChange?: TrackedChange };
- HYPERLINK fields also populate
hyperlinkByIdaslinkNwithfield: "fldN". They become links toread,hyperlinks listand the existing link-aware--textdiff. - Tracked changes: a field is visible in a view iff its
beginrun's revision chain is (isRevisionVisible). Inside<w:del>, the instruction is read fromw:delInstrText. fldNnumbering is global document order across stories, the waytbxNis numbered. Widencli/info/schema.ts.
3. read: three renderings, all deviation-free for documents without fields
- Volatile → the token, in place of the cached value:
Experiment #1 Page {page} of {pages},{date}. This is the same vocabulary as headers/footers today, and the same thing feat(page-numbers): read Word's page numbers as fields, edit them in place, and set start/format #39 asks for in marginals. Use{section-pages}per feat(page-numbers): read Word's page numbers as fields, edit them in place, and set start/format #39. Literal brace text that matches a token exactly is escaped (\{page}) so the two can't be confused. - Content → the cached result, marked with an HTML span:
…in brackets [<span data-field="fld3">1</span>, <span data-field="fld4">3</span>, <span data-field="fld5">4]</span>.Why a span and not a comment or a{…}token:- A comment can't show extent. Which preceding characters are the field? For
4]the bracket is inside the result, and for p56 the field is a whole sentence. And comments may never round-trip, by invariant. - A
{fld3:1}token would change the offset space.find "Figure 1"would stop matching, and every existingpN:S-Eand comment anchor after the field would shift. A span is zero-width markup, exactly like the formatting spans that already ride<span>. - A span can round-trip as a re-binding. bug(edit): whole-paragraph edits silently flatten fields to frozen text; span replace leaves orphan fldChar #42 makes
data-field="fldN"ineditmarkdown refer back to the live field of that id (the same pattern asre-stating an existing image, and the known-note fix feat(move): relocate blocks in place — untracked splice + tracked w:moveFrom/w:moveTo #38 asks for with[^fn1]). It never mints a field from markdown, becauseDDEAUTO/INCLUDETEXT "http://…"/INCLUDEPICTUREwould make field authoring through markdown an injection surface. An unknownfldNis an error, never literal text. - It merges with an existing formatting span (
<span style="…" data-field="fld3">) instead of nesting. - Shown kind:
data-field="fld3"is the handle. Whether to also print the kind or target inline (data-field="fld3 ref:_Ref279753835") is an open question, settled byeval:read's signal ratio, not by taste. The full record is onefields listaway either way.
- A comment can't show extent. Which preceding characters are the field? For
- HYPERLINK fields → markdown links:
[linkedin.com/in/jane-smith](http://www.linkedin.com/in/jennifer-smith-of9e839), identical to a<w:hyperlink>. The display/target mismatch becomes visible. - Generated multi-paragraph fields (TOC, INDEX, BIBLIOGRAPHY): bracket them like a text box story:
<!-- docx:field fld0 toc instr="TOC \o "1-3" \h \z \u" -->before the first paragraph and<!-- docx:field-end fld0 -->after the last, with the entries as plain text. NestedPAGEREF/HYPERLINKspans are suppressed inside a generated region, because the bracket already says "regenerated by Word, don't hand-edit" and per-entry spans would triple the TOC's size.
read --current shows a tracked field insertion or deletion as CriticMarkup around the whole token or span, like an equation.
4. Offset width: the equation rule, split by shape
Kept in lockstep on the AST side (runViewText in find/index.ts, the reader's comment offsetRef) and the XML side (runTextLength/sumRunBearingTextLength/inlineMarkerWidth and every walker that counts equations), from ONE helper in core/fields, as equationToken does for equations:
fldChar,instrText/delInstrText, and anything inside an instruction (including nested fields' results) have width 0. The instruction is never in the find/replace text, which is already true today.- Volatile field: width = its token (
{page}= 6), and atomic. A span that covers the token moves or deletes the whole field. A span that cuts it is refused (fieldSpanRefusal, mirroringequationSpanRefusal). There is no "inside". - Content field: width = its result text, exactly as today, so existing anchors,
wcandfinddon't move. The begin/end boundaries are the atom edges. A span strictly inside the result, a span covering begin through end, and a span cutting one boundary are the three cases. Mutators decide what each means (bug(edit): whole-paragraph edits silently flatten fields to frozen text; span replace leaves orphan fldChar #42). Placement walkers that can't refuse snap outward:comments add --anchor "1, 3"brackets the whole offld3andfld4, instead of ending betweenfld4's result and itsendthe way it does today, and a footnote reference lands after the field'send.
5. Discovery
docx fields list FILE [--json]→[{id, kind, instruction, result, blockId, …}], theidbeing the handle (house list contract). Add a top-leveldocx --helpone-liner. Weak agents find features there.fldNis a locator forraw get --at fldN(serialize begin…end) andcomments add --at fldN.delete --at fldNand the authoring verbs are left to bug(edit): whole-paragraph edits silently flatten fields to frozen text; span replace leaves orphan fldChar #42 and follow-ups.findoutput can name the field a match sits in (p34:152-161 (in fld3, fld4, fld5)) under--json.
6. Stale cached results: preserve, don't fake, don't nag
We never lay out pages, so we can't recompute PAGE/PAGEREF/NUMPAGES, and after an edit the cached results of SEQ/REF/TOC may be stale. Findings so far:
w:dirty="true"on a field's beginfldCharmakes Word for Mac block on open with a modal: "This document contains fields that may refer to other files. Do you want to update the fields in this document?" Verified: one dirtySEQfield in an otherwise clean document wedgeddocx render --engine worduntil its 60 s open timeout, and left the modal on screen for a human to click.<w:updateFields w:val="true"/>insettings.xmlis documented to raise the same prompt (not separately probed). Either one makes Word ask every person who opens the document that question, and the wording ("may refer to other files") reads as a security warning to a recipient. Opening a contract, or sending one, and getting a scary prompt is a real cost, and it breaks unattended Word automation, including our ownrender. So neither may be set by default, or as a side effect of an ordinary edit. If we ever offer it, it's an explicit opt-in (fields update --on-open?) whose help states the prompt.- Word's PDF export recomputed every stale cached value we planted (PAGE
99→1, NUMPAGES55→1, SEQ77→1, DATE1999-01-01→today, REF → "Error! Reference source not found."). LibreOffice recomputes all of them on import too. Sodocx rendershows recomputed values that can differ from whatreadshows (the cached ones). That's expected, not a bug, butrendercan't be used to test a stale cache. What Word shows on screen before an update (cachedREF/SEQuntil F9, as far as we know) is unverified. - Policy for this issue:
readshows the cached content result (it's what the file holds and what the screen shows) and the token for volatile kinds (whose cache is always stale somewhere). Mutators never edit cached results behind the agent's back. Follow-up (not this issue):docx fields updatethat recomputes the deterministic kinds in place (SEQ numbering in document order with\r/\c/\s;REFto a bookmark's text;NOTEREF) and reports what it can't (page-dependent kinds,\rparagraph numbers), without settingdirty.
7. Headers/footers (dependency: #37, then #39)
The parser is part-agnostic, so MarginalsView parts get the same field records. But marginalText only walks direct <w:p> children of the part root, so the content-control page numbers (academic-paper, large-mixed) and tables in headers stay invisible until #37 makes headers block containers. This issue ships the parser + fieldToken. #39 switches marginals onto it. Don't patch marginalText with a second field walker in the meantime.
Implementation notes
src/core/fields/:parse.ts(the run-child tokenizer + stack, producing begin/separate/end markers and the parsed instruction),token.ts(fieldToken,isVolatile, the width helper shared by both sides of the offset bridge),instruction.ts(keyword/args/switches).- Reader:
readRun/walkRunContainerconsult the parser state inWalkState. In instruction phase they suppress text (todayinstrTextis already skipped). In result phase they decorate content-field runs or accumulate a volatile field's result into itsFieldRun. They registerfieldReferencesin the same single walk. Notes, text boxes and cells reuse it viacollectScopedBlocks. - Offset bridge: a new walker obligation in
src/core/CLAUDE.mdbeside the equation rule. "Adding an offset walker: count a volatile field withfieldWidth, never its cached result."<w:fldSimple>stays inRUN_BEARING_WRAPPER_TAGSfor content kinds. For volatile kinds the walkers stop descending its runs and count the token instead (one predicate, both sides). - Markdown:
cli/read/markdown.tsemits the token/span/link.gatherHtmlSpanslearnsdata-field(merge with style spans).formatNotegains thedocx:field/docx:field-endbracket. hyperlinkslens: a HYPERLINK-fieldlinkNlists withfield: "fldN". Whetherhyperlinks replace/deleterewrite the instruction's URL / unwrap the field in v1 or refuse with a pointer is an open question below. Either way it must not hit the rId path.
Tests and coverage
- Unit (
tests/core/fields/*.test.ts): parser cases: every committed fixture row in the table; instr split across runs; all fldChars in one run; noseparate; nested (IF{MERGEFIELD}, TOC{HYPERLINK{PAGEREF}}); begin outside / result inside<w:ins>; tracked deletion withdelInstrText; spanning two paragraphs; cell- and story-scoped; malformed (orphan end / unclosed begin);fldSimpleparity with the complex form. - Offset parity: for each fixture,
findoffsets == XML walker offsets across every field (the existing equation parity tests are the template). Comment--anchorover a field snaps outward. eval:read: snapshot diff reviewed as a product change. p34/p56/p62, the résumé link and the TOC fixture are the cases to look at. WatchsignalRatioonmulti-column(19 REFs).- Fixture: a new
tests/fixtures/setup/fields.ts(no existing fixture fits, since nothing authors fields yet) that dogfoodsraw insertto plant each shape above, plusfields listin its assertions, and is added toCORE_FIXTURESso it round-trips LibreOffice. Include a TOC, a SEQ-numbered caption pair, a REF to it, a field in a cell, in a text box and in a footnote, a HYPERLINK field, and afldSimple. - Weak-agent scenario: fold "my LinkedIn link points at an old profile, fix it to …" into
resume(needs a résumé source with a HYPERLINK field, e.g. theresume-stylingone).criteria.mdchecks the field's instruction URL changed and the display text stayed. validate: a fixture-clean pin for the new unbalanced-field check (no false positives on any committed fixture).- Docs: REFERENCE.md (read output,
fields list,fldN),src/core/CLAUDE.md(offset bridge rule),src/core/ast/CLAUDE.md,src/cli/CLAUDE.md.
Open questions
- Span payload: bare
data-field="fld3", or kind and target inline (fld3 ref:_Ref279753835)? Decide oneval:readnumbers. The cross-reference authoring issue will want the target visible. - Volatile token switches:
{date}drops\@ "d MMMM yyyy". Keep tokens bare (the record has the instruction) or{date:d MMMM yyyy}? Same question as feat(page-numbers): read Word's page numbers as fields, edit them in place, and set start/format #39'sPAGE \* roman. - HYPERLINK-field verbs: should
hyperlinks replacerewrite the instruction URL in v1 (likely what the résumé task needs), or refuse until a follow-up? - Legacy form fields (
FORMTEXT/FORMCHECKBOX/FORMDROPDOWNwithffData): common in legal and HR templates. Should FORMCHECKBOX read as a GFM task checkbox, and is that this issue or the task-list package? - Mid-paragraph
docx:fieldfor content fields in generated regions: is the TOC bracket enough, or do INDEX/BIBLIOGRAPHY need their own treatment? wc: proposed that a volatile field's token counts 0 words, as an equation already does (wconequations.docxp2,$x^2$, is0). A content field counts its result words, as today.
Activity
kklimuk commentedon Nov 6, 2026
🤖 docx-cli triage
Reproduced on
main@ e830c0c.hyperlinks list tests/fixtures/resume-styling.docxreturns only the twomailto:links (link0,link1), whileraw get --at p5showsHYPERLINK "http://www.linkedin.com/in/jennifer-smith-of9e839".read --from p34 --to p34onmulti-column.docxgives…in brackets [1, 3, 4].as plain text, andread --astcontains nofieldkey. So the request is valid and in scope.Why I didn't open a PR: every slice of this issue (AST
FieldRun/Field,fieldReferences, read tokens/spans,fieldWidthoffsets,fields list, thevalidatebalance check) sits on the §1 parser (src/core/fields/parse.ts+ instruction parsing). #42 Step 1 introduces that parser, and the order you wrote down is #42 step 1 → step 2 → #41. A separate triage run is building #42 Step 1 right now. A second parser built here would collide with it file for file and leave you two competing implementations to reconcile. Nothing in this issue can be built ahead of that parser and still be worth reviewing.Blocked on: #42 Step 1 merging (the
src/core/fields/parser). After that, a natural first slice of #41 is §2 + §5 without the read-format change: the ASTfields: Field[]+fldNids +docx fields list+ HYPERLINK fields inhyperlinkById. That makes fields visible without touching Markdown offsets. The §3/§4 read rendering also needs your answers to open questions 1 (span payload) and 2 (volatile token switches), or aneval:readrun.Action needed: answer question — confirm that #41 waits for #42 Step 1 to merge, and whether the first slice should be AST +
fields list+ HYPERLINK-field links (no read-format change yet). Removeblockedonce #42 Step 1 lands.Generated by Claude Code