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: crop support)
  • Python 3.10+ with weasyprint installed:
    Bash
    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.

WeasyPrint Bleed Layout Zones for GIS Maps Diagram showing the four zones of a print-ready PDF canvas in WeasyPrint: (1) the outer physical canvas which equals trim plus twice the bleed on each axis, (2) the bleed zone between the canvas edge and the trim boundary, (3) the trim boundary which is the final cut line, and (4) the inner safe area where all cartographic overlays must remain. Physical canvas = trim + 2 × bleed (each side) 3 mm 3 mm Canvas Trim Safe Trim boundary — final cut line Safe area — all overlays pinned here GIS raster layer extends to canvas edge (incl. bleed zone) positioned with top: −3mm; left: −3mm Legend · Scale bar · North arrow top: 3mm; left: 3mm from canvas edge @page { size: 216mm 303mm; margin: 0; marks: crop; }

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.

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

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

Text
pixels = (trim_mm + 2 × bleed_mm) / 25.4 × DPI
Python
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.

Python
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} &nbsp;|&nbsp; 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.

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

Python
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 body margin or padding that reduces the .bleed-canvas effective size. Explicitly set margin: 0; padding: 0 on both html and body, 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_width and px_height and re-export from your GIS application at those exact dimensions.

  • Blank PDF in CI. WeasyPrint cannot resolve the image path when base_url is not set and the working directory differs from the HTML string’s context. Always pass base_url=str(Path(map_image_path).parent) or use an absolute file URI in the src attribute.

  • Unexpected page breaks in multi-page atlases. WeasyPrint respects break-inside: avoid and page-break-inside: avoid. Apply these to .gis-overlay and 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:

Bash
# 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.