PDF generation¶
Every service eventually has to issue a document: a receipt, a quote, a report, a contract, a payment slip. The usual path is to assemble HTML by hand and hand it to some library — and what breaks is never the rendering, it is everything else: the printed total disagrees with the lines, the amount in words is wrong, the table header vanishes on page 2, the logo failed to load and nobody noticed.
tempest_fastapi_sdk.pdf handles the whole document: typed payload → Jinja2
template → PDF, with real pagination and Brazilian formatting.
Required extra
Brings inweasyprint + jinja2. And it needs system libraries — read
Deployment before shipping a container.
Your first document¶
# scripts/receipt.py
import asyncio
from datetime import date
from tempest_fastapi_sdk.pdf import Party, PdfRenderer, ReceiptDocument
async def main() -> None:
"""Render a receipt to disk."""
renderer = PdfRenderer()
receipt = ReceiptDocument(
number="0001/2026",
issue_date=date(2026, 8, 13),
issuer=Party(name="Acme Serviços LTDA", document="12345678000195"),
payer=Party(name="Ana Souza", document="12345678901"),
amount_cents=125000,
reference="serviços de consultoria prestados em julho/2026",
place="Recife",
)
pdf: bytes = await renderer.render_document(receipt)
with open("receipt.pdf", "wb") as handle:
handle.write(pdf)
if __name__ == "__main__":
asyncio.run(main())
Out comes an A4 page with the amount highlighted, the discharge wording, the issuer and payer blocks, the date spelled out, and a signature line — plus the amount in words, which is the element that stops the figure from being altered after signing:
Recebi de Ana Souza, inscrito(a) no CPF/CNPJ sob o nº 123.456.789-01, a importância de R$ 1.250,00 (mil, duzentos e cinquenta reais), referente a serviços de consultoria prestados em julho/2026.
Cents, always
amount_cents=125000 is R$ 1,250.00. It is the same choice the SDK makes
for payments: a float cannot represent 0.1 + 0.2, and a document off by
a cent is worse than one that fails.
The five bundled documents¶
| Document | Class | What for |
|---|---|---|
receipt |
ReceiptDocument |
Receipt — proof of payment with the amount in words and a signature |
quote |
QuoteDocument |
Quote / proposal with line items, subtotal, discount and total |
report |
ReportDocument |
Paginated tabular report with a repeating header and a grand total |
contract |
ContractDocument |
Contract or declaration with numbered clauses and signatures |
voucher |
VoucherDocument |
Short receipt or label, half a page, with room for a QR code |
Each has its own Pydantic schema. That is what makes bundling the templates worthwhile: an HTML file alone tells you nothing about which keys it needs, so the first missing field shows up as a blank space in a signed document. Here a receipt with no payer does not render — it fails validation, with the field named.
Quote: totals are computed, not accepted¶
from datetime import date
from tempest_fastapi_sdk.pdf import LineItem, Party, QuoteDocument
quote = QuoteDocument(
number="ORC-2026-014",
issue_date=date(2026, 8, 13),
valid_until=date(2026, 9, 13),
issuer=Party(name="Acme Serviços LTDA"),
customer=Party(name="Ana Souza"),
items=[
LineItem(description="Consultoria técnica", quantity=40, unit="h", unit_price_cents=15000),
LineItem(description="Licença anual", unit_price_cents=250000),
],
discount_cents=50000,
payment_terms="50% na assinatura, 50% na entrega.",
)
print(quote.subtotal_cents) # 850000
print(quote.total_cents) # 800000
subtotal_cents and total_cents are computed from the items — there is no way
to pass a total that disagrees with its own lines, which is the defect nobody
catches by looking. A discount larger than the subtotal is rejected at
validation: a negative total would print as if it were a real price.
Fractional quantities round half-up to the cent so the line adds up to the
total, and print with a comma (2,5 mês) — a dot reads as a thousands
separator to the person holding the paper.
Report: what makes a printed listing usable¶
from datetime import date
from tempest_fastapi_sdk.pdf import ReportColumn, ReportDocument
report = ReportDocument(
heading="Vendas por cliente",
subtitle="Julho de 2026",
generated_at=date(2026, 8, 13),
columns=[
ReportColumn(key="cliente", header="Cliente"),
ReportColumn(key="pedidos", header="Pedidos", align="right"),
ReportColumn(key="total_cents", header="Total", align="right", money=True),
],
rows=[{"cliente": "Ana", "pedidos": 3, "total_cents": 125000}],
totals={"total_cents": 125000},
)
Three things the template guarantees, all of which only surface once the report runs past one page:
- The table header repeats on every page. A continuation page whose columns are unlabelled is unreadable.
- Pages are numbered
página X de Y. Somebody has to be able to tell a sheet is missing. - The grand total prints once, on the last page. The first version used
<tfoot>, which istable-footer-groupand therefore repeats — the total appeared at the foot of page 2, above rows that summed to something else. It is pinned by a test that reads the text of the rendered pages.
A missing key prints empty
A row without a column's key renders the cell blank instead of failing. Reports are routinely assembled from partial data, and dropping the whole document over one absent cell helps nobody.
Serving over HTTP¶
# src/api/app.py
from fastapi import Depends, FastAPI
from tempest_fastapi_sdk.pdf import PdfRenderer, make_pdf_router
from src.api.dependencies.auth import require_user
def create_app() -> FastAPI:
app = FastAPI()
app.include_router(
make_pdf_router(
PdfRenderer(),
dependencies=[Depends(require_user)],
),
)
return app
That mounts two routes:
GET /pdf/documents— lists the documents with the full JSON Schema of each payload, enough for a client to build the form without reading the SDK source.POST /pdf/documents/{document}— validates and returns the bytes with aContent-Dispositionheader.
The router adds no auth by itself
Rendering is CPU-bound and the caller holds the connection open.
dependencies= is where the service's authentication and rate limit go —
the router does not guess which, because guessing is how a document
endpoint ends up public.
A client-supplied filename goes through safe_filename: a quote ends the
header value and a newline splits the response into two.
The CLI: the template-editing loop¶
tempest pdf list # what exists
tempest pdf schema receipt # the payload it accepts
tempest pdf render receipt data.json -o receipt.pdf
tempest pdf render receipt data.json --html -o preview.html
--html stops before layout and writes the HTML — it opens in a browser and
reloads instantly. It is the fast way to iterate on a layout before checking
how it actually paginates.
--template-dir points at the project's own templates, so you can preview your
overrides without starting the service.
Overriding a template¶
The renderer looks in the project's template_dir first and falls back to the
bundled set — the same shadowing rule EmailUtils uses. To replace just the
receipt, drop a receipt.html into your directory:
{% extends "_base.html" %}
{% block heading %}RECIBO — {{ doc.issuer.name }}{% endblock %}
{% block content %}
<p>Recebi {{ doc.amount_cents | brl }} ({{ doc.amount_cents | extenso }})
de {{ doc.payer.name }}.</p>
{% endblock %}
from tempest_fastapi_sdk.pdf import PdfRenderer
renderer = PdfRenderer(template_dir="src/templates/pdf")
The filters available in any template:
| Filter | Input | Output |
|---|---|---|
brl |
125000 |
R$ 1.250,00 |
extenso |
125000 |
mil, duzentos e cinquenta reais |
data |
date(2026, 8, 13) |
13/08/2026 |
data_extenso |
date(2026, 8, 13) |
13 de agosto de 2026 |
doc |
"12345678901" |
123.456.789-01 |
qtd |
2.5 |
2,5 |
_base.html exposes the blocks lang, doc_title, extra_head, header,
heading, subheading, header_meta and content.
Security: what a template may load¶
This is the part worth reading carefully.
An HTML renderer resolves URLs on the page's behalf: <img src>, @import,
url() in CSS. Point that at a document whose contents came from a user and it
becomes two bugs at once — file:///etc/passwd reads the host, and
http://169.254.169.254/ reaches the cloud metadata endpoint from inside your
network.
The default denies everything. data: always passes, because it carries
its own bytes and fetches nothing. Anything else has to be named:
from pathlib import Path
from tempest_fastapi_sdk.pdf import AssetPolicy, PdfRenderer
renderer = PdfRenderer(
assets=AssetPolicy(allow_dirs=(Path("src/assets"),)),
)
The check is on the resolved path: neither ../ nor a symlink pointing
outside gets through. A directory that does not exist fails at construction,
because a typo would read as "nothing is allowed" and only surface later as a
document missing its images.
And refusal is loud: WeasyPrint's default is to log the failure and carry
on, which would turn a blocked logo into a silent hole in an invoice. The SDK
aborts the render at the first refusal. strict_assets=False restores the
lenient behavior — and still logs what it dropped.
allow_remote=True is SSRF surface
Turning it on means anything that reaches the template controls a request
made from inside your network. Prefer embedding the image as a data: URI.
That is why Branding.logo_data_uri only accepts data: — a URL would be
refused at render time and silently produce a document with no logo. And
accent_color / page_size / margin have a constrained shape: those values
are written into the stylesheet, and one carrying ; or } could close
the rule and add declarations of its own.
Deployment: the system libraries¶
WeasyPrint draws text through Pango and resolves fonts through
fontconfig. A python:slim image has neither, and the error does not
appear at build time — it appears at the first render, as an OSError from
cffi.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
fontconfig \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
tempest generate --dockerfile emits that block automatically when the project
pins the [pdf] extra in its pyproject.toml.
About the font
Measured on python:3.13-slim: fonts-dejavu-core arrives with the
Pango packages, and the document comes out readable without asking for the
font explicitly. It stays in the list on purpose — it is the guarantee that
the family named in the stylesheet exists, and it costs nothing. On a base
that pulls in no font at all (distroless, say), the layout is right and
every glyph is a rectangle, which is the hardest failure on this list to
diagnose.
The error does not mention Pango
Without the libraries the import succeeds and the first render fails
with OSError: cannot load library 'libgobject-2.0-0' — that is the name
you will see, not pango. Verified on python:3.13-slim.
Reproducible output (needs an environment variable)¶
WeasyPrint writes no creation date and no document identifier, so the PDF
itself carries no clock. The embedded font does: the subset fontTools
produces stamps a timestamp into the head table, and its checksum ends up in
the file. Two renders of the same payload seconds apart produce different
bytes.
Measured: three runs of the same container, three different hashes; the
difference is the font's head checksum, inside the compressed stream.
fontTools honors the SOURCE_DATE_EPOCH reproducible-build convention. With
it pinned, the output becomes byte-identical across processes:
import asyncio
import hashlib
from tempest_fastapi_sdk.pdf import PdfRenderer, ReceiptDocument
async def digest(receipt: ReceiptDocument) -> str:
"""Hash a rendered receipt.
Stable across processes only with ``SOURCE_DATE_EPOCH`` pinned in the
environment.
"""
pdf: bytes = await PdfRenderer().render_document(receipt)
return hashlib.sha256(pdf).hexdigest()
Do not compare hashes across machines
Even with SOURCE_DATE_EPOCH, the bytes depend on the font version and
the WeasyPrint version. A hash computed in CI will not match production if
the images differ. For "this document is the one I issued", store the hash
alongside the artifact — do not recompute it in another environment and
expect a match.
Passing metadata= (for instance {"pdf_identifier": True}) gives the
reproducibility up on purpose.
Concurrency¶
Layout is CPU-bound. Every render goes through a worker thread behind a semaphore, so the event loop never stalls:
The default is 4. More workers than cores turns latency into queueing, not throughput.
Reading a PDF back¶
Writing is half of it. The other half is reading — the first step of every pipeline that hands a PDF to a model: an invoice to classify, a contract to summarize, a tender to transcribe.
This lives in a separate extra, [pdf-read], which pulls only pypdf:
Separate on purpose. Rendering pulls WeasyPrint plus Pango and fontconfig from the system; a service that only reads should carry none of that.
from pathlib import Path
from tempest_fastapi_sdk.pdf import extract_pdf_text
data: bytes = Path("invoice.pdf").read_bytes()
text: str = extract_pdf_text(data)
print(text)
For a three-page PDF the output comes out marked:
=== PAGE 1 ===
Recibo 001
Valor: R$ 1.234,56
=== PAGE 2 ===
Page two
Signature
=== PAGE 3 ===
Page three
Attachment
The marker is not decoration: it gives the model something to cite when you
ask where a value came from. Match it to the language of the surrounding
prompt with page_marker=:
from pathlib import Path
from tempest_fastapi_sdk.pdf import extract_pdf_text
data: bytes = Path("invoice.pdf").read_bytes()
text: str = extract_pdf_text(data, page_marker="=== PÁGINA {page} ===")
Page by page¶
When you want to decide per page instead of receiving one blob,
extract_pdf_pages returns (number, text) tuples, numbered from one:
from pathlib import Path
from tempest_fastapi_sdk.pdf import PageText, extract_pdf_pages
data: bytes = Path("invoice.pdf").read_bytes()
pages: list[PageText] = extract_pdf_pages(data)
for number, text in pages:
print(number, text[:40])
Cutting without lying¶
max_chars bounds the extracted text and announces the cut:
from pathlib import Path
from tempest_fastapi_sdk.pdf import extract_pdf_text
data: bytes = Path("invoice.pdf").read_bytes()
text: str = extract_pdf_text(data, max_chars=60)
The last line becomes:
max_chars is a hard ceiling, and text always comes back (v0.252.0)
Three things changed, because all three were wrong:
| Before | Now |
|---|---|
notice and markers were added on top of the limit (max_chars=40 → 46 chars) |
the result never exceeds max_chars |
a max_chars below page 1 returned only the notice |
document text comes back, cut inside the page |
the notice said PAGE 0, a page that does not exist |
the lowest page it can name is 1 |
The order of preference: whole pages while they fit; if not even the first
fits, a cut inside it announced by
=== PAGE 1 OF 3 TRUNCATED MID-PAGE ===; and if the budget cannot hold a
notice while leaving at least a third for text, the whole budget goes
to text — with no notice and no page marker, because an annotation that
swallows the content is the defect this fixes.
The notice describes the cut that actually happened, not the branch
the code took: MID-PAGE appears only when the returned text is a strict
prefix of the page. When the whole of page 1 fits, the line is
=== DOCUMENT TRUNCATED AFTER PAGE 1 OF 3 === — measured on a 3-page
document, max_chars=120 returns 106 characters in that shape. And at the
budget that holds the page but not that notice (100, same document, where
the boundary form needs 106), the cut gives up one character so the
MID-PAGE warning stays true: 58 of page 1's 59 characters with a
warning beat all 59 with pages 2 and 3 gone in silence.
Measured on a 239-character page: up to max_chars=60 the answer is raw
text exactly as long as the budget; at max_chars=61 the notice becomes
affordable and the answer carries 20 characters of document beside it;
from there the text only grows. The step down happens once — at the
budget where the notice becomes affordable — and it is the price of
knowing half is missing. A test pins it: nowhere else between 1 and 300
does a larger budget return less document.
If your flow relied on the old behaviour (a whole number of pages every
time), use extract_pdf_pages and assemble it yourself: it returns page
by page, with no ceiling.
A silent cut is the dangerous one: the model answers about the half it was
shown, with no sign that a half is missing. Override the sentence with
truncation_notice= when your prompt is in another language.
Text layer only — there is no OCR here
A PDF produced by a word processor carries its text. A PDF that is a scan
carries page images and nothing else. For that one extract_pdf_text
returns an empty string, and extract_pdf_pages returns one entry per
page with empty text — measured: a one-page blank PDF gives [(1, "")]
and "".
Handing a model an empty prompt is how a confident answer gets invented about a page nobody read. Check for it explicitly:
from pathlib import Path
from tempest_fastapi_sdk.pdf import extract_pdf_text
data: bytes = Path("invoice.pdf").read_bytes()
text: str = extract_pdf_text(data)
if not text:
raise ValueError("PDF has no text layer — it is probably a scan")
Route those files to an OCR path.
Without the extra, the error says what to install
Without pypdf the call raises ImportError carrying the full
instruction:
Recap¶
PdfRendererrenders HTML, a template, or a typed document; alwaysasync.- Five bundled documents, each with a Pydantic schema — totals computed, a missing field fails validation.
- The report paginates properly: repeating header,
página X de Y, grand total on the last page only. make_pdf_routerserves it over HTTP;dependencies=is where auth and rate limiting go.tempest pdf render --htmlis the fast loop for adjusting a template.- The asset default denies everything; opening it is an explicit decision.
- The container needs Pango + fontconfig + a font.
extract_pdf_text/extract_pdf_pagesread one back, in the separate[pdf-read]extra. Text layer only: a scan returns"", and checking for that is on you.
Next: Transactional email to send the document as an attachment, or Versioned artifacts if you need to keep every issued document with a hash.