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_ft or measured_ft off a web-mercator display copy, distances distort badly at the parcel scale. Measure in EPSG: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.

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.