Generating PDF Site Selection Reports with Python

This page solves one exact task: turning each row of a scored, ranked GeoDataFrame into a single-page PDF — a map thumbnail, a score breakdown, and a demographics table — that a real-estate committee can read without ever opening a GIS tool.

A committee does not consume a GeoDataFrame; it consumes a document. The PDF is the deliverable at the end of the automated reporting and GIS export stage that translates a suitability score into an argument a decision-maker can hold in their hands. The task is narrow but exacting: the output must be deterministic, so re-running the pipeline on unchanged data produces an identical document, and every geometry that gets drawn must have its coordinate reference system handled before it reaches a plotting axis.

Prerequisites

Before running this task you need:

  • Python packages: geopandas and shapely for the scored geometry, matplotlib to render a static map thumbnail directly from the GeoDataFrame, jinja2 to template an HTML page, and weasyprint to convert that HTML to PDF. Install with pip install geopandas shapely matplotlib jinja2 weasyprint.
  • A scored, ranked shortlist. A GeoDataFrame with one row per candidate site, a suitability_score, per-criterion sub-scores, demographic attributes, and a valid geometry with a CRS set. This comes from building weighted site suitability scores and the ranking and shortlisting stage.
  • The parent context. This page implements the write_pdf writer referenced in Automated Reporting & GIS Export; read that first for the assembler, the CRS policy, and the manifest.

Configuration and execution parameters

The renderer is driven by a handful of parameters that control layout and determinism. Everything that could vary between runs — image resolution, coordinate rounding, the page template — is pinned here rather than left to a library default.

Parameter Value for this task Notes
top_n 10 Render one PDF per site for the top-ranked candidates.
map_crs EPSG:3857 Web Mercator, only for the thumbnail plot’s aspect ratio.
area_crs EPSG:5070 Equal-area CRS for the trade-area size shown in the table.
dpi 150 Thumbnail raster resolution; fixed so images are byte-stable.
page_size A4 Set in the HTML/CSS @page rule consumed by weasyprint.
fig_seed disabled No random elements; matplotlib draws deterministically.
embed base64 PNG Thumbnail embedded inline so the PDF is self-contained.

Two facts drive most of the failures. First, the map CRS is a plotting concern, not a measurement concern: the thumbnail is reprojected to Web Mercator only so the aspect ratio looks right, while every number in the report — trade-area size especially — is computed in an equal-area CRS. Second, determinism requires killing every implicit timestamp: weasyprint and matplotlib will each happily bake now() into metadata, so both must be pinned to the run’s date.

Annotated implementation

The function below renders one PDF per site. For each row it plots a static map thumbnail from the geometry (after an explicit reprojection), encodes it inline, fills a Jinja2 template with the score breakdown and demographics, and hands the HTML to weasyprint. It never mutates the source frame and writes deterministically.

python
from __future__ import annotations

import base64
import io
from pathlib import Path

import geopandas as gpd
import matplotlib
matplotlib.use("Agg")                 # headless, deterministic backend
import matplotlib.pyplot as plt
from jinja2 import Environment, BaseLoader
from pyproj import CRS
from weasyprint import HTML

MAP_CRS = CRS.from_epsg(3857)         # Web Mercator, thumbnail aspect only
AREA_CRS = CRS.from_epsg(5070)        # equal-area, for the km2 figure

PAGE_TEMPLATE = """
<html><head><style>
  @page { size: A4; margin: 1.6cm; }
  body { font-family: sans-serif; color: #111; }
  h1 { font-size: 20px; margin-bottom: 2px; }
  .rank { color: #555; font-size: 12px; }
  img { width: 100%; border: 1px solid #ccc; margin: 10px 0; }
  table { border-collapse: collapse; width: 100%; font-size: 12px; }
  th, td { border: 1px solid #ddd; padding: 4px 8px; text-align: left; }
  th { background: #f2f2f2; }
</style></head><body>
  <h1>{{ name }}</h1>
  <div class="rank">Rank {{ rank }} of {{ total }} &middot; Score {{ score }}</div>
  <img src="data:image/png;base64,{{ map_b64 }}" alt="trade area map"/>
  <h3>Score breakdown</h3>
  <table><tr><th>Criterion</th><th>Weighted contribution</th></tr>
  {% for k, v in criteria %}<tr><td>{{ k }}</td><td>{{ v }}</td></tr>{% endfor %}
  </table>
  <h3>Demographics</h3>
  <table><tr><th>Metric</th><th>Value</th></tr>
  {% for k, v in demographics %}<tr><td>{{ k }}</td><td>{{ v }}</td></tr>{% endfor %}
  </table>
</body></html>
"""


def _thumbnail(geom_row: gpd.GeoDataFrame, dpi: int = 150) -> str:
    """Render one trade-area polygon to an inline base64 PNG."""
    # Reproject ONLY for plotting aspect ratio; assert a CRS first.
    assert geom_row.crs is not None, "geometry has no CRS; cannot plot safely"
    plot_gdf = geom_row.to_crs(MAP_CRS)

    fig, ax = plt.subplots(figsize=(5.5, 3.5))
    plot_gdf.plot(ax=ax, facecolor="#4a90d9", edgecolor="#1f4e79", alpha=0.4)
    plot_gdf.centroid.plot(ax=ax, color="#c0392b", markersize=25)
    ax.set_axis_off()
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight",
                metadata={"CreationDate": None})   # strip nondeterministic time
    plt.close(fig)
    return base64.b64encode(buf.getvalue()).decode("ascii")


def write_pdf(sites: gpd.GeoDataFrame, cfg) -> Path:
    """Render one PDF per shortlisted site; return the output directory."""
    assert sites.crs is not None, "scored sites have no CRS; refusing to render"
    metric = sites.to_crs(AREA_CRS)                # equal-area for size metric
    template = Environment(loader=BaseLoader()).from_string(PAGE_TEMPLATE)

    out_dir = cfg.out_dir / "pdf"
    out_dir.mkdir(parents=True, exist_ok=True)
    total = min(cfg.top_n, len(sites))

    for rank, (idx, row) in enumerate(sites.head(total).iterrows(), start=1):
        one = sites.iloc[[idx]]                    # single-row GeoDataFrame keeps CRS
        area_km2 = metric.geometry.iloc[idx].area / 1_000_000.0
        html = template.render(
            name=row["site_name"],
            rank=rank, total=total,
            score=f"{row['suitability_score']:.1f}",
            map_b64=_thumbnail(one, dpi=cfg.__dict__.get("dpi", 150)),
            criteria=[("Accessibility", f"{row['w_access']:.1f}"),
                      ("Demographic fit", f"{row['w_demo']:.1f}"),
                      ("Competition", f"{row['w_comp']:.1f}")],
            demographics=[("Trade-area population", f"{int(row['population']):,}"),
                          ("Median household income", f"${int(row['median_income']):,}"),
                          ("Trade-area size", f"{area_km2:.1f} km2")],
        )
        pdf_path = out_dir / f"{rank:02d}_{row['site_id']}.pdf"
        HTML(string=html).write_pdf(pdf_path)      # deterministic given fixed HTML
    return out_dir

The renderer keeps the map’s plotting reprojection (MAP_CRS) strictly separate from the measurement reprojection (AREA_CRS). The thumbnail is drawn in Web Mercator only because an unprojected WGS84 plot stretches vertically at mid-latitudes; the trade-area size in the table comes from the equal-area frame, where the number is actually correct. Slicing with sites.iloc[[idx]] returns a GeoDataFrame — not a GeoSeries — so the CRS travels with the single row into _thumbnail.

Per-site PDF rendering flow One row of the scored GeoDataFrame splits into two paths: the geometry is reprojected to Web Mercator and plotted with matplotlib into a base64 PNG thumbnail, while the scores and demographics fill a Jinja2 HTML template. The thumbnail and the filled template merge, and weasyprint writes a single deterministic PDF page. Scored row geometry · scores Plot thumbnail to_crs(3857) · PNG Fill template Jinja2 HTML weasyprint HTML → PDF Site PDF one page · A4

Failure modes and debugging

Symptom Cause Fix
Stretched or squashed map thumbnail Plotting raw EPSG:4326 geometry at mid-latitude Reproject to Web Mercator (or a local UTM zone) before .plot().
Two runs produce diffing PDFs A timestamp baked into PDF metadata or matplotlib output Pin CreationDate to None/the run date; use the Agg backend.
AttributeError: 'GeoSeries' has no attribute 'crs' on plot Sliced a single row as a Series, losing the CRS Slice with .iloc[[idx]] (double brackets) to keep a GeoDataFrame.
Wrong trade-area size in the table Area computed in EPSG:4326 degrees Compute area in an equal-area CRS (EPSG:5070), never in WGS84.
Garbled store names with accents Non-UTF-8 encoding in the HTML string Ensure the template and any file reads are UTF-8.
Blank map for some sites Empty or null geometry in the row Filter or repair geometry before rendering; skip null rows.

The CRS separation is the subtle one. A common bug is to reproject the frame once to an equal-area CRS and then plot from it — producing a technically correct but visually odd thumbnail — or to plot from WGS84 and read the area off the same frame, producing a correct-looking map with a wrong size figure. Keep the two reprojections distinct and named.

Verification

Confirm each PDF is real and complete before shipping the bundle:

  1. File exists and is non-empty: every expected NN_siteid.pdf is present with a non-zero byte size.
  2. Page count: each per-site report is exactly one page. A two-page PDF signals content overflow that a committee will notice.
  3. Non-empty content: the rendered PDF contains extractable text (the site name and score), not just a blank page from a failed template render.
python
from pathlib import Path
from pypdf import PdfReader

def verify_pdfs(out_dir: Path, expected: int) -> None:
    pdfs = sorted(out_dir.glob("*.pdf"))
    assert len(pdfs) == expected, f"{len(pdfs)} PDFs, expected {expected}"
    for p in pdfs:
        assert p.stat().st_size > 0, f"{p} is empty"
        reader = PdfReader(str(p))
        assert len(reader.pages) == 1, f"{p} has {len(reader.pages)} pages"
        text = reader.pages[0].extract_text() or ""
        assert text.strip(), f"{p} rendered no extractable text"

A page count of one per site and non-empty extractable text together catch the two failure modes that a byte-size check alone misses: content overflowing onto a second page, and a template that rendered structurally but produced a visually blank document.

← Back to Automated Reporting & GIS Export