# HTML to PDF in Python: WeasyPrint, xhtml2pdf, pdfkit and Playwright on the same invoice

> We put one invoice through WeasyPrint, xhtml2pdf, pdfkit (wkhtmltopdf) and Playwright: CSS grid, repeated headers, page numbers, fonts and time per PDF.

Published 2026-11-08 · https://shareglint.com/en/guides/html-to-pdf-python

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

```python
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](https://doc.courtbouillon.org/weasyprint/stable/).

### xhtml2pdf

```python
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)

```python
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)

```python
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`](https://playwright.dev/python/docs/api/class-page#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:

```css
@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:

```python
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](https://shareglint.com/en/guides/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](https://shareglint.com/en/use-cases/pdf-invoices) explains where the line is.

## FAQ

### What is the best library to convert HTML to PDF in Python?

For modern CSS, Playwright (a real Chromium) or WeasyPrint. In our test both laid out CSS grid, repeated the table header on page 2 and printed "Page 1 of 2" from CSS. xhtml2pdf ignored grid, and wkhtmltopdf — whose project is archived — ignored grid, shrank the page and did not repeat the header.

### Can I convert HTML to PDF in Python without a browser?

Yes: WeasyPrint and xhtml2pdf are pure Python plus system libraries, with no browser. WeasyPrint supports much more CSS, including @page rules and page-margin boxes; xhtml2pdf is lighter and older, and printed warnings for the CSS it does not implement.

### Is wkhtmltopdf still maintained?

No. Its GitHub repository is archived (last push 2022-11-22), and it uses an old Qt WebKit engine: in our test it ignored CSS grid and scaled the content down by default. pdfkit is only a Python wrapper around it.

### How do I add page numbers to a PDF from HTML?

With CSS: @page { @bottom-center { content: "Page " counter(page) " of " counter(pages); } }. In our test WeasyPrint and Playwright (Chromium 156) printed it; xhtml2pdf and wkhtmltopdf did not.
