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:
| Field | Required | Description |
|---|---|---|
id | Yes | The component's template ID (GUID). Primary resolution key. |
name | No | Human-readable label. Decorative — not used for resolution. |
data | No | Scoped 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.
| Scenario | Behavior |
|---|---|
No data field | Component 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:
| Property | Rule |
|---|---|
| Styles | Component styles added if not already defined. Parent styles win on conflict. |
| Page setup | Parent wins if defined, otherwise inherited from component. |
| Header / Footer | Parent wins if defined, otherwise inherited from component. |
| Metadata | Parent 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.