WeasyPrint PDF Rendering Pipeline

Spatial reports live or die on their final rendered page: a map that shifts two millimetres past the trim, a legend clipped by a footer, or a font substitution that mangles a multilingual place name all read as production defects. WeasyPrint turns an HTML document and a Paged Media stylesheet into a deterministic, print-ready PDF, which makes it the natural output stage for any pipeline that already builds its layout in Jinja2 Templating & Theme Logic for Automated Spatial Reporting. This guide walks the complete path from a rendered template string to a paginated PDF: environment setup, @page configuration, map and font embedding, base_url resolution, presentational hints, and the performance work that keeps batch runs predictable.

Prerequisites

WeasyPrint depends on native rendering libraries (Pango, Cairo, GDK-PixBuf), so the environment needs both the Python package and its system dependencies before anything renders.

  • Python 3.10+ with weasyprint>=60, jinja2>=3.1, and pydyf (installed transitively)
  • System libraries: libpango, libpangocairo, libcairo2, libgdk-pixbuf, and libffi — on a Debian-based image these come from apt, not pip
  • Spatial assets: pre-rendered map images (PNG at 300 DPI, or SVG) plus GeoJSON or attribute records structured for template injection
  • A Paged Media stylesheet: page size, margins, and margin boxes; the same CSS foundations covered in CSS Grid Systems for Report Layouts
Bash
# Debian/Ubuntu system dependencies
apt-get update && apt-get install -y \
  libpango-1.0-0 libpangocairo-1.0-0 libcairo2 \
  libgdk-pixbuf-2.0-0 libffi-dev shared-mime-info

pip install "weasyprint>=60" "jinja2>=3.1"

Verify the install resolves its native libraries before writing any template code — a broken Pango link is the single most common cause of blank text in generated PDFs:

Bash
python -c "import weasyprint; print(weasyprint.__version__)"
weasyprint --info   # prints the resolved Pango/Cairo versions

When you deploy this into an image with no display server, the same dependency list drives the container build in Headless PDF Rendering in Docker Containers.


Pipeline Architecture

The pipeline has four stages: build a Jinja2 context, render the template to an HTML string, hand that string plus a base_url and stylesheets to WeasyPrint, and write the paginated PDF. base_url is the pivot — it is how the renderer locates every map image, font file, and stylesheet referenced by a relative URL.

WeasyPrint Spatial Report Rendering Pipeline A Jinja2 context and template render to an HTML string; WeasyPrint combines that string with Paged Media CSS and base_url-resolved map and font assets to produce a paginated PDF. Jinja2 Context GeoJSON + attrs template.render() HTML String grid containers relative asset URLs Paged Media CSS @page + margin boxes Assets via base_url maps + @font-face WeasyPrint layout + paginate embed fonts Paginated PDF print-ready PDF/A optional Store artifact STAGE 1 STAGE 2 STAGE 3 STAGE 4
The four rendering stages. Paged Media CSS enters from the top and asset resolution via base_url from the bottom; both are consumed inside the WeasyPrint layout pass that produces the paginated PDF.

Step-by-Step Implementation

Step 1: Establish the Environment and a Reusable FontConfiguration

The purpose of this step is to create rendering objects once and reuse them, rather than reconstructing font machinery on every document. WeasyPrint exposes a FontConfiguration that caches resolved @font-face rules; building it per-render is a common and expensive mistake in batch pipelines.

Python
# renderer.py
from __future__ import annotations

import logging
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)

# Build the font configuration ONCE and reuse it across every render call.
FONT_CONFIG = FontConfiguration()


def load_stylesheet(path: str) -> CSS:
    """Parse a Paged Media stylesheet with the shared font configuration."""
    return CSS(filename=path, font_config=FONT_CONFIG)

FontConfiguration is safe to share across sequential renders in one process. It is not safe to share across processes, which matters when you parallelise batches later.

Step 2: Structure the Jinja2 Template with Paged Media CSS

The template produces a semantic HTML document; the stylesheet owns all page geometry. Keeping @page rules in a dedicated stylesheet (rather than inline <style>) means the same layout is reusable across report types. The @page rule sets physical dimensions and margin boxes for running headers and page numbers.

CSS
/* report.css — Paged Media foundation */
@page {
  size: A4 portrait;
  margin: 18mm 16mm 20mm 16mm;

  @top-center {
    content: string(report-title);
    font-size: 8pt;
    opacity: 0.7;
  }
  @bottom-right {
    content: "Page " counter(page) " of " counter(pages);
    font-size: 8pt;
  }
}

h1 { string-set: report-title content(); }

.map-frame img {
  width: 100%;
  height: auto;
  break-inside: avoid;      /* keep a map on one page */
}

The string-set/string() pairing lifts the report title into the running header — a Paged Media feature Chromium-based renderers do not support, which is one reason WeasyPrint suits print-grade spatial reports. The full @page vocabulary (bleed, marks, named pages, landscape map spreads) is covered in Configuring WeasyPrint @page Rules for Spatial Reports.

Jinja
{# spatial_report.html #}
<!DOCTYPE html>
<html lang="{{ lang | default('en') }}">
<head><meta charset="utf-8"><title>{{ report_title }}</title></head>
<body>
  <h1>{{ report_title }}</h1>
  <section class="map-frame">
    <img src="maps/{{ layer_slug }}.png"
         alt="Extent map for {{ layer_name }}, EPSG:{{ crs_epsg }}">
  </section>
  {% if features %}
  <table class="attribute-table">
    <thead><tr><th>Parcel</th><th>Risk</th></tr></thead>
    <tbody>
      {% for f in features %}<tr><td>{{ f.parcel_id }}</td><td>{{ f.risk }}</td></tr>{% endfor %}
    </tbody>
  </table>
  {% endif %}
</body>
</html>

Note the relative src="maps/..." path — it only resolves because Step 3 supplies a base_url.

Step 3: Resolve Maps and Fonts with base_url

WeasyPrint resolves every relative URL — image src, @font-face src, linked stylesheets — against the base_url argument, not the process working directory. Point it at the directory that holds your assets. Fonts referenced via @font-face are subset and embedded into the PDF automatically, so the recipient sees identical glyphs regardless of their installed fonts.

Python
# renderer.py (continued)
from pathlib import Path

def render_pdf(html_string: str, asset_root: Path, out_path: Path) -> Path:
    """Render an HTML string to PDF, resolving assets against asset_root."""
    stylesheet = load_stylesheet(str(asset_root / "report.css"))
    HTML(string=html_string, base_url=str(asset_root)).write_pdf(
        target=str(out_path),
        stylesheets=[stylesheet],
        font_config=FONT_CONFIG,
    )
    logger.info("Wrote %s", out_path)
    return out_path

For maps, prefer embedding a 300 DPI PNG or an SVG. If you must inline an image without a filesystem asset root, encode it as a data: URI in the template context so no path resolution is required at all.

Step 4: Apply Document Metadata and Presentational Hints

WeasyPrint reads PDF metadata (title, author, language) from the HTML <title>, <meta> tags, and lang attribute, so setting them in the template propagates them into the document properties. When rendering HTML produced by third-party GIS export tools that rely on deprecated presentational HTML attributes (width, bgcolor, align), enable presentational_hints so those attributes are honoured:

Python
HTML(string=html_string, base_url=str(asset_root)).write_pdf(
    target=str(out_path),
    stylesheets=[stylesheet],
    font_config=FONT_CONFIG,
    presentational_hints=True,   # honour legacy width/align/bgcolor attributes
    pdf_variant="pdf/a-3b",      # archival variant for long-lived report stores
)

pdf_variant="pdf/a-3b" embeds an ICC profile and enforces the archival constraints many public-sector spatial report archives require.

Step 5: Tune Performance and Validate

The purpose of this step is to keep per-document render time bounded and to catch regressions. Rendering is CPU-bound; the two highest-impact levers are reusing the FontConfiguration (Step 1) and keeping raster payloads small. Pre-rasterise maps to the exact display size rather than shipping a 4000px image scaled down in CSS.

Python
import time

def render_timed(html_string: str, asset_root: Path, out_path: Path) -> float:
    t0 = time.perf_counter()
    render_pdf(html_string, asset_root, out_path)
    elapsed = time.perf_counter() - t0
    if elapsed > 5.0:
        logger.warning("Slow render (%.2fs) for %s", elapsed, out_path.name)
    return elapsed

Production-Ready Script

The following script is a complete, copy-pasteable entry point: it loads GeoJSON, builds a context, renders through Jinja2, and writes a PDF with structured logging, error handling, and argparse.

Python
#!/usr/bin/env python3
"""
render_spatial_pdf.py

Render a print-ready spatial report PDF from GeoJSON via Jinja2 + WeasyPrint.
Usage:
  python render_spatial_pdf.py --data features.geojson --assets ./assets --out ./out
"""
from __future__ import annotations

import argparse
import json
import logging
import sys
from pathlib import Path
from typing import Any

from jinja2 import Environment, FileSystemLoader, select_autoescape
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    datefmt="%H:%M:%S",
)
logger = logging.getLogger("spatial_pdf")

FONT_CONFIG = FontConfiguration()


def load_geojson(path: Path) -> dict[str, Any]:
    with path.open(encoding="utf-8") as fh:
        return json.load(fh)


def build_context(geojson: dict[str, Any]) -> dict[str, Any]:
    """Serialise a FeatureCollection into a flat, template-safe context."""
    features = geojson.get("features", [])
    rows = [
        {
            "parcel_id": f.get("properties", {}).get("parcel_id", "—"),
            "risk": f.get("properties", {}).get("risk_score", "n/a"),
        }
        for f in features
    ]
    name = geojson.get("name", "Unnamed Layer")
    return {
        "report_title": f"{name} — Spatial Report",
        "layer_name": name,
        "layer_slug": name.lower().replace(" ", "-"),
        "crs_epsg": geojson.get("crs", {}).get("properties", {}).get("name", "4326"),
        "features": rows,
        "lang": "en",
    }


def render_html(context: dict[str, Any], template_dir: Path) -> str:
    env = Environment(
        loader=FileSystemLoader(str(template_dir)),
        autoescape=select_autoescape(["html", "xml"]),
    )
    return env.get_template("spatial_report.html").render(**context)


def render_pdf(html_string: str, asset_root: Path, out_path: Path) -> None:
    stylesheet = CSS(filename=str(asset_root / "report.css"), font_config=FONT_CONFIG)
    HTML(string=html_string, base_url=str(asset_root)).write_pdf(
        target=str(out_path),
        stylesheets=[stylesheet],
        font_config=FONT_CONFIG,
        presentational_hints=True,
    )


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Render spatial report PDFs with WeasyPrint")
    parser.add_argument("--data", required=True, type=Path, help="GeoJSON input file")
    parser.add_argument("--assets", required=True, type=Path, help="Asset + template root")
    parser.add_argument("--out", default=Path("out"), type=Path)
    args = parser.parse_args(argv)

    try:
        geojson = load_geojson(args.data)
    except (OSError, json.JSONDecodeError) as exc:
        logger.error("Could not read GeoJSON %s: %s", args.data, exc)
        return 2

    args.out.mkdir(parents=True, exist_ok=True)
    context = build_context(geojson)
    out_file = args.out / f"{context['layer_slug']}_report.pdf"

    try:
        html_string = render_html(context, args.assets)
        render_pdf(html_string, args.assets, out_file)
    except Exception:                       # noqa: BLE001 — top-level guard for CI logs
        logger.exception("Rendering failed for %s", args.data)
        return 1

    logger.info("Rendered %s (%d features)", out_file, len(context["features"]))
    return 0


if __name__ == "__main__":
    sys.exit(main())

Edge Cases & Advanced Configuration

Null and Missing Spatial Data

GeoJSON from automated feeds routinely arrives with null geometries or absent attribute fields. Guard the template so an empty layer collapses to a compact panel instead of an empty map box — the full pattern is documented in Using Jinja2 if-else Blocks to Hide Empty GIS Layers. Pre-compute a has_features boolean in Python rather than testing lengths inside the template.

Scale and Batch Performance

For nightly batches, rendering is CPU-bound and holds the GIL for meaningful stretches, so scale with processes, not threads. Give each worker process its own FontConfiguration. Cap raster resolution to what the page actually shows: a 90mm-wide map at 300 DPI needs roughly 1063px, not 4000px.

Multi-Format Output

WeasyPrint also writes PNG previews via write_png() for quick visual diffs in CI. Generate a low-DPI PNG of page one as a smoke test artifact so reviewers can eyeball layout drift without opening the PDF.

Headless CI/CD

In a container with no X server, WeasyPrint needs no display (it uses Cairo directly), unlike QGIS or Mapnik map rendering. That distinction is what makes it CI-friendly; the container recipe and dependency pinning live in Headless PDF Rendering in Docker Containers.


Troubleshooting

Symptom Likely Cause Resolution
Map images render as empty boxes Relative src resolved against CWD, no base_url Pass base_url=str(asset_root) to HTML(), or embed images as data: URIs
All text is blank in the PDF Pango/Cairo native libraries missing Install libpangocairo-1.0-0 and libcairo2; confirm with weasyprint --info
Custom map-label font falls back to a default @font-face src unresolvable, or FontConfiguration not passed Ensure the font path resolves via base_url and pass font_config to both CSS() and write_pdf()
Running header / page number missing Renderer ignores @page margin boxes Confirm you are on WeasyPrint 60+ and the rules live in a linked stylesheet, not a Chromium-only @media print block
Render time grows linearly across a batch FontConfiguration rebuilt per document Construct it once per process and reuse it for every render
Legacy width/bgcolor attributes from a GIS export ignored Presentational hints disabled Pass presentational_hints=True to write_pdf()

Frequently Asked Questions

Why do my map images not appear in the WeasyPrint PDF?

WeasyPrint resolves relative URLs against base_url, not the current working directory. Pass base_url pointing at the directory that contains your assets, or embed images as data: URIs. If base_url is omitted, relative src paths silently fail and produce empty image boxes with no exception raised — which is why this failure so often reaches production.

How do I embed custom fonts so map labels render consistently?

Declare @font-face rules with a src URL that base_url can resolve, or install the font at the OS level so Pango finds it via fontconfig. WeasyPrint subsets and embeds every referenced font into the PDF automatically, guaranteeing identical glyphs across machines. Always pass the shared font_config to both the CSS() constructor and write_pdf().

Is WeasyPrint fast enough for batch spatial report generation?

WeasyPrint renders a typical 20-page spatial report in one to three seconds. For large batches, reuse a single FontConfiguration, pre-rasterise heavy maps to display size, and parallelise across processes rather than threads, since rendering is CPU-bound and releases little of the GIL.

When should I choose WeasyPrint over ReportLab for spatial PDFs?

Choose WeasyPrint when your layout is expressed in HTML and CSS, when designers iterate on styling, and when content reflows across pages. Choose ReportLab Programmatic Layout when you need pixel-exact coordinate placement of map frames or programmatic drawing. The trade-offs are analysed dimension by dimension in WeasyPrint vs ReportLab for Spatial PDFs.


Detailed Guides in This Section


Parent: Jinja2 Templating & Theme Logic for Automated Spatial Reporting


Conclusion

WeasyPrint converts a Jinja2-rendered HTML document into a deterministic, print-ready PDF, with base_url resolving maps and fonts and Paged Media @page rules controlling geometry. Pair a reused FontConfiguration, disciplined raster sizing, and process-level parallelism, and the pipeline holds its render budget from a single report to a nightly batch of thousands.