The template document
A template is one JSON object. It describes a page, a list of reusable styles, and a flat list of nodes. There is no nesting in the node list — a node points at its parent by id.
Every field below is required. The node props object is a union of the props of all node types, so a node must carry every prop key even when its type does not use them. Leave the unused ones empty: "" for strings, 0 for numbers, false for booleans, [] for arrays. Removing a key is a schema violation; emptying it is not.
Top level
v integer
Schema version. Currently 1. Documents without it are rejected rather than guessed at.
unit const
Coordinate unit. Always "px" at 96 dpi, so 794 x 1123 is an A4 page.
px
lang enum
Document language, used to pick the right CJK letterforms. Omit it and we guess from the characters, which works whenever kana or Hangul appear. Declare it when your Japanese text is written entirely in kanji — an invoice heading like 請求書 is valid Chinese too, so guessing gets it wrong and the document prints in Chinese letterforms. Accepts "ko", "ja", "zh-Hans", "zh-Hant".
kojazh-Hanszh-Hant
page object
Paper, margins, and the running header and footer.
fonts array
Fonts this document needs. An empty array uses the built-in set.
styles array
Reusable text and box styles. Nodes point at them by id.
nodes array
Every node in the document, as one flat array. Nesting is expressed by parent.
page
size enum
Paper size. One of a4 · a5 · letter · legal — anything else is rejected. At 96 dpi that is A4 794×1123, A5 559×794, Letter 816×1056, Legal 816×1344 px. Leave it out and you get a4.
a4a5letterlegal
orientation enum
Paper orientation. Applied after size, so a5 + landscape is a wide A5.
portraitlandscape
margin object
Printable margins in px. Node coordinates start inside them.
margin.t number
Top margin.
margin.r number
Right margin.
margin.b number
Bottom margin. Usually larger than the top to leave room for the footer.
margin.l number
Left margin.
header string
Text repeated at the top of every page. Empty string means no header. Supports {{ … }}.
footer string
Text repeated at the bottom of every page. Empty string means no footer. Supports {{ … }} plus [[page]] and [[pages]].
backgrounds array
Background images. They are laid on every page. Up to three; leave it as an empty array if unused. Note that the coordinates differ from a node: a background is positioned against the whole sheet, while nodes are positioned inside the margin. That is what lets you lay down a form that covers the paper edge to edge.
backgrounds[].assetId string
The id of an asset uploaded with POST /v1/assets.
backgrounds[].x number
Horizontal position in px from the top left of the sheet — outside the margin, unlike a node.
backgrounds[].y number
Vertical position in px from the top left of the sheet.
backgrounds[].w number
Width in px.
backgrounds[].h number
Height in px.
backgrounds[].opacity number
0 to 1, where 1 is opaque. Lower it when a dark form makes the values printed on top hard to read.
backgrounds[].rotate number
Rotation in degrees, for straightening a skewed scan. 0 by default.
backgrounds[].print boolean
Set to false and it appears in the editor but not in the output — for lining fields up against pre-printed paper.
backgrounds[].locked boolean
Pins it in the editor. It has no effect on the render.
fonts
family string
Font family name, matching what styles[].fontFamily asks for.
src enum
Where the font comes from. Only "preinstalled" works today — the built-in families listed in the editor. "r2" is reserved for fonts you upload, which is not available yet; a document asking for it renders with a fallback face and says so in warnings.
preinstalledr2
styles
id string
Style id, unique in the document. Referenced by nodes[].styleId.
fontFamily string
Font family. Use "Noto Sans CJK KR" for any text that may contain Korean, Chinese, or Japanese — a Latin-only font drops those glyphs.
fontSize number
Font size in px.
fontWeight integer
Font weight. 400 is normal, 700 is bold.
italic boolean
Italic.
color string
Text colour as #RRGGBB.
bg string
Background colour as #RRGGBB, or "transparent".
align enum
Horizontal alignment.
leftcenterrightjustify
valign enum
Vertical alignment inside the node box.
topmiddlebottom
lineHeight number
Line height as a multiple of the font size.
border string
A CSS border shorthand such as "1px solid #ddd", or "none".
radius number
Corner radius in px.
opacity number
Opacity from 0 to 1.
nodes
id string
Node id, unique in the document. The n_ prefix is a convention, not a rule.
name string
A name for the object tree in the editor. It is a label, not an identifier — it does not have to be unique and nothing refers to it. Leave it empty and the tree shows the id instead.
parent string
Id of the parent node. Empty string means the node sits directly on the page. A parent that does not exist, or a cycle, is a violation.
order integer
Order among siblings. Also decides what draws on top.
type enum
What this node is.
labelimagelinerectcircletablechartcodehtmlfield
x number
X position in px, relative to the parent.
y number
Y position in px, relative to the parent.
w number
Width in px.
h number
Height in px. 0 means auto height. For a table bound to an array this is the reserved height — see "Tables that grow".
show string
Condition for showing the node. Empty string means always. Anything else is an expression evaluated against your data.
styleId string
Id from styles[]. Empty string means the default style.
props object
Type-specific settings. Every key is required; unused ones stay empty.
nodes[].props
text string
label and code. For a label it is the text to draw; for a code it is the value to encode. Supports {{ … }}. A newline character breaks the line where it sits. Empty for other types.
src string
image only. A data: URI is the only accepted form, because we send no outbound request while rendering — convert the picture to base64 first. An http or https address written into the template fails the render with E:render.compile#external-ref. If the address arrives through a binding such as {{ data.logo }}, that one image is skipped and the response carries an external-ref warning, so a single bad record cannot fail the whole batch. Empty for other types.
fit enum
image only. How the image fills its box. Empty for other types.
""containcoverfill
thickness number
line, rect, circle, table and field. Stroke width in px. For a table it is the grid line, for a field the box border. 0 for other types.
stroke string
line, rect, circle, table and field. Stroke colour as #RRGGBB. For a table it is the grid line, for a field the box border. Empty for other types.
fill string
rect, circle and table. Fill colour as #RRGGBB. For a table it is the header cell background. Empty for other types.
repeat string
rect only. Draws this group once per item of the array at this path, such as "data.companies". Everything inside repeats with it. Empty for other types.
as string
rect only. The name the current item goes by inside a repeating group — "co" lets you write {{ co.name }}. Defaults to "block". Never "item": that name belongs to table rows, so a table inside the group can still read both.
break enum
rect only. Set to "page" to start each repetition on a fresh page. Empty keeps them flowing one after another.
""page
keep enum
rect only. Set to "together" to stop one repetition from splitting across a page boundary. A repetition taller than a page splits anyway.
""together
padding number
table only. Padding inside cells (px). Without this key, a default padding applies only when the table has borders. 0 for other types.
padY number
table only. Padding above and below the cell contents, in px. -1 follows padding — 0 cannot mean "follow", because 0 is a real value meaning no padding.
padX number
table only. Padding left and right of the cell contents, in px. -1 follows padding.
align enum
table only. Default horizontal alignment for grid cells. A cell that sets its own wins. Repeating tables use per-column columns[].align.
""leftcenterright
valign enum
table only. Vertical alignment inside cells. Empty means middle. A cell that sets its own wins.
""topmiddlebottom
wrap enum
table only. Text wrapping in cells. Empty or "on" wraps overflowing text to the next line; "off" keeps one line and truncates with an ellipsis.
""onoff
bind string
table only. Path to the array to repeat, such as "data.items". Empty for other types.
headerHeight number
table only. Header row height in px. 0 for other types.
rowHeight number
table only. Body row height in px. 0 for other types.
max integer
table only. Maximum number of rows drawn; the rest are dropped. 0 for other types.
columns array
table only. Column definitions, left to right. Empty for other types.
columns[].header string
Header cell text.
columns[].cell string
Body cell template. item is the current row, so "{{ item.name }}" reads a field of it.
columns[].width number
Column width in px. The sum should match the table width.
columns[].align enum
Cell alignment.
leftcenterright
rows integer
table only. Row count for a grid table (empty bind). 0 for repeating tables and other types.
cells array
table only. Cells of a grid table. Missing positions are empty cells, so you need not list them all. [] for repeating tables and other types.
cells[].r integer
Row index, from 0.
cells[].c integer
Column index, from 0.
cells[].span integer
Horizontal merge width. 1 is the default.
cells[].rowspan integer
Vertical merge height. 1 is the default.
cells[].text string
Cell content. You can bind values with "{{ data.x }}".
cells[].align enum
Cell text alignment.
leftcenterright
cells[].valign enum
Vertical alignment for this cell only. Empty follows the table.
""topmiddlebottom
cells[].wrap enum
Wrapping for this cell only. Empty follows the table.
""onoff
cells[].pad number
Padding for this one cell, in px. -1 or absent follows the table's padding — 0 cannot mean "follow", because 0 is a real value meaning no padding at all. Use it to tighten or loosen a single cell without touching the whole table.
cells[].padY number
Padding above and below this one cell, in px. -1 or absent falls back to pad, then to the table.
cells[].padX number
Padding left and right of this one cell, in px. -1 or absent falls back to pad, then to the table.
cells[].src string
Image inside the cell. Only values starting with data:image/ — external URLs would make a network request and are rejected by the renderer.
cells[].fit enum
How the cell image fits. Empty means contain.
""containcoverfill
cells[].bg string
Cell background #RRGGBB. Leave empty for none.
cells[].head boolean
Header cell. Turn it on across r=0 for a header row, across c=0 for a header column.
cells[].border enum
"none" removes the border on this cell only.
""none
chartKind enum
chart only. "bar" (the default), "line", "pie" or "donut". Empty for other types.
""barlinepiedonut
codeKind enum
code only. Which symbology to draw: "qr" (the default), "code128", or "ean13". Empty for other types.
""qrcode128ean13
ecc enum
code only, and only for QR. Error correction level L, M, Q or H — empty means M. A higher level survives more damage but needs more modules, so the same text draws a denser code.
""LMQH
html string
html only. Formatted body text. Only an allowlist of tags survives; anything else keeps its words but loses its tag, and script, style and iframe lose their contents as well. Empty for other types.
fieldKind enum
field only. What kind of input to place: "text" (the default), "check", "select", "list", "radio", "button", or "sign". "sign" places a signature space — it does not sign anything. Empty for other types.
""textcheckselectlistradiobuttonsign
fieldName string
field only. The name this input carries inside the PDF — the key whatever reads the filled form will look for. Leave it empty and we use the node id. Two fields with the same name share a value. Empty for other types.
options array
field only, and only for "select", "list" and "radio". The choices to offer. [] for everything else.
required boolean
field only. Marks the input as required. We do not enforce it — it is a flag, and whether it is enforced is up to the program that opens the PDF. false for other types.
readonly boolean
field only. Shows the value but does not let anyone change it. false for other types.
Expressions
Any string field can contain {{ … }} expressions. They are evaluated against the data you send with the render request.
Inside a table cell, item refers to the current row of the bound array. Outside a table it is not defined.
Page numbers use a different syntax, [[page]] and [[pages]], and only work in page.header and page.footer. They are filled in after layout, when the page count is known.
Formatting
format(value, kind, pattern?, locale?)- Renders a value for people to read. kind is number, currency or date. For currency the pattern slot takes a currency code (KRW, USD); for date it takes short, medium, long or iso. A bad currency code prints the code rather than failing the whole document.
Numbers
abs(n)- Distance from zero, sign removed.
ceil(n)- Rounds up to a whole number.
floor(n)- Rounds down to a whole number.
trunc(n)- Drops the decimals without rounding.
sign(n)- Gives -1, 0 or 1.
sqrt(n)- Square root. Negative input gives an empty value, not an error.
pow(n, exp)- Raises to a power.
round(n, digits?)- Rounds to the given number of decimals (default 0).
min(a, b, ...)- Smallest of the arguments.
max(a, b, ...)- Largest of the arguments.
clamp(n, lo, hi)- Keeps a number inside a range: below lo becomes lo, above hi becomes hi.
Text
upper(s)- Uppercase.
lower(s)- Lowercase.
trim(s)- Removes spaces at both ends.
len(s)- Number of characters.
sub(s, start, end?)- A slice of the text, by character position.
replace(s, find, with)- Replaces every occurrence. The search is literal text, never a pattern.
contains(s, find)- True if the text contains the other text.
startsWith(s, find)- True if the text begins with it.
endsWith(s, find)- True if the text ends with it.
padStart(s, width, fill?)- Pads on the left until the width is reached. Used for invoice numbers like 000042.
padEnd(s, width, fill?)- Pads on the right until the width is reached.
split(s, sep)- Cuts text into an array on a separator.
Arrays
count(array)- How many items.
sum(array, prop?)- Adds them up. With a prop it adds that field of each item.
sumProduct(…)- Multiplies two fields of each item and adds the results. Use it for a total tax across mixed rates — sumProduct(items, "amount", "vat") / 100 — or for a line total without storing one: sumProduct(items, "qty", "price"). It knows nothing about tax: the rate is a number your data carries, because rates differ by country and change by law. Rounding happens once at the end, so a regime that rounds per rate band should draw each band separately with filter.
avg(array, prop?)- Mean. An empty array is not an error — the value is left blank and a nan warning is reported, the same as any other number we cannot work out.
minOf(array, prop?)- Smallest item, or smallest of that field.
maxOf(array, prop?)- Largest item, or largest of that field.
first(array)- First item.
last(array)- Last item.
at(array, index)- The item at that position, counting from 0.
join(array, sep?, prop?)- Joins into one string with a separator (default a comma and a space).
filter(array, prop, value)- Keeps the items whose field equals the value.
sortBy(array, prop, desc?)- Sorts by a field. Pass true (or the text "desc") as the third argument for descending.
slice(array, start, end?)- A range of items, by position.
unique(array, prop?)- Removes duplicates.
Choosing
if(test, then, else)- Picks the second argument when the first is true, the third when it is not.
coalesce(a, b, ...)- The first argument that is not empty. Empty means null, undefined, an empty string or an empty array — the same rule isEmpty uses. A zero is not empty, so a price of 0 is kept.
ifEmpty(value, fallback)- The value, unless it is empty — then the fallback. Empty means null, undefined, an empty string or an empty array.
isEmpty(value)- True when the value is null, undefined, an empty string or an empty array.
Dates
dateAdd(date, n, unit)- Adds a period to a date and returns YYYY-MM-DD. unit is day, week, month, quarter or year; a negative number subtracts. Month arithmetic clamps to the last day, so 2026-01-31 plus one month is 2026-02-28 and not 2026-03-03.
dateDiff(from, to, unit?)- Whole units between two dates, counted from the first to the second. Defaults to days. It rounds toward zero, so 29 days is 0 months.
dateStart(date, unit)- The first day of that week, month, quarter or year. Weeks start on Monday.
dateEnd(date, unit)- The last day of that week, month, quarter or year — this is how you get the end of the month without counting 28, 29, 30 or 31 yourself.
datePart(date, part)- One number out of a date. part is year, month, day, weekday, quarter, week or dayOfYear. Months are 1 to 12 and weekdays are 1 for Monday through 7 for Sunday, both following ISO-8601 rather than the zero-based numbering JavaScript uses.
today(timezone?)- Today as YYYY-MM-DD, fixed for the whole render — calling it twice never straddles midnight. It defaults to UTC, which is not the same day everywhere: 8am on the 24th in Seoul is still the 23rd in UTC. Pass an IANA timezone, as in today("Asia/Seoul"), when the date on the document should be the reader's date. It gives a date and never a time, because a time printed without its zone is a time nobody can read.
Formatted text
An html node takes a block of markup — the thing a column of labels cannot do, because one extra line reflows everything you positioned by hand. Use it for terms, notes, footnotes, anything that should flow.
We do not filter your markup; we rebuild it. Your HTML is parsed, and only tags and attributes on our list are written back out. That is the important difference: something we do not understand cannot appear in the output at all, rather than depending on us having thought of it.
Allowed: p, div, span, br, hr, blockquote, pre, h1 to h6, b, strong, i, em, u, s, small, sub, sup, code, a, ul, ol, li, table with its rows and cells, and img. A tag outside that list loses the tag and keeps its words — you do not lose your writing. script, style, iframe and their kin lose their contents too, because those contents are not writing.
Attributes are narrower still: style (each declaration checked, position excluded), colspan and rowspan on cells, href on links, and src, alt, width and height on images. There is no class and no id — they would collide with the classes that lay out your page. An image must be a data: URI, the same rule the image node follows, because drawing a remote image means fetching it during the render. A link may point anywhere; a link is not a fetch.
A {{ … }} inside html becomes text and never markup, whatever the data contains. In an attribute the resolved value is checked the same way a literal one would be, so a value cannot smuggle url(…) into style or javascript: into href. Limits: 20,000 characters, 32 levels of nesting, 2,000 tags — past those we cut and say so in warnings.
Charts
A chart node reuses the same two fields a table uses: bind names the array, and columns describes what to read from each item. columns[0].cell is the label, columns[1].cell is the value, and columns[1].header becomes the title. There is one new field, chartKind: "bar", "line", "pie" or "donut".
The axis picks a round maximum — 1, 2 or 5 times a power of ten — so the gridlines read as numbers a person would choose. A value that is not a number counts as zero rather than failing the render, the same way an unreadable cell renders empty. Pie and donut use absolute values, because a negative slice means nothing.
Charts are drawn as SVG, so they are vector art in the PDF. With many items the labels are thinned out rather than overprinted. The legend sits on one line under a pie or donut and does not move — a legend that finds its own place moves your layout with it.
QR codes and barcodes
A code node turns text into a scannable graphic. Put the value in text and pick codeKind: "qr", "code128", or "ean13". It is drawn as SVG, so it goes into the PDF as vector art — it stays sharp at any print resolution, and the bar edges land exactly where the spec says they should. That last part matters: a barcode rasterised at the wrong moment is a barcode a scanner reads wrong.
Keep a QR square. We never distort a code to fill its box — a squashed one does not scan — so a rectangular box just leaves space on the long side. The quiet zone around a code is part of the spec and we reserve it for you; do not try to crop it away.
ean13 takes 12 digits and we compute the check digit. Give 13 and we verify the one you sent instead. code128 carries ASCII 32 to 126. If the value cannot be encoded — wrong length, characters outside the set, too much data for a QR — the render fails with an error rather than drawing an empty box, because an empty box is only discovered after the labels are printed.
Those three are what we support. Libraries exist that cover a hundred symbologies; the smallest weighs more than our whole worker. If you need one we do not draw, tell us which and why.
Tables
A table works in one of two modes. With bind empty it is a hand-built grid; with bind set to an array path it is a repeating table whose rows come from your data. That single key changes what the table is, and the two modes use different fields — the grid uses rows and cells, the repeating table uses columns and max.
A grid table takes its row count from rows and its columns and widths from columns. Cell contents go into the cells array addressed by r and c — a missing position is simply an empty cell, so you do not have to list them all. Use span and rowspan to merge with the cell to the right or below. Turn head on to make it a header cell; the fill colour is painted only on those.
In a repeating table each entry in columns is one column. header is the heading and cell is the body template, and inside that template item refers to the current row — write it as "{{ item.name }}". The number of rows comes from the data; max caps it. Rows past the cap are dropped and reported in warnings.
The column widths must add up to the table width. If they do not, the browser stretches or shrinks them for you, and the result is not what you designed.
Padding resolves in three steps: the cell (cells[].padY, padX, pad), then the table (padY, padX, padding), and if neither is set a default padding is applied only when there are grid lines. -1 is the "inherit" marker — 0 cannot be used for that because 0 means "no padding" and is a real value.
Alignment resolves the same way. A cell setting wins; otherwise the table align and valign apply. Setting wrap to "off" keeps that cell on one line, clipping the overflow and adding an ellipsis — so overflowing text is never printed on top of the neighbouring cell.
Fields people can fill in
A field node makes a box the recipient can type into after opening the PDF — for contracts, applications and questionnaires, where the values arrive later. Pick the kind with fieldKind: "text" (the default), "check", "select", "list", "radio", "button", or "sign".
fieldName is the name the field carries inside the PDF, and the key whatever reads the filled form will look for. Leave it empty and we use the node id. Only letters, digits, underscore and hyphen are allowed, up to 64 characters; anything else falls back to the node id rather than being corrected, because a corrected name would differ from the one you wrote and you would only find out at the far end. A dot is especially out, since PDF treats it as a name separator.
"select", "list" and "radio" need options. With none there is nothing to choose, so we skip that one field and tell you in the response warnings. A "radio" splits the box you drew into one row per option.
Draw the line yourself with stroke and thickness. On screen the PDF viewer draws the input box for you, but printed paper has no viewer — without a line, a blank form comes out with nowhere visible to write. "sign" in particular is drawn by nothing at all.
You can pre-fill a value — use the same text property that label uses. Bind it like {{ data.payment }} and the data lands in the box, already filled, in the PDF you hand over (whoever receives it can still change it). A "check" reads "false", "0", "no" or empty as unchecked; "select", "list" and "radio" only accept a value that is in options, because a value nobody can pick is worse than an empty box. Korean and other non-Latin text work.
What it will not do: "sign" places a signature space, it does not sign anything. required is a flag we do not enforce — whether it is enforced is up to the program that opens the PDF. Fields never carry calculations or scripts. Ask for an image (PNG or JPEG) and the fields are gone, because an image has no notion of a box to fill. A document may carry 500 fields; past that we stop and tell you.
Tables that grow
A table with a bound array does not have a fixed height — 3 rows and 300 rows are both possible. The same is true of a repeating group. Everything from the first such node onward is laid out in document flow instead of at fixed coordinates, so content after it moves down as it grows.
The h you give a table is a reserved height, not a limit. The table may exceed it. Nodes placed after the table keep the gap you drew, measured from the bottom of that reserved height.
Give a growing table a realistic h. If you leave it at 0, the nodes you placed below it will start at the top of the table instead of after it.
Repeating a whole section
A table repeats rows. A rect repeats everything inside it: set props.repeat to an array path and the group is drawn once per item, with all of its children. This is how one request produces a document that covers many records — a statement per branch, a summary per company — instead of one request per record.
Inside the group, the current item goes by the name in props.as, so {{ co.company }} reads the field company of the current item when as is "co". It defaults to "block". It is deliberately not "item": that name belongs to table rows, so keeping them separate lets a table inside the group read both — {{ co.company }} for the record and {{ item.name }} for the row. The name cannot be data, item or index.
Set props.break to "page" to start each repetition on a fresh page. A group taller than one page splits across pages on its own; you do not have to count. A table inside the group flows too, so it can run past the group you drew and push the rest of that group down.
Page numbers inside one repetition use [[bpage]] and [[bpages]] — "page 1 of 2 of this invoice", not of the whole document. They work in page.footer only. Chromium fills [[page]] while printing; it has no idea where a repetition began, so we draw that footer ourselves afterwards. That is also why such a footer must stay within Latin-1: we can embed a standard font, not a Korean one.
If the path is missing, is not an array, or is empty, the group is simply not drawn — no error. Repetitions share the same node budget as table rows, so a large array is truncated rather than allowed to grow without limit. Groups cannot be nested inside one another.
Extra keys the editor writes
The schema above is everything the renderer needs. But a template saved in the editor and read back through the API carries three more keys — name, hidden and lockOwn. Our validator accepts them, so you may leave them alone; removing them changes nothing about the render.
name is the label shown in the object list and lockOwn records whether you locked it in the editor. Neither affects the render at all.
hidden is different — when true, that node and everything inside it is left out of the output entirely. The eye icon in the editor writes this value. It is easy to confuse with show: show is a condition evaluated against your data, while hidden means the author does not want to see it right now. If you are building documents through the API, use show rather than hidden.