Guides · migration
wkhtmltopdf is archived: the 2026 migration guide — and the feature most replacements quietly drop
Published 2026-08-16 · every claim below was checked against a primary source on the day of writing, and the sources are linked inline.
If a security scan just put wkhtmltopdf on your board, the scan is right and
you already know roughly what happens next: pick a replacement, port the flags, re-test the
templates. Most migration write-ups will get you through that in an afternoon.
This one covers the part they skip. If your PDFs contain form fields people are meant to type into, the three most-recommended replacements cannot produce them at all, and the fourth can only if you know about a flag that is off by default and rarely mentioned. Nothing errors. Nothing warns you. Your fillable form comes back as a picture of a form, and you find out when a customer emails to ask why they cannot type their name.
Where things actually stand
The numbers, pulled from the GitHub API on 2026-08-16 rather than remembered:
| Fact | Value |
|---|---|
| Repository state | archived: true — read-only |
| Last commit pushed | 2022-11-22 |
| Last release | 0.12.6, published 2020-06-10 |
| Open issues at archive time | 1,352 |
| Stars | 14,568 |
| Project started | 2009-08-07 |
This was not a project that died quietly of neglect. The maintainer, Ashish Kulkarni, wrote a long and unusually honest status page explaining exactly why it stopped: wkhtmltopdf is built on Qt 4, which has been unsupported since 2015, and the QtWebKit inside that Qt has not been meaningfully updated since 2012. QtWebKit was deprecated in 2015 and removed from Qt in 2016. There is no upstream left to send patches to.
A rendering engine frozen in 2012 is a compatibility problem — flexbox, grid, modern CSS units. But it is a security problem first, and that is the part your scanner cares about.
Why the security ticket is legitimate
Two CVEs are filed against wkhtmltopdf itself, both confirmed in the NVD:
| CVE | Score | What it is |
|---|---|---|
CVE-2022-35583 | 9.8 Critical | Server-side request forgery. An <iframe> in the input HTML makes
the renderer fetch internal addresses on the attacker's behalf — cloud metadata endpoints,
internal admin panels, anything your PDF box can reach. |
CVE-2020-21365 | 7.5 High | Directory traversal through 0.12.5 — local file disclosure. |
Neither will be fixed, because the repository is read-only. That is the whole argument: not "this is exploitable in your setup today", but "no patch can ever arrive". If your input HTML is entirely yours and your renderer has no network egress, the practical risk is lower than 9.8 suggests. If any part of that HTML comes from a user — a name, a URL, a rich-text field, an uploaded logo — then you are the exact case the maintainer warns about, in his own words:
"Do not use wkhtmltopdf with any untrusted HTML — be sure to sanitize any user-supplied HTML/JS, otherwise it can lead to complete takeover of the server it is running on!"
The ecosystem around it is still generating criticals, too. Two landed in the last two months
— CVE-2026-16766
(2026-07-25, Perl Catalyst::View::Wkhtmltopdf, shell injection via render options,
9.8) and
CVE-2026-16770
(2026-08-13, Perl PDF::WebKit, argument injection through meta tags, 9.8). Those are
wrapper bugs, not engine bugs, but they illustrate the pattern: a CLI that takes dozens of flags,
wrapped by libraries that pass user data near those flags, with nobody upstream reviewing it any
more.
The four replacements everyone recommends
Interestingly, the shortlist most articles give you is close to the maintainer's own. His status page recommends WeasyPrint or the commercial Prince for report generation you control, and Puppeteer for pages that need JavaScript.
| Option | Engine | Good at | Costs you |
|---|---|---|---|
| Playwright / Puppeteer | Chromium | Genuinely modern rendering. Anything that works in Chrome works here, JS included. Flag-for-flag the closest match to what you had. | You now operate a browser: ~400 MB of image, memory spikes, zombie processes, a sandbox to think about. No fillable form fields. |
| WeasyPrint | Own renderer (Python) | Light, no browser, excellent print CSS — page boxes, running headers, footnotes, PDF/A and PDF/UA. Does produce fillable form fields. | No JavaScript at all, and no Blink. Templates written against a browser will need real work. |
| Gotenberg | Chromium, in a container | Turns the browser problem into somebody else's Docker image with an HTTP API in front. Also converts Office documents. | Still Chromium, so the same form limitation. You host and scale it. |
| A hosted API | Varies | No browser in your infrastructure at all. Capability differences are the vendor's problem to solve, not yours. | Per-render cost, an external dependency, and your HTML leaves your network. |
For the mechanical part of the port, Chromium-based tools map almost one-to-one. Here is a representative wkhtmltopdf invocation:
wkhtmltopdf \
--page-size A4 \
--margin-top 20mm --margin-bottom 20mm \
--print-media-type \
--javascript-delay 500 \
--enable-forms \
https://your-app.com/intake-form out.pdf
and the same thing in Playwright, option names verified against the shipped
page.pdf() type definitions:
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.emulateMedia({ media: 'print' }); // --print-media-type
await page.goto('https://your-app.com/intake-form', {
waitUntil: 'networkidle', // --javascript-delay, but honest
});
const bytes = await page.pdf({
format: 'A4', // --page-size A4
margin: { top: '20mm', bottom: '20mm' }, // --margin-top / --margin-bottom
printBackground: true, // wkhtmltopdf printed these by default
});
await browser.close();
// --enable-forms has no equivalent here. See below.
Note what happened to the last flag.
The flag with no equivalent: --enable-forms
wkhtmltopdf's own usage documentation describes it in one line:
--enable-forms Turn HTML form fields into pdf form fields
It is off by default, which is exactly why this bites people. Whoever turned it on may have left the company in 2019. It is one word in a config file. It does not appear in your template code, your tests probably assert on text content rather than on interactivity, and the PDF looks completely normal without it.
wkhtmltopdf --enable-forms?What it actually produces
A real PDF form field is an AcroForm — the interactive form model defined in the PDF specification (ISO 32000-1) and supported by essentially every reader ever shipped: Acrobat, macOS Preview, Chrome and Firefox's built-in viewers, mobile readers. The field is a live object in the document with a name, a type, a value and a rectangle. Someone opens the PDF, clicks, types, saves, and the value is inside the file. You can read those values back out programmatically.
The alternative — what you get without --enable-forms — is a drawing of an input
box. Correct-looking, uneditable, and worthless to anybody trying to fill it in.
Why Chromium cannot do it
This is a real architectural limitation, not a missing option. Chromium's print-to-PDF path rasterises and vectorises the painted page; interactive controls are painted like anything else. Puppeteer's maintainers said as much when they closed issue #3646 in December 2018 — "Puppeteer PDF functionality fully relies on Chromium's Save as PDF implementation" — and pointed upstream. A commenter in that same thread reports the upstream Chromium request (issue 1024713) was marked WontFix in November 2020; we were not able to load the Chromium tracker ourselves to confirm that status, so treat the date as attributed rather than verified.
What we did verify is the behaviour, on this machine, on the day of writing. Twenty lines, reproducible in a minute:
import { chromium } from 'playwright';
import { PDFDocument } from 'pdf-lib';
const HTML = `<form>
<input type="text" name="full_name">
<input type="email" name="email">
<input type="checkbox" name="subscribe">
<select name="plan"><option>Basic</option><option>Pro</option></select>
<textarea name="notes"></textarea>
</form>`;
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(HTML, { waitUntil: 'load' });
const bytes = await page.pdf({ format: 'A4', printBackground: true });
await browser.close();
const doc = await PDFDocument.load(bytes);
console.log(doc.getForm().getFields().length);
Chromium 151.0.7922.34, driven by Playwright, against a form with a text input, an email
input, a checkbox, a select and a textarea. Output: 0. Not a partial conversion —
the PDF has no AcroForm dictionary at all. Puppeteer drives the same engine through the same
DevTools call and gives the same result, and so does anything built on top of them, Gotenberg
and browserless included.
WeasyPrint can — and most migration guides get this wrong
WeasyPrint 58.0 (beta 2023-02-03, stable 2023-02-17) added PDF form generation, funded by Personalkollen. Two ways to switch it on:
# CLI
weasyprint --pdf-forms https://your-app.com/intake-form out.pdf
# Python
from weasyprint import HTML
HTML('https://your-app.com/intake-form').write_pdf('out.pdf', pdf_forms=True)
or, if you want per-control rather than whole-document behaviour, the CSS property:
/* Per-control opt-in, instead of the global flag. */
input, textarea, select { appearance: auto; }
The API reference lists text inputs, checkboxes, textareas and selects; radio-group handling is present in the current source. So if your templates are static HTML — invoices, contracts, intake forms, most report generation — WeasyPrint is a serious answer to this whole article, it is free, and it is the maintainer's own first recommendation. The catch is not forms. The catch is that it does not run JavaScript and is not a browser, so a template built against Chrome's box model will need porting rather than copying.
Or post-process the PDF yourself
The workaround suggested in that Puppeteer thread is legitimate: render with Chromium, then
add the AcroForm fields afterwards with a PDF library — pdf-lib in Node,
pypdf or reportlab in Python, iText in Java. The fields do not have to
come from the browser; they only have to end up in the file.
Worth being honest about what that costs, because it is more than it looks. You need each control's position in PDF coordinates, which means measuring in the page before printing, converting CSS pixels to points, flipping the Y axis, and then tracking how pagination moved everything. Then the type mapping: checkbox appearance streams, radio groups sharing a parent field, select options as a choice field, font resources for the text you will type. It is a week of work to get right and a permanent maintenance line item — but it is well-understood work with no vendor attached, and for a handful of fixed templates it can be the correct call.
If you need fillable output, here is the actual field
Plenty of tools do this. The reason it feels rare is that the four defaults in every migration article mostly do not — not that the capability is scarce.
| Approach | Fillable output | Shape |
|---|---|---|
| WeasyPrint | Yes, since 58.0 | Open source, self-hosted, no JavaScript |
Chromium + your own pdf-lib pass | Yes, if you build it | Full control, no vendor, roughly a week plus upkeep |
| Commercial libraries — IronPDF among others | Yes | In-process, licence per developer or per server, strongest in .NET and Java |
| Hosted APIs — PDFCrowd, DocRaptor, Snapdok API among others | Yes | HTTP call, per-render pricing, nothing to operate |
That list is the ones we checked ourselves, not a ranking and not exhaustive — several other commercial libraries and form-and-signature platforms do this too.
One detail worth noticing: IronPDF renders with Chromium and still produces form fields,
through a CreatePdfFormsFromHtml option on its renderer. That is not a contradiction
of anything above — it is the post-processing pass, done for you inside the library. It is the
shape every non-WeasyPrint answer in this table has, ours included.
The switch is named differently everywhere, which is part of why this capability is hard to
find when you go looking: --pdf-forms in WeasyPrint,
setEnablePdfForms(true) in PDFCrowd, the CSS property
-prince-pdf-form: enable in DocRaptor and Prince,
CreatePdfFormsFromHtml in IronPDF, pdf_forms in ours. Searching a
vendor's docs for "fillable" will often miss it.
Do compare them. They differ in ways that matter more than the checkmark: which controls
convert, what happens to a control the converter does not understand, whether field names survive
from your HTML name attributes, and whether you are told when something was skipped.
Our own answer, stated once: snapdok.io is a hosted API where
pdf_forms: true on a normal render returns AcroForm fields positioned where the
controls rendered.
curl -X POST https://snapdok.io/v1/render \
-H "Authorization: Bearer $SNAPDOK_KEY" \
-H 'Content-Type: application/json' \
-d '{"url":"https://your-app.com/intake-form",
"format":"pdf",
"pdf_forms":true}' \
-o out.pdf -D headers.txt
# X-Form-Fields: how many AcroForm fields were placed
# X-Form-Skipped: controls that were not convertible
Under the hood it is not magic and it is not a browser feature — it is exactly the
post-processing pass described above: Chromium prints the page, then a pdf-lib stage
places the fields. We built it because we did not want to maintain that stage, and neither do
most people. The two response headers exist because the failure mode of this whole category is
silence: X-Form-Fields tells you how many fields were placed and
X-Form-Skipped how many controls were not convertible, so a template change that
quietly breaks your forms shows up as a number instead of as a support email. Free keys need no
card — get one — and the docs list exactly
which controls convert. If WeasyPrint fits your stack, use WeasyPrint; it is free and it is good.
A migration checklist that catches this class of problem
- Grep for the flags before you touch code.
--enable-forms,--print-media-type,--javascript-delay,--disable-smart-shrinking,--header-html. Search config files and environment variables too, not just source — that is where the surprising ones live. - Inventory what your PDFs are for. Read-only records (invoices, receipts, reports) migrate to anything. Documents a human is supposed to complete — intake forms, consent forms, applications, waivers — are the ones with a hidden requirement.
- Assert on structure, not on looks. A screenshot diff passes happily while
every form field is dead. One line per template, in CI:
PDFDocument.load(bytes).getForm().getFields().lengthin Node, orlen(pypdf.PdfReader(f).get_fields() or {})in Python. Expect a number, not a boolean. - Check the text layer too. Some replacements shift or lose embedded fonts, which breaks copy-paste and search — and CJK text is where this shows up first. Extract the text from the new PDF and compare it against the old one.
- Re-test pagination on your longest real document. Not the sample. Page breaks are where a different engine will surprise you, and the old and new engines disagree most on tables that split.
- Keep both paths running for a release. Generate with the old and the new, diff the field counts and the page counts, and only then delete the old code.
If you cannot migrate this quarter
Sometimes the honest answer is that the migration is a month of work and you have a week. The containment measures, in order of how much risk they remove:
- Make sure no untrusted HTML reaches the renderer. This is the one that matters. If every byte of input is generated by your own templates from your own data, the SSRF has no injection point.
- Cut the renderer's network egress. CVE-2022-35583 is only useful to an attacker if the process can reach something worth reaching. Deny by default; allow only the hosts your templates actually load assets from. Block link-local addresses so cloud metadata endpoints are unreachable.
- Confine the process. The maintainer publishes a recommended AppArmor policy; SELinux works as well. Run it as an unprivileged user, in its own container, with a read-only filesystem.
- Write the date down. A risk acceptance with an expiry is a plan. One without is a decision to never migrate.
The short version
wkhtmltopdf is archived, its last commit was 2022-11-22, and its 9.8 SSRF will not be patched. Move. But before you pick the replacement, check whether anything you generate is meant to be filled in — because that requirement is invisible in your template code, lives in a single default-off flag, and every Chromium-based replacement will drop it without a word. If it applies to you: WeasyPrint if you can live without JavaScript, a post-processing pass if you want to own it, or a hosted API if you would rather not think about it again.
Sources checked 2026-08-16: GitHub API · wkhtmltopdf.org/status.html · wkhtmltopdf usage docs · NVD CVE-2022-35583 · puppeteer#3646 · WeasyPrint changelog. The Chromium measurement is our own and reproducible with the script above.