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.

EXAMPLE
{
  "v": 1,
  "unit": "px",
  "page": {
    "size": "a4",
    "orientation": "portrait",
    "margin": { "t": 48, "r": 48, "b": 56, "l": 48 },
    "header": "",
    "footer": "{{ data.company }} · [[page]]/[[pages]]"
  },
  "fonts": [],
  "styles": [
    {
      "id": "s_title",
      "fontFamily": "Noto Sans CJK KR",
      "fontSize": 26, "fontWeight": 700, "italic": false,
      "color": "#1a1a1a", "bg": "transparent",
      "align": "left", "valign": "top", "lineHeight": 1.5,
      "border": "none", "radius": 0, "opacity": 1
    }
    // ...
  ],
  "nodes": [
    {
      "id": "n_title", "parent": "", "order": 0, "type": "label",
      "x": 0, "y": 0, "w": 400, "h": 36,
      "show": "", "styleId": "s_title",
      "props": {
        "text": "{{ data.title }}",
        "src": "", "fit": "", "stroke": "", "fill": "",
        "thickness": 0, "bind": "",
        "headerHeight": 0, "rowHeight": 0, "max": 0, "columns": []
      }
    },
    {
      "id": "n_items", "parent": "", "order": 1, "type": "table",
      "x": 0, "y": 168, "w": 499, "h": 300,
      "show": "", "styleId": "",
      "props": {
        "text": "", "src": "", "fit": "", "stroke": "", "fill": "",
        "thickness": 0,
        "bind": "data.items",
        "headerHeight": 32, "rowHeight": 28, "max": 200,
        "columns": [
          { "header": "Item",  "cell": "{{ item.name }}",  "width": 289, "align": "left" },
          { "header": "Qty",   "cell": "{{ item.qty }}",   "width": 60,  "align": "center" },
          { "header": "Price", "cell": "{{ item.price }}", "width": 150, "align": "right" }
        ]
      }
    }
  ]
}
REQUEST
{
  "template": { /* the object on the left */ },
  "data": {
    "company": "ACME Inc.",
    "title": "Invoice #1042",
    "items": [
      { "name": "Design",  "qty": 2, "price": 120 },
      { "name": "Hosting", "qty": 1, "price": 30 }
    ]
  }
}