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 aTablecell do not wrap and will silently overrun the column. Wrapping long detail text in aParagraphwith an explicit column width lets ReportLab flow it inside the cell, keeping multi-page tables legible. - Pin the
reportlabversion 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.
Related
- Automated Compliance Report Generation — the parent guide covering evidence aggregation and the HTML rendering path
- Building HTML Audit Summaries from Metadata Records — the HTML-first companion to this PDF workflow
- Spatial Data Audit Reporting & Compliance Governance — the domain that treats the rendered PDF as durable evidence
- Audit Trail & Evidence Retention — where the finished PDF’s hash is recorded for tamper detection