Template-driven PDF generation for financial institutions, with late-bound data. Developers upload a template bundle, applications post JSON data, and get a PDF back.
15 document types, every one shipped in both stacks — 7 designed from published specimen documents, 8 reconstructed from real bank statements — as two complete implementations of the same feature:
python/ Jinja2 → HTML/CSS → WeasyPrint → pypdf (recommended)
java/ FreeMarker → XSL-FO → Apache FOP → PDFBox
Run python3 tools/check-bundles.py at any time for the live inventory: it
verifies both stacks ship every type, that their data files are byte-identical,
that every stated total equals its line items, and that samples exist.
| Template | Sample | Stress test |
|---|---|---|
banking-statement |
6-page wealth statement, mailing cover, ledgers | 120-row ledger → 11 pages |
invoice |
2-page institutional fee invoice, remittance stub | 92 line items → 5–6 pages |
announcement-letter |
merger-notice letter, key-dates table | 15+ paragraph letter → 4–5 pages |
contract |
4-page fixed-rate note, amortization schedule | 25+ sections → 11–12 pages |
deposit-receipt |
1-page branch deposit receipt (authored by Claude Haiku following the guide, as a live authorability test) | 50 items → 2 pages |
adverse-action-notice |
credit-decline notice: reasons table, score panel, ECOA rights (Haiku-authored, reviewed) | 25 reasons → 4 pages |
loan-approval-letter |
approval letter with verified amortization schedule (Haiku-authored, reviewed) | 120-month schedule → 7 pages |
Reconstructions — real-world statement layouts rebuilt from genuine
documents (see references/reconstructions/), proving the engine handles
production bank output, not just clean-room designs: relationship-summary
(gradient masthead, hexagon infographic, icon panels), card-statement
(multi-card ledger sections), remittance-advice (form-grid advice with
brand band), consolidated-statement (dual-brand, banded multi-account).
Both stacks render the same JSON for every type — the data contract is shared, only the layout implementation differs. All six original requirements hold across all types: tables, images, headers/footers, font customisation, background images, and stamping.
Nothing here was invented from imagination. references/ holds genuine
specimen documents published by real institutions — Wells Fargo and Bank of
America sample statements, a CFPB credit-card statement, university and
utility invoices, a Factur-X e-invoice, Morgan Stanley and OCC letters, a bank
merger customer notice, Fannie Mae and SBA note forms, a Chase deposit account
agreement — plus a LAYOUT-SPEC.md per type distilled from reading every page
of them: anatomy, tables, typography, furniture, and the canonical data model.
The templates were built against those specs and reviewed page-by-page against
those PDFs. (The rendered branding is the fictional "Meridian Bank, N.A." —
the references ground the layout, not the identity.)
references/<type>/canonical_sample_data.json is the single source for each
type's sample_data.json in both stacks; the copies are byte-identical and CI
should keep them that way.
Python, unless you're constrained to the JVM. Templates are HTML/CSS, so any front-end developer can edit them; iteration is ~1 s; the ecosystem is familiar.
Java (FOP) if you're on the JVM. Note this is not the openhtmltopdf
route I'd normally suggest for Java — for the statement layout it's the wrong
tool. That design needs four independent page regions, and openhtmltopdf is
CSS 2.1 with thin @page margin-box support. XSL-FO has fo:region-end as a
first-class page region.
| Python (WeasyPrint) | Java (FOP) | |
|---|---|---|
| Cold render | ~1.0 s | ~1.5–3 s (JVM start; the service reuses the JVM) |
| Template authoring | HTML + CSS | XSL-FO |
| Side regions | running elements + margin boxes | native |
| PDF bookmarks | not supported | fo:bookmark-tree |
border-radius |
native CSS | fox: extension only |
cd python
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python render.py templates/invoice out/invoice.pdf
.venv/bin/python render.py templates/contract out/c.pdf --data mydata.json
.venv/bin/python render.py templates/announcement-letter out/l.pdf --stamp "DUPLICATE COPY"
.venv/bin/python watch.py templates/invoice --png # live dev loop
.venv/bin/uvicorn service:app --reload # HTTP serviceWeasyPrint needs Pango, Cairo, HarfBuzz and GDK PixBuf at the system level —
use the included Dockerfile rather than fighting a bare VM.
cd java
mvn package
mvn dependency:build-classpath -Dmdep.outputFile=target/classpath.txt
java -cp "target/classes:$(cat target/classpath.txt)" com.statement.Main \
templates/invoice out/invoice.pdf
--data f.json render your own data
--stamp "TEXT" watermark every page
--lint static bundle checks only
--fo dump the intermediate XSL-FO
# HTTP service (upload / render / preview), same surface as the Python one:
TEMPLATE_ROOT=templates_store PORT=8080 \
java -cp "target/classes:$(cat target/classpath.txt)" com.statement.ServicePOST /templates upload a bundle zip → linted + smoke-rendered + versioned,
409 on duplicate name+version
GET /templates list templates and versions
POST /render {"template","version","data","stamp"?} → PDF
POST /preview render an unpublished bundle, nothing stored
GET /healthz liveness + worker count
Upload transport differs: the Python service takes multipart
(curl -F bundle=@b.zip), the Java service takes the zip raw
(curl -H 'Content-Type: application/zip' --data-binary @b.zip), and its
/preview takes {"bundle": <base64 zip>, "data": {...}}. Rendering is
CPU-bound in both; each keeps renders on a bounded worker pool.
references/ ground truth: 12 specimen PDFs + LAYOUT-SPEC.md and
canonical data per document type
tools/rasterize.py PDF → per-page PNGs; the visual-inspection half of QA
tools/render-samples.sh regenerate samples/ from committed templates + data
tools/fonts/ Liberation Sans/Serif/Mono (SIL OFL) + DejaVu (carries
₹ U+20B9, used as a per-glyph fallback — cookbook F13)
TEMPLATE-AUTHORING.md the bundle-authoring recipe (written to be followable
by a developer pairing with a small model — see §8)
python/
render.py Jinja2 → WeasyPrint → pypdf; sandboxed URL fetcher
service.py FastAPI: upload / render / preview, worker pool
watch.py re-render on save, ~1 s loop
templates/{banking-statement,invoice,announcement-letter,contract}/
template.html styles.css manifest.json
sample_data.json stress_data.json assets/ fonts/
java/
src/main/java/com/statement/
Renderer.java FreeMarker → FOP → PDFBox; sandboxed resolver
Service.java HTTP surface (JDK HttpServer, zero extra deps)
ZipBundles.java zip-slip/bomb-safe bundle extraction
Stamper.java PDFBox overlay pass, page-geometry aware
BundleLinter.java layer 1: static checks
StrictEventListener.java layer 2: escalate FOP warnings to failures
PdfInvariants.java layer 3: blank pages, golden page count
Main.java CLI
test/
SecurityCheck.java SSRF, traversal, missing asset, escaping
TrapCheck.java re-introduces each FOP trap, asserts it's caught
service-smoke.sh end-to-end API round-trip (8 checks)
templates/<type>/ 15 bundles, mirroring python/templates/
*.ftlx fop.xconf manifest.json sample_data.json stress_data.json
samples/<type>/ rendered evidence per document type (see samples/README.md)
Every claim below was measured, not inferred. tools/check-bundles.py
re-verifies the mechanical ones on demand (15 types, both stacks,
byte-identical data, totals equal to their line items, samples present):
- Every page of every sample and stress render visually inspected (rasterized and reviewed) against the reference documents — by the authors, then again by independent adversarial reviewers with edit rights
- All arithmetic recomputed from the JSON and matched to the rendered text to the cent (totals chains, tax, amortization schedule, fee-change tables)
- Extracted text proofread: no typos, no
None/null, no unrendered template droppings, consistent labels - Stress renders paginate correctly in both stacks: column headers repeat,
closing totals appear exactly once,
Page X of Ycorrect, no blank pages - Stamping applies to every page, at every page size (the overlay is sized to the document's geometry — a hardcoded A4-landscape stamp clipped on Letter-portrait bundles until the reviewers caught it)
expected_pagesis enforced for sample-data renders (upload validation, CLI default) and correctly not for caller data, whose length varies- Zero renderer warnings on clean builds;
--lintclean on all four Java bundles - Java service round-trip: upload → 201, duplicate → 409, render → PDF, missing required field → 422, unknown version → 404, preview → PDF, zip-slip archive → 400
- Security suites pass in both stacks: SSRF, path traversal, missing asset, auto-escaping, zip slip
- Java trap suite: 6 traps re-introduced, 6 caught, 0 missed
guide/COOKBOOK.md— element recipes, the full gotcha catalog, tips — written for a developer pairing with a small model, backed by two verified snippet galleries inguide/snippets/TEMPLATE-AUTHORING.md— how to build a new bundle, end to endguide/HAIKU-PROMPTS.md— verified copy-paste prompts for driving a small model to reconstruct each of the eight real statement layouts, with the fill-in table and the follow-up prompts that fix common defectsguide/AUTHORABILITY-REPORT.md— measured results of having Claude Haiku author templates from these guides: what small models get right, what they fabricate, and the guide changes each defect producedreferences/<type>/LAYOUT-SPEC.md— what makes each document type look realpython/README.md— workflow, bundle format, API, nine WeasyPrint gotchas the templates encodejava/README.md— FOP vs openhtmltopdf, honest comparison against CSSjava/FOP-TRAPS.md— the three-layer defence against FOP's silent failures
Branding. The logos are placeholder wordmarks and all sample data is
fictional. Drop in your own assets and keep real account numbers out of
sample_data.json — it's committed and runs as the upload smoke test.
Add visual regression. Rasterise each page (tools/rasterize.py) and diff
against golden PNGs in CI. It's the only defence against the "renders fine,
looks wrong" class — in particular a table wider than its page, which FOP
clips silently with no event and no exception.
Auth. Neither service ships authentication; both are meant to sit behind your gateway. Add TLS and an auth layer before exposing them beyond localhost.
| License | Apache 2.0 — see LICENSE. Third-party fonts and reference documents are attributed in NOTICE. |
| Contributing | CONTRIBUTING.md — the checks to run, the house rules, and how to add a template. |
| Data provenance | PRIVACY.md — everything rendered here is fictional, and how that is enforced rather than asserted. |
| Trademarks | Layout specs describe reconstructions as modelled on named institutions' document layout families. These are descriptive references only and imply no affiliation or endorsement. All marks belong to their owners. |
Run the full check suite before any change lands:
python3 tools/check-bundles.py # data contracts, arithmetic, coverage
tools/render-samples.sh # regenerate samples/ from templates
cd java && mvn package && ./test/service-smoke.sh