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.BytesIOinstead 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
| Feature | ReportLab | WeasyPrint | FPDF2 |
|---|---|---|---|
| Approach | Programmatic | HTML/CSS → PDF | Programmatic |
| Learning curve | Medium | Low (if you know CSS) | Low |
| Charts built-in | Yes | No (use images) | No |
| Unicode | TTF fonts | Full | TTF fonts |
| Speed | Fast | Slower (renders HTML) | Fast |
| Layout complexity | High (Platypus) | High (CSS) | Low |
| Maintenance | Active, mature | Active | Active |
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.
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.