← Back to blog

PDF generation in Python without headless Chrome

Generate PDFs from Python with a hosted API or local WASM rendering. No Puppeteer, no LaTeX, no HTML-to-PDF conversion. Just pip install formepdf.

Every Python PDF library makes you choose: low-level coordinate positioning (ReportLab, PDFKit), HTML rendering through a headless browser (WeasyPrint, wkhtmltopdf), or LaTeX. None of them give you a component model with real page breaks.

Forme now has a Python SDK.

Two ways to use it

Hosted API — zero dependencies, one HTTP call:

from formepdf import Forme

client = Forme("forme_sk_...")
pdf = client.render("invoice", {
    "customer": "Acme Corp",
    "items": [{"name": "Widget", "qty": 5, "price": 49}],
    "total": 245,
})

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

You define templates once (in JSX or JSON), deploy them to the hosted API, and render with data from any language. The Python client is stdlib-only — no requests, no aiohttp, just urllib.

Local rendering — self-hosted, no API calls:

pip install formepdf[local]

This adds wasmtime as a dependency and bundles the Forme WASM engine. You build documents with a component DSL that mirrors the JSX API:

from formepdf import Document, Page, View, Text, Image

doc = Document(
    Page(
        View(
            Text("Invoice #001", style={"fontSize": 24, "fontWeight": "bold"}),
            Text("Acme Corp", style={"fontSize": 14, "color": "#666"}),
            style={"flexDirection": "column", "gap": 8},
        ),
        View(
            Text("Widget Pro"),
            Text("$245.00", style={"marginLeft": "auto"}),
            style={"flexDirection": "row", "marginTop": 20},
        ),
    ),
)

pdf_bytes = doc.render()

doc.render() runs the Rust engine through WASM. No subprocess, no browser, no network call. The same engine that powers the JavaScript SDK.

Why not WeasyPrint?

WeasyPrint converts HTML+CSS to PDF. It works, but:

  • It shells out to system libraries (cairo, Pango, GDK-Pixbuf). Installing on Alpine or in a Lambda layer is painful.
  • Page breaks are CSS-based. break-inside: avoid works for simple cases but fails with nested flex layouts.
  • Render times scale with HTML complexity. A 10-page report with tables can take seconds.

Forme's engine is a single WASM binary. No system dependencies. Page breaks are built into the layout algorithm, not bolted on after the fact. A 10-page document renders in ~50ms.

Why not ReportLab?

ReportLab is powerful but low-level. You position elements with absolute coordinates:

# ReportLab
c = canvas.Canvas("invoice.pdf")
c.drawString(72, 750, "Invoice #001")
c.drawString(72, 730, "Customer: Acme Corp")
c.drawString(72, 710, "Total: $245.00")
c.save()

Add a line item and everything below shifts. Add enough items to overflow a page and you're writing your own pagination logic. ReportLab's Platypus framework helps, but you're still thinking in terms of Frames and Flowables rather than components.

Forme uses flexbox. Add content and the layout adjusts. Content overflows a page and the engine handles the break — including repeating table headers on continuation pages.

Components

The Python DSL matches the JSX API:

Component Description
Document Root container, optional metadata
Page Page with size and margins
View Flex container (like <div>)
Text Text with styles, supports inline runs
Image JPEG, PNG, WebP, or base64 data URI
Table, Row, Cell Tables with auto-repeating headers
QrCode Vector QR codes
Barcode Code 128, Code 39, EAN-13, EAN-8, Codabar
Svg Inline SVG content
Watermark Rotated text behind content

Tables that break across pages

from formepdf import Document, Page, Table, Row, Cell, Text

rows = [{"product": f"Item {i}", "price": f"${i * 10}"} for i in range(1, 51)]

doc = Document(
    Page(
        Table(
            Row(
                Cell(Text("Product", style={"fontWeight": "bold"})),
                Cell(Text("Price", style={"fontWeight": "bold"})),
                header=True,
            ),
            *[
                Row(
                    Cell(Text(r["product"])),
                    Cell(Text(r["price"])),
                )
                for r in rows
            ],
            columns=[{"width": "1fr"}, {"width": 100}],
        ),
    ),
)

pdf_bytes = doc.render()

50 rows across multiple pages. Headers repeat automatically. No manual pagination code.

Styles

The style system maps directly to CSS flexbox properties:

View(
    Text("Centered content"),
    style={
        "flexDirection": "column",
        "justifyContent": "center",
        "alignItems": "center",
        "padding": 40,
        "backgroundColor": "#f8f9fa",
        "borderRadius": 8,
        "borderWidth": 1,
        "borderColor": "#dee2e6",
    },
)

Supported properties: fontSize, fontWeight, fontFamily, color, backgroundColor, padding, margin, gap, flex, width, height, borderWidth, borderColor, borderRadius, textAlign, textDecoration, opacity, overflow, and more.

Async rendering

For long-running reports, use the async API:

job = client.render_async("annual-report", data, webhook_url="https://example.com/hook")
# Poll or wait for webhook
result = client.get_job(job["jobId"])

Embedded data

PDFs can carry their source data as a hidden attachment:

doc = Document(Page(Text("Invoice")))
pdf = doc.render(embed_data={"invoice_id": "INV-001", "total": 245})

Extract it later without parsing the visual content:

data = client.extract(pdf_bytes)
# {"invoice_id": "INV-001", "total": 245}

Install

# Hosted API (zero dependencies)
pip install formepdf

# Local rendering (adds wasmtime)
pip install formepdf[local]

Requires Python 3.8+. The package is on PyPI. Source is on GitHub.