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.

Multi-page orientation switch pipeline Three-layer pipeline: raw GIS data flows through pre-scaled asset exports into CSS named @page rules, producing a mixed-orientation PDF with alternating portrait and landscape pages. Data Layer GeoJSON / raster / DB Asset Layer PNG/SVG at exact DPI Layout Layer HTML + named @page CSS Portrait Landscape map canvas ① raw data ② pre-scaled export ③ @page rules ④ PDF output Three-layer orientation pipeline Each wrapper div carries page: landscape/portrait + break-before: page WeasyPrint reads the page name, switches the @page context, and opens a new page in the correct orientation.

Prerequisites

  • Python 3.10+ with weasyprint>=60.0 and jinja2>=3.1 installed:
    Text
    pip install weasyprint jinja2
    
  • Familiarity with CSS Grid Systems for Report Layouts — specifically how @page rules 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.

CSS
/* 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.

CSS
/* 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:

Text
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:

Text
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.

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

Python
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 @page name exactly — page: landscape targets @page landscape. A quoted value (page: 'landscape') is invalid in WeasyPrint and will fall back to the default @page without raising an error.

  • Blank pages between orientation switches. When two consecutive <div> elements each have break-before: page and they share the same orientation, WeasyPrint may insert a blank separator page. Guard against this by only applying break-before: page to 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 @page margin calculation. Strip or reset inline margin styles before passing the document to WeasyPrint, or use !important on the @page margin 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.

Bash
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:

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