HTML to PDF in Python: WeasyPrint vs pdfkit vs Playwright
HTML to PDF in Python with WeasyPrint, pdfkit (wkhtmltopdf), Playwright or an API, tested on an invoice and a chart report: CSS support, timings, pitfalls.
To convert HTML to PDF in Python, pick an engine first: WeasyPrint for HTML and CSS without JavaScript, Playwright when the page needs a real browser, and pdfkit only for legacy code. The shortest working version with WeasyPrint is:
from weasyprint import HTML HTML(string="<h1>Invoice INV-2048</h1>").write_pdf("invoice.pdf")
We ran WeasyPrint, pdfkit and Playwright for Python on the same two documents: a two-page invoice and a report with script-drawn charts. We recorded which CSS each one got right, how long each took, and what broke silently. The code, the results and a troubleshooting table follow, then the same job as one API call. The Node.js version of this test covers Puppeteer, Playwright and PDFKit.
Tested on 2026-10-18 on macOS 27 (Apple M1 Pro, 32 GB) with Python 3.13.16:
weasyprint==70.0(Pango from Homebrew),playwright==1.64.0(Chromium 156.0.8078.4). pdfkit ran aspdfkit==1.0.0with wkhtmltopdf 0.12.6 from Debian 12's package, in an arm64 Docker container on the same machine, so its times include no container start but run inside a Linux VM. WeasyPrint times are the median of 5 renders after one warm-up; Playwright cold is the median of 3 launches, warm of 5 renders on a running browser. The machine ran other jobs at the same time, so read the numbers as relative. The RenderScreenshot example was not run against the live API for this post.
The two test documents
- Invoice: a flexbox header with a small two-column CSS grid for the invoice number and dates, a 48-row table, a totals block, and colors from CSS custom properties. Two A4 pages.
- Report: three KPI cards in a CSS grid, two chart frames side by side with flexbox
gap, and bars that a script draws 600 ms afterload. The script then setsdata-charts-readyon<body>.
Each tool printed A4 with 16 mm top and 22 mm bottom margins and a "Page 1 of 2" footer.
Results
| WeasyPrint 70.0 | pdfkit + wkhtmltopdf 0.12.6 | Playwright 1.64 (Chromium) | |
|---|---|---|---|
| Invoice time | 253 ms | 288 ms | 265 ms cold, 116 ms warm |
| Report time | 101 ms | 1,083 ms (includes a 1,000 ms JS delay) | 1,553 ms cold, 891 ms warm |
| Invoice size | 41,317 B | 25,330 B | 48,935 B |
| CSS grid | yes, but a grid inside a flex row ran past the right margin | no: KPI cards stacked, invoice dates ran together | yes |
| Flexbox, custom properties | yes | yes | yes |
| JavaScript charts | no: empty frames | yes, after a fixed javascript-delay |
yes, after waiting for the flag |
| "Page N of M" footer | yes, from CSS @page |
no: silently dropped by the Debian build | yes, from a footer template |
| Needs | Pango system libraries | the wkhtmltopdf binary | a Chromium download |
Three findings matter more than the times:
- WeasyPrint does not run JavaScript. The report's chart frames printed empty. If your charts come from a script, draw them as SVG on the server or use a browser.
- pdfkit lost the page numbers and still reported success. wkhtmltopdf printed
The switch --footer-center, is not support using unpatched qt, and will be ignoredand exited 0. pdfkit only shows that line withverbose=True. Our wkhtmltopdf alternative page covers the patched and unpatched builds in depth. - Only Playwright printed both documents as Chrome shows them. WeasyPrint is its own layout engine: close to browsers on our invoice, but not identical, as the clipped date grid shows.
WeasyPrint
Install the Python package and the Pango libraries it loads at runtime.
pip install weasyprint==70.0 # Debian 12 / Ubuntu (tested in python:3.13-slim-bookworm): apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 # macOS: brew install pango
"""weasy_pdf.py: python weasy_pdf.py invoice.html invoice.pdf""" import sys from pathlib import Path from weasyprint import CSS, HTML PAGE_CSS = CSS(string=""" @page { size: A4; margin: 16mm 14mm 22mm 14mm; @bottom-center { content: "Page " counter(page) " of " counter(pages); font: 9px Helvetica, Arial, sans-serif; color: #64748b; } } """) src, dest = sys.argv[1], sys.argv[2] html = HTML(filename=src, base_url=str(Path(src).resolve().parent)) html.write_pdf(dest, stylesheets=[PAGE_CSS]) print(dest, Path(dest).stat().st_size, "bytes")
How it differs from a browser:
- Page numbers are CSS. WeasyPrint implements the paged media margin boxes (
@bottom-centerand the others) withcounter(page)andcounter(pages). No template HTML, no JavaScript. The CSS paged media entry explains the model. base_urlresolves relative paths. Without it,<link href="invoice.css">and<img src="logo.png">in a string have nothing to resolve against.- Python from uv or pyenv on macOS may not find Pango. Our first import failed with
cannot load library 'libgobject-2.0-0'even though Homebrew had installed it.DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/libfixed it. - Check grids inside flex rows. The invoice header is a flex row whose right-hand item is a two-column grid with the number and dates. Chrome sized it correctly; WeasyPrint made the item too narrow, so the grid ran past the right margin and cut the dates. Fixed column widths on the grid did not help. An explicit
width: 60mmon the grid container did.
pdfkit (wkhtmltopdf)
pdfkit is a thin wrapper: it builds a wkhtmltopdf command line and runs it. The wkhtmltopdf repository was archived in 2023, and its engine is a Qt WebKit from many years ago.
"""pdfkit_pdf.py: python pdfkit_pdf.py report.html report.pdf (needs the wkhtmltopdf binary)""" import sys import pdfkit options = { "page-size": "A4", "margin-top": "16mm", "margin-right": "14mm", "margin-bottom": "22mm", "margin-left": "14mm", "encoding": "UTF-8", "footer-center": "Page [page] of [topage]", "footer-font-size": "8", "javascript-delay": "1000", # wait for the charts; there is no "wait for selector" "enable-local-file-access": None, } src, dest = sys.argv[1], sys.argv[2] pdfkit.from_file(src, dest, options=options, verbose=True) # verbose shows wkhtmltopdf warnings print(dest, "written")
What we saw:
- No CSS grid. The KPI cards stacked into one column and the invoice's number and dates ran together on one line.
- Scripts run, after a fixed delay. wkhtmltopdf executed our chart script, but only
javascript-delaymakes it wait; it cannot wait for a selector. Set the delay too short and charts are missing; too long and every PDF pays for it, as the 1,083 ms report time shows. enable-local-file-accessis needed for local files in current builds; it also lets the HTML read other local files, so never use it with HTML that users control.
Keep pdfkit only while you migrate away from it.
Playwright for Python
pip install playwright==1.64.0 python -m playwright install chromium
"""playwright_pdf.py: python playwright_pdf.py report.html report.pdf""" import sys from pathlib import Path from playwright.sync_api import sync_playwright FOOTER = ('<div style="font: 9px Helvetica, Arial, sans-serif; width: 100%; text-align: center; color: #64748b">' 'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>') src, dest = sys.argv[1], sys.argv[2] html = Path(src).read_text(encoding="utf-8") with sync_playwright() as p: browser = p.chromium.launch() try: page = browser.new_page() page.set_content(html, wait_until="load") if "chartsReady" in html: # The report draws its charts after load; wait for the flag the page sets. page.locator('body[data-charts-ready="true"]').wait_for(state="attached", timeout=10_000) page.pdf( path=dest, format="A4", print_background=True, margin={"top": "16mm", "right": "14mm", "bottom": "22mm", "left": "14mm"}, display_header_footer=True, header_template="<span></span>", footer_template=FOOTER, ) finally: browser.close() print(dest, Path(dest).stat().st_size, "bytes")
Notes:
- Options are snake_case in Python (
print_background,display_header_footer), camelCase in Node. - The footer template needs its own font. Header and footer templates do not inherit the page's CSS; without
font-family, Chrome prints them in Times. - The empty header stops Chrome from printing the date and title at the top of each page.
playwright installprunes old browsers. When we ran it, it removed older Chromium builds from the sharedms-playwrightcache. Another project on the same machine that pins an older Playwright then needs its own install.- In an async web app, use
playwright.async_apiwith the same calls andawait. Launch the browser once at startup and open a page per request; launching per request costs the cold time in the table.
"""async variant: one browser for the app, one page per document.""" import asyncio from playwright.async_api import async_playwright async def html_to_pdf(browser, html: str) -> bytes: page = await browser.new_page() try: await page.set_content(html, wait_until="load") return await page.pdf(format="A4", print_background=True) finally: await page.close() async def main(): async with async_playwright() as p: browser = await p.chromium.launch() try: pdf = await html_to_pdf(browser, "<h1>Invoice INV-2048</h1>") print(len(pdf), "bytes") finally: await browser.close() asyncio.run(main())
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
OSError: cannot load library 'libgobject-2.0-0' |
Pango not installed or not on the loader path | Install Pango; on macOS set DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib |
| Charts empty in WeasyPrint | WeasyPrint does not run JavaScript | Render charts as SVG on the server, or use Playwright |
| pdfkit footer missing, no error | Unpatched Qt build ignores footer options | verbose=True to see it; use a patched build or move off wkhtmltopdf |
| Layout collapses in pdfkit | No CSS grid in wkhtmltopdf's WebKit | Use another engine; rewriting with floats is a dead end |
| Charts empty in Playwright | Printed before the script drew | Wait for a flag with locator(...).wait_for() |
| Content runs past the right margin in WeasyPrint | A grid inside a flex item sized too narrow | Give the grid container an explicit width; check every template |
| Page numbers in a serif font (Playwright) | Footer template has no font-family |
Inline font styles in the template |
| Images or CSS missing in WeasyPrint | Relative paths with no base | Pass base_url |
wkhtmltopdf blocked from local files |
Local file access is off by default | enable-local-file-access, only for trusted HTML |
page.pdf error in Playwright |
Not Chromium | Launch p.chromium |
The same report with one API call
If you would rather not install Pango, wkhtmltopdf or Chromium, send the HTML to an API that prints with Chrome. This is the chart report with the Python SDK:
"""pip install renderscreenshot""" import os from pathlib import Path from renderscreenshot import Client, TakeOptions client = Client(os.environ["RS_API_KEY"]) html = Path("report.html").read_text(encoding="utf-8") pdf = client.take( TakeOptions.html(html) .wait_for_selector('body[data-charts-ready="true"]') .format("pdf") .pdf_paper_size("a4") .pdf_margin("16mm") .pdf_print_background() .pdf_header("<span></span>") .pdf_footer('<div style="font:9px Helvetica,Arial,sans-serif;width:100%;text-align:center">' 'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>') ) Path("report.pdf").write_bytes(pdf)
Because the renderer is Chrome, the results match the Playwright column: grid, JavaScript and footer templates work. Options the SDK has no method for, such as fit_one_page and page_ranges, go in a raw JSON request; the PDF report use case has a full example. One render is one credit, whatever the page count, and the free plan includes 150 credits a month.
Which one to choose
- WeasyPrint for documents you control that need no JavaScript: invoices, letters, statements. Fast, no browser, real CSS page numbers.
- Playwright when the HTML needs a browser: charts, web components, anything that renders client-side.
- pdfkit only for code you have not migrated yet.
- An API when you would rather not install system libraries or a browser on your servers. See the HTML to PDF API.
Get a free API key and try it on your own report.
Related in this topic
- HTML to PDF API: Paper, Margins, Page Numbers Turn a URL or your HTML into a PDF with real Chrome: paper sizes, margins, header and footer templates with page numbers, one-page fit and print CSS.
- Best HTML to PDF APIs (2026): 7 Options Compared DocRaptor, PDFShift, Api2Pdf, PDFMonkey, Gotenberg, Browserless and RenderScreenshot compared: engines, prices at three volumes, code and an engine test.
- HTML to PDF in Node.js: Puppeteer, Playwright, PDFKit HTML to PDF in Node.js with Puppeteer, Playwright, PDFKit or an API, tested on an invoice and a chart report: times, file sizes, page numbers and pitfalls.
- Laravel HTML to PDF: dompdf, Browsershot or laravel-pdf Generate PDFs from Blade views in Laravel: dompdf, Browsershot, spatie/laravel-pdf and an API, measured on one invoice, with queued jobs, Storage and tests.
- Rails HTML to PDF: wicked_pdf vs Grover vs Prawn (2026) Generate PDFs in Rails 8: wicked_pdf, Grover, Prawn or an API. Measured speed and size on one invoice, Active Storage code, and when each choice fits.
- URL to PDF in Node.js and Python, Tested on Live Pages Convert a URL to PDF in Node.js and Python: print media, cookie banners that repeat on every page, lazy images that never load, with tested scripts.