Writing HTML templates for PDF: a style guide (Jinja, Django, Handlebars)
Templates that render well as PDFs follow a few rules: fixed page geometry, no external state, defensive filters, print CSS. Examples in Jinja and Django syntax.
Web templates assume a scrolling viewport and a user who can resize. PDF templates assume a fixed page and no user. The rules are different, and templates written for the screen produce documents that are subtly wrong in ways nobody notices until a customer does.
Geometry in millimetres
Set the page in the stylesheet and design inside it:
@page { size: A4; margin: 14mm; }
A4 minus 14 mm margins leaves 182 × 269 mm of printable area. Use millimetres for anything that must line up with paper — margins, column widths, signature lines — and points or pixels for type sizes.
Avoid vh, vw and percentage heights. There is no viewport; a height: 100vh block either fills nothing or overflows the page.
No external state
The template gets everything from its context. That means:
- No
now()calls. A preview rendered at 23:59 and a document rendered at 00:01 should not carry
different dates. Pass the date in.
- No relative URLs. Inline HTML has no base document to resolve against. Absolute URLs or data
URIs, always.
- No fetching. Anything the template needs must arrive in the context.
Format in code, place in the template
Money, dates and numbers should arrive formatted:
# in your view or service
ctx = {
"total": f"{order.total_cents / 100:,.2f}",
"issued": order.issued_at.strftime("%-d %B %Y"),
"vat_rate": f"{order.vat_rate:.0%}",
}
<!-- in the template -->
<td class="num">{{ currency }}{{ total }}</td>
Templates that do arithmetic eventually round differently from your ledger, and a one-cent discrepancy in a reconciliation report costs more time than it saves. This is also why PDFGeny's template renderer computes line amounts server-side from quantity and price rather than leaving multiplication to the template language.
Defensive defaults
Every optional field gets a fallback:
{{ client_vat|default:"—" }} {# Django #}
{{ client_vat or "—" }} {# Jinja2 #}
An empty cell is a data problem you can see. A missing cell that shifts the whole table is a template problem you find in production.
Loops for line items
{% for line in lines %}
<tr>
<td>{{ line.description }}</td>
<td class="num">{{ line.qty }}</td>
<td class="num">{{ currency }}{{ line.unit }}</td>
<td class="num">{{ currency }}{{ line.total }}</td>
</tr>
{% endfor %}
Two things to handle that fixtures never show: an empty list (print "No items" rather than an empty table), and a description long enough to wrap to three lines (does the row still fit? does break-inside: avoid still hold?).
Print CSS in the template, page furniture in the renderer
Inside the template:
thead { display: table-header-group; }
tr, .card { break-inside: avoid; }
h2 { break-after: avoid; }
Outside it, as render options — because page numbers are not CSS:
{"header_html": "<span>Acme Ltd</span>",
"footer_html": "Page {{page}} of {{pages}} · {{ invoice_number }}"}
More on which properties actually work in page breaks in HTML to PDF.
Escaping and untrusted data
If any field comes from a customer — a company name, an address, a note — it is untrusted input being placed into markup that a browser will execute. Django and Jinja2 autoescape by default; the danger is the |safe filter someone adds to make a line break work.
Use |linebreaksbr for multi-line fields rather than |safe, and if you genuinely need HTML from a user, sanitise it with an allowlist first.
A structure that scales
For more than two or three documents, share the furniture:
templates/
documents/
_base.html # @page, fonts, shared table and typography styles
_header.html # logo block, seller details
_totals.html # subtotal / tax / grand total
invoice.html # extends _base, includes the parts
receipt.html
credit_note.html
Changing the brand font then touches one file rather than forty — which is exactly the situation PDFGeny's own 40-template catalog is built on: one shared document stylesheet, one partial per document type.
Preview at real scale
Render your longest realistic document before shipping a template, not a two-line fixture. Page breaks, repeated headers, wrapping addresses and empty optional fields only appear with real data — and they are exactly what customers notice first.