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
Bash
pip install weasyprint requests jinja2
  • A Mapbox access token stored in the MAPBOX_ACCESS_TOKEN environment 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.

Capture method decision diagram A decision tree. Start node asks whether the map uses only standard Mapbox style layers. Yes path leads to the Mapbox Static Images API route, then to base64 data URI, then to WeasyPrint PDF. No path leads to headless Chromium via Playwright, then to canvas screenshot PNG, then to the same base64 and WeasyPrint stages. Mapbox GL JS map ready to embed in PDF? Standard Mapbox style layers only? Yes No Static Images API @2x Playwright + Chromium capture bytes → base64 data URI Jinja2 HTML template WeasyPrint → PDF output

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.

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

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

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

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

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

Python
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_TOKEN as 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 @2x and reduce width/height to 900×600 before retrying.

  • Map split across two pages despite page-break-inside: avoid. WeasyPrint respects page-break-inside only 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_overlay above raises before the network call.

Verification

Run the pipeline and inspect the output PDF with an assertion that checks file size and page count:

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