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.

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.

# 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.