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.

Deterministic ReportLab build order for a compliance notice PDF A validated notice context flows into a story builder that produces an ordered sequence of Platypus flowables: letterhead, respondent address block, a violations table, a cure-deadline callout, appeal-rights paragraphs, and a signature block. The flowables are laid out by a page template that draws a running header and a page-number footer on every page. The canvas writer embeds fonts, pins the producer string and creation date, and writes the bytes, producing a deterministic PDF whose sha256 content hash is stable across runs and suitable for archival retention. Notice context typed + validated Story builder · flowables ordered Platypus list Page template header · page-number footer Canvas writer embed · pin · hash Flowable order 1 · Letterhead 2 · Respondent block 3 · Violations table 4 · Cure-deadline callout 5 · Appeal-rights text 6 · Signature block Deterministic PDF embedded fonts · pinned producer + creation date stable sha256 → matches proof-of-service record

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} &#183; 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.

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.

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.