DOCX Mutation API
August 1, 2026 · View on GitHub
Status: Implemented. Source:
Docxodus/DocxSession.cs,Docxodus/RawDocxOps.cs,Docxodus/Internal/MarkdownPayloadParser.cs,Docxodus/Internal/UndoRing.cs. Tests:Docxodus.Tests/DocxSessionTests.cs(DS###),MarkdownPayloadParserTests.cs, and an end-to-end smoke atDocxSessionSmokeTest.cs. WASM bridge:wasm/DocxodusWasm/DocxSessionBridge.cs. npm wrapper:npm/src/session.ts. The full type-level spec lives atdocs/superpowers/specs/2026-05-24-docx-mutation-api-design.md— this doc is the conceptual reading and the recipe book; it points to source for the canonical shapes rather than restating them.
What this is
DocxSession is the write-side counterpart to WmlToMarkdownConverter. The projector turns a DOCX into anchor-addressed markdown; the session lets you mutate the same DOCX by those anchor ids — replace text, insert/split/merge paragraphs, apply formatting, edit table cells — without the agent (or human) ever having to think about OOXML. Anything the markdown subset can't express drops to a clearly-namespaced raw-XML escape hatch.
The intended consumer is an agentic editing pipeline: an LLM reads the markdown projection of a document, decides what to change, and calls a small set of high-level tools. But the same surface is useful for any tooling that wants to make surgical, ID-addressed edits to Word documents — review pipelines, structured-edit UIs, templating workflows.
Why it's shaped the way it is
Three design forces, in order of weight:
The agent must not learn OOXML. Every public method takes an anchor id (a string) and either a markdown payload (a string) or a small typed value (a FormatOp, a CharSpan). The agent never sees an XElement, never picks an SDK type, never has to know that bold is w:b inside w:rPr. The Raw escape hatch exists for the cases the markdown subset can't reach, but it's a separate namespace (session.Raw.*) so it's syntactically obvious when you've left the safe zone.
Edits must be reversible. Agents make mistakes. The session keeps a bounded ring of pre-op snapshots (default 50 deep) so Undo() and Redo() work without the caller orchestrating anything. Snapshots are per-part XML clones, not full package round-trips, so the cost is proportional to the size of the part the op touched — usually just the body.
Errors must be pattern-matchable, not stringly-typed. Every mutation returns an EditResult envelope; failure carries a typed EditErrorCode with a remediation message. The same enum is exposed as a snake-case string union in TypeScript, so JS agents pattern-match the same way C# callers do. No method on the session throws across the boundary (the constructor and Save() are the only places that can — and only for fatal conditions like an invalid DOCX or IO failure).
Architecture
┌──────────────────────────────────────────────────────────────┐
│ npm: openDocxSession(bytes, settings?) → DocxSession │
│ session.replaceText(anchor, md) → EditResult │
│ session.undo(); session.save() → Uint8Array │
└──────────────────────────────────────────────────────────────┘
│ JS ↔ WASM (handle = int sessionId)
┌──────────────────────────────────────────────────────────────┐
│ wasm/DocxodusWasm/DocxSessionBridge.cs │
│ static Dictionary<int, DocxSession> _sessions │
│ [JSExport] static methods, JSON-serialized in/out │
└──────────────────────────────────────────────────────────────┘
│
┌──────────────────────────────────────────────────────────────┐
│ Docxodus/DocxSession.cs (the real work) │
│ sealed class DocxSession : IDisposable │
│ - long-lived WordprocessingDocument over a MemoryStream │
│ - tier A/B/C/D mutation methods + Raw escape hatch │
│ - UndoRing<DocumentSnapshot> for bounded undo │
└──────────────────────────────────────────────────────────────┘
│ owns
┌───────────────────┼────────────────────────┐
▼ ▼ ▼
WordprocessingDoc AnchorIndex UndoRing
(live XDocument (refreshed lazily (per-part XML
per part) after each mutation) snapshots, default 50)
The session owns one WordprocessingDocument open over its own MemoryStream. Mutations operate directly on the in-memory XDocument of the affected part. Re-projection uses the existing WmlToMarkdownConverter over the live document.
For the full public surface — exact method signatures, settings, value types — read Docxodus/DocxSession.cs end-to-end. It's ~700 lines and organized by tier.
How to think about anchors
An anchor id looks like {#h:body:7b9f61007f9341c8aa5878ee63ffc874}. The parts:
kind— what kind of OOXML element this is (p,h,li,tbl,tr,tc,cmt,fn,en,img,drw,unk).scope— which package part it lives in (body,hdr1/hdr2/…,ftr1/…,fn,en,cmt).unid— a 32-char hex stable identifier (Docxodus'sPtOpenXml.Unid).
The Unid is the identity. The kind:scope: prefix is descriptive metadata and can change across mutations. Promoting a Normal paragraph to Heading2 flips its anchor id from {#p:body:abcd} to {#h:body:abcd}. The session's lookup helper (DocxSession.FindAnchor) does a direct dictionary hit first, then falls back to a Unid-only scan, so a cached id whose prefix has gone stale still resolves. Even so, prefer the Modified entry returned in each EditResult for the current canonical form — the fallback is cheap insurance, not a long-term substitute for tracking renames.
Created/Removed/Modified are the contract. Each mutation returns three anchor lists in its EditResult. The lifecycle policy is documented in the Anchor lifecycle section below — that's the contract the agent's mental model is supposed to track.
What the markdown payload subset is, and why
When you pass markdown into ReplaceText, InsertParagraph, or ReplaceCellContent, the session runs it through MarkdownPayloadParser, a hand-rolled parser that accepts only what the projector emits. Block-level: paragraphs, ATX headings (#–######), bulleted lists, ordered lists (with indent-based nesting), blockquotes, fenced code blocks. Inline: **bold**, *italic*, `code`, ~~strike~~, [text](url) links, the GFM hard break (" \n", two trailing spaces → a real w:br), backslash escapes. (A blank line still separates paragraphs; a single in-paragraph newline becomes a w:br, symmetric with the projector's w:br → " \n".)
This is symmetric by design: anything the projector can emit, the parser can accept, so an agent can read markdown out and write markdown in. Anything outside the subset is rejected with a typed error that names either the v1 op to use instead or the v2 op planned to address it. The full table of accepted and rejected syntax is in the spec — the practical shorthand:
- If you can see it in the projection output, you can write it in a payload.
- If you need a table →
InsertTable(anchor, Position, rows, cols, TableInsertOptions?)(borderless, row-majorCellContents,CellAlignment, per-columnColumnWidths), then edit cells withReplaceCellContentor address each cell-paragraph anchor; reshape withInsertTableRow/InsertTableColumn/DeleteTableRow/DeleteTableColumn(by a cell-paragraph anchor; v1 assumes a rectangular grid, now:gridSpan). - If you need a footnote or endnote →
InsertFootnote(anchor, offset, markdown)/InsertEndnote(...); a[^label]reference in a payload stays rejected, because a label can't name a note the payload doesn't define. - If you need a comment or image → those are v2 ops, currently rejected with a clear error.
- For everything OOXML can do that markdown can't (complex tables, math, content controls, drawings) →
session.Raw.*.
We didn't pick CommonMark or GFM as the input language because the projector's subset is small and well-defined; running a full parser against that subset would import surprise (e.g., GFM tables silently splitting paragraphs, autolinks mis-classifying spans). The hand-rolled parser is ~300 LOC, has no dependencies, and gives us complete control over what gets rejected and why.
Two round-trip quirks worth knowing when you write tests against the markdown output:
- The projector escapes markdown punctuation in text content.
-,*,_,`,~,\, and other characters that could be parsed as markdown are backslash-escaped (e.g.,RAWSIBLING-INSERTEDprojects asRAWSIBLING\-INSERTED). Don't write literalContains(...)assertions over hyphenated tokens; either strip backslashes from the projection or use tokens without markdown-significant characters. InsertParagraphwith a bulleted markdown payload does not inherit list numbering. A payload like- item oneparses as aBulletItemblock and the created anchor has kindli, but the inserted paragraph has now:numPr, so Word renders it as a plain paragraph (andSetListLevelwill returnAnchorWrongKindbecause there is no numbering to adjust). To get a real bulleted item in v1, useRaw.InsertXmlwith a fragment derived fromRaw.GetXml(existingListItemAnchor)so thew:numPrand numbering id come along for free. A first-class numbering-inheritance path is on the v2 list (see Known limits).
Anchor lifecycle
Each mutation reports which anchors it created, removed, or modified. This table is the contract — agent harnesses use it to keep their cached projection in sync without re-projecting on every call:
| Op | Created | Removed | Modified | Patch scope |
|---|---|---|---|---|
ReplaceText(p, md) | — (markdown subset can't introduce inline anchors in v1) | descendant inline anchors that no longer exist (rare) | p | p |
DeleteBlock(p) (or h/li/tbl) | — | p + all descendant anchors | — | nearest stable ancestor |
DeleteBlock(fn) / DeleteBlock(en) / DeleteBlock(cmt) | — | the definition anchor (and any cross-references it pointed at — those become "gone" but aren't separately addressed) | — | nearest stable ancestor in the body |
InsertParagraph(p, pos, md) | one anchor per new block | — | — | smallest enclosing common parent |
SplitParagraph(p, offset) | the second half | — | p (first half — convention) | enclosing parent |
MergeParagraphs(a, b) | — | b + descendants | a | a |
ApplyFormat(p, span?, op) | — | — | p | p |
SetParagraphStyle(p, style) | — | — | p (kind prefix may flip) | p |
SetListLevel(p, delta) | — | — | p | enclosing list (downstream items renumber) |
RemoveListMembership(p) | — | — | p (kind flips li→p) | enclosing list |
ReplaceCellContent(tc, md) | — | descendant inline anchors (rare) | tc | tc |
SetHeaderText(p, kind, md) / SetFooterText(...) | the new header/footer paragraph anchors (scope hdr{N}/ftr{N}) | — (reused-part old paragraphs cease to exist; not separately reported in v1) | — | whole document |
InsertPageNumberField(p, field?) | — | — | p (the paragraph the field is appended to) | p |
InsertFootnote(p, offset, md) / InsertEndnote(...) | the note definition (fn/en) + its paragraphs (scope fn/en) | — | p (the citing paragraph) | whole document |
Raw.InsertXml(a, pos, xml) | every block in the new XML | — | — | enclosing parent |
Raw.ReplaceXml(a, xml) | unids present in the new XML but not the old (typical for caller-authored XML) | unids present in the old element but not the new (when a itself is gone) | unids present in both (typical for the GetXml → mutate → ReplaceXml round trip, which preserves Unids) | enclosing parent |
Undo() / Redo() | (diff vs current) | (diff vs current) | (diff vs current) | null — caller re-projects |
Two conventions worth pinning down because they affect agent reasoning:
SplitParagraphkeeps the original Unid on the first half. Reason: external systems (LLM context windows, search indices) bias toward the pre-split anchor position; keeping the prefix-half stable minimizes invalidation downstream.MergeParagraphslets the first anchor absorb the second. Symmetric reason: the first anchor is to the left in reading order and is more likely to be the one a caller has cached.
Tracked-change mode shifts the semantics for ReplaceText and DeleteBlock. When Settings.TrackedChanges = RenderInline, deletions don't remove elements — they wrap old runs in w:del and new content in w:ins. So the affected anchor stays live and appears in Modified instead of Removed. The agent's view of the world doesn't have to change; the EditResult shape is unchanged.
ReplaceText quietly strips a leading auto-number prefix from the payload. When the target paragraph carries w:numPr (numbered heading or list item), the projector emits the resolved number inline (## Fourth The total number…) so a human can read what Word renders. An agent that echoes the visible heading back as its ReplaceText payload would otherwise see Fourth Fourth: … in the saved DOCX — the auto-number is still applied by Word, and the new run text now also starts with the prefix. The session resolves the number via the shared Internal.ListNumberResolver and strips a matching prefix (plus one optional separator: space, tab, or NBSP) from the payload before parsing. Idempotent — if the agent skipped the prefix, nothing is stripped. Documented in DS091/DS091b.
When to use what
Decision tree for the agent (or its prompt):
What am I editing?
├── Just the visible text of a paragraph/heading/list item?
│ → ReplaceText(anchor, markdown)
│
├── Removing a paragraph/heading/list item?
│ → DeleteBlock(anchor)
│
├── Adding a paragraph adjacent to an existing one?
│ → InsertParagraph(anchor, "before" | "after", markdown)
│
├── Splitting one paragraph into two?
│ → SplitParagraph(anchor, offset) # offset is character position
│
├── Joining two adjacent paragraphs?
│ → MergeParagraphs(firstAnchor, secondAnchor)
│
├── Just the bold/italic/underline/code/color/size/font of some characters?
│ → ApplyFormat(anchor, CharSpan(start, length), FormatOp{...})
│ → ApplyFormat(anchor, null, FormatOp{...}) # null span = whole paragraph
│ # FormatOp.FontSizePts → w:sz/w:szCs; FontFamily → w:rFonts ("" clears)
│
├── Changing a paragraph's style (e.g., Normal → Heading2)?
│ → SetParagraphStyle(anchor, styleId)
│
├── Indenting/outdenting a list item or removing it from a list?
│ → SetListLevel(anchor, +1 | -1)
│ → RemoveListMembership(anchor)
│
├── Replacing the contents of a table cell?
│ → ReplaceCellContent(tcAnchor, markdown)
│
├── Setting a section's running header/footer (any body block anchors the section)?
│ → SetHeaderText(bodyAnchor, HeaderFooterKind.Default|First|Even, markdown)
│ → SetFooterText(bodyAnchor, ...) # Created lists the hdr{N}/ftr{N} paragraphs
│
├── Putting a page number in a header/footer paragraph?
│ → InsertPageNumberField(hdrOrFtrAnchor, PageNumberField.CurrentPage|TotalPages)
│
├── Adding a footnote/endnote (cited from a body paragraph at a character offset)?
│ → InsertFootnote(bodyAnchor, offset, markdown)
│ → InsertEndnote(bodyAnchor, offset, markdown) # Created lists the fn/en anchors
│ …then edit it with ReplaceText(notePara, md) or drop it with DeleteBlock(noteDef)
│
├── Inserting/deleting table rows or columns, merging cells,
│ embedding a chart, inserting a math equation,
│ adding a content control?
│ → Drop to session.Raw.* (v2 ops planned for the common cases)
│
└── Anything that needs an undo guard?
→ Just call it. Every successful op takes a snapshot.
session.Undo() restores prior state.
Bulk block removal — DeleteRange and DeleteSection
DeleteRange — bulk sibling removal
session.DeleteRange(fromAnchorId, toAnchorIdExclusive) deletes every top-level
block-level sibling between two anchors. Both endpoints must:
- Be block-level kinds (
p,h,li,tbl). - Live in the same package part (same scope).
- Share a direct parent (the call refuses to span into nested containers like
table cells; use a per-cell
DeleteBlockloop for those). frommust precedetoin document order.
Records one undo snapshot — Undo() after DeleteRange restores every
removed element together. EditResult.Removed lists every anchor (including
descendant anchors of removed blocks) that disappeared.
Tracked-change mode (Settings.TrackedChanges = RenderInline): DeleteRange
wraps each removed paragraph's runs in w:del and marks the paragraph mark
itself as deleted via w:pPr/w:rPr/w:del. Tables get w:trPr/w:del on every
row (Word's row-deletion convention — there is no table-level "delete" markup),
plus the same run/paragraph-mark wrapping inside every cell. Nested tables
recurse. Anchors stay live in the document tree, so the top-level block anchors
land in EditResult.Modified instead of Removed and callers can re-address
them before accepting the changes. Block kinds outside w:p / w:tbl (e.g.
w:sdt content controls in the middle of a range) still fall back to structural
removal in tracked mode — file a follow-up if a consumer needs them tracked.
DeleteSection — heading-bounded bulk removal
session.DeleteSection(headingAnchorId) deletes a heading and every sibling
below it up to (but not including) the next heading at the same or higher
level. "Level" matches the projection's notion: Heading1 = 1, Heading2 = 2,
…, Title = 1, Subtitle = 2.
If the target heading has no sibling-heading boundary after it, the section extends to the end of the parent.
Built on DeleteRange semantics via the shared DeleteSiblingRangeCore helper:
same undo, same EditResult shape, same tracked-change behavior (paragraphs and
tables get w:del markup, anchors stay live, block kinds outside w:p/w:tbl
fall back to structural removal).
Finding anchors via tagged annotations
The session addresses content by anchor id, but real workflows don't start with anchor ids — they start with intent ("edit the indemnification provision," "tighten the termination clause"). The clean way to bridge intent to anchors is to annotate the regions ahead of time, then resolve the annotation to its anchor(s) at edit time.
Docxodus's AnnotationManager already persists annotations into the docx itself: each annotation creates a w:bookmark named _Docxodus_Ann_<id> covering the range, and a custom XML part stores the metadata (LabelId, Label, Color, Metadata key/value bag). See custom_annotations.md for the full mechanism and lifecycle. Annotations survive save/reopen and travel with the document.
DocxSession exposes four discovery helpers that read directly off the long-lived WordprocessingDocument (no save/reopen round-trip per call):
session.ListAnnotations(); // every annotation in the doc — id, labelId, label, color, author, annotatedText
session.FindByAnnotation("ann-id"); // IReadOnlyList<AnchorTarget> — the blocks the bookmark covers
session.FindByLabel("INDEMNIFICATION"); // IReadOnlyDictionary<annotationId, IReadOnlyList<AnchorTarget>>
session.FindByBookmark("_Docxodus_Ann_ann-id"); // lower-level: resolve any bookmark name (managed or user-authored)
The canonical agentic recipe collapses to:
foreach (var (id, anchors) in session.FindByLabel("INDEMNIFICATION"))
foreach (var a in anchors.Where(a => a.Anchor.Kind is "p" or "h" or "li"))
session.ReplaceText(a.Anchor.Id, "Revised indemnification language…");
What FindByAnnotation / FindByLabel / FindByBookmark return in v1:
- All block-level anchors whose subtree overlaps the bookmark range, in document order, deduplicated. That includes the immediate paragraph plus any enclosing table / row / cell, so an agent sees "this annotation lives in a table" without re-walking the tree. Filter by
Anchor.Kind in {"p","h","li"}when you want only the text-bearing blocks suitable forReplaceText. - Empty list when the id/label/bookmark is unknown or the bookmark's end marker is missing. No exceptions for not-found.
- Body scope only. Bookmarks in headers/footers/footnotes aren't part of the v1 surface —
AnnotationManageronly writes to the main document part today. If header/footer annotation support lands, the helpers will return those anchors too.
Two caveats that are explicitly out of scope for v1 (tracked in #132):
- Bookmarks that span partial paragraphs return the enclosing block's anchor, not a character span. A character-range surgical edit needs
ApplyFormat(anchor, CharSpan, op)after computing the offset within the bookmark range yourself. - Mutations don't auto-update bookmarks. A
ReplaceText/SplitParagraph/MergeParagraphscall can invalidate the bookmark covering the affected region. Bookmark preservation across mutations is a separate follow-up.
The agent's prompt should also be aware: it can call session.ListAnnotations() once at session start to enumerate available labels (e.g., "you can target: INDEMNIFICATION, TERMINATION, GOVERNING_LAW") and present those as tools rather than asking the LLM to discover them from text.
Headers, footers, and page-number fields
Running headers/footers and page-number fields live in their own OOXML parts
(HeaderPart/FooterPart), outside the body — so before issue #236 the session
could only inspect them (GetSectionInfo → HeaderPartUris/FooterPartUris),
never author them. These three methods close that gap; they're exposed in .NET,
WASM/npm, and stdio/docx-scalpel.
Methods
| Method | What it does |
|---|---|
SetHeaderText(anchorId, HeaderFooterKind, markdown) | Set the running header story for the section that owns anchorId. |
SetFooterText(anchorId, HeaderFooterKind, markdown) | Same, for the footer. |
InsertPageNumberField(anchorId, PageNumberField = CurrentPage) | Append a PAGE/NUMPAGES field to a paragraph (usually a header/footer one). |
EnsureHeaderFooterVisible(anchorId, HeaderFooterKind) | Make the section's first/even stories actually render (w:titlePg / w:evenAndOddHeaders). |
Why EnsureHeaderFooterVisible exists. SetHeaderText/SetFooterText set the visibility
flags while writing content, which covers authoring a story from scratch. It does not cover a
document that already carries a first/even reference with the flag absent — and that is exactly
what Word leaves behind when "Different first page" / "Different odd & even pages" is switched
back off: the part and its reference stay, only the flag goes. Editing such a pre-existing story
through the anchor-addressed text ops then yields a file whose header content is present but
never rendered. The flags belong to the section, not to a content write, so this is its own
operation: First → w:titlePg, Even → the document-global w:evenAndOddHeaders, Default
→ a successful no-op. Idempotent; a non-body anchor is AnchorWrongKind.
TestFiles/HC031-Complicated-Document.docx is the canonical example — all six stories present,
neither flag set (DS268).
Addressing. SetHeaderText/SetFooterText take any body block in the target
section — the governing w:sectPr is resolved the same way GetSectionInfo resolves
it (a forward mid-document section break, else the body's trailing sectPr; if the
body has none, a trailing sectPr is synthesized). This mirrors GetSectionInfo
returning null for non-body anchors: passing a header/footer/footnote anchor is an
AnchorWrongKind error, because a story attaches to a body section.
HeaderFooterKind = Default / First / Even, mapping to the reference's
w:type. First additionally sets the section's w:titlePg; Even sets
w:evenAndOddHeaders in the settings part — without those flags Word ignores the
first/even story. Calling the same kind twice reuses the existing part and
replaces its content (so SetFooterText is an idempotent "set the footer to this");
a different kind creates a second part/reference.
Two Even sharp edges worth knowing:
w:evenAndOddHeadersis document-global and governs footers too. Once set, even pages stop inheriting the Default stories entirely — a section with only a Default footer shows no footer at all on even pages (spec-correct Word behavior, observed identically in LibreOffice). If you set an Even header and want footers to keep appearing on every page, set an Even footer too.- The flag is inserted at its CT_Settings schema slot via
WordprocessingMLUtil.EnsureEvenAndOddHeaders(shared with the DocxDiff header/footer renderer); every other settings child — including ones the ordering table doesn't know, likew:hdrShapeDefaults/w:shapeDefaultsthat real Word documents carry — stays exactly where it was. (An earlier whole-part reorder corrupted such documents;DS263/DS264pin the fix.)
Content & styling. The markdown uses the same subset as InsertParagraph
(bold/italic/links/etc.). Each paragraph with no explicit style gets the built-in
Header/Footer paragraph style, so it inherits Word's centre-of-page and
right-margin tab stops — the layout page-number footers rely on. An empty payload
yields one empty story paragraph.
Return shape. SetHeaderText/SetFooterText report the new story paragraphs in
EditResult.Created (scope hdr{N}/ftr{N}, 1-based by part-collection order) — pass
one to InsertPageNumberField. InsertPageNumberField reports the target paragraph in
Modified. The field is a native complex field (fldChar/instrText, cached "1"
result), so it renders and updates like a hand-authored field.
Recipe — the S-1 running footer ("Last Updated … / centered page N")
var body = session.Project().AnchorIndex.Values
.First(t => t.Anchor.Kind == "p" && t.Anchor.Scope == "body").Anchor.Id;
var footer = session.SetFooterText(body, HeaderFooterKind.Default, "Last Updated October 2025");
var footerPara = footer.Created[0].Id; // scope "ftr1"
session.SetParagraphFormat(footerPara, new ParagraphFormatOp { Alignment = ParagraphAlignment.Center });
session.InsertPageNumberField(footerPara, PageNumberField.CurrentPage);
Undo/redo and the snapshot reconcile
SetHeaderText/SetFooterText can add an OOXML part, which the session's per-part
snapshot didn't previously track (only the annotations custom-XML part was
create/delete-reconciled). DocumentSnapshot now also records each header/footer
part's relationship id, and RestoreSnapshot reconciles them: on undo it deletes
parts the snapshot lacks; on redo it re-creates the ones it has with their original
relationship id (via AddNewPart<HeaderPart>(relId)) so the just-restored sectPr
reference resolves. Content of surviving parts restores by URI as before. One edge is
documented as intentional: the w:evenAndOddHeaders settings flag (only set by the
Even kind) isn't reverted by undo — it's idempotent and has no visual effect without
an even story.
Which part supplies which kind — SectionInfo.HeaderRefs/FooterRefs
HeaderPartUris/FooterPartUris report which parts a section references but not which
story kind each one supplies, and the projection's hdr{N}/ftr{N} numbering is by
part-collection order, which carries no kind information either. A client that wants to
show or edit "this document's first-page header" therefore cannot resolve it from
those lists.
SectionInfo.HeaderRefs and FooterRefs close that: each entry is a
HeaderFooterRef { HeaderFooterKind Kind; string PartUri; bool Inherited; }, in the
references' declaration order. w:type is optional in OOXML, so an absent (or
unrecognized) value reads as Default per ECMA-376 §17.6.10.
They report the stories that EFFECTIVELY apply, not just the section's own. A section
that declares no reference of a given type continues the previous section's
(ECMA-376 §17.6.17), which is why a multi-section document typically defines its headers
once in the first section and leaves the rest empty — HC031-Complicated-Document.docx
has four sections and only the first declares anything. Reporting own references alone
would tell a caller "this part of the document has no header" when it visibly does, and an
editor acting on that would mint a redundant part and break the inheritance. Inherited
entries carry Inherited = true; editing one edits the part both sections share, which is
what Word does. HeaderPartUris/FooterPartUris keep their original meaning — this
section's own references only.
Combined with the partUri each projection anchor already carries, this gives a client
the full kind → part → story-paragraph-anchors chain:
var info = session.GetSectionInfo(body)!;
var firstHeaderPart = info.HeaderRefs.First(r => r.Kind == HeaderFooterKind.First).PartUri;
var storyParas = session.Project().AnchorIndex.Values
.Where(t => t.PartUri == firstHeaderPart && t.Anchor.Kind == "p")
.Select(t => t.Anchor.Id);
Wire: headerRefs/footerRefs (npm SectionInfo.headerRefs/footerRefs, HeaderFooterRef;
docx-scalpel SectionInfo.header_refs/footer_refs). This is exactly what the browser
editor's band chrome uses to label its kind selector.
Page-number formatting (issue #277)
Page numbering has two independent layers, and conflating them is the usual way to get it wrong.
The section — SetPageNumbering(bodyAnchor, PageNumberingOp) writes w:pgNumType,
which is exactly what Word's Format Page Numbers… dialog writes. Start (w:start) is
the number the section begins at; Format (w:fmt) is the format its pages use. Both
fields are tri-state — null leaves that attribute alone, so the start can be set
without disturbing the format and vice versa. ClearPageNumbering(bodyAnchor) removes the
two attributes (preserving the chapter-numbering ones this surface never writes, and
removing the element only once nothing is left on it). Addressed by any body block, with
the governing w:sectPr resolved exactly as GetSectionInfo resolves it, and synthesized
if the body has none.
This is the normal way to number pages: set the section once, insert plain fields. A
PAGE field with no switch renders through it.
The field — InsertPageNumberField(anchor, field, format?) writes the field's own
\* general-formatting switch (PAGE \* roman). Omitting format — the default —
emits a plain field, byte-for-byte what earlier versions emitted. A switch overrides the
section for that one field and keeps overriding it if the section later changes, so it is
the escape hatch, not the default route. The editor's band deliberately inserts plain
fields for exactly this reason.
Both reject NumberFormat.Bullet and a negative start with
EditErrorCode.InvalidPageNumbering: a bullet is a valid list format with no
page-number counterpart in either vocabulary, so accepting it could only mean silently
writing something else.
NumberFormat is reused rather than duplicated — it is already this library's name for
ST_NumberFormat, which is the type of both w:numFmt and w:pgNumType/@w:fmt.
Docxodus/Internal/NumberFormats.cs is the single owner of the three mappings (OOXML
token, \* switch argument, rendered glyph); the switch spellings are case-significant,
since roman is i, ii, iii and ROMAN is I, II, III. A field's cached result is
seeded with page 1 rendered in the requested format (i, A, 1) rather than a
hardcoded "1", so a renderer that does not recompute fields agrees with the switch.
w:pgNumType has a fixed slot in the CT_SectPr sequence. WordprocessingMLUtil's
Order_sectPr + InsertSectPrChildInOrder place it there — the same slot-insert
discipline as EnsureSettingsChildInOrder, and now the single owner of that ordering for
w:titlePg too (DocxSession and IrMarkupRenderer previously each carried a private
"what follows titlePg" list).
Read-back: SectionInfo.PageNumberStart / PageNumberFormat (wire
pageNumberStart/pageNumberFormat; docx-scalpel page_number_start/
page_number_format). Both are omitted when the attribute is absent rather than
defaulted, because "continues the previous section in Word's default format" is a
different claim from "starts at 1 in decimal" — a UI that cannot tell them apart writes
attributes the document never had.
The editor region
The browser DocxEditor ships the visual affordance as docked bands — see
docs/architecture/ir_editor_feasibility.md § "Header/footer editing region". Story
paragraphs there are ordinary editable blocks addressed by their p:hdr1:<unid> anchors,
which every text/format mutation on this page already accepts.
The band chrome carries the section's page-number format and start-at controls
(setPageNumbering/clearPageNumbering/pageNumbering on DocxEditor). They sit on both
bands and show the same values, because they describe the section rather than either story.
Not yet
- Deleting a header/footer story.
SetHeaderText/SetFooterTextcreate or replace; there is no operation that removes a part and its reference. An empty payload yields an empty story, which is not the same thing. - The in-page-margin editing overlay (editing the running head inside the page box's
top margin in
{ paginated: true }mode). Two things block it: the full-document render assigns Unids to the main document part only, so header/footer content carries nodata-anchor; and pagination clones one header node onto every page, so N pages would mean N DOM nodes sharing one anchor. The docked bands avoid both. - Chapter page numbering (
w:pgNumType/@w:chapStyle/@w:chapSep— "1-1, 1-2" numbers derived from a heading style).ClearPageNumberingpreserves those attributes when a document already has them, but nothing writes them. - Recomputing a field's cached result. The number a page-number field shows in a non-paginated render is the value cached in the file; Word recomputes on open, and the paginated preview substitutes the real per-page number (below), but a continuous-mode render still shows the cached one.
Tier B: footnotes & endnotes (issue #276)
The projection has always read notes — the fn/en scopes, EditSummary.FootnoteCount,
ReplaceText on a note's paragraph, DeleteBlock on a note definition (which also strips
every reference to it). What was missing was the verb that creates one. InsertFootnote /
InsertEndnote close that; there is no separate "edit note" or "delete note" op because the
existing anchor-addressed ops already are those.
Methods
| Method | What it does |
|---|---|
InsertFootnote(anchorId, characterOffset, markdown) | Create a footnote with body markdown and cite it from body paragraph anchorId, characterOffset characters into its text. |
InsertEndnote(anchorId, characterOffset, markdown) | Same, into the endnotes part, emitting a w:endnoteReference. |
Addressing. A body paragraph/heading/list-item anchor, plus a character offset in
[0, len(paragraph text)] — 0 places the citation before all text, len after all of it.
An out-of-range offset is OffsetOutOfRange. Non-body anchors are AnchorWrongKind: Word does
not allow a note reference inside a header/footer story or inside another note, and the fn/en
scopes are note definitions, not citation hosts. Rejecting is deliberate — the alternative is
emitting a document Word offers to repair.
The offset is resolved through the same SplitRunsAtOffset + SplitInlineContainersAtOffset
pair that SplitParagraph and ApplyFormat use, so a citation lands cleanly mid-run and inside
a hyperlink without a second offset walker to drift.
What the first note in a document creates. On a package with no FootnotesPart (or
EndnotesPart), the op writes the whole scaffold Word writes, not just the definition:
- the part itself, holding the two notes Word reserves for page-rendering scaffolding —
w:type="separator"at id-1andw:type="continuationSeparator"at id0. The projector already filters these out of the anchor index (IsBoilerplateNote), andDeleteBlockalready refuses to remove them. - the
w:footnotePr/w:endnotePrsettings declaration naming those two ids, inserted at its CT_Settings schema slot via the sharedEnsureSettingsChildInOrder— the settings part is never wholesale reordered (same discipline asw:evenAndOddHeaders; see the header/footer section for the document this protects). - the
FootnoteText/EndnoteTextparagraph style and the superscriptFootnoteReference/EndnoteReferencecharacter style, find-or-create viaStyleFactory, so a house style already in the document wins and a document that had neither doesn't render the citation as full-size body text.
A second note reuses all of it and only appends a definition.
Note id allocation — ids must ascend in REFERENCE order. This is an invariant every
Word-authored document holds (verified across the TestFiles corpus — including documents whose
ids have gaps, e.g. 17/21/26 — and a 94-footnote real-world model certificate). Renderers depend
on it: LibreOffice numbers the body markers by citation position but pairs them against the
id-sorted definition list, so a first-cited note holding the highest id renders the wrong
note text — the marker reads "1" and points at somebody else's footnote. Nothing errors; the
document is simply, silently wrong.
So the allocator works in two cases:
- Citation follows every existing one (the common case): take one above the highest id used by
any definition in the note part or any reference anywhere in the package (body, headers,
footers, both note parts). Appending keeps ids ascending, so nothing else moves. Scanning
references too — not just definitions — is what stops a document with gaps from aliasing an
existing definition.
DS322pins this with a fixture whose user notes are ids 1, 5 and 9. - Citation lands before an existing one: the new note takes the smallest id cited after it, and
every user note at or above that id shifts up by one — definitions and every reference to them
in every part. Notes cited earlier keep their ids. Word-reserved notes (any
w:type:separator,continuationSeparator,continuationNotice) are never renumbered; their ids sit below every user id, so shifting upward cannot collide. Taking the minimum of the following ids rather than the first keeps this correct even on an input document that already violated the invariant.DS336/DS337pin the ordering and that each shifted note keeps its own text.
Because a shift rewrites w:id on renumbered definitions, and the note-definition anchor's unid is
derived from that id, the shifted notes' fn/en anchors change. Their paragraph anchors — and
every body anchor — are unaffected.
Markup. Word-faithful on both sides:
<!-- body -->
<w:r><w:rPr><w:rStyle w:val="FootnoteReference"/></w:rPr><w:footnoteReference w:id="1"/></w:r>
<!-- footnotes.xml -->
<w:footnote w:id="1">
<w:p>
<w:pPr><w:pStyle w:val="FootnoteText"/></w:pPr>
<w:r><w:rPr><w:rStyle w:val="FootnoteReference"/></w:rPr><w:footnoteRef/></w:r>
<w:r><w:t xml:space="preserve"> </w:t></w:r>
<w:r><w:t>Source: 2025 annual report.</w:t></w:r>
</w:p>
</w:footnote>
The w:footnoteRef/w:endnoteRef auto-number mark plus its separating space go on the first
paragraph of the note only, so the note reads "1 Source: …" rather than "1Source: …".
The body is the same markdown subset as InsertParagraph; an empty payload yields one empty
note paragraph.
Return shape. Created carries the note definition anchor (kind fn/en) followed by
its paragraph anchors (kind p, scope fn/en); Modified carries the citing body paragraph.
That is the whole lifecycle handoff:
var made = session.InsertFootnote(bodyAnchor, 0, "Source: 2025 annual report.");
var notePara = made.Created.First(a => a.Kind == "p" && a.Scope == "fn").Id;
var noteDef = made.Created.First(a => a.Kind == "fn").Id;
session.ReplaceText(notePara, "Source: 2026 annual report."); // edit the note
session.DeleteBlock(noteDef); // drop it + the body citation
Undo/redo and the snapshot reconcile
Creating the first note adds an OOXML part, the same problem SetHeaderText/SetFooterText
solved. DocumentSnapshot therefore records the footnotes/endnotes parts' relationship ids
alongside the header/footer ones, and RestoreSnapshot runs a ReconcileNoteParts twin of
ReconcileHeaderFooterParts: undo deletes a part the snapshot lacks, redo re-creates it with its
original relationship id (AddNewPart<FootnotesPart>(relId)). Content of surviving parts restores
by URI as before. DS328 pins undo-removes-part / redo-restores-part.
FootnoteRefNotSupported — narrowed, not retired
A [^label] reference inside a markdown payload is still rejected. The error code stays
(it is public surface and clients switch on it); only its meaning narrowed: a bare label can't
be resolved to a note the payload doesn't define, so the message now names the dedicated op.
Authoring a note is InsertFootnote/InsertEndnote; the payload subset is for a note's body,
not for minting citations.
Rendering notes in the editor
An editor that can author a footnote has to be able to show it. The browser DocxEditor renders
notes as ordinary editable content, which took three things:
- The render profile emits notes.
DocxSessionOps.RenderHtmland the editor's first-paintcompleteArgsboth setRenderFootnotesAndEndnotes. They must stay in step — the remount output is required to match the first paint byte-for-byte. Footnotes are document content, not an editing affordance, so unlike the header/footer bands this is not opt-in: a document that has notes shows them. - Note paragraphs are anchor-stamped.
HtmlConversionOps.AssignAnchorUnidsassigns the deterministic Unids to the footnotes/endnotes parts as well as the main part, so note paragraphs carrydata-anchorand the editor wires them as ordinary blocks — no new command code, the whole ribbon works inside a note.FindByUnidsearches the note parts too, so the statelessRenderBlockHtmlcan re-render a single note after an edit. Header/footer parts are deliberately not stamped: paginated output clones one header node onto every page, so a stamped header anchor would exist N times in the DOM. Each note renders exactly once, so notes have no such problem. - The citation marker is inert chrome. A citation is a zero-width
w:footnoteReference; the displayed number is computed by the renderer from document order, and the note backref (↩) is generated too. None of it is in the session's run text, so the editor excludes all of it from its content-offset space via oneGENERATED_CHROME_SELECTOR(shared with generated list markers). Each place that has to honour it fails differently if missed: offsets drift (OffsetOutOfRange, silently dropped edits); the display number gets committed as literal text, destroying the citation run; or the user deletes a marker outright and orphans its note.
Both editor modes show notes, differently. Continuous mode renders the converter's
<section class="footnotes"> at the end of the body flow. Paginated mode is richer: pagination.ts
already had a footnote engine (per-page distribution, continuations, splitting), and turning the
render flag on activates it — notes land in a note area at the bottom of the page that cites them,
above a separator rule, and a note too long for its page continues onto the next. Endnotes render as
a section.endnotes appended after the page stack rather than on their own final page, which is a
layout imperfection, not a correctness one: they are visible and editable. A note split across pages
puts a different paragraph of the note in each half, so each half stays independently addressable
— no two editable nodes ever share one anchor.
Note content lives inside the body flow, so anything that walks "the body blocks" must exclude
section.footnotes, section.endnotes and .footnote-item — the header/footer band already does
the equivalent by ignoring an anchor GetSectionInfo can't resolve to a body section (focusing a
note leaves the bands on the last body section rather than blanking them).
Paginated mode's footnote engine. pagination.ts already had per-page distribution, splitting
and continuations; turning the render flag on exercised it against a dense real document for the
first time and it needed four fixes, each worth knowing about because each failed silently:
- an unfitted note was held in a single continuation slot assigned once per note in a page's citation list, so a second unfitted note on the same page overwrote the first and it rendered nowhere — notes now queue and are merged into the next page's list, never replaced;
- the stylesheet's
>combinators were XML-escaped (the CSS is the value of anh:styleelement), so the rule keeping a note's number and first line together was dropped by every browser; - a note that couldn't be split was re-wrapped inside its own content, nesting a complete
.footnote-itemin another's content span; - the content area spanned the full text height while the note block grew upward into it, so body
and note glyphs could be painted on top of each other. The content area is now shrunk to what the
notes leave, making that impossible by construction — worst case is a clean clip — and note
heights are measured in the
.page-footnotesstyling context they render in so the reserve matches the paint.
DS340–DS345 pin the engine half (marker + section emitted, note paragraphs stamped, stamped
anchors resolve through the session, edit-then-re-render, the stateless path, and reserved-note
filtering); npm/tests/editor-footnotes.spec.ts pins the browser half.
Word-reserved notes never surface as editable blocks. IsBoilerplateNote now treats any
typed note as reserved: ECMA-376 §17.11.17 defines the type as normal | separator |
continuationSeparator | continuationNotice, and only normal — which Word omits rather than
writes — is user content. Enumerating just separator/continuationSeparator let continuationNotice
through (real documents carry one), where it projected as a user note and, once the editor started
rendering notes, appeared as a stray empty footnote with no citation.
Not yet
- Moving a citation without deleting and re-creating the note.
- Note numbering options (
w:numFmt, restart-per-page/section, custom marks) — thew:footnotePrwritten on part creation declares only the separators; everything else is Word's default (1, 2, 3…, continuous). - Citing an existing note twice. Each call creates a new definition; there is no "reference note N again" op.
- Tracked-changes mode.
Settings.TrackedChanges = RenderInlinedoes not wrap the citation inw:ins— consistent with every other insert op (InsertParagraph,InsertTable,InsertHorizontalRule,SetHeaderText); onlyReplaceText/DeleteBlock/DeleteRangetrack. - Narrowed projection scopes. A session opened with
ProjectionSettings.ScopesexcludingFootnotes/Endnotesstill writes the note correctly, butCreatedcomes back without the note anchors — they resolve against a projection that omits the part. Family behavior, identical toSetHeaderTextwithHeadersexcluded.
Tier E: Annotations
Anchor-addressed CRUD for the Docxodus annotation system (custom-XML +
bookmark pairs). Mutates the live session document; round-trips through
Save and Reopen. Exposed in .NET, WASM (@docxodus/wasm), and Python
(docx-scalpel).
Methods
| Method | Description |
|---|---|
AddAnnotation(anchorId, span?, DocumentAnnotation) | Annotate a range; auto-generates 16-char hex id when annotation.Id is empty. BookmarkName, AnnotatedText, Created, and PageInfoStale are always set by the session. |
RemoveAnnotation(id) | Removes the bookmark pair and the custom-XML entry. |
UpdateAnnotation(id, AnnotationUpdate) | Mutates scalar fields (label/labelId/color/author) and metadata (per-key merge, explicit null = remove key). Range is preserved. |
MoveAnnotation(id, newAnchorId, newSpan?) | Atomically re-targets to a new anchor + span. Validates the new range before removing the old bookmark. |
AnnotationUpdate
public sealed record AnnotationUpdate
{
public string? LabelId { get; init; }
public string? Label { get; init; }
public string? Color { get; init; }
public string? Author { get; init; }
public IReadOnlyDictionary<string, string?>? MetadataPatch { get; init; }
}
Null/missing fields leave existing values unchanged. MetadataPatch
per-key semantics: non-null value = set/replace, explicit null = remove,
missing key = leave as-is.
New error codes
DuplicateAnnotationId— caller-supplied id already exists, or auto-id collided 4 times in a row (vanishingly rare).AnnotationNotFound—Remove/Update/Moveinvoked with an unknown id.EmptyAnnotationSpan—span.Length == 0, orspan == nulland the resolved block has zero inline runs.
Return shape
All four ops use the standard EditResult envelope with one new field:
AnnotationId carries the affected id on success. Created/Removed are
always empty for these ops (the bookmark + custom-XML entry are internal,
not markdown anchors). Patch is null — annotation ops don't change the
markdown projection. Modified lists the enclosing block anchor (one
entry for Add/Remove/Update; one or two for Move depending on whether the
destination is the same block as the source).
Find* helpers — anchor-level convenience over Grep
For anchor-level lookups that don't need match spans / fragments:
session.FindByText(needle, options?) // first anchor whose text contains needle, or null
session.FindAllByText(needle, options?) // every anchor (deduplicated, in doc order)
session.FindByRegex(pattern, regexOptions?, opts?)// every anchor with at least one regex match
session.FindByKind("h", scope: "body") // direct read over AnchorIndex, no text scan
FindOptions { IgnoreCase, IgnoreWhitespace, KindFilter, ScopeFilter }. IgnoreWhitespace flows down to Grep's WhitespaceMode.Normalize so a needle written with regular spaces hits NBSP-using text — see the smoke-test trap that motivated #136 / #137.
SmartQuotes
DocxSessionSettings.SmartQuotes = true makes every text-modifying op (ReplaceText, ReplaceTextRange, ReplaceMatch, ReplaceTextAtSpan) convert ASCII " and ' in the payload to typographic curly quotes based on context — open at start/after-whitespace/after-open-bracket, close elsewhere. Avoids the cosmetic regression where a fill lands as "foo" next to surrounding already-curly "foo" text. Default off (pass payloads through unchanged).
ApplyFormat — substring and TextMatch overloads
Three entry points for character-formatting (bold/italic/underline/strike/code/color/runStyle):
session.ApplyFormat(anchor, span, op) // explicit CharSpan (use null for whole paragraph)
session.ApplyFormatToSubstring(anchor, str, op) // find first occurrence of str, format it
session.ApplyFormat(textMatch, op) // exact span from a Grep result
The substring + TextMatch overloads exist because computing a CharSpan by hand is fragile when an auto-number prefix (# Fourth The total…) shifts the visible text relative to the run-text indices the CharSpan overload expects — see issue #138. Both convenience overloads just resolve to a CharSpan and call the underlying overload.
ApplyFormatToSubstring is named distinctly (rather than overloading) so existing ApplyFormat(anchor, null, op) whole-paragraph calls stay unambiguous to the C# resolver.
FindPlaceholders — template-slot enumeration
session.FindPlaceholders(kinds?, scope?) is a thin classifier over Grep for the workflow every template-filling agent eventually writes itself. It scans for \$?\[…\] regions and tags each one as:
PlaceholderKind | Pattern | What an agent does with it |
|---|---|---|
BlankFill | [___] or $[___] (underscores only) | Fill with a literal value (a name, a number, a date) |
AlternativeClause | [clause text] (anything else in brackets) | Keep, strip, or pick between alternatives |
Instruction | [insert X], [specify Y], [*italicized hint*] | Parameter description — populate based on the hint |
Instruction placeholders expose the inner text (asterisks stripped) via the Hint field, so the agent can read "insert percentage" or "specify name" and decide what to put.
PlaceholderKinds is a flag enum (BlankFill | AlternativeClause | Instruction = All) for narrowing.
The canonical fill recipe
// Replace every value-blank in the document with a looked-up value.
foreach (var p in session.FindPlaceholders(PlaceholderKinds.BlankFill)
.OrderByDescending(p => p.Match.Span.Start))
{
var value = LookupValueByContext(p.Match.ContextBefore, p.Match.ContextAfter);
session.ReplaceMatch(p.Match, value);
}
This pattern — FindPlaceholders + OrderByDescending(Span.Start) + ReplaceMatch — collapses the 200-line context-needle-disambiguation script the Bluth-Co smoke test had to write down to a five-line loop. Process in reverse offset order so earlier-offset spans stay valid after later edits land, the same rule that applies to ReplaceTextRange's internal pass.
FillPlaceholders — picker-driven multi-pass fill
The 5-line recipe above is now a first-class call:
var summary = session.FillPlaceholders(p => p.Kind switch
{
PlaceholderKind.Instruction when p.Hint?.Contains("price") == true => "1.50",
PlaceholderKind.BlankFill when p.Match.ContextBefore.TrimEnd().EndsWith("name is") => "ACME, INC.",
_ => null
});
// summary.Filled / .Skipped / .StillPresent / .Passes / .Unfilled / .Errors
What FillPlaceholders does internally that the recipe doesn't:
- Reverse-offset ordering. Earlier-offset matches in the same paragraph go stale once a later edit lands;
FillPlaceholderssorts every pass by(anchorId desc, span.Start desc)so each block's matches are applied right-to-left. $-prefix preservation. The placeholder regex\$?\[…\]captures$[___]including the leading$. WithFillOptions.PreserveDollarPrefix = true(default), the picker's return value gets$prepended when needed so"0.20"lands as$0.20, not0.20.- Multi-pass iteration.
FindPlaceholdersreturns innermost brackets only; stripping the inner can surface a previously-nested outer. The loop re-finds placeholders each pass and stops when a pass makes zero changes (orMaxPasses— default 8 — is hit).
The picker is invoked for every kind in FillOptions.Kinds, which defaults to PlaceholderKinds.All — so a picker that wants to ignore alternative-clause brackets should return null for them rather than relying on the option to filter them out. Set Kinds = BlankFill | Instruction if you want the prior behavior of leaving alternative clauses untouched.
The picker is invoked once per placeholder per pass; return null to skip. BulkEditResult.Unfilled lists every placeholder the picker said null to (deduplicated across passes). BulkEditResult.Passes is the highest iteration pass that actually filled at least one placeholder (so a single-fill convergence reports Passes = 1, not 2).
BulkEditResult.Skipped is the first-pass-null count and is not a reliable "is the template done?" signal — a placeholder the picker said null to in pass 1 may be fully resolved by pass 2 (a nested-outer wrapper becomes fillable once its inner is stripped, or a structural delete removes the placeholder entirely). Assert on BulkEditResult.StillPresent == 0 for the trustworthy single-call check: it's a post-loop FindPlaceholders(opts.Kinds, opts.Scope).Count, so Skipped > 0 && StillPresent == 0 correctly reads as "picker skipped on the first pass but later passes finished the job."
FillOptions.CoalesceWhitespaceAroundEmptyFill
Returning "" from the picker is the canonical "drop this optional clause entirely" signal. By default the bracketed match is deleted exactly, which leaves whitespace and punctuation around the (now-gone) brackets untouched. The repro from issue #188:
... pursuant to the General Corporation Law on March 14, 2024 [under the name [_______________]].
For a company that has never been renamed, the picker fills the inner name slot with "Bluth, Inc.", then in pass 2 sees the outer wrapper [under the name Bluth, Inc.] and returns "". The literal-delete result is:
... pursuant to the General Corporation Law on March 14, 2024 .
Note the stray space before the period.
With FillOptions.CoalesceWhitespaceAroundEmptyFill = true, an empty fill (after $-prefix preservation has been applied) absorbs adjacent chars based on the immediate neighbors of the placeholder span:
| Left neighbor | Right neighbor | Action | Example |
|---|---|---|---|
| whitespace (incl. NBSP, narrow NBSP, thin space) | whitespace | consume the trailing space, leaving one | "alpha [x] beta" → "alpha beta" |
| whitespace | . , ; : ! ? | drop the leading space | "… 2024 [x]." → "… 2024." |
( [ { | matching ) ] } | drop both surrounding brackets | "[[x]]" → "" |
Default is false (preserve the literal-delete behavior). $-prefix preservation runs first, so a picker returning "" for $[xxx] with the default PreserveDollarPrefix = true ends up replacing with "$" (not empty) and coalescing is skipped — set PreserveDollarPrefix = false when you want the $ to drop along with the brackets.
Note the .NET implementation reads the live flat text of the enclosing block to find the immediate neighbors, so the rules work regardless of the Boundary setting. The npm TypeScript implementation uses the match's already-populated contextBefore / contextAfter (zero extra round-trip) — with boundary: ContextBoundary.Bracket the outer brackets are not visible to context, so the bracket-coalesce rule won't fire on the JS side. Callers using CoalesceWhitespaceAroundEmptyFill should leave Boundary at the default Char.
ReplaceInner — strip brackets while preserving prefix/suffix
// match.Text = "$[___]"
session.ReplaceInner(match, "0.20");
// paragraph now contains "\$0.20" (the leading $ outside the brackets survives).
ReplaceInner parses the brackets out of match.Text and substitutes the new inner for everything between (and including) [ and ], then dispatches to ReplaceMatch with the recomposed string. Returns MalformedMarkdown if the match text has no balanced brackets. The shared DocxSessionOps.ReplaceInner is reused by both the WASM bridge and the stdio NDJSON host, so the replace_inner op is available to Python wrappers too.
TemplatePlaceholder.AlternativeKinds
When the primary classification is borderline, secondary classifications are exposed via the AlternativeKinds list. The current borderline case: a BlankFill whose inner text contains 4+ spaces (i.e. reads like a multi-word clause that happens to contain a _______). Primary Kind stays BlankFill for back-compat; AlternativeKinds lists AlternativeClause so callers can detect the ambiguity.
Nesting
Nested brackets (e.g. [under the name [Bluth, Inc.]]) resolve to the INNERMOST bracket only — usually what the agent cares about, since the inner is the value slot and the outer is the optional-clause wrapper. If you need both, use Grep directly with a balanced-bracket pattern.
Edit-state introspection — GetEditSummary and GetDiff
GetEditSummary — "am I done?"
session.GetEditSummary() returns a single EditSummary record composing
existing primitives:
| Field | Source |
|---|---|
TotalAnchors | Project().AnchorIndex.Count |
RemainingPlaceholders | FindPlaceholders(All, All) |
BareUnderscoreRuns | Grep(@"(?<![\[_])_{3,}(?![\]_])") (underscore-aware lookarounds bound the maximal run so the count matches the visible underline groups — see DS280b/c) |
FootnoteCount | AnchorIndex filter on kind=fn, scope=fn (excludes reserved separators per #162) |
InlineFootnoteRefCount | Body part's w:footnoteReference count |
CommentCount | AnchorIndex filter on kind=cmt |
Designed to make verification logic declarative:
var summary = session.GetEditSummary();
Assert.Empty(summary.RemainingPlaceholders);
Assert.Empty(summary.BareUnderscoreRuns);
Assert.Equal(0, summary.FootnoteCount); // commentary stripped
GetDiff — "show me what I changed"
session.GetDiff(DiffFormat.Json) (default) returns an anchor-keyed JSON
array of DiffEntry records comparing the projection captured at session
construction time against the current state.
[
{ "op": "delete", "anchorId": "p:body:abc…", "before": "Drafting Note..." },
{ "op": "modify", "anchorId": "p:body:def…", "before": "[___]", "after": "ACME, INC." },
{ "op": "insert", "anchorId": "p:body:ghi…", "after": "New paragraph text" }
]
Initial-projection capture is on by default (DocxSessionSettings.CaptureInitialProjection = true)
and costs ~200ms at construction. Turn it off if you don't plan to diff.
DiffFormat.Unified returns a patch(1)-compatible unified diff over the
markdown projections (--- initial / +++ current headers, 3 lines of
context per hunk, hand-rolled LCS over \n-split lines). Returns the empty
string when nothing has changed:
--- initial
+++ current
@@ -1,6 +1,6 @@
# Document
-{#p:body:6b6439…} First paragraph.
+{#p:body:6b6439…} REPLACED PARAGRAPH
{#p:body:a321f0…} Second paragraph.
DiffFormat.SideBySide returns a diff -y-style two-column rendering — the
initial projection padded to 72 chars on the left, a one-character marker
(' ' unchanged, '|' modified, '<' only-initial, '>' only-current),
then the current projection on the right. Adjacent Delete + Insert pairs
collapse to a single | "modified" row.
Both line-based formats diff the raw markdown projection — anchor tokens
({#…}) appear verbatim in the output. Switch to
AnchorIdRendering = Abbreviated or Sequential in
DocxSessionSettings.ProjectionSettings to keep token noise out of the
diff when reviewing in a terminal.
Sliced projection — ProjectAnchor
session.Project() returns the full document — usually overkill when an agent
only needs to read or edit one section. session.ProjectAnchor(anchorId, depth?)
returns a MarkdownProjection whose Markdown contains only the blocks in
scope and whose AnchorIndex is filtered to those blocks plus their
descendants:
// Just the heading paragraph itself
var self = session.ProjectAnchor(headingAnchor, ProjectionDepth.SelfOnly);
// A table and all its rows/cells
var table = session.ProjectAnchor(tblAnchor, ProjectionDepth.Subtree);
// A heading + everything under it up to the next same-or-higher heading
// (the default — the "give me this section" case)
var section = session.ProjectAnchor(headingAnchor);
ProjectionDepth values:
| Value | Behavior |
|---|---|
SelfOnly | Just the addressed block — its anchor and its own text. For headings, returns only the heading paragraph, not the section underneath. |
Subtree | Self + descendants. Most useful for tbl anchors (returns the whole table). For paragraph-like anchors, equivalent to SelfOnly since they have no descendants. |
SubtreeAndFollowingSiblings (default) | Self + descendants + following siblings up to (but not including) the next sibling at the same or higher heading level. For non-heading anchors, equivalent to Subtree. |
Useful for showing an LLM one section at a time without paying the ~1 s full-projection cost per turn — the agent reads, decides, edits, and re-projects only the slice it touched.
The anchorId argument accepts whatever rendering form the projection's
AnchorIdRendering setting emits — the dual-keyed AnchorIndex resolves
full Unids, abbreviated ids, and sequential ids interchangeably. See
markdown_projection.md
for the rendering modes.
Returns the same MarkdownProjection shape as Project() — caller code that
already consumes the full projection (e.g., reading AnchorIndex to find
follow-up edit targets) works unchanged on a slice. Throws
InvalidOperationException if the anchor isn't in the current AnchorIndex.
ReplaceTextRange — surgical text edits
session.ReplaceTextRange(anchorId, find, replace, options?) finds every literal occurrence of find in one paragraph/heading/list-item's flat text and substitutes replace for each, returning an EditResult per attempted match. Built on Grep — same fragment walker, opposite direction.
Three entry points covering the natural workflows:
session.ReplaceTextRange(anchor, find, replace, opts?) // most common: replace every match in one block
session.ReplaceMatch(textMatch, replace) // convenience for a Grep result
session.ReplaceTextAtSpan(anchor, spanStart, spanLength, repl) // exact-span variant when several identical needles share a block
ReplaceOptions: IgnoreCase (case-insensitive find) and MaxReplacements (cap on how many to apply).
Formatting-preservation contract
The replacement text inherits the formatting of the FIRST run the match spanned. Middle and trailing runs keep their w:rPr but lose the slice of text the match consumed — so a bold phrase that got partially overwritten still has bold formatting for any surviving text. This is the practical sweet spot: it solves the template-fill case where you want [___] → Bluth Co. to take on the surrounding text's formatting, and it's predictable for cross-formatting matches.
If you need different per-fragment behavior (e.g., the replacement should be bold even when the first fragment was plain), use Grep + bespoke Raw.GetXml mutation today, or wait for a future inline-markdown-aware overload.
Ordering and atomicity
Multiple matches in the same paragraph are applied in reverse document order so each earlier-offset match's span stays valid after later edits land — the same trick the projector uses for tracked-change accept passes. The whole call records one snapshot; Undo() rolls every replacement back together.
When to reach for the span-addressed variant
If the agent has computed five [___] placeholder matches in the same paragraph from Grep and wants to fill each with a different value, ReplaceTextRange would only see "five identical [___] needles" and replace each with the first value (or all with the same value). ReplaceTextAtSpan (or ReplaceMatch) addresses each match by its exact coordinates so the disambiguation is unambiguous. Apply spans in reverse offset order in this case for the same reason — earlier spans stay valid after later edits.
Recipe: enumerate-and-fill via Grep + ReplaceMatch
foreach (var match in session.Grep(@"\[_+\]")
.OrderByDescending(m => m.Span.Start))
{
var value = LookupValueByContext(match.ContextBefore, match.ContextAfter);
session.ReplaceMatch(match, value);
}
This pattern collapses the 200-line context-needle-disambiguation script the Bluth-Co smoke test had to write down to a five-line loop.
Grep — cross-run text search
session.Grep(pattern, options?, scope?, contextChars?) searches the flat text of every paragraph/heading/list-item in scope, returning matches in document order. Each TextMatch carries:
EnclosingAnchor— the smallest block-level anchor that fully contains the match.Span— character offset+length within the enclosing block's flat text.Fragments— oneRunFragmentper<w:r>the match spans, in document order. Each fragment names the run's Unid, the slice of the match it contributes, the offset+length inside the run, and the run's visibleFormatting(bold/italic/strike/underline/code/color/hyperlink/runStyle).ContextBefore/ContextAfter— up tocontextChars(default 40) of surrounding text from the same block.Groups— regex capture groups.
The fragment breakdown is the whole point: Word splits paragraph text into many <w:r> elements at every formatting boundary, so a placeholder like [_______________] routinely spans 2–3 runs. Without the fragment list, an agent doing search/replace has to either flatten runs (losing per-fragment formatting) or skip split matches (missing real text). Grep does the walk once and hands back the breakdown so callers can preserve each fragment's formatting when rewriting.
When to use
Need to … → use
Find every literal/regex pattern in the doc → Grep
Find one anchor whose text contains X → Grep, take .First().EnclosingAnchor
Enumerate template placeholders → Grep(@"\[_+\]") or similar
Edit text without losing formatting → Grep + a fragment-aware rewrite (see #139 for the planned ReplaceTextRange built on this)
Find a multi-paragraph clause or pattern that straddles a paragraph break → GrepCrossBlock
Performance
~400 ms for a full-document grep over the 150 KB NVCA Model COI (~500 anchors, ~31 underscore-placeholder matches). Scales linearly with document size + match count.
Known limits
- Each block is grep'd in isolation. Grep iterates paragraphs/headings/list-items and runs the regex against each one's flat text independently.
session.Grep("Hello world")won't match if"Hello "is in one paragraph and"world"is in the next, even though they appear adjacent in the rendered doc. This is by design: everyTextMatchcarries a singleEnclosingAnchorfor the caller to hand back toReplaceText/Raw.ReplaceXml. For cross-block search (legal clauses split for readability, multi-paragraph regions, etc.) useGrepCrossBlock(see next section). RegexOptionsis the .NET enum; the npm wrapper passes its numeric value through (seeGrepOptionsinnpm/src/types.ts).- Tracked-change content currently follows the projector's accepted/rendered text —
Settings.TrackedChanges = StripDeletionswon't filter<w:del>content out of Grep yet. Worth opening as a follow-up if it matters.
Context boundary modes
Grep / GrepCrossBlock / FindPlaceholders accept a ContextBoundary parameter
that decides where the context-computation walker stops:
| Mode | Stops at | Use when |
|---|---|---|
Char (default) | nothing — truncate at contextChars | legacy callers, free-form text where boundaries are noisy |
Bracket | [, ] | template fills with adjacent placeholders — each ContextBefore/ContextAfter is guaranteed to belong to this match only |
Sentence | ., !, ?, :, ; | LLM prompt-building where each snippet should be a self-contained sentence |
Comma | , | matches inside enumerations |
The default contextChars widened from 40 → 80 in #164. Combined with Bracket
mode this lets a template-fill picker use plain .Contains / EndsWith checks
without cross-pollution from adjacent placeholders:
var matches = session.Grep(@"\[CITY\]",
scope: ProjectionScopes.Body,
contextChars: 80,
boundary: ContextBoundary.Bracket);
// matches[0].ContextBefore guaranteed bracket-free
GrepCrossBlock — cross-block text search
session.GrepCrossBlock(pattern, options?, scope?, contextChars?, whitespace?) is the variant of Grep for matches that legitimately span multiple paragraphs — legal clauses split across paragraphs for readability, multi-paragraph indemnification blocks, or Section \d+\.\d+\b straddling a paragraph break.
Each CrossBlockMatch carries:
Text— the matched text, with single\ncharacters at each block boundary the match crossed.EnclosingAnchors— every block-level anchor the match touches, in document order. Always non-empty.Slices— per-block breakdown. EachBlockSlicenames itsAnchor, theSpanInBlock(offset+length within that block's own flat text), and aFragmentslist with the same shape asGrep's.ContextBefore/ContextAfter— surrounding text from the concatenated stream; may include block-boundary\ncharacters.Groups— regex capture groups.
Separator and regex behavior
Adjacent blocks in the searched text are joined with a single \n. That means:
^and$withRegexOptions.Multilineanchor at block boundaries..does not match across boundaries unlessRegexOptions.Singlelineis set.\s,\n, and explicit\npatterns in your regex see the boundary.
What it never crosses
Matches are scoped strictly to keep them meaningful for downstream editing:
- Package parts — body → footnote, header → body, etc. Different package parts are searched independently.
- Container boundaries — a body paragraph cannot bridge into a table-cell paragraph. Table cells form their own groups (
w:tcis the parent). - Non-paragraph siblings — a
w:tbl,sectPr, or any non-w:pelement between two paragraphs breaks the run; matches don't bridge across it.
Superset of Grep
A single-block match still appears in the results with one Slice. Filter Slices.Count > 1 if you only want cross-block hits. The naming reflects "the variant that also handles cross-block," not "only cross-block."
Edit semantics — deferred
Replace on a cross-block match has at least three reasonable behaviors (merge into one block, per-slice independent rewrites, boundary-preserve), none obviously right. Edit primitives are deliberately out of scope until a concrete consumer surfaces the right semantics. Today, callers can read the slice list and apply slice-by-slice edits via ReplaceTextAtSpan themselves.
Performance
Same order of magnitude as Grep: one concatenation pass + one regex pass per sibling group, with RunTextMap shared for fragment resolution. Memory grows with the largest group's concatenated text, not the whole document.
The Raw escape hatch
session.Raw exposes three operations: GetXml(anchorId) returns the element's OOXML as a string (useful as a template), InsertXml(anchor, position, xml) inserts a sibling fragment, ReplaceXml(anchor, xml) swaps the element for a fragment. Newly-inserted elements automatically get Unids and become addressable on the next projection.
The validation pipeline is short-circuit ordered: well-formedness (MalformedXml), namespace whitelist check (DisallowedNamespace — only w:, m: for math, wp:/a: for drawing, r:, and our own PtOpenXml namespace are allowed), structural slot check (IncompatibleElementType). Setting Settings.ValidateRawOps = true additionally runs OpenXmlValidator before and after the op and rolls back via the snapshot if the post-op error count is greater than the pre-op count. Pre-existing schema issues in the input document are tolerated (the validator is only used to detect deltas, not to gate the document overall), and the projector's internal PtOpenXml:Unid attributes are filtered out before counting since they are not in the OOXML schema by design. Slower than the default path but bulletproof for untrusted agent input.
The round-trip recipe. This is the safe pattern for raw mutations the agent should always prefer over authoring XML from scratch:
// .NET
var xml = session.Raw.GetXml(anchor);
var modified = MutateSomehow(xml);
var result = session.Raw.ReplaceXml(anchor, modified);
// TypeScript
const xml = session.raw.getXml(anchor);
const modified = mutateSomehow(xml);
const result = session.raw.replaceXml(anchor, modified);
Starting from a known-valid XML fragment and modifying it locally is dramatically less error-prone than constructing OOXML from scratch — namespace declarations, attribute ordering, and child-element validity are all preserved from the original.
Error catalog (by remediation)
Errors are grouped by what the agent should do in response, not by where in the code they're raised. The EditErrorCode enum lives in Docxodus/DocxSession.cs; the snake-case TypeScript union is in npm/src/types.ts.
| The agent should… | When it sees these codes |
|---|---|
| Re-project and re-derive the anchor from current text | AnchorNotFound |
Re-read the anchor's kind via GetAnchorInfo, reissue with the right op or coordinates | AnchorWrongKind, AnchorsNotAdjacent, InvalidPosition, OffsetOutOfRange |
| Fix the markdown payload (the message names what's wrong) | MalformedMarkdown, UnsupportedMarkdownSyntax, AnchorTokenInPayload |
Call the v1 op the message names, or fall back to Raw.InsertXml | TableInsertNotSupported, FootnoteRefNotSupported, CommentMarkerNotSupported, ImageInsertNotSupported |
Re-query (no ListStyles() API in v1; the agent guesses from the projection) | UnknownStyle, InvalidListLevel |
Use Raw.GetXml(anchor) as a template, mutate, resubmit | MalformedXml, DisallowedNamespace, IncompatibleElementType, ValidationFailed |
| Stop, reopen, or accept "no more history" | SessionDisposed, NothingToUndo, NothingToRedo |
Should not happen; treat as a bug. Op is rolled back, safe to retry once or report. Full exception is on session.LastInternalError | InternalError |
For batched lookups (an agent that just enumerated 50 anchors and wants
previews for all of them), use session.GetAnchorInfos(ids) — a single pass
over the AnchorIndex instead of one walk per id. Returns
IReadOnlyDictionary<string, AnchorInfo?> — unknown ids map to null.
Failure is transactional. On any error, no mutation was applied. The pre-op snapshot was taken but is discarded without restoring (because nothing landed in the first place). Failed ops do not consume an undo slot. This holds for both pre-apply validation failures and runtime failures caught and rolled back.
Recipes
These are worked examples drawn from the end-to-end smoke test (DocxSessionSmokeTest.cs::DS999) and the per-tier tests, lightly genericized. They use the .NET API; the TypeScript API is shape-identical (camelCase method names, string anchors, Promise-free synchronous returns from the npm wrapper since everything runs on the WASM worker).
Replace a clause's text while preserving its style and numbering
using var session = new DocxSession(docxBytes);
var anchor = session.Project()
.AnchorIndex.Values
.First(t => t.Anchor.Kind == "h" && t.Anchor.Scope == "body")
.Anchor.Id;
var result = session.ReplaceText(
anchor,
"**Indemnification.** The Provider shall indemnify the Client for any [breach](https://example.com/terms#breach) of the foregoing.");
// result.Success == true
// result.Modified[0].Id == anchor (kind/scope unchanged)
// result.Patch.Markdown contains the freshly-projected scope
// The paragraph's existing w:pPr (Heading1 style + numbering)
// is preserved — only the runs were swapped.
Split a paragraph and promote the second half to a heading
var split = session.SplitParagraph(originalAnchor, characterOffset: 42);
// split.Modified[0].Id == originalAnchor (first half keeps the Unid)
// split.Created[0] is the new anchor on the second half
var secondHalf = split.Created[0].Id;
session.SetParagraphStyle(secondHalf, "Heading2");
// The anchor's kind prefix is now 'h' instead of 'p';
// resolution by Unid still works either way.
Format a character range with bold
// Bold characters 0..5 of the paragraph (whole-paragraph: pass null span)
var r = session.ApplyFormat(
anchor,
new CharSpan(0, 5),
new FormatOp { Bold = true });
Inject a content control via raw XML
var xml = session.Raw.GetXml(paragraphAnchor);
// Wrap the paragraph in a w:sdt for structured tagging
var modified = WrapInSdt(xml, tag: "PartyName", alias: "Party Name");
var r = session.Raw.ReplaceXml(paragraphAnchor, modified);
// r.Created includes the SDT and the preserved inner paragraph anchors
Apply edits as tracked revisions instead of accepted changes
var settings = new DocxSessionSettings
{
TrackedChanges = TrackedChangeMode.RenderInline,
RevisionAuthor = "agent-alpha",
};
using var session = new DocxSession(docxBytes, settings);
session.ReplaceText(anchor, "Updated clause text.");
// The document now contains <w:del> wrapping the old runs and
// <w:ins> wrapping the new runs. The anchor stays live; result.Removed
// is empty. The agent's mental model doesn't change — the EditResult
// shape is the same, just different fields populated.
Undo after a bad call
session.ReplaceText(anchor, /* something wrong */ "");
// Agent realizes the mistake or the user rejects it.
session.Undo();
// State is byte-equal to pre-op. Redo() would re-apply.
Performance budgets (targets, not gates)
| Op | Target on a 100-page DOCX |
|---|---|
new DocxSession(bytes) | < 250 ms |
ReplaceText (1 paragraph) | < 5 ms + < 30 ms re-projection |
InsertParagraph, SplitParagraph | < 5 ms + < 30 ms |
Project() (full) | reuses converter budget: < 1 s |
Save() | < 200 ms |
Undo() | < 50 ms |
| Memory at 50-deep undo on a 5 MB DOCX | < 80 MB |
These are aspirations. Microbenchmarks aren't in CI by default — flag in PR if you measure 2× above target.
Known limits and open questions
MarkdownPatch.Markdownis currently the full re-projection. TheScopeAnchorIdfield correctly identifies the smallest enclosing block, but the payload is the whole document re-projected. A future optimization (per the spec's open questions) is to emit only the markdown for the named scope. Cheap mitigation: callers that care can splice using their cached projection.- Snapshot granularity is per-part XML clone. For documents with very large embedded images or huge tables, per-element diffs would be more memory-efficient. Deferred until measured to be a problem.
- No
ListStyles()query API in v1. Agents must guessstyleIdvalues forSetParagraphStylefrom what they see in the projection.Heading1–Heading6,Quote, andCodeare reliable defaults across most documents. - Closing a session mid-flight from JS. The WASM bridge holds sessions in a static dictionary keyed by handle; if a JS caller drops a
DocxSessionwithout callingclose(), the .NET-side session is not eligible for GC. The npm wrapper exposesSymbol.disposefor TypeScript 5.2+usingblocks; older runtimes need explicit.close(). Save()strips internalPtOpenXml:Unidattributes by default. The projector assigns a Unid to every descendant of every projected scope; persisting them grows large documents by hundreds of KB of attribute noise (a 148 KB NVCA Model COI round-tripped at 588 KB before this default flipped). Anchor ids therefore do not surviveSave→ re-open by default — a fresh session re-assigns Unids and gets new ids. SetDocxSessionSettings.PersistAnchorIds = trueto keep the ids (which keeps the bloat). This resolves Open Question #1 inmarkdown_projection.mdin favor of "clean OOXML out by default, opt in to anchor stability."
Related
markdown_projection.md— the read-side projector this builds on (anchor scheme, scope semantics, projector handlers)docx_converter.md—WmlToHtmlConverterinternals (sibling write-side converter with very different goals)tracked_changes.md— informs theTrackedChangeModesettingincremental_annotation_overlay.md— anchor-based overlay pattern; the read-side analog of this write-side API
Inspection: block metadata
GetBlockMetadata / GetBlockMetadatas / GetListMembership /
GetSectionInfo are pure reads — no mutation, no undo snapshot, no
projection invalidation. Each returns an immutable record (or null when
the anchor doesn't exist).
BlockMetadata
For every block-level anchor, exposes:
AnchorId,Kind,Scope— duplicated fromAnchorInfoso the record is self-contained.StyleId/StyleName—pStyle/@valfor paragraph kinds,tblStyle/@valfor tables.StyleNameresolves through the styles part'sw:name/@val.OutlineLevel—pPr/outlineLvlwhen present; otherwise inferred from aHeadingNstyle (level 0..8). 0-based per Word convention.List— populated for list-item paragraphs (nullotherwise).HasInlineFormatting— true when any descendantw:rcarries a non-emptyw:rPr. Coarse "does this paragraph have any character formatting at all" probe.
ListMembership
For list-item paragraphs (and also surfaced as BlockMetadata.List):
NumId/AbstractNumId/Level/Format— the standard numbering identity quadruple.StartOverride— non-null when the paragraph'sw:numhas aw:lvlOverride/w:startOverrideat this level. Useful for predicting whatRestartNumberedListwill produce.IsAutoNumbered— always true (a paragraph without numbering returnsnullfromGetListMembership).FromStyle— true whenw:numPris inherited from the paragraph's style chain (style → basedOn → basedOn → ...) rather than set inline. Lets callers reason about whether modifying the paragraph in place versus modifying the underlying style is appropriate.GeneratedLabel— same string asAnchorInfo.AutoNumberPrefix, duplicated here so callers don't take two round-trips.
SectionInfo
For anchors in the body part:
SectionUnid— stable id for the governingw:sectPr.PageWidthTwips/PageHeightTwips— raw twips (1 inch = 1440 twips).Landscape— true whenpgSz/@orient = "landscape".MarginTopTwips/MarginBottomTwips/MarginLeftTwips/MarginRightTwips—pgMarattribute values; defaults to 1440 (1 inch) when missing.Columns—cols/@num, defaults to 1.HeaderPartUris/FooterPartUris— package-part URIs of the header/footer parts referenced viaheaderReference/footerReference, in declaration order. Empty when no headers/footers are referenced.
Returns null for anchors in non-body parts (footnotes, endnotes,
headers, footers, comments) — sectPr is body-only.
NumberFormat enum
Closed enum used by ListMembership.Format (read) and by the list
write surface (when it ships). Values: Decimal, UpperLetter,
LowerLetter, UpperRoman, LowerRoman, Bullet. Any OOXML
numFmt value outside this set maps to Decimal (safest fallback).