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.

WeasyPrint @page Box Anatomy The bleed box surrounds the trimmed page edge with crop marks at the corners; inside the trim, a margin band contains a top-center running header box and a bottom-right page number box, surrounding the central content area. bleed box (3mm) trimmed page edge @top-center header content area map + tables @bottom-right page
The page box: crop marks sit in the bleed area outside the trim; margin boxes (@top-center, @bottom-right) live in the margin band between the trim edge and the content area.

Prerequisites

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.

CSS
@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.

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

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

CSS
@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;
}
Jinja
{% 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.

CSS
@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 bleed to work without a linked stylesheet. WeasyPrint applies @page rules from CSS you pass via stylesheets= or a <link>/<style> in the document. Rules buried in a Chromium-only @media print block that never reaches WeasyPrint are silently ignored.
  • Using @page margin boxes and expecting them in Playwright output. Margin boxes, string(), and marks are 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-map alone will not start a new page; pair it with page-break-before: always so the spread begins cleanly.
  • Bleed declared but content stops at the trim. bleed: 3mm reserves 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:

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


Parent: WeasyPrint PDF Rendering Pipeline