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:

FactValue
Repository statearchived: true — read-only
Last commit pushed2022-11-22
Last release0.12.6, published 2020-06-10
Open issues at archive time1,352
Stars14,568
Project started2009-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:

CVEScoreWhat it is
CVE-2022-355839.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-213657.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.

OptionEngineGood atCosts you
Playwright / PuppeteerChromium 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.
WeasyPrintOwn 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.
GotenbergChromium, 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 APIVaries 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.

If this flag is the only reason you are reading this page, we ran the same HTML form through each replacement and counted the resulting form fields, control by control: What replaces 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

We had this one wrong ourselves before checking, and several widely-cited comparison pages still state that WeasyPrint lacks PDF form support. It was true once. It stopped being true in February 2023.

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.

ApproachFillable outputShape
WeasyPrintYes, since 58.0 Open source, self-hosted, no JavaScript
Chromium + your own pdf-lib passYes, if you build it Full control, no vendor, roughly a week plus upkeep
Commercial libraries — IronPDF among othersYes In-process, licence per developer or per server, strongest in .NET and Java
Hosted APIs — PDFCrowd, DocRaptor, Snapdok API among othersYes 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

  1. 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.
  2. 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.
  3. 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().length in Node, or len(pypdf.PdfReader(f).get_fields() or {}) in Python. Expect a number, not a boolean.
  4. 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.
  5. 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.
  6. 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:

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.