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.
Two kinds of data
Section titled “Two kinds of data”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.
What you can write between the braces
Section titled “What you can write between the braces”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.
The contract falls out of the template
Section titled “The contract falls out of the template”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:
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.
Declaring types and options
Section titled “Declaring types and options”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.
Image-filled text (imageFill)
Section titled “Image-filled text (imageFill)”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.
tileis capped at 4096 repetitions for one box. The count comes from the frame (plus the one-em margin; for agrownode 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.
Scattered text (scatterFill)
Section titled “Scattered text (scatterFill)”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
stampsentry 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
stampsthemselves: 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 fromhalf 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 ascatter-fill-thinnedwarning 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.
Notes and limits
Section titled “Notes and limits”- 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.