ReportLab Programmatic Layout
When a spatial report demands that a map frame sit at a surveyed coordinate to the point, that a scale bar align to a graticule, or that a 40,000-row attribute table stream to disk without exhausting memory, an HTML renderer’s reflow model becomes a liability rather than a convenience. reportlab builds PDFs programmatically — a drawing canvas measured in points and a higher-level document engine (Platypus) that flows content through frames — giving GIS reporting teams deterministic, coordinate-exact control. This guide covers the canvas-versus-flowables decision, PageTemplate and Frame geometry, exact-DPI map placement, LongTable streaming, and using Jinja2 Templating & Theme Logic for Automated Spatial Reporting as a pre-processor that keeps content out of Python code.
Prerequisites
- Python 3.10+ with
reportlab>=4.0andjinja2>=3.1 - Pillow (
pillow>=10) for image inspection and DPI-aware rasters — installed alongsidereportlab - Map rasters: PNG or JPEG exports generated at the target physical size × 300 DPI
- Attribute data: records from GeoPandas, a database cursor, or serialised GeoJSON
- Unit fluency: ReportLab measures in points (1pt = 1/72in); import
mm,cm, andinchfromreportlab.lib.units
pip install "reportlab>=4.0" "jinja2>=3.1" "pillow>=10"
python -c "import reportlab; print(reportlab.Version)"
Unlike the HTML route in the WeasyPrint PDF Rendering Pipeline, ReportLab has no native library dependencies — it is pure Python plus Pillow — which makes it exceptionally simple to install in a slim container.
Pipeline Architecture
The ReportLab pipeline inverts the WeasyPrint model. Instead of a stylesheet paginating HTML, a BaseDocTemplate owns one or more PageTemplates, each defining Frame regions and onPage callbacks; Jinja2 produces a structured intermediate that a builder converts into a flowable list, which the document flows through the frames.
Paragraph, Image, and LongTable flowables; the BaseDocTemplate flows them through Frame geometry while onPage callbacks draw fixed canvas elements.Step-by-Step Implementation
Step 1: Choose Canvas or Platypus (or Both)
The canvas is the low-level surface: you call drawImage, drawString, and line at absolute coordinates measured from the bottom-left origin. Platypus sits above it — Frames hold a list of flowables (Paragraph, Table, Image, Spacer) that paginate automatically. Spatial reports almost always combine the two: flowables carry variable-length tables and narrative, while canvas drawing in onPage callbacks places fixed furniture (headers, scale bars, north arrows).
# units.py — the conversions you will use constantly
from reportlab.lib.units import mm, cm, inch
PAGE_W, PAGE_H = 210 * mm, 297 * mm # A4 in points
MAP_W = 90 * mm # a fixed map column width
assert abs(inch - 72) < 1e-9 # 1 inch == 72 points
Step 2: Use Jinja2 as a Structured Pre-Processor
ReportLab is not HTML-driven, but that does not mean layout content belongs in Python. Render a JSON intermediate with Jinja2 so authors edit a template, not code. The template decides what appears; the builder decides where.
{# report_intermediate.json.j2 #}
{
"title": {{ report_title | tojson }},
"map": { "path": {{ map_path | tojson }}, "width_mm": 90, "epsg": {{ crs_epsg | tojson }} },
"table": {
"columns": ["Parcel", "Zone", "Risk"],
"rows": [
{% for f in features %}
[{{ f.parcel_id | tojson }}, {{ f.zone | tojson }}, {{ f.risk | tojson }}]{{ "," if not loop.last }}
{% endfor %}
]
}
}
# build_intermediate.py
from __future__ import annotations
import json
from jinja2 import Environment, FileSystemLoader
def render_intermediate(context: dict) -> dict:
env = Environment(loader=FileSystemLoader("templates"))
raw = env.get_template("report_intermediate.json.j2").render(**context)
return json.loads(raw) # validated structured data for the builder
Because ReportLab paragraph markup is a small XML-like subset, the same Jinja2 loop that drives table rows can also drive per-cell styling — the technique carried further in Iterating Through Shapefile Attributes in ReportLab.
Step 3: Define PageTemplate and Frame Geometry
A PageTemplate names a page layout and lists the Frame regions content flows into. Here a two-frame layout reserves a fixed map column on the left and a flowing content column on the right.
# document.py
from reportlab.lib.pagesizes import A4
from reportlab.platypus import BaseDocTemplate, PageTemplate, Frame
from reportlab.lib.units import mm
PAGE_W, PAGE_H = A4
MARGIN = 16 * mm
def build_page_template(on_page) -> PageTemplate:
map_frame = Frame(
MARGIN, MARGIN, 90 * mm, PAGE_H - 2 * MARGIN,
id="map", leftPadding=0, rightPadding=0, showBoundary=0,
)
body_frame = Frame(
MARGIN + 96 * mm, MARGIN,
PAGE_W - (MARGIN + 96 * mm) - MARGIN, PAGE_H - 2 * MARGIN,
id="body", showBoundary=0,
)
return PageTemplate(id="report", frames=[map_frame, body_frame], onPage=on_page)
The dedicated Building a ReportLab PageTemplate for Map Frames guide expands this into multi-page templates with scale bars drawn in the onPage callback.
Step 4: Draw Map Images at Exact Coordinates and DPI
To keep a map crisp, generate the source raster at physical size × 300 DPI, then place it with an explicit width and height so ReportLab never upscales. Inside a frame, use the Image flowable; for absolute placement, call canvas.drawImage.
# map_placement.py
from reportlab.platypus import Image
from reportlab.lib.units import mm
from PIL import Image as PILImage
def map_flowable(path: str, width_mm: float = 90) -> Image:
"""Return an Image flowable sized to width_mm, preserving aspect ratio."""
with PILImage.open(path) as im:
px_w, px_h = im.size
target_w = width_mm * mm
target_h = target_w * (px_h / px_w) # lock aspect ratio
img = Image(path, width=target_w, height=target_h)
img.hAlign = "CENTER"
return img
A 90mm-wide map at 300 DPI needs a source raster about 1063px wide (90 / 25.4 × 300). Anything smaller prints soft; anything much larger just bloats the PDF.
Step 5: Flow Attribute Tables with LongTable and Build the Document
LongTable lays out incrementally and splits across pages, unlike Table, which computes the entire layout up front. Set repeatRows=1 so the header reappears on every page. Then hand the flowable list to the document’s build method.
# build_document.py
from reportlab.platypus import LongTable, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib import colors
from reportlab.lib.units import mm
from document import build_page_template
def attribute_table(columns: list[str], rows: list[list[str]]) -> LongTable:
data = [columns] + rows
table = LongTable(data, repeatRows=1, colWidths=[30 * mm, 30 * mm, 20 * mm])
table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f4f4f4")),
("FONTSIZE", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
return table
def build(doc_path: str, intermediate: dict, on_page) -> None:
from reportlab.platypus import BaseDocTemplate
from reportlab.lib.pagesizes import A4
from map_placement import map_flowable
doc = BaseDocTemplate(doc_path, pagesize=A4)
doc.addPageTemplates([build_page_template(on_page)])
styles = getSampleStyleSheet()
story = [
Paragraph(intermediate["title"], styles["Title"]),
map_flowable(intermediate["map"]["path"], intermediate["map"]["width_mm"]),
Spacer(1, 6 * mm),
attribute_table(intermediate["table"]["columns"], intermediate["table"]["rows"]),
]
doc.build(story)
The memory-safe handling of genuinely huge tables is the subject of Flowing Large Attribute Tables with ReportLab Platypus.
Production-Ready Script
A complete entry point that renders the Jinja2 intermediate, builds flowables, draws a scale bar in the onPage callback, and writes the PDF with logging and argparse:
#!/usr/bin/env python3
"""
build_spatial_report.py
Assemble a coordinate-exact spatial report PDF with ReportLab.
Usage: python build_spatial_report.py --data features.geojson --map map.png --out report.pdf
"""
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
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.platypus import (
BaseDocTemplate, PageTemplate, Frame, LongTable, TableStyle,
Paragraph, Spacer, Image,
)
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib import colors
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("reportlab_report")
PAGE_W, PAGE_H = A4
MARGIN = 16 * mm
def build_context(geojson: dict[str, Any], map_path: str) -> dict[str, Any]:
feats = geojson.get("features", [])
return {
"report_title": geojson.get("name", "Spatial Report"),
"map_path": map_path,
"crs_epsg": geojson.get("crs", {}).get("properties", {}).get("name", "4326"),
"features": [
{
"parcel_id": f.get("properties", {}).get("parcel_id", "—"),
"zone": f.get("properties", {}).get("zone", "—"),
"risk": f.get("properties", {}).get("risk_score", "n/a"),
}
for f in feats
],
}
def render_intermediate(context: dict[str, Any]) -> dict[str, Any]:
env = Environment(loader=FileSystemLoader("templates"))
raw = env.get_template("report_intermediate.json.j2").render(**context)
return json.loads(raw)
def draw_furniture(canvas, doc) -> None:
"""onPage callback: scale bar + page number drawn directly on the canvas."""
canvas.saveState()
canvas.setFont("Helvetica", 7)
canvas.drawRightString(PAGE_W - MARGIN, 10 * mm, f"Page {doc.page}")
# 50mm scale bar bottom-left
x0, y0 = MARGIN, 12 * mm
canvas.setLineWidth(1)
canvas.line(x0, y0, x0 + 50 * mm, y0)
canvas.drawString(x0, y0 + 2 * mm, "0 scale 5 km")
canvas.restoreState()
def build_pdf(out_path: Path, intermediate: dict[str, Any]) -> None:
doc = BaseDocTemplate(str(out_path), pagesize=A4)
map_frame = Frame(MARGIN, MARGIN, 90 * mm, PAGE_H - 2 * MARGIN, id="map")
body_frame = Frame(
MARGIN + 96 * mm, MARGIN,
PAGE_W - (MARGIN + 96 * mm) - MARGIN, PAGE_H - 2 * MARGIN, id="body",
)
doc.addPageTemplates([PageTemplate(id="report", frames=[map_frame, body_frame],
onPage=draw_furniture)])
styles = getSampleStyleSheet()
tbl = LongTable(
[intermediate["table"]["columns"]] + intermediate["table"]["rows"],
repeatRows=1, colWidths=[30 * mm, 30 * mm, 20 * mm],
)
tbl.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f4f4f4")),
("FONTSIZE", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
]))
story = [
Paragraph(intermediate["title"], styles["Title"]),
Image(intermediate["map"]["path"], width=90 * mm, height=90 * mm),
Spacer(1, 6 * mm),
tbl,
]
doc.build(story)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Build a spatial report with ReportLab")
parser.add_argument("--data", required=True, type=Path)
parser.add_argument("--map", required=True, type=Path)
parser.add_argument("--out", default=Path("report.pdf"), type=Path)
args = parser.parse_args(argv)
try:
geojson = json.loads(args.data.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.error("Cannot read %s: %s", args.data, exc)
return 2
context = build_context(geojson, str(args.map))
try:
intermediate = render_intermediate(context)
build_pdf(args.out, intermediate)
except Exception: # noqa: BLE001
logger.exception("ReportLab build failed")
return 1
logger.info("Wrote %s (%d rows)", args.out, len(context["features"]))
return 0
if __name__ == "__main__":
sys.exit(main())
Edge Cases & Advanced Configuration
Null Data and Empty Tables
When a filter yields zero features, emit a single-cell LongTable or a styled Paragraph placeholder rather than a header-only table, which prints as a stray horizontal rule. Pre-compute the empty flag in Python — the same discipline used for Conditional Rendering for Missing Spatial Data in the HTML pipeline.
Scale and Memory
LongTable is the memory pivot for large datasets: it releases laid-out rows as it splits, whereas Table retains the whole grid. Avoid wrapping every cell in a Paragraph — plain strings are far cheaper, and per-cell Paragraph objects are the usual cause of a table build ballooning past a gigabyte.
Multi-Format and Vector Maps
ReportLab can embed vector artwork directly via svglib, keeping map linework crisp at any zoom. For raster maps, JPEG cuts file size for photographic basemaps while PNG preserves sharp categorical fills.
Headless CI/CD
Because reportlab has no native display dependency, it drops into a slim CI image with nothing beyond pip install. That simplicity is one axis of the trade-off analysed in WeasyPrint vs ReportLab for Spatial PDFs, and it packages cleanly alongside the Headless PDF Rendering in Docker Containers recipe.
Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
| Map prints blurry | Source raster below target DPI | Regenerate at physical size × 300 DPI; pass explicit width/height so ReportLab never upscales |
| Table header appears only on page one | repeatRows not set |
Use LongTable(..., repeatRows=1) so the header row carries across page breaks |
| Build exhausts memory on a huge table | Table retains the full grid; per-cell Paragraph overhead |
Switch to LongTable; use plain strings, not Paragraph, for simple cells |
| Content lands in the wrong column | Frame order or geometry incorrect | Frames fill in list order; verify each Frame’s x/y/width/height in points |
| Scale bar or header missing on later pages | Drawing done once instead of in onPage |
Draw fixed furniture inside the onPage callback, which fires per page |
| Image flowable distorted | Width and height set independently of aspect ratio | Compute height from source pixel ratio, as in map_flowable |
Frequently Asked Questions
When should I use the ReportLab canvas instead of Platypus flowables?
Use the low-level canvas when you need absolute coordinate control — placing a map at an exact point, drawing a scale bar, or overlaying coordinate ticks. Use Platypus flowables when content must reflow and paginate automatically, such as long attribute tables and narrative text. Most spatial reports combine both: flowables inside frames, canvas drawing in onPage callbacks.
How do I place a map image at an exact size in ReportLab?
ReportLab measures in points, where 1 point equals 1/72 inch. To place a 90mm-wide map, convert with the mm unit and pass explicit width and height to drawImage or the Image flowable. Generate the source raster at the target physical size × 300 DPI so it prints without upscaling.
Can I use Jinja2 with ReportLab even though ReportLab is not HTML-based?
Yes. Jinja2 renders a structured intermediate — JSON or a small paragraph markup subset — that a Python builder walks to emit flowables. This keeps content and presentation logic in templates while ReportLab handles precise placement, giving you the maintainability of templating with the precision of a canvas engine.
Why does my large attribute table run out of memory in ReportLab?
The standard Table flowable computes the whole layout in memory before splitting. For thousands of rows, use LongTable, which lays out incrementally, and set repeatRows to carry the header across pages. Chunking rows and avoiding per-cell Paragraph objects further cuts memory, as detailed in the large-table guide below.
Detailed Guides in This Section
- Building a ReportLab PageTemplate for Map Frames — define
PageTemplateandFrameobjects, position a mapImagein a fixed frame, and draw headers and scale bars viaonPagecallbacks. - Flowing Large Attribute Tables with ReportLab Platypus — stream tens of thousands of rows with
LongTable, repeat headers, and keep memory bounded while avoiding mid-row page splits.
Related
- Jinja2 Templating & Theme Logic for Automated Spatial Reporting — the templating layer that pre-processes ReportLab intermediates
- WeasyPrint PDF Rendering Pipeline — the HTML/CSS alternative to programmatic layout
- Iterating Through Shapefile Attributes in ReportLab — driving flowable rows from shapefile attribute records
- Table Pagination Strategies for Large Attribute Tables — pagination patterns shared across both PDF engines
- Headless PDF Rendering in Docker Containers — packaging ReportLab batch jobs for CI
Parent: Jinja2 Templating & Theme Logic for Automated Spatial Reporting
Conclusion
ReportLab gives spatial reporting teams coordinate-exact control that HTML reflow cannot match: PageTemplate and Frame geometry fix a map column to the point, onPage callbacks draw scale bars and headers, and LongTable streams enormous attribute tables without exhausting memory. Drive it from a Jinja2 intermediate and you keep content in templates while the canvas engine guarantees placement precision on every run.