live

pdfcn-rs

HAML-like templates + Shadcn-style components + Tailwind-style utility classes, compiled to PDF bytes in memory. Pure Rust, no headless browser, running as a Vercel Function.

POST /api/generate-pdf

curl -X POST https://pdfcn-rs.vercel.app/api/generate-pdf \
  -H "Content-Type: application/json" \
  -d '{
    "template": "%h1 Invoice\n%p Total: {{ total }}",
    "data": { "total": "$42.00" }
  }' \
  --output invoice.pdf

Source: github.com/pmaojo/pdfcn-rs

Try it live

Edit the template and data, then generate — this calls /api/generate-pdf on this deployment and renders the PDF it returns.

Generating the first PDF…

Request reference

Full body accepted by POST /api/generate-pdf. Only template is required.

FieldTypeDefaultDescription
templatestring—HAML-like template source (see syntax below).
dataobject{}JSON context resolved against {{ ... }} interpolations.
page.size"a4" | "letter" | "WxH""a4"Custom sizes take millimeters, e.g. "210x297".
page.orientation"portrait" | "landscape""portrait"Page orientation.
page.margin_mmnumberengine defaultUniform page margin in millimeters.
filenamestring"document.pdf"Suggested name via Content-Disposition.
fontsobject{}Map of family name → base64 TTF/OTF bytes. Reference via font-family: "<name>".
imagesobject{}Map of src key → base64 JPEG/PNG bytes. No network fetch happens at render time — embed bytes yourself.
themeobjectlightSemantic token set applied before scanning: { "mode": "light" | "dark", "overrides": { "primary": "#2563eb" } }. One entry rebrands every derived utility (bg-primary, text-primary-foreground, …) and every built-in component built on them — no template changes. Malformed themes are a 400.
svgsobject{}Map of id → SVG source text (plain UTF-8, no base64) for %Vector(id="...") placeholders.
factur_xobject—Splices a Factur-X invoice attachment into the response: { "xml": "...", "profile"?: "en16931", "icc_base64"?: "..." }. See the Factur-X section below.

Responses: 200 with application/pdf bytes on success, 400 for a missing/invalid JSON body, 422 with a plain-text render error: … message when the template or data is invalid.

POST /api/generate-pdf-batch

Render up to 50 documents per request. All documents share one auth check, one font/image decode, one deduplicated remote-image fetch pass and — via the parse cache — one parse of an identical template, while renders fan out across cores. A render failure inside one document becomes an error entry; its siblings still ship.

curl -X POST https://pdfcn-rs.vercel.app/api/generate-pdf-batch \
  -H "Content-Type: application/json" \
  -d '{
    "theme": { "overrides": { "primary": "#0f766e" } },
    "data": { "company": "Acme GmbH" },
    "documents": [
      { "template": "%h1 Statement {{ month }}\\n%p {{ company }}",
        "data": { "month": "July" },   "filename": "statement-july.pdf" },
      { "template": "%h1 Statement {{ month }}\\n%p {{ company }}",
        "data": { "month": "August" }, "filename": "statement-august.pdf" }
    ]
  }'

Shared fields (applied to every document unless it overrides them): data (per-document keys win, shallow merge), page, theme, fonts, images, partials, filename. Per-document fields: template (required), data, page, filename (defaults to document-N.pdf by position).

{
  "results": [
    { "filename": "statement-july.pdf",  "pdf_base64": "JVBERi…" },
    { "filename": "statement-august.pdf", "pdf_base64": "JVBERi…" },
    { "filename": "statement-broken.pdf", "error": "render error: …" }
  ]
}

Problems with the request itself — bad JSON, bad base64, malformed theme, more than 50 documents — are a 400 before any rendering starts.

Template syntax

SyntaxMeaning
%tagElement, e.g. %h1, %p. Indentation nests children — no closing tags.
.class-nameA bare div with that class; chain classes like .flex.gap-2 (Tailwind-style utilities).
%tag.class(attr="value")Tag plus classes plus attributes in parentheses.
{{ path.to.value }}Interpolates from data; also valid inside attribute values.
- if cond / - elseConditional branch on a truthy value from data.
- for x in listRepeats the indented block once per item, binding x.
- include "name"Inlines a caller-supplied partial (requires a Partials resolver on the embedding side; the HTTP API uses NoPartials).

Built-in components

Shadcn-style, pre-styled, used the same way as any %tag:

DocumentLayout Header Card Table Grid Badge Separator SignatureBlock InvoiceTable Alert Avatar Input Textarea Select Label Checkbox RadioItem Progress Breadcrumb Pagination BarChart QRCode PageFooter LineChart StackedBarChart PieChart Sparkline Barcode Vector

Factur-X invoice embedding

Splices an EN 16931/CII invoice XML into the response as a Factur-X-compliant attachment, via an extra top-level factur_x field. Try the "Factur-X invoice" example above — the PDF it returns actually carries the attachment; a page-view can't show it, but pdftk in.pdf dump_data or any PDF/A-3-aware viewer's attachments panel will.

FieldTypeDefaultDescription
factur_x.xmlstring—EN 16931/CII invoice XML, plain UTF-8 (required if factur_x is present).
factur_x.profilestring"en16931"minimum, basic-wl, basic, en16931, or extended.
factur_x.icc_base64string—A genuine sRGB ICC profile, base64-encoded, embedded as the PDF/A /OutputIntent. Omitted rather than fabricated when absent — see docs/spikes/002-factur-x-embedding.md.