Python has four common ways to turn HTML into a PDF, and they differ far more than their one-line examples suggest: two of them render modern CSS correctly, two of them quietly do not. We put one invoice — a flexbox header, a CSS grid for the two addresses, a 45-row table that runs onto a second page, a web font, and page numbers set in CSS — through all four and compared the output and the time per PDF.
How we tested. On 2026-11-08, in the official mcr.microsoft.com/playwright/python:v1.64.1-noble image (Ubuntu 24.04), with WeasyPrint 70.0, xhtml2pdf 0.2.28, pdfkit 1.0.0 with wkhtmltopdf 0.12.6, and Playwright 1.64.1 with its Chromium 156. Each library rendered the same file five times; times are medians on a busy shared machine, so treat them as rough. Playwright's time is for an already-open browser.
What came out
| WeasyPrint 70 | xhtml2pdf 0.2.28 | pdfkit / wkhtmltopdf 0.12.6 | Playwright 1.64 (Chromium) | |
|---|---|---|---|---|
| Needs a browser | No | No | No (bundled old WebKit) | Yes |
| Flexbox header | Yes | Yes | Yes | Yes |
| CSS grid (two address columns) | Yes | No — stacked | No — stacked | Yes |
| Table header repeated on page 2 | Yes | Yes | No | Yes |
"Page 1 of 2" from @page margin box |
Yes | No | No | Yes |
Web font from @font-face |
Yes | Yes | Yes | Yes |
| Page size honoured | A4 | A4 | A4, but content scaled down | A4 |
| Rows on page 1 | 22 | 22 | 36 (shrunk) | 22 |
| Time per PDF | 515 ms | 83 ms | 259 ms | 30 ms |
| File size | 18 KB | 23 KB | 25 KB | 69 KB |
xhtml2pdf said so itself: "Ignoring CSS properties xhtml2pdf does not implement: border-collapse, grid-template-columns". wkhtmltopdf applies "smart shrinking" by default, so it fitted 36 rows on page 1 in smaller type; its page 2 started straight with a row, without the header.
The four, briefly
WeasyPrint
import weasyprint
weasyprint.HTML(filename="invoice.html").write_pdf("invoice.pdf")
A layout engine written for print, with no browser. It handled everything in our test, including CSS grid and the page-number margin box. Two practical notes: it needs Pango from the system (libpango-1.0-0, libpangoft2-1.0-0 on Debian and Ubuntu), and it picks fonts through fontconfig — in our image the generic sans-serif resolved to a CJK font, so name your fonts explicitly with @font-face. It was the slowest of the four here. WeasyPrint documentation.
xhtml2pdf
from xhtml2pdf import pisa
with open("invoice.pdf", "wb") as f:
pisa.CreatePDF(open("invoice.html").read(), dest=f)
Pure Python on top of ReportLab, fast and easy to install, but its CSS is old: no grid, no border-collapse, no page-margin boxes. Fine for simple, table-based documents you write for it; a poor fit for HTML designed for browsers.
pdfkit (wkhtmltopdf)
import pdfkit
pdfkit.from_file("invoice.html", "invoice.pdf", options={"enable-local-file-access": None})
pdfkit is a thin wrapper around the wkhtmltopdf command, which renders with an old Qt WebKit. The project's GitHub repository has been archived since 2022; in our test it missed grid, header repetition and page numbers, and shrank the page. Many older tutorials still recommend it — for new projects, choose one of the others.
Playwright (Chromium)
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("file:///path/to/invoice.html")
page.pdf(path="invoice.pdf", format="A4", print_background=True, prefer_css_page_size=True)
browser.close()
A real browser, so CSS behaves exactly as in Chrome — grid, flexbox, web fonts — and current Chromium also prints @page margin boxes: our "Page 1 of 2" came out. It was the fastest per document once the browser was running; the price is a browser to download and keep updated, and the memory of a Chrome process. print_background=True is needed for coloured backgrounds; Playwright's page.pdf lists the options.
The CSS that made our test document print well
Whatever the library, a few rules decide how a multi-page document breaks. These were in our test file, and WeasyPrint and Playwright honoured all of them:
@page { size: A4; margin: 18mm 16mm 22mm; }
@page { @bottom-center { content: "Page " counter(page) " of " counter(pages); } }
thead { display: table-header-group; } /* repeat the header row on every page */
tr { break-inside: avoid; } /* never split a row across pages */
Add break-before: page to an element that must start a new page (a terms section, an appendix). Set the page size in CSS rather than in each library's options, and pass prefer_css_page_size=True to Playwright, so every tool uses the same size.
Which one to use
- Modern CSS, designed in a browser: Playwright. What you see in Chrome's print preview is what you get.
- No browser allowed, print-quality layout: WeasyPrint. Best CSS paged-media support of the non-browser options.
- Simple tables, minimal dependencies: xhtml2pdf, if you write the HTML for it.
- wkhtmltopdf: only to keep an old system running.
Whichever you use, test with your longest document: page breaks inside table rows, headers on following pages and fonts are where the libraries part ways.
One-page documents without running anything
If the PDF fits on one page — a receipt, a certificate, a ticket, a short invoice — Shareglint renders an HTML template to a one-page vector PDF from a single HTTP request, in Chromium, with no library or browser to install:
import requests
r = requests.post(
"https://shareglint.com/v1/images?wait=true",
headers={"Authorization": "Bearer sk_live_..."},
json={"template": "invoice-a4", "format": "pdf", "modifications": {"number": "2026-0412", "total": "€1,200.00"}},
timeout=60,
)
pdf_url = r.json()["url"]
Here invoice-a4 is a template you save once in your account (an HTML invoice template to start from). The limit is real: our PDFs are always one page, the template's size, with no page breaks, repeated headers or page numbers. Our 45-row test invoice is exactly the kind of document that needs one of the libraries above; PDF invoices explains where the line is.