Designing for PDF rendering

Your templates are rendered by Chromium headless (via Gotenberg) and the output is a static PDF. That changes the design constraints in non-obvious ways.

The mental model

The renderer:

  1. Loads your HTML + the CSS framework you picked (Tailwind by default).
  2. Loads the Google Fonts you've added.
  3. Compiles the Handlebars and injects variables.
  4. Snapshots the page to PDF using Chromium's print engine.

Everything that happens at print time is what ends up in the file. Anything that depends on time, interaction, or animation is either skipped or frozen.

Things to drop

Animations & transitions

/* All of this is invisible in the PDF and just costs render time. */
.btn { transition: all 200ms; }
@keyframes pulse { ... }
.spin { animation: spin 1s linear infinite; }

The hover/focus pseudo-classes don't fire either — there's no cursor.

Interactivity

No <button> JavaScript handlers, no <input> fields, no scroll-driven effects. Just text, layout, images.

Time-dependent values

const today = new Date()      // freezes at render time, that's fine
const random = Math.random()  // same — fine, but be aware

If you genuinely want "today's date" on the PDF, pre-compute it in variables so you control what's printed regardless of when the render happens.

Things to prefer

Vector primitives

HTML + CSS + inline SVG stay vectorial in the PDF. They look crisp at any zoom level and stay sharp on print.

<svg viewBox="0 0 100 100" class="h-24 w-24">
  <circle cx="50" cy="50" r="45" fill="#0f172a" />
</svg>

That circle prints at 600dpi just as well as at 72dpi. No image to chase.

Tailwind + utility classes for layout

Tailwind shines in print — predictable spacing, clean typography, no JavaScript dependencies. The framework is the default for new templates.

Google Fonts via the dashboard

Add fonts from the editor's font picker, then use them with Tailwind's font-family classes (font-sans, custom via font-display if you've set a master). The renderer fetches and subsets them per generation.

Canvas-based charts (Chart.js, ECharts, …)

Canvas content rasterizes in the PDF. At default resolution it looks fuzzy. Two options:

  1. Render at high pixel density. For Chart.js, before creating any chart:
    Chart.defaults.devicePixelRatio = 4
    

    That oversamples the canvas 4× — the resulting bitmap stays sharp when embedded.
  2. Use SVG output where the library supports it. For ECharts:
    const chart = echarts.init(el, null, {renderer: 'svg'})
    

    SVG is always crisp. Prefer it whenever the library offers it.

Page format & landscape

Each template has a format (A1 through A6) and a landscape boolean. Pick them based on content:

ContentPick
Invoice, receipt, single-page letterA4 portrait
Two-page side-by-side comparisonA4 landscape
Pocket-size receiptA6
Poster, large diagramA3 portrait/landscape

You can change these from the editor (right-side properties panel) or via PATCH /v1/documents/{id} from code.

Fonts and weight variants

Add only the weights you actually use — every variant slows the render. A typical document needs 400 for body and 600 or 700 for titles, that's it.

In the editor: Fonts → +Add → Inter Tight → variants 400, 600. Set master: true on the font you want as the body default; others apply when you explicitly use them in CSS (e.g. via Tailwind font-display).

Page breaks

CSS break-before-page / break-after-page / break-inside-avoid work and are the canonical way to control pagination:

<section class="break-after-page">
  Page 1 content
</section>

<section>
  Page 2 content
</section>

<table class="break-inside-avoid">
  <!-- never split this table across two pages -->
</table>

A typical invoice in 60 lines

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Invoice {{invoice.number}}</title>
</head>
<body class="font-sans text-slate-900">
  <main class="p-12">
    <header class="flex items-start justify-between border-b border-slate-200 pb-6">
      <div>
        <p class="text-xs uppercase tracking-widest text-slate-500">Invoice</p>
        <h1 class="font-display text-3xl font-semibold mt-1">
          {{invoice.number}}
        </h1>
      </div>
      <div class="text-right">
        <p class="text-sm">Issued {{invoice.issuedOn}}</p>
        <p class="text-sm text-slate-500">Due {{invoice.dueOn}}</p>
      </div>
    </header>

    <section class="grid grid-cols-2 gap-8 mt-8">
      <div>
        <p class="text-xs uppercase tracking-wider text-slate-500">Billed to</p>
        <p class="mt-1 font-semibold">{{customer.name}}</p>
        <p class="text-sm text-slate-600">{{customer.address.line1}}</p>
        <p class="text-sm text-slate-600">{{customer.address.city}}</p>
      </div>
      <div>
        <p class="text-xs uppercase tracking-wider text-slate-500">From</p>
        <p class="mt-1 font-semibold">{{seller.name}}</p>
        <p class="text-sm text-slate-600">{{seller.address}}</p>
      </div>
    </section>

    <table class="mt-10 w-full text-sm">
      <thead class="border-b border-slate-200 text-left">
        <tr>
          <th class="py-2">Item</th>
          <th class="py-2 text-right">Qty</th>
          <th class="py-2 text-right">Amount</th>
        </tr>
      </thead>
      <tbody>
        {{#each items}}
          <tr class="border-b border-slate-100">
            <td class="py-2">{{label}}</td>
            <td class="py-2 text-right">{{qty}}</td>
            <td class="py-2 text-right">{{amount}} €</td>
          </tr>
        {{/each}}
      </tbody>
      <tfoot>
        <tr>
          <td class="pt-4 font-semibold" colspan="2">Total</td>
          <td class="pt-4 font-semibold text-right">{{invoice.total}} €</td>
        </tr>
      </tfoot>
    </table>

    {{#if paid}}
      <p class="mt-10 text-emerald-700 font-semibold">PAID</p>
    {{/if}}
  </main>
</body>
</html>

Next steps