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 ddx-pdf-engine/src/pdf_factory/api/app.py:35).

Architecture

diagram
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

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 ddx-pdf-engine/src/pdf_factory/engines/__init__.py:106 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 ddx-pdf-engine/src/pdf_factory/manifest/builder.py:202 for the cover branch.
  • markdown_pagination: "natural" (the dudoxx-hms default per manifest/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)

  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.

    PDF Engine — Document Generation and Theming — Dudoxx Docs | Dudoxx