Building a ReportLab PageTemplate for Map Frames
A map frame in a spatial report is a fixed rectangle: it must occupy the same position on every page so plates read consistently, and its furniture — north arrow, scale bar, running header — must sit at exact coordinates. ReportLab’s PageTemplate and Frame objects express precisely this, and onPage callbacks on a BaseDocTemplate handle the fixed canvas drawing. This technique is the layout backbone of ReportLab Programmatic Layout: where the parent guide surveys the whole engine, this one focuses on wiring frames and callbacks so a map Image flowable lands in a reserved region while narrative flows beside it.
Image flowable; the body frame receives flowing text and tables; the header, scale bar, and page number are drawn by the onPage callback.Prerequisites
- Python 3.10+ with
reportlab>=4.0andpillow>=10 - A map raster generated at the frame’s physical width × 300 DPI, per the DPI guidance in ReportLab Programmatic Layout
- Familiarity with ReportLab units: the origin is bottom-left, and all coordinates are in points (
1mm ≈ 2.835pt)
Step 1: Compute Frame Geometry in Points
Every Frame is a rectangle defined by its lower-left corner (x, y), width, and height, all in points. Derive them from the page size and margins so the layout scales if you change paper size.
# geometry.py
from __future__ import annotations
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
PAGE_W, PAGE_H = A4 # (595.27, 841.89) points
MARGIN = 16 * mm
HEADER_H = 12 * mm # reserved band the onPage header draws into
MAP_W = 90 * mm
GUTTER = 6 * mm
CONTENT_BOTTOM = MARGIN
CONTENT_TOP = PAGE_H - MARGIN - HEADER_H
CONTENT_H = CONTENT_TOP - CONTENT_BOTTOM
MAP_FRAME = (MARGIN, CONTENT_BOTTOM, MAP_W, CONTENT_H)
BODY_X = MARGIN + MAP_W + GUTTER
BODY_FRAME = (BODY_X, CONTENT_BOTTOM, PAGE_W - BODY_X - MARGIN, CONTENT_H)
Reserving HEADER_H at the top keeps flowable content from colliding with the header the onPage callback paints — frames and canvas drawing share the page, so their bands must not overlap.
Step 2: Define Frame and PageTemplate Objects
A Frame holds flowables; a PageTemplate bundles frames with an onPage callback. Content fills frames in list order, so put the map frame first.
# template.py
from reportlab.platypus import Frame, PageTemplate
from geometry import MAP_FRAME, BODY_FRAME
def make_template(on_page) -> PageTemplate:
map_frame = Frame(*MAP_FRAME, id="map",
leftPadding=0, rightPadding=0,
topPadding=0, bottomPadding=0, showBoundary=0)
body_frame = Frame(*BODY_FRAME, id="body", showBoundary=0)
return PageTemplate(id="map_report", frames=[map_frame, body_frame], onPage=on_page)
Setting the map frame’s padding to zero ensures the Image fills the reserved width exactly; the default 6pt padding would otherwise shrink the map and break the 300 DPI sizing math.
Step 3: Position the Map Image Flowable in the Fixed Frame
Size the Image to the frame width and derive its height from the source pixel ratio so the map is never distorted. A FrameBreak after the map pushes subsequent flowables into the body frame.
# map_image.py
from reportlab.platypus import Image, FrameBreak
from reportlab.lib.units import mm
from PIL import Image as PILImage
from geometry import MAP_W
def map_story(map_path: str) -> list:
with PILImage.open(map_path) as im:
px_w, px_h = im.size
height = MAP_W * (px_h / px_w) # lock aspect ratio to the 90mm width
img = Image(map_path, width=MAP_W, height=height)
img.hAlign = "LEFT"
return [img, FrameBreak()] # fill map frame, then move to body frame
FrameBreak is the mechanism that separates fixed and flowing content: without it, a short map would leave the body content starting in the leftover space of the map frame.
Step 4: Draw Headers and Scale Bars with an onPage Callback
The onPage callback receives the live canvas and the document, and fires once per page. Draw everything positional here — running header, north arrow, scale bar, page number — using saveState/restoreState so canvas settings never leak.
# furniture.py
from reportlab.lib.units import mm
from geometry import PAGE_W, PAGE_H, MARGIN, MAP_W
def draw_furniture(canvas, doc) -> None:
canvas.saveState()
# Running header
canvas.setFont("Helvetica-Bold", 9)
canvas.drawString(MARGIN, PAGE_H - MARGIN - 4 * mm, "Flood Risk Assessment")
canvas.setLineWidth(0.5)
canvas.line(MARGIN, PAGE_H - MARGIN - 6 * mm,
PAGE_W - MARGIN, PAGE_H - MARGIN - 6 * mm)
# Scale bar inside the map frame (5 km over 50 mm)
bx, by = MARGIN, MARGIN + 6 * mm
canvas.setLineWidth(1.2)
canvas.line(bx, by, bx + 50 * mm, by)
for i in range(6):
x = bx + i * 10 * mm
canvas.line(x, by, x, by + 2 * mm)
canvas.setFont("Helvetica", 6.5)
canvas.drawString(bx, by + 3 * mm, "0")
canvas.drawRightString(bx + 50 * mm, by + 3 * mm, "5 km")
# North arrow at the top-right of the map frame
nx, ny = MARGIN + MAP_W - 8 * mm, PAGE_H - MARGIN - 20 * mm
canvas.setFont("Helvetica-Bold", 8)
canvas.drawCentredString(nx, ny, "N")
canvas.line(nx, ny + 3, nx, ny + 12)
# Page number
canvas.setFont("Helvetica", 7)
canvas.drawRightString(PAGE_W - MARGIN, MARGIN - 4 * mm, f"Page {doc.page}")
canvas.restoreState()
Because the callback reads doc.page, the page number updates automatically as the body content paginates across pages.
Step 5: Assemble the BaseDocTemplate and Build
Register the template on a BaseDocTemplate and call build with the combined story. Feed the body content from your Jinja2 intermediate, as established in the ReportLab Programmatic Layout pipeline.
# build.py
from reportlab.platypus import BaseDocTemplate, Paragraph, LongTable, TableStyle
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from template import make_template
from furniture import draw_furniture
from map_image import map_story
def build_map_report(out_path: str, map_path: str, rows: list[list[str]]) -> None:
doc = BaseDocTemplate(out_path, pagesize=A4)
doc.addPageTemplates([make_template(draw_furniture)])
styles = getSampleStyleSheet()
table = LongTable([["Parcel", "Risk"]] + rows, repeatRows=1,
colWidths=[40 * mm, 25 * mm])
table.setStyle(TableStyle([
("FONTSIZE", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
]))
story = map_story(map_path) + [
Paragraph("Assessed parcels within the mapped extent:", styles["BodyText"]),
table,
]
doc.build(story)
Key Parameters / Configuration Reference
| Parameter | Type | Default | Effect |
|---|---|---|---|
Frame(x, y, w, h) |
points | — | Lower-left origin and size of a content region |
Frame(showBoundary=) |
int/bool |
0 |
Draws the frame outline; set 1 while debugging geometry |
Frame(leftPadding=…) |
points | 6 |
Inner padding; set 0 on map frames for exact image sizing |
PageTemplate(onPage=) |
callable | None |
Callback drawing fixed furniture once per page |
FrameBreak() |
flowable | — | Forces the next flowable into the following frame |
LongTable(repeatRows=) |
int |
0 |
Header rows repeated after a page split |
Common Pitfalls
- Forgetting
FrameBreakafter the map. Body flowables then start in the map frame’s leftover space instead of the body column. - Non-zero padding on the map frame. The default 6pt padding shrinks the drawable width, so a 90mm-sized image no longer fills the frame and the DPI math is off.
- Drawing furniture in the story instead of
onPage. A scale bar added as a flowable appears once and flows away; furniture must be drawn per page in the callback. - Mutating canvas state without
saveState/restoreState. Font or line-width changes leak into later drawing and corrupt subsequent pages.
Verification
Enable frame boundaries and render a two-page sample to confirm the geometry, then assert the page count matches the flowed content:
# Temporarily set showBoundary=1 in make_template, then:
from pypdf import PdfReader
build_map_report("out/plate.pdf", "maps/zone.png", [["P-1", "High"]] * 60)
reader = PdfReader("out/plate.pdf")
assert len(reader.pages) >= 2, "expected table to paginate onto a second page"
print(f"Rendered {len(reader.pages)} pages")
Visually confirm the map fills the left frame edge-to-edge, the scale bar and north arrow sit inside it, and the running header repeats on page two.
Related
- Flowing Large Attribute Tables with ReportLab Platypus — sibling guide on streaming the body-frame tables this layout hosts
- ReportLab Programmatic Layout — the parent pipeline this frame layout plugs into
- Iterating Through Shapefile Attributes in ReportLab — building the row data that fills the body frame
Parent: ReportLab Programmatic Layout