HTML to PDF in Python with WeasyPrint and Jinja2: Invoices and Reports (2026)
Invoices, receipts, certificates and monthly reports still have to be sent as PDF files, and drawing every line and text box with coordinates is slow work. A much easier way is to design the document with the tools you already know, HTML and CSS, and let a library turn it into a print-quality PDF. WeasyPrint does exactly that: it is a pure-Python rendering engine for paged media, so it understands CSS page sizes, margins, page counters, repeating table headers and page breaks, with no headless browser to install. In this tutorial we convert a simple HTML string to PDF, build a multi-page invoice from a Jinja2 template with a logo, page numbers and PDF/A metadata, generate a batch of invoices quickly, render untrusted HTML safely with a restricted URL fetcher and preview the result as PNG images. Everything was tested in a fresh virtual environment on Python 3.13.5 with WeasyPrint 70.0 (released September 2026), Jinja2 3.1.6, pydyf 0.13.0, tinycss2 1.5.1, pypdf 6.21.4 and pypdfium2 5.14.0, and every output block below is the real output of the scripts shown.
Which Python PDF tool should you use?
- WeasyPrint: HTML + CSS to PDF with real paged-media support (headers, footers, page counters, PDF/A, PDF/UA). Best when designers or templates already produce HTML.
- Playwright or a headless Chrome: pixel-perfect for JavaScript-heavy pages, but you ship a whole browser and paged-media CSS is limited.
- ReportLab or fpdf2: you place text and shapes by code. Good for charts and fixed layouts, more work for long flowing documents.
- pdfkit / wkhtmltopdf: the wkhtmltopdf project is archived and no longer maintained, so avoid it for new projects.
Install
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install weasyprint==70.0 jinja2
pip install pypdf pypdfium2 # only to check and preview the PDFs in this tutorial
WeasyPrint uses the Pango text library. On Debian or Ubuntu install it with sudo apt install libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0; on macOS use brew install pango; on Windows the official docs recommend installing Pango through MSYS2 or using the standalone executable. Version 70.0 is a security release (it stops rendering EPS images and always uses your URL fetcher), so upgrade if you render images or HTML you do not control.
Step 1: Convert an HTML string to PDF
from weasyprint import HTML
from pypdf import PdfReader
html = """
<h1>Hello, PDF</h1>
<p>This file was generated from <b>HTML and CSS</b> with WeasyPrint.</p>
"""
HTML(string=html).write_pdf("hello.pdf")
reader = PdfReader("hello.pdf")
print("pages:", len(reader.pages))
print("page size (pt):", [round(float(x)) for x in reader.pages[0].mediabox[2:]])
print("text:", reader.pages[0].extract_text().replace("\n", " | "))
$ python pdf_hello.py
pages: 1
page size (pt): [595, 842]
text: Hello, PDF | This file was generated from HTML and CSS with WeasyPrint.
HTML(string=...) parses the markup and write_pdf() lays it out and saves the file. The default page is A4, which is 595 x 842 PDF points (1/72 inch). You can also pass HTML(filename="page.html") or HTML(url="https://..."), and WeasyPrint ships a command-line tool: weasyprint page.html page.pdf.
Step 2: Design the invoice template with HTML and CSS
We keep the layout in a Jinja2 template, so the Python code only supplies data. The {{ }} placeholders print values and the {% for %} loop writes one table row per item:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Invoice {{ invoice.number }}</title>
<meta name="author" content="{{ company.name }}">
<link rel="stylesheet" href="invoice.css">
</head>
<body>
<header class="top">
<img src="logo.svg" alt="logo" width="56">
<div>
<h1>{{ company.name }}</h1>
<p>{{ company.address }}</p>
</div>
<div class="meta">
<p><b>Invoice</b> {{ invoice.number }}</p>
<p><b>Date</b> {{ invoice.date }}</p>
<p><b>Bill to</b> {{ invoice.customer }}</p>
</div>
</header>
<table>
<thead><tr><th>#</th><th>Item</th><th>Qty</th><th>Unit price</th><th>Amount</th></tr></thead>
<tbody>
{% for item in items %}
<tr><td>{{ loop.index }}</td><td>{{ item.name }}</td><td>{{ item.qty }}</td>
<td>{{ "%.2f"|format(item.price) }}</td><td>{{ "%.2f"|format(item.qty * item.price) }}</td></tr>
{% endfor %}
</tbody>
</table>
<div class="total">Total due: {{ invoice.currency }} {{ "{:,.2f}".format(total) }}</div>
</body>
</html>
The stylesheet is where WeasyPrint shines. The @page rule sets the paper size and margins, and the @bottom-left and @bottom-right margin boxes print a footer with counter(page) and counter(pages) on every page:
@page {
size: A4;
margin: 18mm 15mm 20mm;
font-family: "DejaVu Sans", sans-serif; /* margin boxes do not inherit from body */
@bottom-left { content: "Thank you for your business"; font-size: 9pt; color: #666; }
@bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 9pt; color: #666; }
}
body { font-family: "DejaVu Sans", sans-serif; font-size: 10pt; color: #222; }
.top { display: flex; gap: 12px; align-items: center; border-bottom: 3px solid #1f6feb; padding-bottom: 8px; }
.top h1 { margin: 0; font-size: 18pt; color: #1f6feb; }
.top p { margin: 2px 0; }
.meta { margin-left: auto; text-align: right; }
table { width: 100%; border-collapse: collapse; margin-top: 14px; }
thead { display: table-header-group; } /* repeat header on every page */
th { background: #1f6feb; color: white; text-align: left; padding: 6px; }
td { padding: 5px 6px; border-bottom: 1px solid #ddd; }
tr { break-inside: avoid; }
td:nth-child(n+3), th:nth-child(n+3) { text-align: right; }
.total { margin-top: 14px; margin-left: auto; width: 60mm; padding: 10px; text-align: right;
font-weight: bold; background: #f0f6ff; border-radius: 6px;
box-shadow: 2px 2px 6px #999; } /* box-shadow is new in WeasyPrint 70 */
thead { display: table-header-group } repeats the column headings on each new page, break-inside: avoid stops a row from being cut in half, and the box-shadow on the total box only renders since WeasyPrint 70. The logo is a small SVG file, which WeasyPrint draws as sharp vector graphics.
Step 3: Render the Jinja2 template to a multi-page PDF
import re
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
from weasyprint import HTML
from pypdf import PdfReader
TEMPLATES = Path(__file__).parent / "templates"
env = Environment(loader=FileSystemLoader(TEMPLATES), autoescape=select_autoescape())
company = {"name": "Raza Analytics", "address": "Blue Area, Islamabad"}
invoice = {"number": "INV-2026-0042", "date": "2026-11-05",
"customer": "Acme Traders", "currency": "USD"}
items = [{"name": f"Data cleaning task {i}", "qty": i % 4 + 1, "price": 12.5 * (i % 5 + 1)}
for i in range(1, 46)]
total = sum(i["qty"] * i["price"] for i in items)
html_text = env.get_template("invoice.html").render(
company=company, invoice=invoice, items=items, total=total)
# base_url lets WeasyPrint find invoice.css and logo.svg next to the template
HTML(string=html_text, base_url=TEMPLATES).write_pdf("invoice.pdf", pdf_variant="pdf/a-3b")
reader = PdfReader("invoice.pdf")
print("items:", len(items), "| total:", f"{total:,.2f}")
print("pages:", len(reader.pages))
print("title:", reader.metadata.title, "| author:", reader.metadata.author)
for n, page in enumerate(reader.pages, start=1):
text = page.extract_text()
rows = [l for l in text.splitlines() if "Data cleaning task" in l]
print(f"page {n}: header repeated={'# Item Qty' in text}, rows {rows[0].split()[0]}-{rows[-1].split()[0]},",
"footer:", text.splitlines()[-1])
xmp = reader.trailer["/Root"]["/Metadata"].get_object().get_data().decode()
part = re.search(r'pdfaid:part="(\d)" pdfaid:conformance="(\w)"', xmp)
print(f"PDF/A-{part[1]}{part[2].lower()} metadata found | size:", Path("invoice.pdf").stat().st_size, "bytes")
$ python pdf_invoice.py
items: 45 | total: 4,200.00
pages: 2
title: Invoice INV-2026-0042 | author: Raza Analytics
page 1: header repeated=True, rows 1-32, footer: Thank you for your business Page 1 of 2
page 2: header repeated=True, rows 33-45, footer: Thank you for your business Page 2 of 2
PDF/A-3b metadata found | size: 20132 bytes
Forty-five items do not fit on one A4 page, so the table flows onto page 2 with its header repeated and the footer reads "Page 1 of 2" and "Page 2 of 2". The PDF title and author come straight from the HTML <title> and <meta name="author"> tags. base_url=TEMPLATES is what lets WeasyPrint resolve the relative paths invoice.css and logo.svg, and pdf_variant="pdf/a-3b" writes the metadata required for PDF/A-3b, the archival format many accounting and e-invoicing systems expect. Always enable autoescape in Jinja2: a customer name such as "Tom & Jerry's Café" is then escaped correctly instead of breaking your HTML.
Step 4: Preview the pages as PNG images
WeasyPrint only outputs PDF, so to check a layout in tests or in a web preview we rasterise it with pypdfium2:
import pypdfium2 as pdfium
pdf = pdfium.PdfDocument("invoice.pdf")
for i in range(len(pdf)):
image = pdf[i].render(scale=1.5).to_pil()
image.save(f"invoice_page{i + 1}.png")
print(f"invoice_page{i + 1}.png", image.size)
$ python pdf_preview.py
invoice_page1.png (893, 1263)
invoice_page2.png (893, 1263)
Page 1 shows the logo, company name, invoice details and rows 1 to 32; page 2 starts with the repeated blue header row, lists rows 33 to 45 and ends with the shadowed "Total due: USD 4,200.00" box above the "Page 2 of 2" footer.
Step 5: Generate many PDFs fast
import time
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
TEMPLATES = Path(__file__).parent / "templates"
OUT = Path("invoices"); OUT.mkdir(exist_ok=True)
env = Environment(loader=FileSystemLoader(TEMPLATES), autoescape=select_autoescape())
template = env.get_template("invoice.html")
fonts = FontConfiguration() # shared between documents
cache = {} # shared image cache (logo is parsed once)
landscape = CSS(string="@page { size: A4 landscape !important }", font_config=fonts)
customers = ["Acme Traders", "Blue Sky Ltd", "Nova Foods", "Indus Textiles", "Tom & Jerry's Café"]
start = time.perf_counter()
for n, customer in enumerate(customers, start=1):
items = [{"name": f"Consulting hour {d}", "qty": 2, "price": 40.0 + n} for d in range(1, n * 3 + 1)]
html_text = template.render(
company={"name": "Raza Analytics", "address": "Blue Area, Islamabad"},
invoice={"number": f"INV-2026-{100 + n}", "date": "2026-11-05",
"customer": customer, "currency": "USD"},
items=items, total=sum(i["qty"] * i["price"] for i in items))
doc = HTML(string=html_text, base_url=TEMPLATES).render(
stylesheets=[landscape] if n == 5 else None, font_config=fonts, cache=cache)
path = OUT / f"INV-2026-{100 + n}.pdf"
doc.write_pdf(path)
print(f"{path} pages={len(doc.pages)} size={path.stat().st_size:>6} B "
f"page={doc.pages[0].width:.0f}x{doc.pages[0].height:.0f}px {customer}")
print(f"5 invoices in {time.perf_counter() - start:.2f} s")
$ python pdf_batch.py
invoices/INV-2026-101.pdf pages=1 size= 13004 B page=794x1123px Acme Traders
invoices/INV-2026-102.pdf pages=1 size= 13195 B page=794x1123px Blue Sky Ltd
invoices/INV-2026-103.pdf pages=1 size= 13420 B page=794x1123px Nova Foods
invoices/INV-2026-104.pdf pages=1 size= 13799 B page=794x1123px Indus Textiles
invoices/INV-2026-105.pdf pages=1 size= 14295 B page=1123x794px Tom & Jerry's Café
5 invoices in 0.38 s
Calling render() first gives you a Document object, so you can inspect the page count and size before writing. Sharing one FontConfiguration and one cache dictionary across documents avoids reloading fonts and images, and here five invoices took 0.38 seconds in total. The extra CSS object switches only the last invoice to landscape (1123 x 794 CSS pixels); it needs !important because the template's own @page rule would otherwise win.
Step 6: Render untrusted HTML safely
If users can type HTML that ends up in your PDFs (comments, product descriptions, report notes), they can also point images at local files or internal network addresses. Since WeasyPrint 68 you control every request with a URLFetcher subclass:
from pathlib import Path
from weasyprint import HTML
from weasyprint.urls import URLFetcher
from pypdf import PdfReader
ASSETS = (Path(__file__).parent / "templates").resolve()
class LocalAssetsFetcher(URLFetcher):
"""Allow only files inside the templates folder (plus inline data: URLs)."""
def fetch(self, url, headers=None):
if url.startswith("data:") or url.startswith(ASSETS.as_uri() + "/"):
print("allowed:", url.replace(ASSETS.as_uri(), "templates"))
return super().fetch(url, headers)
print("blocked:", url)
raise ValueError(f"blocked {url}")
# HTML written by a user (for example a comment or a product description)
untrusted = """
<p>Customer note</p>
<img src="logo.svg" width="40">
<img src="file:///etc/passwd">
<img src="http://169.254.169.254/latest/meta-data/">
"""
doc = HTML(string=untrusted, base_url=ASSETS,
url_fetcher=LocalAssetsFetcher(timeout=5)).render()
doc.write_pdf("safe.pdf")
text = PdfReader("safe.pdf").pages[0].extract_text().replace("\n", " | ")
print("pages:", len(doc.pages), "| text:", text)
$ python pdf_safe.py
allowed: templates/logo.svg
blocked: file:///etc/passwd
blocked: http://169.254.169.254/latest/meta-data/
pages: 1 | text: Customer note | AR
The logo inside the templates folder was allowed, while the local system file and the cloud metadata address were refused before any request was made, and the PDF was still produced. For simpler cases you can pass URLFetcher(allowed_protocols=["file", "data"]) to block HTTP completely.
Common errors and fixes
- "Relative URI reference without a base URI": you used
HTML(string=...)with relative links. We reproduced it; the PDF is still written but without the CSS and logo:
Pass$ python pdf_no_base_url.py ERROR weasyprint: Relative URI reference without a base URI: invoice.css ERROR weasyprint: Relative URI reference without a base URI: logo.svg PDF written, but without CSS and logobase_url=pointing to the folder that holds your assets, as in Step 3. - "Unknown rendering option: pdf_varient": since version 70 WeasyPrint logs an error for misspelled
write_pdf()options instead of silently ignoring them. We tested a typo and got exactly this message, so turn onloggingwhile developing. - OSError: cannot load library 'libpango' or 'libgobject': the system Pango libraries are missing. Install them as shown in the Install section (on Windows, through MSYS2).
- Footer in a different font: page margin boxes do not inherit styles from
body. Setfont-familyinside@page, as in our stylesheet. - Flexbox or grid looks slightly different from Chrome: WeasyPrint has no JavaScript and its layout engine is its own. Keep print templates simple, use tables for tabular data and check the PNG preview in your tests.
Where to use this next
- Merge all generated invoices into one monthly file or add a cover page with pypdf: merge and split PDFs in Python.
- Email each invoice PDF as an attachment to the customer with Send Email with Python: smtplib, HTML and attachments.
- Read tables back out of PDFs you receive from others with Extract Tables from PDF in Python with pdfplumber.
Conclusion
WeasyPrint turns the HTML and CSS skills you already have into professional PDFs: @page rules for paper size and footers, page counters, repeating table headers and PDF/A output, all from a Jinja2 template filled with your data. Render once to inspect pages, share fonts and caches for batches, restrict the URL fetcher for user content and your invoices and reports can be produced automatically in a fraction of a second each.