PDFPipe

Migrating from wkhtmltopdf / Already HTML and CSS

Migrating off wkhtmltopdf without changing what the pages look like

A command line tool built on a fork of an old browser engine, driven entirely by flags, where almost every flag you were passing has a different home on the other side.

What ports and what does not

The HTML ports. Most of the CSS ports. What does not port is the entire configuration surface, because wkhtmltopdf put page setup, headers, footers, margins and zoom in command line flags rather than in the document, and a modern renderer puts most of them back in CSS. So the migration is not one change, it is a decision per flag about whether it becomes a render option or a stylesheet rule. The other thing that does not port is the engine: wkhtmltopdf is built on a fork of a browser engine from around the middle of the last decade and is no longer actively maintained, so anything your templates avoided because it did not work will now work, and anything that only worked because of that engine's quirks will not.

What the call becomes

The binary invocation becomes one POST to /v1/pdf with html or url in the body. Flags split three ways. --page-size becomes options.format. --orientation Landscape becomes options.landscape. --margin-top and its siblings become options.margin, which takes either one value or an object with top, bottom, left and right. --print-media-type is the default here, and --no-print-media-type becomes options.media set to screen. --header-html and --footer-html become options.header_html and options.footer_html, which take markup rather than a file path. --zoom has no counterpart and becomes options.scale, which is not the same operation and is the flag most likely to change your layout. Everything else, and there was a lot of it, is either a stylesheet decision or gone.

Side by side

The old shape and the new one, with the parts that have no counterpart called out in comments rather than quietly omitted.

bash
# Before
wkhtmltopdf \
  --page-size A4 --orientation Portrait \
  --margin-top 20mm --margin-bottom 25mm \
  --header-html header.html --footer-html footer.html \
  --print-media-type \
  invoice.html invoice.pdf

# After
curl -X POST https://api.pdfpipe.xyz/v1/pdf \
  -H "Authorization: Bearer $PDFPIPE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<!doctype html>...",
    "filename": "invoice.pdf",
    "options": {
      "format": "A4",
      "landscape": false,
      "margin": { "top": "20mm", "bottom": "25mm", "left": "15mm", "right": "15mm" },
      "header_html": "<div style=\"font-size:8pt\">Invoice</div>",
      "footer_html": "<div style=\"font-size:8pt\"><span class=\"pageNumber\"></span></div>"
    }
  }'

# Note: --print-media-type was the default you were choosing explicitly.
# Print media is the default here, so that flag has no replacement: it is
# already what happens. It is options.media set to "screen" that is the
# deliberate act now.

The CSS delta, in both directions

What the old engine accepted that a current one does not, and what it refused that a current one wants. The second direction is the one people forget, and it is where the value of the migration is.

  • Flexbox and grid work now and did not before. That sounds like good news and it is the largest source of layout drift, because your templates are full of float and table-based workarounds that were written against an engine that had no alternative, and those workarounds now render in an engine that also supports the thing they were working around.
  • The legacy page-break-before, page-break-after and page-break-inside properties still work, but break-before, break-after and break-inside are the current spelling and the ones the rest of the documentation is written in. Leave the legacy ones alone during the migration and change them afterwards, so you are not debugging two things at once.
  • Anything relying on the old engine's handling of floats around page breaks will move. That engine had specific behaviour at a break that no current engine reproduces, and templates that were nudged until they looked right are nudged against something that no longer exists.
  • The -webkit- prefixed properties your templates carry are mostly unnecessary now and mostly harmless. The exception is any prefixed property that had a different meaning in the old fork than the unprefixed one has today.
  • wkhtmltopdf largely ignored the @page rule and took page setup from flags. A current renderer honours @page, so an @page block that was inert in your templates is now live, and if one is in there from an earlier attempt it will start applying.

What changes without anything erroring

The dangerous list. Each of these produces a different document and no diagnostic, so none of them are caught by a test that only checks the render succeeded.

  • Page count. Fonts, metrics and line breaking all differ between the two engines, so a document tuned to fit a number of lines per page will not fit the same number, and anything positioned relative to a page moves with it.
  • Font rendering, because wkhtmltopdf resolved fonts through the host system and a hosted renderer resolves them from your @font-face URLs. A template that named a font family installed on your build server will silently fall back.
  • Header and footer content, because wkhtmltopdf substituted its own placeholder variables into the header file and the substitution here is a different set of class names. A header that printed a page number by one mechanism prints nothing under the other, and nothing errors.
  • Anything that depended on JavaScript having finished. The old engine's window for scripts and the point at which a modern renderer captures the page are different, so a chart that was reliably present can become intermittently absent.

What to diff before cutting over

Render the same twenty documents through both, covering your longest and your shortest, and compare page counts first. That single number catches most of the drift and it takes a script rather than an eye. Then compare the extracted text of each pair, which catches missing content without you having to look at every page. Only then compare visually, and start with the pages the page-count diff flagged.

The one thing that always breaks

The header and footer. Every wkhtmltopdf migration breaks them, because the old tool took a file path and substituted its own variables into it, and header_html here takes markup and substitutes a different set of class names. The header renders, it just renders without the page number, and because something appears in roughly the right place it survives review and ships.

Frequently asked

Do I have to rewrite my templates to leave wkhtmltopdf?

No. The templates are HTML and CSS and they carry across. What needs attention is the delta above: the places the two engines disagree, and the configuration that used to live outside the document and now lives inside it, or the other way round.

Can I run both for a while?

Yes, and it is the safest way to do it. Put both behind one internal function that takes your data and returns bytes, switch on an environment variable, and run the new path on real traffic while the old one still serves. You get a diff on real documents rather than on fixtures, and you keep a way back that does not involve a deploy.

What about the documents already generated?

Nothing here changes them. They are files that already exist. What is worth deciding before the cutover is whether a regenerated document has to match the original byte for byte or merely say the same thing, because for anything with a legal or audit character the answer is usually to keep the original file rather than to be able to reproduce it.

Other migrations

The nearest neighbours first, then others that started from the same kind of tool, because the model matters more than the language.

Render one of your existing documents through the playground before changing any code. That comparison is the whole of the risk assessment for this migration.