· Tutorials · By Simon Chiu

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 as pdfkit==1.0.0 with 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 after load. The script then sets data-charts-ready on <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:

  1. 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.
  2. pdfkit lost the page numbers and still reported success. wkhtmltopdf printed The switch --footer-center, is not support using unpatched qt, and will be ignored and exited 0. pdfkit only shows that line with verbose=True. Our wkhtmltopdf alternative page covers the patched and unpatched builds in depth.
  3. 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-center and the others) with counter(page) and counter(pages). No template HTML, no JavaScript. The CSS paged media entry explains the model.
  • base_url resolves 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/lib fixed 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: 60mm on 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-delay makes 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-access is 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 install prunes old browsers. When we ran it, it removed older Chromium builds from the shared ms-playwright cache. Another project on the same machine that pins an older Playwright then needs its own install.
  • In an async web app, use playwright.async_api with the same calls and await. 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