Guides · wkhtmltopdf migration · forms
What replaces
wkhtmltopdf --enable-forms? We ran one form through each option and counted
the fields
Published 2026-08-17 · every number on this page was measured on the day of writing, on one machine, from the same HTML. The script is included so you can disagree with us in public.
If you are here you already know the general shape of the problem: wkhtmltopdf is archived and carries an unpatched 9.8, you have to move, and exactly one flag in your command line has no obvious home. Everything else ports in an afternoon. This page is about that one flag.
The short answer, before the evidence:
| Option | Fillable fields? | What it costs you |
|---|---|---|
| Playwright · Puppeteer · Gotenberg · browserless | No — measured zero | Nothing to configure, because it cannot be configured |
| WeasyPrint 58.0+ | Yes, behind a default-off flag | No JavaScript, not a browser — templates need porting |
Chromium + your own pdf-lib pass |
Yes, if you build it | Roughly a week, then permanent upkeep |
| Commercial libraries and hosted APIs | Yes, several of them | Licence or per-render cost; the switch is named differently by each |
The rest of this page is how we know, which controls actually survive in each case, and the two failure modes that will not show up in a visual diff.
What the flag did, precisely
wkhtmltopdf's own usage documentation spells out both halves, including the default:
--enable-forms Turn HTML form fields into pdf form fields
--disable-forms Do not turn HTML form fields into pdf form fields (default)
What it produced is an AcroForm: the interactive form model in the PDF specification (ISO 32000-1). A field is a real object with a name, a type, a value and a rectangle — so a person opens the file, clicks, types, saves, and the value lives inside the document. You can read those values back out with any PDF library. Without it you get a picture of an input box: pixel-perfect, completely dead.
That is why this is worth an article of its own. The difference is invisible to a screenshot test, invisible in code review, and invisible in your template source — the flag usually lives in a config file or an environment variable, set once by somebody who has since left.
The test
One HTML form, fourteen named controls, covering the types real intake forms actually
contain — including the two nobody thinks about, hidden and file:
<form>
<input type="text" name="full_name" maxlength="60">
<input type="email" name="email" required>
<input type="tel" name="phone">
<input type="date" name="dob">
<input type="number" name="party_size" value="2">
<input type="password" name="pw">
<input type="checkbox" name="subscribe" checked>
<input type="radio" name="plan" value="basic">
<input type="radio" name="plan" value="pro">
<select name="country"><option>US</option><option>DE</option></select>
<select name="skills" multiple><option>a</option><option>b</option></select>
<textarea name="notes"></textarea>
<input type="hidden" name="token" value="x">
<input type="file" name="doc">
<button type="submit">Send</button>
</form>
Every engine below got that exact file and nothing else. We then loaded the resulting PDF and counted AcroForm fields, and read each field's type and flag bits, rather than looking at it.
Chromium: zero, and not by accident
Playwright, Puppeteer, Gotenberg, browserless and anything else that drives headless Chrome all funnel into the same DevTools print path, so one measurement covers the category:
import { chromium } from 'playwright';
import { PDFDocument } from 'pdf-lib';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('file:///tmp/form.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); // -> 0
Chromium 151.0.7922.34: 0. Not a partial conversion or a few unsupported
controls — the document comes back with no usable form at all. Chromium's print pipeline paints
the page and then vectorises what it painted; an <input> is painted like a
rounded rectangle with a border, because at that stage that is all it is. Puppeteer's tracker has
carried this as
issue #3646,
"Pdf form inputs not editable", since December 2018; it is closed, and the answer has always
pointed upstream at Chromium rather than at Puppeteer.
So if your migration plan is "swap wkhtmltopdf for Playwright", the plan is fine for everything except this, and it will fail silently.
WeasyPrint: yes — and more than its documentation admits
WeasyPrint 58.0 (beta 2023-02-03, stable 2023-02-17) added the capability outright — the changelog entry reads "Support PDF forms, with financial support from Personalkollen". This is worth stating plainly because a lot of comparison pages, including an earlier version of our own, still say WeasyPrint cannot do forms. It could not, until February 2023. It can now.
It is off by default, exactly like wkhtmltopdf's was:
weasyprint --pdf-forms form.html out.pdf
from weasyprint import HTML
from pypdf import PdfReader
HTML(filename='form.html').write_pdf('out.pdf', pdf_forms=True)
fields = PdfReader('out.pdf').get_fields() or {}
print(len(fields)) # -> 15
for name, f in fields.items():
print(name, f.get('/FT'), f.get('/Ff'))
We measured WeasyPrint 69.0 (released 2026-06-02, the current stable at time
of writing). Without the flag: 0 fields. With it: 15. Field
names come straight from the HTML name attribute, and
maxlength="60" survives as the PDF's own /MaxLen:
| Field | PDF type | What WeasyPrint 69.0 did |
|---|---|---|
full_name | Text | /MaxLen 60 preserved |
email, phone, dob | Text | Plain text fields — no email or date validation carried over |
party_size | Text | Initial value 2 preserved |
pw | Text | Password flag set — input is masked in the reader |
subscribe | Button | Checkbox, checked state preserved |
plan | Button | Radio group, correctly flagged; plus a second
plan.plan entry in the field dictionary |
country | Choice | Combo box |
skills | Choice | Multi-select flag set |
notes | Text | Multiline flag set |
token | Text | ⚠ the hidden input — see below |
doc | Text | File-select flag set |
unknown-5-14 | Button | The submit button, as a pushbutton with a generated name |
Two things there are worth your attention.
First, the
API
reference says appearance: auto is "supported for text inputs, check boxes, text
areas, and select only" — four types. The 69.0 build we measured produced considerably more than
four: radio groups, a password field, a file-select field and a pushbutton as well. That is a
pleasant surprise for radio buttons and an unpleasant one for the next paragraph. Either way,
measure your own form rather than reading the list.
The hidden input becomes a visible, editable field
This is the finding we did not expect and the reason to read the field dump rather than look
at the page. Our <input type="hidden" name="token" value="x"> came out as a
full-size text field — a 178.5 × 10.7 pt widget, placed on the page, editable,
carrying its value.
Hidden inputs are where applications keep CSRF tokens, record identifiers, tenant identifiers, signed state. Rendering a document that is supposed to be a form and shipping your session token into it as a visible, editable box is a bad afternoon. Nothing warns you; the flag is global, and it applies to controls you never thought of as controls.
The fix is one CSS rule, and it works — with it, the same document produces 14 fields and no
token:
/* Otherwise every <input type=hidden> becomes a visible, editable
text field in the PDF, carrying its value. */
input[type=hidden] { display: none; }
If you go the WeasyPrint route, audit what is in your form beyond the visible controls before you turn the flag on, and assert on the field names in CI, not just the count.
Doing the post-processing pass yourself
The other legitimate answer: render with Chromium as normal, then add the AcroForm fields
afterwards with a PDF library — pdf-lib in Node, pypdf or
reportlab in Python, iText in Java. The fields never have to come from the browser.
They only have to end up in the file.
Worth costing honestly, because it looks like an afternoon and is not. You need each control's position in PDF coordinates, which means measuring every control in the page before printing, converting CSS pixels to points, flipping the Y axis, and then working out how pagination moved everything — a control on page three is at a different offset than your DOM measurement suggests. Then the type mapping: checkbox appearance streams, radio groups sharing a parent field, select options as a choice field, font resources for text that does not exist yet. Then CJK, where the font a reader uses to display typed text has to be embedded or the field renders empty.
Call it a week to get right and a permanent maintenance line item. For a handful of fixed templates that never change, it is a perfectly good call and nobody can take it away from you.
The hosted and commercial options
Several vendors do this, and the reason it feels rare is that the four tools every migration article recommends mostly do not — not that the capability is scarce. The switch is named differently by every one of them, which is the other reason people conclude it does not exist:
| Product | How you turn it on |
|---|---|
| WeasyPrint | --pdf-forms · pdf_forms=True ·
CSS appearance: auto |
| PDFCrowd | setEnablePdfForms(true) |
| DocRaptor (and Prince) | CSS -prince-pdf-form: enable |
| IronPDF | CreatePdfFormsFromHtml = true |
| Snapdok API | pdf_forms: true |
Searching a vendor's documentation for "fillable" will often miss all of these. Search for "form" and read the rendering options list.
Note that IronPDF renders with Chromium and still produces form fields. That does not contradict the measurement above — it is the post-processing pass, done for you inside the library. That is the shape of every non-WeasyPrint answer here, ours included.
What ours did on the same form
Same file, same method, so you can hold it to the same standard. snapdok.io
produced 11 fields: the five text inputs plus the password, the checkbox with
its checked state, the radio group, both selects, and the textarea with its multiline flag.
maxlength survived as /MaxLen 60.
The two it did not convert are the interesting part. token (hidden) and
doc (file) were skipped and reported, each with a reason, rather
than turned into fields:
"skipped": [
{ "tag": "input", "type": "hidden", "name": "token", "reason": "unsupported-type" },
{ "tag": "input", "type": "file", "name": "doc", "reason": "unsupported-type" }
]
That is a deliberate difference of opinion rather than a feature: we think a hidden input is
not a control a person should be typing into, so it does not become one. The counts come back on
every render as headers, because the failure mode of this entire category is silence —
X-Form-Fields is how many were placed, X-Form-Skipped is how many were
not, so a template change that quietly kills your forms shows up as a number in your logs instead
of as a support email six weeks later.
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: 11
# X-Form-Skipped: 2
Free keys need no card — get one — and the documentation lists exactly which controls convert.
Where WeasyPrint beat us, from the same run
Two places, and they are real:
- Password inputs. WeasyPrint sets the PDF password flag, so the reader masks what is typed. Ours emits a plain text field — functionally fine, but it does not mask.
- Multi-select. WeasyPrint marks
<select multiple>with the multi-select flag. Ours emits an ordinary dropdown, so a reader will let you pick one option rather than several.
Both are on our list. If either matters to your documents today, WeasyPrint is more faithful today, it is free, and we would rather you knew that from us than found it after migrating.
How to check your own migration in one line
Whatever you pick, the thing that actually prevents this bug is asserting on structure instead of on appearance. A screenshot diff passes happily while every field in the document is dead:
// Node — fails loudly the day a template change kills the fields.
import { PDFDocument } from 'pdf-lib';
const n = (await PDFDocument.load(bytes)).getForm().getFields().length;
if (n !== 11) throw new Error(`expected 11 form fields, got ${n}`);
# Python
from pypdf import PdfReader
assert len(PdfReader('out.pdf').get_fields() or {}) == 11
Put the expected number in the test. A count of zero is the bug this whole page is about, and a count that silently drops from 11 to 9 is the same bug arriving quietly a year later. Assert on names as well as the number if your forms carry anything sensitive — that is what would have caught the hidden-input surprise above.
Picking one
- Your templates are static HTML — invoices, contracts, intake forms, most report generation: WeasyPrint. Free, self-hosted, no browser, genuinely good at print CSS, and the wkhtmltopdf maintainer's own first recommendation. Audit your hidden inputs first.
- Your templates need a browser — JavaScript-rendered content, a charting library, a design system built against Chrome's box model: Chromium plus a post-processing pass. Build it yourself if the templates are few and stable; buy it if they are not.
- You would rather not operate any of this: a hosted API, ours or somebody else's. Compare them on which controls convert and on whether they tell you what they skipped, not on the checkmark in the feature table.
Whichever you pick, do the inventory first. Read-only documents — receipts, statements, reports — migrate to anything at all, and most of what you generate is probably in that category. It is worth ten minutes to find out which documents are not.
Sources checked 2026-08-17: wkhtmltopdf usage docs · puppeteer#3646 · WeasyPrint changelog · WeasyPrint API reference · PDFCrowd · DocRaptor · IronPDF. Measurements are our own: Chromium 151.0.7922.34 via Playwright, WeasyPrint 69.0 with pypdf, and snapdok.io's renderer, all run against the form above on 2026-08-17.