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 controlreportlab>=4.0— deterministic PDF composition and canvas managementpandas>=2.0— data normalization and attribute aggregation before plottingsvglib>=1.5— converts the vector SVG buffer into a ReportLab drawing object- Headless rendering capability (
Aggbackend) 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:
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.
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.
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.
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.
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.
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.
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.
#!/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:
- Use
multiprocessing.Poolwith achunksizeof 20–50 rather thanthreading.ThreadPool— ReportLab’s canvas writer is not thread-safe. - 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:
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:
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:
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
- Syncing Chart.js Outputs to ReportLab Canvas — two-stage JavaScript-to-Python pipeline for embedding browser-rendered Chart.js charts into ReportLab PDFs via base64 image transport.
Related
- Dynamic Map & Data Embedding Workflows — parent section covering the full pipeline from geospatial ingestion through document output
- Automated Static Map Generation from GeoJSON — produces the raster or vector map tiles that sit alongside Matplotlib charts in the same PDF layout
- Dynamic Legend Injection for Variable Datasets — computes legend dimensions dynamically, feeding into the
figsizecalculation before chart export - Table Pagination Strategies for Large Attribute Tables — handles the attribute table component that shares the page canvas with embedded charts
- Typography Mapping for Multi-Language Spatial Data — cross-engine font registration for PDFs that combine Matplotlib charts with non-Latin text
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.