HTML to PDF: the complete guide
Every approach to converting HTML into PDF belongs to one of two families, and knowing which family a tool is in predicts almost everything about how it will behave.
Family one reimplements the browser. Libraries like dompdf, mpdf, TCPDF, WeasyPrint, xhtml2pdf, and jsPDF parse HTML and CSS themselves and draw a PDF directly. No browser involved: fast, light, easy to deploy. The catch is that they only speak the subset of CSS their maintainers have implemented, and browsers move faster than any reimplementation can.
Family two runs the browser. Headless Chromium (driven by Puppeteer, Playwright, or a rendering API) loads your page exactly as Chrome would, executes the JavaScript, and prints the result. Output is pixel-identical to the browser; the cost is that now someone is operating a browser fleet.
That’s the whole decision, and the rest of this guide is evidence. Unlike most write-ups of this topic, everything below was tested by us with the same two documents, and every output PDF is downloadable so you can check our claims.
The test, and the scoreboard
Two documents, used across our whole series: a modern invoice (Flexbox header, CSS Grid line items, woff2 web font, inline SVG) and a Chart.js operations report (stat tiles, two canvas charts, a table). Rendered through everything:
| Renderer | Family | Language | Time | Modern CSS | JS / charts | Full write-up |
|---|---|---|---|---|---|---|
| dompdf 3.1.6 | Reimplementation | PHP | 0.31s | Collapsed | No | PHP round |
| mpdf 8.3.1 | Reimplementation | PHP | 0.39s | Collapsed, SVG OK | No | PHP round |
| TCPDF 6.11.3 | Reimplementation | PHP | 0.09s | Unstyled; deprecated | No | PHP round |
| WeasyPrint 69 | Reimplementation | Python | 1.06s | Near browser-parity | No | Python round |
| xhtml2pdf 0.2.17 | Reimplementation | Python | 0.23s | Collapsed | No | Python round |
| jsPDF 4.2.1 | Reimplementation | Node/browser | n/a | No server HTML at all | Browser-only | Node round |
| wkhtmltopdf 0.12.6 | 2015 WebKit | CLI | 2.9s | Flexbox yes, Grid no | 2015-era engine, modern JS fails | Migration guide |
| Puppeteer + Chromium | Browser | Node | 5.7s cold | Perfect | Yes | Node round |
| Capture API | Browser | Any (REST) | one POST | Perfect | Yes (wait_network) | API render, verified |
Three results worth pulling out of the table:
- One reimplementation library has genuinely caught up on CSS: WeasyPrint. Its version 69 rendered our Flexbox-and-Grid invoice almost indistinguishably from Chrome, the only non-browser renderer to pass. If your documents are static and your stack speaks Python, it deserves real consideration.
- No reimplementation library executes JavaScript. Not one. Canvas charts render as empty boxes in every library we tested, including WeasyPrint, because there is no JS engine inside. Dashboards, reports with charts, and client-side-framework pages structurally require a browser.
- wkhtmltopdf is in neither useful camp anymore. It’s an archived project wrapping a 2015 browser: modern CSS partially works, modern JavaScript doesn’t parse, and no one is patching it. We wrote a dedicated migration guide with a flag-by-flag translation table.
Choosing by scenario
Simple, fully-controlled templates (invoices, receipts, labels). Write table-based, 2010-style HTML and a reimplementation library is genuinely the right answer: free, milliseconds per document, zero infrastructure. Our table-markup invoice through dompdf came out clean at 0.17s and 2 KB. The constraint is discipline: the template can never drift toward modern CSS, and nobody can hand you HTML you don’t control.
Modern CSS, still static. WeasyPrint if you’re in Python. In PHP and Node there is no equivalent; the libraries in those ecosystems remain years behind, so your options jump straight to a browser engine.
Anything with JavaScript, charts, or “must match the browser”. A browser engine is the only option, and your choice is operational: run it or rent it.
Running it means Puppeteer or Playwright driving headless Chromium: a ~130 MB binary, roughly 250 MB of memory per concurrent render, font packages, zombie-process management, a queue, and platform quirks like the Ubuntu AppArmor sandbox policy that crashed our first launch. Our production headless-Chrome guide covers the full checklist, including serverless options. At steady volume with engineering time available, this works well.
Renting it means a rendering API. Ours works like this, and the PDFs it produced for this series are downloadable next to the local Chromium references they match:
curl -u "$CONVERTERER_API_KEY:" https://api.converterer.com/jobs \
-d url="https://yourapp.example/invoices/1024" \
-d page_size=A4 \
-d wait_network=true
Margins, headers and footers with page numbers, wait conditions for JavaScript-heavy pages, and HTTP auth for protected URLs are parameters on the same call (full reference). The free tier’s 100 conversions a month exists precisely so you can diff its output against your browser before believing us.
Getting the output right, whatever you choose
A few things bite everyone, in every family:
- Print CSS is its own discipline. Page breaks (
page-break-inside: avoid),@pagemargins, and print-media styles determine whether multi-page documents look designed or shredded. We keep a print CSS primer for exactly this. - Backgrounds default to off in browser engines. Puppeteer needs
printBackground: true; our API prints them by default. The “where did my brand colour go” bug is this setting, every time. - Fonts need to be loaded before capture. Browser engines: wait for
document.fonts.readyor use network-idle waits. Reimplementation libraries: check the formats they accept (dompdf wants TTF, not woff2; WeasyPrint handled woff2 fine in our test). - JavaScript needs explicit waiting.
networkidle0in Puppeteer,wait_network=trueon our API. Without it you capture the page before your charts draw, and get exactly this. - Beyond ~50 paginated pages with book-grade typesetting needs (footnotes, cross-references, running headers), browsers stop being the right tool: that’s paged.js or commercial engines like PrinceXML territory, covered in the production guide.
The short version
If you remember one thing: find out which family a tool is in before you adopt it. Reimplementation libraries are excellent exactly as far as their CSS support extends and not one property further, and none of them run JavaScript. Browser engines render everything correctly and bill you in operations instead. WeasyPrint is the one library that has narrowed the CSS gap; nothing has narrowed the JavaScript one. When the operational bill isn’t worth paying, that’s what the API is for.