Parchment is a Word (.docx) document generation library with two complementary rendering modes. It combines a .NET data model with either a docx template (token replacement, loops, conditionals) or a markdown template (full content rendering), both driven by liquid via Fluid. Markdown is parsed with Markdig; HTML chunks are converted via OpenXmlHtml.
See Milestones for release notes.
Parchment supports two complementary template formats:
- Docx template — start from a hand-crafted Word document with
{{ field }}substitution tokens and{% for %}/{% if %}block tags. Output preserves every detail of the source document. - Markdown template — start from a
.mdfile with full liquid support. Markdown is parsed by Markdig and rendered into a target docx. Optionally provide a style-source.docxwhose styles, headers, footers, and section properties are inherited.
Invoice {{ Number }}
Customer: {{ Customer.Name }}
Total: {{ Total }} {{ Currency }}var store = new TemplateStore();
store.RegisterDocxTemplate<Invoice>("substitution", template);
using var stream = new MemoryStream();
await store.Render("substitution", SampleData.Invoice(), stream);Tokens match model members case-insensitively via Fluid's default member access strategy, but Parchment uses PascalCase by convention ({{ Customer.Name }}), not snake_case. There is no snake-case translation layer — stick with the property name as declared on the model.
Tokens in MainDocumentPart, every HeaderPart / FooterPart, FootnotesPart, and EndnotesPart are all scanned and substituted, so page headers, footers, and footnotes can use {{ ... }} and {% ... %} exactly like body paragraphs.
A {% for %} paragraph and its matching {% endfor %} repeat the intervening content once per item.
{% for line in Lines %}
- {{ line.Description }}: {{ line.Quantity }} x {{ line.UnitPrice }}
{% endfor %}
The body isn't restricted to paragraphs. Anything between the open and close paragraphs at the same nesting level — additional paragraphs, <w:tbl> tables, images, section breaks — is captured and cloned per iteration. This makes per-group "heading + table" layouts straightforward: place a styled heading paragraph and a hand-crafted Word table between {% for %} and {% endfor %}, and reference the loop variable inside the table cells.
{% for dept in Departments %}
[heading paragraph: {{ dept.Name }}]
[Word table with cells {{ dept.X }}, {{ dept.Y }}, ...]
{% endfor %}
The open and close tags must be siblings (same parent element). A {% for %} paragraph in the body and an {% endfor %} paragraph inside a table cell will not pair up.
{% if Customer.IsPreferred %}
Preferred customer: {{ Customer.Name }}
{% else %}
Regular customer: {{ Customer.Name }}
{% endif %}
Loops and conditionals can be nested to arbitrary depth. The outer loop variable stays in scope for inner constructs.
{% for group in Groups %}
{{ group.Name }}
{% for item in group.Items %}
- {{ item }}
{% endfor %}
{% endfor %}
Declare a model property as TokenValue and return one of:
- A plain
string(or any value Fluid stringifies) — plain text substitution. This is the default when the property is typed asstringdirectly; assigning a string to aTokenValueproperty goes through the same path via the implicit conversion. new MarkdownToken(string)— the value is rendered as markdown via Markdig and spliced into the host paragraph.new HtmlToken(string)— the value is converted from HTML (via OpenXmlHtml) and spliced into the host paragraph.new OpenXmlToken(Func<IOpenXmlContext, IEnumerable<OpenXmlElement>>)— the callback emits raw OpenXML elements. Useful for rich tables, generated charts, custom-styled lists.new MutateToken(Action<Paragraph, IOpenXmlContext>)— the callback receives the host paragraph and mutates it in place. The token text is cleared before the callback runs. Useful for adding runs with custom formatting, injecting bookmarks, or tweaking paragraph properties while preserving the original paragraph.
bullet_list and numbered_list render an IEnumerable<string> property as a real Word list (<w:numPr> with a proper numbering definition), not as literal text. The token must sit alone in its paragraph — the host paragraph is replaced with one <w:p> per item.
Content:
using var template = DocxTemplateBuilder.Build(
"""
Tags:
{{ Tags | bullet_list }}
""");Render:
var store = new TemplateStore();
store.RegisterDocxTemplate<Invoice>("bullet-filter", template);
using var stream = new MemoryStream();
await store.Render("bullet-filter", SampleData.Invoice(), stream);numbered_list is identical in shape — swap the filter name to produce a decimal-numbered list instead of bullets.
Escapes <, >, &, ", and ' in a string value. Useful when a token's value is spliced into a context where raw markup would be interpreted — e.g. feeding a user-supplied string into a MarkdownToken source that contains HTML blocks:
{{ UserSuppliedComment | escape_xml }}
Declare a model property as TokenValue and return new MarkdownToken(...) to inject rendered markdown at the substitution site:
Model:
public class NoteModel
{
public required string Title;
public required TokenValue Body;
}Content:
# {{ Title }}
{{ Body }}Render:
var store = new TemplateStore();
store.RegisterDocxTemplate<NoteModel>("markdown-hatch", template);
await store.Render(
"markdown-hatch",
new NoteModel
{
Title = "Weekly summary",
Body = new MarkdownToken(
"""
## Highlights
- Shipped the **new feature**
- Closed _several_ bugs
- Ran a code review
> Stay the course
""")
},
stream);Alternatively, use the | markdown filter on a plain string property:
Model:
public class ArticleModel
{
public required string Heading;
public required string Content;
}Content:
# {{ Heading }}
{{ Content | markdown }}Render:
var store = new TemplateStore();
store.RegisterDocxTemplate<ArticleModel>("markdown-filter", template);
await store.Render(
"markdown-filter",
new ArticleModel
{
Heading = "Release notes",
Content =
"""
### Bug fixes
- Fixed crash on **empty input**
- Resolved _timeout_ in batch mode
"""
},
stream);Both approaches produce the same structural replacement — the host paragraph is swapped with the rendered markdown elements when the token is the entire paragraph. If the token shares its paragraph with other text or with sibling tokens, the runtime falls back to inline splicing (single produced paragraph → its runs are extracted and merged into the host) or paragraph splitting (multiple produced blocks → host is split at the token offset and the produced blocks slot between the two halves). See Inline-aware structural replacement for the full rules.
Markdown templates: Neither MarkdownToken nor | markdown is needed when using RegisterMarkdownTemplate. The entire template is already markdown — a plain string property containing markdown syntax is interpolated into the source before Markdig parses it, so formatting works automatically:
Model:
public class BriefModel
{
public required string Title;
public required string Details;
}Content:
Render:
var store = new TemplateStore();
store.RegisterMarkdownTemplate<BriefModel>(
"brief",
markdown,
styleSource);
await store.Render(
"brief",
new BriefModel
{
Title = "Sprint recap",
Details =
"""
## Done
- Landed the **search** feature
- Fixed _three_ regressions
> Ship it.
"""
},
targetStream);Declare a model property as TokenValue and return new HtmlToken(...) to convert HTML source to Word elements at the substitution site. Use this when the value arrives as HTML — typically from a CMS, a WYSIWYG editor, or any system that already speaks HTML:
Model:
public class PostModel
{
public required string Title;
public required TokenValue Body;
}Content:
# {{ Title }}
{{ Body }}Render:
var store = new TemplateStore();
store.RegisterDocxTemplate<PostModel>("html-hatch", template);
await store.Render(
"html-hatch",
new PostModel
{
Title = "Welcome",
Body = new HtmlToken(
"""
<p>Welcome to the <b>weekly digest</b>.</p>
<ul>
<li>Search performance is up</li>
<li>Two regressions closed</li>
</ul>
""")
},
stream);When the output can't be expressed as markdown or HTML — generated tables, charts, custom-styled callouts — use new OpenXmlToken(...) to emit raw OpenXML directly. The callback receives an IOpenXmlContext exposing numbering, image parts, and style lookups:
Model:
public class ReportModel
{
public required string Title;
public required TokenValue Callout;
}Content:
# {{ Title }}
{{ Callout }}Render:
var store = new TemplateStore();
store.RegisterDocxTemplate<ReportModel>("openxml-hatch", template);
await store.Render(
"openxml-hatch",
new ReportModel
{
Title = "Status",
Callout = new OpenXmlToken(_ =>
[
new Paragraph(
new Run(
new RunProperties(
new Color
{
Val = "C00000"
},
new Bold()),
new Text("Critical: review required")
{
Space = SpaceProcessingModeValues.Preserve
}))
])
},
stream);Use new MutateToken(...) to receive the host paragraph and modify it in place. The token text is cleared before the callback runs, so the original paragraph structure (properties, styles) is preserved:
Model:
public class StyledModel
{
public required string Label;
public required TokenValue Highlight;
}Content:
{{ Label }}
{{ Highlight }}Render:
var store = new TemplateStore();
store.RegisterDocxTemplate<StyledModel>("mutate", template);
await store.Render(
"mutate",
new StyledModel
{
Label = "Before",
Highlight = new MutateToken((paragraph, _) =>
{
paragraph.Append(
new Run(
new RunProperties(
new Bold()),
new Text("Custom content")
{
Space = SpaceProcessingModeValues.Preserve
}));
})
}, stream);Mark any collection property on the model with [ExcelsiorTable] and the matching {{ ... }} substitution is rendered as a fully-formatted Word table by Excelsior at render time. Headings, column ordering, formatting, null display, and custom render callbacks all come from Excelsior's [Column] attribute on the element type — the same configuration surface used for spreadsheets.
Mark the collection on the model:
public class Quote
{
public required string Reference;
[ExcelsiorTable]
public required IReadOnlyList<QuoteLine> Lines;
}
public class QuoteLine
{
[Column(Heading = "Item", Order = 1)]
public required string Description;
[Column(Heading = "Qty", Order = 2)]
public required int Quantity;
[Column(Order = 3, Format = "C0")]
public required decimal UnitPrice;
}Drop a {{ Lines }} substitution into the template on its own line. The template:
Register and render normally:
var templatePath = Path.Combine(ScenarioPath("excelsior-table"), "input.docx");
var store = new TemplateStore();
store.RegisterDocxTemplate<Quote>("excelsior-quote", templatePath);
var model = new Quote
{
Reference = "Q-2026-0042",
Lines =
[
new()
{
Description = "Strategy workshop",
Quantity = 2,
UnitPrice = 4500m
},
new()
{
Description = "Implementation support",
Quantity = 8,
UnitPrice = 1750m
},
new()
{
Description = "Documentation review",
Quantity = 1,
UnitPrice = 950m
}
]
};
using var stream = new MemoryStream();
await store.Render("excelsior-quote", model, stream);The rendered output:
Rules:
- The substitution must sit alone in its own paragraph — structural table replacement swaps the entire host paragraph, so surrounding text would be discarded. The runtime throws at registration (
ParchmentRegistrationException) and the source generator emitsPARCH007if this is violated. - The substitution must be a plain member-access expression — filters (
{{ Lines | reverse }}) and arithmetic are rejected because the Excelsior path walks the model object directly and bypasses Fluid evaluation. DiagnosticPARCH008covers this at compile time. - Nested paths like
{{ Customer.Lines }}work — the registration walks the model type recursively at build time, so[ExcelsiorTable]can sit on any reachable collection property. - Currency and date formatting in the rendered table honor
Excelsior.ValueRenderer.Culture(defaults toCultureInfo.CurrentCulture). Set it once in a module initializer to override the default locale. - Loop-scoped tokens are not rendered as tables — the map is keyed on dotted paths from the root model, so
{{ dept.Lines }}inside{% for dept in Departments %}falls through to plain Fluid output. For a per-group table inside a loop (or any table needing styling beyond the options below), build it directly withOpenXmlToken+ Excelsior'sWordTableBuilder— see Loop-scoped or fully-custom tables below.
By default the rendered table uses Excelsior's default cell formatting, which falls back to the host document's Normal paragraph style. To drive the cell font, size, and spacing from named paragraph styles defined in the template, set HeadingParagraphStyle / BodyParagraphStyle on the attribute:
public class StyledQuote
{
[ExcelsiorTable(HeadingParagraphStyle = "TBLHeading", BodyParagraphStyle = "TBLText")]
public required IReadOnlyList<QuoteLine> Lines;
}The style ids must exist in the template's styles part. Unlike inline run formatting, a paragraph style reaches every cell paragraph — including IsHtml and link cells — so the look is consistent across all content. (These map straight onto Excelsior's WordTableBuilder.HeadingParagraphStyle/BodyParagraphStyle.)
[ExcelsiorTable] only resolves for collections reachable from the root model — a token like {{ dept.Lines }} inside a {% for dept in Departments %} loop falls through to plain Fluid output. The same applies when a table needs styling or grouping beyond what the attribute exposes. In those cases, bypass the attribute and build the table directly with Excelsior's WordTableBuilder, returning it from an OpenXmlToken on a TokenValue-typed property:
public class GroupedReport
{
public required IReadOnlyList<QuoteLine> Lines;
// [ExcelsiorTable] can't render a loop-scoped or fully-styled table, so build it directly
// with Excelsior's WordTableBuilder and return it from an OpenXmlToken. The token's
// {{ LinesTable }} substitution must sit alone in its paragraph (structural replacement).
public TokenValue LinesTable =>
new OpenXmlToken(context =>
[
new WordTableBuilder<QuoteLine>(Lines)
.HeadingParagraphStyle("TBLHeading")
.BodyParagraphStyle("TBLText")
.Build(context.MainPart)
]);
}Reference the property with a solo {{ LinesTable }} token (it must sit alone in its paragraph — the same structural-replacement rule as any OpenXmlToken). The full WordTableBuilder surface is available — headingStyle/bodyStyle callbacks, per-column CellStyle, and the HeadingParagraphStyle/BodyParagraphStyle shown above. Because the property is evaluated per render, the same pattern inside a {% for %} loop produces one table per iteration.
Mark a string property with [Html] or [Markdown] (any attribute named HtmlAttribute / MarkdownAttribute, or [StringSyntax("html")] / [StringSyntax("markdown")]) and the matching {{ ... }} substitution is rendered as a structurally-replaced block of Word content instead of raw text. Html runs through the OpenXmlHtml converter; markdown runs through the same Markdig pipeline used by the full markdown template flow.
The attributes are detected by name — Parchment does not ship them. Define them in a consuming project (or use [StringSyntax("html")] from System.Diagnostics.CodeAnalysis):
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
sealed class HtmlAttribute : Attribute;Mark the property:
public class HtmlDoc
{
public required string Title;
[Html]
public required string Body;
}Drop a {{ Body }} substitution into the template on its own line:
var templatePath = Path.Combine(ScenarioPath("html-property"), "input.docx");
var store = new TemplateStore();
store.RegisterDocxTemplate<HtmlDoc>("html-doc", templatePath);
var model = new HtmlDoc
{
Title = "Report",
Body = "<p>Hello <b>world</b></p><p>Second para</p>"
};
using var stream = new MemoryStream();
await store.Render("html-doc", model, stream);[Markdown] is the same shape — mark the property with a MarkdownAttribute-named attribute (or [StringSyntax("markdown")]) and the string is parsed as markdown at render time:
public class MarkdownDoc
{
public required string Title;
[Markdown]
public required string Body;
}As an alternative to defining custom attributes, [StringSyntax] from System.Diagnostics.CodeAnalysis is equivalent (case-insensitive):
public class StringSyntaxHtmlDoc
{
public required string Title;
[StringSyntax("html")]
public required string Body;
}public class StringSyntaxMarkdownDoc
{
public required string Title;
[StringSyntax("markdown")]
public required string Body;
}Rules:
- The substitution must be a plain member-access expression — filters and arithmetic are rejected because the formatted rendering is selected by attribute rather than Fluid evaluation. Diagnostic
PARCH010. - Only
string/string?properties are supported. [Html]+[Markdown]on the same property, or[Html]+[StringSyntax("markdown")](and vice versa), is rejected at registration as a mismatch.- Unlike
[ExcelsiorTable], the token does not have to sit alone in its paragraph. Surrounding text and sibling tokens are preserved — see Inline-aware structural replacement.
When an [Html] / [Markdown] token shares its paragraph with other text or sibling tokens, Parchment chooses one of three modes based on what the rendered content looks like:
| Token position | Rendered shape | Result |
|---|---|---|
| Solo (covers the whole paragraph) | Anything | Host paragraph is replaced by the rendered elements. The host's pPr is lost; rendered paragraphs/tables stand on their own. |
| Non-solo | A single paragraph (typical for inline-only HTML like <b>x</b>, or single-line markdown) |
The produced paragraph's pPr is dropped; its runs are spliced into the host at the token offset. Surrounding text and the host's pPr are preserved. |
| Non-solo | Multiple block-level elements, or a non-paragraph block (table) | The host is split at the token offset: text before becomes its own paragraph (cloning host's pPr), the rendered blocks slot in between, and text after becomes another paragraph (also cloning host's pPr). Empty before/after halves are still emitted. |
Two non-solo block-shaped tokens in the same paragraph are rejected at render time — the splits would overlap and there is no defined way to compose them. Move one of the tokens to its own paragraph.
Any property assignable to IEnumerable<string> (string[], List<string>, IReadOnlyList<string>, etc.) is auto-rendered as a Word native bullet list when referenced as a solo {{ Property }} substitution. No attribute is required — detection is purely type-driven, mirroring Excelsior's Enumerable string properties feature.
Mark the property:
public class Person
{
public required string Name;
public required IEnumerable<string> Tags;
}Drop a {{ Tags }} substitution into the template on its own line:
Register and render normally:
var templatePath = Path.Combine(ScenarioPath("string-list"), "input.docx");
var store = new TemplateStore();
store.RegisterDocxTemplate<Person>("string-list-scenario", templatePath);
var model = new Person
{
Name = "Ada Lovelace",
Tags = ["Author", "Mathematician", "Engineer"]
};
using var stream = new MemoryStream();
await store.Render("string-list-scenario", model, stream);The rendered output:
Behavior:
- The auto path is opportunistic, not strict: it only fires when the token sits alone in its paragraph and has no filter chain. Otherwise the substitution falls through to standard Fluid evaluation — so existing
{{ Tags | bullet_list }}and{{ Tags | numbered_list }}filter usage keeps working unchanged. - A null collection renders as empty (no error). An empty collection renders as no paragraphs.
- Properties marked
[ExcelsiorTable]keep ownership — the Excelsior path runs first and wins for[ExcelsiorTable] IEnumerable<string>(though Excelsior itself rejectsstringas an element type, so this combination is rarely useful in practice). - Loop-scoped tokens fall through. The detection map is keyed on dotted paths from the root model only, matching the
[ExcelsiorTable]and[Html]/[Markdown]limitations. Inside{% for c in Customers %}{{ c.Tags }}{% endfor %}, use the explicitbullet_listfilter. - Numbered output: opt out of the auto path with
{{ Tags | numbered_list }}.
A markdown template is a .md file containing the full body of the document plus liquid tokens for substitution, looping, and conditional content. The template below combines headings, emphasis, a pipe table driven by a loop, an ordered list driven by a loop, and a blockquote chosen by an {% if %}:
The model the template binds against:
public class ReportContext
{
public required Report Report;
}
public class Report
{
public required string Title;
public required string Author;
public required Date Date;
public required string Summary;
public required IReadOnlyList<Finding> Findings;
public required IReadOnlyList<ActionItem> Actions;
public required bool HasRisks;
}
public class Finding
{
public required string Area;
public required string Status;
public required string Owner;
}
public class ActionItem
{
public required string Title;
public required string Detail;
}Render it like any other template:
using var brandDocx = DocxTemplateBuilder.Build();
var reportModel = SampleData.Report();
var store = new TemplateStore();
store.RegisterMarkdownTemplate<ReportContext>(
"report",
markdownSource,
styleSource: brandDocx);
using var stream = new MemoryStream();
await store.Render("report", reportModel, stream);The rendered docx (page 1):
The optional styleSource is a docx whose styles, headers, footers, theme, and section properties (page size, margins, header/footer references) are inherited by the output. If omitted, a built-in blank template is used.
Extended emphasis syntax beyond standard bold/italic:
~~strikethrough~~
~subscript~
^superscript^
++underline++
==highlight==Standard *italic*, **bold**, and _italic_ work as usual.
| A | B |
|---|---|
| 1 | 2 |
| 3 | 4 |Header cells are bold and centered. Rendered output:
Column alignment markers in the header separator row (:---, :---:, ---:) are honored for both header and body cells:
| Left | Center | Right | Default |
|:-----|:------:|------:|---------|
| a | b | c | d |
| e | f | g | h |Columns with no alignment marker fall back to the defaults (header centered, body left). Rendered output:
The number of dashes in each separator cell determines the column's relative width. The table uses fixed layout and each gridCol / cell gets an explicit width proportional to its dash count:
| Name | Description | Count |
|------|--------------------|------|
| A | Short | 1 |
| BB | A longer description | 22 |Dash counts 6 / 20 / 6 yield widths in the same ratio (18.75% / 62.5% / 18.75%). Rendered output:
Behaviour notes:
-
If the
|characters in the header, separator, and body rows all line up at the same column positions, dash counts are treated as readability padding and ignored — the table sizes naturally. Custom widths are inferred only when the separator dashes intentionally break that alignment. The two snippets below illustrate the distinction:| Left | Center | Right | Default | ← pipes align across all rows ⇒ uniform widths |:-----|:------:|------:|---------| | a | b | c | d |
| Name | Description | Count | ← separator pipes shift ⇒ widths apply |------|--------------------|------| | A | Short | 1 |
-
When all column widths are equal (uniform separators like
|---|---|), no explicit widths are emitted — Word's default auto-distribution produces the same layout, so the docx stays minimal. -
Tables nested in blockquotes or list items are auto-sized to fit their indented container, so the explicit dxa column widths are skipped regardless of dash counts.
Grid tables use +---+ borders and +===+ to separate the header row:
+---+---+
| A | B |
+===+===+
| 1 | 2 |
+---+---+
| 3 | 4 |
+---+---+Rendered output:
Bare URLs and email addresses are automatically converted to hyperlinks with the Hyperlink character style:
Visit https://example.com or email user@example.comAlpha and roman numeral list markers beyond standard 1. numbering:
a. lower alpha
b. items
A. upper alpha
B. items
i. lower roman
ii. items
I. upper roman
II. itemsEach format produces the corresponding Word numbering definition. Rendered output (lower alpha):
ASCII quotes and dashes are replaced with typographic equivalents:
| Input | Output |
|---|---|
'text' |
\u2018text\u2019 (curly single quotes) |
"text" |
\u201Ctext\u201D (curly double quotes) |
-- |
\u2013 (en-dash) |
--- |
\u2014 (em-dash) |
... |
\u2026 (ellipsis) |
Attach a Word style to a heading or paragraph with {.StyleName} syntax. The first class attribute is used as the paragraph's ParagraphStyleId:
## Section heading {.MyCustomHeading}
Some intro paragraph. {.IntroBlock}HTML comment blocks (<!-- ... -->) are dropped during rendering rather than passed through as empty paragraphs. This allows embedding snippet markers, authoring notes, or TODOs in template sources without bleeding visible whitespace into the output docx:
# {{ Title }}
<!-- TODO: add executive summary -->
Body text follows the heading.Only standalone comment blocks are removed; inline HTML, scripts, styles, and any other HTML constructs render normally via OpenXmlHtml.
Parchment has no dedicated image binding or attribute. Images flow through one of three paths depending on where the reference lives.
Pictures placed directly in the .docx template (Word's Insert → Picture, or pasted in) pass through to the output verbatim. Token scanning only mutates paragraphs that contain liquid tokens; image parts, drawings, and shapes elsewhere in the document are cloned byte-for-byte. Drop a logo into the header in Word, render, the logo comes out — no model wiring needed.
<img> tags (in an [Html] property, an HTML block inside a markdown template, or inline HTML inside a [Markdown] property) and markdown  syntax both delegate to OpenXmlHtml's ImageResolver. The same resolution table applies to both:
src / url value |
Result |
|---|---|
data:image/...;base64,... (data URI) |
Bytes decoded, embedded as <w:drawing> |
file:///... URI |
File read from disk, embedded |
Absolute or CWD-relative file path (e.g. C:\images\logo.png, images/logo.png) |
File read from disk, embedded |
http://... / https://... |
Fetched synchronously, embedded |
Path that fails the active ImagePolicy (or fails to read) |
Falls back to rendering the alt text |
By default, TemplateStore exposes LocalImages = ImagePolicy.AllowAll() and WebImages = ImagePolicy.AllowAll() — Parchment renders developer-bound model content, so locking either down by default would silently break <img src="C:\..."> and  references. If a model carries images from less-trusted sources, override the policies on the store:
var store = new TemplateStore
{
LocalImages = ImagePolicy.SafeDirectories("C:/assets/branding"),
WebImages = ImagePolicy.Deny()
};The ImagePolicy type is OpenXmlHtml's — Deny, AllowAll, SafeDomains(...), SafeDirectories(...), Filter(predicate). See OpenXmlHtml's image-policy docs for the full surface.
Determinism holds as long as the bytes at the resolved source don't change between renders. Enabling WebImages couples render output to network state — if reproducibility matters, materialize web URLs into the model (or use ImagePolicy.Deny() for WebImages) before rendering.
For full control — explicit sizing, anchored positioning, charts, anything outside markdown or HTML — return an OpenXmlToken from a TokenValue-typed model property. The render delegate receives an IOpenXmlContext whose AddImagePart(byte[] bytes, string contentType) adds an ImagePart to the document and returns its relationship ID. Build the <w:drawing> directly (inline or anchor element, EMU-sized extents, blip ref pointing at the rel-id) and yield it as one of the produced elements.
Use this path when image bytes live in memory (rendered chart, database blob, dynamically generated PNG) and round-tripping through base64 or a temp file would be wasteful.
Model:
public class BrandKit
{
public required string Title;
public required TokenValue Logo;
}Word's Drawing element conflicts with the DocumentFormat.OpenXml.Drawing namespace, so namespace aliases keep the construction code readable:
using A = DocumentFormat.OpenXml.Drawing;
using PIC = DocumentFormat.OpenXml.Drawing.Pictures;
using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing;Template content:
# {{ Title }}
{{ Logo }}Render — context.AddImagePart returns a relationship ID; the rest is standard OpenXML drawing plumbing (extent in EMUs, blip referencing the rel-id, preset shape geometry):
var store = new TemplateStore();
store.RegisterDocxTemplate<BrandKit>("image-token", template);
await store.Render(
"image-token",
new BrandKit
{
Title = "Brand kit",
Logo = new OpenXmlToken(context =>
{
var relId = context.AddImagePart(imageBytes, "image/png");
// Word measures images in EMUs (English Metric Units): 914400 per inch.
const long widthEmu = 914400L;
const long heightEmu = 914400L;
var inline = new DW.Inline(
new DW.Extent
{
Cx = widthEmu,
Cy = heightEmu
},
new DW.DocProperties
{
Id = 1U,
Name = "Logo"
},
new A.Graphic(
new A.GraphicData(
new PIC.Picture(
new PIC.NonVisualPictureProperties(
new PIC.NonVisualDrawingProperties
{
Id = 0U,
Name = "logo.png"
},
new PIC.NonVisualPictureDrawingProperties()),
new PIC.BlipFill(
new A.Blip
{
Embed = relId
},
new A.Stretch(new A.FillRectangle())),
new PIC.ShapeProperties(
new A.Transform2D(
new A.Offset
{
X = 0L,
Y = 0L
},
new A.Extents
{
Cx = widthEmu,
Cy = heightEmu
}),
new A.PresetGeometry(new A.AdjustValueList())
{
Preset = A.ShapeTypeValues.Rectangle
})))
{
Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture"
}));
return [new Paragraph(new Run(new Drawing(inline)))];
})
},
stream);Whether registering by hand (RegisterDocxTemplate<T>(...)) or through the source generator's RegisterWith(store) helper, the template is fully validated against T at registration — before any render runs. Missing members, block tags targeting non-enumerable properties, or [ExcelsiorTable] tokens that break the solo-in-paragraph / plain-member-access rules throw ParchmentRegistrationException immediately. Register templates at app startup and any binding mismatch surfaces there, not on the first render.
The source generator is the recommended way to register templates. Decorate the model class itself with [ParchmentModel] and Parchment's source generator (a) validates the template tokens against the model at compile time, and (b) emits a RegisterWith helper that pre-compiles all the member accessors needed at registration time — Fluid accessors per reachable type, plus the per-template maps for [ExcelsiorTable], [Html] / [Markdown], and IEnumerable<string> properties.
No runtime reflection at registration. Both the model graph walk in SharedFluid.RegisterTypeGraph and the dotted-path walks in ExcelsiorTableMap / FormatMap / StringListMap short-circuit when the source generator has already populated their caches. This makes Parchment safe for trimming and NativeAOT scenarios, and matches the modern .NET pattern set by System.Text.Json's JsonSerializerContext, EF Core's compiled models, and the regex source generator.
The model must be partial — the generator emits a RegisterWith helper onto the same class. Both docx and markdown templates are supported — the generator branches on the path's extension (.docx → docx flow, .md → markdown flow):
[ParchmentModel("Templates/invoice.docx")]
public partial class Invoice
{
public string Number { get; set; } = "";
public Customer Customer { get; set; } = new();
// ...
}
[ParchmentModel("Templates/report.md")]
public partial class Report
{
public string Title { get; set; } = "";
// ...
}The attribute is applied directly to the binding model — there is no separate marker / "template" class. Models almost always need Parchment-aware code on them anyway ([Html] / [Markdown] / [ExcelsiorTable] annotations, helper properties shaping values for binding), so the partial + Parchment-dependency tax is already paid. See CLAUDE.md → "Design decisions" for the full rationale.
The runtime TemplateStore.RegisterDocxTemplate<T>(name, path) / RegisterMarkdownTemplate<T>(name, markdown) overloads still exist as an escape hatch — use them when the model can't be made partial (third-party / generated types), when the model isn't known at compile time, or when one model needs to bind multiple templates. They are reflection-based and not trimming-friendly; the SG path is preferred whenever the model is yours to annotate.
In both cases the generator also emits a RegisterWith(store) helper so registration is one line at runtime:
var store = new TemplateStore();
Invoice.RegisterWith(store);
Report.RegisterWith(store, styleSource: File.OpenRead("brand.docx"));The markdown helper has an extra optional styleSource parameter that mirrors RegisterMarkdownTemplate<T> — pass a brand docx whose page setup, headers/footers, and styles should be inherited by the rendered output.
Add each template to <AdditionalFiles> so the generator can find it:
<ItemGroup>
<AdditionalFiles Include="Templates\invoice.docx" />
<AdditionalFiles Include="Templates\report.md" />
</ItemGroup>The generator emits the following diagnostics. Unless noted, each applies to both flows.
A {{ }} token references a property that doesn't exist on the model type.
// Model
public class Letter
{
public Customer Customer { get; set; }
}
public class Customer
{
public string Name { get; set; }
}
// Template paragraph — "Missing" does not exist on Customer
{{ Customer.Missing }}
A {% for %} tag targets a property that doesn't implement IEnumerable<T>. Note that string is explicitly rejected even though it implements IEnumerable<char>.
// Model — Customer is not a collection
public class Letter
{
public Customer Customer { get; set; }
}
// Template paragraphs
{% for item in Customer %}
...
{% endfor %}
Only for/endfor/if/elsif/else/endif are supported as block tags.
// Template paragraph — "foobar" is not a recognised tag
{% foobar %}
The path in [ParchmentModel("...")] wasn't found among the project's <AdditionalFiles>. Add the docx to the csproj:
<ItemGroup>
<AdditionalFiles Include="Templates\invoice.docx" />
</ItemGroup>Docx only. Block tags ({% for %}, {% if %}, etc.) must sit alone in their own paragraph. Mixing a block tag with other text on the same line is rejected. Markdown templates are exempt — Fluid parses the whole file as one template, so inline block tags like Hello {% if x %}World{% endif %} are legal.
// Template paragraphs — "prefix" is on the same line as the for tag
prefix {% for line in Lines %}
{{ line.Description }}
{% endfor %}
The template at the path exists in <AdditionalFiles> but couldn't be opened. For docx, typically a corrupt or truncated file. For markdown, this also fires when Fluid fails to parse the file (e.g. an unclosed {% for %} block) — the diagnostic message includes the parser error.
Docx only. [ExcelsiorTable]-driven structural replacement is wired through the docx flow only; markdown templates ignore the attribute. An [ExcelsiorTable] substitution replaces the entire host paragraph with a Word table. If the paragraph contains other text, that text would be discarded. The token must be the only content in its paragraph.
// Model
public class Invoice
{
[ExcelsiorTable]
public List<Line> Lines { get; set; }
}
// Template paragraph — "Prefix" shares the paragraph with {{ Lines }}
Prefix {{ Lines }}
Docx only. The Excelsior render path walks the CLR model directly and bypasses Fluid evaluation, so filters and complex expressions would be silently ignored. Only plain member-access ({{ Lines }} or {{ Customer.Lines }}) is allowed.
// Template paragraph — the | reverse filter would be silently dropped
{{ Lines | reverse }}
Previously emitted when an [Html] / [Markdown] token shared its paragraph with other content. The runtime now splices inline content in place and splits the host paragraph for block-level content, so non-solo tokens are valid. The id is intentionally not reused.
Docx only. Formatted rendering is selected by the property attribute rather than via Fluid, so filter chains are not applied. Use plain member access ({{ Body }} or {{ Customer.Bio }}). The [Html] / [Markdown] attributes are ignored in the markdown flow — markdown templates have no concept of structural property substitution.
The decorated model is nested inside another type that is not declared partial. The generator emits the registration helper as partial class { ... } and every enclosing type in the chain must be partial too, otherwise the C# compiler rejects the declaration as conflicting with the user's existing one (CS0260). Make every enclosing type partial:
public partial class Outer // <-- partial
{
[ParchmentModel("template.docx")]
public partial class Letter
{
public Customer Customer { get; set; } = new();
}
}Docx only. Warning, not error. The template's word/settings.xml does not include the <w:removePersonalInformation/> element, which corresponds to Word's "Remove personal information from file properties on save" privacy option. Renders inherit the template's settings.xml, so any author / lastModifiedBy / revision metadata on the template propagates to every generated docx. To silence the warning, open the template in Word, go to File → Options → Trust Center → Trust Center Settings → Privacy Options, tick "Remove personal information from file properties on save", and re-save.
Parchment binds tokens by reflecting on the model type. Public properties and public fields — both instance and static — are bindable at every depth, including nested traversal like {{ Customer.Address.City }} whether each hop is a property or a field. The source generator's ShapeBuilder mirrors the same rules. The following kinds of members are not bound — a token referencing them fails registration (ParchmentRegistrationException) or compile-time validation (PARCH001).
Static-member caveat: static members participate in Fluid substitution ({{ Logo }} against public static string Logo) but do not participate in the per-template maps. [ExcelsiorTable] on a static collection, [Html] / [Markdown] on a static string, and auto-bullet-list dispatch on a static IEnumerable<string> are silently treated as no-ops — the runtime map walkers and the SG dotted-path walker both skip static members. Mark the member instance, or wrap it in an instance computed property, when attribute-driven dispatch is required.
RegisterDocxTemplate<IFoo>(...) / RegisterMarkdownTemplate<IFoo>(...) is rejected at registration time with a ParchmentRegistrationException ("Model type 'IFoo' is an interface. Parchment binds against a concrete type's properties via reflection — register against a class, record, or struct instead."). Reflection on an interface returns only members declared directly on that interface, missing inherited base-interface members. The source-generator path is blocked at the language level — [ParchmentModel] is declared with AttributeUsage(AttributeTargets.Class), so applying it to an interface is a CS0592 compile error. Abstract classes are supported and work as a polymorphic binding surface: register against the abstract type and pass any concrete subclass to Render.
A property declared with a body on an interface — e.g. interface IDocument { string Title { get; } string Header => $"=== {Title} ==="; } — is not bound on classes that implement the interface unless the class re-declares it as a regular instance property. typeof(Foo).GetProperties(...) does not surface interface-default members; they're only reachable via the interface type. The SG-side walker visits BaseType but not implemented interfaces, so it behaves the same. Workaround: declare the property on the model class itself.
Extension properties compile to static methods on the extension container class, not as instance properties of the target type. typeof(Customer).GetProperties(...) does not return them, and ITypeSymbol.GetMembers() on the target returns only members declared on the type symbol. Neither the runtime walkers nor ShapeBuilder see them. Workaround: declare the property on the model class itself — the model already pays the partial-class / Parchment-reference cost, so helper / computed properties belong there.
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8328)
AMD Ryzen 9 5900X 3.70GHz, 1 CPU, 24 logical and 12 physical cores
.NET SDK 11.0.100-preview.3.26207.106
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3| Method | Mean | Allocated |
|---|---|---|
| RegisterFromMemoryStream | 0.25 ms | 193 KB |
| RegisterFromBufferedStream | 0.25 ms | 191 KB |
| RegisterFromFilePath | 0.33 ms | 191 KB |
ItemCount varies the number of loop iterations (line items for docx, findings/actions for markdown).
| Method | ItemCount | Mean | Allocated |
|---|---|---|---|
| DocxTemplate | 3 | 0.24 ms | 180 KB |
| MarkdownTemplate | 3 | 0.54 ms | 372 KB |
| DocxTemplate | 50 | 0.41 ms | 295 KB |
| MarkdownTemplate | 50 | 1.04 ms | 771 KB |
| DocxTemplate | 500 | 1.98 ms | 1,444 KB |
| MarkdownTemplate | 500 | 6.86 ms | 4,549 KB |
A pure-loop docx template ({% for line in Lines %}{{ line.Description }}: {{ line.Quantity }} x {{ line.UnitPrice }}{% endfor %}) rendered against varying iteration counts.
| Method | LoopItems | Mean | Allocated |
|---|---|---|---|
| RenderLoop | 10 | 0.21 ms | 181 KB |
| RenderLoop | 100 | 0.55 ms | 409 KB |
| RenderLoop | 1000 | 3.85 ms | 2,669 KB |
Run benchmarks with:
dotnet run --project src/Parchment.Benchmarks --configuration Release
Rendering the same template with the same model produces a byte-identical output. Useful for hash-based caching, dedup, and legal sign-off workflows.
Parchment icon designed by Alum Design from The Noun Project.













