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.

ReportLab Map-Frame PageTemplate Layout An A4 page with a running header band at top drawn by onPage, a fixed map frame on the left containing a map and scale bar, and a body frame on the right containing flowing paragraphs and a table. onPage: running header map frame Image flowable (fixed 90mm) scale bar body frame flowing text attribute table onPage: Page N
The map frame reserves a fixed left column for the 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.0 and pillow>=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.

Python
# 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.

Python
# 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.

Python
# 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.

Python
# 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.

Python
# 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 FrameBreak after 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:

Python
# 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.


Parent: ReportLab Programmatic Layout