Native content controls
August 15, 2026 · View on GitHub
DocxSession treats Word structured-document tags (w:sdt) as first-class live
objects. ListContentControls() walks every requested story part and returns controls
in package story order and outer-before-inner document order. Every entry includes its
native w:id, type, placement, tag, alias, lock, placeholder state, binding facts,
owning part/scope, nesting parent/depth, current text/list values, and mutation status.
Identity and projection
The public anchor is sdt:{scope}:{unid}. For a unique, valid signed 32-bit
w:sdtPr/w:id, unid is a deterministic hash of that native id; the scope identifies
the owning story. It therefore survives value edits and a normal clean save/reopen,
even though PtOpenXml:Unid bookkeeping is stripped. Missing, invalid, or
package-wide duplicate native ids remain enumerable under deterministic diagnostic
anchors but are not mutable. Repeating-item clones receive fresh native ids before
their anchors are made public.
Mutation also requires an exact SDT envelope: one w:sdtPr, one w:sdtContent,
one w:id, no more than one mutually exclusive family marker, and at most one
w:lock carrying a native lock value. This envelope is deliberately stricter than
CT_SdtPr, where both w:sdtPr and w:id are minOccurs="0": a spec-valid control
from a non-Word generator that omits either is enumerable but not mutable. The gate fails
closed — it never edits markup it cannot address — at the cost of refusing some
schema-legal input Word itself never emits. The same malformed-envelope gate applies through
ancestors, so a valid child cannot bypass a malformed outer lock. Malformed controls stay
enumerable under diagnostic anchors and fail before an undo snapshot. A repeating template
is cloneable only when every nested SDT satisfies the same invariant.
sdt is an AnchorIndex kind in both the WML projector and the immutable IR emitter.
The IR captures projector-order anchor facts while its private package is open, so
index parity also holds with RetainSources=false. The current Markdown oracle indexes
the outer SDT but omits content delivered through an inline or block SDT, so adding the
anchor does not change historical Markdown bytes. HTML and ListBlocks remain wrapper-
transparent and flatten/render the contained blocks.
ListInlineSpans additionally returns outer-to-inner contentControlAnchorIds for each
run.
Mutations
The typed surface is deliberately operation-specific:
FillContentControlTextandFillContentControlRichTextSetContentControlCheckedandSetContentControlDateSelectContentControlItemfor dropdowns and combo boxesFillContentControlPicture, using the native-image byte validation and relationship management shared with issue #453AddRepeatingSectionItemandRemoveRepeatingSectionItem
Fills preserve w:sdt, w:sdtPr, w:sdtEndPr, and metadata not owned by the
operation. Text fills retain representative run/paragraph properties and clear only
the showing-placeholder marker. Empty rich-text input normalizes to one schema-safe
empty paragraph or run payload, preserving the wrapper and placeholder definition while
clearing its showing-state marker. Picture fills replace the image relationship without
rebuilding the wrapper. Discovery and fill share the same picture topology gate: exactly
one canonical, embedded, mutable image must belong to the control, so CanMutate cannot
advertise zero-image, multi-image, linked, or unsupported picture controls as writable.
Repeating clones freshen every nested content-control id,
drawing docPr id, and w14:paraId — Word 2013+ stamps a paraId on essentially every
paragraph, so refusing to clone one would make the feature inert on real templates;
paraId is package-unique, so the clone mints fresh values from the same allocator native
comment authoring uses. w14:textId is deliberately copied verbatim: it is a hash of the
paragraph's text rather than an identity, and Word emits the same value for two paragraphs
with the same content. Clones still reject markup whose identity is semantic —
bookmark, comment, permission, custom XML container/range, move, and note-reference. The
clone gate also rejects every live tracked-revision carrier recognized by the revision
registry; duplicating such markup would duplicate its native revision ids and make later
resolution ambiguous. The final item cannot be removed.
Dropdown selection writes the selected item's native w:lastValue as well as its
displayed text. Combo boxes do the same for a listed item and also accept custom text;
custom text becomes both the displayed payload and w:lastValue. Checkbox fills honor
the selected w14:checkedState/w14:uncheckedState font on the produced glyph run.
Whole-content replacement and repeating-item removal use the same bookmark-removal gate as other structural edits: crossing or externally referenced ranges fail before history changes. Rich-text links are validated against the document that will remain after replacement, so a payload cannot target a bookmark it simultaneously removes. After a successful replacement/removal, owner-local hyperlink relationships are promoted or reference-counted away and unreferenced image parts are swept. Undo/redo restores that XML and package relationship topology together.
Every successful operation is one undo/redo step. Text, rich-text, checkbox, date,
dropdown, and combo-box fills support inline and block controls only. Row/cell controls
remain enumerable; picture and repeating-section operations use their own structural
shape checks instead of the text-placement rule. Whole-control fills are rejected in
render_inline tracked-change mode because they do not yet have a faithful replacement
revision encoding; surgical text/format operations inside a control remain available.
Empty and nested-SDT-only payloads derive row/cell/block/inline placement from their nearest
owning content-model boundary rather than defaulting to block placement.
Anchor and receipt lifecycle
Typed fills preserve the selected wrapper identity and return that sdt anchor in
Modified. AddRepeatingSectionItem returns the fresh item anchor in Created and the
section anchor in Modified. RemoveRepeatingSectionItem returns the item anchor in
Removed and the section anchor in Modified. These identities remain usable through
undo/redo according to whether the corresponding wrapper is live.
Generic tracked DeleteRange/DeleteSection keeps a selected SDT wrapper live until
revision resolution. Its receipt therefore reports the wrapper sdt anchor and every
retained descendant anchor in Modified; a structural fall-through that is actually
removed appears in Removed.
Locks, bindings, and nesting
Content locks are effective through ancestors for the content-control operations on
this page. w:lock is not yet consulted by the generic anchor-addressed surface: an
op such as ReplaceTextAtSpan or DeleteRange still edits through a contentLocked
control. Honouring w:lock document-wide is a separate change to the generic
mutation path and is outside issue #452. A locked target or ancestor fails
without changing history. A whole-content replacement that would discard a nested
control is also refused; callers address the nested child directly.
CanMutate is the single honest answer to "would a mutation succeed?", and discovery
evaluates the same gates the mutation does, in the same order. That includes the
session-level ones: under render_inline tracked-change mode every control reports
CanMutate: false with the tracked reason, and a whole-content replacement whose
bookmark ranges the fill would orphan — or that an internal hyperlink still targets —
is reported unmutable before it is attempted. A registry that advertised a mutation the
session is guaranteed to refuse is worse than no registry: an agent planning off it
builds a batch that cannot apply.
For repeating sections, CanMutate describes the default operation honestly: a
section must have a safe final clone template, and an item is removable only when it
is a direct child, at least one sibling item will remain, and its wrapper is not
locked. Orphaned/non-direct items and clone-sensitive templates remain enumerable with
the corresponding diagnostic.
Bindings fail closed by default. Both w:dataBinding and the Office 2013
w15:dataBinding form are recognized. bindingPolicy: "detach_target" is the only
opt-in: it removes the selected control's own native binding element before the
mutation. It never removes an ancestor binding and never edits or regenerates a
Custom XML data part. A target inside a bound ancestor is always refused.
Transports
The shared JSON facade, WASM bridge, TypeScript package, Python host/client, and MCP
server expose the same typed operations. Options JSON is strict: the only fill option
is bindingPolicy, with preserve (default) or detach_target. The MCP grouped tool
is docxodus_content_controls; it advertises the same optional optimistic
preconditions guard as the other mutating tools, and its mutating actions participate in
docxodus_mutations apply/preview rollback, while list is read-only and rejected as
a batch step. Picture bytes cross JSON transports only as base64.
An omitted date displayText selects the invariant default; an explicitly empty
string remains empty through the JSON, WASM, and TypeScript layers.
Failures are structured EditErrorCode values, including not found, malformed,
unsupported family/placement, wrong type, locked, bound, invalid value, unsafe nested
fill, and repeating-section constraint errors. Refused operations do not consume undo
history.