Configuring WeasyPrint @page Rules for Spatial Reports
The @page at-rule is where a spatial report’s physical geometry is declared: sheet size, margins, printer bleed, crop marks, and the margin boxes that carry running headers and page numbers. This technique is the print-configuration layer of the WeasyPrint PDF Rendering Pipeline — once your Jinja2 template produces valid HTML, the @page rules decide how that content is trimmed, folded, and paginated. Unlike Chromium-based renderers, WeasyPrint implements the full CSS Paged Media Module, including named pages, margin boxes, and the bleed/marks properties that commercial print delivery depends on.
@top-center, @bottom-right) live in the margin band between the trim edge and the content area.Prerequisites
- Python 3.10+ with
weasyprint>=60installed and its native libraries verified (weasyprint --info) - A working render call that already passes a stylesheet, as established in the WeasyPrint PDF Rendering Pipeline
- Target trim and bleed dimensions from your print vendor — cross-checked against Print-Ready Page Sizing Standards for GIS Reports
Step 1: Set Page Size, Margins, Bleed, and Crop Marks
The base @page rule defines the trimmed sheet. size sets the finished dimensions; margin reserves the band for margin boxes and body inset; bleed extends the paintable area beyond the trim so full-bleed maps survive the guillotine; marks: crop cross tells WeasyPrint to draw registration furniture.
@page {
size: A4 portrait; /* 210mm × 297mm finished */
margin: 18mm 16mm 20mm 16mm;
bleed: 3mm; /* paint 3mm past the trim on every edge */
marks: crop cross; /* draw crop + registration marks */
}
bleed only produces visible ink extension if your full-bleed elements actually reach the bleed box — set such elements to width: 100% inside a container that itself overflows the trim, or use negative margins. The precise safe-zone and gutter arithmetic is worked through in Margin and Bleed Alignment in Automated PDFs.
Step 2: Add Margin Boxes for Running Headers and Page Numbers
WeasyPrint exposes sixteen margin boxes (@top-left, @top-center, @bottom-right, and so on). Each accepts a content property that can pull a running string via string(), a live page count via counter(), or generated text. Lift the report title into the header with the string-set mechanism so every page carries the current section name.
h1 { string-set: doc-title content(); }
h2 { string-set: section content(); }
@page {
@top-center {
content: string(doc-title);
font-size: 8pt;
opacity: 0.7;
}
@top-right {
content: string(section);
font-size: 8pt;
opacity: 0.6;
}
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-size: 8pt;
}
@bottom-left {
content: "Generated " string(gen-date);
font-size: 7pt;
opacity: 0.55;
}
}
Inject the generation date from Jinja2 into a hidden element so string-set can capture it:
<span style="string-set: gen-date content(); display: none">{{ generated_at }}</span>
Step 3: Define Named Pages for Landscape Map Spreads
Spatial reports routinely mix portrait narrative pages with landscape map spreads. Declare a named page and switch orientation on it, then assign that page to the map container with the page property. WeasyPrint honours the orientation change per element — a capability that keeps large-format maps readable without splitting them.
@page landscape-map {
size: A4 landscape; /* 297mm × 210mm */
margin: 12mm;
@bottom-right {
content: "Map " counter(map-plate);
font-size: 8pt;
}
}
.map-spread {
page: landscape-map; /* force this block onto a landscape named page */
page-break-before: always;
counter-increment: map-plate;
}
{% for plate in map_plates %}
<section class="map-spread">
<img src="maps/{{ plate.slug }}.png" alt="Map plate {{ loop.index }}: {{ plate.title }}">
</section>
{% endfor %}
The orientation-transition mechanics — and how to keep a CSS Grid from collapsing across the switch — are covered in Handling Multi-Page Landscape vs Portrait Switches.
Step 4: Target the First Page with :first
A cover page usually needs no running header. The :first pseudo-class targets only the opening page of the document, letting you blank its margin boxes.
@page :first {
@top-center { content: none; }
@top-right { content: none; }
margin-top: 40mm; /* extra breathing room for the title block */
}
Combine :first with :left/:right when a bound report needs mirrored inner margins for the gutter.
Key Parameters / Configuration Reference
| Property | Value | Notes |
|---|---|---|
size |
A4 portrait / A3 landscape / 210mm 297mm |
Keyword or explicit dimensions; sets the trim box |
margin |
18mm 16mm 20mm 16mm |
Reserves the band that margin boxes occupy |
bleed |
3mm |
Paintable extension past the trim; 3mm is the standard commercial value |
marks |
crop cross |
Draws crop and/or registration marks in the bleed area |
page |
landscape-map |
On an element, assigns it to a named @page rule |
string-set / string() |
content() |
Lifts live text into margin boxes as running headers |
counter(page) / counter(pages) |
— | Current and total page numbers inside margin-box content |
Common Pitfalls
- Expecting
bleedto work without a linked stylesheet. WeasyPrint applies@pagerules from CSS you pass viastylesheets=or a<link>/<style>in the document. Rules buried in a Chromium-only@media printblock that never reaches WeasyPrint are silently ignored. - Using
@pagemargin boxes and expecting them in Playwright output. Margin boxes,string(), andmarksare Paged Media features Chromium does not implement. Keep these rules WeasyPrint-specific and do not share them with a browser-print path. - Named page assigned but not force-breaking.
page: landscape-mapalone will not start a new page; pair it withpage-break-before: alwaysso the spread begins cleanly. - Bleed declared but content stops at the trim.
bleed: 3mmreserves the area but paints nothing unless a full-bleed element actually overflows to the bleed box.
Verification
Render a two-page sample and confirm the geometry programmatically with pypdf, checking the MediaBox (bleed) and TrimBox dimensions:
from pypdf import PdfReader
reader = PdfReader("out/sample.pdf")
page = reader.pages[0]
media = [float(v) for v in page.mediabox]
trim = [float(v) for v in page.trimbox]
# MediaBox should exceed TrimBox by 2 × bleed (3mm ≈ 8.5pt) on each axis
assert media[2] - trim[2] >= 8, "bleed not applied to MediaBox width"
print("MediaBox", media, "TrimBox", trim)
Visually, open the PDF at 100% scale and confirm crop marks sit outside the trim, the running header shows the current section, and Page X of Y increments correctly.
Related
- WeasyPrint vs ReportLab for Spatial PDFs — sibling guide weighing HTML
@pagecontrol against programmatic canvas placement - Margin and Bleed Alignment in Automated PDFs — safe zones, trim marks, and gutter compensation feeding these
@pagevalues - Print-Ready Page Sizing Standards for GIS Reports — ISO 216 and ANSI dimensions for the
sizeproperty