Skip to content

Binding and expressions

A template is a page with holes in it. Binding is how you make a hole: select the text that changes on every render and press Bind. The text becomes {{customer.name}}, and the same press adds customer.name to the template’s data contract — there is no second document to keep in step.

Variables are the values that appear once per document: a customer name, an issue date, a subject line. You declare them, give each an example value, and the preview uses that example while you design.

Lists are the values that repeat: the line items of an invoice, the rows of a statement, the products on a label sheet. A list feeds a table or a grid, which draws one row or one cell per entry.

The distinction matters because it is what a caller sends: one object with a few named fields, plus one array per repeating part.

Expressions are deliberately small. They can read values, do arithmetic, compare, choose between two results, format, and aggregate a list:

{{ customer.name }}
{{ item.qty * item.price }}
{{ subtotal * 0.1 | number(0) }}
{{ issueDate | date("yyyy/MM/dd") }}
{{ issueDate | date("Gee年MM月dd日") }}
{{ total | currency("JPY") }}
{{ sum(items, i => i.qty * i.price) }}
{{ balance > 0 ? "Amount due" : "Paid in full" }}

Aggregates: sum, avg, min, max, count. Arithmetic helpers: abs, round, ceil, floor. Text: substr(text, start[, len]) — character-based slicing, out-of-range indexes clamp; pairs with comb text boxes for forms that split one value across box groups (postal codes, dates). contains(text, part), startsWith(text, part), endsWith(text, part) — partial matches for conditions that == cannot express, such as showing a block for every company whose name contains a group name. Dates: age(birthDate, asOfDate) (full years, the birthday itself turns the number over) and dateDiff(fromDate, toDate) (days, negative when to is earlier). Both take ISO dates. There is no today(): the engine never reads a clock, so the reference date is data — pass issueDate, or declare a today variable and fill it when you render. A blank operand yields empty output rather than an error. Formatters are applied with |: number, date, and currency (JPY, USD, EUR). Dates support Japanese era tokens: G is the era name and ee the zero-padded era year, so date("Gee年MM月dd日") renders 令和08年08月09日. Keep the data itself an ISO date (2026-08-09) — the format lives in the template. Only the tokens you write are emitted, so date("ee") returns just the era year (08) — for forms that split a date across box groups, combine it with comb cells instead of substr. Formatters pass empty strings through: an unfilled field prints as blank instead of failing.

There are no loops, no assignment and no way to define a function — the language cannot do those things at all. That is a deliberate boundary rather than a missing feature: it is what makes a template safe to run when someone else wrote it, and what lets the system work out, without executing anything, exactly which data paths a template reads.

Because every reference can be found statically, the payload shape is derived rather than written by hand. Open Data schema in the studio and you get the same contract in four forms: JSON Schema, TypeScript, Zod, and a sample payload you can paste into a test.

Callers can fetch it at any time:

Terminal window
curl https://composepdf.com/v1/canvases/{canvasId}/schema \
-H "x-api-key: $COMPOSEPDF_API_KEY"

The contract is enforced before layout runs, and a violation names the path that caused it. Details are in Data contract.

Each variable carries a type — text, number, date, true/false, an object, or a list — and can be marked Required. A text, number or date variable can also declare Options: a closed set of allowed values. Options travel into the published schema as an enum, so your form can render them as a select, and a value outside the set is rejected at the API boundary rather than printed onto a customer’s document.

A text box can be painted with a picture instead of a colour: the outlines of the characters become the window the image shows through, the way a clipping mask works in a vector editor. Both halves are bindable — the same render can change the words and the picture behind them.

{
"id": "headline", "type": "text",
"frame": { "x": 15, "y": 36, "w": 180, "h": 46 },
"para": { "align": "center" },
"imageFill": { "src": "{{candy}}", "tile": { "w": 15, "h": 15 } },
"spans": [{ "text": "{{title}}", "style": { "size": 96 } }]
}

src is an image id or an expression that resolves to one. How the picture meets the box is one of four:

Mode What it does
cover (default) Fills the box, cropping the overflow; focal ([x, y], [0.5, 0.5] by default) decides which part survives
contain The whole picture fits inside the box, leaving the rest of the characters empty
stretch The picture is distorted to the box
tile tile: { w, h } in mm, repeated from the corner of the box; fit and focal are ignored

The picture is placed over the text box grown by one em of the largest span on every side, so accents and descenders that reach past the line boxes are painted too; a tiled pattern keeps its phase at the box corner.

  • The characters stay real text. They are clipped, not converted to artwork, so the text is still searchable, copyable, extractable and tagged for accessibility in the PDF.
  • tile is capped at 4096 repetitions for one box. The count comes from the frame (plus the one-em margin; for a grow node the frame is counted as tall as its page) divided by the tile size rather than from your data, so a tile that would need more than 4096 copies is rejected before rendering rather than producing a document of millions of drawing operations.
  • The span colour is ignored while an image fills the text. It is not an error — the picture simply replaces the paint. A highlight behind the characters still draws.

The other way to paint a headline is to build the letters out of things. The engine walks the outline of every character, fills the inside with a grid, and drops a piece of vector artwork on each point — so the word reads as a heap of candy, confetti or leaves rather than as type. Where imageFill cuts flat at the edge of a character, the scattered pieces spill past it, which is what makes the shape look built rather than masked.

{
"id": "headline", "type": "text",
"frame": { "x": 15, "y": 44, "w": 180, "h": 56 },
"para": { "align": "center" },
"scatterFill": {
"stamps": [
{ "viewBox": [100, 100],
"paths": [
{ "d": "M46 52L54 52L54 100L46 100Z", "fill": { "space": "rgb", "r": 0.85, "g": 0.78, "b": 0.6 } },
{ "d": "M85 40C85 59.33 69.33 75 50 75C30.67 75 15 59.33 15 40C15 20.67 30.67 5 50 5C69.33 5 85 20.67 85 40Z", "fill": { "space": "rgb", "r": 0.93, "g": 0.25, "b": 0.38 } }
] }
],
"size": { "min": 5, "max": 8 },
"spacing": 4,
"seed": 7
},
"spans": [{ "text": "{{title}}", "style": { "size": 96, "font": "noto-bold" } }]
}
Field What it does
stamps The artwork to scatter, one or more. Each is a viewBox ([w, h]) and the same flattened paths a vector node carries — one is picked per placement, with equal probability
size { min, max } in mm: the longer side of a placed piece, drawn from this range
spacing mm between placement points, along the outlines and across the interior grid. Default: size.max × 0.7, which keeps the pieces slightly overlapping at their largest
density Probability in [0, 1] that a point actually gets a piece; 1 (default) places on every point
rotate Turn each piece to one of 72 angles, 5° apart. true by default
seed Seed of the placement (default 1). The same seed over the same words gives the same arrangement on every machine and every run; change it to reshuffle
  • The characters stay real text. They are drawn as an empty clip — opened and closed with nothing painted inside — so nothing of the type is visible, yet the text is still searchable, copyable, extractable and tagged for accessibility in the PDF. The words are bindable like any other text, and changing them re-scatters the artwork around the new shapes.
  • The artwork is embedded, not referenced. A stamps entry carries its own paths, so a template keeps rendering identically no matter what happens to the library the drawing came from.
  • Exclusive with imageFill. A text box takes one paint: declaring both is rejected before rendering (text-paint-exclusive) rather than silently picking one.
  • Capped at 3000 placed pieces, 20000 drawing items and 4 MB of artwork per box. The 4 MB is the stamps themselves: a copy places the artwork by reference instead of repeating its path data, so a heavy illustration gets as many copies as a light one. The count comes from half the frame ÷ spacing² (ink rarely covers more than half a box) rather than from your data, so a 0.1mm spacing cannot turn one word into millions of drawing operations. A template whose estimate is more than 4× the cap is rejected before rendering (scatter-fill-count) — that is a typo, not a design. Anything under that renders: a scatter that would reach the cap has its spacing widened until the whole box fits, so every letter keeps its material, and a scatter-fill-thinned warning tells you the spacing that was actually used. Set the spacing yourself if you want the denser look on a smaller box.
  • The span colour is ignored, as with imageFill: the artwork carries its own paint. A highlight behind the characters still draws.
  • A path only becomes part of the contract once you declare it. The studio lists the paths a template reads and offers to declare them in one action, so nothing is silently undeclared.
  • Undeclared templates still render. A template with no declarations at all is not validated against a contract — it is the older, contract-free behaviour, kept so existing templates keep working.
  • Expressions cannot reach outside the payload. No network, no files, no clock: two renders of the same payload produce the same page.