Python PDF Generation with ReportLab — Deep Dive

Production PDF generation with ReportLab involves custom page templates, complex layouts with multiple frames, embedded charts, Unicode/font handling, and performance optimization for high-volume output.

Custom Page Templates with Frames

BaseDocTemplate with PageTemplate and Frame objects gives you full layout control:

from reportlab.platypus import BaseDocTemplate, PageTemplate, Frame, Paragraph
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm

def create_invoice_template():
    doc = BaseDocTemplate("invoice.pdf", pagesize=A4)

    # Define frames for different page regions
    header_frame = Frame(
        2*cm, A4[1] - 6*cm, A4[0] - 4*cm, 4*cm,
        id="header"
    )
    body_frame = Frame(
        2*cm, 3*cm, A4[0] - 4*cm, A4[1] - 10*cm,
        id="body"
    )
    footer_frame = Frame(
        2*cm, 1*cm, A4[0] - 4*cm, 2*cm,
        id="footer"
    )

    template = PageTemplate(
        id="invoice",
        frames=[header_frame, body_frame],
        onPage=draw_invoice_background
    )

    doc.addPageTemplates([template])
    return doc

def draw_invoice_background(canvas, doc):
    """Draw static elements on every page."""
    canvas.saveState()

    # Company logo area
    canvas.setFillColor("#2C3E50")
    canvas.rect(0, A4[1] - 2*cm, A4[0], 2*cm, fill=1)
    canvas.setFillColor("white")
    canvas.setFont("Helvetica-Bold", 16)
    canvas.drawString(2*cm, A4[1] - 1.4*cm, "ACME CORPORATION")

    # Footer line
    canvas.setStrokeColor("#BDC3C7")
    canvas.line(2*cm, 2.5*cm, A4[0] - 2*cm, 2.5*cm)
    canvas.setFont("Helvetica", 8)
    canvas.setFillColor("#7F8C8D")
    canvas.drawString(2*cm, 2*cm, f"Page {doc.page}")
    canvas.drawRightString(A4[0] - 2*cm, 2*cm,
                           "Invoice generated automatically")

    canvas.restoreState()

Multi-Page Layouts with Template Switching

from reportlab.platypus import NextPageTemplate, PageBreak

story = []

# First page uses "cover" template
story.append(NextPageTemplate("cover"))
story.append(Paragraph("Annual Report 2026", title_style))
story.append(PageBreak())

# Remaining pages use "content" template
story.append(NextPageTemplate("content"))
story.append(Paragraph("Executive Summary", heading_style))
# ... content ...

Custom Fonts and Unicode

ReportLab’s built-in fonts (Helvetica, Times, Courier) don’t support Unicode. For non-Latin scripts, register TTF fonts:

from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont

# Register fonts
pdfmetrics.registerFont(TTFont("NotoSans", "NotoSans-Regular.ttf"))
pdfmetrics.registerFont(TTFont("NotoSans-Bold", "NotoSans-Bold.ttf"))
pdfmetrics.registerFont(TTFont("NotoSansCJK", "NotoSansCJKjp-Regular.ttf"))

# Use in styles
from reportlab.lib.styles import ParagraphStyle

japanese_style = ParagraphStyle(
    "Japanese",
    fontName="NotoSansCJK",
    fontSize=12,
    leading=16,
)

# Now supports Japanese, Chinese, Korean characters
story.append(Paragraph("こんにちは世界", japanese_style))

Font Family Registration

from reportlab.pdfbase.pdfmetrics import registerFontFamily

registerFontFamily(
    "NotoSans",
    normal="NotoSans",
    bold="NotoSans-Bold",
    italic="NotoSans-Italic",
    boldItalic="NotoSans-BoldItalic"
)

This lets Platypus automatically switch to bold/italic variants when it encounters <b> or <i> tags in Paragraph markup.

Inline Charts with ReportLab Graphics

from reportlab.graphics.shapes import Drawing
from reportlab.graphics.charts.barcharts import VerticalBarChart
from reportlab.graphics import renderPDF

def create_bar_chart(data, categories):
    drawing = Drawing(400, 200)
    chart = VerticalBarChart()
    chart.x = 50
    chart.y = 30
    chart.width = 300
    chart.height = 150
    chart.data = data
    chart.categoryAxis.categoryNames = categories
    chart.categoryAxis.labels.angle = 30
    chart.valueAxis.valueMin = 0
    chart.bars[0].fillColor = colors.HexColor("#3498DB")
    chart.bars[1].fillColor = colors.HexColor("#E74C3C")

    drawing.add(chart)
    return drawing

# Usage in Platypus
chart = create_bar_chart(
    [[12, 15, 18, 22], [8, 9, 12, 14]],
    ["Q1", "Q2", "Q3", "Q4"]
)
story.append(chart)

Barcode Generation

from reportlab.graphics.barcode import qr, code128

# QR Code
qr_code = qr.QrCodeWidget("https://example.com/invoice/12345")
qr_code.barWidth = 100
qr_code.barHeight = 100

d = Drawing(120, 120)
d.add(qr_code)
story.append(d)

# Code 128 Barcode
from reportlab.graphics.barcode import createBarcodeDrawing

barcode = createBarcodeDrawing(
    "Code128", value="INV-2026-0042",
    barWidth=1.2, barHeight=40,
    humanReadable=True
)
story.append(barcode)

Table Spanning and Complex Layouts

from reportlab.platypus import Table, TableStyle

data = [
    ["Invoice #12345", "", "", "Date: 2026-03-28"],
    ["Item", "Qty", "Price", "Total"],
    ["Widget Pro", "10", "$25.00", "$250.00"],
    ["Widget Lite", "25", "$12.00", "$300.00"],
    ["Support Plan", "1", "$500.00", "$500.00"],
    ["", "", "Subtotal:", "$1,050.00"],
    ["", "", "Tax (10%):", "$105.00"],
    ["", "", "Total:", "$1,155.00"],
]

table = Table(data, colWidths=[200, 60, 80, 80])
table.setStyle(TableStyle([
    # Header row spans all columns
    ("SPAN", (0, 0), (2, 0)),
    ("SPAN", (3, 0), (3, 0)),

    # Column header styling
    ("BACKGROUND", (0, 1), (-1, 1), colors.HexColor("#34495E")),
    ("TEXTCOLOR", (0, 1), (-1, 1), colors.white),

    # Totals section
    ("LINEABOVE", (2, 5), (-1, 5), 1, colors.black),
    ("FONTNAME", (2, 7), (-1, 7), "Helvetica-Bold"),
    ("LINEABOVE", (2, 7), (-1, 7), 2, colors.black),

    # Grid for item rows
    ("GRID", (0, 1), (-1, 4), 0.5, colors.grey),
    ("ALIGN", (1, 1), (-1, -1), "RIGHT"),
]))

High-Volume Generation Patterns

For generating thousands of PDFs (invoices, reports, certificates):

import io
from concurrent.futures import ProcessPoolExecutor
from reportlab.platypus import SimpleDocTemplate, Paragraph
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.pagesizes import A4

def generate_single_invoice(invoice_data):
    """Generate one PDF, return bytes."""
    buffer = io.BytesIO()
    doc = SimpleDocTemplate(buffer, pagesize=A4)
    styles = getSampleStyleSheet()

    story = build_invoice_story(invoice_data, styles)
    doc.build(story)

    return invoice_data["id"], buffer.getvalue()

def generate_batch(invoices, output_dir, max_workers=4):
    """Generate PDFs in parallel using multiple processes."""
    from pathlib import Path

    Path(output_dir).mkdir(exist_ok=True)

    with ProcessPoolExecutor(max_workers=max_workers) as executor:
        futures = {
            executor.submit(generate_single_invoice, inv): inv["id"]
            for inv in invoices
        }

        for future in futures:
            inv_id, pdf_bytes = future.result()
            path = Path(output_dir) / f"invoice-{inv_id}.pdf"
            path.write_bytes(pdf_bytes)
            print(f"Generated: {path}")

Performance Tips

  • Use io.BytesIO instead of file paths when generating to memory
  • Process pool (not thread pool) — ReportLab releases the GIL poorly
  • Pre-load fonts once before spawning workers
  • Reuse styles — create getSampleStyleSheet() once per worker
  • Typical throughput: 50-200 simple PDFs/second on modern hardware

PDF/A Compliance

For archival documents (legal, government), PDF/A compliance is required:

from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4

c = canvas.Canvas("archive.pdf", pagesize=A4)

# Set PDF metadata
c.setTitle("Annual Report 2026")
c.setAuthor("Acme Corp")
c.setSubject("Financial Summary")
c.setCreator("ReportLab via Python")

# Embed all fonts (required for PDF/A)
# Using registered TTF fonts ensures embedding
c.setFont("NotoSans", 12)
c.drawString(72, 700, "This document uses embedded fonts for PDF/A compliance.")

c.save()

# Note: Full PDF/A validation requires additional metadata (XMP, output intent)
# ReportLab PLUS (commercial) has built-in PDF/A support
# For open-source, post-process with pikepdf or pdfa-converter

Testing Generated PDFs

import pytest
from pathlib import Path

def test_invoice_generation():
    """Verify generated PDF has expected properties."""
    from pypdf import PdfReader

    # Generate
    generate_invoice(test_data, "test_invoice.pdf")

    # Verify
    reader = PdfReader("test_invoice.pdf")

    assert len(reader.pages) >= 1
    assert reader.metadata.title == "Invoice #12345"

    # Extract text and verify key content
    text = reader.pages[0].extract_text()
    assert "Widget Pro" in text
    assert "$1,155.00" in text
    assert "Acme Corp" in text

    Path("test_invoice.pdf").unlink()

ReportLab vs. Alternatives

FeatureReportLabWeasyPrintFPDF2
ApproachProgrammaticHTML/CSS → PDFProgrammatic
Learning curveMediumLow (if you know CSS)Low
Charts built-inYesNo (use images)No
UnicodeTTF fontsFullTTF fonts
SpeedFastSlower (renders HTML)Fast
Layout complexityHigh (Platypus)High (CSS)Low
MaintenanceActive, matureActiveActive

The one thing to remember: ReportLab’s power is in combining Platypus for auto-pagination with Canvas callbacks for branding — master the BaseDocTemplate + PageTemplate + Frame pattern for production-grade document generation at any scale.

pythonreportlabPDFdocument-generationproductiontemplates

See Also

  • Python Docx Generation python-docx lets you create and edit Word documents from Python — perfect for automating letters, contracts, and reports.
  • Python Excel Openpyxl openpyxl lets Python read and write real Excel files — no Excel needed on the computer.
  • Ci Cd Why big apps can ship updates every day without turning your phone into a glitchy mess — CI/CD is the behind-the-scenes quality gate and delivery truck.
  • Containerization Why does software that works on your computer break on everyone else's? Containers fix that — and they're why Netflix can deploy 100 updates a day without the site going down.
  • Python 310 New Features Python 3.10 gave programmers a shape-sorting machine, friendlier error messages, and cleaner ways to say 'this or that' in type hints.