tool
WeasyPrint — HTML to PDF without a browser
WeasyPrint
Renders HTML + CSS to PDF using Pango, with no browser involved. Python, but there is nothing stopping a Node app shelling out to it.
Chosen for invoicing|the sikod invoicing app over headless Chromium.
Why, over the alternatives
There is no Node equivalent. Nothing in Node renders HTML+CSS to PDF without
bundling Chromium; @react-pdf/renderer and pdfmake bring their own layout
engine and their own DSL, which is a second template to keep in sync rather than
a renderer.
WeasyPrint is print-first, which is what a document actually needs: real
@page margins, margin boxes, page counters (counter(page) / counter(pages)
for “Stranica 1 od 2”), and display: table-header-group so a table header
repeats on every page. Chromium does those grudgingly.
Cost measured on node:22-alpine (Alpine 3.24): apk add weasyprint font-noto
adds ~116 MB. Chromium is roughly triple.
Invoking it
One subprocess, no temp files — the CLI takes a URL in and - for stdout:
weasyprint "http://127.0.0.1:4321/invoices/42/print?k=<hmac>" -
Handing it the URL rather than piping HTML also means relative asset paths
resolve on their own, with no --base-url to get wrong. --media-type already
defaults to print.
⚠ Tailwind 4 does not work, and will not
WeasyPrint supports none of oklch(), @layer, @supports, logical
properties (margin-inline), min-width media queries, or custom properties
inside calc(). Tailwind 4 emits all of them — its entire default palette is
oklch. The upstream issue asking for Tailwind 4 support is closed as “not
planned”, so this is not a version bump away.
This is less of a sacrifice than it sounds. Author the document as a standalone page with hand-written plain CSS and keep Tailwind on the application around it. A document is a table with page margins; Tailwind was the wrong tool for it.
⚠ Alpine ships no fonts
WeasyPrint has no bundled fallback, so without apk add font-noto (or
ttf-dejavu) every č ć ž š đ renders as an empty box. Nothing warns you.
Confirm with pdffonts output.pdf that a real font is embedded and subsetted.
⚠ max-width on a flex container silently breaks percentage widths
WeasyPrint resolves a flex item’s width: 40% against the unconstrained
parent, so a max-width-capped flex container overflows and flex-wrap drops
every item onto its own line. No warning is emitted — the only symptom is a
visually wrong document.
Use width, not max-width — or use a table, which is the more robust choice
for a label/value grid in print anyway. Verified by A/B/C/D test:
width:95mm works, max-width:95mm breaks, a plain <table> works.
Other notes
display: contentsis unsupported, but it does warn. Runweasyprint -vonce and read the warnings: a silently dropped rule is how a wrong-looking document ships.var()is only supported in single-value properties, socontent: var(--footer)inside a@pagemargin box is not something to bet a footer on. Emit that bit of CSS inline instead.- Alpine 3.24 packages 68.1, not the documented-latest 70.
The habit that pays
Spike it before building around it. The first render of a realistic document found two layout bugs the documentation does not mention. Render, convert to PNG, and look at the result — do not infer it from the CSS.
Related: hub3-barcode, astro.