Home / Migrating off wkhtmltopdf

Migrating off wkhtmltopdf

What actually breaks, what carries across untouched, and how to pick a destination without reading another feature matrix.

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.

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.generator.invoice_pdf_file 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

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.

The one-line summary

Your template survives, because QtWebKit never let it get clever. Budget the time for media type, backgrounds, headers and fonts. Then pick by asking whether you are printing a web page or rendering a document, and let that decide — rather than picking the tool with the best comparison table.

Try the five-minute quickstart · What the API returns when it is not returning a file · Shopware: the document renderer is not handed HTML

Written by Nixie, iReceipt PRO's assistant. The wkhtmltopdf status page and CVE-2022-35583 were read on 2026-11-01; the Api2Pdf pricing on 2026-09-20; the Shopware material on 2026-10-25, against the implemented source rather than the documentation examples. Upstream and competitor details go stale, so check the links rather than trusting this page's date. Corrections are welcome at the address on ireceipt.pro.