Content controls

Find and fill Word content controls by tag, alias, or id. Set text, dropdown, checkbox, and date values and manage repeating sections, headless or live.

Word content controls (w:sdt, Structured Document Tags) are labeled, bounded regions of a document. A block-level control wraps one or more paragraphs or a table and carries a stable tag, alias, and id. That stability makes them the natural anchor for templates, conditional sections, and document automation: design the template in Word, tag the fillable regions, then fill them by tag from code.

The editor parses block controls into the document model, keeps them editable, renders their boundary, and round-trips them losslessly on save, including unmodeled properties such as w:dataBinding and w15:repeatingSection.

Content controls are addressed by tag, alias, or id. They are not the {{mustache}} template variables handled by the docxtemplater plugin; the two systems coexist in one document.

Finding controls

Headless (no DOM, no editor), against a parsed Document:

import {
  parseDocx,
  findContentControls,
  findContentControl,
} from "@eigenpal/docx-editor-core/headless";

const doc = await parseDocx(buffer);

const all = findContentControls(doc);                       // ContentControlInfo[]
const checkboxes = findContentControls(doc, { type: "checkbox" });
const intro = findContentControl(doc, { tag: "intro" });    // first match or undefined

The filter is { tag?, alias?, id?, type? }. Each ContentControlInfo carries tag, alias, id, sdtType, lock, listItems, placeholder, text, plus modeled state: showingPlaceholder, checked, dateFormat, and dataBinding.

When showingPlaceholder is true, text is the placeholder boilerplate ("Click here to enter text"), not entered content. Check this flag before treating text as real data.

Setting text content

setContentControlContent fills a richText or plainText control with a string (or BlockContent[] headless):

import { setContentControlContent, removeContentControl } from "@eigenpal/docx-editor-core/headless";

let next = setContentControlContent(doc, { tag: "intro" }, "Filled by template");

// Conditional sections: drop a control, or unwrap it (keep its content)
next = removeContentControl(next, { tag: "optionalClause" });
next = removeContentControl(next, { tag: "wrapper" }, { keepContent: true });

All headless mutators are pure: they return a new Document and preserve the control's identity and raw w:sdtPr, so the result still round-trips. Mutators affect the first match only; when a tag repeats, enumerate with findContentControls and disambiguate by id.

Safety rules (each overridable with { force: true }):

  • A content-locked control throws ContentControlLockedError on edit; a deletion-locked one throws on remove.
  • A typed control (dropdown, date, checkbox, picture, group) throws ContentControlTypeError on free-text replacement. Use the typed setter below.
  • A data-bound control (w:dataBinding) throws ContentControlBoundError: its content is driven by the Custom XML store, so a direct write would not persist in Word.
  • An unmatched filter throws ContentControlNotFoundError.

Setting typed values (dropdown, checkbox, date)

setContentControlValue updates both the visible content and the structured w:sdtPr state:

import { setContentControlValue } from "@eigenpal/docx-editor-core/headless";

setContentControlValue(doc, { tag: "status" }, { kind: "dropdown", value: "2" });
setContentControlValue(doc, { tag: "agree" }, { kind: "checkbox", checked: true });
setContentControlValue(doc, { tag: "effective" }, { kind: "date", date: "2026-06-01" });

A dropdown value must match one of the control's list items (by value or display text). A date is ISO yyyy-mm-dd and is rendered with the control's w:dateFormat. comboBox controls are pick-only today; typing a value outside the list is not supported.

In the editor, typed controls also get an interactive trigger at the top-right of their box: it toggles the checkbox, opens the dropdown's item menu, or opens a date picker, each as a normal undoable edit. No wiring required, in both the React and Vue adapters.

Repeating sections

Repeating-section controls (w15:repeatingSection) round-trip verbatim, and two helpers manage their items:

import { addRepeatingSectionItem, removeRepeatingSectionItem } from "@eigenpal/docx-editor-core/headless";

// Clone an item (with fresh ids); afterIndex picks the insertion point.
let next = addRepeatingSectionItem(doc, { tag: "lineItems" }, { afterIndex: 0 });

// Drop one item by index.
next = removeRepeatingSectionItem(next, { tag: "lineItems" }, 2);

Unwrapping a repeating-section control is refused, since it would orphan the w15 structure.

In the live editor

The same operations exist on DocxEditorRef, running against the editor so writes are normal undoable edits:

const ref = useRef<DocxEditorRef>(null);

ref.current?.getContentControls({ type: "dropDownList" }); // PMContentControl[]
ref.current?.scrollToContentControl({ tag: "intro" });
ref.current?.setContentControlContent({ tag: "intro" }, "Filled");  // boolean
ref.current?.setContentControlValue({ tag: "agree" }, { kind: "checkbox", checked: true });
ref.current?.removeContentControl({ tag: "optionalClause" });

These return false when no control matches; locked and typed controls throw under the same rules as headless unless { force: true } is passed. The ref's setContentControlContent takes a string (newlines become paragraphs).

Headless document generation

A complete fill-and-export pipeline in Node:

import { readFile, writeFile } from "node:fs/promises";
import {
  parseDocx,
  setContentControlContent,
  setContentControlValue,
  removeContentControl,
  repackDocx,
} from "@eigenpal/docx-editor-core/headless";

const buffer = await readFile("contract-template.docx");
let doc = await parseDocx(buffer);

doc = setContentControlContent(doc, { tag: "customerName" }, "Acme GmbH");
doc = setContentControlValue(doc, { tag: "effective" }, { kind: "date", date: "2026-07-01" });
doc = removeContentControl(doc, { tag: "betaClause" }); // condition not met

const out = await repackDocx(doc);
await writeFile("contract-acme.docx", Buffer.from(out));

Current limits

  • Scope is block-level controls in the document body. Inline controls parse and round-trip but are not part of this addressing API.
  • Controls inside headers/footers and inside table cells are not discovered; findContentControls will not return them and mutators report "not found".
  • dataBinding and repeating sections round-trip verbatim but have no live behavior (no bound-value resolution, no automatic repeat expansion).

Next steps

On this page