Embedding Mapbox Exports into WeasyPrint PDFs
WeasyPrint is a synchronous HTML/CSS-to-PDF engine with no JavaScript runtime and no WebGL renderer. Mapbox GL JS requires both: it streams vector tiles, evaluates style expressions on the GPU, and maintains event listeners that have no meaning in a print context. The solution is to decouple the two layers — capture the Mapbox visualization as a high-resolution static asset before PDF composition, then inject it into WeasyPrint as a base64 data URI. This technique is a targeted step within Automated Static Map Generation from GeoJSON, which covers the full server-side rasterization pipeline including geopandas-driven feature extraction and contextily basemap integration.
Prerequisites
- Python 3.10+
weasyprint≥ 61.0,requests≥ 2.31,jinja2≥ 3.1
pip install weasyprint requests jinja2
- A Mapbox access token stored in the
MAPBOX_ACCESS_TOKENenvironment variable - Familiarity with Dynamic Map & Data Embedding Workflows — specifically how server-side rendering isolates PDF generation from browser state
No system fonts or GDAL binaries are required for this specific technique; WeasyPrint’s own font fallback handles raster-only templates.
Capture Strategy: API vs. Headless Chromium
The choice of capture method depends on whether your layer stack stays within Mapbox’s declarative style specification.
Use the Static Images API path for standard choropleth, point, and line-layer maps — it is faster, requires no browser binary, and is CI-friendly with zero WebGL dependencies. Switch to the headless Chromium path when your map includes custom 3D extrusions, heat maps with GPU shader effects, or any layer type that relies on GL draw calls unavailable in the API.
Step-by-Step Implementation
Step 1: Fetch a High-DPI Static Image
Construct the Static Images API URL with an @2x suffix. A 1200×800 tile at @2x returns a 2400×1600 pixel PNG — sufficient for professional print without exceeding API size limits.
import os
import requests
MAPBOX_ACCESS_TOKEN = os.environ["MAPBOX_ACCESS_TOKEN"]
def fetch_mapbox_static(
style: str = "mapbox/light-v11",
lon: float = -122.4194,
lat: float = 37.7749,
zoom: int = 12,
width: int = 1200,
height: int = 800,
scale: int = 2,
) -> bytes:
"""Return raw PNG bytes from the Mapbox Static Images API at @{scale}x DPI."""
url = (
f"https://api.mapbox.com/styles/v1/{style}/static/"
f"{lon},{lat},{zoom}/{width}x{height}@{scale}x"
f"?access_token={MAPBOX_ACCESS_TOKEN}"
)
response = requests.get(url, timeout=15)
response.raise_for_status()
# Guard against quota-exceeded JSON bodies returned at HTTP 200
if not response.headers.get("Content-Type", "").startswith("image/png"):
raise ValueError(f"Unexpected content type: {response.headers.get('Content-Type')}")
return response.content
The style parameter accepts any Mapbox-hosted or custom style identifier in username/style-id format. Always validate Content-Type before passing bytes downstream — a quota error returns a JSON body at HTTP 200 in some API versions.
Step 2: Encode as a Base64 Data URI
Converting the PNG bytes to a data: URI removes all external file dependencies from the WeasyPrint render call. This is critical in containerised environments where WeasyPrint cannot make outbound HTTP requests during PDF composition. For Dynamic Legend Injection for Variable Datasets, this same pattern handles legend images alongside the map.
import base64
def to_data_uri(image_bytes: bytes, mime: str = "image/png") -> str:
"""Encode raw image bytes as an inline data URI string."""
encoded = base64.b64encode(image_bytes).decode("utf-8")
return f"data:{mime};base64,{encoded}"
Step 3: Build the HTML Template with Print CSS
The template must handle two print-specific concerns: page-break-inside: avoid keeps the map on a single page, and the @page rule enforces consistent paper dimensions across all CI environments regardless of the host OS’s default paper size setting. See How to Set Exact Bleed Margins in WeasyPrint for GIS Maps for the full @page bleed specification when working with press-ready output.
from jinja2 import Template
MAP_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
@page {
size: letter;
margin: 1.5cm;
}
body {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: #222;
margin: 0;
}
h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
.map-block {
margin: 1.5rem 0;
page-break-inside: avoid;
text-align: center;
}
.map-block img {
max-width: 100%;
height: auto;
border: 1px solid #d0d0d0;
display: block;
margin: 0 auto;
}
.caption {
font-size: 0.8rem;
color: #555;
margin-top: 0.4rem;
}
</style>
</head>
<body>
<h1>{{ title }}</h1>
<p>Generated {{ date }}.</p>
<div class="map-block">
<img src="{{ map_uri }}" alt="{{ map_alt }}">
<p class="caption">{{ caption }}</p>
</div>
</body>
</html>"""
def render_html(
map_uri: str,
title: str,
caption: str,
map_alt: str,
date: str,
) -> str:
return Template(MAP_TEMPLATE).render(
map_uri=map_uri,
title=title,
caption=caption,
map_alt=map_alt,
date=date,
)
Step 4: Render to PDF with WeasyPrint
Pass the rendered HTML string directly to HTML(string=…). Because the image is already a data: URI, WeasyPrint makes zero network requests during this step, making the render fully deterministic and offline-safe.
import datetime
from weasyprint import HTML
def generate_pdf(output_path: str = "spatial_report.pdf") -> None:
"""Fetch map, build HTML, write PDF."""
image_bytes = fetch_mapbox_static(lon=-122.4194, lat=37.7749, zoom=12)
data_uri = to_data_uri(image_bytes)
html_string = render_html(
map_uri=data_uri,
title="San Francisco Urban Analysis",
caption="Mapbox Light v11 · zoom 12 · @2x scale · EPSG:3857",
map_alt="Static basemap of San Francisco at zoom level 12",
date=datetime.date.today().isoformat(),
)
HTML(string=html_string).write_pdf(output_path)
print(f"Written: {output_path}")
if __name__ == "__main__":
generate_pdf()
Step 5: Add GeoJSON Overlays to the Static Image
The Static Images API accepts an inline GeoJSON overlay as a URL-encoded path segment. Insert it between the style and the coordinate segment. This renders polygons, route lines, or point markers on the raster without a headless browser.
import json
import urllib.parse
def fetch_with_geojson_overlay(
geojson: dict,
style: str = "mapbox/light-v11",
lon: float = -122.4194,
lat: float = 37.7749,
zoom: int = 12,
width: int = 1200,
height: int = 800,
) -> bytes:
"""Fetch a static map with a GeoJSON FeatureCollection overlaid."""
geojson_str = json.dumps(geojson, separators=(",", ":"))
encoded = urllib.parse.quote(geojson_str)
if len(encoded) > 8192:
raise ValueError(f"Encoded GeoJSON exceeds 8 KB ({len(encoded)} bytes); simplify features.")
url = (
f"https://api.mapbox.com/styles/v1/{style}/static/"
f"geojson({encoded})/"
f"{lon},{lat},{zoom}/{width}x{height}@2x"
f"?access_token={MAPBOX_ACCESS_TOKEN}"
)
response = requests.get(url, timeout=20)
response.raise_for_status()
return response.content
The encoded GeoJSON string must stay under 8 KB after URL-encoding. For larger feature sets, render the overlay as a Mapbox-hosted tileset and reference it by layer name in your style instead.
Step 6: Fall Back to Headless Chromium for Complex Layers
Custom Mapbox GL JS layers — heat maps, 3D extrusions, custom shader effects — cannot be represented by the Static Images API, which only understands Mapbox style specifications. For those cases, use playwright to capture the canvas after the map tiles have fully loaded.
from playwright.sync_api import sync_playwright
def capture_mapbox_via_playwright(
html_path: str,
selector: str = "#map canvas",
) -> bytes:
"""Render a Mapbox GL JS map in headless Chromium and capture the canvas as PNG bytes."""
with sync_playwright() as pw:
browser = pw.chromium.launch()
page = browser.new_page(viewport={"width": 2400, "height": 1600})
page.goto(f"file://{html_path}")
# window.__mapLoaded is set by a map.on('idle', ...) listener in the HTML page
page.wait_for_function("window.__mapLoaded === true", timeout=30_000)
element = page.query_selector(selector)
png_bytes = element.screenshot()
browser.close()
return png_bytes
Set window.__mapLoaded = true inside a map.on('idle', ...) listener in your Mapbox HTML page so Playwright knows when tile loading is complete. Pass the returned bytes directly to to_data_uri() and continue with the same WeasyPrint pipeline.
Key Parameters / Configuration Reference
| Parameter | Type | Default | Effect |
|---|---|---|---|
scale (@Nx suffix) |
int |
2 |
Pixel multiplier; 2 ≈ 200 DPI, 3 ≈ 300 DPI for offset press |
width × height |
int |
1200 × 800 |
Logical tile dimensions before scale is applied; max 1280 px per axis |
@page { size } |
CSS string | letter |
Paper dimensions in WeasyPrint; use A4, A3, or 297mm 210mm as needed |
page-break-inside: avoid |
CSS rule | — | Prevents WeasyPrint splitting the map div across a page boundary |
requests timeout |
int seconds |
15 |
Raise for large overlays or slow network; set to 30 for GeoJSON overlays |
geojson(...) segment |
URL-encoded string | — | Inline GeoJSON overlay; must be under 8 KB encoded |
Common Pitfalls
-
Blank or 401 response from Static Images API. The token is missing or restricted to a browser origin. Set
MAPBOX_ACCESS_TOKENas a server-side environment variable and ensure the token’s allowed URLs include your CI host, or use a secret-scoped token with no URL restrictions. -
WeasyPrint memory spike on very large base64 strings. Images above 4000×4000 px decoded occupy 48 MB of RAM per RGBA channel. If the WeasyPrint process hits an OOM kill signal in a constrained container, cap the image at
@2xand reducewidth/heightto 900×600 before retrying. -
Map split across two pages despite
page-break-inside: avoid. WeasyPrint respectspage-break-insideonly when the element fits within a single page’s content height. If the image is taller than the page body after margins, WeasyPrint has no choice but to split it. Fix by reducing image height or switching to landscape orientation:@page { size: letter landscape; }. -
GeoJSON overlay silently clipped or missing. The API rejects malformed or oversized GeoJSON without a descriptive error — it returns a plain raster with no overlay. Always validate the FeatureCollection with a schema check before encoding, and confirm the encoded length is under 8192 bytes. The guard in
fetch_with_geojson_overlayabove raises before the network call.
Verification
Run the pipeline and inspect the output PDF with an assertion that checks file size and page count:
import subprocess
import pathlib
def verify_pdf(pdf_path: str, min_bytes: int = 50_000) -> None:
"""Assert the generated PDF is non-trivial and has at least one page."""
path = pathlib.Path(pdf_path)
assert path.exists(), f"PDF not found: {pdf_path}"
assert path.stat().st_size >= min_bytes, (
f"PDF suspiciously small ({path.stat().st_size} bytes) — likely a blank render"
)
# Use pdfinfo (poppler-utils) to check page count
result = subprocess.run(
["pdfinfo", str(path)], capture_output=True, text=True, check=True
)
assert "Pages:" in result.stdout, "pdfinfo did not report any pages"
print(f"Verification passed: {pdf_path} ({path.stat().st_size:,} bytes)")
In a CI pipeline, add a pixel-diff step using pdf2image and Pillow to compare the embedded map region against a reference PNG stored in the repository. This catches silent rendering regressions when Mapbox style versions change or the API modifies tile rendering behaviour between deployments.
Related
- Automated Static Map Generation from GeoJSON — parent workflow covering the full server-side rasterization pipeline with
geopandasandcontextily - Dynamic Legend Injection for Variable Datasets — pair legend blocks with embedded map images in the same WeasyPrint template
- How to Set Exact Bleed Margins in WeasyPrint for GIS Maps — apply precise
@pagemargin rules around embedded map images - Syncing Chart.js Outputs to ReportLab Canvas — alternative headless-capture approach for chart outputs, adaptable to Mapbox GL JS
- Dynamic Map & Data Embedding Workflows — top-level section covering all server-side map and data embedding patterns