Most people arrive at this page because something forced them, not because they wanted a project. So: the migration is less dangerous than it looks, the danger is concentrated in four places, and which tool you land on depends on one question about your documents rather than on a comparison table.
Why you are here
Pick whichever of these applies.
- The maintainer said so. wkhtmltopdf's own status page says a 0.12.7 was planned for August 2020 to fix 0.12.6 regressions, and adds that there is "no deadline by when the last two points will happen, or even that they will be done at all". It points readers at WeasyPrint, the commercial Prince for reports, or Puppeteer for pages that need JavaScript. That is the project telling you to leave.
-
Your distro dropped it.
wkhtmltopdfis in Debian bookworm at 0.12.6-2, and it is not in trixie — it was removed from the archive rather than updated. If your base image tracks stable, an ordinary upgrade is the forcing function. -
An audit flagged it.
CVE-2022-35583,
published 2022-08-22, is an SSRF in 0.12.6: an
iframein the input HTML can reach internal addresses from wherever the renderer runs. There is no fixed upstream version, because there are no upstream versions. The status page's own wording is blunter than most security tooling: "Do not use wkhtmltopdf with any untrusted HTML."
That last one decides your urgency, so separate it from the general staleness. If every byte of HTML you render is built by your own code from your own data, the SSRF is a hardening issue and you can plan the migration calmly. If any part of the document comes from a user — a note field, a logo URL, a custom template — you have a live problem, and the migration is not the first thing to do. Sandbox or block outbound network from the renderer today, then migrate.
The good news, which nobody tells you
Your template is probably going to survive.
wkhtmltopdf renders with QtWebKit, an engine that predates most of what makes CSS hard to port. No grid. Flexbox nobody trusted. So whoever wrote your invoice template did it with tables and floats and fixed widths, because that is what worked — and tables and floats render identically in every modern engine you might move to. The usual porting nightmare, where a layout leans on a quirk of one renderer, mostly does not apply here. The quirks all ran the other way.
What does not carry across is a short and specific list.
1. Media type flips
wkhtmltopdf renders screen media unless you passed
--print-media-type. Chrome's page.pdf() renders
print media by default. If your CSS has any
@media print block — and templates that have been around a
while usually accumulate one — those rules were dormant under
wkhtmltopdf and switch on the moment you move.
Check this before anything else:
grep -rn '@media print\|@media screen' templates/
In Puppeteer, if you want the old behaviour while you port:
await page.emulateMediaType('screen'); // matches wkhtmltopdf's default
Fix the CSS properly later. Do not ship the compatibility flag and forget it.
2. Backgrounds disappear
wkhtmltopdf prints background colours and images unless told not to. Chrome does the opposite:
const pdf = await page.pdf({
printBackground: true, // without this, your header bar renders white
format: 'A4',
});
This is the single most common "the migration broke everything" report, and it is one line.
3. Headers and footers are a rewrite, not a port
wkhtmltopdf took whole HTML documents:
wkhtmltopdf --header-html header.html --footer-html footer.html \
--margin-top 25mm invoice.html invoice.pdf
Chrome takes template strings, and they live in their own tiny document that inherits none of your page CSS:
await page.pdf({
displayHeaderFooter: true,
headerTemplate: `<div style="font-size:9px; width:100%; padding:0 12mm;">
Invoice <span class="title"></span>
</div>`,
footerTemplate: `<div style="font-size:9px; width:100%; text-align:center;">
<span class="pageNumber"></span> / <span class="totalPages"></span>
</div>`,
margin: { top: '25mm', bottom: '18mm' },
printBackground: true,
});
Three things bite here, in this order. The templates default to roughly
10px font in Chrome's own styling, so an unstyled header looks like a
mistake — set font-size explicitly. They have zero
horizontal padding by default, so text sits flush against the paper edge.
And they are clipped to the margin box, so a header taller than
margin.top is silently cut off rather than pushing content
down.
The substitution classes are date, title,
url, pageNumber and totalPages,
and they only work on elements inside those templates.
WeasyPrint does this differently again, through CSS @page
margin boxes, which is closer to how a typesetter would think about it
and much nicer if your documents are genuinely page-oriented:
@page {
size: A4;
margin: 25mm 15mm 18mm 15mm;
@bottom-center { content: counter(page) " / " counter(pages); }
}
4. Fonts, again, in a new container
This one is not really a wkhtmltopdf problem, but the migration is when it bites, because you are rebuilding the image. A font that resolved on your laptop and in the old image will not resolve in the new one unless you install it. The symptom is not an error — it is a silent fallback, and it usually looks fine in English and terrible in anything with diacritics or CJK.
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-liberation fonts-noto-core fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
Then actually look at a rendered page with a non-ASCII name in it. Every team that skips this ships the bug.
If you reach wkhtmltopdf through a framework wrapper, read this first
The four items above assume you shell out to wkhtmltopdf
yourself. Plenty of teams do not — they reach it through a wrapper that
hides the binary, and in Ruby that is overwhelmingly
wicked_pdf,
usually paired with the wkhtmltopdf-binary gem so the
executable arrives as a dependency rather than as a
Dockerfile line. Laravel's Snappy and Python's
pdfkit sit in the same position.
This changes the shape of the migration in a way that is easy to miss
when you are costing it, because
the wrapper is doing two jobs and only one of them is
rendering. The other is letting you write the document in your
framework's own view language, with its helpers available. A
.pdf.erb is an ERB template: it has your conditionals, your
loops, your date formatting, your string helpers, your model objects.
That is most of why the wrapper was pleasant to use, and it is the part
nobody budgets for.
Swap the binary and you keep all of it. WeasyPrint, Chrome or Gotenberg all take HTML, so your view layer renders as it does today and only the last step changes. This is the cheap migration and for most wrapper users it is the right one.
Move to a hosted renderer with its own template language and you
lose it. Hosted APIs generally take a logic-less template —
Mustache, Handlebars, Liquid, Jinja — plus a JSON payload. Logic-less is
the point: the renderer will not execute arbitrary code from a template.
But it means every helper call in your view has to move somewhere. In ERB
terms, a <% if items.any? %> block becomes a section
tag and ports cleanly, while strftime,
to_sentence, pluralize,
number_to_currency and every custom helper in
app/helpers do not exist on the other side. They move into
whatever builds the JSON.
That is usually a small, dull job — you format the string in Ruby instead of in the template — but it is a job, it touches code rather than markup, and it is invisible if you cost the migration by looking at the template alone.
The one thing that goes the other way. Hosted renderers
typically forbid external resources: no <img src="https://…">,
no linked stylesheet, no webfont URL. Everything is inlined, images as
base64 data URIs. That sounds like a lot of work — except wicked_pdf
ships wicked_pdf_asset_base64 precisely because
wkhtmltopdf's own asset resolution is unreliable across environments, and
it is the conventional way to put a logo or a signature into a
.pdf.erb. Teams that took that advice have already inlined
their assets for their own reasons.
So before you price this, grep for it:
grep -rn 'wicked_pdf_asset_base64\|data:image/' app/views/
grep -rln '\.pdf\.erb$' app/views/ | xargs grep -c '<%='
The first tells you whether the asset work is already done. The second tells you how much logic is actually in your PDF views, which is the number that decides whether a logic-less template is a half-day or a fortnight. In the templates worth migrating it is usually a handful of interpolations and one conditional — these are documents, not applications.
If the renderer belongs to a platform rather than to you
There is a third position, and it is the one most likely to be costed wrong. You do not shell out to a binary and you do not call a wrapper. Your document is a document type inside a platform you build on — a shop system, an ERP, a practice-management suite — and the rendering step is a service that platform owns. The migration question changes completely, because you are not replacing a renderer. You are registering one.
The test, for any platform, before you cost anything: find the extension point and ask what it is handed. If it is handed data, a template API is available to you. If it is handed HTML, your choice is among converters and the only interesting question is which one.
Two ways to run that test wrong, and both of them cost you options rather than giving you extra ones. They are worth knowing because the mistake is invisible from inside: you get a clean, confident, wrong answer.
Read the interface, not the platform's own implementation
Shopware 6 is the cautionary example, and we wrote it up separately
because it takes a page of its own:
the document renderer is not handed
HTML. The short version is that every published example of a Shopware
format renderer declares a dependency on html, because every
published example is a format renderer and the platform's own PDF
implementation is the template everyone copies. Read three of them and you
will conclude the slot takes HTML. Read
AbstractDocumentRenderer and you find
getDependencies() defaults to an empty array, and the input
struct carries the order snapshot and every data provider's DTO. The HTML
only arrives if the renderer asks for it.
A vendor's own subclass is one point in the space the contract permits, and it is usually the point where the vendor already solved the problem their way — which is precisely the problem you are trying to solve differently. The contract is the extension point. The vendor's class is just the first tenant.
Run that test one level higher than feels natural
The first extension point you find is almost never the only one — and the first one you find is almost always the renderer, which takes HTML by definition. Finding it and stopping there will tell you your options are converters when they are not.
Sylius is the example that shows the difference, because it publishes both levels and they answer differently.
The lower level is
sylius/pdf-generation-bundle,
which describes itself as "a swappable PDF generation abstraction with
context-based adapter selection" and ships three adapters:
knp_snappy (wkhtmltopdf), dompdf, and
gotenberg. A custom adapter is one method:
#[AsPdfGenerationAdapter('my_adapter')]
final class MyCustomAdapter implements PdfGenerationAdapterInterface
{
public function generate(string $html): string { /* … */ }
}
string $html in, string out. That is the
renderer altitude, and it is a converter's socket.
The level above it is in the invoicing plugin, and it is a different shape:
interface InvoicePdfFileGeneratorInterface
{
public function generate(InvoiceInterface $invoice): InvoicePdf;
}
Handed the invoice — entity, line items, tax rows, billing data — and returning a filename and bytes. No HTML anywhere in the signature. The default implementation renders a Twig template and shells out through Snappy, but that happens inside the thing you are replacing, so a replacement never sees it.
And the swap is config, not a fork.
config/services/generators.xml registers the implementation
as sylius_invoicing. and aliases
the interface to it; the invoice creator depends on that one service.
Point the alias at your own class and every invoice the store emits
changes path:
services:
Sylius\InvoicingPlugin\Generator\InvoicePdfFileGeneratorInterface:
alias: app.generator.invoice_pdf_file
So on the same platform, at two altitudes, both answers are true. At the adapter you are choosing between converters. At the generator you can send data to a template API and the Twig file, the fonts and the binary all become unreachable code. Which one you are looking at decides your whole migration, and nothing in either interface's name tells you which you found.
Find the renderer slot. Then look up one level and ask whether anything above it is handed data. Only close the question when you have checked both.
Worth knowing if you are on Sylius specifically: this is
current, not legacy. The invoicing plugin's v2.3.0, released 8 October
2026, requires Sylius 2.x and still depends on
knplabs/knp-snappy-bundle — so a stock Sylius 2 store is
still generating invoices through wkhtmltopdf, with everything in "Why you
are here" waiting for it. The generator contract is unchanged from the 1.x
line, so the swap above applies to both.
And the honest part, since this section is about costing things
correctly. If what you want is to stop maintaining the binary,
you do not need us and you should not pay for us: dompdf is
pure PHP and gotenberg is a container with an HTTP API, both
already wired into that adapter bundle, both free, and your existing Twig
template keeps working untouched. That is the cheap, correct migration for
most Sylius stores, and the generator altitude is irrelevant to it. The
upper seam only earns its keep when the cost you are carrying is the
template itself — the page breaks that move when an address runs
to three lines, the per-country invoice variants, the font that renders
differently in CI than on a laptop. If that is not your pain, take the
adapter and go.
Where to go
The question that decides it is not "which renderer is best". It is:
Is your document a web page that happens to be printed, or a printed document that happens to be authored in HTML?
Almost every invoice, receipt, ticket, certificate and packing label is the second thing. Dashboards, exported reports with charts, and anything rendered from a live React view are the first.
If your documents are web pages that happen to be printed:
headless Chrome, via
Puppeteer
or Playwright.
It is what the wkhtmltopdf maintainer points at for JavaScript-heavy
input, and it is the only option that runs your JS. The cost is that you
now operate a browser: a few hundred MB of image, a process that leaks if
you reuse pages carelessly, a concurrency limit you will discover during
your busiest hour, and a Chromium version to keep patched. In serverless
it is worse — @sparticuz/chromium is tens of megabytes of
bundle before your own code, and cold starts are what you would expect
from unpacking a browser.
If your documents are printed documents:
WeasyPrint
is the first thing to try, and it is what the status page recommends
first. It implements the print CSS specs properly — @page,
margin boxes, running elements, page counters — rather than approximating
them, it has no browser in it, and the memory profile is in a completely
different class. It does not run JavaScript, which is a feature: if your
invoice needs JS to lay out, the invoice is the problem. Where it can
genuinely lose is very complicated tabular layout and some CJK
line-breaking, so render your ugliest real document before deciding.
If you need typesetting rather than rendering: Typst if you are willing to leave HTML behind, or the commercial Prince if you are not and you need industrial-grade page layout with support attached. Both are the right answer for long multi-section documents with cross-references, and both are the wrong answer for a one-page receipt.
If you want the browser but not the operations: Gotenberg wraps Chromium in an HTTP service you run yourself. It is a real improvement over embedding Puppeteer in your app — the browser lifecycle stops being your application's problem — but be honest about what it changes. You still own the container, the queue, the retry budget, the Chromium CVEs and the capacity planning. You have moved the problem across a network boundary, not off the estate.
If what you actually want is wkhtmltopdf, just not on your machines: Api2Pdf is the one service that still says the word out loud. Their homepage describes the product as "the tools you already trust — Headless Chrome, wkhtmltopdf, and LibreOffice — wrapped in one transparent, serverless REST API". Pricing, when we read it on 2026-09-20, was $1 a month plus usage, metered at $0.001 per MB of bandwidth and $0.00019551 per second of compute.
For a lot of readers of this page that is the shortest path there is. Your template already renders correctly under QtWebKit, because you spent years making it. Nothing in the four sections above applies if the engine does not change — no media-type flip, no missing backgrounds, no header rewrite, no font hunt. You swap a subprocess call for an HTTP call and you are done by lunchtime.
Two caveats, and the second one is the reason to check before committing.
The first is that this does not fix the thing that sent you here.
wkhtmltopdf is still unmaintained, CVE-2022-35583 is still unfixed
upstream, and the SSRF now runs on somebody else's network instead of
yours — which is better for you and no better for the person whose
internal address ends up in an iframe. You have bought time,
not a fix, and it is worth being clear with yourself about which one you
wanted.
The second is specific and we could not settle it from outside. The
endpoints Api2Pdf surfaces on its own homepage are
POST /chrome/pdf/html, POST /chrome/pdf/url and
POST /libreoffice/pdf. There is no wkhtmltopdf path
among them, even though the copy above it names the engine twice.
That may mean the route exists and is not featured, or that the engine
list is describing history. We do not know, and guessing on your behalf
would be worse than saying so. If wkhtmltopdf's specific
rendering is the thing you are paying to keep — the exact page breaks, the
exact table widths — ask them for the endpoint by name before you migrate
to it. If any hosted Chrome would do, the question does not arise and the
/chrome routes are right there.
Or you can not run a renderer at all, which is the option this site sells and the one we have the most obvious interest in, so treat the next section accordingly.
Where we fit, and where we do not
iReceipt PRO is a rendering API: a template plus JSON in, a file out, in PDF, JPG, PNG or WEBP. There is no browser pool, no font package, no Chromium version to track. The template lives with us and you send the values.
import { IReceiptPRO } from "@ireceipt.pro/js";
const irp = new IReceiptPRO(process.env.IRECEIPT_API_KEY);
const pdf = await irp.createPdfFromPrivateTemplate("your_template_id", {
invoice: { number: "13", total: "$2200.00" },
});
The five-minute quickstart is the honest way to evaluate that, because either it produces a file you are happy with in five minutes or it does not.
Do not use us if
- You render a handful of documents a day. Puppeteer and a cron job is genuinely the right answer at that volume and you should not pay anyone for this, us included.
- You need it offline or air-gapped. We are a cloud API. There is no version of us that runs inside your network, and if that is a requirement then WeasyPrint is your answer and the rest of this section is irrelevant.
- Your documents are long typeset output — hundred-page reports with cross-references and a table of contents. Typst, Prince and WeasyPrint are built for that and we are not.
- You need a document editor rather than a rendering call. Different product category.
- The layout has to live in your repository. Our templates live with us. For some teams that is the entire objection and it is a fair one.
-
Your PDF views are full of framework logic. Our
templates are HTML and CSS with Mustache, and Mustache is logic-less by
design — no JavaScript, no external resources, no helper calls. Per the
wrapper section above, that is the real cost of coming to us from a
.pdf.erbor a Blade view, and it is a code change rather than a template change. Count your interpolations before you believe anyone's estimate, ours included.
There is also a question you should ask us and every other hosted renderer before you send it a single invoice: what happens to the content you post — is it stored, where, and for how long. Ask it in writing and expect a specific answer, because you are about to send customer names and amounts to somebody else's infrastructure. If a vendor is vague about that, including us, that is your answer. Ours is on our privacy page: we do not collect or store information received by the API during use of the service.
A migration checklist that works whatever you pick
The destination matters less than doing this in an order that lets you back out.
- Snapshot before you touch anything. Render fifty real documents with the current pipeline — your ugliest ones, not your prettiest — and keep the PDFs. Include a long table that breaks across pages, a non-ASCII customer name, a zero-line invoice and your largest real document. This is the only artefact that will tell you whether the migration worked.
-
Diff visually, not byte-wise. Two renderers never
produce identical bytes. Rasterise both sides and compare images —
pdftoppmplus any image diff is enough, and it catches the page-break shift that a text diff will not. -
grepfor@mediafirst, per the media-type flip above. It is the change most likely to move every page break in the document at once. - Run both pipelines in parallel behind a flag for one full billing cycle. Month-end is when document pipelines fail, and it is the only load test that is actually representative.
- Keep the old renderer reachable until you have been through a month-end on the new one. Not in the request path — just installable.