Skip to content

PdfBroker.io runs both wkhtmltopdf and WeasyPrint as hosted services, so we see a lot of templates move from one to the other. The failures are consistent, and they are mostly not where people expect. This article covers what actually breaks when an existing wkhtmltopdf template is rendered by WeasyPrint, with the before-and-after for each case.

The surprise for most teams is that the body of the document usually survives. Tables, headings, typography, floats — those come across with minor tweaks. What does not come across is everything that was never CSS in the first place.

In wkhtmltopdf, page furniture lives outside the document. You pass --header-html header.html and --footer-html footer.html on the command line, or use --header-left, --header-center and --header-right for plain text, tuned with --header-spacing and --header-line. wkhtmltopdf loads those as separate documents and paints them into a reserved strip on every page.

WeasyPrint has no equivalent, because it does not need one. Page furniture is CSS:

@page {
  size: A4;
  margin: 25mm 20mm 20mm 20mm;

  @top-center {
    content: "Invoice";
    font-size: 9pt;
    color: #666;
  }

  @bottom-right {
    content: counter(page) " / " counter(pages);
    font-size: 9pt;
  }
}

WeasyPrint implements the full set of page margin boxes, along with the :left, :right, :first and :blank page selectors — so a different header on the first page is a rule, not a second HTML file and a conditional in your calling code.

For anything richer than a text string, use a running element. The markup stays in the document and CSS lifts it into the margin:

.letterhead { position: running(letterhead); }

@page {
  @top-left { content: element(letterhead); }
}
<div class="letterhead">
  <img src="logo.png" alt="Contoso AB">
  <span>Org.nr 556000-0000</span>
</div>

This is the single largest piece of work in most migrations. Budget for rewriting the header and footer rather than porting them.

Page numbers move from string substitution to counters

wkhtmltopdf substitutes variables into header and footer HTML: [page], [topage], [frompage], [section], [date], [title] and a handful of others. The usual implementation reads them off the query string that wkhtmltopdf appends to the header document:

<script>
  document.addEventListener('DOMContentLoaded', function () {
    var vars = {};
    location.search.substring(1).split('&').forEach(function (pair) {
      var kv = pair.split('=');
      vars[kv[0]] = kv[1];
    });
    document.getElementById('page').textContent = vars.page;
    document.getElementById('topage').textContent = vars.topage;
  });
</script>
<div>Page <span id="page"></span> of <span id="topage"></span></div>

In WeasyPrint that whole file becomes one declaration:

@bottom-center {
  content: "Page " counter(page) " of " counter(pages);
}

Worth noting what this fixes as well as what it costs you. [topage] in wkhtmltopdf counts the pages of the current document in a multi-document render, which trips people up when concatenating. counter(pages) is the page count of the rendered document, which is almost always what the person writing the template meant.

Nothing runs

WeasyPrint does not execute JavaScript. There is no equivalent of --javascript-delay, no --enable-javascript, no waiting for a chart library to finish drawing. This is the difference that decides whether a migration takes an afternoon or a sprint.

Go through your templates and find every place the DOM is built or modified after load:

  • Charts drawn by Chart.js, ApexCharts or similar. Render them server-side to SVG or PNG and pass the result in.
  • Tables populated by fetch from an API. Fetch the data before rendering and put it in the HTML.
  • Date and currency formatting done in the browser. Format it in C# — ToString("N2", culture) — where you have the culture properly available anyway.
  • QR codes and barcodes generated client-side. Generate them as SVG server-side.

Charts are the one that most often turns into a real project. Everything else on that list is usually an improvement: doing the formatting in your application means it is testable, which it was not when it lived in a <script> tag inside a template.

If a template genuinely needs a live browser — a third-party embed you cannot pre-render — WeasyPrint is the wrong target and you should stay on wkhtmltopdf or move to headless Chromium instead. That is a real answer, not a fallback.

Layouts tuned against --zoom will shift

wkhtmltopdf renders through a viewport, with --zoom (default 1) and --dpi (default 96) available to nudge the result. Templates that have been in production for a few years often carry a zoom factor that someone arrived at by trial and error, plus pixel dimensions that only look right at that factor.

WeasyPrint treats px as 1/96 inch, per the CSS specification, and has no zoom knob. Expect a first render that is close but wrong — usually slightly too large if the old template ran at a zoom below 1.

The fix is to stop specifying page geometry in pixels. Use mm or pt for anything that relates to the paper, keep px for hairlines and borders where it does not matter, and let @page { size: A4; margin: … } own the page box rather than a wrapper div with a fixed width.

What you get back

Some things that were awkward or impossible under QtWebKit work properly in WeasyPrint. The notes below refer to 69.0, which is what PdfBroker.io runs.

Custom properties and var() are supported, so a template can have a colour palette at the top instead of a hex code repeated forty times. So are all the mathematical functions, calc() included, along with logical properties and viewport units.

Fragmentation control actually holds. break-inside: avoid on a table row, break-after: avoid on a heading so it does not strand itself at the foot of a page, orphans and widows — these were unreliable in wkhtmltopdf, particularly inside tables and floats, and they are the reason many teams gave up on multi-page layout entirely.

tr, figure { break-inside: avoid; }
h2 { break-after: avoid; }
p { orphans: 3; widows: 3; }

And PDF/A and PDF/UA output becomes possible, which wkhtmltopdf cannot produce at all. If the European Accessibility Act applies to your documents, that is on its own a sufficient reason to move. See the PDF variants reference for the conformance levels.

Where WeasyPrint stops short

Flexbox is implemented — all the flex-*, align-*, justify-* and order properties, plus the flex and flex-flow shorthands — but the documentation is candid that the module "works for simple use cases but is not deeply tested". Treat a flex-heavy layout as something to verify page by page rather than assume.

Grid works for straightforward cases: display: grid, line names, grid areas, flexible lengths. The documented gaps include inline-grid, subgrids, and the auto-fill and auto-fit repeat functions, so a template built around repeat(auto-fit, minmax(…)) needs rewriting with explicit tracks.

If you are on an older WeasyPrint than 69, check the version's own feature list before relying on any of this. calc(), logical properties and viewport units are all recent additions, and a template that renders here may not render on a 67 install.

Images and fonts

Both engines on PdfBroker.io take resources the same way, so this part of a template does not change. Send files alongside the HTML and reference them by name:

{
  "htmlBase64String": "PCFET0NUWVBFIGh0bWw...",
  "resources": {
    "logo.png": "iVBORw0KGgoAAAANSUhEU..."
  },
  "weasyPrintToPdfArguments": {
    "pdf-variant": "pdf/a-2b"
  }
}
<img src="logo.png" alt="Contoso AB">

Base64 data URIs in src work too, but they make the template unreadable and you lose the ability to swap an image per request. The resources documentation covers both.

Switching the call

If you already render through PdfBroker.io, the API change is the endpoint and the arguments object:

public async Task<byte[]> RenderAsync(string htmlBase64, CancellationToken ct = default)
{
    using var response = await _http.PostAsJsonAsync("api/pdf/weasyprint", new
    {
        HtmlBase64String = htmlBase64,
        WeasyPrintToPdfArguments = new Dictionary<string, string>
        {
            ["pdf-variant"] = "pdf/a-2b"
        }
    }, ct);

    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsByteArrayAsync(ct);
}

Keeping this behind an interface pays for itself during the migration, because you can run both engines against the same template and diff the output while you work.

An order that works

Do the header and footer first, on one representative document. It is the largest piece and it tells you early whether the rest is going to be easy.

Then remove the JavaScript, template by template, moving the work into your application. Do this while wkhtmltopdf is still rendering in production — pre-rendered charts and server-side formatting work fine in both engines, so nothing has to switch over yet.

Page geometry last, once the content is stable. Chasing millimetres while the header is still moving wastes a day.

Render both engines side by side for a release or two before you drop the old path. Templates have edge cases that only appear on the invoice with fourteen line items.