Guides · reference
Every wkhtmltopdf flag and its 2026 equivalent — and the five defaults that flip on you silently
Published 2026-08-17 · the left column is taken verbatim from the shipped usage documentation; the right columns were checked against the shipped type definitions and the tools' own help output, and the behavioural claims were measured on the day of writing.
You have a command line with a dozen flags on it, it has worked since 2017, and now you have to write the same thing against something that is still maintained. Most of the flags have an equivalent. A handful do not. The ones that will actually cost you a Friday are neither: they are the flags you did not pass, because wkhtmltopdf's default and the replacement's default disagree.
Three things before the table, because they change how you read it:
- The shipped usage documentation lists 123 long options — seven of which
only print help, licence or man-page text. Playwright's
page.pdf()in 1.62.1 takes 15. That gap is not a feature deficit so much as a change of address: most of what wkhtmltopdf spelled as a flag is now CSS, or a call you make on the page before you print it. - WeasyPrint's answer to most of the geometry flags is "that is CSS". It
has no page-size, orientation or margin options at all — they live in
@page, which is where the standard put them. If your templates are yours to edit, that is less work than it sounds. - Five defaults flip. We measured them rather than listing them, because every one produces a PDF that renders, uploads and looks approximately right in a thumbnail — which is how they reach production.
The five that flip: measured, not remembered
Start here, because a literal port of the command line is what nearly everybody writes first, and it is wrong in five ways at once:
# What you have
wkhtmltopdf \
--page-size A4 \
--orientation Portrait \
--margin-left 10mm --margin-right 10mm \
--javascript-delay 500 \
https://app.example.com/invoice/42 invoice.pdf
# The obvious three lines of Playwright
const page = await browser.newPage();
await page.goto('https://app.example.com/invoice/42', { waitUntil: 'networkidle' });
const bytes = await page.pdf({ format: 'A4' });
We ran one probe document through that, and through a corrected version, and read the
results back out of the PDFs instead of eyeballing them. The document is deliberately
obnoxious — dark background, one heading, and a pair of paragraphs that only appear under
@media screen and @media print respectively:
<!doctype html><html><head>
<meta charset="utf-8"><title>Flag map probe document</title>
<style>
html,body{margin:0;padding:0}
body{background:#101010;color:#ffffff;font:14px/1.4 Helvetica,Arial,sans-serif}
.screen-only{display:none}
.print-only{display:none}
@media screen { .screen-only{display:block} }
@media print { .print-only{display:block} }
</style></head><body>
<h1>PROBE-HEADING</h1>
<p class="screen-only">MEDIA-SCREEN</p>
<p class="print-only">MEDIA-PRINT</p>
<p>BODYTEXT-ANCHOR</p>
</body></html>
and the four readers are all command-line tools, so you can repeat this on your own template in about five minutes:
# background: is the page centre still dark?
pdftoppm -gray -r 12 -singlefile out.pdf ras # then read the centre pixel of ras.pgm
# margins: where does the first glyph actually start, in points?
pdftotext -bbox out.pdf - # <word xMin=... yMin=...>
# media type: which of the two paragraphs survived?
pdftotext out.pdf - | grep -o 'MEDIA-[A-Z]*'
# outline: is there a /Outlines entry in the catalog?
node -e "…PDFDocument.load(b).catalog.get(PDFName.of('Outlines'))"
Chromium 151.0.7922.34 driven by Playwright 1.62.1, WeasyPrint 69.0, on 2026-08-17:
| Behaviour | wkhtmltopdf 0.12.6 | Playwright page.pdf() | What we measured on the naive port |
|---|---|---|---|
| Paper size | A4 (documented default) | Letter | page.pdf({}) → 612 × 792 pt. If you did not pass format, your
European invoices are now US Letter. |
| Backgrounds | printed — --background is the default |
printBackground: false |
Centre pixel grey 255 (white) instead of 16. The dark panel, the zebra striping and the coloured status chip are simply gone. |
| Left / right margin | 10 mm (documented default) | 0 | First glyph at x = 0.0 pt instead of 27.7 pt. Text runs into the paper edge and printers clip it. |
| Media type | screen — --no-print-media-type is
the default | print, always | The @media print paragraph appeared and the @media screen one
vanished. Every @media print { display:none } rule you ever wrote for the
browser now fires. |
| PDF outline / bookmarks | on — --outline is the default,
depth 4 | outline: false |
/Outlines absent. Nobody notices until a reader opens the sidebar on a
90-page document. |
page.pdf() uses print media and offers no option to change it — you call
page.emulateMedia({ media: 'screen' }) first. WeasyPrint takes
-m screen and also defaults to print.The corrected port, which is what the four lines should have been:
const bytes = await page.pdf({
format: 'A4', // wkhtmltopdf defaults to A4; page.pdf() defaults to Letter
printBackground: true, // --background was on by default
margin: { top: '10mm', right: '10mm', bottom: '10mm', left: '10mm' },
outline: true, // and see below: this one needs `tagged` too
tagged: true,
});
// Screen media, which is what wkhtmltopdf gave you unless you passed
// --print-media-type. page.pdf() uses print media whatever you do,
// so this has to be said out loud, before the render:
await page.emulateMedia({ media: 'screen' });
Two behaviours the type definitions do not tell you
outline: true on its own does nothing
Playwright documents the option as "whether or not to embed the document outline into the
PDF. Defaults to false." Setting it to true did not produce an
outline. Setting it together with tagged: true did. Same page, same run, four
combinations:
page.pdf({ format: 'A4' }) -> /Outlines absent
page.pdf({ format: 'A4', outline: true }) -> /Outlines absent <-- surprise
page.pdf({ format: 'A4', tagged: true }) -> /Outlines absent
page.pdf({ format: 'A4', outline: true,
tagged: true }) -> /Outlines PRESENT
So if you are replacing wkhtmltopdf's default-on --outline, budget for the
tagged-PDF pass as well. That is not a bad trade — a tagged PDF is the accessible one, and
wkhtmltopdf could not produce it at all — but it is a second flag, and the failure mode of
forgetting it is silence.
A stray @page rule beats the landscape option
preferCSSPageSize is documented as the switch that gives CSS priority over
format. It governs the size. It does not govern the orientation,
which the CSS wins regardless, in both directions:
page.pdf({ format: 'A4' }) 595.9 x 842.9 portrait
+ <style>@page{size:A5 landscape}</style> 842.9 x 595.9 LANDSCAPE A4
+ <style>@page{size:landscape}</style> 842.9 x 595.9 LANDSCAPE A4
+ <style>@page{size:A5}</style> 595.9 x 842.9 portrait
page.pdf({ format: 'A4', landscape: true })
+ <style>@page{size:portrait}</style> 595.9 x 842.9 PORTRAIT
page.pdf({ format: 'A4', preferCSSPageSize: true })
+ <style>@page{size:A5 landscape}</style> 595.0 x 420.0 A5 landscape
Read the third line: @page { size: landscape } turned an A4 portrait render
landscape with preferCSSPageSize off, and the fifth line shows
@page { size: portrait } overriding an explicit landscape: true.
wkhtmltopdf's -O was authoritative; here the stylesheet has a vote. If a page
comes out rotated after migration, grep the CSS for @page before you touch the
render options.
The map, by what you were trying to do
Page geometry
| wkhtmltopdf | Playwright / Puppeteer | WeasyPrint |
|---|---|---|
-s, --page-size A4 | format: 'A4' (default Letter) |
@page { size: A4 } |
--page-width / --page-height |
width / height, units in the string |
@page { size: 210mm 297mm } |
-O, --orientation Landscape | landscape: true —
but see above, CSS wins | @page { size: A4 landscape } |
-T -B -L -R, --margin-* | margin: { top, right, bottom, left },
default 0 | @page { margin: 20mm 10mm } |
--zoom <float>, default 1, unbounded |
scale, clamped to 0.1–2. Outside that Chromium
rejects the call: Protocol error (Page.printToPDF): scale is outside of [0.1 - 2] range |
CSS zoom / rem-based sizing; the CLI has no scale option |
-d, --dpi, default 96 |
No equivalent. CSS pixels are the unit; use scale or real CSS lengths. |
No page DPI; -D caps image resolution |
--disable-smart-shrinking |
Nothing to disable — the shrink-to-fit behaviour that made wkhtmltopdf's
pixel/DPI ratio non-constant does not exist here. preferCSSPageSize and
scale are the levers. | Same: no such behaviour |
--viewport-size | page.setViewportSize() before
the render | No viewport concept; layout is the page box |
What gets painted
| wkhtmltopdf | Playwright / Puppeteer | WeasyPrint |
|---|---|---|
--background (default) / --no-background |
printBackground, default false |
Painted; suppress with CSS if you want it gone |
--print-media-type (off by default) |
Print media always; page.emulateMedia({ media: 'screen' }) to go back |
-m, --media-type, defaults to print |
--images / --no-images |
page.route() aborting image requests |
Custom URL fetcher, or CSS img { display: none } |
--image-dpi 600 / --image-quality 94 |
No equivalent — images are embedded as fetched | -D, --dpi and -j, --jpeg-quality, plus
--optimize-images |
-g, --grayscale |
No equivalent in either. Post-process:
gs -sDEVICE=pdfwrite -sColorConversionStrategy=Gray -dProcessColorModel=/DeviceGray,
or a CSS filter: grayscale(1) on html if approximate is enough. | |
-l, --lowquality, --no-pdf-compression |
No equivalent | --uncompressed-pdf is the debug direction only |
--user-style-sheet <path> |
page.addStyleTag({ path }) | -s, --stylesheet |
--minimum-font-size | No page.pdf option; a Chromium launch argument exists but we did not test it, so treat this as unconfirmed | No equivalent |
-e, --encoding | Serve a correct
Content-Type: text/html; charset=…, or use page.setContent() |
-e, --encoding — a direct match |
JavaScript and waiting
| wkhtmltopdf | Playwright / Puppeteer | WeasyPrint |
|---|---|---|
-n, --disable-javascript | context({ javaScriptEnabled: false }) |
Does not run JavaScript at all — that is the engine, not an option |
--javascript-delay <msec>, default 200 |
waitUntil: 'networkidle', page.waitForSelector(), or
page.waitForFunction() — wait for the thing, not for a guess at the clock |
n/a |
--window-status <s> |
page.waitForFunction(() => window.status === 'ready') — the same trick,
and it still works | n/a |
--run-script <js> | page.evaluate() after load,
or page.addInitScript() before it | n/a |
--stop-slow-scripts (default) | Navigation and render timeouts | n/a |
--load-error-handling abort|ignore|skip |
Check the Response object page.goto() returns and decide yourself
— no policy switch, which is more code and less surprise |
--fail-on-http-errors |
--debug-javascript | page.on('console', …) and
page.on('pageerror', …) | n/a |
Headers and footers — the family that costs the most
wkhtmltopdf gave you nineteen options here, including --header-html, which
loads a whole HTML document. Chromium gives you two template strings, and they come with two
restrictions Playwright states plainly and everybody discovers anyway: script tags inside
templates are not evaluated, and page styles are not visible inside templates.
That second one has a specific, measurable failure mode. A template that relies on a class from the page's own stylesheet is not "ignored" — it renders, at Chromium's fallback size, which is far too small to see:
displayHeaderFooter: true, margin: { top: '20mm', bottom: '20mm' }
headerTemplate: '<div class="brandbar">HDRTEXT</div>'
page stylesheet says .brandbar{font-size:22px}
-> text IS in the PDF, glyph box height 0.8pt (unreadable)
headerTemplate: '<div style="font-size:12px">HDRTEXT</div>'
-> glyph box height 9.0pt (fine)
headerTemplate: '<div style="font-size:22px">HDRTEXT</div>'
-> glyph box height 16.5pt
A 0.8 pt glyph box is ink on the page, so it will not show up as missing in a diff of
the text layer; it looks like a hairline smudge in the margin. The fix is to inline every
style the template needs, and to remember that displayHeaderFooter turns on
both slots — Chromium's stock header is a date line and its stock footer is the URL,
so pass an empty <span></span> for the one you did not want.
| wkhtmltopdf | Playwright / Puppeteer | WeasyPrint |
|---|---|---|
--header-html <url> |
headerTemplate — a string, not a URL; inline all styling |
@top-left / @top-center / @top-right margin boxes in @page |
--header-left/-center/-right <text> |
One template; place the three parts yourself | Three separate margin boxes with content: |
--footer-* (same six) | footerTemplate |
@bottom-left / @bottom-center / @bottom-right |
[page] / [topage] |
<span class="pageNumber"> / <span class="totalPages"> |
counter(page) / counter(pages) |
[date], [title], [webpage] |
<span class="date">, <span class="title">, <span class="url"> |
string-set + content: string(…) |
[section] / [subsection] |
No equivalent — compute it and inject it per page range, or give up and use a static header | string-set: chapter content() on the heading, then
content: string(chapter) in the margin box. This is the one place WeasyPrint is
clearly ahead of Chromium. |
--header-spacing, --header-line |
Margin size plus a border in the template | Margin box padding and border-bottom |
--header-font-name/-size, default Arial 12 |
Inline CSS in the template — required, see the measurement above | Normal CSS on the margin box |
--replace <name> <value> |
Build the template string in your own code — it is a string | Same, or string-set |
Document structure — where the object model went
wkhtmltopdf's command line was not only flags: it had objects. You could write
wkhtmltopdf cover c.html toc page a.html page b.html out.pdf and get a cover sheet,
a generated table of contents and two documents in one file. Nothing in the replacement set
has that shape.
| wkhtmltopdf | 2026 |
|---|---|
toc object, --toc-header-text,
--toc-level-indentation, --toc-text-size-shrink,
--disable-dotted-lines, --xsl-style-sheet |
No equivalent in Playwright. WeasyPrint builds one from CSS
generated content with target-counter() for the page numbers, which is a real
answer but a rewrite. In Chromium you generate the TOC markup yourself, which means you
need the page numbers, which means rendering twice. |
cover object |
Render the cover separately and concatenate — pdf-lib's
copyPages(), pdftk, or qpdf --empty --pages. |
Multiple page objects in one call |
Same: render each, then merge. |
--outline (default), --outline-depth 4 |
outline: true plus tagged: true; no depth
control. WeasyPrint emits an outline by default — we measured it present with no flags
at all. |
--page-offset, --exclude-from-outline |
No equivalent. Post-process, or restructure the document. |
--title <text> |
Comes from <title>; in our probe the PDF's /Title was
"Flag map probe document" in every renderer. To force a different one, set it with
pdf-lib afterwards. |
--copies, --collate |
Not a rendering concern any more — that is the print job. |
Network, auth and file access
| wkhtmltopdf | Playwright / Puppeteer | WeasyPrint |
|---|---|---|
--cookie <n> <v>, --cookie-jar |
context.addCookies(); storageState is the jar |
Custom URL fetcher |
--custom-header, --custom-header-propagation |
extraHTTPHeaders on the context, documented as sent "with every
request" — so the propagation switch has no counterpart |
Custom URL fetcher |
--username / --password |
httpCredentials on the context | Custom URL fetcher |
-p, --proxy, --bypass-proxy-for,
--proxy-hostname-lookup |
chromium.launch({ proxy: { server, bypass, username, password } }) |
Standard HTTP_PROXY environment handling |
--ssl-crt-path, --ssl-key-path,
--ssl-key-password |
clientCertificates on the browser context |
Custom URL fetcher |
--post, --post-file |
No equivalent. Do the POST with your HTTP client and feed the response HTML to
page.setContent(), or make the endpoint accept GET. |
Feed it stdin |
--cache-dir | Persistent browser context, or an HTTP cache in front of the renderer | -c, --cache-folder |
--disable-local-file-access (default),
--enable-local-file-access, --allow <path> |
Covered at length in
the CVE-2022-35583 page, because this
is where the security ticket lives. Short version: the restrictive one was already the
default, so a wrapper passing --enable-local-file-access is a regression
rather than a configuration. On the replacement side, page.route() is the
in-process lever and WeasyPrint has --allowed-protocols, which restricts
schemes and not hosts — neither one substitutes for an egress boundary. | |
--disable-external-links, --disable-internal-links,
--keep-relative-links, --resolve-relative-links |
No equivalent in either. Link annotations are emitted from the markup;
strip or rewrite the hrefs before rendering if you need them gone. | |
Form fields
--enable-forms is the flag that does not map cleanly, and it needs more than a
table row, so it has its own page:
what replaces
wkhtmltopdf --enable-forms, where the same form goes through each engine and
the AcroForm fields are counted. The two-line summary: Chromium produces zero form fields no
matter what you pass it, and WeasyPrint produces them with --pdf-forms. The
related SVG flags map to CSS:
| wkhtmltopdf | 2026 |
|---|---|
--enable-forms / --disable-forms (default) |
Chromium: none. WeasyPrint: --pdf-forms /
pdf_forms=True / CSS appearance: auto. Hosted APIs: a request flag,
named differently by each. |
--checkbox-svg, --checkbox-checked-svg,
--radiobutton-svg, --radiobutton-checked-svg |
CSS. These existed because QtWebKit drew form controls badly; every current engine
styles input[type=checkbox] with ordinary rules, or you swap in a background
image. |
Things you gain, which no flag map will show you
A migration table only lists losses, which makes the move look worse than it is. Four capabilities on the other side have no wkhtmltopdf flag because wkhtmltopdf could not do them:
- Tagged, accessible PDFs.
tagged: truein Chromium,--pdf-tagsin WeasyPrint. If anything you produce is read by a screen reader or is subject to an accessibility requirement, this alone justifies the work. - PDF/A and PDF/UA output. WeasyPrint 69.0's
--pdf-variantaccepts seventeen compliance targets includingpdf/a-3bandpdf/ua-1. Archival compliance used to mean a second tool. - Page ranges.
pageRanges: '1-5, 8'. - A browser engine from this decade. Grid, flexbox, custom properties,
@font-facewith WOFF2,position: sticky, and JavaScript frameworks that render at all. The QtWebKit inside wkhtmltopdf has not been meaningfully updated since 2012 — most of the CSS your designers write today it simply does not implement.
If your templates are yours: the WeasyPrint shape
Two-thirds of the flag list above is page geometry and page furniture, and WeasyPrint's position is that all of it belongs in the stylesheet. That is either a rewrite or a relief, depending on whether you own the templates:
/* WeasyPrint has no --page-size, no --orientation, no --margin-*.
All four live in CSS, which is also where the browser wanted them. */
@page {
size: Letter landscape; /* --page-size Letter + --orientation Landscape */
margin: 25mm 5mm; /* --margin-top/-bottom --margin-left/-right */
@top-center { content: 'Quarterly report'; } /* --header-center */
@bottom-right { content: counter(page) '/' counter(pages); } /* --footer-right [page]/[toPage] */
}
Measured on 2026-08-17 with WeasyPrint 69.0 and no command-line options at all:
$ weasyprint page.html out.pdf # the @page block above, nothing else
$ pdfinfo out.pdf | grep 'Page size'
Page size: 792 x 612 pts (letter) <- landscape honoured
$ pdftotext -bbox out.pdf - | head
<word xMin="20.17" yMin="82.87" …> <- 25mm top margin honoured
In the same run its defaults matched wkhtmltopdf's more closely than Chromium's did: the background was painted, the outline was present, and the page carried a margin without being asked. What you give up is the browser — no JavaScript, and a CSS subset rather than Chromium's. For a server-rendered invoice that is often no loss at all. For a dashboard built in React it is the end of the conversation.
Where a hosted renderer fits, and what it costs
The third option is not running a renderer. Every hosted API in this space, ours included, exposes a small fraction of these 123 flags — that is the deal, and it is worth stating precisely rather than in marketing terms.
snapdok.io takes a JSON body and returns bytes. It renders with real
Chromium via Playwright, prints backgrounds by default, accepts header and footer templates
(reserving the margin for them automatically, because of the sliver problem above), and can
emit AcroForm fields with pdf_forms:
curl -X POST https://snapdok.io/v1/render \
-H "Authorization: Bearer $SNAPDOK_KEY" \
-H 'Content-Type: application/json' \
-d '{"url":"https://app.example.com/invoice/42",
"format":"pdf",
"wait_until":"networkidle",
"pdf_header":"<div style=\"font-size:10px;width:100%;text-align:center\">Invoice 42</div>",
"pdf_forms":true}' \
-o invoice.pdf
--page-size Legal --orientation Landscape --margin-top 15mm, the
Snapdok API does not currently express it and you want Playwright or WeasyPrint, both of
which do. And your HTML leaves your network, which is a decision to make deliberately for
documents assembled from customer data — the trade-off is discussed on
the security page. What you get instead is
that nobody on your team maintains a browser in a container.The short version
Most flags map. The geometry family moves into CSS, the waiting family becomes real waits
instead of a 200 ms guess, and the network family becomes browser-context options. The
table of contents and cover objects have no successor and turn into code you write.
--enable-forms and --grayscale have no equivalent in a Chromium
renderer at all.
But the flags that break production are the ones you never typed. Port the command line
literally and you get Letter paper with no backgrounds, no margins, no outline, and print
media applied to templates that were written against screen media. Set
printBackground, margin, format, outline with
tagged, and decide about emulateMedia deliberately — then diff a real
document, page by page, against one your current stack produced. Five minutes of
pdftotext -bbox is cheaper than one reprint run.
For the wider decision — which replacement, and what the security ticket actually says — see the migration guide and CVE-2022-35583 explained.
Two of the defaults above have a page of their own, because they cost people whole
afternoons once the port is finished:
why the PDF header does not show (five
causes, including the 1 px default font size inside header templates) and
why landscape: true is
ignored (a CSS @page rule outranks it, and
preferCSSPageSize is not the fix).
Sources, all checked 2026-08-17:
wkhtmltopdf 0.12.6
usage documentation (the left column, verbatim) ·
Playwright's
page.pdf() reference, cross-checked against the type definitions shipped in
playwright-core@1.62.1 ·
WeasyPrint
API reference and the weasyprint --help output of 69.0 ·
MDN on
@page.
Every measurement above is ours, taken with Chromium 151.0.7922.34 via Playwright 1.62.1 and
WeasyPrint 69.0, and reproducible with the probe document and the four command lines given
near the top. We did not install wkhtmltopdf to measure it: an archived binary carrying an
unpatched 9.8 does not go on a working machine to make a table symmetrical, so every claim in
its column comes from its own shipped documentation.