Skip to content

SimonCropp/Parchment

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

139 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Parchment

Build status NuGet Status

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.

NuGet package

Parchment

Two modes

Parchment supports two complementary template formats:

  1. 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.
  2. Markdown template — start from a .md file with full liquid support. Markdown is parsed by Markdig and rendered into a target docx. Optionally provide a style-source .docx whose styles, headers, footers, and section properties are inherited.

Docx template

Input docx content

Invoice {{ Number }}

Customer: {{ Customer.Name }}

Total: {{ Total }} {{ Currency }}

snippet source | anchor

Render

var store = new TemplateStore();
store.RegisterDocxTemplate<Invoice>("substitution", template);

using var stream = new MemoryStream();
await store.Render("substitution", SampleData.Invoice(), stream);

snippet source | anchor

Token naming

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.

Loops

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.

Conditionals

{% if Customer.IsPreferred %}
Preferred customer: {{ Customer.Name }}
{% else %}
Regular customer: {{ Customer.Name }}
{% endif %}

Nested loops and conditionals

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 %}

Token override hatches

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 as string directly; assigning a string to a TokenValue property 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.

List filters

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 }}
    """);

snippet source | anchor

Render:

var store = new TemplateStore();
store.RegisterDocxTemplate<Invoice>("bullet-filter", template);
using var stream = new MemoryStream();
await store.Render("bullet-filter", SampleData.Invoice(), stream);

snippet source | anchor

numbered_list is identical in shape — swap the filter name to produce a decimal-numbered list instead of bullets.

escape_xml filter

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 }}

Markdown property

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 { get; init; }
    public required TokenValue Body { get; init; }
}

snippet source | anchor

Content:

# {{ Title }}

{{ Body }}

snippet source | anchor

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);

snippet source | anchor

Markdown filter

Alternatively, use the | markdown filter on a plain string property:

Model:

public class ArticleModel
{
    public required string Heading { get; init; }
    public required string Content { get; init; }
}

snippet source | anchor

Content:

# {{ Heading }}

{{ Content | markdown }}

snippet source | anchor

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);

snippet source | anchor

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 { get; init; }
    public required string Details { get; init; }
}

snippet source | anchor

Content:

# {{ Title }}

{{ Details }}

snippet source | anchor

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);

snippet source | anchor

Html property

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 { get; init; }
    public required TokenValue Body { get; init; }
}

snippet source | anchor

Content:

# {{ Title }}

{{ Body }}

snippet source | anchor

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);

snippet source | anchor

OpenXml property

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 { get; init; }
    public required TokenValue Callout { get; init; }
}

snippet source | anchor

Content:

# {{ Title }}

{{ Callout }}

snippet source | anchor

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);

snippet source | anchor

Mutate paragraph

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 { get; init; }
    public required TokenValue Highlight { get; init; }
}

snippet source | anchor

Content:

{{ Label }}

{{ Highlight }}

snippet source | anchor

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);

snippet source | anchor

Excelsior tables

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 { get; init; }

    [ExcelsiorTable]
    public required IReadOnlyList<QuoteLine> Lines { get; init; }
}

public class QuoteLine
{
    [Column(Heading = "Item", Order = 1)]
    public required string Description { get; init; }

    [Column(Heading = "Qty", Order = 2)]
    public required int Quantity { get; init; }

    [Column(Order = 3, Format = "C0")]
    public required decimal UnitPrice { get; init; }
}

snippet source | anchor

Drop a {{ Lines }} substitution into the template on its own line. The template:

Template before render

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);

snippet source | anchor

The rendered output:

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 emits PARCH007 if 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. Diagnostic PARCH008 covers 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 to CultureInfo.CurrentCulture). Set it once in a module initializer to override the default locale.

Html and Markdown properties

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)]
sealed class HtmlAttribute : Attribute;

snippet source | anchor

Mark the property:

public class HtmlDoc
{
    public required string Title { get; init; }

    [Html]
    public required string Body { get; init; }
}

snippet source | anchor

Drop a {{ Body }} substitution into the template on its own line:

Template before render

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);

snippet source | anchor

Rendered output

[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 { get; init; }

    [Markdown]
    public required string Body { get; init; }
}

snippet source | anchor

Template before render

Rendered output

As an alternative to defining custom attributes, [StringSyntax] from System.Diagnostics.CodeAnalysis is equivalent (case-insensitive):

public class StringSyntaxHtmlDoc
{
    public required string Title { get; init; }

    [StringSyntax("html")]
    public required string Body { get; init; }
}

snippet source | anchor

public class StringSyntaxMarkdownDoc
{
    public required string Title { get; init; }

    [StringSyntax("markdown")]
    public required string Body { get; init; }
}

snippet source | anchor

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.

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.

Enumerable string properties

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 { get; init; }
    public required IEnumerable<string> Tags { get; init; }
}

snippet source | anchor

Drop a {{ Tags }} substitution into the template on its own line:

Template before render

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);

snippet source | anchor

The rendered output:

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 rejects string as 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 explicit bullet_list filter.
  • Numbered output: opt out of the auto path with {{ Tags | numbered_list }}.

Markdown template

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 %}:

Sample

# {{ Report.Title }}

*Prepared by **{{ Report.Author }}** on {{ Report.Date }}*

## Summary

{{ Report.Summary }}

## Findings

| Area | Status | Owner |
| --- | --- | --- |
{% for finding in Report.Findings -%}
| {{ finding.Area }} | {{ finding.Status }} | {{ finding.Owner }} |
{% endfor %}

## Action items

{% for item in Report.Actions %}
1. **{{ item.Title }}** — {{ item.Detail }}
{% endfor %}

{% if Report.HasRisks %}
> ⚠ Outstanding risks remain. See appendix for mitigation plan.
{% else %}
> No outstanding risks.
{% endif %}

snippet source | anchor

The model the template binds against:

public class ReportContext
{
    public required Report Report { get; init; }
}

public class Report
{
    public required string Title { get; init; }
    public required string Author { get; init; }
    public required Date Date { get; init; }
    public required string Summary { get; init; }
    public required IReadOnlyList<Finding> Findings { get; init; }
    public required IReadOnlyList<ActionItem> Actions { get; init; }
    public required bool HasRisks { get; init; }
}

public class Finding
{
    public required string Area { get; init; }
    public required string Status { get; init; }
    public required string Owner { get; init; }
}

public class ActionItem
{
    public required string Title { get; init; }
    public required string Detail { get; init; }
}

snippet source | anchor

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);

snippet source | anchor

The rendered docx (page 1):

Markdown template output

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.

Supported Markdig extensions

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:

Pipe table output

Grid tables use +---+ borders and +===+ to separate the header row:

+---+---+
| A | B |
+===+===+
| 1 | 2 |
+---+---+
| 3 | 4 |
+---+---+

Rendered output:

Grid table output

Bare URLs and email addresses are automatically converted to hyperlinks with the Hyperlink character style:

Visit https://example.com or email user@example.com

Alpha 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. items

Each format produces the corresponding Word numbering definition. Rendered output (lower alpha):

Lower alpha list output

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 comments are stripped

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.

Images

Parchment has no dedicated image binding or attribute. Images flow through one of three paths depending on where the reference lives.

Static images embedded in the template

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> inside HTML and markdown ![alt](url)

<img> tags (in an [Html] property, an HTML block inside a markdown template, or inline HTML inside a [Markdown] property) and markdown ![alt](url) 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 ![](path) 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.

Custom OpenXmlToken (programmatic embedding)

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 { get; init; }
    public required TokenValue Logo { get; init; }
}

snippet source | anchor

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;

snippet source | anchor

Template content:

# {{ Title }}

{{ Logo }}

snippet source | anchor

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);

snippet source | anchor

Registration-time validation

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.

Source generator (compile-time validation)

Decorate a partial class with [ParchmentTemplate] and Parchment's source generator validates the template tokens against the model type at compile time:

[ParchmentTemplate("Templates/invoice.docx", typeof(Invoice))]
public partial class InvoiceReport
{
}

The generator emits the following diagnostics:

PARCH001 — unknown model member

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 }}

PARCH002 — loop source is not enumerable

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 %}

PARCH003 — unsupported block tag

Only for/endfor/if/elsif/else/endif are supported as block tags.

// Template paragraph — "foobar" is not a recognised tag
{% foobar %}

PARCH004 — template file not in <AdditionalFiles>

The path in [ParchmentTemplate("...", typeof(T))] wasn't found among the project's <AdditionalFiles>. Add the docx to the csproj:

<ItemGroup>
  <AdditionalFiles Include="Templates\invoice.docx" />
</ItemGroup>

PARCH005 — block tag shares a paragraph

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.

// Template paragraphs — "prefix" is on the same line as the for tag
prefix {% for line in Lines %}
{{ line.Description }}
{% endfor %}

PARCH006 — template file unreadable

The docx at the template path exists in <AdditionalFiles> but couldn't be opened — typically a corrupt or truncated file.

PARCH007[ExcelsiorTable] token not alone in paragraph

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 }}

PARCH008[ExcelsiorTable] token with filters or complex expression

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 }}

PARCH009 — retired

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.

PARCH010[Html] / [Markdown] token with filters or complex expression

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 }}).

It also generates a RegisterWith(store) helper so registration is one line at runtime.

Add the docx as an additional file:

<ItemGroup>
  <AdditionalFiles Include="Templates\invoice.docx" />
</ItemGroup>

Benchmarks

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

Registration

Method Mean Allocated
RegisterFromMemoryStream 0.25 ms 193 KB
RegisterFromBufferedStream 0.25 ms 191 KB
RegisterFromFilePath 0.33 ms 191 KB

Rendering

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

Loops

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

Determinism

Rendering the same template with the same model produces a byte-identical output. Useful for hash-based caching, dedup, and legal sign-off workflows.

Icon

Parchment icon designed by Alum Design from The Noun Project.

About

Parchment is a Word (.docx) document generation library with two complementary rendering modes.

Resources

License

Stars

Watchers

Forks

Contributors

Languages