Generate PDF Reports from HTML with WeasyPrint

The monthly customer report is drawn with ReportLab coordinates, and every change — a longer company name, a new column, a logo swap — means recalculating positions by hand. HTML and CSS are the obvious alternative: the layout reflows, designers can edit templates, and the same template can preview in a browser. The first attempt with WeasyPrint renders, but the result looks nothing like the browser preview: US Letter pages instead of A4, no page numbers, a table row sliced in half across a page break with its header left behind, fallback fonts, missing images, and the flexbox layout collapsed into a single column.

Root Cause

WeasyPrint is a layout engine for paged media, not a browser screenshot tool. It implements CSS for print — @page rules, page margin boxes, page counters, break-inside — which browsers largely ignore on screen, and it does not run JavaScript. A template designed for the screen relies on defaults that do not exist on paper: the page size defaults to the locale-independent A4 only when specified, otherwise the stylesheet's absence leaves WeasyPrint's default; running headers need @page margin boxes rather than fixed-position divs; tables need explicit break rules; fonts must be installed or declared with @font-face; and relative image paths resolve against the base_url you pass, not against the working directory. Layout features added recently to browsers are supported selectively, so screen-oriented CSS frameworks produce broken output. Building a report for WeasyPrint means writing a print stylesheet deliberately.

Minimal Diagnostic

Render a representative template and ask WeasyPrint what it produced: page count and size, fonts actually used, and any resources it could not load. Capture its log messages, which name missing images and unsupported CSS.

# pip install weasyprint jinja2
import logging
from pathlib import Path
from weasyprint import HTML

TEMPLATE_HTML = Path("templates/rendered-sample.html")      # a template rendered with sample data

def diagnose(html_path: Path) -> None:
    messages = []
    handler = logging.Handler()
    handler.emit = lambda record: messages.append(f"{record.levelname}: {record.getMessage()}")
    for name in ("weasyprint", "weasyprint.progress"):
        logging.getLogger(name).addHandler(handler)
    try:
        document = HTML(filename=str(html_path), base_url=str(html_path.parent)).render()
    except Exception as exc:
        raise SystemExit(f"render failed: {type(exc).__name__}: {exc}")
    first = document.pages[0]
    print(f"pages: {len(document.pages)}, size: {first.width:.0f} x {first.height:.0f} CSS px "
          f"({first.width * 25.4 / 96:.0f} x {first.height * 25.4 / 96:.0f} mm)")
    fonts = sorted({font.description.family for font in getattr(document, "fonts", {}).values()}) \
        if hasattr(document, "fonts") else "n/a"
    print("fonts:", fonts)
    for m in messages:
        if "WARNING" in m or "ERROR" in m:
            print(m)

if __name__ == "__main__":
    diagnose(TEMPLATE_HTML)
pages: 7, size: 816 x 1056 CSS px (216 x 279 mm)
fonts: n/a
WARNING: Failed to load image at "img/logo.svg": [Errno 2] No such file or directory
WARNING: Ignored `display: grid` in "report.css" at 41:3, unknown value
WARNING: Ignored `position: fixed; top: 0` page header: use @page margin boxes

The page is Letter (216 × 279 mm), the logo path resolved against the wrong directory, and two CSS rules were ignored. Exact warning texts vary by WeasyPrint version; the important part is reading them rather than eyeballing the PDF.

Anatomy of a paged report The A4 page has an 18 millimetre margin. The top-left margin box holds the company name and the top-right margin box holds the report title as running headers. The bottom-center margin box holds Page N of M generated from page counters. The content area holds the report body, where a long table repeats its thead on each page and rows are kept intact with break-inside avoid. @page { size: A4; margin: 18mm } 1 @top-left company name 2 @top-right report title and period 3 3 thead repeated on every page 4 4 tbody rows break-inside: avoid 5 @bottom-center Page N of M

Fix: A Print Stylesheet, Explicit Base URL and a Rendering Function

Render the Jinja2 template to HTML, then hand it to WeasyPrint with a print stylesheet and a base_url pointing at the template folder. Changed lines carry comments.

# pip install weasyprint jinja2
from datetime import date
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration

TEMPLATES = Path("templates")
OUT = Path("out/reports")

PRINT_CSS = """
@page {
  size: A4;                                         /* changed: explicit paper size */
  margin: 22mm 18mm 20mm 18mm;
  @top-left { content: "Northwind Traders"; font-size: 8pt; color: #475569; }
  @top-right { content: string(report-title); font-size: 8pt; color: #475569; }
  @bottom-center { content: "Page " counter(page) " of " counter(pages); font-size: 8pt; }  /* changed */
}
@font-face { font-family: "Inter"; src: url("fonts/Inter-Regular.woff2"); }                  /* changed */
@font-face { font-family: "Inter"; font-weight: 700; src: url("fonts/Inter-Bold.woff2"); }
body { font-family: "Inter", "DejaVu Sans", sans-serif; font-size: 9.5pt; line-height: 1.35; }
h1 { string-set: report-title content(); font-size: 16pt; margin: 0 0 6mm; }                 /* changed */
table { width: 100%; border-collapse: collapse; }
thead { display: table-header-group; }                                                        /* changed: repeat */
tr { break-inside: avoid; }                                                                   /* changed: no split rows */
td, th { padding: 2mm 1.5mm; border-bottom: 0.3pt solid #cbd5e1; text-align: left; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.section { break-before: page; }                                                              /* changed */
"""

env = Environment(loader=FileSystemLoader(TEMPLATES), autoescape=select_autoescape(["html"]))

def render_report(customer: dict, rows: list[dict], period: str) -> Path:
    html = env.get_template("customer-report.html").render(
        customer=customer, rows=rows, period=period, generated=date.today().isoformat())
    font_config = FontConfiguration()
    dest = OUT / f"{customer['id']}-{period}.pdf"
    dest.parent.mkdir(parents=True, exist_ok=True)
    try:
        HTML(string=html, base_url=str(TEMPLATES.resolve())).write_pdf(        # changed: resources resolve here
            dest,
            stylesheets=[CSS(string=PRINT_CSS, font_config=font_config)],
            font_config=font_config,
        )
    except Exception as exc:
        raise RuntimeError(f"PDF rendering failed for {customer['id']}: {exc}") from exc
    return dest

if __name__ == "__main__":
    print(render_report({"id": "C-1042", "name": "Contoso Ltd"},
                        [{"date": "2026-09-01", "item": "Paper A4", "amount": 49.20}], "2026-09"))

counter(pages) gives the total page count, which only a paged-media engine can know. string-set copies the <h1> text into a named string used by the header, so each section's title appears in the running header automatically. display: table-header-group on thead repeats column headings on every page a table spans, and break-inside: avoid on rows keeps each row whole. Passing autoescape to Jinja2 prevents a customer name containing < or & from breaking the HTML — the same templating habits described in build HTML reports with Jinja2 templates.

Data to PDF through HTML Report data is rendered into HTML by a Jinja2 template with autoescaping. WeasyPrint parses the HTML with the print stylesheet, resolves images and fonts against the base URL, lays out pages using @page rules and break properties, and writes the PDF. A branch shows a missing base URL causing images and fonts to fail to load with warnings rather than errors. Data dicts or DataFrame Jinja2 HTML with autoescape Print CSS @page, breaks, fonts WeasyPrint layout paged media PDF A4 with page N of M No base_url images, fonts missing

Variant Fix 1: Charts and Images Without Files on Disk

Reports usually include charts generated per customer. Render them to SVG in memory and embed them as data URIs, which avoids temporary files and keeps charts sharp at any zoom:

# pip install matplotlib
import base64
import io
import matplotlib
matplotlib.use("Agg")                        # no display needed on servers
import matplotlib.pyplot as plt

def chart_data_uri(months: list[str], values: list[float]) -> str:
    fig, ax = plt.subplots(figsize=(6.5, 2.4))
    ax.bar(months, values, color="#2563eb")
    ax.spines[["top", "right"]].set_visible(False)
    ax.tick_params(labelsize=7)
    buf = io.BytesIO()
    fig.savefig(buf, format="svg", bbox_inches="tight")
    plt.close(fig)                           # free memory in long batch runs
    return "data:image/svg+xml;base64," + base64.b64encode(buf.getvalue()).decode()

In the template: <img src="{{ chart }}" alt="Monthly spend" style="width:100%">. SVG keeps text in the chart selectable and the PDF small. matplotlib.use("Agg") is what keeps chart generation working in scheduled jobs without a display — the failure otherwise is covered in fix matplotlib no display error in headless jobs.

Variant Fix 2: Fast Batches

Generating hundreds of reports is dominated by font loading and CSS parsing, which repeat for every document if done naively. Parse the stylesheet and font configuration once per process, and render in parallel processes:

# pip install weasyprint jinja2
from concurrent.futures import ProcessPoolExecutor
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration

_font_config = None
_stylesheet = None

def _init_worker() -> None:
    global _font_config, _stylesheet
    _font_config = FontConfiguration()
    _stylesheet = CSS(string=PRINT_CSS, font_config=_font_config)     # parsed once per worker

def _render(job: tuple[str, str]) -> str:
    html, dest = job
    HTML(string=html, base_url=str(TEMPLATES.resolve())).write_pdf(
        dest, stylesheets=[_stylesheet], font_config=_font_config)
    return dest

def render_many(jobs: list[tuple[str, str]], workers: int = 4) -> list[str]:
    with ProcessPoolExecutor(max_workers=workers, initializer=_init_worker) as pool:
        return list(pool.map(_render, jobs, chunksize=8))

Typical business reports of a few pages render in well under a second each once fonts are cached; the first document per worker is slower. Keep images small — a 6 MB photo embedded in each of 500 reports dominates both time and output size — following reduce PDF file size with Python.

WeasyPrint or ReportLab WeasyPrint reflows layout automatically when content changes, lets designers edit HTML and CSS templates, positions elements through CSS rather than coordinates, renders more slowly, depends on the Pango system library and uses installed or @font-face fonts. ReportLab requires manual layout changes or Platypus flowables, needs Python developers to edit, positions precisely by coordinates, renders quickly, is pure Python apart from optional accelerators, and needs fonts registered explicitly. Aspect WeasyPrint ReportLab Layout changes reflows automatically manual or Platypus Who edits templates HTML and CSS people Python developers Exact positioning via CSS coordinates Speed slower fast System dependencies needs Pango pure Python

Verification

Check the PDFs produced, not the HTML: page size, page count range, that the running header and page numbers appear on every page, and that expected content is present.

# pip install pymupdf
from pathlib import Path
import pymupdf

def verify_report(pdf_path: Path, must_contain: list[str], max_pages: int = 30) -> None:
    with pymupdf.open(pdf_path) as doc:
        assert 1 <= doc.page_count <= max_pages, f"{doc.page_count} pages"
        for page in doc:
            w_mm, h_mm = page.rect.width / 72 * 25.4, page.rect.height / 72 * 25.4
            assert abs(w_mm - 210) < 1 and abs(h_mm - 297) < 1, f"page {page.number + 1} is not A4"
            text = page.get_text()
            assert f"Page {page.number + 1} of {doc.page_count}" in text, f"page {page.number + 1}: no page number"
        full = " ".join(page.get_text() for page in doc)
        missing = [s for s in must_contain if s not in full]
        assert not missing, f"missing content: {missing}"
        fonts = {f[3] for page in doc for f in page.get_fonts()}
        assert any("Inter" in f for f in fonts), f"template font not embedded, got {sorted(fonts)[:4]}"
    print(f"{pdf_path.name}: {len(must_contain)} checks passed")

The font assertion catches silent fallbacks: when a font file is missing, WeasyPrint substitutes a system font without failing, and the report looks subtly wrong everywhere. Run the checks on every generated file in batch jobs; they cost milliseconds.

FAQ

Does WeasyPrint run JavaScript? No. Render anything dynamic — charts, computed tables — on the Python side before passing HTML in.

Can I use Bootstrap or Tailwind? Simple utility classes work; grid systems relying on flexbox features WeasyPrint does not implement may not. A small hand-written print stylesheet is more predictable.

How do I get a table of contents with page numbers? Use target-counter(attr(href), page) in CSS on links to section anchors. WeasyPrint fills in the page number during layout.

What about installing WeasyPrint on servers? It needs the Pango library from the operating system. If import fails with cannot load library, see fix WeasyPrint cannot load library error.

Part of Generating PDF Reports Dynamically.

/html>