Configuring ReportLab PageTemplate Frames for ISO 216 Sizes

ReportLab gives you absolute control over page geometry, which is exactly what a GIS report needs when a map frame must occupy a precise rectangle on an A3 plate and the methodology text must reflow inside an A4 body frame. The catch is that ReportLab works in points from a bottom-left origin, so getting ISO 216 sizes right means doing the millimetre-to-point conversion deliberately and computing every frame rectangle from the page box. This guide is a focused technique within the Print-Ready Page Sizing Standards for GIS Reports workflow, covering how to size Frame objects to A4 and A3, assemble a BaseDocTemplate with portrait and landscape PageTemplate objects, and reserve a dedicated map frame. For the broader programmatic layout toolkit, see the ReportLab Programmatic Layout section.

ReportLab frame geometry on an ISO 216 page A page rectangle with its origin at the bottom-left corner. Inside, a body frame sits near the top and a taller map frame sits below it, with margin offsets marked from the page edges. A4 page box — 595.28 × 841.89 pt body frame Flowables reflow here map frame reserved for the map Image origin (0, 0) y↑ x→ top margin

Prerequisites

  • Python 3.10+ with reportlab>=4.0 installed (pip install reportlab)
  • A pre-rendered map image (PNG or PDF) sized to the intended map-frame dimensions
  • Understanding that ReportLab measures in points (1 pt = 1/72 inch) from a bottom-left origin
  • Familiarity with the ISO 216 series and target margins from Print-Ready Page Sizing Standards for GIS Reports

Step 1: Import ISO 216 Page Sizes and Points

Never hardcode ISO dimensions. reportlab.lib.pagesizes ships the exact point sizes, and reportlab.lib.units provides mm and inch so margin arithmetic stays readable.

Python
from __future__ import annotations

from reportlab.lib.pagesizes import A4, A3, landscape
from reportlab.lib.units import mm

# A4 = (595.2755..., 841.8897...) points  → 210 × 297 mm
# A3 = (841.8897..., 1190.5511...) points → 297 × 420 mm
print(A4, A3)
print(landscape(A3))  # (1190.55, 841.89) — width and height swapped

landscape() and its counterpart portrait() return a swapped (width, height) tuple, which is safer than manually reversing the values. Because A-series sizes share a 1:√2 ratio, an A4 body page pairs cleanly with an A3 fold-out map plate at the same aspect.

Step 2: Compute Frame Geometry from Margins

A Frame is defined by its lower-left corner (x1, y1), plus width and height. Derive all four from the page box and the margins, remembering that the origin is at the bottom.

Python
from reportlab.platypus import Frame


def make_frames(page_size: tuple[float, float], margin: float) -> tuple[Frame, Frame]:
    """Split an ISO page into a top body frame and a lower map frame."""
    page_w, page_h = page_size
    usable_w = page_w - 2 * margin
    usable_h = page_h - 2 * margin

    map_h = usable_h * 0.62          # map frame takes 62% of usable height
    body_h = usable_h - map_h - 6 * mm  # gap between frames

    body_frame = Frame(
        x1=margin,
        y1=margin + map_h + 6 * mm,   # sits above the map frame
        width=usable_w,
        height=body_h,
        id="body",
    )
    map_frame = Frame(
        x1=margin,
        y1=margin,                    # anchored at the bottom margin
        width=usable_w,
        height=map_h,
        id="map",
    )
    return body_frame, map_frame

The critical subtraction is page_h - 2 * margin: using only the top margin is the single most common cause of clipped content, because the bottom margin then eats into the frame without being accounted for.

Step 3: Build a BaseDocTemplate with PageTemplates

BaseDocTemplate is the layout engine; each PageTemplate names a page layout and holds one or more frames. Register a portrait A4 template and a landscape A3 template so the report can switch between text pages and map plates.

Python
from reportlab.platypus import BaseDocTemplate, PageTemplate
from reportlab.lib.units import mm


class SpatialReport(BaseDocTemplate):
    def __init__(self, filename: str, **kw) -> None:
        super().__init__(filename, pagesize=A4, **kw)

        body_a4, map_a4 = make_frames(A4, margin=18 * mm)
        body_a3, map_a3 = make_frames(landscape(A3), margin=15 * mm)

        self.addPageTemplates([
            PageTemplate(id="portrait_body", frames=[body_a4, map_a4],
                         pagesize=A4),
            PageTemplate(id="landscape_plate", frames=[map_a3, body_a3],
                         pagesize=landscape(A3)),
        ])

Ordering frames inside a template matters: ReportLab fills them in list order, so on the landscape plate the map frame is listed first to receive the map Image before any caption flows into the secondary frame.

Step 4: Flow Content and Switch Templates

Content is a list of flowables. NextPageTemplate schedules which template the following page uses, and FrameBreak advances to the next frame within the current template.

Python
from reportlab.platypus import Image, Paragraph, NextPageTemplate, FrameBreak, PageBreak
from reportlab.lib.styles import getSampleStyleSheet

styles = getSampleStyleSheet()


def build_story(map_a4_path: str, map_a3_path: str) -> list:
    return [
        Paragraph("Regional Flood Exposure Assessment", styles["Title"]),
        Paragraph("Methodology and study area follow overleaf.", styles["BodyText"]),
        FrameBreak(),                          # move into the A4 map frame
        Image(map_a4_path, width=170 * mm, height=150 * mm),

        NextPageTemplate("landscape_plate"),   # next page is the A3 plate
        PageBreak(),
        Image(map_a3_path, width=380 * mm, height=250 * mm),
        FrameBreak(),
        Paragraph("Plate 1 — 1:25,000 hydrological catchment.", styles["BodyText"]),
    ]


doc = SpatialReport("iso216_report.pdf")
doc.build(build_story("catchment_a4.png", "catchment_a3.png"))

Sizing the Image explicitly in millimetres keeps the map inside its frame; ReportLab does not clip an oversized image, it overflows the frame silently, so the width and height must be smaller than the frame you computed in Step 2. This flowable-driven approach complements the table-flowing techniques in Iterating Through Shapefile Attributes in ReportLab.

Step 5: Verify the Emitted Page Size

Confirm ReportLab wrote the ISO sizes you intended by reading the media box back.

Bash
pdfinfo iso216_report.pdf | grep "Page size"
# Page 1: 595.28 x 841.89 pts (A4)
# Page 2: 1190.55 x 841.89 pts (A3 landscape)

Key Parameters / Configuration Reference

Spec Value Notes
Point definition 1 pt = 1/72 inch ReportLab’s base unit
A4 size 595.28 × 841.89 pt Import as A4, do not hardcode
A3 size 841.89 × 1190.55 pt Import as A3; use landscape(A3) to swap
Frame origin bottom-left (x1, y1) y grows upward, unlike CSS
Frame height page_h - 2 * margin Subtract both margins or content clips
Frame fill order list order in PageTemplate First frame receives flowables first
NextPageTemplate template id Schedules the template for the next page
FrameBreak Advances to the next frame on the current page

Common Pitfalls

  • Hardcoding 595 × 842 as integers. The true A4 size is fractional (595.28 × 841.89); rounding to integers shifts every frame by up to a point and can push a map past the trim. Import the constants instead.

  • Forgetting the bottom-left origin. Positioning a header at y1 = margin places it at the bottom of the page, not the top. Header frames anchor near page_h - margin - header_height.

  • Oversized images overflowing the frame. ReportLab does not scale an Image to fit its frame automatically. Compute the image width and height to be smaller than the frame or the picture bleeds over the margin.

  • Mismatched pagesize on the PageTemplate. Setting pagesize=A4 on the document but pagesize=landscape(A3) on a template without a NextPageTemplate produces a page at the wrong size. Always schedule the template before the PageBreak that should use it.

Verification

Assert the media box in CI with PyMuPDF so a geometry regression fails the build before a mis-sized report is distributed.

Python
import fitz  # pymupdf

A4_PT = (595.28, 841.89)
A3L_PT = (1190.55, 841.89)

def assert_iso_sizes(pdf_path: str) -> None:
    doc = fitz.open(pdf_path)
    r0, r1 = doc[0].rect, doc[1].rect
    assert round(r0.width) == round(A4_PT[0]) and round(r0.height) == round(A4_PT[1]), \
        f"page 1 not A4: {r0.width:.0f}×{r0.height:.0f}"
    assert round(r1.width) == round(A3L_PT[0]) and round(r1.height) == round(A3L_PT[1]), \
        f"page 2 not A3 landscape: {r1.width:.0f}×{r1.height:.0f}"

assert_iso_sizes("iso216_report.pdf")