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 at DocxSessionSmokeTest.cs. WASM bridge: wasm/DocxodusWasm/DocxSessionBridge.cs. npm wrapper: npm/src/session.ts. The full type-level spec lives at docs/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's PtOpenXml.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-major CellContents, CellAlignment, per-column ColumnWidths), then edit cells with ReplaceCellContent or address each cell-paragraph anchor; reshape with InsertTableRow/InsertTableColumn/DeleteTableRow/DeleteTableColumn (by a cell-paragraph anchor; v1 assumes a rectangular grid, no w: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-INSERTED projects as RAWSIBLING\-INSERTED). Don't write literal Contains(...) assertions over hyphenated tokens; either strip backslashes from the projection or use tokens without markdown-significant characters.
  • InsertParagraph with a bulleted markdown payload does not inherit list numbering. A payload like - item one parses as a BulletItem block and the created anchor has kind li, but the inserted paragraph has no w:numPr, so Word renders it as a plain paragraph (and SetListLevel will return AnchorWrongKind because there is no numbering to adjust). To get a real bulleted item in v1, use Raw.InsertXml with a fragment derived from Raw.GetXml(existingListItemAnchor) so the w:numPr and 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:

OpCreatedRemovedModifiedPatch scope
ReplaceText(p, md)— (markdown subset can't introduce inline anchors in v1)descendant inline anchors that no longer exist (rare)pp
DeleteBlock(p) (or h/li/tbl)p + all descendant anchorsnearest 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 blocksmallest enclosing common parent
SplitParagraph(p, offset)the second halfp (first half — convention)enclosing parent
MergeParagraphs(a, b)b + descendantsaa
ApplyFormat(p, span?, op)pp
SetParagraphStyle(p, style)p (kind prefix may flip)p
SetListLevel(p, delta)penclosing list (downstream items renumber)
RemoveListMembership(p)p (kind flips lip)enclosing list
ReplaceCellContent(tc, md)descendant inline anchors (rare)tctc
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 XMLenclosing 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:

  • SplitParagraph keeps 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.
  • MergeParagraphs lets 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 DeleteBlock loop for those).
  • from must precede to in 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 for ReplaceText.
  • 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 — AnnotationManager only 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 / MergeParagraphs call 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 (GetSectionInfoHeaderPartUris/FooterPartUris), never author them. These three methods close that gap; they're exposed in .NET, WASM/npm, and stdio/docx-scalpel.

Methods

MethodWhat 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: Firstw: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:evenAndOddHeaders is 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, like w:hdrShapeDefaults/w:shapeDefaults that real Word documents carry — stays exactly where it was. (An earlier whole-part reorder corrupted such documents; DS263/DS264 pin 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.

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 sectionSetPageNumbering(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 fieldInsertPageNumberField(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/SetFooterText create 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 no data-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). ClearPageNumbering preserves 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

MethodWhat 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 -1 and w:type="continuationSeparator" at id 0. The projector already filters these out of the anchor index (IsBoilerplateNote), and DeleteBlock already refuses to remove them.
  • the w:footnotePr/w:endnotePr settings declaration naming those two ids, inserted at its CT_Settings schema slot via the shared EnsureSettingsChildInOrder — the settings part is never wholesale reordered (same discipline as w:evenAndOddHeaders; see the header/footer section for the document this protects).
  • the FootnoteText/EndnoteText paragraph style and the superscript FootnoteReference/EndnoteReference character style, find-or-create via StyleFactory, 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. DS322 pins 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/DS337 pin 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.RenderHtml and the editor's first-paint completeArgs both set RenderFootnotesAndEndnotes. 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.AssignAnchorUnids assigns the deterministic Unids to the footnotes/endnotes parts as well as the main part, so note paragraphs carry data-anchor and the editor wires them as ordinary blocks — no new command code, the whole ribbon works inside a note. FindByUnid searches the note parts too, so the stateless RenderBlockHtml can 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 one GENERATED_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 an h:style element), 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-item in 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-footnotes styling context they render in so the reserve matches the paint.

DS340DS345 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) — the w:footnotePr written 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 = RenderInline does not wrap the citation in w:ins — consistent with every other insert op (InsertParagraph, InsertTable, InsertHorizontalRule, SetHeaderText); only ReplaceText/DeleteBlock/DeleteRange track.
  • Narrowed projection scopes. A session opened with ProjectionSettings.Scopes excluding Footnotes/Endnotes still writes the note correctly, but Created comes back without the note anchors — they resolve against a projection that omits the part. Family behavior, identical to SetHeaderText with Headers excluded.

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

MethodDescription
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).
  • AnnotationNotFoundRemove/Update/Move invoked with an unknown id.
  • EmptyAnnotationSpanspan.Length == 0, or span == null and 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:

PlaceholderKindPatternWhat 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; FillPlaceholders sorts 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 $. With FillOptions.PreserveDollarPrefix = true (default), the picker's return value gets $ prepended when needed so "0.20" lands as $0.20, not 0.20.
  • Multi-pass iteration. FindPlaceholders returns 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 (or MaxPasses — 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 neighborRight neighborActionExample
whitespace (incl. NBSP, narrow NBSP, thin space)whitespaceconsume 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:

FieldSource
TotalAnchorsProject().AnchorIndex.Count
RemainingPlaceholdersFindPlaceholders(All, All)
BareUnderscoreRunsGrep(@"(?<![\[_])_{3,}(?![\]_])") (underscore-aware lookarounds bound the maximal run so the count matches the visible underline groups — see DS280b/c)
FootnoteCountAnchorIndex filter on kind=fn, scope=fn (excludes reserved separators per #162)
InlineFootnoteRefCountBody part's w:footnoteReference count
CommentCountAnchorIndex 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:

ValueBehavior
SelfOnlyJust the addressed block — its anchor and its own text. For headings, returns only the heading paragraph, not the section underneath.
SubtreeSelf + 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.

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 — one RunFragment per <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 visible Formatting (bold/italic/strike/underline/code/color/hyperlink/runStyle).
  • ContextBefore / ContextAfter — up to contextChars (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: every TextMatch carries a single EnclosingAnchor for the caller to hand back to ReplaceText/Raw.ReplaceXml. For cross-block search (legal clauses split for readability, multi-paragraph regions, etc.) use GrepCrossBlock (see next section).
  • RegexOptions is the .NET enum; the npm wrapper passes its numeric value through (see GrepOptions in npm/src/types.ts).
  • Tracked-change content currently follows the projector's accepted/rendered text — Settings.TrackedChanges = StripDeletions won'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:

ModeStops atUse when
Char (default)nothing — truncate at contextCharslegacy 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

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 \n characters 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. Each BlockSlice names its Anchor, the SpanInBlock (offset+length within that block's own flat text), and a Fragments list with the same shape as Grep's.
  • ContextBefore / ContextAfter — surrounding text from the concatenated stream; may include block-boundary \n characters.
  • Groups — regex capture groups.

Separator and regex behavior

Adjacent blocks in the searched text are joined with a single \n. That means:

  • ^ and $ with RegexOptions.Multiline anchor at block boundaries.
  • . does not match across boundaries unless RegexOptions.Singleline is set.
  • \s, \n, and explicit \n patterns 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:tc is the parent).
  • Non-paragraph siblings — a w:tbl, sectPr, or any non-w:p element 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 textAnchorNotFound
Re-read the anchor's kind via GetAnchorInfo, reissue with the right op or coordinatesAnchorWrongKind, 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.InsertXmlTableInsertNotSupported, 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, resubmitMalformedXml, 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.LastInternalErrorInternalError

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)

OpTarget 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.Markdown is currently the full re-projection. The ScopeAnchorId field 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 guess styleId values for SetParagraphStyle from what they see in the projection. Heading1Heading6, Quote, and Code are 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 DocxSession without calling close(), the .NET-side session is not eligible for GC. The npm wrapper exposes Symbol.dispose for TypeScript 5.2+ using blocks; older runtimes need explicit .close().
  • Save() strips internal PtOpenXml:Unid attributes 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 survive Save → re-open by default — a fresh session re-assigns Unids and gets new ids. Set DocxSessionSettings.PersistAnchorIds = true to keep the ids (which keeps the bloat). This resolves Open Question #1 in markdown_projection.md in favor of "clean OOXML out by default, opt in to anchor stability."

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 from AnchorInfo so the record is self-contained.
  • StyleId / StyleNamepStyle/@val for paragraph kinds, tblStyle/@val for tables. StyleName resolves through the styles part's w:name/@val.
  • OutlineLevelpPr/outlineLvl when present; otherwise inferred from a HeadingN style (level 0..8). 0-based per Word convention.
  • List — populated for list-item paragraphs (null otherwise).
  • HasInlineFormatting — true when any descendant w:r carries a non-empty w: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's w:num has a w:lvlOverride/w:startOverride at this level. Useful for predicting what RestartNumberedList will produce.
  • IsAutoNumbered — always true (a paragraph without numbering returns null from GetListMembership).
  • FromStyle — true when w:numPr is 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 as AnchorInfo.AutoNumberPrefix, duplicated here so callers don't take two round-trips.

SectionInfo

For anchors in the body part:

  • SectionUnid — stable id for the governing w:sectPr.
  • PageWidthTwips / PageHeightTwips — raw twips (1 inch = 1440 twips).
  • Landscape — true when pgSz/@orient = "landscape".
  • MarginTopTwips / MarginBottomTwips / MarginLeftTwips / MarginRightTwipspgMar attribute values; defaults to 1440 (1 inch) when missing.
  • Columnscols/@num, defaults to 1.
  • HeaderPartUris / FooterPartUris — package-part URIs of the header/footer parts referenced via headerReference / 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).