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: avoidworks 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.