Versioned print forms — HTML in, PDF out.
Self-hosted service for print documents: invoices, certificates, government forms. Whoever owns the form edits it in a web editor — code or visual — and publishes a numbered version. Your application posts JSON to one stable code and gets a PDF. That version is frozen, so the same call renders the same document years later.
Try the editor → — the examples gallery on a
live instance. Open any template, edit it, watch the PDF change. Nothing is
saved there and uploads are cleared within the hour; it is the same image this
repository publishes, run with LINFORM_ROLE=demo.
Status: early development, usable. Everything described here works and is tested; what is not settled is the shape of the editor's newer corners. The version model — publish, pin, roll back — is the part that will not move.
Yes, if the printed page has to be exact and stay exact for years; the person who owns the form should be able to change it without a deploy; your data must not leave your network.
No, if your templates are Word documents and their authors will not give that up; you need JavaScript or a layout that leans on CSS grid; you want a queue, retries and stored results; you need per-team isolation inside one instance.
The long version, against Carbone, Gotenberg, PDFMonkey and the report designers: docs/COMPARISON.md.
git clone https://github.com/Lito130965/linform && cd linform
docker compose up -d --build # app on :8100 + PostgreSQL (not exposed)That gives a working service with authentication off — enough to open the
editor and render something. Before exposing it to anyone, copy .env.example
to .env and set an authentication section.
One container and no database to configure, if you prefer — SQLite in a file next to the app, from the published image:
docker run -p 8100:8000 ghcr.io/lito130965/linform:latestImages are published on each tagged release; latest follows the newest, and a
release tag pins the image the way a template version pins a document. Building
it yourself is docker build -t linform . && docker run -p 8100:8000 linform.
Create a template, publish a version, render a PDF:
# 1. Template with a stable code your app will render by
curl -X POST localhost:8100/api/templates \
-H "Content-Type: application/json" \
-d '{"code": "invoice", "name": "Invoice"}'
# 2. A draft — a working copy with no version number yet
# → {"id": 1, "status": "draft", ...}
curl -X POST localhost:8100/api/templates/invoice/drafts \
-H "Content-Type: application/json" \
-d '{"html_content": "<h1>Invoice #{{ number }}</h1>", "comment": "initial"}'
# 3. Publish that draft by its id — this is where version 1 is minted
curl -X POST localhost:8100/api/templates/invoice/drafts/1/publish
# 4. Render: JSON in, PDF out
curl -X POST localhost:8100/api/render/invoice \
-H "Content-Type: application/json" \
-d '{"number": 42}' --output invoice.pdfReady-made templates to start from live in examples/, each with sample data and curl commands.
HTML template with {{ placeholders }} → Jinja2 (sandboxed) →
final HTML → WeasyPrint → PDF
Jinja2 runs sandboxed, because a template is untrusted input. WeasyPrint lays
the document out with CSS Paged Media — @page, running headers and footers,
page counters, explicit break control — which is what makes a page repeatable
rather than approximately right. External URLs are blocked by default, so a
template cannot make the server fetch an internal address.
Writing one, including barcodes and QR codes drawn from payload data: docs/TEMPLATES.md. What the visual editor does beyond typing — snapping, page-break marking, the keyboard: docs/EDITOR.md.
Archival and tagged output. LINFORM_PDF_VARIANT=pdf/a-3b writes PDF/A,
the standard an archive or a public-sector filing is usually required to be in;
pdf/ua-1 writes a tagged document a screen reader can navigate. PDF/A the
engine handles by itself — fonts and colour profiles are its business. PDF/UA
is a flag and a requirement on the template, because a tagged file is only
navigable if there is something in the markup to tag; that trade is spelled out
in docs/CONFIGURATION.md.
A draft is not a version. A draft is a working copy: no number, editable, deletable, and unreachable by any consuming application. A version exists only once something is published — it is numbered then, which means a version number always refers to something a consumer could legitimately have rendered.
Published versions are immutable, and exactly one is current (enforced by the database, so it holds with any number of replicas). Pointing that at an older version is the rollback; it mints no new number. A consumer either renders "whatever is current" or pins an explicit version, and deciding which documents pin which version is the consumer's business rule, kept out of this service on purpose.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/render/{code} |
Main endpoint: render the current version |
| POST | /api/render/{code}/versions/{v} |
Pin an exact version (reproducible forever) |
| POST | /api/render |
Ad-hoc render: raw HTML + data, nothing stored |
| GET | /api/templates/{code}/placeholders |
Fields the template expects — the integration contract |
| POST | /api/templates/{code}/drafts |
Start a working copy |
| POST | /api/templates/{code}/drafts/{id}/publish |
Publish it: numbered, frozen, live |
| POST | /api/templates/{code}/versions/{v}/current |
Point consumers at a version (the rollback) |
| GET | /health · /ready |
Liveness · readiness |
Templates, directories, assets, accounts and the admin endpoints:
docs/API.md, or /docs on a running instance.
With a key configured, the editor gains an assistant that drafts a template from a description or a scan and makes targeted corrections. It never writes to the database — saving and publishing stay human actions, so immutability is untouched.
For anything the editor already does — the page, a header or footer, a block, a preset, a field — it asks for that operation rather than writing markup, and you see it as a list of sentences. What lands is then what the panels produce: a footer the header switch maintains, a page number built on counters, both still editable afterwards. Its vocabulary is exactly the editor's, checked against the editor's own source in CI. Changes apply in the visual editor as they arrive, and one press takes them back.
Bring your own key: it stays on the backend and is never sent to the browser. What leaves your machine when you use it — the template, placeholder names, the prose of the chat, attached screenshots, and your test data only if you opt in — is listed in docs/ASSISTANT.md. This is the one place where Linform talks to a third party; everything else runs entirely inside your deployment and needs no internet access at all.
Better to know before you build on it:
- No JavaScript in templates. WeasyPrint renders documents, not web pages.
- Print CSS, not browser CSS. Flexbox lays out, and so does explicit CSS
grid — both checked against the pinned engine by rendering a page and reading
back where things landed (
tests/test_engine_capabilities.py), so the claim stays honest across upgrades. The further corners of grid — auto-placement, named areas, subgrid — are untested, and a layout copied from a web page may still not survive the trip. - Rendering is synchronous. One request, one PDF, with a hard timeout and a
hard in-flight ceiling — past it the service replies
429 Retry-Afterinstead of queueing without bound. Bulk generation and retries are the calling application's job; Linform gives it an idempotent building block. - No business data is stored. Payloads are rendered and forgotten. It follows that Linform cannot re-render a document you did not keep the data for — store the version number alongside your document and pin it.
- One instance mounts everything, unless you split it.
LINFORM_ROLE=renderleaves the management API out of the process entirely; either way, keep the editor on an internal network. - The visual canvas approximates pagination; the preview is the truth. It draws each page boundary where the page really ends and marks what a break will do to the element it crosses, but it does not reflow content across the break, and it is a different layout engine from the renderer. Author in the canvas, confirm in the PDF beside it; where they disagree, the PDF is right. The full difference is in docs/EDITOR.md.
The short version: a template is untrusted code, and an editor user is trusted. Jinja runs sandboxed, external URL fetching is off by default so a template cannot make the server fetch internal addresses, markup is stripped of executable content before it reaches the editor canvas with a CSP behind it, passwords are slow-hashed and tokens stored as digests, and payloads are never logged or stored. Anyone who can sign in as an editor, however, can read and change every template in the deployment — there is no per-template permission model.
Full threat model, what is deliberately not covered, a hardening checklist, and how to report a vulnerability: SECURITY.md.
- A PDF variant per template version. The archival standard is an instance setting today, which sits awkwardly beside "a version renders the same document forever" — changing it changes what an old version produces.
- Accessibility hints in the editor — an image with no alternative text, a table with no header row, a heading that is only a large paragraph.
- Keyboard parity in the canvas — column widths, row heights and free
positioning are still mouse-only gestures (
good first issue). - An asset storage interface, so a deployment with page-sized backgrounds can put them somewhere other than the database.
- Interface localisation — one locale today (
help wanted).
What is already done
- Render core:
POST /api/render(HTML + JSON → PDF) - Stored templates with immutable versions (draft → published → archived)
- Render by stable template code + explicit version pinning
- Web editor: HTML mode with live paged preview, placeholder panel
- Content-addressed assets (logos, backgrounds) with
asset://references - Version history with diff, publish/rollback from the UI
- Visual (WYSIWYG) editing mode alongside the HTML mode — a purpose-built DOM editor whose round trip is byte-exact through the Jinja bridge (no third-party WYSIWYG re-serializing the markup)
- Import a starting template from
.docx - Barcodes and QR codes from payload data
- Optional AI assistant (bring your own key)
- Deployment role split (
editor/render) so render nodes carry no management API - Verified multi-replica run (
--scale), checked in CI
Linform comes out of a problem I know from working on regulated, form-heavy reporting: a printed form has to render years later exactly as it did when it was filed. Architecture, scope, the version model and the test strategy are mine, and the history shows the directions that were tried and dropped — a third-party WYSIWYG among them. Implementation was AI-assisted; see the Co-Authored-By trailers. The reasoning is in docs/DECISIONS.md.
| DECISIONS.md | Why it is built this way, and what was rejected |
| TEMPLATES.md | Writing a template: Jinja, paged CSS, barcodes, assets |
| EDITOR.md | The visual canvas: keyboard, snapping, page breaks |
| API.md | Every endpoint, accounts and roles, the version model |
| CONFIGURATION.md | Every environment variable |
| OPERATIONS.md | Observability, performance, deployment roles, backup |
| TESTING.md | Golden PDFs, browser tests, accessibility |
| TEST-STRATEGY.md | What is tested, what is not, and the risks behind both |
| MANUAL-CHECKS.md | What is checked by hand, and why |
| SECURITY.md | Threat model and reporting |
| CONTRIBUTING.md | Running and testing it locally |
| CHANGELOG.md | What changed, release by release |
MIT


