April 18, 2026

Python PDF Generation: ReportLab, WeasyPrint & API Compared

PythonPDF generationWeasyPrintReportLabtutorial

Generating PDFs in Python comes up constantly — invoices, reports, certificates, data exports. Python has no shortage of libraries, but each has different strengths, dependencies, and CSS support.

This guide compares six major approaches with working code examples and explains when to use each. All API examples use FUNBREW PDF — no Chromium binary required in your server.

Python PDF Generation: Six Approaches Compared

Approach HTML Support CSS Quality Dependencies Serverless Best For
FUNBREW PDF API Full High (Chromium) requests only Yes Production, SaaS, complex layouts
ReportLab None Medium Yes Data reports, charts, programmatic docs
WeasyPrint Full Medium–High Large (GTK) Difficult HTML-to-PDF, mid-scale apps
xhtml2pdf XHTML Low Medium Yes Simple documents, no binary needed
pdfkit (wkhtmltopdf) Full Medium wkhtmltopdf binary Difficult Legacy wkhtmltopdf environments
Playwright for Python Full High (Chromium) Chromium binary Difficult Local dev, combined with test automation

For complex HTML layouts — charts, tables, CJK fonts — a PDF API is the most practical approach. See the PDF API vs library comparison for a detailed breakdown.

1. FUNBREW PDF API (Recommended)

Installation and Base Setup

pip install requests python-dotenv
# pdf_client.py
import os
import requests
from dotenv import load_dotenv

load_dotenv()

FUNBREW_API_KEY = os.environ["FUNBREW_API_KEY"]
FUNBREW_API_URL = os.getenv("FUNBREW_API_URL", "https://api.pdf.funbrew.cloud/v1")


def html_to_pdf(
    html: str,
    engine: str = "quality",   # "quality" (Chromium) or "fast" (wkhtmltopdf)
    format: str = "A4",
    landscape: bool = False,
    margin: dict | None = None,
) -> bytes:
    """
    Convert an HTML string to a PDF binary.

    Args:
        html: HTML string to convert
        engine: "quality" (Chromium) or "fast" (wkhtmltopdf)
        format: Paper size ("A4", "Letter", "Legal", etc.)
        landscape: Whether to use landscape orientation
        margin: Page margins in mm (top/bottom/left/right)

    Returns:
        PDF binary as bytes
    """
    if margin is None:
        margin = {"top": "20mm", "bottom": "20mm", "left": "15mm", "right": "15mm"}

    response = requests.post(
        f"{FUNBREW_API_URL}/pdf/from-html",
        headers={
            "Authorization": f"Bearer {FUNBREW_API_KEY}",
            "Content-Type": "application/json",
        },
        json={
            "html": html,
            "engine": engine,
            "format": format,
            "landscape": landscape,
            "margin": margin,
        },
        timeout=30,
    )
    response.raise_for_status()
    return response.content

See the API docs for the full list of request options.

Basic Usage

# generate_basic.py
from pdf_client import html_to_pdf

html = """<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    body { font-family: -apple-system, 'Segoe UI', sans-serif; padding: 40px; color: #1e293b; }
    h1 { color: #2563eb; }
  </style>
</head>
<body>
  <h1>Hello, PDF!</h1>
  <p>Generated with the FUNBREW PDF API from Python.</p>
</body>
</html>"""

pdf_bytes = html_to_pdf(html)

with open("output.pdf", "wb") as f:
    f.write(pdf_bytes)

print(f"PDF generated: {len(pdf_bytes):,} bytes")

Invoice PDF Example

# invoice_generator.py
from datetime import date
from pdf_client import html_to_pdf


def build_invoice_html(
    customer_name: str,
    amount: int,
    items: list[dict] | None = None,
    company_name: str = "FUNBREW Inc.",
) -> str:
    tax = int(amount * 0.1)
    total = amount + tax
    invoice_number = f"INV-{date.today().strftime('%Y%m%d')}-001"
    issue_date = date.today().strftime("%B %d, %Y")

    if not items:
        items = [{"name": "Service fee", "quantity": 1, "price": amount}]

    rows = "".join(
        f"<tr><td>{item['name']}</td>"
        f"<td style='text-align:right'>{item['quantity']}</td>"
        f"<td style='text-align:right'>${item['price']:,}</td>"
        f"<td style='text-align:right'>${item['price'] * item['quantity']:,}</td></tr>"
        for item in items
    )

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    @page {{ size: A4; margin: 20mm 15mm; }}
    * {{ box-sizing: border-box; margin: 0; padding: 0; }}
    body {{
      font-family: -apple-system, 'Segoe UI', sans-serif;
      font-size: 12px; color: #1e293b;
      -webkit-print-color-adjust: exact; print-color-adjust: exact;
    }}
    .header {{ display: flex; justify-content: space-between;
      border-bottom: 3px solid #2563eb; padding-bottom: 16px; margin-bottom: 24px; }}
    .title {{ font-size: 28px; font-weight: 700; color: #2563eb; }}
    table {{ width: 100%; border-collapse: collapse; margin-top: 16px; }}
    th {{ background: #dbeafe; padding: 10px; text-align: left; }}
    td {{ padding: 10px; border-bottom: 1px solid #e2e8f0; }}
    .total {{ text-align: right; margin-top: 16px; }}
    .total-amount {{ font-size: 22px; font-weight: 700; color: #2563eb; }}
  </style>
</head>
<body>
  <div class="header">
    <div>
      <div class="title">Invoice</div>
      <div>Invoice #: {invoice_number}</div>
      <div>Date: {issue_date}</div>
    </div>
    <div style="text-align:right"><strong>{company_name}</strong></div>
  </div>
  <p><strong>Bill To: {customer_name}</strong></p>
  <table>
    <thead>
      <tr><th>Item</th><th>Qty</th><th>Unit Price</th><th>Subtotal</th></tr>
    </thead>
    <tbody>{rows}</tbody>
  </table>
  <div class="total">
    <p>Subtotal: ${amount:,}</p>
    <p>Tax (10%): ${tax:,}</p>
    <p class="total-amount">Total: ${total:,}</p>
  </div>
</body>
</html>"""


def generate_invoice_pdf(customer_name: str, amount: int, items=None) -> bytes:
    html = build_invoice_html(customer_name, amount, items)
    return html_to_pdf(html, engine="quality", format="A4")


if __name__ == "__main__":
    pdf = generate_invoice_pdf(
        customer_name="Acme Corporation",
        amount=15_000,
        items=[
            {"name": "Web Development", "quantity": 1, "price": 12_000},
            {"name": "Monthly Support", "quantity": 3, "price": 1_000},
        ],
    )
    with open("invoice.pdf", "wb") as f:
        f.write(pdf)
    print(f"Invoice PDF: {len(pdf):,} bytes")

Error Handling and Retry

# pdf_client_production.py
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


def create_pdf_session() -> requests.Session:
    """Create an HTTP session with automatic retry logic."""
    session = requests.Session()
    retry = Retry(
        total=3,
        backoff_factor=1.0,    # waits 1s, 2s, 4s between retries
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=["POST"],
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount("https://", adapter)
    return session


_session = create_pdf_session()


def html_to_pdf_with_retry(html: str, **kwargs) -> bytes:
    response = _session.post(
        f"{FUNBREW_API_URL}/pdf/from-html",
        headers={
            "Authorization": f"Bearer {FUNBREW_API_KEY}",
            "Content-Type": "application/json",
        },
        json={"html": html, "engine": "quality", "format": "A4", **kwargs},
        timeout=30,
    )
    response.raise_for_status()
    return response.content

2. ReportLab

Installation

pip install reportlab

Basic Usage

# reportlab_basic.py
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.lib import colors
from reportlab.platypus import SimpleDocTemplate, Paragraph, Table, TableStyle, Spacer
from io import BytesIO


def generate_report_pdf(title: str, data: list[list]) -> bytes:
    """Generate a tabular report PDF with ReportLab."""
    buffer = BytesIO()
    doc = SimpleDocTemplate(
        buffer,
        pagesize=A4,
        topMargin=20 * mm,
        bottomMargin=20 * mm,
        leftMargin=15 * mm,
        rightMargin=15 * mm,
    )

    styles = getSampleStyleSheet()
    elements = []

    elements.append(Paragraph(title, styles["Title"]))
    elements.append(Spacer(1, 12))

    table = Table(data, colWidths=[80 * mm, 40 * mm, 40 * mm])
    table.setStyle(TableStyle([
        ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2563eb")),
        ("TEXTCOLOR",  (0, 0), (-1, 0), colors.white),
        ("FONTSIZE",   (0, 0), (-1, 0), 11),
        ("ALIGN",      (1, 0), (-1, -1), "RIGHT"),
        ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f1f5f9")]),
        ("GRID",       (0, 0), (-1, -1), 0.5, colors.HexColor("#e2e8f0")),
        ("TOPPADDING",    (0, 0), (-1, -1), 8),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
    ]))
    elements.append(table)

    doc.build(elements)
    return buffer.getvalue()


if __name__ == "__main__":
    data = [
        ["Product", "Qty", "Revenue"],
        ["Product A", "10", "$50,000"],
        ["Product B", "5",  "$30,000"],
        ["Product C", "20", "$80,000"],
    ]
    pdf = generate_report_pdf("Q1 Sales Report", data)
    with open("report_reportlab.pdf", "wb") as f:
        f.write(pdf)
    print(f"ReportLab PDF: {len(pdf):,} bytes")

ReportLab key points:

  • Low-level programmatic API — builds PDFs from primitives (paragraphs, tables, shapes)
  • No HTML input; you compose documents with Python objects
  • Japanese text requires registering a TrueType font with TTFont
  • Excellent for chart-heavy reports via reportlab.graphics

Embedding matplotlib Charts

from reportlab.platypus import Image as RLImage
import matplotlib.pyplot as plt
from io import BytesIO

# Generate a chart with matplotlib
fig, ax = plt.subplots(figsize=(5, 3))
ax.bar(["Q1", "Q2", "Q3", "Q4"], [120, 145, 132, 178])
ax.set_title("Quarterly Revenue")
img_buffer = BytesIO()
fig.savefig(img_buffer, format="png", dpi=150, bbox_inches="tight")
img_buffer.seek(0)
plt.close(fig)

# Embed in ReportLab
chart_img = RLImage(img_buffer, width=130 * mm, height=78 * mm)
elements.append(chart_img)

3. WeasyPrint

Installation

# macOS
brew install python3 pango
pip install weasyprint

# Ubuntu/Debian
sudo apt-get install python3-cffi libcairo2 libpango-1.0-0 libgdk-pixbuf2.0-0
pip install weasyprint

Basic Usage

# weasyprint_basic.py
from weasyprint import HTML, CSS
from io import BytesIO


def html_to_pdf_weasyprint(html: str, base_url: str | None = None) -> bytes:
    """Convert HTML to PDF with WeasyPrint."""
    buffer = BytesIO()
    HTML(string=html, base_url=base_url).write_pdf(
        buffer,
        stylesheets=[CSS(string="@page { size: A4; margin: 20mm 15mm; }")],
    )
    return buffer.getvalue()


html = """<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    body { font-family: sans-serif; padding: 20px; }
    h1 { color: #2563eb; border-bottom: 2px solid #2563eb; padding-bottom: 8px; }
    table { width: 100%; border-collapse: collapse; margin-top: 16px; }
    th { background: #dbeafe; padding: 8px; text-align: left; }
    td { padding: 8px; border-bottom: 1px solid #e2e8f0; }
  </style>
</head>
<body>
  <h1>WeasyPrint Demo</h1>
  <table>
    <thead><tr><th>Property</th><th>Value</th></tr></thead>
    <tbody>
      <tr><td>Library</td><td>WeasyPrint 62.x</td></tr>
      <tr><td>Engine</td><td>Cairo / Pango</td></tr>
      <tr><td>JavaScript</td><td>Not supported</td></tr>
    </tbody>
  </table>
</body>
</html>"""

pdf = html_to_pdf_weasyprint(html)
with open("output_weasyprint.pdf", "wb") as f:
    f.write(pdf)
print(f"WeasyPrint PDF: {len(pdf):,} bytes")

WeasyPrint key points:

  • Good HTML/CSS support including CSS Paged Media (@page)
  • GTK/Pango dependency makes Docker images large
  • No JavaScript support — dynamic rendering is not possible
  • Font rendering depends on system fonts

Docker Setup for WeasyPrint

FROM python:3.12-slim

RUN apt-get update && apt-get install -y \
    libpango-1.0-0 \
    libpangocairo-1.0-0 \
    libcairo2 \
    libgdk-pixbuf2.0-0 \
    libffi-dev \
    && rm -rf /var/lib/apt/lists/*

RUN pip install weasyprint

4. xhtml2pdf

Installation

pip install xhtml2pdf

Basic Usage

# xhtml2pdf_basic.py
from io import BytesIO
from xhtml2pdf import pisa


def html_to_pdf_xhtml2pdf(html: str) -> bytes:
    """Convert HTML to PDF with xhtml2pdf."""
    buffer = BytesIO()
    result = pisa.CreatePDF(html, dest=buffer)
    if result.err:
        raise RuntimeError(f"xhtml2pdf conversion error: {result.err}")
    return buffer.getvalue()


html = """<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    @page { size: A4; margin: 20mm; }
    body { font-family: Helvetica, sans-serif; font-size: 12px; }
    h1 { color: #2563eb; }
  </style>
</head>
<body>
  <h1>xhtml2pdf Sample</h1>
  <p>Simple PDF generation without external binaries.</p>
</body>
</html>"""

pdf = html_to_pdf_xhtml2pdf(html)
with open("output_xhtml2pdf.pdf", "wb") as f:
    f.write(pdf)
print(f"xhtml2pdf PDF: {len(pdf):,} bytes")

xhtml2pdf key points:

  • No external binary — purely Python
  • Only CSS 2.1 subset supported (Flexbox, Grid, CSS variables: no)
  • Works well for simple documents; complex layouts may break
  • Requires explicit font setup for non-Latin scripts

5. pdfkit (wkhtmltopdf)

Installation

# Install the wkhtmltopdf binary first
# macOS: brew install wkhtmltopdf
# Ubuntu: sudo apt-get install wkhtmltopdf

pip install pdfkit

Basic Usage

# pdfkit_basic.py
import pdfkit


options = {
    "page-size": "A4",
    "margin-top": "20mm",
    "margin-bottom": "20mm",
    "margin-left": "15mm",
    "margin-right": "15mm",
    "encoding": "UTF-8",
    "enable-local-file-access": "",
}

html = """<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>body { font-family: sans-serif; padding: 40px; }</style>
</head>
<body>
  <h1 style="color:#2563eb">pdfkit Sample</h1>
  <p>Uses wkhtmltopdf under the hood.</p>
</body>
</html>"""

# Save to file
pdfkit.from_string(html, "output_pdfkit.pdf", options=options)

# Get as bytes
pdf_bytes = pdfkit.from_string(html, False, options=options)
print(f"pdfkit PDF: {len(pdf_bytes):,} bytes")

# From URL
# pdfkit.from_url("https://example.com", "output_url.pdf")

pdfkit key points:

  • Requires wkhtmltopdf binary — adds ~90MB to deployments
  • CSS 3 support is partial; the project is effectively unmaintained since 2024
  • Best used when you already have a wkhtmltopdf-based workflow to maintain
  • For new projects, prefer WeasyPrint or a PDF API

6. Playwright for Python

Installation

pip install playwright
playwright install chromium

Basic Usage

# playwright_basic.py
import asyncio
from playwright.async_api import async_playwright


async def html_to_pdf_playwright(html: str) -> bytes:
    """Convert HTML to PDF with Playwright (async)."""
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()

        await page.set_content(html, wait_until="networkidle")

        pdf_bytes = await page.pdf(
            format="A4",
            margin={"top": "20mm", "bottom": "20mm", "left": "15mm", "right": "15mm"},
            print_background=True,
        )
        await browser.close()
        return pdf_bytes


async def main():
    html = """<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    body { font-family: -apple-system, sans-serif; padding: 40px; }
    h1 { color: #2563eb; }
    .chart-placeholder {
      width: 400px; height: 200px; background: #dbeafe;
      display: flex; align-items: center; justify-content: center;
      border-radius: 8px; color: #1e40af;
    }
  </style>
</head>
<body>
  <h1>Playwright PDF Demo</h1>
  <p>Captures JavaScript-rendered content into PDF.</p>
  <div class="chart-placeholder">Chart area (JS rendered)</div>
</body>
</html>"""

    pdf = await html_to_pdf_playwright(html)
    with open("output_playwright.pdf", "wb") as f:
        f.write(pdf)
    print(f"Playwright PDF: {len(pdf):,} bytes")


asyncio.run(main())

Playwright key points:

  • Full Chromium rendering including JavaScript execution
  • playwright install chromium downloads ~200MB
  • Best when you already use Playwright for browser automation or E2E testing
  • For production PDF generation, migrating to FUNBREW PDF API removes the Chromium dependency from your server

Use Case Guide: Which Library to Choose

Invoices and Certificates (Complex HTML Layout)

Recommendation: FUNBREW PDF API

# Full CSS control — Flexbox, Grid, web fonts, print-color-adjust all work
pdf = html_to_pdf(invoice_html, engine="quality")

If you have an existing HTML/CSS template, one HTTP call converts it to a high-quality PDF. See the Django, FastAPI & Flask PDF integration guide for framework-specific patterns.

Data Analysis Reports (Charts and Tables)

Recommendation: ReportLab

ReportLab works directly with matplotlib charts, making it ideal for automated data pipelines.

Batch Generation (Hundreds to Thousands of PDFs)

Recommendation: FUNBREW PDF API + async concurrency

# batch_pdf.py
import asyncio
import aiohttp


async def html_to_pdf_async(
    session: aiohttp.ClientSession,
    html: str,
    semaphore: asyncio.Semaphore,
) -> bytes:
    async with semaphore:
        async with session.post(
            f"{FUNBREW_API_URL}/pdf/from-html",
            headers={
                "Authorization": f"Bearer {FUNBREW_API_KEY}",
                "Content-Type": "application/json",
            },
            json={"html": html, "engine": "quality", "format": "A4"},
        ) as resp:
            resp.raise_for_status()
            return await resp.read()


async def generate_pdfs_batch(html_list: list[str], concurrency: int = 5) -> list[bytes]:
    """Convert multiple HTML strings to PDFs in parallel."""
    semaphore = asyncio.Semaphore(concurrency)  # cap at 5 simultaneous requests
    connector = aiohttp.TCPConnector(limit=10)

    async with aiohttp.ClientSession(connector=connector) as session:
        tasks = [html_to_pdf_async(session, html, semaphore) for html in html_list]
        results = await asyncio.gather(*tasks, return_exceptions=True)

    pdfs = []
    for i, result in enumerate(results):
        if isinstance(result, Exception):
            print(f"[Warning] PDF {i} failed: {result}")
        else:
            pdfs.append(result)
    return pdfs


async def main():
    html_list = [
        build_invoice_html(f"Customer {i:03d}", 10_000 + i * 500)
        for i in range(1, 11)
    ]
    pdfs = await generate_pdfs_batch(html_list, concurrency=5)
    print(f"Generated {len(pdfs)} PDFs")


asyncio.run(main())

For more batch processing patterns, see the PDF batch processing guide.

FastAPI Integration

# FastAPI endpoint
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from pdf_client import html_to_pdf

app = FastAPI()


class InvoiceRequest(BaseModel):
    customer_name: str
    amount: int
    items: list[dict] | None = None


@app.post("/api/pdf/invoice")
async def create_invoice_pdf(req: InvoiceRequest):
    try:
        html = build_invoice_html(req.customer_name, req.amount, req.items)
        pdf_bytes = html_to_pdf(html, engine="quality", format="A4")
        return Response(
            content=pdf_bytes,
            media_type="application/pdf",
            headers={
                "Content-Disposition": "attachment; filename=invoice.pdf",
                "Content-Length": str(len(pdf_bytes)),
            },
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

For full Django, Flask, and FastAPI integration, see the Django, FastAPI & Flask PDF guide.

Library Comparison: CSS Support

CSS Feature FUNBREW PDF API WeasyPrint xhtml2pdf pdfkit
Flexbox Yes Yes No Partial
CSS Grid Yes Yes No No
CSS variables Yes Yes No No
@page rule Yes Yes Partial Partial
Web fonts (Google Fonts) Yes Partial No No
print-color-adjust Yes Yes No Partial

Dependency Size Comparison

# FUNBREW PDF API
pip install requests          # ~2 MB

# WeasyPrint
pip install weasyprint        # ~50 MB (includes GTK bindings)
# + apt-get install libpango-1.0-0 libcairo2 ...

# pdfkit
pip install pdfkit            # ~1 MB
# + wkhtmltopdf binary: ~90 MB

# Playwright
pip install playwright        # ~15 MB
playwright install chromium   # ~200 MB

Frequently Asked Questions

What is the easiest way to generate PDFs in Python?

The FUNBREW PDF API. Just pip install requests, send a POST request with your HTML, and receive a PDF binary. No binaries to install, no system libraries to configure, and it works in Docker and serverless environments out of the box.

How do I handle Japanese text in Python PDF libraries?

With the FUNBREW PDF API, add <meta charset="UTF-8"> to your HTML and set font-family: 'Noto Sans JP', sans-serif; in CSS. Noto Sans JP is pre-installed, so no extra setup is needed. For WeasyPrint, you need a Japanese font installed on the system. For ReportLab, register the font explicitly with TTFont. See the Japanese font guide for details.

Should I use WeasyPrint or pdfkit for a new project?

Choose WeasyPrint. pdfkit depends on wkhtmltopdf, which has been effectively unmaintained since 2024 and has poor modern CSS support. WeasyPrint is actively maintained and supports CSS Paged Media properly. For production-grade HTML PDF generation with full CSS support, the FUNBREW PDF API is the most reliable option.

Can I generate PDFs in a Django view?

Yes. Call html_to_pdf() in your view and return a HttpResponse with content_type="application/pdf". See the Django, FastAPI & Flask PDF guide for complete view examples including async Celery patterns.

How do I add page numbers in Python PDF generation?

With the FUNBREW PDF API, use the displayHeaderFooter: true option and provide a footerTemplate with the page number span. You can also use CSS @page rules with @bottom-center { content: counter(page) "/" counter(pages); }. The HTML to PDF CSS tips guide has complete examples.

How does Playwright compare to using the API in production?

Playwright downloads a full Chromium binary (~200 MB) and runs it as a subprocess. This adds memory usage, cold-start latency, and operational complexity. The FUNBREW PDF API offloads the Chromium process to a managed service, so your application stays lightweight. Playwright is great for local development and E2E test workflows.

Summary

Use Case Recommended Approach Reason
Invoices, certificates, complex HTML FUNBREW PDF API Full CSS, no binary, production-grade
Data analysis reports with charts ReportLab matplotlib integration, programmatic control
Archiving existing HTML pages WeasyPrint CSS Paged Media, actively maintained
Simple internal documents (no binary) xhtml2pdf Zero external dependencies
Combined with E2E test automation Playwright Reuse existing browser automation
High-volume batch generation / SaaS FUNBREW PDF API Scalable, no infra to manage

Start by testing your templates in the Playground, then check the API docs for the full request reference.

Related

Powered by FUNBREW PDF