tool

WeasyPrint — HTML to PDF without a browser

created 2026-10-01 updated 2026-10-02 pdf · printing · html · css · python · docker · alpine · invoicing

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: contents is unsupported, but it does warn. Run weasyprint -v once and read the warnings: a silently dropped rule is how a wrong-looking document ships.
  • var() is only supported in single-value properties, so content: var(--footer) inside a @page margin 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.