05-documentswave: W2filled18 citations

PDF Engine — Document Generation and Theming

Audiences: developer, internal, clinical-buyer

PDF Engine — Document Generation and Theming

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 @page chrome) — 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-hms theme).
  • 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 from ddx-api use the sync /generate/markdown path; 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 at GET /health; structured logs via structlog; Chromium is launched once per worker as a singleton (see lifespan in ).

Architecture

Rendering diagram…

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

LayerChoiceWhy
Web frameworkFastAPI 0.135+Async-native, automatic OpenAPI, Pydantic v2
HTML→PDF (primary)Playwright + Chromium singletonRenders modern JS (mermaid diagrams, 3D CSS, custom fonts via @font-face); see ddx-pdf-engine/src/pdf_factory/engines/playwright.py
HTML→PDF (chrome)WeasyPrintThe only engine that correctly renders @page { @top-left { … } } header/footer slots; see ddx-pdf-engine/src/pdf_factory/engines/weasyprint_engine.py
Markdown→DOCXVendored shaegroup docx-engineWrites a real .docx (OOXML), not an HTML-disguise — opens cleanly in Word, supports tracked-changes redlining downstream
MermaidLocal bundle (offline)No CDN at runtime → no PHI leak, no network flake; render failures return HTTP 422 instead of silently degrading (CR-037)
ValidationPydantic v2Single source of truth in manifest/models.py; mirrored to Zod in the Mastra TS schemas
AuthX-API-Key headerService-to-service only; no per-user auth — callers (ddx-api, ddx-presentation) gate human identity upstream

Four built-in themes

ThemeUse caseSource
corporateGeneric business documentsstyling/themes/corporate.py
minimalSparse / tech-doc stylestyling/themes/minimal.py
corporate-deepFormal navy, investor-tonestyling/themes/corporate_deep.py
dudoxx-hmsInternal Dudoxx product surface — warm-cream paper (#F7F4ED) + ink + teal, Instrument Serif h1/h2, Inter body, JetBrains Mono codestyling/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 is the single engine router (Invariant #4 in the engine module). The rules:

  1. Explicit engine: "playwright" / engine: "weasyprint" → honored verbatim.
  2. engine: "auto" with any mermaid fence → Playwright (JS required for mermaid render).
  3. engine: "auto" with page_chrome header/footer slots → WeasyPrint (only it renders @top-* margin boxes).
  4. Otherwise → Playwright (faster startup, well-tested path).

Cover decks and natural pagination

Two manifest features matter for branded documents:

  • mode: cover on a manifest page renders a full-bleed cover slide (logo, title, byline) with a hard page-break-after. See for the cover branch.
  • markdown_pagination: "natural" (the dudoxx-hms default per ) 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 and .

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 .

Data Flow

Sync path (POST /generate/markdown, POST /docx)

  1. Caller POSTs Markdown + optional theme + optional manifest.
  2. Pydantic v2 validation on the request model.
  3. For PDF: builder converts markdown → manifest → HTML, then select_engine() picks Playwright or WeasyPrint, then the engine renders bytes.
  4. For DOCX: DocxEngine().generate(request) produces OOXML bytes directly (no HTML hop).
  5. Response is a StreamingResponse with Content-Disposition and the appropriate media type — no job, no SSE, no storage.

Async path (POST /jobs + SSE)

  1. Caller uploads manifest files (multipart) → server creates a Job.
  2. Background task drives the same builder + engine path; emits SSE events at each phase (validate, compile, render, done).
  3. Caller subscribes to GET /jobs/{id}/events (SSE) to track progress.
  4. 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.

Operational Notes

Environment variables (PDF_API_* prefix)

  • PDF_API_HOST / PDF_API_PORT (default 0.0.0.0:6450)
  • PDF_API_ENGINE_LIST — enabled engines, default playwright,weasyprint
  • PDF_API_JOB_RETENTION_HOURS — cleanup horizon for finished jobs
  • PDF_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-engine from the monorepo root.
  • Health: curl http://localhost:6450/health returns {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

  1. Don't use engine: "auto" when you need @page chrome — auto only routes to WeasyPrint if it detects page_chrome signals at compile time. For company-branded PDFs with header bars, set engine: "weasyprint" explicitly in the manifest. (Documented in ddx-presentation/CLAUDE.md §"PDF generation — critical rules".)
  2. CSS Unicode escapes are forbidden in manifest JSON — "\\00B7" parses as \00B7 literal in JSON strings, never as ·. Use the literal Unicode character.
  3. Cover slide pagination is CSS-driven — .cover-slide { page-break-after: always; } in the theme, NOT inline width:210mm;height:297mm.
  4. Skill bundle drift is silent — when adding a theme/element/endpoint, refresh ddx-pdf-engine/skill-bundle/ (CR-037). Stale bundle means /help and /meta/skill lie to external agents.
  5. 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.