Chart-to-PDF Sync with Matplotlib

Generating print-ready spatial reports programmatically exposes a persistent gap: analytical charts created during exploratory analysis are tightly coupled to interactive plotting state, while PDF layout engines require deterministic, stateless vector assets placed at exact page coordinates. Ad-hoc screenshot exports introduce raster artifacts and vary between machines; copying figures across sessions pollutes global pyplot state. This workflow closes that gap with a reproducible, vector-accurate pipeline that isolates figure rendering from document assembly, enabling the same chart code to serve both screen preview and production PDF without modification.

When integrated into the broader Dynamic Map & Data Embedding Workflows pipeline, this approach becomes a reusable stage that supplies precisely dimensioned chart assets to any downstream layout engine — ReportLab, WeasyPrint, or PrinceXML.

Prerequisites

  • Python 3.10+ with an isolated virtual environment
  • matplotlib>=3.7 — figure generation and explicit backend control
  • reportlab>=4.0 — deterministic PDF composition and canvas management
  • pandas>=2.0 — data normalization and attribute aggregation before plotting
  • svglib>=1.5 — converts the vector SVG buffer into a ReportLab drawing object
  • Headless rendering capability (Agg backend) for CI/CD or server environments
  • Familiarity with Automated Static Map Generation from GeoJSON if you are embedding charts alongside raster map tiles

Install all dependencies in one step:

Bash
pip install matplotlib reportlab pandas svglib

Verify headless operation by calling matplotlib.use("Agg") before any pyplot or figure import. In CI environments, set MPLBACKEND=Agg as an environment variable to enforce this globally without code changes.

Pipeline Architecture

The pipeline separates five discrete concerns so that each can be tested, scaled, and replaced independently. The SVG buffer is the handoff contract between the rendering stage and the layout stage — nothing else crosses the boundary.

Chart-to-PDF Synchronization Pipeline Five sequential stages connected by arrows: Stage 1 Normalize Data using pandas, Stage 2 Headless Figure using matplotlib Agg, Stage 3 SVG Buffer using io.BytesIO, Stage 4 Coordinate Map converting inches to points, Stage 5 PDF Canvas using reportlab. A label below states SVG buffer is the handoff contract between stages 3 and 4. 1 · Normalize Data pandas 2 · Headless Figure matplotlib Agg 3 · SVG Buffer io.BytesIO 4 · Coordinate Map in → pts × 72 5 · PDF Canvas reportlab SVG buffer = handoff contract between stages 3 and 4

Step-by-Step Implementation

1. Data Normalization and Attribute Validation

Clean and structure input data with pandas before any figure code runs. Validate numeric ranges, drop null rows in required columns, and coerce categoricals so that axis tick labels are deterministic across batch runs. For spatial reporting, this step commonly merges attribute tables with feature counts from PostGIS or GeoPackage queries — the same normalization that feeds Table Pagination Strategies for Large Attribute Tables also applies here.

Python
import pandas as pd

def normalize_reporting_data(raw_df: pd.DataFrame) -> pd.DataFrame:
    """Validate and structure data for chart rendering."""
    df = raw_df.copy()
    df.dropna(subset=["metric_value", "category"], inplace=True)
    df["metric_value"] = pd.to_numeric(df["metric_value"], errors="coerce")
    df.dropna(subset=["metric_value"], inplace=True)  # drop coercion failures
    df["category"] = df["category"].astype("category")
    return df.sort_values("category").reset_index(drop=True)

2. Headless Figure Initialization

Use matplotlib.figure.Figure directly rather than pyplot to avoid polluting global plotting state during batch execution. Explicit figsize (width, height in inches) defines the physical footprint that will translate to PDF points in step 4. Setting dpi=300 produces a 1800×1200 px raster fallback if needed, but the SVG export path ignores DPI entirely.

Python
import matplotlib
matplotlib.use("Agg")  # must precede any pyplot import
from matplotlib.figure import Figure
from matplotlib.backends.backend_agg import FigureCanvasAgg

def create_headless_figure(
    width_in: float = 6.0,
    height_in: float = 4.0,
    dpi: int = 300,
) -> Figure:
    fig = Figure(figsize=(width_in, height_in), dpi=dpi)
    FigureCanvasAgg(fig)  # attach canvas so savefig() is available
    return fig

Configure axes tick formatting, grid style, and typeface at this stage. For reports with variable legend sizes, the Dynamic Legend Injection for Variable Datasets workflow demonstrates how to compute legend height dynamically and adjust figsize accordingly before calling savefig.

3. In-Memory Vector Buffer Export

Render the figure into an io.BytesIO stream as SVG. SVG is preferred over PDF-in-memory because svglib can parse it cleanly without conflicting with the outer ReportLab canvas’s PDF structure. The bbox_inches="tight" flag trims whitespace; if it causes aspect-ratio drift, replace it with explicit pad_inches.

Python
import io
from matplotlib.figure import Figure

def export_figure_to_buffer(fig: Figure, pad_inches: float = 0.1) -> io.BytesIO:
    buf = io.BytesIO()
    fig.savefig(
        buf,
        format="svg",
        bbox_inches="tight",
        pad_inches=pad_inches,
        transparent=False,
    )
    buf.seek(0)  # reset pointer — downstream readers start from position 0
    return buf

Always call buf.seek(0) immediately after savefig. ReportLab’s svglib parser reads from the current stream position; a missing seek produces a silent empty embed with no exception raised.

4. Coordinate Mapping and Layout Translation

Matplotlib dimensions are in inches; ReportLab operates in PostScript points (1 inch = 72 pt). Failing to convert produces coordinate drift that shifts charts off-page or overlaps with headers. This function returns the bottom-left corner for centered placement, clamped to a minimum margin.

Python
def inches_to_points(inches: float) -> float:
    """Convert Matplotlib inch dimension to ReportLab PostScript points."""
    return inches * 72.0

def calculate_chart_position(
    page_width_pts: float,
    page_height_pts: float,
    chart_width_in: float,
    chart_height_in: float,
    margin_pts: float = 36.0,
) -> tuple[float, float]:
    """Return bottom-left (x, y) in points for centered chart placement."""
    w = inches_to_points(chart_width_in)
    h = inches_to_points(chart_height_in)
    x = max((page_width_pts - w) / 2, margin_pts)
    y = max((page_height_pts - h) / 2, margin_pts)
    return x, y

Coordinate drift most commonly appears when mixing raster and vector exports within the same batch. Standardize on SVG-to-ReportLab via svglib for all chart assets to keep the coordinate model consistent. For page dimension constants, see the Print-Ready Page Sizing Standards for GIS Reports reference which lists A4, A3, and ANSI D dimensions in both inches and points.

5. Canvas Assembly and Metadata Injection

Convert the SVG buffer to a ReportLab Drawing with svglib.svg2rlg, then place it on the canvas using renderPDF.draw. This preserves true vector paths rather than rasterizing the chart. Inject standardized metadata (title, author, spatial reference ID, creation date) immediately before canvas.save() to maintain audit trails for compliance documentation.

Python
import io
import datetime
from reportlab.pdfgen import canvas as rl_canvas
from reportlab.lib.pagesizes import A4
from reportlab.graphics import renderPDF
from svglib.svglib import svg2rlg

def assemble_pdf(
    svg_buffer: io.BytesIO,
    output_path: str,
    chart_x: float,
    chart_y: float,
    metadata: dict,
) -> None:
    c = rl_canvas.Canvas(output_path, pagesize=A4)

    drawing = svg2rlg(svg_buffer)
    if drawing is None:
        raise ValueError("svglib could not parse the SVG buffer — check for empty or malformed SVG")
    renderPDF.draw(drawing, c, chart_x, chart_y)

    c.setAuthor(metadata.get("author", "GIS Reporting Engine"))
    c.setTitle(metadata.get("title", "Spatial Analysis Report"))
    c.setSubject(metadata.get("subject", "Chart-to-PDF Sync"))
    c.setCreator("Python 3.10+ / ReportLab 4.0 / Matplotlib 3.7")
    c.setCreationDate(datetime.datetime.now())
    c.save()

renderPDF.draw places the drawing’s bottom-left corner at (chart_x, chart_y) in ReportLab’s coordinate system, which has its origin at the bottom-left of the page. If your layout template measures from the top-left (common in CSS-based tools like WeasyPrint), subtract from page_height_pts when computing y.

Production-Ready Script

The following complete script wires all five stages into a batch runner with logging, try/finally cleanup, and configurable parameters. It processes a list of report records and writes one PDF per record.

Python
#!/usr/bin/env python3
"""Batch chart-to-PDF renderer for automated spatial reporting."""

import io
import datetime
import logging
from pathlib import Path

import matplotlib
matplotlib.use("Agg")
from matplotlib.figure import Figure
from matplotlib.backends.backend_agg import FigureCanvasAgg

import pandas as pd
from reportlab.pdfgen import canvas as rl_canvas
from reportlab.lib.pagesizes import A4
from reportlab.graphics import renderPDF
from svglib.svglib import svg2rlg

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)

PAGE_W, PAGE_H = A4  # 595.27 × 841.89 pts
CHART_W_IN = 6.0
CHART_H_IN = 4.0
MARGIN_PTS = 36.0


def normalize_reporting_data(raw_df: pd.DataFrame) -> pd.DataFrame:
    df = raw_df.copy()
    df.dropna(subset=["metric_value", "category"], inplace=True)
    df["metric_value"] = pd.to_numeric(df["metric_value"], errors="coerce")
    df.dropna(subset=["metric_value"], inplace=True)
    df["category"] = df["category"].astype("category")
    return df.sort_values("category").reset_index(drop=True)


def render_chart(df: pd.DataFrame) -> io.BytesIO:
    fig = Figure(figsize=(CHART_W_IN, CHART_H_IN), dpi=300)
    FigureCanvasAgg(fig)
    ax = fig.add_subplot(111)
    ax.bar(df["category"].astype(str), df["metric_value"])
    ax.set_xlabel("Category")
    ax.set_ylabel("Metric Value")
    ax.tick_params(axis="x", rotation=30)
    fig.tight_layout()

    buf = io.BytesIO()
    fig.savefig(buf, format="svg", bbox_inches="tight", pad_inches=0.1)
    buf.seek(0)

    fig.clear()
    del fig
    return buf


def chart_position() -> tuple[float, float]:
    w = CHART_W_IN * 72.0
    h = CHART_H_IN * 72.0
    x = max((PAGE_W - w) / 2, MARGIN_PTS)
    y = max((PAGE_H - h) / 2, MARGIN_PTS)
    return x, y


def write_pdf(buf: io.BytesIO, output_path: Path, metadata: dict) -> None:
    c = rl_canvas.Canvas(str(output_path), pagesize=A4)
    drawing = svg2rlg(buf)
    if drawing is None:
        raise RuntimeError(f"SVG parse failed for {output_path.name}")
    x, y = chart_position()
    renderPDF.draw(drawing, c, x, y)
    c.setAuthor(metadata.get("author", "GIS Reporting Engine"))
    c.setTitle(metadata.get("title", "Spatial Report"))
    c.setCreationDate(datetime.datetime.now())
    c.save()


def process_batch(records: list[dict], output_dir: str) -> None:
    out = Path(output_dir)
    out.mkdir(parents=True, exist_ok=True)

    for idx, record in enumerate(records):
        buf: io.BytesIO | None = None
        try:
            df = normalize_reporting_data(pd.DataFrame(record["data"]))
            if df.empty:
                log.warning("Record %d has no plottable data — skipping", idx)
                continue
            buf = render_chart(df)
            dest = out / f"report_{idx:04d}.pdf"
            write_pdf(buf, dest, record.get("metadata", {}))
            log.info("Wrote %s", dest)
        except Exception as exc:
            log.error("Record %d failed: %s", idx, exc)
        finally:
            if buf is not None:
                buf.close()

Edge Cases and Advanced Configuration

Null and Sparse Data

When normalize_reporting_data drops all rows (fully null input), render_chart receives an empty DataFrame. The production script above guards with an early continue, but downstream consumers still expect a file. Emit a placeholder SVG — a rectangle containing a “No data available” label — so the batch produces a complete PDF regardless.

Scale and Performance Tuning

For batches exceeding 500 records, memory pressure from unclosed SVG strings can exhaust heap. Two mitigations:

  1. Use multiprocessing.Pool with a chunksize of 20–50 rather than threading.ThreadPool — ReportLab’s canvas writer is not thread-safe.
  2. Cap SVG string length by reducing figure complexity: fewer tick marks, simplified grid, thinner stroke widths. A 6×4 in figure should produce SVG under 80 KB; profiles above 500 KB indicate hidden complexity (e.g., per-point scatter marker paths).

Multi-Format Output

To produce both a screen-quality PNG and a print-quality PDF from the same figure, render two separate buffers before closing the figure:

Python
svg_buf = io.BytesIO()
png_buf = io.BytesIO()
fig.savefig(svg_buf, format="svg", bbox_inches="tight")
fig.savefig(png_buf, format="png", dpi=150, bbox_inches="tight")
svg_buf.seek(0)
png_buf.seek(0)

This avoids re-rendering and guarantees that both outputs reflect identical axis state. When the same page layout must host a chart, a map tile, and a legend panel, coordinate these buffers as described in Automated Static Map Generation from GeoJSON.

Headless CI/CD Environments

On Docker images without a display server, confirm the backend is active before the first figure creation:

Bash
python -c "import matplotlib; print(matplotlib.get_backend())"
# Expected: agg

If the output is not agg, add ENV MPLBACKEND=Agg to your Dockerfile and rebuild. Font availability is the second common failure mode — embed a minimal font set (e.g., DejaVu) into your image rather than relying on host system fonts.

Troubleshooting

Symptom Likely Cause Resolution
Blurry text or jagged lines in PDF Raster fallback — wrong format argument Ensure format="svg" in savefig(). Confirm matplotlib.use("Agg") runs before any pyplot import.
Chart clipped at page margins bbox_inches="tight" expanded figure beyond expected size Remove bbox_inches or set pad_inches=0.2. Verify allocated canvas region matches CHART_W_IN × CHART_H_IN.
Missing or substituted axis fonts Font not available in headless container Register fonts via matplotlib.font_manager.fontManager.addfont(). Bundle DejaVu in the Docker image.
Memory grows unbounded in batch BytesIO or Figure not released Wrap each iteration in try/finally. Call buf.close() and fig.clear() before del.
Coordinate offset — chart shifts up or down Page-origin mismatch between tools ReportLab origin is bottom-left. If template measures from top-left, compute y = page_height_pts - chart_y - chart_height_pts.
svg2rlg returns None silently Empty or malformed SVG in buffer Add if drawing is None: raise RuntimeError(...) after svg2rlg. Log buf.getvalue()[:200] to inspect the raw SVG.

Why does svg2rlg return None with no error?

svglib silently returns None for SVG documents it cannot parse rather than raising an exception. The most common triggers are: an empty buffer (missing buf.seek(0) after savefig), SVG produced by an older Matplotlib version with namespace quirks, or a BytesIO that was already closed by an earlier buf.close() call. Add an explicit None check and log buf.getvalue()[:500].decode("utf-8", errors="replace") to inspect what svglib received.

How do I embed a proprietary typeface into the PDF?

Register the font with ReportLab’s font subsystem before creating the canvas:

Python
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont

pdfmetrics.registerFont(TTFont("BrandFont", "/fonts/BrandFont-Regular.ttf"))

Then set rcParams["font.family"] = "BrandFont" in Matplotlib and c.setFont("BrandFont", 10) in ReportLab so both tools use the same typeface. Without registration, ReportLab substitutes Helvetica and the PDF viewer may substitute further. The Typography Mapping for Multi-Language Spatial Data guide covers cross-engine font registration in depth for reports that mix Latin and non-Latin scripts.

Detailed Guides in This Section

Conclusion

Chart-to-PDF Sync with Matplotlib delivers deterministic, vector-accurate chart embedding by separating figure rendering from PDF assembly at the SVG buffer boundary. Integrating this pipeline into the broader automated spatial reporting stack — alongside static map generation, legend injection, and attribute table pagination — eliminates manual layout steps and produces audit-ready deliverables that remain consistent across every batch run.