HTML to PDF in Python with WeasyPrint and Jinja2: Invoices and Reports (2026)

Automation Nov 05, 2026

Watch on YouTube

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:
    $ 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 logo
    
    Pass base_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 on logging while 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. Set font-family inside @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

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.

Share:
Last updated: November 2026
Built-in Functions in Python 2026 str() in Python 2026: String Conversion + Modern Formatting & Best Practices memoryview with JAX in Python 2026: Zero-Copy NumPy → JAX Array Interop + Efficient ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + GPU Pinning & ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + ML Examples memoryview with NumPy & PyTorch in Python 2026: Zero-Copy Views, Efficient Slicing & ML Interop Examples memoryview with NumPy in Python 2026: Zero-Copy Views, Efficient Slicing & Real ML Examples memoryview() in Python 2026: Zero-Copy Magic for Large Binary Data + Real Examples import() in Python 2026: How It Works, Security Risks & Modern Alternatives (importlib) zip() in Python 2026: How to Use It, strict=True Behavior & Real-World Examples vars() in Python 2026: Accessing Object Namespace + Modern Introspection Patterns type() in Python 2026: Dynamic Type Inspection & Object Creation + Modern Patterns tuple() in Python 2026: Immutable Sequences + Modern Patterns & Best Practices super() in Python 2026: Method Resolution & Modern Inheritance Patterns sum() in Python 2026: Summing Iterables + Modern Numeric Patterns & Best Practices staticmethod() in Python 2026: Static Methods, Utility Functions & Modern Best Practices sorted() in Python 2026: Sorting Iterables + Modern Patterns & Best Practices slice() in Python 2026: Creating Slice Objects + Modern Patterns & Best Practices setattr() in Python 2026: Dynamic Attribute Setting + Modern Patterns & Safety set() in Python 2026: Mutable Sets Creation + Modern Patterns & Best Practices