PDF Engine — Document Generation and Theming
Audiences: developer, internal, clinical-buyer
A self-hosted FastAPI service (port 6450, v0.10.0) that turns Markdown / HTML / JSON manifests into branded A4/A3/Letter PDFs and DOCX via a dual-engine router (Playwright for JS+mermaid, WeasyPrint for full
@pagechrome) — used by every Dudoxx surface that needs a printable artifact: clinical reports, invoices, treatment plans, investor decks, legal contracts.
Business Purpose
Healthcare and consultancy workflows still terminate in paper-shaped artifacts — discharge letters, referral notes, invoices, treatment plans, GDPR data-export bundles, board memos. Outsourcing this to a SaaS PDF API (DocRaptor, PDFShift) costs ~$0.05/document, leaks PHI to a third party, and offers zero control over typography or page chrome. The Dudoxx PDF Engine replaces that with an in-cluster service that:
- Renders Dudoxx-branded documents using the same OKLCH palette + Instrument Serif / Inter / JetBrains Mono type stack as the web product (
dudoxx-hmstheme). - Stays inside the tenant VPC so PHI never crosses an external boundary.
- Generates both PDF and DOCX from the same Markdown source — DOCX matters for legal redlining, where lawyers refuse PDF.
- Carries a self-describing capability surface (
/help,/meta/skill,/meta/skill.zip) so AI agents discover what the engine can do without re-reading source code.
Cost model: zero per-document marginal cost once the service is running; the only spend is the ~4 GB RAM for the Chromium singleton.
Audiences
- Investor: differentiator vs. ZorgDomein / Doctolib who outsource document generation — Dudoxx owns the print path end-to-end, which is a moat in markets (DE, FR) where document branding and on-prem deployment are legally load-bearing.
- Clinical buyer (doctor/nurse/receptionist): every PDF the clinic prints — discharge letter, prescription, referral note, invoice — comes out with consistent typography and the clinic's logo, with zero per-page cost.
- Developer/partner: a REST API (
POST /jobs,POST /generate,POST /generate/markdown,POST /docx) plus a discoverable skill bundle. Internal callers fromddx-apiuse the sync/generate/markdownpath; long batch jobs (presentation regen) use the async/jobs+ SSE flow. - Internal (ops/support): process-managed by
ddx-manage.sh start pdf-engine; health probe atGET /health; structured logs viastructlog; Chromium is launched once per worker as a singleton (see lifespan inddx-pdf-engine/src/pdf_factory/api/app.py:35).
Architecture
Caller (ddx-api, ddx-presentation regen, ddx-web)
│
▼ HTTP (X-API-Key)
┌───────────────────────────────────┐
│ FastAPI app (pdf-factory:6450) │
│ ├─ routers/jobs.py (async) │
│ ├─ routers/generate.py(sync PDF) │
│ ├─ routers/docx.py (sync DOCX)│
│ ├─ routers/download.py │
│ └─ routers/help.py (CR-037) │
└──────────────┬────────────────────┘
│
┌───────┴───────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ select_engine│ │ DocxEngine │
│ (router) │ │ (shaegroup) │
└───┬──────┬───┘ └──────────────┘
│ │
▼ ▼
Playwright WeasyPrint
(JS+mermaid) (full @page chrome)
Two complementary rendering engines sit behind a single select_engine() router; the caller asks for auto and the router decides based on content signals (mermaid fences → Playwright; page_chrome header slots → WeasyPrint). DOCX generation uses a vendored shaegroup engine and bypasses HTML rendering entirely.
The library is a thin REST shell on top of a much larger Python library at ddx-pdf-engine/src/pdf_factory/ (~4,200 lines): fluent builder API, content elements (19 types), styling engine with theme cascade, manifest loader with $ref resolution.
Architecture anchor: coding_context/ddx-hms-context.md lists ddx-pdf-engine as a Tier-1 service on port 6450.
Tech Stack & Choices
| Layer | Choice | Why |
|---|---|---|
| Web framework | FastAPI 0.135+ | Async-native, automatic OpenAPI, Pydantic v2 |
| HTML→PDF (primary) | Playwright + Chromium singleton | Renders modern JS (mermaid diagrams, 3D CSS, custom fonts via @font-face); see ddx-pdf-engine/src/pdf_factory/engines/playwright.py |
| HTML→PDF (chrome) | WeasyPrint | The only engine that correctly renders @page { @top-left { … } } header/footer slots; see ddx-pdf-engine/src/pdf_factory/engines/weasyprint_engine.py |
| Markdown→DOCX | Vendored shaegroup docx-engine | Writes a real .docx (OOXML), not an HTML-disguise — opens cleanly in Word, supports tracked-changes redlining downstream |
| Mermaid | Local bundle (offline) | No CDN at runtime → no PHI leak, no network flake; render failures return HTTP 422 instead of silently degrading (CR-037) |
| Validation | Pydantic v2 | Single source of truth in manifest/models.py; mirrored to Zod in the Mastra TS schemas |
| Auth | X-API-Key header | Service-to-service only; no per-user auth — callers (ddx-api, ddx-presentation) gate human identity upstream |
Four built-in themes
| Theme | Use case | Source |
|---|---|---|
corporate | Generic business documents | styling/themes/corporate.py |
minimal | Sparse / tech-doc style | styling/themes/minimal.py |
corporate-deep | Formal navy, investor-tone | styling/themes/corporate_deep.py |
dudoxx-hms | Internal Dudoxx product surface — warm-cream paper (#F7F4ED) + ink + teal, Instrument Serif h1/h2, Inter body, JetBrains Mono code | styling/themes/dudoxx_hms.py |
Themes are named bundles of StyleRule objects, not CSS files — the styling engine compiles them with a deterministic cascade (defaults < theme < global < page < class < instance < raw CSS). This keeps theme switching to a single manifest field ("theme": "dudoxx-hms") instead of swapping CSS files.
Dual-engine routing
select_engine() in ddx-pdf-engine/src/pdf_factory/engines/__init__.py:106 is the single engine router (Invariant #4 in the engine module). The rules:
- Explicit
engine: "playwright"/engine: "weasyprint"→ honored verbatim. engine: "auto"with any mermaid fence → Playwright (JS required for mermaid render).engine: "auto"withpage_chromeheader/footer slots → WeasyPrint (only it renders@top-*margin boxes).- Otherwise → Playwright (faster startup, well-tested path).
Cover decks and natural pagination
Two manifest features matter for branded documents:
mode: coveron a manifest page renders a full-bleed cover slide (logo, title, byline) with a hardpage-break-after. Seeddx-pdf-engine/src/pdf_factory/manifest/builder.py:202for the cover branch.markdown_pagination: "natural"(thedudoxx-hmsdefault permanifest/document_classes.py:81) lets markdown flow page-to-page using browser pagination instead of forcing a hard page break between source files — required for multi-page reports that read as one body.
RFC 6266 Content-Disposition
Generated files set Content-Disposition: attachment; filename="ascii_fallback"; filename*=UTF-8''<percent-encoded> so non-ASCII filenames (em-dashes, German umlauts, French accents) survive download. See ddx-pdf-engine/src/pdf_factory/api/routers/docx.py:25 and api/routers/generate.py:88.
POST /docx endpoint (v0.9.0+)
A new sync endpoint that accepts the same Markdown + manifest shape as /generate/markdown but renders a real .docx via the vendored shaegroup engine. allow_raw=False is the default — raw HTML in markdown is stripped before conversion to keep the output deterministic. See api/routers/docx.py:41.
Data Flow
Sync path (POST /generate/markdown, POST /docx)
- Caller POSTs Markdown + optional theme + optional manifest.
- Pydantic v2 validation on the request model.
- For PDF: builder converts markdown → manifest → HTML, then
select_engine()picks Playwright or WeasyPrint, then the engine renders bytes. - For DOCX:
DocxEngine().generate(request)produces OOXML bytes directly (no HTML hop). - Response is a
StreamingResponsewithContent-Dispositionand the appropriate media type — no job, no SSE, no storage.
Async path (POST /jobs + SSE)
- Caller uploads manifest files (multipart) → server creates a
Job. - Background task drives the same builder + engine path; emits SSE events at each phase (
validate,compile,render,done). - Caller subscribes to
GET /jobs/{id}/events(SSE) to track progress. - On completion, caller fetches the PDF via
GET /jobs/{id}/download.
The Chromium browser is launched once per worker in the FastAPI lifespan (app.state.browser) and reused across requests; per-call launches added ~800 ms each before this change. See ddx-pdf-engine/src/pdf_factory/api/app.py:55-66.
Mermaid failures are loud, not silent (CR-037)
When a markdown source contains a mermaid fence and the local bundle fails to render it, the response is HTTP 422 with structured error detail ({type: "mermaid_render_error", source, error}) instead of silently degrading to a code block. This was a deliberate inversion of the prior "fail-open" behavior that hid broken diagrams from authors. See the exception handler in api/app.py:152-171.
Implicated Code
MANDATORY: ≥3 file:line citations. No page accepted without this section populated.
ddx-pdf-engine/src/pdf_factory/api/app.py:94—create_app()wires routers, CORS, exception handlers; declares version0.10.0(matchespyproject.toml; CHANGELOG.md still tops out at the 0.9.0 entry).ddx-pdf-engine/src/pdf_factory/api/app.py:55— Chromium singleton launched in lifespan and exposed viaapp.state.browser.ddx-pdf-engine/src/pdf_factory/engines/__init__.py:106—select_engine(), the single dual-engine router (Invariant #4); decides Playwright vs. WeasyPrint based on mermaid +page_chromesignals.ddx-pdf-engine/src/pdf_factory/engines/playwright.py— Playwright-backed engine (CHROMIUM_LAUNCH_KWARGS, reusable browser).ddx-pdf-engine/src/pdf_factory/engines/weasyprint_engine.py— WeasyPrint engine for full@pagechrome.ddx-pdf-engine/src/pdf_factory/api/routers/docx.py:41—POST /docxendpoint;DocxEngine().generate(request)returns OOXML bytes.ddx-pdf-engine/src/pdf_factory/api/routers/docx.py:25—_docx_streaming_response()sets RFC 6266 Content-Disposition.ddx-pdf-engine/src/pdf_factory/api/routers/generate.py:88— RFC 6266 Content-Disposition on the PDF download path.ddx-pdf-engine/src/pdf_factory/styling/themes/dudoxx_hms.py—dudoxx-hmstheme: warm-cream paper (#F7F4ED), Instrument Serif + Inter + JetBrains Mono.ddx-pdf-engine/src/pdf_factory/manifest/document_classes.py:71—dudoxx-hmsdocument class: A4 portrait, WeasyPrint,markdown_pagination: natural.ddx-pdf-engine/src/pdf_factory/manifest/builder.py:202—page.mode == "cover"branch in the compiler (cover-slide rendering with hard page-break-after).ddx-pdf-engine/src/pdf_factory/manifest/builder.py:164—natural_md = manifest.markdown_pagination == "natural"(suppresses hard page-breaks between adjacent markdown pages).ddx-pdf-engine/CHANGELOG.md— v0.9.0 changelog (dual-engine routing,/docx,dudoxx-hmstheme, mermaid HTTP 422).ddx-pdf-engine/CLAUDE.md— service architecture overview + skill-bundle refresh protocol.
Operational Notes
Environment variables (PDF_API_* prefix)
PDF_API_HOST/PDF_API_PORT(default0.0.0.0:6450)PDF_API_ENGINE_LIST— enabled engines, defaultplaywright,weasyprintPDF_API_JOB_RETENTION_HOURS— cleanup horizon for finished jobsPDF_API_CORS_ORIGIN_LIST— comma-separated allowed origins
Full reference: ddx-pdf-engine/API_SERVER.md.
Process management
- Start / stop / restart / logs all flow through
./ddx-manage.sh {start|stop|restart|logs} pdf-enginefrom the monorepo root. - Health:
curl http://localhost:6450/healthreturns{status, version, engines: {playwright, weasyprint}, jobs_active, jobs_total}. - Swagger UI:
http://localhost:6450/docs.
Validation
cd ddx-pdf-engine && uv run pytest(~480 tests)uv run ruff check src/ && uv run ruff format src/uv run mypy src/(strict)
Known pitfalls
- Don't use
engine: "auto"when you need@pagechrome — auto only routes to WeasyPrint if it detectspage_chromesignals at compile time. For company-branded PDFs with header bars, setengine: "weasyprint"explicitly in the manifest. (Documented inddx-presentation/CLAUDE.md§"PDF generation — critical rules".) - CSS Unicode escapes are forbidden in manifest JSON —
"\\00B7"parses as\00B7literal in JSON strings, never as·. Use the literal Unicode character. - Cover slide pagination is CSS-driven —
.cover-slide { page-break-after: always; }in the theme, NOT inlinewidth:210mm;height:297mm. - Skill bundle drift is silent — when adding a theme/element/endpoint, refresh
ddx-pdf-engine/skill-bundle/(CR-037). Stale bundle means/helpand/meta/skilllie to external agents. - Mermaid is bundled locally — no CDN fallback, no internet egress; if a diagram fails, you get HTTP 422 with the source snippet, not a silent degrade.
Self-describing capability surface (CR-037)
External LLM agents can discover capabilities without source access:
GET /help— markdown capability index (no auth).GET /help/{topic}— topic-scoped docs:themes,document-class,mermaid,math,3d-css,packages,pitfalls,samples.GET /meta/skill— structured JSON skill descriptor.GET /meta/skill.zip— downloadable skill bundle (versioned).
The canonical source is ~/.claude/skills/pdf-engine/; the engine ships a vendored snapshot at ddx-pdf-engine/skill-bundle/. Both are committed; drift is caught by tests/test_help_endpoints.py.
Related Topics
- Document Management — upstream caller; persists generated PDFs to MinIO + a FHIR
DocumentReference. - Document ACL — access-control rules for the rendered artifacts once stored.
- Doxing — AI Document Intelligence — sibling AI-summarization service on the same document substrate.
- FHIR ResourceFacade — canonical storage primitive that wraps the PDF binary in a
DocumentReference. - MinIO ↔ Prisma ↔ FHIR Aggregate Storage — where PDF bytes physically live.
- OCR Server — sibling document service; the inverse path (PDF → text), where PDF Engine handles text → PDF.