fpdf2 generates PDF documents in pure Python. pip install fpdf2. Basic: from fpdf import FPDF. pdf = FPDF(); pdf.add_page(); pdf.set_font("Helvetica", size=12). pdf.cell(0, 10, "Hello World"). pdf.output("out.pdf"). Font: pdf.set_font("Helvetica", "B", 14) — B=bold I=italic U=underline. pdf.set_font("Times", size=11). pdf.set_font("Courier"). Cell: pdf.cell(w, h, text, border=1, align="C", fill=True). w=0 — full remaining width. Multi-cell: pdf.multi_cell(0, 8, long_text) — wraps at width. Line break: pdf.ln(5). pdf.ln() — default height. Colors: pdf.set_fill_color(200, 200, 200). pdf.set_text_color(255, 0, 0). pdf.set_draw_color(0, 0, 0). Image: pdf.image("logo.png", x=10, y=10, w=50). pdf.image(io.BytesIO(data), ...). Position: pdf.set_x(10), pdf.set_y(50), pdf.set_xy(10, 50). Size: pdf.get_string_width(text). pdf.epw — effective page width. Page size: FPDF(orientation="L", format="A4"). Header/footer: override header() and footer() methods. HTML: from fpdf import HTMLMixin. pdf.write_html("<b>bold</b><br><table>..."). Output bytes: pdf.output() — returns bytes. pdf.output("file.pdf") — writes file. Claude Code generates fpdf2 invoice renderers, report templates, and PDF pipelines.
CLAUDE.md for fpdf2
## fpdf2 Stack
- Version: fpdf2 >= 2.7 | pip install fpdf2
- Init: FPDF(); pdf.add_page(); pdf.set_font("Helvetica", "B", 14)
- Cell: pdf.cell(w, h, text, border=1, align="C", fill=True) — w=0 = full width
- Wrap: pdf.multi_cell(0, 8, long_text) — auto line-break at page width
- Color: pdf.set_fill_color(r,g,b) | set_text_color | set_draw_color
- Image: pdf.image("path", x, y, w) — PNG/JPEG; pass BytesIO for in-memory
- Output: pdf.output("file.pdf") | bytes_pdf = pdf.output() for HTTP response
fpdf2 PDF Generation Pipeline
# app/pdf_gen.py — fpdf2 invoice, report, and table PDF generators
from __future__ import annotations
import io
from dataclasses import dataclass
from datetime import date
from typing import Any
from fpdf import FPDF, FPDFException
# ─────────────────────────────────────────────────────────────────────────────
# 1. Base document class with header/footer
# ─────────────────────────────────────────────────────────────────────────────
class BasePDF(FPDF):
"""
Subclass FPDF and override header() / footer() for consistent branding.
header() is called automatically at the start of each new page.
footer() is called automatically before each page ends.
"""
def __init__(
self,
company: str = "My Company",
logo_path: str | None = None,
**kwargs,
) -> None:
super().__init__(**kwargs)
self.company = company
self.logo_path = logo_path
self.set_auto_page_break(auto=True, margin=20)
self.add_page()
def header(self) -> None:
# Logo
if self.logo_path:
try:
self.image(self.logo_path, x=10, y=8, w=30)
except FPDFException:
pass
# Company name
self.set_font("Helvetica", "B", 16)
self.set_text_color(40, 40, 40)
self.cell(0, 10, self.company, align="R")
self.ln(12)
# Separator line
self.set_draw_color(200, 200, 200)
self.line(10, self.get_y(), self.w - 10, self.get_y())
self.ln(4)
def footer(self) -> None:
self.set_y(-15)
self.set_font("Helvetica", "I", 8)
self.set_text_color(150, 150, 150)
self.cell(0, 10, f"Page {self.page_no()}", align="C")
def section_title(self, title: str) -> None:
"""Styled section header."""
self.set_font("Helvetica", "B", 12)
self.set_fill_color(240, 240, 240)
self.set_text_color(50, 50, 50)
self.cell(0, 8, f" {title}", fill=True)
self.ln(10)
def body_text(self, text: str, line_height: int = 6) -> None:
"""Regular body paragraph with auto word-wrap."""
self.set_font("Helvetica", size=10)
self.set_text_color(60, 60, 60)
self.multi_cell(0, line_height, text)
self.ln(3)
# ─────────────────────────────────────────────────────────────────────────────
# 2. Invoice generator
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class LineItem:
description: str
quantity: float
unit_price: float
@property
def total(self) -> float:
return self.quantity * self.unit_price
def generate_invoice(
invoice_number: str,
invoice_date: date,
due_date: date,
client: dict[str, str],
items: list[LineItem],
tax_rate: float = 0.10,
company: str = "My Company",
notes: str = "",
) -> bytes:
"""
Generate a PDF invoice.
Returns the PDF as bytes for HTTP response or file write.
"""
pdf = BasePDF(company=company, orientation="P", format="A4")
pdf.set_margins(15, 15, 15)
# Invoice metadata
pdf.set_font("Helvetica", "B", 22)
pdf.set_text_color(50, 50, 50)
pdf.cell(0, 12, "INVOICE", align="C")
pdf.ln(8)
# Invoice number / dates — two columns
col_w = (pdf.epw - 10) / 2
pdf.set_font("Helvetica", size=10)
pdf.set_text_color(80, 80, 80)
pdf.cell(col_w, 6, f"Invoice #: {invoice_number}")
pdf.cell(col_w, 6, f"Bill To: {client.get('name', '')}", align="R")
pdf.ln(6)
pdf.cell(col_w, 6, f"Date: {invoice_date.strftime('%B %d, %Y')}")
pdf.cell(col_w, 6, client.get("address", ""), align="R")
pdf.ln(6)
pdf.cell(col_w, 6, f"Due: {due_date.strftime('%B %d, %Y')}")
pdf.cell(col_w, 6, client.get("city", ""), align="R")
pdf.ln(10)
# Table header
col_widths = [pdf.epw * 0.50, pdf.epw * 0.15, pdf.epw * 0.17, pdf.epw * 0.18]
headers = ["Description", "Qty", "Unit Price", "Total"]
pdf.set_fill_color(50, 50, 50)
pdf.set_text_color(255, 255, 255)
pdf.set_font("Helvetica", "B", 10)
for w, h in zip(col_widths, headers):
pdf.cell(w, 8, h, align="C", fill=True)
pdf.ln()
# Table rows (alternating shading)
subtotal = 0.0
for i, item in enumerate(items):
fill = i % 2 == 0
pdf.set_fill_color(248, 248, 248)
pdf.set_text_color(50, 50, 50)
pdf.set_font("Helvetica", size=9)
pdf.cell(col_widths[0], 7, item.description, fill=fill)
pdf.cell(col_widths[1], 7, str(item.quantity), align="C", fill=fill)
pdf.cell(col_widths[2], 7, f"${item.unit_price:,.2f}", align="R", fill=fill)
pdf.cell(col_widths[3], 7, f"${item.total:,.2f}", align="R", fill=fill)
pdf.ln()
subtotal += item.total
# Totals section
pdf.ln(4)
tax = subtotal * tax_rate
total = subtotal + tax
right_x = pdf.epw * 0.60
def total_row(label: str, amount: float, bold: bool = False) -> None:
pdf.set_x(15 + right_x)
pdf.set_font("Helvetica", "B" if bold else "", 10)
pdf.set_text_color(50, 50, 50)
pdf.cell(pdf.epw * 0.22, 7, label)
pdf.cell(pdf.epw * 0.18, 7, f"${amount:,.2f}", align="R")
pdf.ln()
total_row("Subtotal:", subtotal)
total_row(f"Tax ({tax_rate:.0%}):", tax)
# Separator
pdf.set_draw_color(180, 180, 180)
pdf.line(15 + right_x, pdf.get_y(), pdf.w - 15, pdf.get_y())
pdf.ln(1)
total_row("TOTAL:", total, bold=True)
# Notes
if notes:
pdf.ln(8)
pdf.section_title("Notes")
pdf.body_text(notes)
return pdf.output()
# ─────────────────────────────────────────────────────────────────────────────
# 3. Data table report
# ─────────────────────────────────────────────────────────────────────────────
def generate_table_report(
title: str,
columns: list[str],
rows: list[list[Any]],
col_widths: list[float] | None = None,
company: str = "My Company",
orientation: str = "P",
) -> bytes:
"""Generate a PDF with a data table. Auto-distributes column widths if not provided."""
pdf = BasePDF(
company=company,
orientation=orientation, # "L" for landscape wide tables
format="A4",
)
pdf.set_margins(10, 10, 10)
# Title
pdf.set_font("Helvetica", "B", 16)
pdf.set_text_color(40, 40, 40)
pdf.cell(0, 10, title, align="C")
pdf.ln(12)
# Column widths
n = len(columns)
widths = col_widths or [pdf.epw / n] * n
# Header row
pdf.set_fill_color(70, 130, 180)
pdf.set_text_color(255, 255, 255)
pdf.set_font("Helvetica", "B", 9)
for w, col in zip(widths, columns):
pdf.cell(w, 8, str(col), border=1, align="C", fill=True)
pdf.ln()
# Data rows
pdf.set_font("Helvetica", size=9)
for i, row in enumerate(rows):
fill = i % 2 == 0
pdf.set_fill_color(245, 245, 250) if fill else pdf.set_fill_color(255, 255, 255)
pdf.set_text_color(40, 40, 40)
for w, cell in zip(widths, row):
pdf.cell(w, 7, str(cell), border="LR", fill=True)
pdf.ln()
# Bottom border
for w in widths:
pdf.cell(w, 0, "", border="T")
pdf.ln(6)
pdf.set_font("Helvetica", "I", 8)
pdf.set_text_color(130, 130, 130)
pdf.cell(0, 6, f"Total: {len(rows)} row(s)")
return pdf.output()
# ─────────────────────────────────────────────────────────────────────────────
# 4. Flask response helper
# ─────────────────────────────────────────────────────────────────────────────
FLASK_EXAMPLE = '''
from flask import Flask, Response
from app.pdf_gen import generate_invoice, LineItem
from datetime import date
app = Flask(__name__)
@app.get("/invoice/<int:invoice_id>")
def invoice_pdf(invoice_id: int):
items = [
LineItem("Consulting services", 8, 150.00),
LineItem("Code review", 2, 95.00),
]
pdf_bytes = generate_invoice(
invoice_number=f"INV-{invoice_id:04d}",
invoice_date=date.today(),
due_date=date.today().replace(day=28),
client={"name": "Acme Corp", "address": "123 Main St", "city": "Springfield"},
items=items,
tax_rate=0.10,
)
return Response(
pdf_bytes,
mimetype="application/pdf",
headers={"Content-Disposition": f"attachment; filename=invoice-{invoice_id}.pdf"},
)
'''
# ─────────────────────────────────────────────────────────────────────────────
# Demo
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
from pathlib import Path
print("=== Generating invoice.pdf ===")
items = [
LineItem("Python consulting", 10, 200.00),
LineItem("Code review & refactoring", 4, 150.00),
LineItem("Documentation", 2, 80.00),
]
pdf_bytes = generate_invoice(
invoice_number="INV-0042",
invoice_date=date(2024, 1, 15),
due_date=date(2024, 2, 15),
client={
"name": "Acme Corporation",
"address": "456 Commerce Blvd",
"city": "San Francisco, CA 94102",
},
items=items,
tax_rate=0.085,
company="Claude Skills LLC",
notes="Thank you for your business! Payment is due within 30 days.",
)
Path("/tmp/invoice.pdf").write_bytes(pdf_bytes)
print(f" Written {len(pdf_bytes):,} bytes to /tmp/invoice.pdf")
print("\n=== Generating report.pdf ===")
columns = ["Name", "Department", "Sales ($)", "Target ($)", "% Target"]
rows = [
["Alice Johnson", "West", "142,000", "130,000", "109%"],
["Bob Martinez", "East", "98,500", "120,000", "82%"],
["Carol Williams", "Central", "175,200", "150,000", "117%"],
["David Chen", "North", "61,000", "90,000", "68%"],
]
report_bytes = generate_table_report(
"Q4 Sales Performance",
columns, rows,
company="Sales Analytics Inc",
orientation="L",
)
Path("/tmp/report.pdf").write_bytes(report_bytes)
print(f" Written {len(report_bytes):,} bytes to /tmp/report.pdf")
For the reportlab alternative — ReportLab is more powerful for complex documents (flowable layouts, vector graphics, platypus story model), but fpdf2’s immediate-mode API is much simpler for straightforward documents: pdf.cell(w, h, text) maps directly to the position model, and generating an invoice requires only add_page, set_font, cell, and multi_cell — no flowables, story lists, or frame objects. For the WeasyPrint / pdfkit alternative — WeasyPrint and pdfkit convert HTML/CSS to PDF, which is the right approach when you already have an HTML template (Jinja2 → WeasyPrint is a clean pipeline), but they require OS-level dependencies (wkhtmltopdf, Cairo, Pango); fpdf2 is a pure-Python PDF writer with no binary dependencies, making it easier to deploy in containers and Lambda functions. The Claude Skills 360 bundle includes fpdf2 skill sets covering FPDF initialization with orientation and format, header() and footer() override for page templates, cell() with width/height/text/border/align/fill, multi_cell() for auto-wrapping text, set_fill_color/set_text_color/set_draw_color, image() for PNG/JPEG embedding, set_x/set_y/set_xy for positioning, epw effective page width, section_title and body_text helpers, generate_invoice with line items and tax, generate_table_report with alternating rows and auto column widths, and Flask Response PDF download pattern. Start with the free tier to try PDF document generation code generation.