How to Set Exact Bleed Margins in WeasyPrint for GIS Maps
This guide demonstrates the precise technique for engineering bleed margins in WeasyPrint using CSS Paged Media, and fits directly into the Print-Ready Page Sizing Standards for GIS Reports workflow. WeasyPrint does not expose native PDF/X bleed box metadata — instead, visual bleed is achieved by expanding the @page canvas to trim + bleed on every axis, zeroing all margins, and using negative CSS positioning to push map rasters past the trim boundary while keeping cartographic overlays strictly inside the safe area.
Prerequisites
- WeasyPrint ≥ 56.0 (earlier versions lack
marks: cropsupport) - Python 3.10+ with
weasyprintinstalled:pip install weasyprint>=56.0 - A GIS map export (PNG or TIFF) from QGIS, ArcGIS Pro, or a Python pipeline using
geopandas+contextily, sized at the correct pixel dimensions (calculated in Step 3 below) - Familiarity with Margin and Bleed Alignment in Automated PDFs as conceptual background — this page focuses on the WeasyPrint-specific implementation
Canvas Zone Diagram
The diagram below shows the relationship between the physical canvas, the trim boundary, the bleed zone, and the safe area on a single printed sheet. The GIS raster fills the entire canvas (including bleed), while cartographic overlays — legends, scale bars, north arrows — are constrained to the inner safe area.
Implementation
Step 1 — Define Trim Size and Bleed Constants
Hard-code your trim dimensions and bleed as named constants. This makes it unambiguous when the values are reused in pixel calculations and CSS templates, and prevents off-by-one drift across multi-page atlas runs.
from pathlib import Path
import weasyprint
# All measurements in millimetres
TRIM_WIDTH: int = 210 # ISO A4 width
TRIM_HEIGHT: int = 297 # ISO A4 height
BLEED: int = 3 # Standard commercial print bleed (3 mm on all sides)
# Physical PDF canvas — what WeasyPrint sets as the MediaBox
PAGE_W: int = TRIM_WIDTH + BLEED * 2 # 216 mm
PAGE_H: int = TRIM_HEIGHT + BLEED * 2 # 303 mm
Using integer millimetres keeps the arithmetic exact. Floating-point values (e.g., 2.5 mm bleed) are valid but require additional rounding checks in the DPI pixel calculation below.
Step 2 — Configure the @page Rule and Reset Margins
The @page size property sets the absolute physical canvas. Setting margin: 0 on both @page and body eliminates every browser-default gutter that would otherwise clip the bleed content.
CSS_TEMPLATE: str = f"""
@page {{
size: {PAGE_W}mm {PAGE_H}mm;
margin: 0;
marks: crop; /* WeasyPrint ≥56: emit trim marks in the bleed zone */
}}
*, *::before, *::after {{
box-sizing: border-box;
}}
html, body {{
margin: 0;
padding: 0;
width: {PAGE_W}mm;
height: {PAGE_H}mm;
font-family: system-ui, -apple-system, sans-serif;
}}
/* Full bleed canvas — the reference frame for all absolute children */
.bleed-canvas {{
position: relative;
width: {PAGE_W}mm;
height: {PAGE_H}mm;
overflow: hidden;
}}
/* Map raster pushed into bleed zone on all sides */
.map-image {{
position: absolute;
top: -{BLEED}mm;
left: -{BLEED}mm;
width: calc(100% + {BLEED * 2}mm);
height: calc(100% + {BLEED * 2}mm);
object-fit: cover;
image-rendering: crisp-edges; /* Preserve cartographic line crispness */
}}
/* All cartographic overlays stay inside the trim boundary */
.gis-overlay {{
position: absolute;
top: {BLEED}mm;
left: {BLEED}mm;
width: {TRIM_WIDTH}mm;
height: {TRIM_HEIGHT}mm;
padding: 10mm;
box-sizing: border-box;
color: #111;
}}
/* Proof aid — shows trim boundary during development; removed in final output */
.trim-mark {{
position: absolute;
top: {BLEED}mm;
left: {BLEED}mm;
width: {TRIM_WIDTH}mm;
height: {TRIM_HEIGHT}mm;
border: 0.35pt dashed #cc0000;
pointer-events: none;
}}
@media print {{
.trim-mark {{ display: none; }}
}}
"""
The marks: crop declaration instructs WeasyPrint to render crop mark lines in the bleed zone, which many commercial RIPs expect for imposition. If your press does not require them, omit it — they add thin registration marks at each corner.
Step 3 — Calculate Exact Pixel Dimensions for GIS Raster Exports
WeasyPrint embeds rasters at the CSS box size without intelligent upsampling. If the source image is undersized, the PDF stretches it, degrading contour lines, parcel boundaries, and label legibility. Export your GIS map canvas at exactly:
pixels = (trim_mm + 2 × bleed_mm) / 25.4 × DPI
DPI: int = 300 # Commercial offset minimum; 400+ for large-format
px_width: int = round((TRIM_WIDTH + BLEED * 2) / 25.4 * DPI) # 2551 px
px_height: int = round((TRIM_HEIGHT + BLEED * 2) / 25.4 * DPI) # 3579 px
print(f"Export map at: {px_width} × {px_height} px @ {DPI} DPI")
# → Export map at: 2551 × 3579 px @ 300 DPI
When generating map exports from Automated Static Map Generation from GeoJSON, pass these pixel dimensions as the figure size to matplotlib or as the width/height to your headless browser capture. For QGIS layout exports, set the output resolution to DPI and the page size to PAGE_W × PAGE_H mm so the export already includes the bleed border.
Vector overlays — SVG legends, scale bars, coordinate grids — should be embedded directly in the HTML rather than baked into the raster. WeasyPrint renders SVG paths at native resolution, so they remain crisp at any print DPI without contributing to raster file size.
Step 4 — Build the HTML and Render the PDF
Combine the CSS template with a minimal HTML body that positions the raster and overlays in their respective zones.
def build_map_pdf(
map_image_path: str,
output_path: str,
title: str,
scale_text: str,
crs: str,
include_proof_marks: bool = True,
) -> None:
"""Render a bleed-correct GIS map PDF via WeasyPrint.
Args:
map_image_path: Path to the GIS raster exported at exactly PAGE_W×PAGE_H px.
output_path: Destination PDF file path.
title: Map title rendered in the safe area.
scale_text: Human-readable scale (e.g. '1:24,000').
crs: Coordinate reference system label (e.g. 'EPSG:32612').
include_proof_marks: When True, renders the red trim-mark overlay.
"""
proof_class = "trim-mark" if include_proof_marks else "trim-mark-hidden"
html_content: str = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
{CSS_TEMPLATE}
.trim-mark-hidden {{ display: none; }}
</style>
</head>
<body>
<div class="bleed-canvas">
<img class="map-image" src="{map_image_path}" alt="GIS map: {title}">
<div class="{proof_class}"></div>
<div class="gis-overlay">
<h1 style="margin:0 0 4mm;font-size:14pt;font-weight:700;">{title}</h1>
<p style="margin:0;font-size:8pt;color:#444;">
Scale: {scale_text} | CRS: {crs}
</p>
</div>
</div>
</body>
</html>"""
weasyprint.HTML(
string=html_content,
base_url=str(Path(map_image_path).parent),
).write_pdf(output_path)
print(f"PDF written: {output_path}")
# Example call
build_map_pdf(
map_image_path="exports/hydrology_300dpi.png",
output_path="output/hydrology_bleed.pdf",
title="Regional Hydrology Analysis",
scale_text="1:24,000",
crs="EPSG:32612",
include_proof_marks=False, # Set True during proofing
)
The base_url argument ensures WeasyPrint resolves relative image paths correctly when the script runs from a different working directory — a common cause of blank pages in CI pipelines.
Step 5 — Handle Multi-Page Atlases and Landscape Orientation
For multi-page map atlases, declare a named page rule per orientation and apply it with a CSS class on each .bleed-canvas div. This prevents handling multi-page landscape vs portrait switches from requiring separate build scripts.
LANDSCAPE_CSS: str = f"""
@page landscape-bleed {{
size: {PAGE_H}mm {PAGE_W}mm; /* Swap axes for landscape */
margin: 0;
marks: crop;
}}
.landscape-page {{
page: landscape-bleed;
width: {PAGE_H}mm;
height: {PAGE_W}mm;
}}
"""
Each page section in your HTML then declares class="bleed-canvas landscape-page" to trigger the named @page rule. WeasyPrint promotes the page: CSS property to the block-level box, inserting a page break and switching the MediaBox dimensions automatically.
Step 6 — Validate the Output PDF
Run programmatic preflight before delivering any print-ready PDF.
import subprocess
import re
def validate_pdf_dimensions(
pdf_path: str,
expected_w_mm: float,
expected_h_mm: float,
tolerance_mm: float = 0.1,
) -> bool:
"""Assert the PDF MediaBox matches the expected canvas dimensions.
Uses pdfinfo (part of poppler-utils) to read page geometry.
"""
result = subprocess.run(
["pdfinfo", pdf_path],
capture_output=True, text=True, check=True,
)
# pdfinfo reports size in points (1 pt = 25.4/72 mm)
match = re.search(r"Page size:\s+([\d.]+) x ([\d.]+) pts", result.stdout)
if not match:
raise ValueError(f"Could not parse page size from pdfinfo output: {result.stdout}")
pt_to_mm = 25.4 / 72
actual_w_mm = float(match.group(1)) * pt_to_mm
actual_h_mm = float(match.group(2)) * pt_to_mm
w_ok = abs(actual_w_mm - expected_w_mm) <= tolerance_mm
h_ok = abs(actual_h_mm - expected_h_mm) <= tolerance_mm
if w_ok and h_ok:
print(f"PASS {actual_w_mm:.2f} × {actual_h_mm:.2f} mm (expected {expected_w_mm} × {expected_h_mm})")
else:
print(f"FAIL {actual_w_mm:.2f} × {actual_h_mm:.2f} mm — expected {expected_w_mm} × {expected_h_mm}")
return w_ok and h_ok
# Assert the bleed PDF canvas is 216 × 303 mm
assert validate_pdf_dimensions("output/hydrology_bleed.pdf", PAGE_W, PAGE_H)
In a CI pipeline, run this assertion immediately after write_pdf() and fail the build if it returns False. This prevents silently-malformed PDFs from reaching a press.
Key Parameters Reference
| Parameter | Type | Default | Effect |
|---|---|---|---|
size in @page |
mm string |
— | Sets the physical PDF MediaBox; must equal trim + 2 × bleed |
margin in @page |
0 |
browser-default | Eliminates gutters that clip bleed content |
marks: crop |
keyword | none | Adds corner crop marks in the bleed zone (WeasyPrint ≥56) |
top/left on .map-image |
−bleed mm |
— | Pushes raster past the trim boundary into the bleed zone |
image-rendering: crisp-edges |
keyword | auto |
Prevents anti-aliasing on vector-sourced rasters; preserves line crispness |
base_url in HTML() |
path string | working dir | Resolves relative asset paths — critical in headless CI environments |
DPI for raster export |
integer | 300 | Drives pixel dimension calculation; use ≥300 for offset, ≥150 for digital |
Common Pitfalls
-
Clipped map edges. The most frequent cause is a non-zero
bodymargin orpaddingthat reduces the.bleed-canvaseffective size. Explicitly setmargin: 0; padding: 0on bothhtmlandbody, not just@page. -
Blurry contour lines and parcel boundaries. The source raster was not exported at the calculated pixel dimensions. WeasyPrint maps 1 CSS pixel to 1 image pixel at the declared DPI; an undersized image is stretched without interpolation. Recalculate
px_widthandpx_heightand re-export from your GIS application at those exact dimensions. -
Blank PDF in CI. WeasyPrint cannot resolve the image path when
base_urlis not set and the working directory differs from the HTML string’s context. Always passbase_url=str(Path(map_image_path).parent)or use an absolute file URI in thesrcattribute. -
Unexpected page breaks in multi-page atlases. WeasyPrint respects
break-inside: avoidandpage-break-inside: avoid. Apply these to.gis-overlayand any legend blocks to prevent them from fragmenting across spreads. Float-based layouts also trigger spurious breaks — use absolute positioning within.bleed-canvas.
Verification
After rendering, confirm the output is correct with three checks:
# 1. Check MediaBox dimensions match PAGE_W × PAGE_H in mm (pdfinfo converts from points)
pdfinfo output/hydrology_bleed.pdf | grep "Page size"
# Expected: Page size: 612.28 x 859.37 pts (216 × 303 mm)
# 2. Verify the embedded image pixel dimensions in the PDF
pdfimages -list output/hydrology_bleed.pdf | awk 'NR>2 {print $2, $3, $4}'
# Expected: image 2551 3579
# 3. Open the proof PDF (with trim-mark overlay) and visually confirm:
# - Red dashed border sits exactly 3 mm inside all four canvas edges
# - No map content is missing within 5 mm of the trim line
# - Legends, scale bars, and title text are entirely inside the dashed border
evince output/hydrology_proof.pdf # or any PDF viewer
For automated pipelines, wire the validate_pdf_dimensions() assertion from Step 6 into your CI job as a post-build gate. A mismatch of more than 0.1 mm typically indicates a unit-conversion bug or an incorrect @page size interpolation in the CSS template.
Related
- Print-Ready Page Sizing Standards for GIS Reports — parent: ISO page sizes, DPI thresholds, and rendering engine selection
- Margin and Bleed Alignment in Automated PDFs — sibling: coordinate-system normalisation and ReportLab crop-mark workflows
- Converting QGIS Layout Templates to Automated CSS Grids — sibling: translating QGIS page geometry into CSS
@pagerules - Handling Multi-Page Landscape vs Portrait Switches — sibling: named
@pagerules for mixed-orientation atlas PDFs - Document Architecture & Layout Rules for Spatial Reports — section index: grid systems, typography mapping, and overall document structure