# π Papermake
**Turn your [Typst](https://typst.app/) 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.
```bash
# Publish a template
curl -X POST "localhost:3000/api/templates/invoice/publish?tag=latest" \
-F "main_typ=@invoice.typ" \
-F 'metadata={"name":"Invoice","author":"you@company.com"}'
# Render it with data
curl -X POST localhost:3000/api/render/invoice:latest \
-H "Content-Type: application/json" \
-d '{"data": {"company": "Acme Corp", "amount": 1500}}'
# β {"data":{"render_id":"e9c1β¦","pdf_hash":"sha256:8e0eβ¦","duration_ms":42}}
# Download the PDF
curl localhost:3000/api/renders/e9c1β¦/pdf --output invoice.pdf
```
## Why Papermake?
- **Templates as code** β immutable versions, mutable tags: `invoice:v1.0.0` never changes, `invoice:latest` moves
- **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
```bash
git clone https://github.com/rkstgr/papermake
cd papermake
docker compose up -d
```
This starts:
- **Papermake server** on `localhost:3000`
- **MinIO** (S3-compatible storage) on `localhost:9000`, console at `http://localhost:9001`
- **ClickHouse** (render history) on `localhost:8123`
Write a template β the input data is available as `#data`:
```typst
// invoice.typ
= Invoice #data.number
*Bill To:* #data.customer.name \
*Amount:* $#data.amount
```
Publish it, render it, download the PDF:
```bash
curl -X POST "localhost:3000/api/templates/invoice/publish?tag=latest" \
-F "main_typ=@invoice.typ" \
-F 'metadata={"name":"Invoice","author":"you@company.com"}'
curl -X POST localhost:3000/api/render/invoice:latest \
-H "Content-Type: application/json" \
-d '{"data": {"number": "INV-001", "customer": {"name": "Acme Corp"}, "amount": 1500}}'
# Use the render_id from the response above
curl localhost:3000/api/renders/<render_id>/pdf --output invoice.pdf
```
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:
```bash
curl -X POST localhost:3000/api/templates/hello/publish-simple \
-H "Content-Type: application/json" \
-d '{
"main_typ": "Hello #data.name!",
"metadata": {"name": "Hello", "author": "you@company.com"}
}'
```
### Running without Docker
```bash
# Point the server at your S3 and ClickHouse instances
cp .env.example .env
cargo run -r -p papermake-server
```
## π¦ Using the library
The core renderer is available as a standalone Rust library β no server, no
storage backends:
```toml
[dependencies]
papermake = "0.2"
```
```rust
use papermake::{render_template, InMemoryFileSystem};
use std::sync::Arc;
let template = "Hello #data.name!";
let fs = Arc::new(InMemoryFileSystem::new());
let data = serde_json::json!({ "name": "World" });
let result = render_template(template.to_string(), fs, &data)?;
if result.success {
std::fs::write("hello.pdf", result.pdf.unwrap())?;
}
```
### 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:
```rust
use papermake::{render_template_with_options, RenderOptions};
let result = render_template_with_options(template.to_string(), fs, &data, &RenderOptions::pdf_a3b())?;
```
Over HTTP, request it per render:
```bash
curl -X POST localhost:3000/api/render/invoice:latest \
-H "Content-Type: application/json" \
-d '{"data": {"company": "Acme Corp"}, "pdf_standard": "a-3b"}'
```
### 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:
```rust
let mut fs = InMemoryFileSystem::new();
fs.add_file("logo.png", std::fs::read("assets/logo.png")?);
fs.add_file("header.typ", std::fs::read("templates/header.typ")?);
```
```typst
#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:
```bash
FONTS_DIR=./fonts cargo run
```
## HTTP API
All routes except `/health` are prefixed with `/api`.
| `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:
| [`papermake`](crates/papermake) | Core Typst rendering engine with virtual file system ([crates.io](https://crates.io/crates/papermake)) |
| [`papermake-registry`](crates/papermake-registry) | Content-addressable template storage (S3) and render history (ClickHouse) |
| [`papermake-server`](crates/papermake-server) | Axum HTTP API on top of the registry |
| [`papermake-worker`](crates/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/`](webui).

## Contributing
```bash
git clone https://github.com/rkstgr/papermake
cd papermake
cargo test --workspace
```
Before opening a PR, run `cargo fmt --all` and `cargo clippy --workspace --all-targets`.
---
Built with Rust π¦ β’ Powered by [Typst](https://typst.app/) β’ Inspired by the Docker registry and Git's content addressing