Skip to content

Quick Start for Developers

Get started with Templify in your .NET application.

Prerequisites

  • .NET 8.0 SDK or later (Download). The library targets net8.0, net9.0 and net10.0.
  • A Word template (.docx). Microsoft Word is only needed to author templates, not to process them.

Installation

dotnet add package TriasDev.Templify

Or in Visual Studio's Package Manager Console:

Install-Package TriasDev.Templify

Your First Template

Step 1: Create a Word Template

Create a Word document with this content and save it as template.docx:

Hello {{Name}}!

This is your invoice #{{InvoiceNumber}} dated {{Date}}.

Total Amount: {{Amount}} EUR

Placeholders are written as {{VariableName}}, without spaces inside the braces ({{ Name }} is not a placeholder and stays as text). Names consist of letters, digits and underscores; use dots and brackets for nested data ({{Customer.Address.City}}, {{Items[0].Name}}).

Step 2: Write the Code

using TriasDev.Templify.Core;

var data = new Dictionary<string, object>
{
    ["Name"] = "John Doe",
    ["InvoiceNumber"] = "INV-2025-001",
    ["Date"] = DateTime.Now.ToString("yyyy-MM-dd"),
    ["Amount"] = 1250.50m
};

var processor = new DocumentTemplateProcessor();
using var templateStream = File.OpenRead("template.docx");
using var outputStream = File.Create("output.docx");

ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);

if (result.IsSuccess)
{
    Console.WriteLine($"Done: {result.ReplacementCount} placeholders replaced.");
}
else
{
    Console.WriteLine($"Failed: {result.ErrorMessage}");
}

All public types live in namespaces below TriasDev.Templify: TriasDev.Templify.Core (processors, options, results), TriasDev.Templify.Conditionals (standalone condition evaluation), TriasDev.Templify.Formatting (boolean formatters), TriasDev.Templify.Replacements (text replacement tables) and TriasDev.Templify.Utilities (JsonDataParser).

The output stream must be readable, writable and seekable (a MemoryStream, or a FileStream opened with FileAccess.ReadWrite such as File.Create; File.OpenWrite is write-only and does not work), because the document is edited in place after the template has been copied into it. An unusable stream is reported as a failed result (IsSuccess == false with an ErrorMessage) before anything is written to it. An output with earlier content (for example an existing file opened with FileMode.OpenOrCreate) is truncated first; if it cannot be truncated (SetLength is not supported), processing fails with an Invalid output stream: ... error and the earlier content is left unchanged.

Step 3: Run It

Run the program and open output.docx:

Hello John Doe!

This is your invoice #INV-2025-001 dated 2025-01-15.

Total Amount: 1250.50 EUR

Numbers and dates are formatted with PlaceholderReplacementOptions.Culture (the current culture by default), so 1250.50 may appear as 1250,50 on a German system. See Configuration Options.

Other Input and Output Shapes

// File to file: the output file is written only when processing succeeds.
ProcessingResult fileResult = processor.ProcessTemplateFile("template.docx", "output.docx", data);

// Bytes to bytes: `output` is empty when processing fails.
byte[] template = File.ReadAllBytes("template.docx");
ProcessingResult bytesResult = processor.ProcessTemplate(template, data, out byte[] output);

// Read-only data (IReadOnlyDictionary<string, object?>), e.g. an ImmutableDictionary or ReadOnlyDictionary.
// The dictionary is not copied; lookups use its own key comparer.
IReadOnlyDictionary<string, object?> readOnlyData = new Dictionary<string, object?> { ["CustomerName"] = null };
ProcessingResult readOnlyResult = processor.ProcessTemplate(templateStream, outputStream, readOnlyData);

Every overload (stream, byte[] and file) also accepts a JSON string instead of a dictionary (see Using JSON Data). TextTemplateProcessor.ProcessTemplate and DocumentTemplateProcessor.ValidateTemplate accept IReadOnlyDictionary<string, object?> data as well.

Web Applications (Async I/O)

The API is synchronous: processing is CPU-bound, runs in memory and typically takes milliseconds. Do the I/O around it asynchronously. In ASP.NET Core, request and upload streams do not allow synchronous reads, so buffer an uploaded template first:

using var templateStream = new MemoryStream();
await uploadedFile.CopyToAsync(templateStream, cancellationToken);
templateStream.Position = 0;

using var outputStream = new MemoryStream(); // a Word output must be readable, writable and seekable
ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);

See the ASP.NET Core examples.

Data

Nested Data and Objects

Values can be dictionaries, objects (POCOs, anonymous types, records), lists and arrays, in any combination:

var data = new Dictionary<string, object>
{
    ["Company"] = new Dictionary<string, object>
    {
        ["Name"] = "Acme Corp",
        ["Address"] = new { Street = "123 Main St", City = "Springfield" }
    },
    ["Items"] = new List<string> { "Item 1", "Item 2" }
};
Company: {{Company.Name}}
Address: {{Company.Address.Street}}, {{Company.Address.City}}
First item: {{Items[0]}}

Dictionary keys are looked up with the dictionary's own comparer (ordinal, case-sensitive for a plain Dictionary<string, object>); object properties are matched case-insensitively.

Using JSON Data

Pass the JSON text directly; nested objects and arrays become dictionaries and lists:

string json = File.ReadAllText("data.json");
ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, json);

Or parse it once and reuse the dictionary:

using TriasDev.Templify.Utilities;

Dictionary<string, object> data = JsonDataParser.ParseJsonToDataDictionary(json);

The JSON root must be an object. Invalid JSON is not a failed result: JsonException (or ArgumentException for an empty string) is thrown to the caller.

Avoid JsonSerializer.Deserialize<Dictionary<string, object>>

It leaves every value as a JsonElement. Since 1.8.0 nested paths through JsonElement objects and arrays resolve ({{Customer.Name}}, {{Items[0].Name}}), but a top-level JSON array used directly in {{#foreach Items}} fails with "is not a collection", and a JSON false is truthy in {{#if Flag}}. Use the JSON overload or JsonDataParser instead.

Error Handling

ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data);

if (!result.IsSuccess)
{
    // Template syntax errors (e.g. an {{#if}} without {{/if}}) and data errors
    // (e.g. {{#foreach}} over a value that is not a collection)
    Console.WriteLine(result.ErrorMessage);   // "Processing failed: ..."
    return;
}

foreach (string name in result.MissingVariables)
{
    Console.WriteLine($"Missing: {name}");
}

foreach (ProcessingWarning warning in result.Warnings)
{
    Console.WriteLine(warning);   // e.g. "MissingVariable [placeholder]: Variable 'X' was not found in the data."
}

ProcessingResult has IsSuccess, ErrorMessage, ReplacementCount, MissingVariables, Warnings, HasWarnings, and GetWarningReport() / GetWarningReportBytes(), which render the warnings as a Word document. See Processing Warnings.

Only two things throw instead of returning a failed result: invalid arguments (ArgumentNullException, invalid JSON as above, unreadable files for ProcessTemplateFile), and a missing variable when MissingVariableBehavior.ThrowException is configured, which throws InvalidOperationException. A malformed condition such as {{#if Count > 2 && IsActive}} (&& is not an operator; use and) does not fail the document: it evaluates to false and adds an ExpressionFailed warning.

Validating a Template

ValidateTemplate checks a template without producing output: unmatched markers, invalid conditions and, when data is passed, missing variables.

using var template = File.OpenRead("template.docx");
ValidationResult validation = processor.ValidateTemplate(template, data);

if (!validation.IsValid)
{
    foreach (ValidationError error in validation.Errors)
    {
        Console.WriteLine($"{error.Type}: {error.Message}");
    }
}

Console.WriteLine(string.Join(", ", validation.AllPlaceholders));
Console.WriteLine(string.Join(", ", validation.MissingVariables));

Each missing variable or loop collection is reported as an error of type MissingVariable (so IsValid is false for that data) and listed in MissingVariables; call ValidateTemplate(template) without data to check the syntax only. validation.Warnings reports loops over empty collections (EmptyLoopCollection, controlled by WarnOnEmptyLoopCollections), condition keywords used as variable names (ReservedWordAsVariable) and conditions that test a variable missing from the data on its own (MissingConditionVariable, see below). Warnings never make IsValid false.

ValidationWarning.Type is one of these ValidationWarningType values:

Type Meaning
EmptyLoopCollection A loop collection is empty, so the loop content could not be checked (only with WarnOnEmptyLoopCollections)
ReservedWordAsVariable A condition uses a bare keyword (Exists, Empty, ...) as a variable that is in the data; write [Exists]
MissingConditionVariable A condition tests a variable that is not in the data for truthiness on its own, so it is treated as false

MissingConditionVariable covers variables used as bare operands: the whole condition ({{#if Missing}}, {{#elseif Missing}}, inline {{#if Missing}}...{{/if}}) and the operands of and, or and not ({{#if not Missing}}, {{#if A and Missing}}). Inside loops, item properties, named iteration variables and loop metadata (@first, ...) are resolved as in processing. It is not reported for the operands of exists, is empty and is not empty (they are meant for missing values), for comparison operands (Status = Active compares with the text Active when there is no such variable) or for the other operators, and not for inline {{(...)}} expressions. The variable is not added to MissingVariables and is not a MissingVariable error, so existing validation code keeps its result. The warning is the same for Word and OpenDocument templates.

Inline expressions ({{(A and B)}}, {{(Price > 100):yesno}}) are validated through the parsed expression, like processing evaluates them. AllPlaceholders lists the variables they reference (A, B, Price), not the expression text. An expression that cannot be parsed ({{(A and)}}, {{(A && B)}}) is an InvalidConditionalExpression error, also without data. With data, each referenced variable that processing reads from the data and that is missing is a MissingVariable error and is listed in MissingVariables: the operands tested for truthiness ({{(A and Missing)}}, {{(not Missing)}}) and the variable operands of in, contains, startswith and endswith. As in processing, comparison operands are not reported ({{(Status = Active)}} compares with the text Active when there is no such variable), nor the operands of exists, is empty and is not empty. Inline expressions get no MissingConditionVariable warning, because their missing operands are already errors.

ValidationError.Type is one of these ValidationErrorType values:

Type Meaning
UnmatchedConditionalStart / UnmatchedConditionalEnd An {{#if}} without {{/if}}, or the other way round
UnmatchedLoopStart / UnmatchedLoopEnd A {{#foreach}} without {{/foreach}}, or the other way round
InvalidPlaceholderSyntax Invalid template syntax, for example an invalid loop variable name
MissingVariable A variable or loop collection is not in the data (only when data is passed)
InvalidConditionalExpression A condition or an inline {{(...)}} expression cannot be parsed
InvalidDocument The input cannot be read as a supported template: unsupported format, unreadable stream, or corrupted or rejected package

InvalidDocument is reported by TemplateProcessor and OdtTemplateProcessor. DocumentTemplateProcessor still reports an unreadable Word document as InvalidPlaceholderSyntax ("Validation failed: ..."), for compatibility with earlier versions.

Configuration Options

Customize template processing with PlaceholderReplacementOptions:

using System.Globalization;
using TriasDev.Templify.Core;

var options = new PlaceholderReplacementOptions
{
    MissingVariableBehavior = MissingVariableBehavior.ReplaceWithEmpty,
    Culture = CultureInfo.GetCultureInfo("de-DE"),
    EnableNewlineSupport = true,
    EnableMarkdown = true,
    UpdateFieldsOnOpen = UpdateFieldsOnOpenMode.Auto
};

var processor = new DocumentTemplateProcessor(options);

Available Options

Option Type Default Description
MissingVariableBehavior enum LeaveUnchanged LeaveUnchanged keeps {{Name}}, ReplaceWithEmpty removes it, ThrowException throws InvalidOperationException
Culture CultureInfo CurrentCulture Culture for formatting numbers, dates and localized boolean formats
BooleanFormatterRegistry BooleanFormatterRegistry? null Custom boolean formatters; null uses the built-in formatters for Culture
EnableNewlineSupport bool true Convert \n to Word line breaks
EnableMarkdown bool true Render markdown in values (**bold**, *italic*, _italic_, ~~strike~~) as Word formatting. Set to false to insert values as plain text; newline handling is unaffected. Single placeholders can opt out with {{Name:raw}}
WarnOnEmptyLoopCollections bool true ValidateTemplate warns about loops over empty collections (does not affect ProcessTemplate)
UpdateFieldsOnOpen enum Never When to prompt Word to update fields
TextReplacements dictionary null Text replacement lookup table applied to replaced values (e.g. TextReplacements.HtmlEntities)
DocumentProperties DocumentProperties? null Metadata properties to set on output document

Set Culture explicitly on servers

Culture defaults to CultureInfo.CurrentCulture at the time the options are created, i.e. the locale of the server process or request thread. The same template and data can then produce different numbers, dates and localized boolean formats on differently configured machines. Set Culture explicitly (for example CultureInfo.GetCultureInfo("en-US") or CultureInfo.InvariantCulture) for reproducible output.

Sharing Options and Processors

A DocumentTemplateProcessor or TextTemplateProcessor and its options can be shared by concurrent calls. BooleanFormatterRegistry.Register is not synchronized: register all custom boolean formatters first, then assign the registry to PlaceholderReplacementOptions.BooleanFormatterRegistry, and do not modify it afterwards.

using TriasDev.Templify.Formatting;

var registry = new BooleanFormatterRegistry(CultureInfo.GetCultureInfo("de-DE"));
registry.Register("status", new BooleanFormatter("Aktiv", "Inaktiv"));   // register first ...

var options = new PlaceholderReplacementOptions
{
    Culture = CultureInfo.GetCultureInfo("de-DE"),
    BooleanFormatterRegistry = registry                                     // ... then share
};

Where Templates Are Processed

ProcessTemplate automatically processes all headers and footers in the document - no additional API calls or configuration needed. The same visitor pipeline (placeholders, conditionals, loops) is applied to every header and footer part (Default, First Page, Even Page).

The same applies to footnotes and endnotes (separator notes are skipped), text boxes (VML and DrawingML, both the mc:Choice and the mc:Fallback rendering) and content controls (block, row, cell and inline). Comments are not processed.

Update Fields on Open (TOC Support)

When templates contain Table of Contents (TOC) or other dynamic fields, and content changes during processing (via conditionals or loops), page numbers become stale.

Why this happens: The OpenXML SDK cannot calculate page numbers—only Word's layout engine can determine actual pagination.

UpdateFieldsOnOpenMode Options

Mode Description
Never Never prompt to update fields (default)
Always Always prompt to update fields
Auto Recommended - Only prompt if document contains fields (TOC, PAGE, etc.)

Usage Examples

For applications processing user-uploaded templates (recommended):

// Auto-detect: only prompts if document has TOC, PAGE, etc.
var options = new PlaceholderReplacementOptions
{
    UpdateFieldsOnOpen = UpdateFieldsOnOpenMode.Auto
};

For templates known to have TOC:

var options = new PlaceholderReplacementOptions
{
    UpdateFieldsOnOpen = UpdateFieldsOnOpenMode.Always
};

Fields Detected in Auto Mode

Auto mode looks at field codes in the body, headers, footers, footnotes and endnotes. Both complex fields (w:instrText) and simple fields (w:fldSimple) count. The field type (the first word of the field code) must be one of:

  • TOC - Table of Contents
  • PAGE - Current page number
  • NUMPAGES - Total page count
  • PAGEREF - Page references
  • DATE - Current date
  • TIME - Current time
  • FILENAME - Document filename
  • REF - Cross-references
  • NOTEREF - Footnote/endnote references
  • SECTIONPAGES - Pages in current section

Note: When enabled, Word displays a prompt asking the user to confirm field updates. This is a security measure built into Word.

Document Properties

Set metadata on the output document (Author, Title, Subject, etc.). Properties left as null preserve the original template value.

var options = new PlaceholderReplacementOptions
{
    DocumentProperties = new DocumentProperties
    {
        Author = "My Application",
        Title = "Generated Report"
    }
};

Available Properties

Property Maps to in Word
Author Author (Creator)
Title Title
Subject Subject
Description Comments
Keywords Keywords
Category Category
LastModifiedBy Last Modified By

Note: When DocumentProperties is null (default), all original template metadata is preserved. When set, only non-null properties are applied.

Next Steps

Need Help?