Generating PDF Compliance Reports with Python and ReportLab

To generate a PDF compliance report in Python, build a list of platypus flowables — a Paragraph title, a styled Table of per-dataset results, and a signature block — and hand them to a SimpleDocTemplate, which paginates and writes the PDF in one build call. ReportLab needs no system dependencies and gives byte-level control over layout, which makes it the dependable choice when a compliance PDF must be reproducible and archival.

This guide is the concrete PDF-rendering companion to Automated Compliance Report Generation, which covers where the pass/fail records come from, and it sits within the broader Spatial Data Audit Reporting & Compliance Governance domain that treats the rendered PDF as a durable evidence object rather than a disposable printout. Here the focus is narrow: turning a list of compliance records into a paginated, signed PDF you can submit to an auditor.

The document structure a PDF report needsA ReportLab document is assembled from a page template, a flowable story, and document-level metadata.Document metadataset oncetitle, authorsubjectproducerPage templatefixed per house styleheader, footerpage numbersmarginsStoryvaries per reportheadingstablesverdict blocks
The story is the only part that varies per report; the other two layers are fixed once.

Automated Python Implementation

The script below is self-contained and runnable. Given a list of compliance records — each naming a dataset, its content hash, the standard checked, a verdict, and a detail string — it renders a multi-page PDF with a title block, an executive summary line, a colour-coded results table whose header repeats across pages, per-page footers with page numbers and a generation timestamp, and an approver signature block. The only dependency is reportlab.

#!/usr/bin/env python3
"""Render a spatial dataset-compliance report to PDF with ReportLab.

Usage:
    python generate_compliance_pdf.py

Dependency:
    pip install "reportlab>=4.0"
"""
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone

from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.platypus import (
    SimpleDocTemplate,
    Table,
    TableStyle,
    Paragraph,
    Spacer,
)

@dataclass(frozen=True)
class ComplianceRecord:
    """One dataset's compliance result, as pulled from stored evidence."""
    dataset_id: str
    sha256: str
    standard: str
    verdict: str          # "pass", "fail", or "partial"
    detail: str

# Colours used to make the verdict column scannable at a glance.
VERDICT_COLOURS = {
    "pass": colors.HexColor("#1b7f4b"),
    "fail": colors.HexColor("#b3261e"),
    "partial": colors.HexColor("#8a6d00"),
}

def _footer(canvas, doc) -> None:
    """Draw a page number and generation timestamp on every page."""
    canvas.saveState()
    canvas.setFont("Helvetica", 8)
    stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
    canvas.drawString(20 * mm, 12 * mm, f"Generated {stamp}")
    canvas.drawRightString(
        A4[0] - 20 * mm, 12 * mm, f"Page {canvas.getPageNumber()}"
    )
    canvas.restoreState()

def _results_table(records: list[ComplianceRecord]) -> Table:
    """Build the styled per-dataset results table with a repeating header."""
    cell = ParagraphStyle("cell", fontName="Helvetica", fontSize=8, leading=10)
    header = ["Dataset", "Hash", "Standard", "Verdict", "Detail"]
    rows: list[list] = [header]
    for rec in records:
        rows.append([
            Paragraph(rec.dataset_id, cell),
            Paragraph(rec.sha256[:12], cell),
            Paragraph(rec.standard, cell),
            Paragraph(rec.verdict.upper(), cell),
            Paragraph(rec.detail or "—", cell),
        ])

    # Column widths sum to under the printable frame width of A4 with margins.
    table = Table(
        rows,
        colWidths=[40 * mm, 26 * mm, 30 * mm, 22 * mm, 52 * mm],
        repeatRows=1,
    )
    style = [
        ("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
        ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e8e8e8")),
        ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
        ("FONTSIZE", (0, 0), (-1, 0), 8),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("ROWBACKGROUNDS", (0, 1), (-1, -1),
         [colors.white, colors.HexColor("#f6f6f6")]),
    ]
    # Colour each verdict cell by its result.
    for i, rec in enumerate(records, start=1):
        colour = VERDICT_COLOURS.get(rec.verdict, colors.black)
        style.append(("TEXTCOLOR", (3, i), (3, i), colour))
        style.append(("FONTNAME", (3, i), (3, i), "Helvetica-Bold"))
    table.setStyle(TableStyle(style))
    return table

def generate_compliance_pdf(
    records: list[ComplianceRecord],
    out_path: str,
    reporting_period: str,
    approver: str | None = None,
) -> str:
    """Render the records to a paginated PDF and return the output path."""
    styles = getSampleStyleSheet()
    doc = SimpleDocTemplate(
        out_path,
        pagesize=A4,
        title=f"Compliance Report {reporting_period}",
        author="Geospatial Compliance",
        topMargin=20 * mm,
        bottomMargin=20 * mm,
        leftMargin=20 * mm,
        rightMargin=20 * mm,
    )

    passing = sum(1 for r in records if r.verdict == "pass")
    failing = sum(1 for r in records if r.verdict == "fail")

    story = [
        Paragraph("Spatial Data Compliance Report", styles["Title"]),
        Paragraph(f"Reporting period: {reporting_period}", styles["Normal"]),
        Spacer(1, 6),
        Paragraph(
            f"{len(records)} datasets · {passing} passing · "
            f"{failing} failing",
            styles["Normal"],
        ),
        Spacer(1, 14),
        _results_table(records),
        Spacer(1, 28),
    ]

    signer = approver or "________________________"
    story.append(Paragraph(
        f"Approved by: {signer}", styles["Normal"]))
    story.append(Paragraph(
        "Signature: ______________________   Date: ____________",
        styles["Normal"],
    ))

    doc.build(story, onFirstPage=_footer, onLaterPages=_footer)
    return out_path

if __name__ == "__main__":
    sample = [
        ComplianceRecord("parcels_2025", "3a7bd3e2360a" + "0" * 52,
                         "ISO 19115-3", "pass", "All mandatory fields present"),
        ComplianceRecord("flood_zones_v4", "9c56cc51b374" + "0" * 52,
                         "DCAT-AP", "fail", "Missing dct:license"),
        ComplianceRecord("roads_centreline", "1f0e3dad9990" + "0" * 52,
                         "license", "partial", "Attribution text absent"),
    ]
    written = generate_compliance_pdf(
        sample,
        out_path="compliance_report_2025_Q2.pdf",
        reporting_period="2025-Q2",
        approver=None,
    )
    print(f"Wrote {written}")

Running the script writes compliance_report_2025_Q2.pdf to the working directory. Each record maps to one table row, the verdict column is colour-coded, the header row repeats if the table spills onto a second page, and every page carries a footer with its page number and the UTC generation time. The signature block is intentionally left blank for a human approver to complete on the archival copy, or you can pass an approver name to pre-fill it.

Validation and pipeline integration

Because the PDF is a binary that is awkward to diff, validate the flowable-building logic rather than the rendered bytes. The test below confirms the document builds without error and that the output begins with the PDF magic header, which is a fast smoke test that catches most rendering regressions.

Whether a report may be regeneratedThree tests decide whether a compliance PDF may be regenerated or must be reissued as a new version.Has it been distributed?Issue a new versionsupersedes, does not replaceyesnoDid the underlying evidencechange?New report, new periodnever re-render silentlyyesnoRegenerate freelyundistributed draft
A distributed report is a published document; correcting it silently is the thing that destroys trust in the series.
# tests/test_compliance_pdf.py
from pathlib import Path
from generate_compliance_pdf import ComplianceRecord, generate_compliance_pdf

def test_pdf_is_written_and_valid(tmp_path: Path):
    records = [
        ComplianceRecord("layer_a", "a" * 64, "ISO 19115-3", "pass", "ok"),
        ComplianceRecord("layer_b", "b" * 64, "license", "fail", "no licence"),
    ]
    out = tmp_path / "report.pdf"
    generate_compliance_pdf(records, str(out), "2025-Q2")
    data = out.read_bytes()
    assert data.startswith(b"%PDF-")          # valid PDF header
    assert len(data) > 1000                    # non-trivial content

def test_empty_records_still_builds(tmp_path: Path):
    out = tmp_path / "empty.pdf"
    generate_compliance_pdf([], str(out), "2025-Q2")
    assert out.read_bytes().startswith(b"%PDF-")

Run the generator and its tests locally before wiring it into a schedule:

# Install the pinned dependency and verify the renderer
pip install "reportlab>=4.0" pytest

# Smoke-test the flowable logic
pytest tests/test_compliance_pdf.py -q

# Produce a sample PDF from the module's __main__ block
python generate_compliance_pdf.py

In CI, render the PDF from a fixture set of records on every change to the reporting code and upload it as a build artifact so reviewers can open the actual document produced by the branch:

name: Render Compliance PDF

on:
  pull_request:
    paths:
      - 'reporting/**'

jobs:
  render-pdf:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install "reportlab>=4.0" pytest
      - run: pytest tests/test_compliance_pdf.py -q
      - run: python reporting/generate_compliance_pdf.py
      - uses: actions/upload-artifact@v4
        with:
          name: compliance-pdf
          path: '*.pdf'

Long-term compliance best practices

  • Pre-fill the approver only for internal drafts. For a submitted report, leave the signature block blank so a named person physically or digitally signs it. A machine-populated approver name is a claim, not an attestation, and an auditor will treat the two differently.
  • Wrap every table cell in a Paragraph. Plain strings in a Table cell do not wrap and will silently overrun the column. Wrapping long detail text in a Paragraph with an explicit column width lets ReportLab flow it inside the cell, keeping multi-page tables legible.
  • Pin the reportlab version and record it in the report. Layout can shift subtly between major versions. Pinning the version and stamping it into the document footer means a future reader can reproduce the exact output, which is what makes the PDF archival rather than merely printed.
  • Embed the dataset content hash, never just the name. The hash prefix in the results table ties the report to the exact bytes evaluated, so the PDF remains meaningful even after the dataset is reprocessed and its name is reused for a newer version.
  • Target PDF/A for long-term retention. A standard PDF is fine for review, but archival submission often requires PDF/A. Generate the working PDF with ReportLab, then convert to PDF/A with a downstream tool so fonts and colour profiles are fully embedded for decade-scale retention.
  • Hash the finished PDF into the audit trail. After build, compute the SHA-256 of the output file and record it alongside the report metadata so the document becomes self-evidencing and any later substitution is detectable.
What belongs on the first page of a compliance PDFThe elements an audit reader looks for on page one, and why each matters.AnswersCommon omissionScopewhich catalogue, which datasets'all data' with no countPeriodevidence cut-off dategeneration date onlyVerdictone sentence, unhedgeda table insteadProvenancepolicy version, run idomitted entirely
A reader who cannot answer these four questions on page one will not read page two.