π Papermake
Turn your Typst templates into PDF APIs. Publish once, render anywhere.
Papermake is a content-addressable template registry with server-side rendering β like a Docker registry, but for document templates.
# Publish a template
# Render it with data
# β {"data":{"render_id":"e9c1β¦","pdf_hash":"sha256:8e0eβ¦","duration_ms":42}}
# Download the PDF
Why Papermake?
- Templates as code β immutable versions, mutable tags:
invoice:v1.0.0never changes,invoice:latestmoves - Server-side rendering β no local Typst installation, just HTTP
- Content-addressable storage β templates stored by SHA-256 hash and deduplicated, like Git
- Full audit trail β every render is logged with input/output hashes, so any PDF can be traced back to the exact template and data that produced it
- Self-hostable β one Rust binary plus S3 and ClickHouse
Quick start
This starts:
- Papermake server on
localhost:3000 - MinIO (S3-compatible storage) on
localhost:9000, console athttp://localhost:9001 - ClickHouse (render history) on
localhost:8123
Write a template β the input data is available as #data:
// invoice.typ
= Invoice #data.number
*Bill To:* #data.customer.name \
*Amount:* $#data.amount
Publish it, render it, download the PDF:
# Use the render_id from the response above
A template can also include additional files (images, imports, fonts) via repeated
-F "files[]=@logo.png" fields and an optional JSON schema via -F "schema=@schema.json".
For quick experiments there is a JSON-only endpoint that takes the template inline:
Running without Docker
# Point the server at your S3 and ClickHouse instances
π¦ Using the library
The core renderer is available as a standalone Rust library β no server, no storage backends:
[]
= "0.2"
use ;
use Arc;
let template = "Hello #data.name!";
let fs = new;
let data = json!;
let result = render_template?;
if result.success
PDF/A output
Pass RenderOptions to control the PDF standard of the output β e.g. PDF/A-3b,
the archivable profile that permits arbitrary embedded files and serves as the
base for ZUGFeRD/Factur-X e-invoices:
use ;
let result = render_template_with_options?;
Over HTTP, request it per render:
Images & other files
Rendering happens against a virtual file system β there is no disk access and no
--root directory like the Typst CLI has. Every file your template references
(images, imports, data files) must be provided through the file system you pass in:
let mut fs = new;
fs.add_file;
fs.add_file;
#import "header.typ": make_header
#image("logo.png", width: 40pt)
Paths are matched with or without a leading / (logo.png and /logo.png are
equivalent), so Typst's rooted paths resolve to the files you added. When publishing
to the registry, the same applies: upload assets as additional files[] and reference
them by their file name.
Fonts
System fonts are discovered automatically. To bundle additional fonts (e.g. in a
container without system fonts), point the optional FONTS_DIR environment variable
at a directory of font files before the first render:
FONTS_DIR=./fonts
HTTP API
All routes except /health are prefixed with /api.
| Method | Endpoint | Description |
|---|---|---|
GET |
/health |
Server health check |
POST |
/api/templates/{name}/publish?tag={tag} |
Publish template (multipart: main_typ, metadata, optional schema, files[]) |
POST |
/api/templates/{name}/publish-simple?tag={tag} |
Publish template (JSON body) |
GET |
/api/templates |
List templates (limit, offset, search) |
GET |
/api/templates/{name}/tags |
List a template's tags |
GET |
/api/templates/{reference} |
Get template metadata |
POST |
/api/render/{reference} |
Render to PDF, body {"data": {...}, "pdf_standard": "a-3b"} (pdf_standard optional: 1.7, a-2b, a-3b) β returns render_id |
GET |
/api/renders |
Recent render history (limit, offset) |
GET |
/api/renders/{render_id}/pdf |
Download a rendered PDF |
References take the form name:tag (tag defaults to latest).
Architecture
This repository is a Cargo workspace:
| Crate | Purpose |
|---|---|
papermake |
Core Typst rendering engine with virtual file system (crates.io) |
papermake-registry |
Content-addressable template storage (S3) and render history (ClickHouse) |
papermake-server |
Axum HTTP API on top of the registry |
papermake-worker |
Queue-based render worker (early stub) |
Client ββpublishβββΆ ββββββββββββββββββββ ββmanifests/blobsβββΆ βββββββββββββββββ
β papermake-server β β S3 storage β
Client ββrenderββββΆ β (Axum HTTP API) β ββrender recordsββββΆ β ClickHouse β
ββββββββββ¬ββββββββββ βββββββββββββββββ
βΌ
ββββββββββββββββββββ
β Typst engine β
β (papermake) β
ββββββββββββββββββββ
- Content-addressable storage β every file is stored once under its SHA-256 hash; a template version is a manifest mapping file names to hashes
- Immutable content, mutable tags β tags are lightweight pointers to manifest hashes
- Render tracking β each render stores the input data, output PDF, and their hashes for full reproducibility
There is also an experimental web interface for browsing templates and renders in
webui/.

Contributing
Before opening a PR, run cargo fmt --all and cargo clippy --workspace --all-targets.
Built with Rust π¦ β’ Powered by Typst β’ Inspired by the Docker registry and Git's content addressing