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
What each page of the committee pack has to carry The cover page carries the run identifier, weight version and the ranked table of the whole shortlist. Each site page below it carries a locator map, the score breakdown by criterion, the demographic summary and the exclusion notes, with a footer repeating the run identifier so a detached page is still traceable. Two page templates carry the entire pack Cover · one per pack Title · market · date of the run run_id · weight_version · data vintage Ranked table — the whole shortlist rank · site · score · reachable population near-tie rows marked, not silently ordered excluded sites listed with the reason nothing here is retyped — it is the table the pipeline validated Footer · run_id repeated on every page Site page · one per candidate Locator map catchment + competitors fixed extent, fixed scale Score breakdown criterion bars summing to the headline number Demographics & competition reachable population by drive-time band nearest own store · nearest competitor Notes · caveats · what the model did not see Every element is a field of the scored row. A page that needs a human to type a number is a page that will go stale.

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.

Five things that make two identical runs produce different bytes A creation timestamp, a document identifier, font fallback, unordered attribute iteration and a re-rendered basemap tile each change the output of an otherwise identical report run. Each is pinned by an explicit setting, so a rebuild of the same scored run produces the same file and a hash comparison becomes a real check. The same data, different bytes — and the five reasons why what varies between runs how it is pinned PDF creation timestamp set it from the run date, not the clock document / trailer identifier derive it from a hash of the run inputs font fallback on the build host ship the font file, register it explicitly attribute / legend iteration order sort keys before rendering, never rely on insert order basemap tiles fetched at render time cache the tiles with the run, fix the extent Why it is worth the effort With all five pinned, re-rendering last quarter's pack reproduces it byte for byte — so a diff between two packs means the data changed, and a regression test on the report is a one-line hash comparison. Without them, every rebuild differs and no one can tell a content change from a rendering artifact.

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.

Frequently Asked Questions

Should the map figures be rendered at report time or cached?

Cache them as part of the run and let the report reference the cached image. A locator map is the slowest element on the page and the only one that reaches the network, so rendering it inline couples report generation to tile-server availability and makes a rebuild non-reproducible. Rendering the figures once into a run-scoped directory keeps the PDF step a fast, offline assembly, and it means a regenerated pack shows exactly the map the committee saw the first time.

How large should a committee pack be allowed to get?

Large enough to cover the shortlist and no larger, which in practice means one page per candidate plus the cover. A pack that runs to eighty pages because it includes every screened site is not read; the screening evidence belongs in the workbook and the spatial deliverables, where it can be filtered. If the shortlist genuinely needs forty pages, that is a signal the cut-off was set too loosely rather than a formatting problem.

What belongs on the page that the model cannot compute?

A short notes block, populated from structured fields rather than free text: the caveats attached to the site during screening, the criteria that were unavailable and therefore excluded, and the data vintages behind the demographics. Committees ask what the model did not see far more often than they question the arithmetic, and a page that answers that question in advance keeps the discussion on the decision rather than on the pipeline.

Can the same code produce both a print pack and a screen version?

Yes, and it should, provided the difference is confined to a stylesheet and a page-size setting rather than a second rendering path. Two code paths drift, and the version a stakeholder forwards is invariably the one that was not checked. Keep a single template with a print and a screen profile, generate both from the same scored row, and record which profile produced a file in its metadata so a mismatch is visible rather than inferred.

← Back to Automated Reporting & GIS Export