Skip to content

PDX Markup

PDX markup is an XML-based DSL for authoring document and email templates. It was designed to be familiar to anyone who can read HTML, but constrained to the subset that maps cleanly to DocumentModel and EmailModel.

Two parsers live in Pragmatic.Documents.Markup:

  • PdxDocParser.Parse(markup)DocumentTemplate
  • PdxEmailParser.Parse(markup)EmailTemplate

Both also expose ParseFile(path) convenience methods.


Authoring a DocumentBuilder in C# is great when the author is the developer. When a designer, content team, or localisation team owns the layout, a text file is the right surface:

  • no C# toolchain required
  • git-friendly diffs
  • easy to version per-customer variants
  • parseable by tools outside the runtime (snippet validators, linters, preview tools)

The markup is declarative — expressions live in {{ ... }} blocks, so a designer can author the structure and a developer provides the data.


<document title="Invoice {{invoice.number}}" author="{{company.name}}"
page-size="A4" orientation="portrait" margin="normal" lang="en">
<page>
<!-- content -->
</page>
<page>
<!-- another page -->
</page>
</document>

Root attributes:

AttributeValuesMaps to
titletext or {{expr}}DocumentTemplate.Title
authortext or {{expr}}DocumentTemplate.Author
langISO code (en, it-IT)DocumentTemplate.Language
page-sizeA4, Letter, Legal, A5DocumentTemplate.PageSize
orientationportrait, landscapeDocumentTemplate.Orientation
marginnormal, narrow, wideDocumentTemplate.Margins
page-data-sourcedata-source nameone page per item

Inside <page> you place nodes. Each maps to a model node:

ElementMaps toAttributes
<heading level="N">HeadingNodelevel (1-6)
<text>TextNode
<paragraph>ParagraphNode
<spacer />SpacerNodeheight (points)
<hr />HorizontalRuleNode
<image src="..." alt="..." />ImageNodesrc, alt, width, height
<barcode value="...">BarcodeNodevalue, type (qr, code128, …)
<hyperlink href="...">text</hyperlink>HyperlinkNodehref
<toc />TocNodemax-level, title
<page-break />PageBreakNode
<field type="PageNumber" />FieldNodetype, format
<bookmark name="...">BookmarkNodename
<footnote>text</footnote>FootnoteNode
<header> / <footer>Page header / footer nodes
<table>TableNode with rowssee below
<import src="partial.pdxdoc" />Include a partial templatesrc

Plain table:

<table>
<row><cell>Item</cell><cell>Qty</cell><cell>Price</cell></row>
<row><cell>Design review</cell><cell>1</cell><cell>€250</cell></row>
</table>

Data-bound table (repeats <row-template> once per item):

<table data-source="invoice.items">
<column width="90">Description</column>
<column width="20" align="center">Qty</column>
<column width="25" align="right">Price</column>
<row-template>
<cell>{{item.description}}</cell>
<cell>{{item.qty}}</cell>
<cell>{{item.price | currency:"EUR"}}</cell>
</row-template>
</table>

Inside row-template, the loop variable is the singular form of the data source key (invoice.itemsitem). For custom names:

<row-template as="line">
<cell>{{line.description}}</cell>
</row-template>

Any attribute or text content can include {{ expression }} interpolation. See templating.md for the expression syntax.

<page>
<header>
<text align="right">Page <field type="PageNumber"/> of <field type="PageCount"/></text>
</header>
<footer>
<text align="center">Confidential — {{company.name}}</text>
</footer>
<heading level="1">{{invoice.number}}</heading>
<!-- ... -->
</page>
<document title="Invoice {{invoice.number}}" author="{{company.name}}">
<page>
<heading level="1">{{company.name}}</heading>
<text>{{company.address}}</text>
<spacer />
<heading level="2">Invoice {{invoice.number}}</heading>
<text>Date: {{invoice.date | date:"dd MMMM yyyy"}}</text>
<text>Customer: {{customer.name}} — VAT {{customer.vatId}}</text>
<hr />
<table data-source="invoice.items">
<column width="90">Description</column>
<column width="20" align="center">Qty</column>
<column width="25" align="right">Price</column>
<column width="25" align="right">Total</column>
<row-template>
<cell>{{item.description}}</cell>
<cell>{{item.qty}}</cell>
<cell>{{item.price | currency:"EUR"}}</cell>
<cell>{{item.total | currency:"EUR"}}</cell>
</row-template>
</table>
<hr />
<text>Subtotal: {{invoice.subtotal | currency:"EUR"}}</text>
<text>VAT 22%: {{invoice.vat | currency:"EUR"}}</text>
<heading level="3">TOTAL: {{invoice.total | currency:"EUR"}}</heading>
</page>
</document>

Resolve it with a data context (see next section).


PDX-Email is the email counterpart. The root is <email>, children are <section> / <hero> / <two-columns> / <article> / <footer> / <social-bar>.

<email subject="Welcome to {{company.name}}"
preheader="Your account is ready."
width="600"
lang="en">
<hero background-color="#1F497D">
<heading>Welcome, {{user.firstName}}!</heading>
<text>Thanks for signing up.</text>
</hero>
<section padding="24">
<column>
<paragraph>You're all set. Click the button below to get started:</paragraph>
<button href="{{verifyLink}}">Verify your email</button>
</column>
</section>
<footer background-color="#eeeeee">
<column>
<text align="center" color="#666">© {{year}} {{company.name}}</text>
</column>
</footer>
</email>

Element mapping:

ElementMaps to EmailBuilder
<section> + <column>.Section(s => s.Column(...))
<hero>.Hero(...)
<two-columns> / <three-columns>.TwoColumns(...) / .ThreeColumns(...)
<article>.Article(...)
<footer>.Footer(...)
<social-bar>.SocialBar(...)
<heading> / <text> / <button> / <image> / <spacer> / <divider>Column nodes

See email-rendering.md for what each section is and when to use it.


using Pragmatic.Documents.Markup;
var template = PdxDocParser.Parse(xmlString);
// or
var template = PdxDocParser.ParseFile("invoice.pdxdoc");

Parser errors throw MarkupParseException with the offending element name and line number:

MarkupParseException: Root element must be <document>, got <documentz> (line 1)

Large templates split into reusable fragments:

<!-- invoice.pdxdoc -->
<document title="Invoice {{invoice.number}}">
<page>
<import src="header.pdxdoc" />
<heading level="1">Invoice {{invoice.number}}</heading>
<!-- ... -->
<import src="footer.pdxdoc" />
</page>
</document>
var resolver = new DocumentTemplateResolver()
.WithPartial("header.pdxdoc", PdxDocParser.ParseAsPartialFile("header.pdxdoc"))
.WithPartial("footer.pdxdoc", PdxDocParser.ParseAsPartialFile("footer.pdxdoc"));

Partials receive the same data context as the parent template.


Parsing gives you a template. To get a renderable DocumentModel / EmailModel, resolve it with data:

using Pragmatic.Documents.Templating.Data;
using Pragmatic.Documents.Templating.Pipes;
var resolver = new DocumentTemplateResolver(PipeRegistry.Default);
var data = new TemplateDataContext()
.AddSource("invoice", invoice)
.AddSource("company", company)
.AddSource("customer", customer);
var model = await resolver.ResolveAsync(template, data);
PdfRenderer.RenderTo(File.Create("invoice.pdf"), model);

See templating.md for the data context, pipes, and custom pipe registration.


A VS Code / Rider language server for PDX-Doc is on the roadmap. For now use XML IntelliSense — the schema validates structure, though expression syntax is intentionally free-form.