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, [] 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
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.
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]].
fonts
family string
Font family name, matching what styles[].fontFamily asks for.
src enum
Where the font comes from. "preinstalled" is bundled; "r2" is one you uploaded.
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.
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.
labelimagelinerectcircletablechartcodehtml
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 {{ … }}. Empty for other types.
src string
image only. A data: URI or an uploaded asset key. Empty for other types.
fit enum
image only. How the image fills its box. Empty for other types.
""containcoverfill
thickness number
line only. Stroke width in px. 0 for other types.
stroke string
line, rect, circle. Stroke colour as #RRGGBB. Empty for other types.
fill string
rect, circle. Fill colour as #RRGGBB. 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.
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[].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.
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.
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 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.