Every wkhtmltopdf replacement makes you give something up. Browser engines run your JavaScript but ignore half of CSS Paged Media. Print-first engines nail running headers and footnotes but will not execute a single script. The PHP and Java libraries are light, yet their layout engines still treat a page the way a 2010 browser would. We tested eleven wkhtmltopdf alternatives against the same three documents so you can see exactly what each one costs.
The field covers WeasyPrint, Gotenberg, Playwright, Puppeteer, Chrome headless, Vivliostyle, OpenHTMLtoPDF, Dompdf, Paged.js, mPDF and xhtml2pdf. Each one got a CSS and JavaScript probe score, render time, memory and a file-read and SSRF check, and the main contenders were also installed on Ubuntu, Debian and Rocky Linux. Every tool links to its GitHub repository, and the six we would actually deploy have their own tested setup guide.
Current as of October 2026.
wkhtmltopdf alternatives at a glance
The ranking weighs three things: how much modern CSS and JavaScript survives into the PDF, how painful the install is (across four distros for the main contenders), and whether the project is still moving (commits and human contributors in the last 90 days, plus release cadence). wkhtmltopdf sits at the bottom as the baseline you are leaving.
| # | Tool | Engine | CSS probes (of 15) | JS probes (of 3) | Invoice render | Commits / contributors (90 days) | License |
|---|---|---|---|---|---|---|---|
| 1 | WeasyPrint | Own Python layout engine | 15 | 0 | 1.31 s | 137 / 9 | BSD-3-Clause |
| 2 | Gotenberg | Chromium + LibreOffice behind an HTTP API | 12 | 3 | 0.26 s per request | 120 / 8 | MIT |
| 3 | Playwright | Chromium headless shell | 12 | 3 | 1.46 s (Node), 1.27 s (Python) | 661 / 58 | Apache-2.0 |
| 4 | Puppeteer | Chrome for Testing | 12 | 3 | 1.56 s | 209 / 31 | Apache-2.0 |
| 5 | Chrome headless CLI | chrome-headless-shell (Chrome for Testing) | 12 | 3 | 0.29 s (headless shell) | Chromium project | BSD-3-Clause (Chromium) |
| 6 | Vivliostyle | Vivliostyle.js on Chromium | 15 | 2 | 6.20 s | 264 / 5 (vivliostyle.js) | AGPL-3.0 |
| 7 | OpenHTMLtoPDF | Own Java layout engine + PDFBox | 6 | 0 | 1.16 s | 59 / 16 | LGPL |
| 8 | Dompdf | Own PHP layout engine | 2 | 0 | 0.65 s | 18 / 4 | LGPL-2.1 |
| 9 | Paged.js | Polyfill on Chromium (pagedjs-cli) | 12 | 2 | 2.27 s | 2 / 2 | MIT |
| 10 | mPDF | Own PHP layout engine | 1 | 0 | 0.48 s* | 10 / 4 | GPL-2.0 |
| 11 | xhtml2pdf | ReportLab | 6 | 0 | 1.16 s* | 180 / 2 | Apache-2.0 |
| Baseline | wkhtmltopdf | Patched QtWebKit (frozen 2012) | 2 | 2 | 0.29 s | 0 / 0 (archived) | LGPL-3.0 |
If you only pick one: WeasyPrint for invoices, reports and anything you control end to end, Gotenberg when several apps need PDFs over HTTP, and Playwright when the page needs JavaScript to finish drawing. The rest of this page shows why.
Why wkhtmltopdf needs replacing now
The project is discontinued. The main repository is archived with its last push in November 2022, the last real release dates from June 2020, and the separate packaging repo that builds the patched Qt binaries is archived too. Its maintainer’s own status page says the WebKit inside it has not been updated since 2012, tells you not to feed it untrusted HTML, and points report builders to WeasyPrint and JavaScript-heavy pages to Puppeteer.
Security is the part that bites. CVE-2022-35583 is a CVSS 9.8 SSRF: an injected iframe makes the renderer fetch internal addresses. Nobody will patch it. Distros are walking away as well. On Debian 13, apt-cache policy wkhtmltopdf reports Candidate: (none), which is why apt install wkhtmltopdf stops with E: Package 'wkhtmltopdf' has no installation candidate. Ubuntu 24.04 still carries an unpatched-Qt build, and Rocky Linux 10 has nothing.
You can still run it while you migrate. The old patched-Qt packages installed cleanly on Ubuntu 26.04, Ubuntu 24.04, Debian 13 and Rocky Linux 10 in our lab, and our wkhtmltopdf install guide for Ubuntu and Debian and the Rocky and AlmaLinux version cover that path. Treat it as a bridge, not a destination.
How we tested
All rendering and timing ran on one Proxmox VM: Ubuntu 26.04 LTS, 4 vCPUs passed through as the host CPU, 8 GB RAM. Install paths were also tested on Ubuntu 24.04, Debian 13 and Rocky Linux 10 with SELinux enforcing. Every tool got the same three documents, built for this test:
- invoice.html: an invoice with 140 line items, a flexbox header, inline SVG logo, a Google web font (Lobster), CSS variables, a repeating table header and
@pagemargin boxes printing “Page X of Y”. - report.html: a print-first report with a running chapter header (
string-set), a table of contents built withtarget-counter(), a CSS grid, two text columns, afloat: footnote, a landscape named page, plus probes for:has(),:is()and CSS nesting. - dashboard.html: a chart drawn by JavaScript, text added after a 500 ms timer, and a line that only appears if ES2020 optional chaining parses.
Each probe is either text that only appears when a feature fails (for example CSSVAR_FAIL stays visible if CSS variables do not work) or a geometry check on the PDF. pdftotext -bbox confirms the grid cards share a baseline, the footnote sits in the bottom quarter of its page and the landscape table landed on a wider-than-tall page. pdffonts confirms the web font was embedded.
Timing used a transient systemd unit per render, so the “Service runtime” and “Memory peak” systemd prints cover every child process (Chrome alone spawns several). Each tool ran one warm-up and five measured renders per document, and we report the median. That warm-cache memory peak is the per-render working set: it excludes shared libraries already sitting in the page cache. We validated it against wkhtmltopdf’s max RSS (42,652 KB) and against a run after dropping caches (68 MiB), and report the cold-cache number separately. Library users also got a warm in-process test: one browser or interpreter, 20 renders, median of the last 19. Gotenberg ran in a fresh container per document, timed with curl.
Two tools needed adapted inputs, flagged with an asterisk above. mPDF turns @page { size: A4; } into blank pages (more on that below), so its copies use mPDF’s own sheet-size: A4. xhtml2pdf crashed on the report with AttributeError: 'Element' object has no attribute 'cssElement', and removing one nav a { color: inherit; } rule was the only single change that let it render. Versions tested were the latest releases on the test date. The JavaScript waits used each tool’s own mechanism: a 1 s settle for libraries, --javascript-delay 1000 for wkhtmltopdf, --virtual-time-budget for the Chrome CLI and waitDelay=1s for Gotenberg.
| Tool | Version tested | Installed from |
|---|---|---|
| WeasyPrint | 70.0 (uv), 67.0 (Ubuntu 26.04 apt) | PyPI via uv, distro package |
| Gotenberg | 8.37.0 | gotenberg/gotenberg:8 image |
| Playwright | 1.64.0 (Node), 1.63.0 (Python) | npm, PyPI |
| Puppeteer | 25.12.0 (Chrome 154) | npm |
| Chrome headless | chrome-headless-shell 155.0.8059.39 | Chrome for Testing |
| Vivliostyle CLI | 11.3.3 | npm |
| Paged.js CLI | 0.4.3 | npm |
| OpenHTMLtoPDF | 1.1.94 (OpenJDK 21) | Maven Central |
| Dompdf | 3.1.6 (PHP 8.5) | Composer |
| mPDF | 8.3.1 (PHP 8.5) | Composer |
| xhtml2pdf | 0.2.23 | PyPI via uv |
| wkhtmltopdf | 0.12.6.1 (patched Qt) | jammy .deb from the archived packaging repo |
CSS and JavaScript support compared
The engines split into three camps, and the camp matters more than the brand. Chromium-based tools (Gotenberg, Playwright, Puppeteer, Chrome CLI) all score the same 12 of 15 because they share an engine. They handle flexbox, grid, :has(), nesting, margin boxes and named pages. They skip string-set running headers, target-counter() page references and footnotes. WeasyPrint and Vivliostyle passed all fifteen. The PHP libraries and wkhtmltopdf barely register.
| Feature | WeasyPrint | Chromium tools | Vivliostyle | Paged.js | OpenHTMLtoPDF | Dompdf | mPDF | xhtml2pdf | wkhtmltopdf |
|---|---|---|---|---|---|---|---|---|---|
| Flexbox | yes | yes | yes | yes | no | no | no | yes | no |
| CSS grid | yes | yes | yes | yes | no | no | no | no | no |
| Multi-column text | yes | yes | yes | yes | no | no | no | no | no |
| CSS variables | yes | yes | yes | yes | yes | yes | no | yes | no |
| :has() / :is() / nesting | yes / yes / yes | yes / yes / yes | yes / yes / yes | yes / yes / yes | no / no / no | no / no / no | no / no / no | yes / yes / no | no / no / no |
| Running text in a margin box (@top-left) | yes | yes | yes | yes | yes | no | no | no | no |
| Page X of Y margin box | yes | yes | yes | yes | yes | no | no | no | no (needs –footer flags) |
| Running header (string-set) | yes | no | yes | yes | no | no | no | no | no |
| TOC with target-counter() | yes | no | yes | no | yes | no | no | no | no |
| float: footnote | yes | no | yes | yes | no | no | no | no | no |
| Landscape named page | yes | yes | yes | no | yes | no | no | no | no |
| Repeating table header | yes | yes | yes | no | no | yes | yes | yes | yes |
| Web font embedded | yes | yes | yes | yes | yes | no | no | yes | yes |
| JavaScript runs | no | yes | yes | yes | no | no | no | no | yes |
| Timer-delayed JS | no | yes | no | no | no | no | no | no | yes |
| ES2020 syntax | no | yes | yes | yes | no | no | no | no | no |
Page two of the report shows the split at a glance. wkhtmltopdf stacks the three KPI cards and ignores the columns. The Chromium column has no “Chapter: Summary” running header. Dompdf prints the footnote on top of the paragraph text:

The invoice tells the same story at a more everyday level. wkhtmltopdf leaves the CSSVAR_FAIL probe visible, loses the brand colour on the table header and shrinks the whole page. mPDF drops the web font and the logo. OpenHTMLtoPDF and xhtml2pdf stack the invoice’s flex header blocks vertically (xhtml2pdf did keep the simpler dashboard tiles in a row, which is why its table entry reads yes):

Click either image for the full-size version. The source documents, raw PDFs and probe scripts stay in our lab notes, so every “yes” above traces to a file.
Speed and memory benchmark
wkhtmltopdf was fast, and only one alternative matched it from a cold start: the Chrome CLI, specifically the chrome-headless-shell build. Everything that drives a browser from Node or Python pays about a second of startup per process. That is why the warm, in-process numbers further down matter more for anyone running a service.
| Tool (cold CLI) | Invoice | Report | Dashboard | Working memory (invoice) | First render, caches dropped |
|---|---|---|---|---|---|
| wkhtmltopdf | 0.29 s | 0.28 s | 1.06 s | 10 MiB | 0.37 s / 68 MiB |
| Chrome CLI (headless shell) | 0.29 s | 0.22 s | 0.20 s | 95 MiB | 0.51 s / 324 MiB |
| mPDF* | 0.48 s | 0.19 s | 0.16 s | 36 MiB | 0.60 s / 111 MiB |
| Dompdf | 0.65 s | 0.21 s | 0.19 s | 50 MiB | 0.79 s / 124 MiB |
| OpenHTMLtoPDF (JVM) | 1.16 s | 0.93 s | 0.79 s | 134 MiB | 1.40 s / 232 MiB |
| xhtml2pdf* | 1.16 s | 0.75 s | 0.70 s | 72 MiB | 1.56 s / 146 MiB |
| Playwright (Python) | 1.27 s | 1.23 s | 2.23 s | 201 MiB | 1.67 s / 756 MiB |
| WeasyPrint | 1.31 s | 0.95 s | 0.70 s | 56 MiB | 1.59 s / 120 MiB |
| Playwright (Node) | 1.46 s | 1.44 s | 2.41 s | 194 MiB | 1.79 s / 537 MiB |
| Puppeteer | 1.56 s | 1.49 s | 2.46 s | 223 MiB | 2.47 s / 698 MiB |
| Paged.js CLI | 2.27 s | 2.11 s | 2.02 s | 312 MiB | 2.99 s / 808 MiB |
| Vivliostyle CLI | 6.20 s | 4.04 s | 3.60 s | 422 MiB | 8.24 s / about 1.2 GiB |
Inside a long-running process the order changes. Reusing one browser, Playwright rendered the invoice in a median 600 ms from Node and 607 ms from Python, and Puppeteer took 1,176 ms. WeasyPrint’s Python API needed 639 ms for the invoice, 237 ms for the report and 49 ms for the script-free dashboard. Gotenberg, which keeps Chromium warm inside its container, answered the invoice in 0.26 s and the report in 0.22 s per HTTP request. A fresh container was healthy 303 to 377 ms after docker run, and container memory peaked between 222 and 296 MiB.
One WeasyPrint trap showed up here. Neither the PyPI package nor Ubuntu’s weasyprint package pulls in libharfbuzz-subset0. Without it, WeasyPrint warns “Using fontTools instead of HarfBuzz-Subset” and falls back to a slower subsetter. Installing that one library cut its invoice render from 1.47 s to 1.31 s.
Security defaults: local file reads and SSRF
An HTML to PDF converter fed with user input is an SSRF tool unless you lock it down, and wkhtmltopdf’s CVE is the famous example. So we gave every tool the same hostile page. It had an iframe and an <object> pointing at file:///etc/passwd, a <link rel="attachment"> to the same file, and an image plus stylesheet on a local HTTP listener at 127.0.0.1. Defaults only, nothing tuned.
| Tool (defaults) | /etc/passwd ended up in the PDF | Fetched the localhost URL |
|---|---|---|
| WeasyPrint | Yes, as an embedded file attachment | Yes |
| Chrome CLI, Puppeteer, Playwright (page opened as a file:// URL) | Yes, printed in the iframe | Yes |
| Puppeteer, Playwright (HTML passed with setContent) | No | Yes |
| Gotenberg | No, filtered by its default deny list | Not tested (127.0.0.1 is the container itself) |
| wkhtmltopdf | No | Yes |
| Vivliostyle, OpenHTMLtoPDF, mPDF | No | Yes |
| Dompdf, xhtml2pdf | No | No |
Gotenberg logged 'file:///etc/passwd' matches the expression from the denied list: value filtered, which is the behaviour you want from a service. WeasyPrint’s attachment result surprises people because nothing shows on the page. Run pdfdetach -list on the output and there it is. Every per-tool guide in this series shows the fix for its tool (a custom URL fetcher for WeasyPrint, request interception for Playwright and Puppeteer, allow lists for Gotenberg). Paged.js failed to render this page at all, so its row is missing.
1. WeasyPrint
The standout for documents you control. WeasyPrint passed all 15 CSS probes, including the paged-media features nobody else in the Python or PHP world gets right: running headers from string-set, a real table of contents with page numbers from target-counter(), footnotes and a landscape named page. Its files were among the smallest too: 30 KB for the invoice against 69 to 71 KB from the untagged Chromium output. Puppeteer and the Chrome CLI write tagged (accessible) PDFs by default, which pushed the same page to 298 KB. WeasyPrint only tags output when you pass --pdf-tags or a PDF/UA variant, so compare like for like if accessibility matters. Kozea ships several releases a year, and 9 people committed in the last 90 days.
The catch: no JavaScript at all, so charts must be pre-rendered as SVG. On Rocky Linux 10 it is not packaged (not in EPEL 10 either), and a pip or uv install fails with OSError: cannot load library 'libpango-1.0-0' until you dnf install pango. On Debian and Ubuntu the distro package lags upstream by several major versions (61.1 on Ubuntu 24.04, 62.3 on Debian 13), so we install it with uv:
sudo apt install -y libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env
uv tool install weasyprint
Then it’s one command per document: weasyprint invoice.html invoice.pdf. The full walkthrough, with the Python API, fonts, PDF/A, a Docker image and a hardened URL fetcher, is in our WeasyPrint setup guide.
2. Gotenberg
For most teams replacing wkhtmltopdf across several apps, Gotenberg is the least work. It wraps Chromium and LibreOffice in one Docker image behind a multipart HTTP API, so a PHP monolith, a Python worker and a shell script all post HTML and get a PDF back. Our invoice and report requests came back in 0.22 to 0.26 s (the JavaScript dashboard took 1.20 s including its 1 s wait), and the same API converts DOCX and XLSX through LibreOffice and merges PDFs. The project cut 17 releases in the last twelve months.
docker run -d --name gotenberg -p 3000:3000 gotenberg/gotenberg:8
curl -s -o invoice.pdf -F "[email protected];filename=index.html" http://localhost:3000/forms/chromium/convert/html
What holds it back: it inherits Chromium’s paged-media gaps (no running headers from string-set, no target-counter()), relative assets must be uploaded with the request, and the container is the heaviest thing on this list to keep running. On Rocky Linux 10, Docker’s convenience script failed with Unable to find a match: docker-ce, and adding Docker’s RHEL repository by hand fixed it. See the Gotenberg Docker and API guide for Compose, headers and footers, Office conversion and the hardening flags.
3. Playwright
The best library when the HTML needs a real browser. It is the busiest project here (661 commits and 58 human contributors in 90 days) with official bindings for Node.js, Python, Java and .NET. It was also the quickest browser library once warm, at about 600 ms per invoice in both Node and Python. It handled every JavaScript probe and supports header and footer templates with pageNumber and totalPages, which covers what most people used --header-html for.
The weak spots: a browser per worker means about 200 MiB of working memory and a second of cold start. The installer’s --with-deps only knows apt, so on Rocky Linux 10 it stops with sh: line 1: apt-get: command not found and you install the libraries with dnf yourself. Ubuntu 24.04’s packaged Node.js 18 is too old as well: Playwright requires Node.js 20 or higher. The Playwright HTML to PDF guide covers both languages, the Rocky dependency list and a small PDF service.
4. Puppeteer
Same engine and the same probe score as Playwright, Node.js only, and the tool the wkhtmltopdf status page names for JavaScript-heavy pages. Pick it if your team already lives in the Chrome DevTools Protocol world or uses a framework built on it. Otherwise Playwright was faster in our warm test (600 ms against 1,176 ms per invoice) and installs more predictably.
Where it struggles: Ubuntu 23.10 and newer restrict unprivileged user namespaces, so Puppeteer’s bundled Chrome crashes on Ubuntu 24.04 and 26.04 with No usable sandbox!. The lazy fix is --no-sandbox. The right fix is a short AppArmor profile for the Chrome path, which we tested and show in the Puppeteer setup guide. Fresh servers also miss Chrome’s shared libraries (libatk-1.0.so.0 was the first to fail on all three distros).
5. Chrome headless CLI
The closest thing to a drop-in for shell scripts that call wkhtmltopdf, with one important detail: which Chrome binary you call. Our 0.29 s invoice time (level with wkhtmltopdf) and 0.20 s dashboard time came from chrome-headless-shell, the lean headless build Google ships through Chrome for Testing. --virtual-time-budget fast-forwards timers instead of sleeping, so the JavaScript dashboard finished even faster. Install it under /opt straight from the Chrome for Testing feed (this needs curl, jq and unzip):
CHS_URL=$(curl -s https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json | jq -r '.channels.Stable.downloads["chrome-headless-shell"][] | select(.platform=="linux64") | .url')
curl -sLo /tmp/chrome-headless-shell-linux64.zip "${CHS_URL}"
sudo unzip -oq /tmp/chrome-headless-shell-linux64.zip -d /opt
sudo mkdir -p /usr/local/bin
sudo ln -sf /opt/chrome-headless-shell-linux64/chrome-headless-shell /usr/local/bin/chrome-headless-shell
Before the first run, the headless shell needs the shared libraries Chrome loads, which a fresh server lacks on every distro (libatk-1.0.so.0 was the first to fail in our tests). On Ubuntu 24.04 and newer, Debian 13 and later:
sudo apt install -y --no-install-recommends libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 libdbus-1-3 libexpat1 libgbm1 libglib2.0-0t64 libnspr4 libnss3 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 fonts-dejavu-core
On Rocky Linux 10 the equivalent set comes from AppStream:
sudo dnf -y install nss atk at-spi2-atk cups-libs libdrm libxkbcommon libXcomposite libXdamage libXrandr libXfixes mesa-libgbm pango cairo alsa-lib
Ubuntu 23.10 and newer restrict unprivileged user namespaces, so the headless shell dies there with No usable sandbox! until it gets an AppArmor profile for its /opt path. Debian 13 and Rocky Linux 10 printed without one. Create the profile:
sudo vim /etc/apparmor.d/chrome-headless-shell-pdf
Add the following:
abi <abi/4.0>,
include <tunables/global>
profile chrome-headless-shell-pdf /opt/chrome-headless-shell-linux64/chrome-headless-shell flags=(unconfined) {
userns,
include if exists <local/chrome-headless-shell-pdf>
}
Load it on Ubuntu (Debian and Rocky Linux skip the first line), then convert a page as a normal user. As root it refuses to start without --no-sandbox:
sudo apparmor_parser -r /etc/apparmor.d/chrome-headless-shell-pdf
chrome-headless-shell --no-pdf-header-footer --print-to-pdf=report.pdf file:///srv/docs/report.html
Google’s packaged google-chrome --headless skips the AppArmor step on Ubuntu, because Ubuntu already ships a profile for /opt/google/chrome, and its package pulls in the libraries. It starts slower, though. In the Chrome guide’s interleaved startup test, the headless shell took a median 0.32 s per call against 1.10 s for google-chrome with its default profile and 0.72 to 0.76 s with an explicit --user-data-dir.
What holds it back: there are no CLI flags for page size, margins or header templates. All of that moves into CSS @page rules. Waiting for a specific element is not possible either. The Chrome headless print to PDF guide has both binaries, the library list per distro, the AppArmor profile for this exact path, the flag reference and a parallel batch script. If you prefer the packaged browser (no AppArmor work, updates through apt), our Google Chrome install for Ubuntu covers the repository setup.
6. Vivliostyle
The most complete CSS Paged Media implementation we tested, tied with WeasyPrint at 15 of 15, and it also runs JavaScript because it lays pages out inside Chromium. It’s built for books and long-form publishing (the CLI builds PDF and EPUB from HTML or Markdown sources), and vivliostyle.js landed 264 commits in 90 days. Install it with npm install -g @vivliostyle/cli and build with vivliostyle build report.html -o report.pdf.
Watch out for this: it was by far the slowest and hungriest tool, at 6.2 s and 422 MiB for the invoice and about 1.2 GiB on a cold cache. It also ignored the 500 ms timer probe. The AGPL-3.0 license also needs a look before you embed it in a commercial service.
7. OpenHTMLtoPDF
The pick for Java shops. The maintained fork lives under the openhtmltopdf organisation and publishes to Maven Central as io.github.openhtmltopdf. The original danfickle repository stopped in 2024, so update your coordinates if you still pull com.openhtmltopdf. It handled margin boxes, target-counter() TOC entries and the landscape page, and produced the leanest report PDF of the field at 15 KB.
The limits: no flexbox, grid or columns, so modern layouts collapse into stacked blocks. It also needs well-formed input, which means parsing HTML5 with jsoup first. JVM start-up puts a cold render at about 1.2 s, which goes away inside a running application. Flying Saucer, the project it forked from, is still active too if you need its API.
8. Dompdf
For PHP applications that cannot run a sidecar, Dompdf is the practical choice. It is a pure-PHP Composer package with no binaries, it rendered in 0.19 to 0.65 s, and it was one of only two tools that neither read the local file nor fetched the localhost URL at default settings. The Dompdf guide shows page numbers, custom fonts and the options that keep it locked down.
Why it is not higher: it scored 2 of 15. There is no flexbox or grid, no margin boxes, and our @font-face web font was not embedded on the first try. Templates written for a browser need rework into tables and floats. If the layout has to look modern, call Gotenberg from PHP instead.
9. Paged.js
Paged.js is a polyfill that fragments the page into print boxes inside Chromium. It’s clever, and it scored 12 of 15 with running headers and footnotes the plain Chromium tools lack. The library still sees commits, but the command-line wrapper is the problem. pagedjs-cli hasn’t had a release since July 2023 and still pins Puppeteer 20.9. A global npm install as root downloaded its old Chromium into /root/.cache, where normal users can’t reach it. We had to point it at a current Chrome with PUPPETEER_EXECUTABLE_PATH.
The downside: no repeating table headers, no landscape named page, and it refused to render our security test page. Use it inside a browser pipeline you already own, not as a standalone converter.
10. mPDF
mPDF is quick (0.16 to 0.48 s) and common in PHP applications, but it is the tool most likely to surprise you. The standard CSS rule @page { size: A4; } made it emit 34 blank pages from a one-line document, and 577 pages for our invoice. Its own sheet-size: A4 property, or size: 210mm 297mm, works fine. Also note that its GitHub releases page stops at 2022 even though Composer installs a 2026 build, so check Packagist when you audit versions.
What hurts: 1 of 15 CSS probes, no CSS variables, and the web font wasn’t embedded. The GPL-2.0 license is stricter than Dompdf’s LGPL.
11. xhtml2pdf
xhtml2pdf converts HTML into ReportLab drawing calls. It is pure Python and easy to install with uv tool install xhtml2pdf, and it’s still maintained (a release landed on our test date). It kept the dashboard’s flex tiles in a row and handled CSS variables and the web font, which is better than we expected.
Where it falls short: it crashed on the report until one innocent rule was removed, it stretched the 140-line invoice to 9 pages because table rows wrap badly, and it has no paged-media support. Commit activity is high, but only two people made those commits in 90 days. For new Python work, WeasyPrint is the better bet.
wkhtmltopdf flags and what replaces them
Most migrations stall on the command-line flags, because wkhtmltopdf put layout in flags while every modern engine expects it in CSS. This map covers the flags we see most in real projects:
| wkhtmltopdf flag | WeasyPrint | Chromium (Playwright, Puppeteer, Gotenberg, Chrome CLI) |
|---|---|---|
| –page-size A4, -O Landscape | @page { size: A4 landscape; } | Same CSS with preferCSSPageSize, or format / landscape options (Gotenberg: paperWidth, paperHeight, landscape) |
| –margin-top 20mm (and friends) | @page { margin: 20mm; } | Same CSS, or the margin option (Gotenberg: marginTop and so on) |
| –header-html / –footer-html | @page margin boxes (@top-center, @bottom-right) with counter(page) and counter(pages) | headerTemplate / footerTemplate with pageNumber and totalPages classes (Gotenberg: header.html, footer.html); margin boxes also work |
| –footer-center “[page]/[topage]” | content: counter(page) “/” counter(pages) | <span class=”pageNumber”></span>/<span class=”totalPages”></span> |
| toc | Links with target-counter(attr(href), page) | No native equivalent; build it in JS or use Paged.js or Vivliostyle |
| –javascript-delay 1000 | Not applicable (no JS) | waitForSelector / waitForFunction; Gotenberg waitDelay or waitForExpression; Chrome CLI –virtual-time-budget |
| –enable-local-file-access | base_url plus a custom url_fetcher that allows only your asset directory | Pass HTML with setContent and serve assets yourself; Gotenberg uploads assets with the request |
| –print-media-type | Print media is the default | Print media is the default for page.pdf(); Gotenberg emulatedMediaType |
| –zoom 1.2 | zoom option in the Python API (it scales every CSS unit, page size included) | scale option (0.1 to 2) |
| –disable-smart-shrinking | Not needed: layout uses the real page width | Not needed: layout uses the real page width |
| –outline | Bookmarks generated from h1 to h6 by default | Playwright outline: true, Puppeteer outline (experimental), Gotenberg generateDocumentOutline, Chrome CLI –generate-pdf-document-outline |
Expect wkhtmltopdf’s “smart shrinking” to have hidden layout bugs. Our invoice printed visibly smaller there than anywhere else, so pages that looked right in wkhtmltopdf often need their widths fixed once a modern engine stops scaling them down.
Also considered
These came up during research but did not make the ranked list, mostly for license or scope reasons:
- Prince and PDFreactor: the strongest commercial paged-media engines, with paid licenses. Worth a look if WeasyPrint’s output isn’t enough and budget allows.
- iText pdfHTML: capable, but AGPL or a commercial license, which rules it out for most closed-source services.
- browserless: a hosted-or-self-hosted Chrome pool with a PDF endpoint, dual-licensed SSPL or commercial. Gotenberg covers the same ground under MIT.
- chromedp and Puppeteer Sharp: the same Chromium approach for Go and .NET. Expect the Puppeteer numbers above.
- Flying Saucer: still active, but OpenHTMLtoPDF is the fork most Java projects moved to.
- Typst and Pandoc: excellent PDF tools, but they don’t render your existing HTML and CSS, so they only fit if you’re rewriting templates anyway.
- Stirling-PDF: a self-hosted PDF toolbox with a web UI and API rather than a rendering library. We cover it in the Stirling-PDF Docker guide.
Pick by the document you render
Match the tool to the job rather than to the language you happen to use:
- Invoices, statements and reports from your own templates: WeasyPrint. It’s the only fast, light engine that passed every paged-media probe, and its PDFs came out 2 to 4 times smaller than untagged Chromium output.
- A shared PDF service for several apps or languages: Gotenberg. It answered in 0.22 to 0.26 s per request, blocked the file-read probe by default, and handles Office documents in the same container.
- Dashboards and pages that draw with JavaScript: Playwright, with a warm browser at a 600 ms median per page, or Puppeteer if you are tied to its ecosystem.
- Shell scripts and cron jobs that called wkhtmltopdf:
chrome-headless-shellwith--print-to-pdf, the only alternative that matched wkhtmltopdf’s 0.29 s per-run time. - Books and long-form publishing: Vivliostyle, if you can afford 4 to 6 seconds and 330 to 420 MiB per build.
- PHP or Java apps that cannot run a browser: Dompdf or OpenHTMLtoPDF, with templates rewritten for tables and floats instead of flexbox and grid.
If you are on Odoo or another platform that hard-codes wkhtmltopdf, keep the patched-Qt build running and lock it down in the meantime. Run it only on HTML you generate, never on user-supplied markup, and track the platform’s own plans for a replacement engine.