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.

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.

Page furniture that earns its spaceHeader identifying the parcel, footer with run id and "n of m" pagination, a summary block on the first page and a provenance block on the last.Header: parcel and applicationon every page, because pages travel aloneFooter: run id and "3 of 11"an incomplete document becomes obviousFirst page: outcome summaryincluding the indeterminate countLast page: provenance blockinputs, rule pack, generation time in UTC
Pages get separated and photocopied. Every one has to say what it belongs to.

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.

Vector figures versus embedded screenshotsA drawn vector figure and a captured map screenshot compared by size, print quality, durability and whether it can disagree with the verdict.Vector figureMap screenshotSizeA few kilobytesHundreds of kilobytes per pagePrintSharp at any scalePixelated; often unreadableSource of truthThe same geometry the verdict usedA separate rendering that can driftLicensingNoneBasemap terms somebody must check
A screenshot from another tool can disagree with the verdict. A figure drawn from the same geometry cannot.

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 byte-level variationGeneration timestamps, unpinned font subsets and library-written document identifiers each make an otherwise identical report hash differently.Generation timestamppass it in as data; record it in provenanceFont subsetspin the fonts in the project, not the systemDocument id and creation dateset explicitly rather than left to the libraryResult: byte-identical regenerationa hash match is evidence the file is unaltered
Pin all three and regenerating a report becomes a check against the archived copy.

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.

Part of: Audit-ready report generation

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.