Pulp EngineDocument Rendering
Get started

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.md filename 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):

  1. Deep-clone the raw payload (structuredClone) as the working object — unmapped keys remain accessible
  2. For each FieldMappingEntry:
    • If expression is 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 (or targetPath if sourcePath is absent) using lodash-style dot-path resolution
    • If the resolved value is undefined and defaultValue is set, use defaultValue
    • If the final value is not undefined, write it to targetPath in the result
  3. 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: true and ajv-formats (adds "date", "email", etc.)
  • On failure: HTTP 422 with body { error: 'Validation Failed', issues: ValidationError[] }
  • Each ValidationError has { 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: default 2
  • 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
  • format options: 'short' | 'medium' (default) | 'long' | 'full'
    • short → dd/mm/yyyy style
    • medium → e.g. 14 Mar 2026
    • long → e.g. 14 March 2026
    • full → 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 via parseFloat and 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 has style or paginationHints; 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:

  • dataPath resolves to an array → renders children N times with per-item scoped data
  • dataPath resolves to undefined (key absent) → returns empty string silently
  • dataPath resolves to a non-array, non-null value → returns empty string and emits console.warn
  • Array is empty → renders emptyChildren if present, otherwise returns empty string

Scoped data per iteration (merges in this order, later entries win):

  1. Parent adapted data
  2. Item properties (if item is a plain object; scalar items do not spread)
  3. Loop data-frame variables: @index (0-based), @number (1-based), @count, @first, @last, @item, and @parent.number. Repeaters and tables accept optional itemAlias; aliases preserve parent fields without item spreading. Inline #each exposes 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
  • alternateRowStyle applied to rows at index 1, 3, 5… (0-based odd)
  • Cell value comes from expression or the col.key dot-path, then format. A cell template overrides display. Row scope follows item aliases and exposes @value, @index, @rowNumber (also @number), @first, @last and @count.
  • null / undefined cell values render as empty string

Empty state:

  • If the array is empty, a single <tr><td colspan="N"> row is emitted with emptyMessage
  • Default emptyMessage: 'No items.'

Header:

  • Explicit showHeader controls display. showHeader:true, repeatHeader:false emits a non-repeating <thead style="display:table-row-group">. Without showHeader, HTML preserves its legacy repeatHeader !== false display rule; DOCX/PPTX preserve their existing display behavior.
  • thead { display: table-header-group } is included in the @media print CSS block, causing the header to repeat on every printed page

Footer:

  • Emitted as <tfoot> only when showFooter: true
  • Every footer cell emits col.align and col.style regardless of whether col.footer text is set (no bare unstyled <td>)
  • col.footer is 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

  1. dataPath is resolved against adapted data using the same dot/bracket resolver as table and repeater (supports items[0].sub syntax).
  2. For each item in the resolved array, categoryField and valueField are resolved.
  3. Items where valueField does not resolve to a number (or a numeric string) are silently skipped.
  4. If no usable data points remain, emptyMessage is 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 via warmBrowser() (in-process mode) or the child-process dispatcher’s warmup() 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.
  • null treated as missing by exists/notExists: intentional — document templates rarely distinguish null from absent.
  • Numeric comparison operators (gt, gte, lt, lte): return false (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.
  • dataPath bracket indexing: items[0].sub syntax is supported for repeater, table, and chart nodes. Only non-negative integer indices are valid; negative indexes, floats, string/quoted keys, bare bracket-first paths ([0].a), and unclosed brackets throw a DataPathError at render time. Missing properties or out-of-range indexes return undefined without 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:

  1. Reads the PDF’s /Catalog/Dests (Chromium emits one entry per HTML id) to build a {destName → destination page} map.
  2. Walks each page’s /Annots, selects /Link annotations whose destination name starts with heading-, and uses pdf-lib’s drawText to 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.