Generating PDF Compliance Reports with Python
Planning departments and applicants still consume compliance findings as PDFs, because a paginated document with an embedded parcel map is what gets stamped, filed, and entered into the hearing record. This guide builds a per-parcel PDF compliance report in Python that embeds pass/fail tables, the exact rule versions applied, the coordinate reference system used for measurement, and a small map thumbnail per parcel — all rendered directly from a frozen result set so the document is reproducible. It is a task within the audit-ready report generation module, which frames how results become defensible artifacts.
Prerequisites
Step-by-step
Step 1: Load the frozen result set and confirm the CRS
Read the evaluation output as written. The report must never recompute a verdict, and it must fail fast if the geometry did not arrive in the metric CRS that produced the measurements.
import geopandas as gpd
MEASURE_CRS = "EPSG:2276" # NAD83 Texas North Central (US feet) used at evaluation
def load_results(path: str) -> gpd.GeoDataFrame:
gdf = gpd.read_file(path)
if gdf.crs is None or gdf.crs.to_epsg() != 2276:
# Distance/area in the report only mean something in the measurement CRS.
raise ValueError("Results must be in EPSG:2276, the evaluation CRS")
# Freeze a display copy for thumbnails; measured columns already exist.
gdf["required_ft"] = gdf["required_ft"].astype(float)
gdf["measured_ft"] = gdf["measured_ft"].astype(float)
return gdf
Step 2: Render a map thumbnail per parcel
Generate thumbnails in a batch and key them by parcel ID, rather than drawing inside the PDF layout loop. Reproject a copy for display only; the numbers in the table stay in feet.
import matplotlib
matplotlib.use("Agg") # headless backend for server-side rendering
import matplotlib.pyplot as plt
def render_thumbnails(gdf: gpd.GeoDataFrame, out_dir: str) -> dict[str, str]:
web = gdf.to_crs("EPSG:3857") # web mercator, geometry only, for a clean square tile
paths = {}
for pid, row in web.set_index("parcel_id").iterrows():
fig, ax = plt.subplots(figsize=(1.6, 1.6), dpi=150)
colour = "#c0392b" if row["status"] != "COMPLIANT" else "#5a8f6b"
gpd.GeoSeries([row.geometry]).plot(ax=ax, color=colour, edgecolor="#322e3d")
ax.set_axis_off()
p = f"{out_dir}/{pid}.png"
fig.savefig(p, bbox_inches="tight", pad_inches=0)
plt.close(fig)
paths[pid] = p
return paths
Step 3: Build the per-parcel pass/fail table
Assemble the table rows in memory so the layout step is pure presentation. Each row names the rule version and the CRS so the page is self-describing.
def build_rows(gdf: gpd.GeoDataFrame) -> list[dict]:
rows = []
for _, r in gdf.iterrows():
rows.append({
"parcel_id": r["parcel_id"],
"rule": f'{r["rule_id"]} @ {r["rule_version"]}',
"required_ft": f'{r["required_ft"]:.1f}',
"measured_ft": f'{r["measured_ft"]:.1f}',
"status": r["status"],
"crs": f'EPSG:{gdf.crs.to_epsg()}',
})
return rows
Step 4: Lay out the PDF with ReportLab
Compose a document header carrying the run metadata, then a table and thumbnail per parcel. ReportLab’s platypus flowables keep pagination automatic.
from reportlab.lib.pagesizes import letter
from reportlab.lib import colors
from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle,
Paragraph, Image, Spacer)
from reportlab.lib.styles import getSampleStyleSheet
def write_pdf(rows: list[dict], thumbs: dict[str, str], out_path: str) -> None:
doc = SimpleDocTemplate(out_path, pagesize=letter, title="Zoning Compliance Report")
styles = getSampleStyleSheet()
story = [Paragraph("Zoning Compliance Report", styles["Title"]), Spacer(1, 12)]
for row in rows:
table = Table([
["Parcel", row["parcel_id"]],
["Rule", row["rule"]],
["Required / Measured", f'{row["required_ft"]} ft / {row["measured_ft"]} ft'],
["CRS", row["crs"]],
["Status", row["status"]],
], colWidths=[130, 250])
bg = colors.HexColor("#f4d6c8") if row["status"] != "COMPLIANT" else colors.HexColor("#cfe0d2")
table.setStyle(TableStyle([("BACKGROUND", (0, 4), (1, 4), bg),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey)]))
story += [table, Image(thumbs[row["parcel_id"]], width=90, height=90), Spacer(1, 18)]
doc.build(story)
WeasyPrint is an equally valid renderer if your team prefers templating the report as HTML and CSS; consult the ReportLab user guide for flowable and pagination details.
Verification
Re-open the source frame and assert that the PDF represents every parcel exactly once, and spot-check that a known failing parcel is coloured and labelled as non-compliant.
def verify(gdf: gpd.GeoDataFrame, rows: list[dict]) -> None:
assert len(rows) == len(gdf), "Row count drift between results and PDF table"
assert set(r["parcel_id"] for r in rows) == set(gdf["parcel_id"]), "Missing parcels"
fails = gdf[gdf["status"] != "COMPLIANT"]["parcel_id"].tolist()
for pid in fails:
row = next(r for r in rows if r["parcel_id"] == pid)
assert row["status"] != "COMPLIANT"
print(f"Verified {len(rows)} parcels, {len(fails)} flagged")
Common Pitfalls
- Measuring after reprojection. If you compute
required_ftormeasured_ftoff a web-mercator display copy, distances distort badly at the parcel scale. Measure inEPSG:2276, store the scalar, and reproject only the geometry you draw. - Unbounded thumbnail generation. Rendering a Matplotlib figure inside the PDF loop for tens of thousands of parcels exhausts file handles and time. Batch the thumbnails first and reference them by ID.
- Non-reproducible bytes. Embedding a wall-clock timestamp in the PDF body means two identical runs hash differently. Keep volatile metadata in the accompanying manifest, not baked into every page.
Page Furniture That Earns Its Space
A compliance PDF is read under time pressure by people looking for one thing, and the furniture around the content decides whether they find it.
Every page needs a header identifying the parcel and the application, because pages get separated, photocopied and filed individually. Every page needs a footer with the run identifier and page numbering in the form “3 of 11”, so an incomplete document is obvious. The first page needs a summary block — outcome counts, including indeterminate — and the last needs the provenance block naming inputs, rule pack and generation time.
What does not earn its space is decorative branding on every page, a full-page cover sheet that delays the finding, or a legend explaining colours that the document then uses to convey meaning. Colour should be redundant with text in a compliance document: the reader may be looking at a monochrome photocopy, and an outcome distinguishable only by hue is an outcome lost.
Embedding Geometry Without Bloating the File
Reports are far more useful with a picture, and pictures are how compliance PDFs become fifty-megabyte files that nobody can email.
The economical approach is vector rather than raster: draw the parcel, the envelope and the encroachment as paths at a fixed scale, with a north arrow and a scale bar. A vector figure of a parcel is a few kilobytes, renders sharply at any zoom, and prints correctly. A screenshot of a map viewer is hundreds of kilobytes, ages badly, and usually carries basemap imagery whose licence terms nobody has checked.
Where a basemap genuinely helps orientation, use a small, low-resolution inset rather than a full-page image, and confirm the attribution requirement. And keep the figure’s data in the document’s own coordinate space: a drawing generated from the same geometry the verdict was computed from cannot disagree with it, whereas an exported image from a separate tool can and eventually will.
def draw_parcel_figure(canvas, parcel, envelope, encroachment, box, scale):
"""Vector figure from the same geometry the verdict used — kilobytes, not megabytes."""
canvas.saveState()
canvas.translate(box.x, box.y)
canvas.scale(scale, scale)
_path(canvas, parcel, stroke=(0.2, 0.2, 0.2), fill=None)
_path(canvas, envelope, stroke=(0.35, 0.56, 0.42), dash=(3, 2))
if encroachment is not None and not encroachment.is_empty:
_path(canvas, encroachment, stroke=(0.85, 0.55, 0.44), fill=(0.96, 0.84, 0.78))
canvas.restoreState()
Making the Output Reproducible
A PDF that differs byte for byte between two runs over the same data cannot be compared against an archived copy, which removes a genuinely useful verification path.
Three sources of variation account for nearly all of it. The generation timestamp, which should be passed in as data and recorded in the provenance block rather than being read from the clock inside the renderer. Embedded font subsets, which vary if fonts are resolved from the system rather than pinned in the project. And the document identifier and creation date that PDF libraries write into the trailer by default, both of which can be set explicitly.
Pin all three and regenerating a report becomes a check: produce it again from the archived verdicts and compare hashes with the copy on file. A match is strong evidence the document has not been altered, and it costs nothing to obtain.
Related
Part of: Audit-ready report generation
- Templating compliance narratives with Jinja — the prose this document renders.
- Exporting compliance results to GeoJSON and CityGML — the machine-readable sibling.
- Generating tamper-evident compliance certificates — when the document needs to be verifiable.
- Provenance and lineage tracking — what the provenance block is drawn from.
Frequently Asked Questions
Should I use ReportLab or WeasyPrint for compliance PDFs?
Both produce audit-quality output. ReportLab gives you programmatic, flowable-based control that suits data-dense per-parcel tables and precise pagination. WeasyPrint lets you author the report as HTML and CSS, which is convenient when you already maintain templates and want designers to adjust layout without touching Python. For high-volume batch runs where you want fine control over table styling and embedded images, ReportLab is the more direct fit.
How do I keep the PDF reproducible for audit purposes?
Render from a frozen result set, never recompute verdicts at layout time, and move all volatile metadata such as generation timestamps into a separate manifest that travels with the file. Sort any collections you print so ordering is stable. Then a SHA-256 of the PDF bytes is a meaningful tamper check, because identical inputs produce identical output.
Can I include the map thumbnail without distorting the reported distances?
Yes. Treat measurement and display as separate concerns. Compute every distance and area in the projected metric or state-plane CRS, write those numbers into attribute columns, and reproject a copy of the geometry purely for the thumbnail. The picture can live in web mercator while the table stays in feet.
How large can these reports get before performance suffers?
The layout step scales with parcel count, so the practical limit is usually thumbnail rendering rather than the PDF assembly. Batch and cache thumbnails, generate them at modest DPI, and for very large jurisdictions split the output into per-district PDFs. Assembling several thousand parcels into one document is routine; tens of thousands is better paginated across multiple files.
Once the PDF is produced, the same frozen frame feeds machine-readable exports described in exporting compliance results to GeoJSON and CityGML, so downstream permitting systems receive the identical verdicts your PDF prints.