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 five rules cover almost every document.

Print CSS worth having
/* Never split a table row across pages. */
tr { break-inside: avoid; }

/* Repeat the column headings on every page. */
thead { display: 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; }

thead { display: table-header-group } is the one people miss. Without it a fifty-line invoice has column headings on page one and unlabelled numbers on pages two and three.

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.

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.