Generating Compliance Notice PDFs with ReportLab
This guide sits under automating violation notice generation and delivery, part of the Violation Tracking & Compliance Enforcement track, and covers the one step where a validated notice context becomes a durable, servable document. Where the parent guide assembles and dispatches, this page renders: turning a typed context into an immutable PDF that a hearing officer will still read years later.
The task is narrow and unforgiving. The rendered notice is the legal artifact — the thing served, archived, and produced on appeal — so it must be deterministic (the same context yields byte-identical output, verifiable by content hash), self-contained (fonts embedded so it renders identically on any machine), and archival (structured for long-term retention, ideally PDF/A). Get the layout wrong and a clerk re-keys it by hand; get the determinism wrong and you cannot prove the archived document matches the one you served. This walkthrough builds the notice with ReportLab’s Platypus flowable framework: letterhead, a violations table, a computed cure-deadline block, appeal-rights language, a signature block, and page numbering — with the byte-level controls that make the output reproducible.
Permalink to this section Step 1 — Register and Embed Fonts
A notice that renders with a substitute font on the recipient’s machine is a different document. Register the exact TrueType faces you will use and embed them so the PDF is self-describing. Relying on the base-14 PDF fonts is tempting but brittle: they are not embedded, and metrics vary across viewers, breaking table alignment and, worse, breaking byte-for-byte reproducibility.
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
# Ship the .ttf files with the application; do not rely on system fonts, which
# differ across build hosts and would make output non-reproducible.
FONT_DIR = "assets/fonts"
def register_fonts() -> None:
pdfmetrics.registerFont(TTFont("Notice", f"{FONT_DIR}/NotoSans-Regular.ttf"))
pdfmetrics.registerFont(TTFont("Notice-Bold", f"{FONT_DIR}/NotoSans-Bold.ttf"))
pdfmetrics.registerFont(TTFont("Notice-Italic", f"{FONT_DIR}/NotoSans-Italic.ttf"))
# Register the family so <b>/<i> markup in Paragraphs resolves correctly.
pdfmetrics.registerFontFamily(
"Notice", normal="Notice", bold="Notice-Bold", italic="Notice-Italic",
)
Vendoring the font files with the codebase, rather than reading them from the OS, is what lets a build on a developer laptop and a build on a CI runner produce identical bytes.
Permalink to this section Step 2 — Build the Story as Ordered Flowables
Platypus lays out a document from a “story” — an ordered list of flowables (paragraphs, tables, spacers) that flow across pages automatically. Build the story from the typed context so the structure is data-driven, and define paragraph styles once so every notice looks the same.
from reportlab.lib import colors
from reportlab.lib.enums import TA_JUSTIFY
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, Spacer, Table, TableStyle
BODY = ParagraphStyle("body", fontName="Notice", fontSize=10, leading=14,
alignment=TA_JUSTIFY, spaceAfter=6)
H = ParagraphStyle("h", fontName="Notice-Bold", fontSize=13, leading=16, spaceAfter=8)
SMALL = ParagraphStyle("small", fontName="Notice", fontSize=8, leading=10,
textColor=colors.HexColor("#5b6b80"))
def respondent_block(ctx) -> list:
return [
Paragraph("NOTICE OF VIOLATION", H),
Paragraph(f"Case No. {ctx.case_number} · Parcel {ctx.parcel_id}", SMALL),
Spacer(1, 6 * mm),
Paragraph(ctx.respondent_name, BODY),
Paragraph(ctx.service_address.replace(", ", "<br/>"), BODY),
Spacer(1, 4 * mm),
]
Escaping is not optional here. Respondent names and violation descriptions are user-derived strings; pass them through an XML-escaping helper before handing them to Paragraph, or an ampersand in a business name will raise at layout time or corrupt the markup.
Permalink to this section Step 3 — Render the Violations Table
The heart of the notice is the table of cited violations. Each row pairs a code section with the observed condition and the specific corrective action. Use a Platypus Table with an explicit column model so long descriptions wrap inside the cell rather than overrunning the page, and wrap cell text in Paragraph objects so it flows.
def violations_table(ctx) -> Table:
header = [Paragraph("<b>Code Section</b>", BODY),
Paragraph("<b>Condition Observed</b>", BODY),
Paragraph("<b>Required Correction</b>", BODY)]
rows = [header]
for c in ctx.citations:
rows.append([
Paragraph(f"{c.code_section}<br/><font size=8>({c.code_version})</font>", BODY),
Paragraph(escape(c.heading + ": " + c.description), BODY),
Paragraph(escape(c.correction), BODY),
])
# Fixed column widths keep the table byte-stable and prevent overrun.
table = Table(rows, colWidths=[34 * mm, 68 * mm, 68 * mm], repeatRows=1)
table.setStyle(TableStyle([
("FONT", (0, 0), (-1, -1), "Notice", 9),
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e6f0f8")),
("LINEBELOW", (0, 0), (-1, 0), 0.8, colors.HexColor("#1f5d8c")),
("GRID", (0, 1), (-1, -1), 0.4, colors.HexColor("#c9d4e0")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
]))
return table
repeatRows=1 reprints the header on every page the table spills onto — essential when a property has a dozen citations and the table breaks across pages, so no continuation page shows an unlabeled grid.
Permalink to this section Step 4 — Stamp Header, Footer, and Page Numbers
Every page must carry the case number and a “Page X of Y” footer so a detached page is still identifiable and a respondent cannot claim a page was withheld. ReportLab knows the total page count only after the first layout pass, so render in two passes: draw with a placeholder, then re-draw once the total is known.
from reportlab.lib.pagesizes import LETTER
from reportlab.platypus import BaseDocTemplate, Frame, PageTemplate
class NoticeDoc(BaseDocTemplate):
def __init__(self, buffer, case_number: str):
super().__init__(buffer, pagesize=LETTER,
leftMargin=22 * mm, rightMargin=22 * mm,
topMargin=28 * mm, bottomMargin=22 * mm,
title=f"Notice of Violation {case_number}",
author="Department of Code Enforcement")
self.case_number = case_number
frame = Frame(self.leftMargin, self.bottomMargin,
self.width, self.height, id="body")
self.addPageTemplates([PageTemplate(id="notice", frames=[frame],
onPage=self._decorate)])
def _decorate(self, canvas, doc) -> None:
canvas.saveState()
canvas.setFont("Notice", 8)
canvas.setFillColor(colors.HexColor("#5b6b80"))
canvas.drawString(22 * mm, LETTER[1] - 16 * mm,
f"Case {self.case_number} — Notice of Violation")
# total page count comes from doc.page after the first pass
canvas.drawRightString(LETTER[0] - 22 * mm, 14 * mm,
f"Page {doc.page}")
canvas.restoreState()
For a true “Page X of Y”, subclass the canvas to buffer all showPage calls and stamp the total once the document is complete; the single-pass doc.page above is adequate when notices are short and the footer only needs the current page.
Permalink to this section Step 5 — Pin Metadata for Deterministic, Archival Output
By default ReportLab stamps the current timestamp and a version-specific producer string into the PDF, so two runs of the same notice differ in bytes and hash. For an evidentiary document that must be verifiable by content hash, pin these fields explicitly. This is also the foundation for PDF/A conformance, which archival retention programs increasingly require.
import io
from datetime import datetime, timezone
def render_notice(ctx, deadline, story: list) -> bytes:
buffer = io.BytesIO()
doc = NoticeDoc(buffer, ctx.case_number)
doc.build(story)
pdf = buffer.getvalue()
# ReportLab embeds a creation date + producer; for reproducibility we build
# with a fixed producer and derive the doc date from the violation, not now().
# (Set canv.setProducer / info dict via BaseDocTemplate.canv in a real build.)
return pdf
def content_hash(pdf: bytes) -> str:
import hashlib
return hashlib.sha256(pdf).hexdigest()
To make the hash stable, set a fixed producer on the document info dictionary, derive the PDF’s creation date from the notice context (the violation or generation date) rather than datetime.now(), and embed all fonts as in Step 1. For PDF/A, additionally embed an sRGB output-intent profile, mark all fonts embedded (no base-14), and add the XMP metadata block that declares the conformance level — libraries such as pikepdf can post-process a ReportLab PDF into PDF/A-2b if you cannot emit it directly.
Permalink to this section Parameter and Flag Reference
| Parameter | Type | Default | Notes for compliance notices |
|---|---|---|---|
pagesize |
tuple | LETTER |
US municipal standard; use A4 only where the jurisdiction mandates it. |
embed_fonts |
bool |
True |
Non-negotiable for reproducibility and archival; never ship base-14. |
producer |
str |
pinned constant | Fix it, or every run’s bytes differ and the hash is useless. |
creation_date |
datetime |
context-derived | Derive from the notice date, not now(), for a stable hash. |
repeatRows |
int |
1 |
Reprints the violations-table header on continuation pages. |
colWidths |
list[float] | fixed mm | Fixed widths keep layout and bytes deterministic; avoid None (auto). |
pdfa_level |
str |
"none" |
"2b" for archival retention; requires output intent + XMP. |
escape_input |
bool |
True |
XML-escape all respondent/violation strings before Paragraph. |
Permalink to this section Common Failure Patterns and Fixes
Permalink to this section Non-reproducible bytes across runs
Two builds of the same notice produce different hashes because ReportLab stamps the wall-clock creation date and a version-tagged producer. Pin both — a fixed producer string and a context-derived creation date — and embed fonts so no host substitution creeps in. Assert byte-equality of two renders of one fixture in a unit test.
Permalink to this section Ampersands and angle brackets crashing layout
Platypus Paragraph parses a mini-XML markup, so a raw & or < in a business name or description raises ValueError or silently mangles output. Route every user-derived string through xml.sax.saxutils.escape before building the flowable, and fixture-test a name containing & and <.
Permalink to this section Table overrunning the page width
Passing colWidths=None lets ReportLab auto-size columns from content, so one long description blows the table past the right margin and clips. Set explicit colWidths that sum to less than the frame width, wrap every cell in a Paragraph so text wraps, and set VALIGN to TOP.
Permalink to this section Missing glyphs rendered as blank boxes
A respondent name with an accented or non-Latin character renders as tofu if the embedded font lacks the glyph. Use a broad-coverage face (a Noto family is a safe default) and, for names outside its range, register a fallback font; log any glyph substitution so a clerk can verify the served name is legible.
Permalink to this section PDF/A validation failures on archive
A PDF that looks fine fails PDF/A validation because it lacks an output intent, has unembedded fonts, or omits the XMP metadata block. Run a validator in CI against a sample notice, embed an sRGB output intent, confirm every font is embedded, and add the conformance XMP; post-process with pikepdf if ReportLab alone will not satisfy the checker.
Permalink to this section Audit and Logging Guidance
Log, per rendered notice, the case number, the content hash, the ReportLab and font versions, the PDF/A level, and the flowable count — enough to reproduce the exact document later or to prove which build produced it. Persist the hash next to the archived PDF in append-only storage; the same hash is written onto the proof-of-service record so tracking certified mail delivery for violation notices can later assert that the document served is byte-identical to the document archived. Never log the rendered bytes themselves at info level, and redact respondent PII from any layout-error traces so a stack trace does not become an incidental disclosure. Verify a sample of stored hashes on a schedule so silent archive corruption surfaces in days rather than at audit time.
Permalink to this section Frequently Asked Questions
Permalink to this section Why ReportLab instead of an HTML-to-PDF converter?
ReportLab gives you direct, deterministic control over the byte stream — embedded fonts, pinned metadata, exact table geometry — which is what makes a notice reproducible and hash-verifiable. HTML-to-PDF stacks introduce a browser or rendering engine whose output shifts across versions, so the same notice can hash differently after an unrelated upgrade, undermining the evidentiary chain.
Permalink to this section How do I get a real “Page X of Y” footer?
Buffer the pages: subclass canvas.Canvas, capture each showPage call into a list instead of emitting immediately, and after build completes iterate the captured pages stamping the now-known total before writing them out. The single-pass doc.page only knows the current page number, which is fine for short notices but cannot show the total.
Permalink to this section Is PDF/A actually required for violation notices?
It depends on your state’s records-retention rules, but archival programs increasingly expect PDF/A because it guarantees the document renders identically decades later with all fonts and color profiles self-contained. Even where not strictly mandated, emitting PDF/A-2b is cheap insurance for a document you must produce years after service.
Permalink to this section Related
- Automating violation notice generation and delivery — the parent pipeline that supplies the context this renderer consumes.
- Tracking certified mail delivery for violation notices — matches the content hash rendered here against the served document.
- Cross-referencing state and local building codes — resolves the code sections printed in the violations table.
- Modeling code violation lifecycles in Python — the case records that become notices.