PaperPony

Template guide

A template is HTML with Handlebars in it. It is compiled when you store it, so a template that can never render is refused at POST /v1/templates rather than the first time you use it in anger.

Syntax

{{value}} inserts an escaped value. {{{value}}} inserts it unescaped. Only do that with content you produced yourself. A field that is missing renders as nothing rather than failing the whole document, because one absent optional value should not cost you an invoice.

A fragment
<h1>Invoice {{number}}</h1>

{{#if buyer.tax_id}}
  <p>VAT {{buyer.tax_id}}</p>
{{/if}}

<table>
  <thead><tr><th>Item</th><th>Amount</th></tr></thead>
  <tbody>
    {{#each items}}
      <tr>
        <td>{{this.description}}</td>
        <td>{{formatCurrency this.amount currency=../currency}}</td>
      </tr>
    {{/each}}
  </tbody>
</table>

Helpers

Five formatting helpers and four block helpers, and nothing else. An unknown helper is a compile error rather than a silent blank, and there is deliberately no helper that reads a file, opens a socket or evaluates a string, because every template we render arrived over the internet.

HelperWhat it does
formatDate{{formatDate issued_on locale="en-GB" dateStyle="long"}}Formats an ISO date or timestamp through Intl.DateTimeFormat. Options: locale, dateStyle, timeStyle, year, month, day, timeZone. Defaults to en-US and a medium date.
formatCurrency{{formatCurrency total currency="EUR" locale="de-DE"}}Formats a number as money. The value is in major units: 1250.5, not 125050. Options: currency, locale, minimumFractionDigits, maximumFractionDigits, useGrouping.
formatNumber{{formatNumber weight maximumFractionDigits=2}}Grouping and decimals through Intl.NumberFormat, without a currency symbol.
uppercase{{uppercase status}}Upper-cases a value.
lowercase{{lowercase email}}Lower-cases a value.

Block helpers

  • if / else{{#if due_on}}Due {{formatDate due_on}}{{/if}}
  • unless{{#unless paid}}Payment outstanding{{/unless}}
  • each{{#each items}}<tr><td>{{this.description}}</td></tr>{{/each}}
  • with{{#with buyer}}{{name}}, {{city}}{{/with}}

Page breaks

Chromium prints your CSS, so the standard fragmentation properties are what control pagination. These rules cover almost every document.

Print CSS worth having
/* Keep a row whole when it can be. A row taller than a page is
   broken anyway, and this still costs a page break before it. */
tbody tr { break-inside: avoid; }

/* Nothing here repeats the column headings, because Chromium already
   does. thead defaults to table-header-group. */

/* Keep the totals block together. */
.totals { break-inside: avoid; }

/* Start each section on a fresh page. */
.chapter { break-before: page; }

/* Do not leave a heading stranded at the foot of a page. */
h2 { break-after: avoid; }

This block used to carry two more lines and both were wrong. thead { display: table-header-group } was described as the rule people miss, and it is already the default: a four-page table repeats its headings with no stylesheet asking. And break-inside: avoid was promised as never splitting a row, which it cannot deliver for a row taller than a page.

Both were measured rather than reasoned about. Table rows splitting across pages has the figures, including what the declaration costs in pages and the one-line exemption that gets them back.

Headers and footers

Page numbers cannot come from the document itself, because Chromium does not support CSS page margin boxes. They come from footer_html, which is rendered into the margin band with its own tiny stylesheet.

Options
{
  "options": {
    "margin": { "top": "25mm", "bottom": "20mm" },
    "header_html": "<div style=\"font-size:8pt;width:100%;padding:0 15mm;color:#888\">Acme Ltd</div>",
    "footer_html": "<div style=\"font-size:8pt;width:100%;padding:0 15mm;color:#888;text-align:right\">Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>"
  }
}
  • The classes pageNumber, totalPages, date and title are filled in by the renderer.
  • Give the band room with margin, or it will be there and empty.
  • Style it inline. It is a separate mini-document and does not see your stylesheet, and its default font size is zero.

Fonts

The renderer ships Noto Sans, Noto Serif, Noto Sans Mono, Noto CJK, DejaVu and Liberation, covering Latin, Cyrillic, Greek, Arabic, Hebrew and CJK. Naming one of those costs no network request. Non-Latin coverage is where most HTML-to-PDF services quietly fall back to boxes.

Fonts in a template
/* Installed in the renderer, so no network request. */
body { font-family: 'Noto Sans', sans-serif; }
.figures { font-family: 'Noto Sans Mono', monospace; }
.heading { font-family: 'Noto Serif', serif; }

/* Or bring your own over HTTPS. */
@font-face {
  font-family: 'Your Brand';
  src: url('https://cdn.example.com/brand.woff2') format('woff2');
  font-display: block;
}

A @font-face over public HTTPS works too, subject to the same egress rules as any other subresource: private and loopback addresses are refused, and the whole document is capped at 20 MB across at most 100 subresources. Set "wait_for": "networkidle", which is the default, so the render does not capture the page before the font arrives.

If you are debugging the same problem in your own renderer, missing fonts in headless Chrome covers what fontconfig picks and why installing the Noto packages is only half of it.

Limits

  • Template source and inline HTML: 5 MB. Reference large assets by URL rather than inlining them.
  • Render time: 15 seconds by default, up to 60 with timeout_ms. Past it you get render_timeout and no charge.
  • Data nesting: 32 levels. Prototype-polluting keys are dropped before your template ever sees them.
  • Free-plan renders run with JavaScript disabled, so a document that builds itself with a script renders differently there than on a paid plan.

Complete templates

The recipes are whole documents you can store as they are: invoice, receipt, certificate, report and shipping label.