Skip to content

Fill Word content controls from the model by their tag#138

Open
ds-o wants to merge 1 commit into
Amberg:mainfrom
ds-o:feature/content-control-binding
Open

Fill Word content controls from the model by their tag#138
ds-o wants to merge 1 commit into
Amberg:mainfrom
ds-o:feature/content-control-binding

Conversation

@ds-o

@ds-o ds-o commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds support for binding Word content controls (structured document tags, w:sdt) to the model by their tag. If a content control's Tag is a DocxTemplater placeholder — e.g. {{ds.Name}} or {{ds.Price}:f(c)} — its content is filled with the resolved model value, reusing the existing model lookup and formatter pipeline. The content control itself is preserved, so it stays a named, re-fillable region.

Today the only way to drive a content control is to embed a {{…}} placeholder in its visible content, which is consumed on the first render and can't model a named region that is filled in a later pass. This lets a content control be addressed by its tag instead.

Usage

Set a content control's Tag (Developer → Properties → Tag) to a placeholder:

Content control Tag Result
{{ds.Name}} Filled with the value of ds.Name.
{{ds.Name}:ToUpper()} Inline-text formatters work as in placeholders.
{{ds.DeliveryDate}:F('d MMM yyyy')} Date/number format strings are supported.
{{(ds.A + ds.B)}} C# expressions are supported.
var template = DocxTemplate.Open("template.docx");
template.BindModel("ds", new { Name = "World", DeliveryDate = new DateTime(2026, 7, 8) });
template.Save("generated.docx");

Content controls inside a loop are filled once per iteration (the tag resolves against the loop scope, e.g. {{ds.Items}} or {{.Name}}); content controls in headers/footers are filled too.

Semantics

Unlike a text placeholder, a content control is treated as a named, persistent region:

  • The control and its tag are never removed and are preserved in the output, so it stays addressable.
  • A tag that cannot be bound — unbound (absent) or resolving to nullleaves the control unchanged (under SkipBindingAndRemoveContent). This makes multi-pass filling non-destructive, including when passes share a model type whose other members are simply null rather than absent — a control filled in an earlier pass is never cleared by a later pass.
  • The w:showingPlcHdr flag is cleared on a successful fill, so Word shows the value as real text.
  • Under ThrowException an unbound tag throws; under HighlightErrorsInDocument the control's content is highlighted in place without discarding it.

Robustness / backward compatibility:

  • A tag that is not a placeholder, is a block directive ({{#items}}), or is a malformed brace directive ({{@}}, {{/switch}}, {{}}) is ignored — it never aborts the render. Existing templates are unaffected (a tag only participates if it contains a valid {{…}} placeholder).
  • Target text is scoped to the control's own content, so a nested content control is never consumed or corrupted by an outer one.
  • Collapsing multi-paragraph content leaves no stray empty paragraphs; the first run's formatting is preserved.
  • Only inline-text formatters (ToUpper, format, expressions) are supported on a tag. Block-producing formatters (html, md, img, template) are not supported inside a content control (use them as normal body placeholders).

Implementation

  • The work is done in VariableReplacer.ReplaceVariables(OpenXmlElement, …), the single choke point that fills marked text for both the root element and each cloned loop body — so loops (and headers/footers) are handled for free.
  • For each SdtElement whose tag is a Variable/Expression placeholder, the value is resolved before the content is touched (so an unbound/null tag leaves the control untouched), then written into a single target Text via the existing ApplyFormatter path.
  • The control is never routed through RemoveWithEmptyParent in a way that could dissolve it, and target selection excludes text owned by nested controls.

Tests

New ContentControlBindingTest (18 tests) covering: block and inline controls, formatter and expression tags, non-placeholder and malformed tags left untouched, placeholder-in-content, filling inside a loop and in a header, empty controls, multi-paragraph collapse, nested controls, ThrowException / HighlightErrorsInDocument, bound-null, and the deferred multi-pass scenario with both anonymous and shared model types. Every generated document is asserted valid via DocxTemplate.Validate().

Docs

README: new Content Controls section, a feature-list bullet, and notes on the formatter and GetTemplateSchema() limitations (tag bindings are not part of the rendered text, same as sub-templates).

A Word content control (structured document tag) whose w:tag is a
placeholder - e.g. a tag of `{{ds.Name}}` or `{{ds.Price}:f(c)}` - now
has its content filled from the bound model, using the same model lookup
and formatters as text placeholders. Content controls inside loops are
filled once per iteration, and content controls in headers/footers are
filled too.

A content control is treated as a named, persistent region:
- the control and its tag are preserved (never removed), so it stays
  addressable;
- a tag that cannot be bound - unbound OR resolving to null - leaves the
  control unchanged (under SkipBindingAndRemoveContent), so multi-pass
  filling is non-destructive even when passes share a model type whose
  other members are null;
- the "showing placeholder" flag is cleared on a successful fill;
- under HighlightErrorsInDocument an unbound control is highlighted in
  place without discarding its content.

Robustness: a tag that is not a placeholder, is a block directive
(`{{#items}}`), or is a malformed brace directive (`{{@}}`, `{{/switch}}`)
is ignored rather than aborting the render; the target text is scoped to
the control's own content so a nested control is never corrupted; and
collapsing multi-paragraph content leaves no stray empty paragraphs.

Only inline-text formatters (ToUpper, format, expressions) are supported
on a tag; block-producing formatters (html/md/img/template) are not.

Existing templates are unaffected (a tag only participates if it contains
a valid `{{...}}` placeholder).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant