Pulp Engine — Technical Specification
Engineering reference for the template model and rendering pipeline as implemented. Documents behaviour, not design intent.
File retains its
mvp-technical-spec.mdfilename for link stability; the content is the current technical reference and is no longer scoped to the original MVP.
1. Template Structure
A template is a JSON file conforming to TemplateDefinition:
interface TemplateDefinition {
key: string // stable slug, unique across all templates
version: string // semver (e.g. "1.0.0")
name: string
description?: string
inputSchema: Record<string, unknown> // JSON Schema Draft-07, validates ADAPTED data
fieldMappings?: FieldMappingEntry[] // raw payload → adapted data shape
renderConfig?: RenderConfig
document: DocumentNode // root AST node
}
RenderConfig
interface LocaleConfig {
currency?: string // BCP 47 locale tag for Intl.NumberFormat. Default: "en-US"
date?: string // BCP 47 locale tag for Intl.DateTimeFormat. Default: "en-AU"
}
interface RenderConfig {
paperSize?: 'A4' | 'A3' | 'Letter' | 'Legal' | 'Tabloid' // default: A4
orientation?: 'portrait' | 'landscape' // default: portrait
margin?: { top: string; right: string; bottom: string; left: string }
customCss?: string // CSS injected into <style>. Safe mode (default): @import and url() fetch
// vectors are stripped. Pass allowUnsafeCustomCss: true to
// HtmlRenderer.render() for verbatim injection — admin sources only.
fontImports?: string[] // Font @import URLs. Always validated: https: only, private/internal hosts blocked.
locale?: LocaleConfig // locale for currency/date helpers
pageHeader?: string // HTML allowlist-sanitized at render time; script/event-handler/external-resource vectors stripped; residual CSS and Chromium risk not zero. Passed to Puppeteer headerTemplate after sanitization. Inline styles only; font-size: 0 by default. Span classes: pageNumber, totalPages, date, title, url. Capped at 10,000 chars. Visible in PDF output only.
pageFooter?: string // Same sanitization model and constraints as pageHeader. Passed to Puppeteer footerTemplate after sanitization. Capped at 10,000 chars. Visible in PDF output only.
}
locale applies to all {{currency}} and {{date}} helper calls within the template, including table column formatters. The number helper uses toFixed() and is not locale-aware. Both fields are optional; defaults are preserved when locale is omitted.
Templates are stored in PostgreSQL as JSONB (template_versions.definition). Multiple versions are retained; the latest by created_at is used for rendering. The TemplateDefinition shape is enforced at runtime by Zod (TemplateDefinitionSchema in apps/api/src/validation/template-definition.schema.ts) at all persistence boundaries — POST create, PUT update, and version restore. Unknown structural fields are rejected (strict mode); key must match /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ and version must be major.minor.patch with optional prerelease/build metadata suffixes.
2. AST Node Types
All nodes extend BaseNode:
interface BaseNode {
id: string
type: string
style?: NodeStyle // camelCase CSS properties → inline style=""
paginationHints?: PaginationHints
meta?: Record<string, unknown>
}
Structural nodes
Not part of the AnyContentNode union — not valid inside children arrays directly.
| Node | Key fields |
|---|---|
document |
children: SectionNode[] |
section |
label?, children: AnyContentNode[] |
column |
span? (flex proportion), children: AnyContentNode[] |
Content nodes
AnyContentNode union — valid anywhere a children array is accepted.
| Node | Key fields | HTML output |
|---|---|---|
container |
children: AnyContentNode[] |
<div> |
columns |
columns: ColumnNode[], gap? |
<div style="display:flex"> |
heading |
level: 1–6, content: string (Handlebars) |
<h1>–<h6> |
text |
content: string (Handlebars), inline? |
<p> or <span> |
image |
src: string (Handlebars), alt?, fit?, width?, height? |
<img> |
table |
See Table Behaviour | <table> |
icon |
name (literal/Handlebars), map?, colorMap?, fallback?, size?, color?, strokeWidth?, alt?, label? |
Bundled inline SVG plus optional escaped label; raster images in Office |
chart |
See Chart Behaviour | <div class="chart-wrapper"> + inline SVG |
spacer |
size: string, horizontal? |
<div style="height:N"> or <span style="width:N"> |
divider |
thickness?, color?, dashStyle? |
<hr> |
pageBreak |
(no extra fields) | <div style="break-after:page"> |
conditional |
condition, children, elseChildren? |
See Conditional and Repeater Behaviour |
repeater |
dataPath, children, emptyChildren? |
See Conditional and Repeater Behaviour |
richText |
content: RichTextBlock[] |
See RichText Node Behaviour |
signatureField |
label, anchorText, tabType?, signerRole?, hidden? |
<span> (anchorText rendered; hidden mode: color:transparent; font-size:2pt) |
templateRef |
templateKey, dataPath?, version? / label?, fieldMappings?, applyTargetContract? |
Inline sections from referenced template; see Template Composition |
toc |
maxDepth? (1–6, default 3), ordered?, title?, showPageNumbers?, leaderStyle? ('dots'|'dashes'|'none') |
<div class="toc"> + <ul>/<ol> with anchor links to headings; with showPageNumbers also emits a leader + reserved page-number slot that is filled post-render by pdf-lib (see §14) |
positioned |
top, left, width, height, children: AnyContentNode[] |
<div style="position:absolute;..."> — physical-unit CSS values (mm, pt, in, px) |
pivotTable |
See Pivot Table Behaviour | <table> — cross-tab grid with row/column dimension grouping and measure aggregation |
barcode |
value: string (Handlebars), format (20 formats; see Wave 52 below), payload?, width?, height?, ecLevel? (QR), displayValue? (1-D formats), colors?, rotate?, quietZone? |
<img> with inline SVG data URI, produced by @pulp-engine/barcode-renderer (bwip-js + qrcode) |
custom |
customType: string, props?, children? |
Plugin-registered node; rendered by the plugin’s HTML renderer (see plugin-api) |
NodeStyle
Any camelCase CSS property is valid (e.g. fontWeight, backgroundColor, marginBottom). Compiled to kebab-case inline style="" attributes. No validation is applied to style values.
3. Field Mapping Behaviour
Field mappings transform the raw caller payload into the adapted data shape that the renderer and schema validator operate on.
interface FieldMappingEntry {
targetPath: string // dot-path written into adapted data, e.g. "customer.name"
sourcePath?: string // dot-path read from raw payload; defaults to targetPath if omitted
defaultValue?: unknown
expression?: string // jexl expression evaluated against the raw payload; takes precedence over sourcePath when non-empty
}
Algorithm (DataAdapter.adapt):
- Deep-clone the raw payload (
structuredClone) as the working object — unmapped keys remain accessible - For each
FieldMappingEntry:- If
expressionis a non-empty string, evaluate it as a jexl expression against the raw payload; use the result as the value - Otherwise, read value from raw payload at
sourcePath(ortargetPathifsourcePathis absent) using lodash-style dot-path resolution - If the resolved value is
undefinedanddefaultValueis set, usedefaultValue - If the final value is not
undefined, write it totargetPathin the result
- If
- Fields with no mapping pass through unchanged (the clone preserves them)
Important: defaultValue is only applied when the source resolves to undefined. A source value of null, false, 0, or "" is used as-is.
Expression evaluation: jexl expressions in expression are evaluated safely — missing identifiers resolve to undefined (not a throw). Invalid syntax surfaces as a 500 error from the API.
Scope: Field mappings apply at two levels:
- Template-level (
TemplateDefinition.fieldMappings): transforms the raw caller payload before rendering begins - TemplateRef-level (
TemplateRefNode.fieldMappings): transforms the already dataPath-scoped data before it flows into the referenced sub-template’s children. The referenced template’s own top-level fieldMappings are NOT applied — only the referencing node’s fieldMappings.
Ordered and per-item calculated fields (FA-29)
Each mapping may set scope: 'adapted' to read the working result, including earlier mappings. The default remains payload, which reads the original input. Reorder mappings to control dependencies; label is an optional display name.
A target such as orders[].lines[].total evaluates once per existing object item. Up to three array segments are supported, and the target must end in a field. These mappings always use adapted scope; explicit payload scope is rejected. Non-arrays and primitive array items are skipped. Missing arrays are never created. Context merges document, outer item and current item fields, with $item, $index, $parent and $root aliases.
Example mappings:
{ targetPath: 'lines[].total', expression: 'qty * price' }
{ targetPath: 'subtotal', expression: 'lines|sum("total")', scope: 'adapted' }
Functions and transforms use one isolated synchronous JEXL engine. Compilation uses a 500-expression LRU and rejects unknown function names. Dates use the captured report clock, locale and time zone. Aggregates and rounding use decimal arithmetic; ordinary JEXL arithmetic retains its existing behavior. Arrays are limited to 100,000 items and strings to 1 MiB. Callable values are rejected. Mapping failures name fieldMappings[i] and return 422 template_expression_error; validation and dry runs collect failures instead.
Expression function reference
This table is generated from EXPRESSION_FUNCTIONS in packages/data-adapter/src/functions.ts.
| Name | Kind | Category | Signature | Description |
|---|---|---|---|---|
| startsWith | transform | string | value|startsWith(prefix) |
Test a literal prefix. |
| endsWith | transform | string | value|endsWith(suffix) |
Test a literal suffix. |
| isEmpty | transform | logic | value|isEmpty |
Test for an empty value. |
| matches | transform | string | value|matches(pattern) |
Test a bounded safe regular expression. |
| toTime | transform | date | value|toTime |
Convert a date to epoch milliseconds. |
| contains | transform | logic | value|contains(value, ignoreCase=false) |
Test text or array membership. |
| inList | transform | logic | value|inList(values, ignoreCase=false) |
Test membership in a scalar list. |
| condition | transform | logic | value|condition(leaf, globals?) |
Apply a structured condition. |
| upper | transform | string | value|upper |
Uppercase text. |
| lower | transform | string | value|lower |
Lowercase text. |
| trim | transform | string | value|trim |
Trim surrounding whitespace. |
| replace | transform | string | value|replace(search, replacement) |
Replace every literal occurrence. |
| split | transform | string | value|split(separator) |
Split text into an array. |
| substring | transform | string | value|substring(start, end?) |
Extract a text range. |
| padStart | transform | string | value|padStart(length, character) |
Pad text on the left. |
| length | transform | string | value|length |
Length of text or an array. |
| toString | transform | string | value|toString |
Convert a scalar to text. |
| round | transform | number | value|round(places=0) |
Round with decimal half-expand arithmetic. |
| floor | transform | number | value|floor |
Round down. |
| ceil | transform | number | value|ceil |
Round up. |
| abs | transform | number | value|abs |
Absolute numeric value. |
| toNumber | transform | number | value|toNumber |
Convert a numeric value, otherwise null. |
| min | function | number | min(values...) |
Smallest numeric argument. |
| max | function | number | max(values...) |
Largest numeric argument. |
| div | function | number | div(a, b, fallback=null) |
Finite decimal division with a fallback. |
| default | transform | logic | value|default(fallback) |
Fallback for null, undefined or empty text. |
| coalesce | function | logic | coalesce(values...) |
First non-null and nonempty argument. |
| now | function | date | now() |
One captured render timestamp. |
| today | function | date | today() |
Calendar date in the render time zone. |
| toDate | transform | date | value|toDate |
Normalize a date to an ISO timestamp. |
| addDays | transform | date | value|addDays(days) |
Add calendar days in the render time zone. |
| addMonths | transform | date | value|addMonths(months) |
Add calendar months, clamping the day. |
| diffDays | transform | date | value|diffDays(other) |
Difference in calendar days. |
| startOfMonth | transform | date | value|startOfMonth |
Start of the local calendar month. |
| endOfMonth | transform | date | value|endOfMonth |
End of the local calendar month. |
| year | transform | date | value|year |
Local calendar year. |
| month | transform | date | value|month |
Local calendar month, 1 through 12. |
| formatDate | transform | date | value|formatDate(pattern) |
Format using the report date token language. |
| sum | transform | array | array|sum(field?) |
Sum numeric values with decimal arithmetic. |
| avg | transform | array | array|avg(field?) |
Average numeric values. |
| count | transform | array | array|count(field?) |
Count rows, or truthy field values. |
| minOf | transform | array | array|minOf(field?) |
Smallest numeric value. |
| maxOf | transform | array | array|maxOf(field?) |
Largest numeric value. |
| pluck | transform | array | array|pluck(field) |
Extract one field per item. |
| sortBy | transform | array | array|sortBy(field, direction="asc") |
Stable numeric-aware sort without changing input. |
| unique | transform | array | array|unique(field?) |
Keep the first item for each distinct key. |
| first | transform | array | array|first |
First item. |
| last | transform | array | array|last |
Last item. |
| take | transform | array | array|take(count) |
First count items. |
| join | transform | array | array|join(separator, field?) |
Join scalar values or an item field. |
| groupBy | transform | array | array|groupBy(field) |
Group into ordered key/items records. |
| find | transform | lookup | array|find(field, value) |
First row whose field equals the lookup value. |
| filterBy | transform | lookup | array|filterBy(field, value) |
All rows whose field equals the lookup value. |
4. Validation Behaviour
Schema validation runs after field mapping, against the adapted data shape. inputSchema describes the adapted shape, not the raw payload.
- Validator: Ajv v8 with
allErrors: trueandajv-formats(adds"date","email", etc.) - On failure: HTTP 422 with body
{ error: 'Validation Failed', issues: ValidationError[] } - Each
ValidationErrorhas{ path: string, message: string }
The POST /render pipeline order:
Raw payload → Field Mapping → Schema Validation → HTML Rendering → PDF Rendering
Validation errors are returned before any rendering occurs.
5. Formatting Helpers
Handlebars helpers are available inside any content string (text, heading, image src, table column headers/footers). All helpers are registered once and are idempotent.
{{currency value [currencyCode]}}
- Formats a number as a currency string using
Intl.NumberFormat - Locale:
en-US(affects digit grouping) currencyCode: ISO 4217 code, default'USD'- Minimum fraction digits: 2
- Example:
{{currency loan.amount "AUD"}}→A$50,000.00 - Returns original value as string if not a valid number
{{number value [decimals]}}
- Formats a number to a fixed number of decimal places
decimals: default2- Example:
{{number loan.rate 2}}→5.75 - Returns original value as string if not a valid number
{{date value [format]}}
- Formats a date string or Date object using
Intl.DateTimeFormat - Locale:
en-AU formatoptions:'short'|'medium'(default) |'long'|'full'short→ dd/mm/yyyy stylemedium→ e.g.14 Mar 2026long→ e.g.14 March 2026full→ e.g.Saturday, 14 March 2026
- Returns empty string if value is null or empty; returns raw string if invalid date
{{uppercase value}} / {{lowercase value}}
String case conversion. Value coerced to string before conversion.
{{sum array "field"}}
Sums a named numeric field across an array of objects. Designed for table footer expressions where a grand total needs to be computed inline.
array— resolves from the current data context (e.g.loan.items)"field"— a simple (non-nested) field name on each item (e.g."amount")- Numeric values are summed directly.
- Numeric strings (e.g.
"12.5") are coerced viaparseFloatand included. - Non-numeric strings,
null,undefined, and missing fields are ignored (treated as 0). - Non-array first argument or empty array returns
0.
Composable with other helpers via Handlebars subexpressions:
{{sum loan.items "amount"}}
{{currency (sum loan.items "amount") "AUD"}}
Table cell formatting
Table column definitions may specify format (helper name) and formatArgs (array of additional arguments). These are applied to cell values in the renderer, not via Handlebars template strings:
{ "key": "amount", "format": "currency", "formatArgs": ["AUD"] }
6. Conditional and Repeater Behaviour
Conditional (ConditionalNode)
Evaluates a structured condition against the current data context at render time.
interface StructuredCondition {
path: string // dot-path into adapted data
operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'exists' | 'notExists'
value?: unknown // not required for exists/notExists
}
Operator semantics:
| Operator | Behaviour |
|---|---|
eq |
strict equality (===) |
ne |
strict inequality (!==) |
gt / gte / lt / lte |
numeric comparison only; returns false if either operand is not a number |
exists |
resolved value is not undefined and not null |
notExists |
resolved value is undefined or null |
Rendering:
- Condition true → renders
children - Condition false → renders
elseChildren(if present), otherwise returns empty string (no wrapper, no placeholder) - Output is wrapped in a
<div>with inline style only if the node hasstyleorpaginationHints; otherwise children are emitted unwrapped
conditionExpression (optional escape hatch): When conditionExpression is a non-empty, non-whitespace string, it is evaluated as a jexl expression against the current data context and takes precedence over condition. Missing identifiers evaluate to undefined (falsy). Invalid syntax throws ExpressionError which surfaces as a 500 from the API. When absent or blank, condition is evaluated as before.
Repeater (RepeaterNode)
Resolves an array from adapted data and renders children once per item.
interface RepeaterNode extends BaseNode {
dataPath: string // dot-path to the array
children: AnyContentNode[]
emptyChildren?: AnyContentNode[]
}
Behaviour:
dataPathresolves to an array → renderschildrenN times with per-item scoped datadataPathresolves toundefined(key absent) → returns empty string silentlydataPathresolves to a non-array, non-null value → returns empty string and emitsconsole.warn- Array is empty → renders
emptyChildrenif present, otherwise returns empty string
Scoped data per iteration (merges in this order, later entries win):
- Parent adapted data
- Item properties (if item is a plain object; scalar items do not spread)
- Loop data-frame variables:
@index(0-based),@number(1-based),@count,@first,@last,@item, and@parent.number. Repeaters and tables accept optionalitemAlias; aliases preserve parent fields without item spreading. Inline#eachexposes the same variables. See Repeater and table scope.
7. Table Behaviour
Conditional formatting
Tables support ordered rowStyleRules; columns support cellStyleRules and
cellVisuals. Every node, including sections and documents, supports
styleRules and visibleWhen. Each condition is either a structured condition
clause/group or { "expression": "JEXL" }. Lists contain at most 20 rules.
Styles accept literal NodeStyle keys; the editor exposes portable text/fill,
bold, italic and strike controls. Later matching keys win; stopIfTrue stops
at that matching rule. The cascade is row/stripe, row rule, column style, cell
rule, then visual scale fill. The data never supplies CSS property values.
{
"key": "amount", "header": "Amount",
"cellStyleRules": [{
"when": { "path": "$value", "operator": "lt", "value": 0 },
"style": { "color": "#C62828" }, "stopIfTrue": true
}],
"cellVisuals": {
"dataBar": { "color": "#90CAF9" },
"iconSet": { "set": "arrows3", "thresholds": [33, 67] }
}
}
Cell rules see the normal row/item scope plus reserved $value (raw/computed,
unformatted) and $index (zero based). Row rules see the row scope and $index.
Node conditions see their current scope, including an enclosing repeater item.
Missing names are falsy; malformed expressions raise template_expression_error
with the originating node id. Expressions share the bounded compiler cache.
Numeric visuals use finite raw values after dataView; one pre-pass computes
min, median (p50), and max. Data bars clamp the min-to-max fraction. Scales
interpolate #rgb/#rrggbb colours through optional median/explicit anchors. Icons
use ordered numeric thresholds, or percentages of the numeric range (not ranks),
with optional reverse and hidden values. Empty/non-numeric values have no visual.
| Feature | HTML/PDF | DOCX | PPTX | XLSX | CSV |
|---|---|---|---|---|---|
| Cell/row/pivot text, fill, bold, italic, strike | Yes | Yes | Yes | Static cell styles | None |
| Node style rules | NodeStyle | Existing mapped subset | Existing mapped subset | None | None |
| Visibility | Yes | Yes | Yes | Table discovery | Table discovery |
| Colour scale | Cell background | Shading | Fill | Native CF; static fill on text | None |
| Data bar | Print background gradient | Nested borderless DXA grid | Value only | Native CF on numeric cells | None |
| Icons | Accessible inline SVG | Coloured Unicode run | Coloured Unicode run | Native CF on numeric cells | None |
XLSX text-formatted cells omit bars/icons. Typed numeric helpers retain native
CF. ExcelJS 4.4 drops data-bar showValue, so showValue:false also uses the
hidden Excel number format (;;;). Native icon glyphs depend on viewer fonts.
Pair colour with icons, labels or bold text for accessibility. PPTX does not
render data bars in this version. DOCX bars have a fixed grid and a trailing
paragraph; Word/LibreOffice visual review remains part of release verification.
Tables are first-class nodes rendered directly to <table> / <thead> / <tbody> / <tfoot>.
interface TableNode extends BaseNode {
dataPath: string
columns: TableColumnDefinition[]
showHeader?: boolean // explicit display; absent preserves each format's legacy rule
gridLines?: 'none' | 'horizontal' | 'all' | 'outer'
gridColor?: string // default #cccccc when gridLines is set
layout?: 'auto' | 'fixed' // HTML layout; DOCX always fixed
repeatHeader?: boolean // default true — thead { display: table-header-group } for print
showFooter?: boolean // default false
caption?: string
emptyMessage?: string // default 'No items.'
tableStyle?: NodeStyle
headerRowStyle?: NodeStyle
bodyRowStyle?: NodeStyle
alternateRowStyle?: NodeStyle // applied to every odd-indexed row (1, 3, 5…)
footerRowStyle?: NodeStyle
}
interface TableColumnDefinition {
key: string // required column identity and default row dot-path
expression?: string // JEXL raw value evaluated per row
cell?: string // Handlebars inline HTML, overrides key/format display
cellType?: 'text' | 'image' | 'barcode' | 'link' | 'checkbox'
image?: { width?: string; height?: string; fit?: ImageFit; alt?: string }
barcode?: { format: BarcodeFormat; width?: string; height?: string;
displayValue?: boolean; errorCorrectionLevel?: QrErrorCorrectionLevel }
link?: { href: string } // Handlebars URL; display text is the label
visibleWhen?: ConditionClause
visibleWhenExpression?: string // non-empty JEXL expression wins
overflow?: 'wrap' | 'nowrap' | 'ellipsis'
header: string // Handlebars string
footer?: string // Handlebars string, compiled against full adapted data (not row)
width?: string
align?: 'left' | 'center' | 'right'
format?: string // helper name: 'currency', 'date', 'number'
formatArgs?: unknown[] // additional args passed to the format helper
style?: NodeStyle // applied to <td> cells
headerStyle?: NodeStyle // applied to <th> cells (merged with headerRowStyle)
sparkline?: { // when set, renders inline SVG instead of text
sparkType: 'line' | 'bar'
valuesPath: string // dot-path within each row to a number array
color?: string // hex (#abc), rgb(), or hsl() — default: palette colour #0
width?: number // SVG width in pixels (20–400, default 80)
height?: number // SVG height in pixels (10–100, default 20)
}
}
Body rows:
- One
<tr>per item in the resolved array alternateRowStyleapplied to rows at index 1, 3, 5… (0-based odd)- Cell value comes from
expressionor thecol.keydot-path, thenformat. Acelltemplate overrides display. Row scope follows item aliases and exposes@value,@index,@rowNumber(also@number),@first,@lastand@count. null/undefinedcell values render as empty string
Empty state:
- If the array is empty, a single
<tr><td colspan="N">row is emitted withemptyMessage - Default
emptyMessage:'No items.'
Header:
- Explicit
showHeadercontrols display.showHeader:true, repeatHeader:falseemits a non-repeating<thead style="display:table-row-group">. WithoutshowHeader, HTML preserves its legacyrepeatHeader !== falsedisplay rule; DOCX/PPTX preserve their existing display behavior. thead { display: table-header-group }is included in the@media printCSS block, causing the header to repeat on every printed page
Footer:
- Emitted as
<tfoot>only whenshowFooter: true - Every footer cell emits
col.alignandcol.styleregardless of whethercol.footertext is set (no bare unstyled<td>) col.footeris a Handlebars string compiled against the full adapted data context, not the row — use this for aggregated values computed upstream
Rich cells and column visibility
Visibility is evaluated once against the table’s enclosing scope, before widths, grouped headers, footers and empty-state colspans are calculated. Hidden columns are removed in every format. Grouped header spans shrink to their visible columns; zero-width spans disappear. An all-hidden table emits no content, with an empty worksheet retained for XLSX validity.
Templates compile once per column through bounded caches. HTML escapes binding values while retaining authored inline markup. Plain formats turn authored <br>/paragraph ends into line breaks and strip markup; decoded binding text stays literal. CSV/XLSX template output is protected against spreadsheet formulas.
| Cell | HTML/PDF | DOCX | PPTX | XLSX | CSV |
|---|---|---|---|---|---|
| Template | Inline HTML | Plain text with line breaks | Plain text with line breaks | Text | Text |
| Numeric expression | Formatted | Formatted | Formatted | Numeric raw value when unformatted | Formatted |
| Image | Safe URL, private assets inlined | Native image, resolved once per distinct source | Alt text and table_cell_degraded warning |
Alt/source text | Source text |
| Barcode | Inline SVG | SVG with PNG fallback | Value and degradation warning | Value | Value |
| Link | Safe hyperlink | Native hyperlink | Label | Native hyperlink | Label |
| Checkbox | SVG box | U+2611/U+2610 | U+2611/U+2610 | Boolean | true/false |
| Sparkline | SVG | PNG | Text and degradation warning | Text | Text |
Option blocks must match cellType; barcode requires its format. Sparkline excludes templates, expressions and non-text types. Nested tables in cell are rejected. Image and hyperlink URLs share the renderer’s safe URL guards. Checkbox strings false, 0, no and empty count as false.
overflow defaults to wrap. HTML uses a constrained cell wrapper for nowrap/ellipsis; DOCX/PPTX ellipsis uses a server-side character estimate based on explicit column width and font size. XLSX sets wrapText; CSV has no layout. This is an approximation, not an Office layout measurement.
Table and pivot styles
gridLines and gridColor apply consistent none/horizontal/all/outer borders in HTML/PDF, DOCX and PPTX. Explicit HTML row/column styles take precedence. Absent gridLines retains legacy format defaults (HTML none, Office grey grid). layout selects HTML auto/fixed; DOCX always uses fixed layout. These fields do not style CSV/XLSX.
DOCX now gives column styles precedence over row styles, including background shading, and resolves percentage widths against the containing content width. These fixes can alter existing Word output. Odd-indexed rows (1, 3, 5) receive alternateRowStyle in every renderer. Body spans and merge-repeated values remain FA-103.
8. Chart Behaviour
Chart nodes render data arrays as inline SVG. No external charting library or JavaScript is required — the SVG is generated server-side and embedded directly in the HTML output.
interface ChartNode extends BaseNode {
type: 'chart'
chartType: 'bar' | 'line' | 'pie' | 'area' | 'donut' | 'horizontalBar' | 'stackedBar' | 'groupedBar' | 'scatter' | 'gauge' | 'funnel' | 'waterfall' | 'treemap' | 'heatmap' | 'combo' | 'stackedArea' | 'bullet' | 'bubble' | 'radar' | 'pareto' | 'histogram' | 'boxPlot' | 'candlestick' | 'gantt'
// Data binding
dataPath: string // dot/bracket path to an array in adapted data, e.g. "sales" or "report.items"
categoryField: string // field name on each item to use as category label (X-axis or pie slice label)
valueField: string // field name on each item to use as the numeric value
// Display
title?: string // plain-text title rendered above the chart
showLegend?: boolean // false suppresses all legends, including multi-series charts
emptyMessage?: string // shown when the data array is empty or all values are non-numeric. Default: "No chart data."
// Sizing
width?: string // CSS width of the chart wrapper (default: "100%")
height?: string // CSS height of the chart wrapper (default: "300px")
// Extended chart type fields
xField?: string // scatter: field name for X-axis numeric value
seriesFields?: string[] // wide format: ordered series fields; also line/area/stackedArea
seriesLabels?: string[] // stackedBar/groupedBar/combo: human-readable bar-series labels
innerRadiusRatio?: number // donut: inner radius as fraction of outer (0–1, default 0.55)
gaugeMin?: number // gauge: minimum scale value (default 0)
gaugeMax?: number // gauge: maximum scale value (default 100)
// Data labels (bar / horizontalBar / stackedBar / groupedBar)
showDataLabels?: boolean
dataLabelPosition?: 'outside' | 'inside' | 'center' | 'auto' // default 'auto'
// Combo chart (bar + line overlay)
lineSeriesFields?: string[] // combo: line-series field names
lineSeriesLabels?: string[] // combo: line-series labels
secondaryAxis?: { // combo: when set, line series use a right-side y-axis
label?: string
min?: number
max?: number
}
}
Chart types
| Type | Description |
|---|---|
bar |
Vertical bar chart. Bars adapt width to the number of categories. Category labels appear below each bar. |
line |
Line chart with data-point circles. Category labels appear below each point. |
pie |
Pie chart. When showLegend: true the pie is offset left and a legend appears on the right showing category, colour swatch, and percentage. Percentage labels are rendered inside slices wider than ~23°. |
area |
Area chart. Filled polygon below the line with 20% opacity, plus solid stroke on top. |
donut |
Annular (ring) variant of pie. innerRadiusRatio controls hole size (default 0.55). |
horizontalBar |
Horizontal bar chart. Category labels appear to the left of each bar. |
stackedBar |
Stacked vertical bar chart. Multiple seriesFields are stacked per category. Legend shown when >1 series. |
groupedBar |
Grouped vertical bar chart. Multiple seriesFields are placed side-by-side per category. Legend shown when >1 series. |
scatter |
Scatter plot. Uses xField for X-axis and valueField for Y-axis. Optional categoryField for point labels. |
gauge |
Half-circle gauge. Reads a single value (first array element or single object). gaugeMin/gaugeMax set the scale. |
funnel |
Funnel chart. Stages stacked top-to-bottom with width proportional to valueField; category labels alongside each stage. |
waterfall |
Waterfall chart. Successive positive/negative deltas on valueField; running total tracked across categories with colour-coded bars. |
treemap |
Treemap. Rectangular areas sized by valueField and labelled from categoryField; squarified layout inside the viewBox. |
heatmap |
2-D heatmap. Uses rowField + columnField as row/column dimensions and valueField as cell intensity on a sequential colour ramp. |
combo |
Grouped bars (seriesFields) with one or more line overlays (lineSeriesFields). Optional secondaryAxis draws a right-side y-scale so bar/line series with mismatched magnitudes (e.g. volume + price) share one panel cleanly. Legend distinguishes bar (rect swatch) from line (stroke + dot) for monochrome print legibility. HTML/PDF, DOCX, PPTX and editor previews preserve the line overlay. |
Charts use a 480x300 SVG viewBox (legacy gauges: 480x260; banded/target gauges: 480x360) and a six-colour fallback palette. Node and theme palettes override it. Legends reserve plot space.
Chart colours & legend
palette (1-24 colours), seriesColors and combo lineSeriesColors set explicit colours. colorMap maps categories or series keys/labels. colorField reads each source row. Up to 20 colorRules use rule conditions with the row plus $value, $category and $series.
Per-mark precedence: first matching colour rule, valid row colour field, negativeColor, category/series map, explicit series colour, node palette, theme.chart.palette, legacy theme.chartPalette, built-in palette. renderConfig.theme.chart also supports negativeColor and legendPosition. New colour fields accept #RGB, #RRGGBB, rgb() and rgba(); they normalize to opaque #RRGGBB and discard alpha. Invalid colours cannot enter SVG attributes.
legend: { show, position } accepts top/bottom/right/none. showLegend: false suppresses legends. Top/bottom wrap up to three rows; right caps at twelve entries and 160px width; overflow shows “+N more”. Heatmaps use heatmapColors: { low, mid?, high }; waterfalls use waterfallColors: { increase, decrease, total }. Table sparklines inherit the first chart palette colour unless explicitly coloured.
Chart series and aggregation
The shared resolveChartData serves every document renderer and the editor. With no new fields, one input row remains one point. Wide seriesFields also supports line, area and stackedArea. Long format uses seriesField for distinct series names and valueField for observations; it cannot coexist with seriesFields. Supported long-format types are bar, line, area, stackedArea, stackedBar, groupedBar and combo bars.
aggregate is none/sum/count/avg/min/max; long format defaults to sum. Filtering, expressions and distinct run on original rows before numeric records are pivoted by category/series. Count counts numeric observations only. seriesOrder is input/asc/desc/totalDesc; maxSeries (1-20) folds remaining series into otherSeriesLabel (default Other). Combo line fields stay separate from folded bars.
Aggregated dataView.topN ranks category totals; others folds original records and re-aggregates them, so averages retain their original weights. Others stays last. Aggregated sorting accepts category/value paths, including date sorting; offset/limit apply afterwards. missingAs is gap/zero; line/area default to gaps, stacked charts to zero. Heatmaps aggregate duplicate cells when aggregation is selected; gauges aggregate all filtered numeric rows. Date bucketing belongs in calculated fields.
KPI Node Behaviour
A kpi leaf contains label (Handlebars), valuePath, optional format/formatArgs, prefix/suffix, comparePath, deltaMode (percent/absolute/both/none), deltaDecimals (0-4), direction (higherIsBetter/lowerIsBetter/neutral), statusOn (value/delta/deltaPercent), up to ten half-open bands, ordered statusRules, trendPath/trendValueField/trendType, footnote, layout (card/compact), emptyMessage and width.
Delta is value minus comparison; percentage is delta divided by the absolute comparison. Zero comparison shows n/a plus the absolute delta. Missing/non-numeric values show emptyMessage without an arrow. Rules override bands, which override sentiment; theme success/danger/neutral colours override green/red/grey defaults. Paths resolve in the current repeater scope. Arrows and signed text supplement colour. For in-cell indicators, use table iconSet formatting.
| Format | KPI rendering |
|---|---|
| HTML/PDF | Accessible card, formatted value/delta, status and SVG sparkline; card stays together during pagination. |
| DOCX | Unsplittable one-cell table, accent border/shading, 20pt value, coloured delta and PNG trend. |
| PPTX | Text card with coloured runs and PNG trend; compact layout uses one text row. |
| CSV/XLSX | Ignored; table extraction is unchanged. |
Gauges also accept gaugeBands with required from/to/color, gaugeTarget, gaugeTargetField (takes precedence) and gaugeUnits. Bands draw an outer ring and colour the value arc; target draws a tick and label. Bounds are half-open. Legacy gauges retain their original SVG when these fields are absent.
Data labels (bar family)
bar, horizontalBar, stackedBar, groupedBar, and combo honour showDataLabels. With the default dataLabelPosition: 'auto', single-value bars place labels outside the bar end; stacked segments centre labels inside. Stacked segments shorter than ~12 px fall back to a right-side callout so no value is silently dropped; small-segment behaviour can be overridden by setting dataLabelPosition explicitly.
Data resolution
dataPathis resolved against adapted data using the same dot/bracket resolver astableandrepeater(supportsitems[0].subsyntax).- For each item in the resolved array,
categoryFieldandvalueFieldare resolved. - Items where
valueFielddoes not resolve to a number (or a numeric string) are silently skipped. - If no usable data points remain,
emptyMessageis rendered instead of the SVG.
HTML output
<div class="chart-wrapper" style="width:100%;height:300px">
<p class="chart-title">Sales by Region</p> <!-- only when title is set -->
<svg viewBox="0 0 480 300" ...>...</svg>
</div>
NodeStyle and paginationHints on the chart node are merged into the wrapper style attribute alongside the width/height defaults.
Extended chart types (Wave 61)
All 24 chart types share resolveChartData in chart-renderer. It covers gauge objects/arrays, combo bar and line fields and heatmap row/column fields. HTML, Word, PowerPoint and the canvas call this resolver; CSV/XLSX skip charts.
| Type | Fields and rules |
|---|---|
bullet |
valueField actual, optional targetField, up to five rangeFields band upper bounds; bands are sorted. |
bubble |
xField, valueField (y), positive sizeField, optional colour groupField; radius proportional to square root of size, clamped 3-24 px. |
radar |
categoryField spokes (at least three), seriesFields and optional seriesLabels polygons. |
pareto |
Nonnegative valueField values, stable descending sort, cumulative percentage ending at 100%; optional paretoThreshold (0-100). |
histogram |
Numeric valueField observations or arrays; binCount 1-100 or positive binWidth (takes precedence), default Sturges. Equal-width bins retain exact edges and include max in the last bin. Widths requiring over 1,000 bins take the empty-message path. |
boxPlot |
Numeric observations or arrays grouped by categoryField; linear-interpolation QUARTILE.INC quartiles; boxWhiskers Tukey 1.5 IQR (default) or minmax. |
candlestick |
openField, highField, lowField, closeField; ohlcStyle candle (default) or bar. Invalid OHLC rows are skipped; scale covers low to high without forcing zero. |
gantt |
startField, optional endField, progressField (0-1 or 0-100), groupField; ISO dates or epoch milliseconds, date-only/unzoned values mean UTC. Missing end or equal start/end is a diamond milestone. Invalid dates/reversed intervals are skipped. Day/week/month ticks honour locale/time zone formatting. |
New chart axes use linear scales. Gantt and bullet grow with row count when height is unset; Gantt reserves at least 24 viewBox units per row. renderChartToSvgSized exposes the SVG viewBox dimensions. HTML and canvas size by aspect ratio, and Office derives image height from the same ratio. Explicit height preserves the requested box. Office images are not native editable charts.
9. RichText Node Behaviour
Rich-text nodes store content: RichTextBlock[] and optional source: { dataPath, format: 'markdown'|'html'|'text', whenEmpty?: 'fallback'|'hide' }. Changes are additive within format version 1.
Blocks are paragraphs, headings (levels 1-4), blockquotes containing paragraphs, and ordered/unordered lists with at most four levels. Paragraphs/headings support alignment, 0-8 half-inch indent steps and before/after spacing in pt/px/em. Ordered lists support decimal, lower/upper alpha, lower/upper Roman, legal numbering and a positive start. The fifth level is rejected at its schema path.
Text leaves support bold, italic, underline, strike, one super/subscript value, colour, highlight, guarded font sizes (6-96pt or 8-128px) and guarded font families. Colours accept hex/rgb/rgba/hsl/hsla; named colours are rejected. Links allow http/https/mailto/tel only. HTML output revalidates every CSS value and URL. Author-controlled static leaves retain existing Handlebars behavior.
PDF uses native HTML tags and counters. DOCX uses native run properties, headings, borders and a numbering registry owned by each render. PPTX uses native runs and bullets, and reports legal numbering and hyperlink degradations. CSV/XLSX do not extract rich-text nodes. Old HTML output remains byte-identical; three/four-level HTML lists use decimal/alpha/Roman/decimal defaults, while older shorter lists retain their historical numbering unless explicitly configured.
Formatted sources resolve in the current scope, including repeater items and template references. CommonMark plus strike and allowlisted HTML convert to native blocks; plain text uses blank lines for paragraphs and single newlines for breaks. Payload expressions are always literal. Missing/null/blank values fall back or hide; non-strings fall back with diagnostics. Conversion caps are 200,000 characters, 2,000 blocks and bounded nesting. Unsupported elements degrade to text or are dropped for active content. Diagnostics appear in dry-run/validate; PPTX includes structured warnings.
The visual editor uses marked field chips serialized as ordinary mustache text, fixed Tiptap attributes, a single roving toolbar and four-level Tab indentation. Canvas, data and diff previews share one rich-text renderer. Data mode lazily loads the formatted-content converters.
10. Pagination Behaviour
Pagination hints are compiled to both modern (break-*) and legacy (page-break-*) CSS properties for maximum Puppeteer/print compatibility.
interface PaginationHints {
breakBefore?: 'auto' | 'always' | 'avoid' | 'page' | 'left' | 'right'
breakAfter?: 'auto' | 'always' | 'avoid' | 'page' | 'left' | 'right'
breakInside?: 'auto' | 'avoid'
keepWithNext?: boolean // compiles to break-after: avoid
}
Mapping:
| Hint | CSS emitted |
|---|---|
breakBefore: "always" |
break-before: always; page-break-before: always |
breakAfter: "avoid" |
break-after: avoid; page-break-after: avoid |
breakInside: "avoid" |
break-inside: avoid; page-break-inside: avoid |
keepWithNext: true |
break-after: avoid; page-break-after: avoid |
Pagination hints are applied as inline styles on the node’s wrapper element. These are CSS soft hints — Puppeteer honours them when there is sufficient content flow room; hard layout constraints take precedence.
The <style> block in every rendered document includes:
@media print {
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
tbody { display: table-row-group; }
}
11. Pivot Table Behaviour
The pivotTable node groups a flat source array into a cross-tab. Its additive matrix fields retain format generation 1.
Node definition
interface PivotDimension {
field: string; label?: string; sort?: 'asc' | 'desc' | 'none'
bucket?: 'year' | 'quarter' | 'month' | 'week' | 'day' | 'fiscalYear' | 'fiscalQuarter'
fiscalYearStartMonth?: number // 1-12, fiscal buckets only; default 1
bucketLabel?: 'short' | 'long'
sortBy?: { measure: string; direction: 'asc' | 'desc' }
top?: { n: number; by: string; otherLabel?: string }
}
interface PivotMeasure {
field: string; label: string; key?: string
aggregation?: 'sum' | 'count' | 'avg' | 'min' | 'max' | 'distinctCount' | 'median'
format?: string; formatArgs?: unknown[]
showAs?: 'value' | 'percentOfRow' | 'percentOfColumn' | 'percentOfGrandTotal'
| 'runningTotal' | 'percentOfParentRow' | 'percentOfParentColumn'
| 'runningTotalAcross' | 'differenceFromPrevious' | 'percentDifferenceFromPrevious'
}
interface PivotCalculatedMeasure {
key: string; label: string; expression: string
format?: string; formatArgs?: unknown[]
}
interface PivotTableNode extends BaseNode {
type: 'pivotTable'; dataPath: string
rowDimensions: PivotDimension[] // 1-4 levels
columnDimension: PivotDimension // required outer column level
nestedColumnDimensions?: PivotDimension[] // up to 2 inner levels
measures: PivotMeasure[]
calculatedMeasures?: PivotCalculatedMeasure[] // up to 10, after base measures
showRowGrandTotal?: boolean // default true
showColumnGrandTotal?: boolean // default true
showRowSubtotals?: boolean // default false
showColumnSubtotals?: boolean // default false
showDimensionLabels?: boolean // default false
subtotalLabel?: string // default "{value} Total"
grandTotalLabel?: string // default "Grand Total"
emptyCell?: string; caption?: string; emptyMessage?: string
tableStyle?: NodeStyle; headerRowStyle?: NodeStyle; bodyRowStyle?: NodeStyle
alternateRowStyle?: NodeStyle; totalRowStyle?: NodeStyle
gridLines?: 'none' | 'horizontal' | 'all' | 'outer'; gridColor?: string
layout?: 'auto' | 'fixed'
}
Base measures without an explicit key use measure_1, measure_2, etc., in display order. Keys match /^[A-Za-z_]\w*$/, exclude prototype names, and must be unique across resolved base keys and calculated keys. Explicit keys are recommended before writing expressions or ranking rules. Removing or reordering an implicit measure changes its key.
Shared transform and header grid
toPivotConfig carries the node, numeric arithmetic, date locale and render time zone into the pure data-adapter transform. Date bucketing accepts Date, ISO date/time strings and epoch milliseconds, using UTC when no time zone is set. Invalid dates share the empty member. Keys sort chronologically (2026-Q1, 2026-03, 2026-W11); labels use the date locale. Weeks are ISO weeks. Fiscal years use the calendar year in which they end: July 2026 with start month 7 is FY2027-Q1.
Top-N ranks base aggregates within each parent, processes outer levels first, and remaps the remainder to a distinct internal Others member that always sorts last. Every raw record feeds every row-prefix/column-prefix pair: at most 5 x 4 feeds per measure. This prefix cube preserves weighted averages and exact distinct counts/medians at all margins. Axis trees sort siblings by their own label direction or measure (including calculated measures), inheriting the preceding level’s effective direction when unset. Closed groups emit subtotals after their children.
Calculated expressions compile once and see base aggregates plus earlier calculated keys. profit / revenue therefore recomputes weighted margin at every subtotal and total. Non-finite/non-numeric results become null; syntax errors surface as template_expression_error with the pivot node ID. Calculated measures do not accept show-as modes.
Show-as modes retain baseValue. Parent percentages use parent-prefix denominators, and zero/null denominators produce empty cells. Running-total-across and previous-column modes visit leaf columns and reset at each outermost column group. Difference modes leave the first leaf and all total columns empty. Percent displays default to one decimal place unless explicitly formatted. Down-the-rows runningTotal retains its existing behavior.
The result exposes columns (canonical keys, display labels, depth and subtotal marker), row dimensionLabels, display measures, and a shared header. Header cells contain text, absolute column position, colSpan, rowSpan, role and dataDerived. With C column levels and M measures, height is C + (M > 1 ? 1 : 0). Row labels span the full height; values span all descendant leaf/subtotal columns times M. A subtotal spans the remaining dimension rows, while Grand Total spans C rows. showDimensionLabels appends the column path to the last row-label corner. pivotHeaderGrid expands spans into occupied positions and marks their origins.
Legacy pivots without matrix features retain the prior accumulation/order path to preserve HTML/CSV bytes. An explicit inner row sort now takes effect; stored templates with only the outer direction keep their prior behavior.
Measure rules and visuals
Base measures accept cellStyleRules, cellVisuals and applyRulesTo (cells by default; opt into rowTotals, subtotals, grandTotal). Rules see $value, $column (flat canonical path), $columnValues (canonical key array), $rowValues, $measure and $kind. Column subtotal cells use kind subtotal; the overall total keeps grandTotal. Leaf-only min/p50/max anchors exclude subtotal columns and rows. Section 7’s support and precedence apply.
Rendering targets
| Target | Header and values |
|---|---|
| HTML/PDF | Shared spans become scoped th cells. Column subtotal headers/cells use pivot-col-subtotal, default shading and totalRowStyle; row subtotals retain existing classes. |
| DOCX | Native table with gridSpan/vMerge, repeating header rows and an explicit grid matching every data column. |
| PPTX | Native table with colspan; vertical spans become blank themed continuation cells. Content-aware widths cover every grid column. |
| CSV | Span text appears only at its top-left position; other positions are blank. Existing dialect/export options apply. |
| XLSX | Every header span is merged; numbers and ratios remain numeric with format codes such as 0.0%. |
Data-derived headers and dimension labels are neutralized against spreadsheet formula injection. Subtotal labels replace {value} with the group’s display label. The Sales Pivot (Matrix) starter demonstrates two years, four quarters, year subtotals, two base measures and a weighted margin. Wide matrices should use landscape/larger paper or XLSX; there is no hard column limit or automatic 200-column warning yet. Exact median cubes can consume substantial memory at high row/cardinality counts.
12. Features, Limitations, and Non-Goals
Features added beyond the original MVP scope
The following were originally considered deferred but have since shipped:
| Feature | Notes |
|---|---|
Drag-and-drop template editor (apps/editor) |
Implemented and in internal pilot — see docs/editor-guide.md |
| Authentication / authorisation | Scoped API key model (API_KEY_ADMIN, API_KEY_RENDER, API_KEY_PREVIEW, API_KEY_EDITOR), editor session tokens (HMAC-signed), named-user mode (EDITOR_USERS_JSON). See docs/api-guide.md. |
| Template version pinning | Optional version field in POST /render body. See docs/api-guide.md. |
| PDF streaming | Both POST /render and POST /render/preview/pdf stream via Puppeteer createPDFStream() — no Node.js PDF buffer. Chrome render-time memory still applies; in RENDER_MODE=container / socket it applies inside the ephemeral worker, insulated from the API process. |
| Image asset management | Upload, list, and delete image files via POST /assets/upload, GET /assets, DELETE /assets/:id. Files stored in ASSETS_DIR and served at /assets/*. Image src strings can reference assets by path (e.g. /assets/my-logo.png). |
| Chart / graph nodes | 24 chart types (bar, line, pie, area, stackedArea, donut, horizontalBar, stackedBar, groupedBar, scatter, gauge, funnel, waterfall, treemap, heatmap, combo, bullet, bubble, radar, pareto, histogram, boxPlot, candlestick, gantt) rendered as inline SVG. Optional showDataLabels on the bar family; combo supports a dual-axis line overlay. Sparkline formatter on table columns. See Chart Behaviour. |
| Barcode / QR node | 20 formats via @pulp-engine/barcode-renderer, including PDF417, Aztec, GS1, postal and Swiss QR. Handlebars-capable value or structured payload (EPC, Swiss QR-bill, vCard, Wi-Fi), colours, rotation and quiet zone. HTML/PDF inline SVG; DOCX SVG with PNG fallback; PPTX PNG. |
| Template CRUD | POST /templates (create), PUT /templates/:key (update), DELETE /templates/:key (soft delete), POST /templates/:key/versions/:version/restore (restore historical version). |
dataPath bracket indexing |
items[0].sub syntax supported in repeater, table, and chart dataPath fields. |
richText node with structured content |
Paragraph, heading, quote, four-level lists, inline typography, safe links and formatted payload sources. Safe HTML renderer with whitelisted tags. Visual and JSON editing in apps/editor. See RichText Node Behaviour. |
Template composition (templateRef) |
Inline rendering of referenced templates with dataPath scoping and fieldMappings for data transformation. Max nesting depth: 5. See Template Composition. |
Table of contents (toc) |
Auto-generated anchor-linked TOC from document headings. See TOC Node Behaviour. |
Absolute positioning (positioned) |
Container with physical-unit CSS positioning for form overlays. CSS unit whitelist enforced. See Positioned Node Behaviour. |
Cross-tab / pivot table (pivotTable) |
Pre-render data transform: flat array → 2D grid grouped by row/column dimensions with aggregated measures (sum, count, avg, min, max). All render targets (HTML, DOCX, CSV, XLSX). See Pivot Table Behaviour. |
Known limitations
See the consolidated Current limitations table in mvp-scope.md — that is the canonical list.
Known constraints
- Puppeteer startup:
first PDF request per process takes ~2–3 s (cold browser)Resolved — the browser is now warmed at startup viawarmBrowser()(in-process mode) or the child-process dispatcher’swarmup()IPC (child-process mode). The ~2–3 s Chromium launch cost is paid during server boot, not on the first request. Subsequent requests continue to reuse the singleton browser instance. Container/socket modes are ephemeral and unaffected. nulltreated as missing byexists/notExists: intentional — document templates rarely distinguishnullfrom absent.- Numeric comparison operators (
gt,gte,lt,lte): returnfalse(not an error) if either operand is not a number. - Alternating row style: applies to rows at odd indices (1, 3, 5…), not to even indices. The first data row (index 0) always uses
bodyRowStyle. dataPathbracket indexing:items[0].subsyntax is supported forrepeater,table, andchartnodes. Only non-negative integer indices are valid; negative indexes, floats, string/quoted keys, bare bracket-first paths ([0].a), and unclosed brackets throw aDataPathErrorat render time. Missing properties or out-of-range indexes returnundefinedwithout throwing.
13. Template Composition
The templateRef node embeds a referenced template’s document sections inline at render time.
interface TemplateRefNode extends BaseNode {
type: 'templateRef'
templateKey: string // key of the template to resolve
dataPath?: string // dot-path to scope parent data
version?: string // exact version pin, exclusive with label
label?: string // tenant-scoped release pointer
applyTargetContract?: boolean // target mappings then input-schema validation
fieldMappings?: FieldMappingEntry[] // transform scoped data before sub-template rendering
dataSource?: TemplateRefDataSource // external data source (fetched server-side)
dataMergeStrategy?: 'replace' | 'shallow' | 'deep' // how fetched data merges with parent
}
Resolution: All template references are pre-resolved before the synchronous render walk via resolveAllRefs(). Max nesting depth is 5. Cycle detection prevents infinite recursion.
Data flow: parent data → dataPath scoping → data source fetch + merge → fieldMappings (DataAdapter.adapt) → sub-template children. The referenced template’s own top-level fieldMappings are not applied — only the referencing node’s mappings. The referenced template’s renderConfig (margins, headers, fonts) is also not applied — only its document sections are rendered inline.
Field mappings on templateRef enable parameterized composition: a master template can pass transformed data to a reusable sub-template (e.g. a “letterhead” fragment receiving recipientName from the parent’s customer.name).
Data Source Binding
When dataSource is set, the sub-report fetches its own data server-side during a pre-render resolution pass (after resolveAllRefs, before the synchronous render walk). Sibling data sources at the same depth are fetched in parallel.
interface TemplateRefDataSource {
type: string // 'static' | 'url' | plugin-registered
data?: Record<string, unknown> // type='static': inline data payload
url?: string // type='url': endpoint (supports {{handlebars}} interpolation)
headers?: Record<string, string> // type='url': HTTP headers (values support interpolation)
timeoutMs?: number // fetch timeout (default 10s, range 1–30s)
method?: 'GET' | 'POST' // HTTP method (default GET)
body?: string // POST body (supports interpolation)
config?: Record<string, unknown> // arbitrary config for plugin types
}
Merge strategies (dataMergeStrategy, default 'replace'):
replace— fetched data replaces parent context entirely (sub-report is independent)shallow—{ ...parentScoped, ...fetched }(inherit shared context like company info)deep— recursive merge, fetched wins at leaf level; arrays replaced wholesale
Error handling: Production renders fail with DataSourceError (HTTP 422, code data_source_error). Preview renders degrade gracefully — the sub-report falls back to parent-scoped data.
Security: URL data sources are validated against the same SSRF guard as webhook delivery (validateWebhookUrl). The UrlValidator is injected via dependency injection to maintain package boundaries.
Plugin extensibility: Plugins register custom data source types via ctx.registerDataSourceProvider(type, provider). Built-in types static and url cannot be overridden.
Caching: A per-render cache deduplicates identical URL fetches (keyed by post-interpolation URL + headers). Static data sources are not cached (clone is cheap).
Per-row scope and target contracts (Wave 37)
A shared browser-safe scope helper serves HTML/TOC, CSV/XLSX, DOCX and PPTX.
It resolves the key (including an exclusive label or exact-version pin), scopes
dataPath, merges the instance’s source result, applies node mappings, then
optionally applies target mappings and validates its input schema. Target-contract
errors aggregate with indexed subReport.<id>[indices].<field> paths.
The pre-render walker follows live conditional branches and repeater item scopes,
including positioned/sticky/custom children and nested references, to depth five.
URL sources interpolate per item and deduplicate identical method/URL/headers/body
requests. A per-render session holds instance results, promises and a limiter,
preventing overlapping renders from clearing each other’s cache. Defaults are
five simultaneous requests and 200 distinct requests per render, shared with
parameter choices. Static and legacy plugin sources retain singleton scope;
plugins may implement instanceKey to opt into per-row instances. Preview
suppresses source failures/cap overflow and degrades those instances.
Root typed report parameters resolve once before adaptation and remain available
as @params / $params across all output formats and included reports. Label
pins use tenant-scoped resolution without A/B bucketing. applyTargetContract
defaults off, preserving legacy mapping/schema behavior. Independently bound child
parameter declarations are reserved for Wave 38’s reusable-component props.
14. TOC Node Behaviour
The toc node auto-generates a table of contents from all headings in the document.
interface TocNode extends BaseNode {
type: 'toc'
maxDepth?: 1 | 2 | 3 | 4 | 5 | 6 // default 3
ordered?: boolean // <ol> when true, <ul> when false (default)
title?: string // optional heading above the list
showPageNumbers?: boolean // default false — when true, reserves a right-aligned slot and post-processes the PDF to stamp real page numbers
leaderStyle?: 'dots' | 'dashes' | 'none' // default 'dots'; only meaningful when showPageNumbers is true
}
Heading collection: Before rendering begins, HtmlRenderer.buildBody() performs a lightweight AST pre-walk that collects { id, level, content } for every heading node in the document — including headings inside containers, columns, conditionals, repeaters, positioned nodes, and templateRefs. Heading content is interpolated via interpolatePlain() so Handlebars expressions resolve to plain text (no HTML entities in TOC link text).
Anchor links: All <h1>–<h6> tags in rendered output include id="heading-{nodeId}". The TOC emits <a href="#heading-{nodeId}"> links. In PDF output, Chromium resolves these as in-document jumps.
Filtering: Only headings with level <= maxDepth appear in the TOC. Indentation is applied via margin-left proportional to heading level.
Page numbers (PDF output): When showPageNumbers is true, the HTML renderer emits a wrap-safe leader (radial-gradient for dots, linear-gradient for dashes) followed by a reserved-width <span class="toc-page-slot"> per entry. The HTML/preview path leaves the slot blank — page numbers are a print-only artifact.
On the PDF path, TemplateRenderer walks the AST via hasTocWithPageNumbers and, when any reachable TOC node requests page numbers, sets stampTocPageNumbers: { destPrefix: 'heading-' } on the dispatch options. After Puppeteer produces the PDF, pdf-renderer invokes @pulp-engine/pdf-transform#stampTocPageNumbers which:
- Reads the PDF’s
/Catalog/Dests(Chromium emits one entry per HTMLid) to build a{destName → destination page}map. - Walks each page’s
/Annots, selects/Linkannotations whose destination name starts withheading-, and uses pdf-lib’sdrawTextto stamp the resolved page number right-aligned inside each annotation’s/Rect.
CSS target-counter() is not used — Chromium’s Puppeteer page.pdf() does not resolve it against printed pages. The destPrefix filter prevents non-TOC <a href="#heading-…"> anchors from being stamped inadvertently.
Known limitation — wrapped titles: When a TOC entry title wraps to a second line, the leader sits between the last line and the page number rather than continuing per-line. Acceptable for v1; short titles avoid this.
15. Positioned Node Behaviour
The positioned node renders an absolutely-positioned container for form overlays and pre-printed stationery use cases.
interface PositionedNode extends BaseNode {
type: 'positioned'
top: string // e.g. "10mm", "72pt", "1in", "50px"
left: string
width: string
height: string
children: AnyContentNode[]
}
CSS unit validation: Position and dimension values are validated against a whitelist regex: /^\d+(\.\d+)?(mm|pt|in|px|cm|em|rem|%)$/. This is enforced at both the Zod schema (API boundary) and the renderer (defense in depth). Invalid values fall back to 0px. This blocks CSS injection vectors (expression(), url(), calc(), semicolon injection).
Rendering: Emits <div style="position:absolute;top:...;left:...;width:...;height:...">children</div>. Children are rendered recursively.
Parent requirement: The positioned node must be placed inside a container with style.position: 'relative' set by the template author. The renderer does not auto-wrap the parent — this would require parent-context awareness, violating the stateless-per-node rendering model.
Readable chart controls
Chart nodes accept optional axes: { x, y }, referenceLines, valueFormat, categoryFormat, sliceLabel, subtitle and footnote. Each axis accepts show, title, gridlines, finite min/max (min < max), positive step, tickCount (2-12), scale (linear/log), labelRotation (0/45/90/auto), labelWrap and positive integer/auto labelInterval. Value axes use Y except horizontal bars (X); scatter has two numeric axes. Pie, donut, gauge, funnel, treemap and heatmap ignore axes.
Linear scales use 1/2/5 intervals and rounded ticks; excessive explicit steps fall back to automatic intervals. Bar scales include zero, stacks diverge, and geometry clamps to explicit bounds. Positive line/scatter ranges may omit zero when min > max * 5/6. Log scales support bar, horizontal bar, grouped bar, line, scatter and combo; non-positive data or unsupported chart types fall back to linear with an SVG diagnostic comment. Reference lines (max 10) choose exactly one finite value, scoped valuePath, or stat (mean/median/min/max), plus label/colour/dash and primary/secondary axis.
Formats have format, up to four scalar formatArgs, and optional prefix/suffix (max 16 characters each). Shared helper names include number, currency, date, formatNumber and formatDate; compact and percent use Intl number locale, falling back to currency locale and en-US. secondaryAxis.format independently formats combo line ticks/labels. Category formatting precedes truncation. Pie/donut slice labels accept percent/value/valueAndPercent/none. Title, subtitle, footnote and axis titles interpolate Handlebars against the chart scope as plain text and are escaped once when written to HTML/SVG.
Declarative data views (FA-28; node sections 6/7/8/11)
Tables and repeaters accept dataView: { filter?, filterExpression?, sort?, distinct?, offset?, limit? }. Charts additionally accept topN. Pivots accept only filter and filterExpression, applied before pivot aggregation. Existing templates without a view retain their original row order and counts. Data views never mutate input arrays.
The normal pipeline is filter, stable multi-key sort, distinct, offset, limit. A nonblank filterExpression overrides the structured filter. Each filter receives merged parent and item fields plus the same report globals as conditions. Sort keys specify path, direction (asc default), type (auto default), and nulls (last default). Auto type is chosen once per column: numeric values, ISO dates, otherwise locale-aware strings with numeric collation. Null, undefined and empty strings follow null placement independently of direction. Whole-row distinct uses canonical object keys.
Top-N runs filter, distinct, ranking, take N, aggregate Others, then sorts retained rows. Others stays last. Ranking defaults to the chart value field; Others aggregates each value and series field using sum (default), count, avg, min or max. Top-N accepts 1?500, cannot combine with offset/limit, and is unavailable for line, area, scatter, waterfall, heatmap and gauge charts. Row limit accepts 0?1,000,000; sorting accepts 1?5 keys.
HTML/PDF, DOCX, PPTX, CSV and XLSX resolve the same view before row scopes. Pivot format limitations are unchanged. Table footer $rows contains visible rows; the original data path still refers to the full adapted array. Example: {{sum $rows "total"}} totals the displayed table.
Grouping and record pagination (Wave 18)
Tables accept one to four groups, ordered from outermost to innermost. Each
level has by (an item-relative path), optional labelPath, dateBucket
(day, week, month, quarter, year), weekStart (monday by default),
sort (asc, desc, none), sortBy: { aggregate, field, direction },
limit (1-10000), and blankLabel. Aggregate sort defaults to descending;
count needs no field. Blank keys sort last, and a limit applies after sorting.
A data view filters and orders the source before grouping. Details retain their
source order within each group. Date-only values keep their calendar date;
timestamps use the render time zone (UTC by default). Naive ISO timestamps are
interpreted as UTC, independently of the server’s local zone.
A table level also accepts headerRows, footerRows, subtotal (boolean or
{ label, style }), keepTogether, pageBreakBetween, and repeatHeader
(level zero only). Bands reuse table header-cell content, style and colspan
rules. Each band’s spans must equal the table column count. subtotal.label
uses {value} for the group label. Columns accept aggregate with
sum, count, avg, min, or max; showFooter displays their grand totals,
and an explicit column footer overrides that column’s automatic grand total.
Aggregates use decimal arithmetic, ignore nonnumeric values except for count,
and return null for empty numeric sets. Parent subtotals include all items in
that parent group, even when an inner level limits the displayed children;
grand totals cover the displayed detail rows.
Band and grouped detail scopes expose @group.key, .value, .label, .count,
.items, .level, .index, .isFirst, .isLast, .parent, and date bucket
.start/.end (inclusive calendar dates). Parent report bindings stay available.
For example, {{sum @group.items "amount"}} totals the current group and
{{@group.parent.label}} names its parent. Repeater text also supports @index.
The old {{#groupBy}} helper remains supported; structural grouping supersedes
it for reports needing bands, totals or pagination.
Repeaters accept group with the data-group fields above. Each iteration then
receives a group instead of a raw item; put a nested table or repeater over
@group.items inside its children. itemPagination applies per item, or per
group in group mode:
{ "breakBetween": "oddPage", "keepTogether": true,
"repeatLeadingChildren": 1, "restartPageNumbering": true }
breakBetween is none, page, or oddPage. Numbering restart requires a
page break. repeatLeadingChildren is an integer from 0 to 20, capped at the
available child count. PDF repeats these children above continuation content.
An item too large for one page can still split despite keepTogether.
HTML uses grouped tbody units, fixed column layouts and repeating theads.
PDF performs parity insertion, numbering, TOC stamping, chrome and metadata in
one post-pass. Odd/even hints on nodes and sections use named destinations;
blank duplex pages have no chrome. Blank pages count toward the preceding
numbering run. Restarted totalPages counts that run; TOC numbers remain
absolute document pages. Streamed, buffered and child-process renders use the
same pass. DOCX maps groups to native rows and pagination to sections, with
SECTIONPAGES for restarted totals. Nested section boundaries inside a Word
table/columns fall back to a plain page break with a warning. DOCX even-page
hints use a plain break. See the compatibility guide for format limits.
Master layouts provide shared chrome and named child slots; see Master layouts for pinning, locking and dependent validation.
Label layout, text fitting and structured barcode payloads (Wave 52)
repeater.layout is optional: mode is flow|grid|labels, with optional preset,
columns/rowsPerSheet, cellWidth/cellHeight, columnGap/rowGap, cellPadding,
fillOrder (across|down), skip/copies (number or Handlebars), clip and showOutlines.
Lengths accept mm/cm/in/pt/px. Omission preserves the original flow output.
Seven presets and pure geometry/position helpers live in template-model.
text.fit and heading.fit accept mode:wrap|clip|ellipsis|shrink, maxLines
(1..50) and minFontSize (positive pt/px). PDF runs static CDP measurement only for
shrink markers, under the existing script-blocking CSP. Other formats estimate
font size/truncation; CSV/XLSX do not apply text fitting.
Barcode formats: qr, code128, code39, ean13, upc, datamatrix, pdf417, aztec,
ean8, upce, itf14, gs1-128, gs1datamatrix, gs1qr, code93, codabar, usps-imb,
royalmail, auspost, swissqr. payload:{kind,fields} selects EPC/Swiss payment,
vCard or Wi-Fi builders; all fields interpolate as plain text. colors accepts
bars/background/text as #RRGGBB; rotate is 0/90/180/270; quietZone is 0..20.
Encoder/builder failures produce accessible placeholders and warnings.