Handling Multi-Page Landscape vs Portrait Switches in Automated Spatial Reports
Orientation switching is one of the most common sources of broken layout in automated GIS PDF pipelines. Spatial reports routinely combine wide map canvases — which require landscape pages — with dense methodology narratives, attribute tables, and executive summaries that read more cleanly in portrait. When those switches are left implicit, PDF engines stretch content, clip bleed zones, or force a single orientation across the entire document, breaking visual hierarchy and violating cartographic conventions. This guide is a focused implementation step within the broader CSS Grid Systems for Report Layouts workflow, covering exactly how to declare and trigger orientation changes using CSS named @page rules, wire the triggers into a Python/WeasyPrint pipeline, and pre-scale GIS exports so they fit the usable print area without distortion.
Prerequisites
- Python 3.10+ with
weasyprint>=60.0andjinja2>=3.1installed:pip install weasyprint jinja2 - Familiarity with CSS Grid Systems for Report Layouts — specifically how
@pagerules interact with grid containers. - GIS assets (maps, legends, scale bars) exported from QGIS, GeoPandas/Matplotlib, or a tile renderer at a known DPI. If you are converting from QGIS layout templates, see Converting QGIS Layout Templates to Automated CSS Grids for the upstream export step.
- A target paper standard (US Letter or ISO A4) confirmed before export — aspect ratios differ and cannot be corrected at render time without distortion.
Step 1: Declare Named @page Rules
The W3C CSS Paged Media Module Level 3 specification lets you attach a name to any @page block and then assign that name to an HTML element. WeasyPrint and PrinceXML fully support this pattern. Declare three blocks: a default fallback, a named landscape context, and a named portrait context.
/* Fallback — applies to any page without an explicit assignment */
@page {
size: letter portrait;
margin: 20mm;
}
/* Named landscape context */
@page landscape {
size: letter landscape;
margin: 15mm;
}
/* Named portrait context */
@page portrait {
size: letter portrait;
margin: 20mm;
}
Keep margins in the @page block exclusively. Do not override them with inline styles on content elements — that causes margin drift when the renderer calculates available space for pagination.
Step 2: Assign Page Names to Wrapper Elements
Add orientation classes to the HTML wrapper elements that open each section. Two CSS properties work together: page names the @page context and break-before: page forces a hard page break before the element so the new orientation takes effect immediately.
/* Portrait section wrapper */
.page-portrait {
page: portrait;
break-before: page;
page-break-before: always; /* legacy fallback for older renderers */
}
/* Landscape section wrapper */
.page-landscape {
page: landscape;
break-before: page;
page-break-before: always;
}
/* Prevent spatial elements from splitting across a page boundary */
.map-canvas,
.legend-container,
.scale-bar {
break-inside: avoid;
page-break-inside: avoid; /* legacy fallback */
}
break-inside: avoid must be applied to the parent container, not just the <img> tag — the renderer computes break opportunities on block-level boxes, and an image nested inside a <div> can still split if the <div> does not carry the constraint.
Step 3: Pre-Scale GIS Assets to the Usable Print Area
CSS width: 100% cannot reliably upscale low-resolution raster maps without introducing pixelation and breaking scale bar accuracy. Export assets at the exact pixel dimensions that match the usable print area at your target DPI.
Usable area formula:
usable_width_mm = page_width_mm - left_margin_mm - right_margin_mm
usable_height_mm = page_height_mm - top_margin_mm - bottom_margin_mm
pixel_width = (usable_width_mm / 25.4) * dpi
pixel_height = (usable_height_mm / 25.4) * dpi
US Letter landscape at 150 DPI, 15 mm margins:
usable_width_mm = 279.4 - 15 - 15 = 249.4 mm → (249.4 / 25.4) × 150 ≈ 1473 px
usable_height_mm = 215.9 - 15 - 15 = 185.9 mm → (185.9 / 25.4) × 150 ≈ 1098 px
Export GIS maps at 1473 × 1098 px for this configuration. Confirm the aspect ratio matches the usable area (≈ 1.34:1 for Letter landscape) before injecting into HTML. This calculation applies equally to bleed-safe map exports; see How to Set Exact Bleed Margins in WeasyPrint for GIS Maps for the bleed offset variant.
Step 4: Assemble the HTML Document in Python
Build the full HTML document as a string before passing it to WeasyPrint. Using Jinja2 keeps orientation logic declarative and avoids error-prone string concatenation for complex reports.
from __future__ import annotations
from pathlib import Path
from typing import NamedTuple
from jinja2 import Environment, BaseLoader
from weasyprint import HTML, CSS
REPORT_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
@page { size: letter portrait; margin: 20mm; }
@page landscape { size: letter landscape; margin: 15mm; }
@page portrait { size: letter portrait; margin: 20mm; }
.page-portrait { page: portrait; break-before: page; page-break-before: always; }
.page-landscape { page: landscape; break-before: page; page-break-before: always; }
.map-canvas,
.legend-container,
.scale-bar { break-inside: avoid; page-break-inside: avoid; }
body { font-family: sans-serif; font-size: 10pt; color: #1a1a1a; }
img { display: block; width: 100%; height: auto; }
</style>
</head>
<body>
{%- for section in sections %}
<div class="{{ section.orientation_class }}">
{{ section.html | safe }}
</div>
{%- endfor %}
</body>
</html>
"""
class ReportSection(NamedTuple):
orientation_class: str # "page-portrait" | "page-landscape"
html: str
def render_spatial_report(
sections: list[ReportSection],
output_path: Path,
dpi: int = 150,
) -> None:
"""Render a mixed-orientation spatial PDF from a list of HTML sections."""
env = Environment(loader=BaseLoader())
template = env.from_string(REPORT_TEMPLATE)
html_string = template.render(sections=sections)
HTML(string=html_string).write_pdf(
str(output_path),
presentational_hints=True,
)
# --- Usage ---
sections = [
ReportSection(
orientation_class="page-portrait",
html="<h1>Executive Summary</h1><p>Methodology details…</p>",
),
ReportSection(
orientation_class="page-landscape",
html=(
'<div class="map-canvas">'
'<img src="site_overview_1473x1098.png" alt="Site overview map">'
'</div>'
'<div class="legend-container">'
'<img src="legend.svg" alt="Map legend">'
'</div>'
'<div class="scale-bar">'
'<img src="scale_bar.svg" alt="Scale bar">'
'</div>'
),
),
ReportSection(
orientation_class="page-portrait",
html="<h2>Attribute Summary</h2><table>…</table>",
),
]
render_spatial_report(sections, Path("spatial_report.pdf"))
presentational_hints=True allows WeasyPrint to honour HTML attribute hints such as width and align that may appear in legacy table markup sourced from GIS tools.
Step 5: Guard Scale Bars and Legends Against Page Splits
If a legend or scale bar lives in the same wrapper <div> as a map image but the image itself overflows the page, the renderer may attempt to split the container. Two strategies prevent this:
Strategy A — Self-contained orientation wrapper (preferred): Place the map canvas, legend, and scale bar all inside a single .page-landscape <div>. The break-inside: avoid on .map-canvas and .legend-container prevents internal splits, and break-before: page on the wrapper guarantees the entire unit starts on a fresh landscape page.
Strategy B — Duplicate the scale bar: If the map canvas is too tall to fit with the legend on a single page, export the scale bar as a separate SVG and inject it again at the top of the next landscape page. Automated pipelines should derive the scale bar SVG dynamically from the map’s CRS and zoom level so duplication is exact. The Automating Legend Scaling Based on Layer Complexity guide covers dynamic SVG generation from layer metadata that feeds directly into this strategy.
def build_landscape_section(
map_img_path: str,
legend_svg: str,
scale_bar_svg: str,
map_alt: str,
) -> str:
"""Return an HTML fragment for a self-contained landscape map page."""
return (
f'<div class="map-canvas">'
f' <img src="{map_img_path}" alt="{map_alt}">'
f'</div>'
f'<div class="legend-container">{legend_svg}</div>'
f'<div class="scale-bar">{scale_bar_svg}</div>'
)
Key Parameters / Configuration Reference
| Spec | Value | Notes |
|---|---|---|
@page landscape size — US Letter |
279.4mm × 215.9mm |
Confirm renderer does not swap width/height in output |
@page portrait size — US Letter |
215.9mm × 279.4mm |
Default for text-heavy sections |
| Landscape margin | 15mm |
Tighter than portrait to maximise map canvas |
| Portrait margin | 20mm |
Standard reading margin |
| Recommended export DPI | 150–300 |
150 for screen-review PDFs; 300 for print |
| Map export width (Letter landscape, 15 mm margin, 150 DPI) | ≈ 1473 px |
(249.4 / 25.4) × 150 |
| Map export height (same config) | ≈ 1098 px |
(185.9 / 25.4) × 150 |
break-before value |
page |
Triggers @page name lookup on the new page |
break-inside value |
avoid |
Must be on the block container, not the <img> |
| Legacy fallback | page-break-before: always |
Required for WeasyPrint < 53, PrinceXML older builds |
Common Pitfalls
-
Orientation silently ignored. The
page:CSS property value must match the@pagename exactly —page: landscapetargets@page landscape. A quoted value (page: 'landscape') is invalid in WeasyPrint and will fall back to the default@pagewithout raising an error. -
Blank pages between orientation switches. When two consecutive
<div>elements each havebreak-before: pageand they share the same orientation, WeasyPrint may insert a blank separator page. Guard against this by only applyingbreak-before: pageto the first element of each orientation run, not every element within that orientation. -
Scale bar pixel width wrong after CSS stretch. If the map image is narrower than the usable print area and CSS stretches it to fill, the embedded scale bar raster becomes inaccurate. Always export the map at the exact usable-area pixel dimensions so no scaling occurs at render time.
-
Margins overridden by inline styles. If a CSS framework or QGIS HTML export injects
style="margin: 0"on<body>, it can override the@pagemargin calculation. Strip or reset inline margin styles before passing the document to WeasyPrint, or use!importanton the@pagemargin declaration as a last resort.
Verification
Open the rendered PDF in a tool that exposes media box metadata (pdfinfo from poppler-utils, Adobe Acrobat, or pymupdf). The media box of every landscape page must show width > height.
pdfinfo spatial_report.pdf | grep "Page size"
# Expect alternating dimensions for a mixed-orientation report
For automated CI validation, assert media box dimensions with PyMuPDF:
import fitz # pymupdf
def assert_page_orientations(pdf_path: str, expected: list[str]) -> None:
"""Assert each page matches 'landscape' or 'portrait' as expected."""
doc = fitz.open(pdf_path)
for i, page in enumerate(doc):
w, h = page.rect.width, page.rect.height
actual = "landscape" if w > h else "portrait"
assert actual == expected[i], (
f"Page {i + 1}: expected {expected[i]}, got {actual} ({w:.0f}×{h:.0f})"
)
assert_page_orientations(
"spatial_report.pdf",
["portrait", "landscape", "portrait"],
)
Add this assertion as a CI gate against a committed reference PDF to catch silent orientation regressions before any spatial report is published.
Why does WeasyPrint ignore my @page landscape rule?
The most common cause is a mismatch between the CSS selector and the page name. The wrapper element must carry page: landscape (not page: 'landscape' with quotes) and break-before: page. Verify that selector specificity is not overridden by an inline style imported from a GIS tool’s HTML export.
Should I use break-before: page or page-break-before: always?
Use break-before: page as the primary directive — it is the current CSS Paged Media standard. Also include page-break-before: always as a legacy fallback. WeasyPrint 61+ honours break-before: page correctly; versions below 53 require the legacy form.
How do I calculate map export dimensions for a landscape page?
Usable width = page width minus both horizontal margins. For US Letter landscape (279.4 mm) with 15 mm margins: 279.4 − 30 = 249.4 mm usable. At 150 DPI that is (249.4 / 25.4) × 150 ≈ 1473 px. Export at exactly that width and the proportional height to prevent any scaling at render time.
Related
- CSS Grid Systems for Report Layouts — parent guide covering the full grid template system this technique extends
- How to Set Exact Bleed Margins in WeasyPrint for GIS Maps — companion technique for bleed-safe map placement on landscape pages
- Converting QGIS Layout Templates to Automated CSS Grids — upstream step for exporting GIS assets from QGIS at print-ready dimensions
- Preventing Table Row Splits Across PDF Page Breaks — applies the same
break-inside: avoidlogic to attribute tables in portrait sections