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.
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.
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.
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.
Related
- Generating PDF Reports Dynamically — ReportLab-based generation and report design
- Add Page Numbers and Headers to PDF Reports — the ReportLab equivalent of margin boxes
- Build HTML Reports with Jinja2 Templates — the templating layer in depth
- Fix WeasyPrint Cannot Load Library Error — system dependencies on Linux, macOS and Windows
Part of Generating PDF Reports Dynamically.