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:
geopandasandshapelyfor the scored geometry,matplotlibto render a static map thumbnail directly from theGeoDataFrame,jinja2to template an HTML page, andweasyprintto convert that HTML to PDF. Install withpip install geopandas shapely matplotlib jinja2 weasyprint. - A scored, ranked shortlist. A
GeoDataFramewith one row per candidate site, asuitability_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_pdfwriter 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.
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 }} · 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.
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:
- File exists and is non-empty: every expected
NN_siteid.pdfis present with a non-zero byte size. - Page count: each per-site report is exactly one page. A two-page PDF signals content overflow that a committee will notice.
- Non-empty content: the rendered PDF contains extractable text (the site name and score), not just a blank page from a failed template render.
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.
Related
- Automated Reporting & GIS Export — the assembler and CRS policy this PDF writer plugs into.
- Building Weighted Site Suitability Scores — the score breakdown each report page renders.
- Ranking and Shortlisting Candidate Sites — supplies the ordered top-N the reports cover.
← Back to Automated Reporting & GIS Export