Skip to content

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.


{{expression}} → Evaluator
| |
▼ ▼
TemplateDataContext PipeRegistry
(named data sources) (format / transform)
ComponentPurpose
TemplateDataContextNamed roots (invoice, customer, company) with property accessors
Expression evaluatorParses `invoice.items[0].price
PipeRegistryCatalogue of pipes; default has upper/lower/trim/default/number, extensible

{{ invoice.number }}
{{ customer.address.city }}
{{ items[0].name }}

Properties are resolved via IPropertyAccessor. Built-in accessors:

  • DictionaryPropertyAccessor for IDictionary<string, object?>
  • ReflectionPropertyAccessor for 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 }}
{{ total | currency:"EUR" }} // literal string arg
{{ count | number:"#,##0" }} // format-string arg

Pipes with multiple arguments separate them with commas:

{{ value | clamp:0,100 }}

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

NameArgumentsPurpose
upperfooFOO
lowerFOOfoo
trimstrip surrounding whitespace
defaultvaluefallback when input is null/empty
numberformat.NET numeric format string

With Pragmatic.Documents.Templating.I18N:

NameArgumentsPurpose
currencyISO code1200 + "EUR"€1,200.00
dateformatDateTime → formatted per culture
percentdecimals0.22522.5%
translatekeyresolve 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());

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.

Both work. Dictionary sources use DictionaryPropertyAccessor; typed objects use ReflectionPropertyAccessor. For AOT apps, prefer:

  • typed records + ReflectionPropertyAccessor if your property graph is small (reflection is cached per-type)
  • a custom IPropertyAccessor<T> for hot paths

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.


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.


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>

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

  • 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.