DEV Community

Cover image for Can I use it in a PDF? CSS support across HTML to PDF engines
Accreditly
Accreditly

Posted on Originally published at html2img.com

Can I use it in a PDF? CSS support across HTML to PDF engines

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 @page margin 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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-box draft. Modern display: flex markup silently renders as stacked blocks.
  • Fragmentation is where most real bugs live. Chrome, WeasyPrint and Prince honour break-inside: avoid and repeat thead across pages; wkhtmltopdf only understands the legacy page-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)

Collapse
 
to21as profile image
Tobias

One row worth adding under the engine columns: what the wrapper defaults to. Chromium supports @page size, but Puppeteer's page.pdf() has preferCSSPageSize false 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?