Your template renders perfectly in the browser, then falls apart in the PDF: the grid collapses, the footer vanishes and counter(page) prints nothing. Your CSS is fine. The problem is that "HTML to PDF" is not one renderer. It is four different engines with four different ideas about which parts of CSS exist.
Browsers have caniuse.com. PDF engines have changelogs, forum threads and folklore. So we built the missing table. The full write-up lives on the HTML to Image blog as Can I use it in a PDF? CSS support across HTML to PDF engines; this is the condensed version with the rows that bite most often.
The four engines
Nearly every converter you will meet is one of these under the hood:
-
Chrome headless: Puppeteer, Playwright, Gotenberg and most hosted APIs. Browser-grade CSS, and since Chrome 131 (November 2024) proper
@pagemargin boxes. - wkhtmltopdf: Qt WebKit frozen around 2012. Archived in January 2023, last release June 2020, and carrying an unpatched 9.8 SSRF vulnerability (CVE-2022-35583).
- WeasyPrint: a Python layout engine built for pagination. No JavaScript, excellent paged media.
- Prince: the commercial reference implementation for print CSS, with its own engine and its own JavaScript.
The support table
Checked against Chrome 131 or later, wkhtmltopdf 0.12.6, WeasyPrint 69 and Prince 16, in August 2026.
| Feature | Chrome headless | wkhtmltopdf | WeasyPrint | Prince |
|---|---|---|---|---|
| Flexbox | Yes | Broken (2009 draft syntax) | Yes | Yes |
| Grid | Yes | No | Yes, gaps remain | Partial, no fragmentation |
Custom properties var()
|
Yes | No | Yes | Yes |
calc() |
Yes | Unreliable | Yes, added 2025 | Yes |
@page size and margins |
Yes | CLI flags instead | Yes | Yes |
@page margin boxes |
Yes, since 131 | No, CLI headers | Yes | Yes |
counter(page) / counter(pages)
|
Margin boxes only | No, [page] tokens |
Yes | Yes |
Named pages (page: cover) |
Yes | No | Yes | Yes |
break-before / after / inside
|
Yes | Legacy page-break-* only |
Yes | Yes |
Named strings (string-set) |
No | No | Yes | Yes |
target-counter() |
No | No | Yes | Yes |
leader() |
No | No | Yes | Yes |
Footnotes (float: footnote) |
No | No | Yes | Yes |
| Runs JavaScript | Yes, full V8 | 2012-era engine | No | Own engine |
The shape of it: Chromium wins everything a browser cares about and loses everything print specific beyond margin boxes. wkhtmltopdf loses everything. The two dedicated print engines are the only ones with the generated content features long documents need.
The row that changed recently: headers and footers
Until late 2024 Chromium ignored @page margin boxes entirely, which is why every Puppeteer tutorial reaches for headerTemplate and footerTemplate, a clunky parallel HTML system with its own font quirks. From Chrome 131 it is just CSS, in all 16 margin regions:
@page {
@top-center {
content: "Northgate Coffee · Statement";
font-size: 9pt;
}
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-size: 9pt;
}
}
One catch: in Chromium, counter(page) only resolves inside margin boxes. Drop it into body content and you get nothing. WeasyPrint and Prince render it anywhere.
What Chromium still cannot do is pull document content into the header. The classic "current chapter title in the running head" pattern needs named strings, which only the print engines implement:
h2 {
string-set: chapter content();
}
@page {
@top-right {
content: string(chapter);
}
}
The rows Chromium does not have at all
A print table of contents needs the page number of a target element, plus the dotted line leading to it:
.toc a::after {
content: leader(".") target-counter(attr(href), page);
}
target-counter() and leader() work in WeasyPrint and Prince and nowhere else. The same goes for footnotes via float: footnote. If you are locked into Chromium and need these, Paged.js polyfills them in JavaScript before printing, at the cost of a heavier render and another dependency.
The gotchas hiding inside "Yes"
- WeasyPrint only gained grid in v62 (April 2024) and
calc()in the 2025 releases. An older install following a newer tutorial fails in confusing ways, so pin and check your version. - Prince 16's grid cannot fragment across pages yet. Use grid for components, not page-spanning layout.
- wkhtmltopdf's flexbox is the abandoned 2009
-webkit-boxdraft. Moderndisplay: flexmarkup silently renders as stacked blocks. - Fragmentation is where most real bugs live. Chrome, WeasyPrint and Prince honour
break-inside: avoidand repeattheadacross pages; wkhtmltopdf only understands the legacypage-break-*spellings, and its table splitting is fragile even then.
Picking an engine
For invoices, statements and reports: Chromium, now that it has real headers and page numbers. For books, contracts and anything with a table of contents or cross references: WeasyPrint for free, Prince when there is budget. And if you are still on wkhtmltopdf, the table above is the migration argument in one screen: an archived engine, a critical CVE and a decade of missing CSS.
The full version, covering @page setup, named pages, fragmentation control and the JavaScript story, is on the blog: CSS support across HTML to PDF engines.
Which engine are you running, and what is the worst CSS surprise it has given you? Share it in the comments below.
Top comments (1)
One row worth adding under the engine columns: what the wrapper defaults to. Chromium supports
@pagesize, but Puppeteer'spage.pdf()haspreferCSSPageSizefalse by default, so@page { size: A4 }is silently discarded and you get Letter until someone flips it. My own HTML-to-PDF API inherits that default, which means the table can say Yes and the user still cannot have it.Same shape as your
counter(page)catch: the feature exists, and something between the CSS and the engine decides you do not get it. Did that bite you on margin boxes too, or does Chromium honor those regardless of the flag?