Skip to main content

Composition

Build complex documents from smaller, reusable pieces. A component is a self-contained block of styles and content that can be referenced from any document or other component.

Why compose?

Without composition, a 3-page tax form or a branded invoice repeats the same header, footer, and style blocks in every template. With composition:

  • Reuse — define a header once, reference it from every template
  • Separate concerns — a designer owns the header component, an accountant owns the box-grid component
  • Iterate safely — update a component and every document that references it picks up the change

Components

A component uses "component" as its root key (instead of "document"):

{
"component": {
"styles": {
"companyName": { "fontSize": 18, "fontWeight": "bold", "color": "#1A1A2E" },
"companyAddress": { "fontSize": 9, "color": "#666666" }
},
"content": [
{ "p": "$data.company.name", "style": "companyName" },
{ "p": "$data.company.address", "style": "companyAddress" },
{ "separator": { "lineType": "solid", "length": 500, "width": 1, "color": "#1A1A2E" } }
]
}
}

Components support everything a full document supports — styles, content elements, $data references — except they don't need pageSetup or metadata (those come from the parent document).

Components are stored as templates via the Templates API, just like full documents. Each component gets a unique ID (GUID) when created.

Referencing components

Use ref to insert a component into your document. A ref has three fields:

FieldRequiredDescription
idYesThe component's template ID (GUID). Primary resolution key.
nameNoHuman-readable label. Decorative — not used for resolution.
dataNoScoped data context. Replaces the component's $data root.

Content-level ref

Insert a component at a specific position in the content array:

{
"document": {
"pageSetup": { "size": "LETTER", "margins": [30, 30, 30, 30] },
"content": [
{ "ref": { "id": "a1000001-0001-0001-0001-000000000001", "name": "company-header" } },
{ "h1": "Invoice #$data.invoice.number" },
{ "p": "Date: $data.invoice.date" },
{ "ref": { "id": "a1000001-0001-0001-0001-000000000002", "name": "line-items-table" } },
{ "ref": { "id": "a1000001-0001-0001-0001-000000000003", "name": "signature-block" } }
]
}
}

Each ref is replaced with the component's content array, flattened in place. The component's styles merge into the document.

Top-level ref

Reference components at the document root to inherit configuration (page setup, header, footer, styles) without inserting content at a specific position:

{
"document": {
"ref": [
{ "id": "b2000001-0001-0001-0001-000000000001", "name": "corporate-branding" }
],
"content": [
{ "h1": "My Document" },
{ "p": "Content goes here." }
]
}
}

Top-level refs merge their config into the document using "first-write wins" — if the document already defines a property (e.g., pageSetup), the component's value is ignored. Printable content from top-level refs is prepended to the document's content array.

Data flow

By default, the parent document's $data context flows through to all referenced components. A component's $data.company.name resolves from the same data object passed to the parent.

Scoped data with data

Use the data field on a ref to provide a scoped data context to the component:

{
"ref": {
"id": "a1000001-0001-0001-0001-000000000002",
"name": "address-block",
"data": {
"name": "$data.recipient.name",
"street": "$data.recipient.address",
"city": "$data.recipient.city"
}
}
}

Inside the component, $data.name, $data.street, and $data.city resolve from the scoped mapping — not the parent's full data object.

ScenarioBehavior
No data fieldComponent inherits the full parent $data context
"data": { "items": "$data.lineItems" }Component sees $data.items mapped from parent's lineItems
"data": { "name": "$data.approver.name" }Component sees $data.name as a scalar value

Merge rules

When a component is composed into a document, its properties merge with the parent:

PropertyRule
StylesComponent styles added if not already defined. Parent styles win on conflict.
Page setupParent wins if defined, otherwise inherited from component.
Header / FooterParent wins if defined, otherwise inherited from component.
MetadataParent wins if defined, otherwise inherited from component.
Content (content-level ref)Component content replaces the ref element in place.
Content (top-level ref)Component content prepended to the document's content array.

Real-world example: composed tax form

A T4 tax slip composed from 5 independent components:

Parent document (t4-tax-slip.json):

{
"document": {
"metadata": {
"title": "T4 Statement of Remuneration Paid",
"author": "Canada Revenue Agency"
},
"pageSetup": { "size": "LETTER", "margins": [30, 30, 30, 30] },
"content": [
{ "ref": { "id": "a1000001-...-000000000001", "name": "component-t4-header" } },
{ "ref": { "id": "a1000001-...-000000000002", "name": "component-t4-employer" } },
{ "ref": { "id": "a1000001-...-000000000003", "name": "component-t4-boxes" } },
{ "ref": { "id": "a1000001-...-000000000004", "name": "component-t4-employee" } },
{ "ref": { "id": "a1000001-...-000000000005", "name": "component-t4-footer" } }
]
}
}

One component (component-t4-header.json):

{
"component": {
"styles": {
"yearBox": {
"fontSize": 20, "fontWeight": "bold", "textAlign": "center",
"borderTop": "1.5pt solid #000000",
"borderBottom": "1.5pt solid #000000",
"borderLeft": "1.5pt solid #000000",
"borderRight": "1.5pt solid #000000"
},
"t4Badge": {
"fontSize": 24, "fontWeight": "bold", "color": "#FFFFFF",
"backgroundColor": "#000000", "textAlign": "center"
}
},
"content": [
{
"table": {
"widths": [1, 1, 5],
"rows": [[
{ "p": "$data.year", "style": "yearBox" },
{ "p": "T4", "style": "t4Badge" },
{ "p": "STATEMENT OF REMUNERATION PAID" }
]]
}
}
]
}
}

Data passed at generation time:

{
"year": "2024",
"employer": { "name": "MERIDIAN TECHNOLOGIES INC.", "address": "1200 BAY STREET..." },
"employee": { "firstName": "JEAN-PIERRE", "lastName": "TREMBLAY", "sin": "123 456 789" },
"box14dollars": "78,450", "box14cents": "00",
"box22dollars": "15,690", "box22cents": "00"
}

The composer resolves all 5 refs, merges their styles, flattens their content in order, and produces a single document definition ready for rendering.

Where refs resolve

Refs work anywhere content elements appear:

  • Top-level content array
  • Columns layout contents
  • Table cell content
  • List items
  • Header and footer content arrays

Nesting

Components can reference other components. The composer resolves refs recursively up to 5 levels deep. Circular references are detected and rejected.

document
└─ ref: page-layout (depth 1)
└─ ref: branded-header (depth 2)
└─ ref: legal-footer (depth 2)
└─ ref: disclaimer (depth 3)

Safety

  • Tenant isolation — refs only resolve within the same tenant. A component in tenant A cannot reference a component in tenant B.
  • Circular detection — the composer tracks visited component IDs and rejects cycles.
  • Depth limit — maximum 5 levels of nesting for both top-level and content-level refs.

Composition pipeline

When you call the Generate from Template endpoint:

1. Load the parent template
2. TemplateComposer resolves all refs (recursive, depth-limited)
3. Merge styles, config, and content into a single DocumentDefinition
4. TemplateDataResolver resolves $data.* and $item.* tokens
5. LayoutBuilder renders the final PDF

Steps 2 and 3 happen before data injection, so components can contain $data references that get resolved in step 4 with the caller's data.