Data binding & templates
Each field points at the real JSON via a Binding:
scalar— a direct value (path.in.json).template— free text with{token}, e.g."Client: {name} — Total: {SUM(rows.total)}".array— a table built from an array of objects, column by column, including calculated columns (a fixed label + a formula evaluated per row — can combine fixed text with more than one token:"{pnr} - {product}").keyvalue— a "field / value" table from a manually chosen list of paths.section— the array a section repeats, one item per repetition.chart— an array to aggregate into a pie/bar chart (path,labelColumn,valueColumn, optionalfilters) — see Charts.kpi— an array to aggregate (sum/count/avg/min/max) into a single number for a KPI card, overriding its free-textvalue— see KPI cards.
Writing a path
A path can be written bare or delimited, and the difference matters for exactly
one reason: a bare path splits on ., so a JSON key whose name contains
a dot has no bare form.
| Written | Resolves to |
|---|---|
{id} | the key id |
{cliente.nome} | walks cliente → nome |
{[id]} | the key id — the delimited form of the same thing |
{[cliente].[nome]} | walks cliente → nome |
{[cliente.nome]} | the literal key "cliente.nome"; a dot inside the brackets does not separate |
{["token name"]} | the key "token name" |
{[total] + 1} | arithmetic — the operator is outside the brackets |
{CURRENCY([total], "R$", 2)} | a delimited path as a function argument |
Two rules are worth stating outright:
- A segment with a space has to be quoted —
["my key"], not[my key]. In this format an operator is only an operator with whitespace on both sides, so[a + b]would be ambiguous between the key"a + b"and arithmetic inside the brackets. It is refused rather than guessed. - Quotes are
"or', whichever the content allows. There is no escape character; a key containing"is written between'.tokenForpicks for you.
The bare form is not deprecated and never will be — {cliente.nome},
{my-key} and {my key} are the paths they always were. Use the delimited
form when the key needs it, or all the time for uniformity: the editor writes
it everywhere and shows the short form in the canvas cell, so {[id]} is what
is stored and {id} is what you read.
{[a]} used to be the key literally named [a], because the lexer absorbed
the brackets into the identifier. It is now the path a. If you have a JSON
key with a bracket in its name, write {["[a]"]}.
Template functions
Inside {...} (text, calculated column, footer cell — anywhere):
| Function | What it does |
|---|---|
SUM, COUNT, AVG | Aggregate an array path. |
CONCAT, UPPER, LOWER | String helpers. |
TRIM(path) | Trims whitespace from both ends — useful for a legacy system's fixed-width field ("invoice": " 01156189"). {token}/CONCAT preserve the value exactly as it came, on purpose — the space only goes away if you ask for it. |
DATE(path, "output"[, "input"]) | Formats a date — see below. |
CURRENCY(path, "$") | Formats a number with a currency symbol. |
NUMBER(path, decimals) | Like C's %.2f — decimal places only, no symbol/thousands separator (that's CURRENCY). |
IF(condition, "then", "else") | Conditional — see below. |
Simple arithmetic works too: {qty * price}, {subtotal - discount}.
How an expression is read. * and / bind tighter than + and -, and
parentheses group:
{qty * price + fee} -> qty * price, then + fee
{(base + fee) * qty} -> base + fee first, then * qty
One rule is unusual and worth knowing, because it is what lets a JSON key contain a hyphen or a space: an operator is only an operator when it has whitespace on both sides.
{my-key} -> the path "my-key" (not "my minus key")
{my key} -> the path "my key"
{a - b} -> subtraction
{a -b} -> neither: the path "a -b", which usually resolves to empty
The same applies to the comparison operators inside IF: IF(a == 2, …) is a
comparison, IF(a==2, …) is a path called a==2.
An expression that cannot produce a number resolves to empty rather than
failing: {"x" + 1}, {a / 0}, and a path that does not exist all behave that
way (a missing path counts as 0 inside a sum, so {missing + a} gives a).
A syntax error — an unterminated quote, an unclosed parenthesis — also
resolves that field to empty rather than failing the whole document: one stray
comma cannot cost a 200-page report. The position of the error is reported in
the editor instead (the field warning, and live inside the ƒx editor), which
is where it can still be fixed. expressionError(source, t?) gives you the
same message programmatically, and parse throws if you want the strict
version.
A function can receive another function or an arithmetic expression as
an argument ({CURRENCY(SUM(rows.total), "$")}), including two
combined by an operator ({SUM(a) - SUM(b)} subtracts correctly), with
one exception: SUM, COUNT, and AVG's own argument is always
read as a raw array path, never resolved as a nested function call —
{SUM(CONCAT(a, b))} doesn't work, it has to be a plain path like
rows.total. Their result can still be nested inside an outer
function just fine (that's what makes {CURRENCY(SUM(rows.total), "$")}
above work — CURRENCY is resolving its own argument, not SUM).
IF(condition, "then", "else") picks one of the two remaining
arguments — condition is either a comparison (status == "paid",
total > 100; operators ==, !=, >, >=, <, <=, always
surrounded by spaces) or a bare path/expression, checked for
true/false (empty string, "0", and "false" count as false, anything
else as true). Only the chosen branch is actually resolved —
{IF(hasDiscount, discountAmount, "0")} won't fail even if
discountAmount doesn't exist in the data when hasDiscount is false.
Dates are always read/written in UTC
DATE's 3rd argument (optional) gives the input format — without
it, JS's new Date(raw) tries to guess, and a date like "10/04/2025"
(the 10th) turns into October 10th (American format). Passing
DATE(dueDate, "DD/MM/YYYY", "DD/MM/YYYY") reads it exactly as
written, with no ambiguity.
Dates are always read/written in UTC — not the browser/server's
own timezone that happens to generate the PDF. A date-only value
("2026-07-01", no time) comes out matching exactly what was written,
no matter where it runs; a datetime with an explicit offset
("...T23:30:00-03:00") is converted to the equivalent UTC instant.
Writing an expression: the ƒx editor
<Designer> has a ƒx button next to every field that accepts an expression:
a table column formula, each cell of the totals row, the KPI's
title/value/caption, and a text field's content. It opens a window with
- the fields the schema is bound to, on the left, in two groups. Fields
of each item of the bound array (
total, no prefix) are what resolve inside a table row or a repeated section; the full data paths (invoices.total) are what an aggregation needs.SUM(total)from the first group would find nothing — the two scopes are the reason the list is split. - a multi-line editor with autocomplete, offering the functions above plus
AND/OR/NOTwith the hint for each. It writes the spaces an operator needs on both sides, so it cannot produce a one-sided operator. Suggestions appear only inside the braces — outside them you are writing literal text, and a list of functions there is only in the way. - live validation — the same messages the editor's field warning shows. A syntax error blocks saving; a suspicious operator only warns.
The editor holds the field value itself, braces included, opened with
whatever was already there. There is no separate compose box: you edit in
place, and a literal prefix stays where it is (FAT-{invoice}). Clicking a
field on the left inserts the bare path inside braces, or {path} outside
them.
It also reports an unbalanced brace, which nothing else does: the template
resolver matches /\{([^{}]+)\}/g, so a { with no pair simply does not
match and that stretch comes out as literal text in the PDF —
{CURRENCY(total printed as-is. It is not an expression syntax error (the
parser never sees that stretch) nor a generation failure; it was just a field
coming out wrong in silence.
The pieces are exported for a custom UI: suggestAt, applySuggestion,
insertAtCaret, wordAtCaret, ALL_SUGGESTIONS, plus tokenAtCaret and
braceError for the brace side (json-pdf-designer/server too — they are
pure).
Public functions for a custom binding UI
buildInputs(data, bindings) // resolves every binding at once
renderTemplate(template, data) // resolves a free "{token}" template
resolveToken(token, data) // resolves a single token/function
rowsFromArrayBinding(list, columns) // array of objects -> table rows
columnLabel(col), columnKey(col)
describeBinding(b, t?), describeBindingShort(b, t?) // t: Dict — defaults to English
CUSTOM_FIELD_FUNCTIONS // the list of available functions