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:

OptionFillable 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.

One column is deliberately missing: wkhtmltopdf itself. We did not install an archived binary with an unpatched 9.8 SSRF on a working machine to make a table look symmetrical. Its behaviour is quoted from its documentation above and nowhere else on this page do we make a per-control claim about 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:

FieldPDF typeWhat WeasyPrint 69.0 did
full_nameText/MaxLen 60 preserved
email, phone, dobText Plain text fields — no email or date validation carried over
party_sizeTextInitial value 2 preserved
pwTextPassword flag set — input is masked in the reader
subscribeButtonCheckbox, checked state preserved
planButtonRadio group, correctly flagged; plus a second plan.plan entry in the field dictionary
countryChoiceCombo box
skillsChoiceMulti-select flag set
notesTextMultiline flag set
tokenText⚠ the hidden input — see below
docTextFile-select flag set
unknown-5-14ButtonThe 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:

ProductHow 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 APIpdf_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:

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

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.