Templating — Expressions, Pipes, Data Context
The Pragmatic.Documents.Templating package is the expression engine shared between PDX-Doc and PDX-Email templates. It evaluates {{ ... }} expressions against a TemplateDataContext, applies pipes, and emits the resolved values back into the template.
The three building blocks
Section titled “The three building blocks”{{expression}} → Evaluator | | ▼ ▼TemplateDataContext PipeRegistry(named data sources) (format / transform)| Component | Purpose |
|---|---|
TemplateDataContext | Named roots (invoice, customer, company) with property accessors |
| Expression evaluator | Parses `invoice.items[0].price |
PipeRegistry | Catalogue of pipes; default has upper/lower/trim/default/number, extensible |
Expression syntax
Section titled “Expression syntax”Property access
Section titled “Property access”{{ invoice.number }}{{ customer.address.city }}{{ items[0].name }}Properties are resolved via IPropertyAccessor. Built-in accessors:
DictionaryPropertyAccessorforIDictionary<string, object?>ReflectionPropertyAccessorfor typed records and classes
You can register custom accessors for JSON nodes, expando objects, dynamic proxies — anything with a “property by name” contract.
Pipes format or transform the value before it lands in the output:
{{ price | currency:"EUR" }}{{ date | format:"yyyy-MM-dd" }}{{ title | upper }}{{ name | default:"anonymous" }}Pipes chain left to right:
{{ description | trim | upper | truncate:80 }}Literals and conditionals
Section titled “Literals and conditionals”{{ total | currency:"EUR" }} // literal string arg{{ count | number:"#,##0" }} // format-string argPipes with multiple arguments separate them with commas:
{{ value | clamp:0,100 }}Missing values
Section titled “Missing values”If a property doesn’t exist in the data context, the expression returns null (not a runtime error). Use | default:"fallback" to substitute.
The resolver records a TemplateWarning for each missing property so you can audit which placeholders went unresolved:
var result = await resolver.ResolveAsync(template, data);foreach (var warning in resolver.Warnings) Console.WriteLine($"[warn] {warning.Path}: {warning.Reason}");Built-in pipes
Section titled “Built-in pipes”| Name | Arguments | Purpose |
|---|---|---|
upper | — | foo → FOO |
lower | — | FOO → foo |
trim | — | strip surrounding whitespace |
default | value | fallback when input is null/empty |
number | format | .NET numeric format string |
With Pragmatic.Documents.Templating.I18N:
| Name | Arguments | Purpose |
|---|---|---|
currency | ISO code | 1200 + "EUR" → €1,200.00 |
date | format | DateTime → formatted per culture |
percent | decimals | 0.225 → 22.5% |
translate | key | resolve translation key via ITranslationResolver |
I18N pipes honour the culture from the data context (data.Culture) and fall back to CultureInfo.CurrentCulture.
Enable them when building the resolver:
using Pragmatic.Documents.Templating.I18N;using Pragmatic.Documents.Templating.Pipes;
var resolver = new DocumentTemplateResolver(PipeRegistry.Default.WithI18N());TemplateDataContext
Section titled “TemplateDataContext”Named data sources keep expressions unambiguous when multiple roots are in play.
using Pragmatic.Documents.Templating.Data;
var data = new TemplateDataContext() .AddSource("invoice", new Dictionary<string, object?> { ["number"] = "2026-042", ["date"] = DateTime.UtcNow, ["subtotal"] = 6000m, ["vat"] = 1320m, ["total"] = 7320m, ["items"] = new[] { /* ... */ }, }) .AddSource("customer", new { Name = "Mario Rossi", VatId = "IT98765432101" }) .AddSource("company", companyProfile);Each root is isolated — invoice.total and customer.total can coexist without collision.
Typed vs dictionary sources
Section titled “Typed vs dictionary sources”Both work. Dictionary sources use DictionaryPropertyAccessor; typed objects use ReflectionPropertyAccessor. For AOT apps, prefer:
- typed records +
ReflectionPropertyAccessorif your property graph is small (reflection is cached per-type) - a custom
IPropertyAccessor<T>for hot paths
Async data sources
Section titled “Async data sources”For sources that need an I/O call (database lookup, API fetch) at resolve time, implement IDataSourceProvider:
public sealed class InvoiceDataSource : IDataSourceProvider{ public string Name => "invoice"; public async ValueTask<object?> ResolveAsync(string path, CancellationToken ct) { // called once per referenced path at resolve time return await _repo.GetInvoiceFieldAsync(path, ct); }}
data.AddSource(new InvoiceDataSource());The resolver awaits provider calls during template resolution.
Resolving templates
Section titled “Resolving templates”using Pragmatic.Documents.Markup;using Pragmatic.Documents.Templating;using Pragmatic.Documents.Templating.Data;using Pragmatic.Documents.Templating.Pipes;using Pragmatic.Documents.Pdf;
var template = PdxDocParser.ParseFile("invoice.pdxdoc");var resolver = new DocumentTemplateResolver(PipeRegistry.Default.WithI18N());
var data = new TemplateDataContext(CultureInfo.GetCultureInfo("it-IT")) .AddSource("invoice", invoice) .AddSource("customer", customer);
var model = await resolver.ResolveAsync(template, data);
using var fs = File.Create("invoice.pdf");PdfRenderer.RenderTo(fs, model);For email templates use EmailTemplateResolver + PdxEmailParser with the same pattern.
Custom pipes
Section titled “Custom pipes”Implement ITemplatePipe:
using Pragmatic.Documents.Templating.Pipes;
public sealed class TruncatePipe : ITemplatePipe{ public string Name => "truncate";
public object? Execute(object? input, IReadOnlyList<string> args, CultureInfo culture) { if (input is not string s) return input; if (args.Count == 0 || !int.TryParse(args[0], out var max)) return s; return s.Length <= max ? s : s[..max] + "…"; }}Register it:
var pipes = PipeRegistry.Default .WithI18N() .With(new TruncatePipe());
var resolver = new DocumentTemplateResolver(pipes);Use it in templates:
<text>{{ article.title | truncate:80 }}</text>Culture awareness
Section titled “Culture awareness”The data context carries a CultureInfo. I18N pipes respect it:
var data = new TemplateDataContext(CultureInfo.GetCultureInfo("de-DE")) .AddSource("invoice", invoice);
// {{ invoice.total | currency:"EUR" }} → "1.200,00 €" (German formatting)Without a culture, pipes fall back to CultureInfo.CurrentCulture.
For translation (e.g. {{ "invoice.title" | translate }}), wire an ITranslationResolver:
public sealed class ResxTranslationResolver : ITranslationResolver{ public string Resolve(string key, CultureInfo culture) => ResourceManager.GetString(key, culture) ?? key;}
var resolver = new DocumentTemplateResolver(PipeRegistry.Default.WithI18N(new ResxTranslationResolver()));Performance
Section titled “Performance”- Expressions are parsed once per template load (cached inside the template).
- Property access is cached per accessor per type.
- Pipe lookup is a hashtable lookup.
- Resolving an invoice template with ~20 expressions + a 10-row data-bound table: ~0.5ms on modern hardware.
For templates evaluated thousands of times, prefer loading the template once at startup and reusing it — the resolver is stateless across calls.
Related
Section titled “Related”- markup-parser.md — PDX-Doc / PDX-Email structural syntax
- pdf-rendering.md — render resolved document models
- email-rendering.md — render resolved email models
Pragmatic.Documents.Templating.Spreadsheet.Samples— data-source catalogue and spreadsheet workflows