Report & PDF Generation
The formal, multi-section compliance report is the headline deliverable the client pays for — and the Final Report plus many smaller printable reports (system analysis, data-flow, supply-chain, process cost) all render to PDF. Reports are produced in one selected language at a time — Hebrew (default), English, Arabic, Russian, or French — so the engine must handle right-to-left (Hebrew, Arabic), mixed bidirectional text, and embedded fonts for all five scripts. This page locks that engine.
Decision — ✅ Locked
Render report content as HTML/CSS, convert to PDF with headless Chromium, deployed as a dedicated Gotenberg service on ECS Fargate. Generation runs as an async BullMQ job; the worker POSTs HTML to Gotenberg and stores the returned PDF in the S3 documents bucket.
- Engine: Gotenberg (Apache-2.0) — a container-native service wrapping Chromium (+ LibreOffice) behind a REST API.
- Deployment: its own Fargate service (a fourth reuse of the
api-workerspattern), autoscaled independently, entirely inside the VPC. - Fonts: the Noto family baked into the image — Noto Sans (Latin/Cyrillic → En/Fr/Ru) + Noto Sans Hebrew + Noto Sans Arabic, embedded on render (OFL-licensed).
- Content pipeline: AI-generated section HTML/markdown + data tables → a themed HTML template → Gotenberg → PDF → S3 → the
reportsentity (Data Model §L).
Why we chose this
- RTL is the deciding constraint, and only a browser engine clears it cleanly. Every PDF library that lays out glyphs itself (react-pdf, pdfmake, PDFKit) must re-implement the Unicode bidirectional algorithm and Arabic contextual shaping — historically they do this poorly. react-pdf's RTL has been broken since 2019. Chromium inherits a mature, battle-tested text stack that renders Hebrew, Arabic, and mixed En↔He/Ar bidi correctly for body text and tables. With Hebrew as the default report language, nothing less than browser-grade bidi is acceptable.
- Our content is already HTML. Sections are AI-generated HTML/markdown; CSS gives pixel-perfect branded multi-page control —
@pagemargins, running headers/footers with page numbers, page-break rules, repeating table headers. No separate templating language to learn. - Gotenberg over raw Puppeteer removes precisely the pain teams regret at scale: Chromium process leaks/zombies, memory blow-ups, glibc/font/flag Docker friction, and weekly Chromium CVE patching. One REST endpoint, purpose-built for containers; the worker stays a lean Node container and the heavy Chromium lifecycle is isolated in its own autoscaling service.
- Security & residency fit. Rendering AI-produced HTML you didn't hand-author sits behind Chromium's sandbox plus a container boundary (defense in depth), and all compliance content stays in the VPC — no third-party PDF SaaS ever sees tenant data.
- Font correctness. Baking pinned Noto font versions into the image (never relying on system fonts) prevents the "right in the browser, wrong glyphs in headless" class of bug and gives byte-stable output. Noto is designed for harmonious cross-script embedding and covers all five scripts.
Operational sizing
- ~512 MB–1 GB RAM per Chromium instance; ~6 concurrent renders per instance; scale horizontally on queue depth.
- Async by default — BullMQ absorbs latency, so the network hop to Gotenberg is free; a large report never blocks a request thread (this is also why long AI generations must not run synchronously behind the ALB/Cloudflare request timeout).
- Output PDFs land in the S3 documents bucket; the
reportsrow records language, included sections, S3 URL, and status.
Alternatives considered and rejected
- @react-pdf/renderer — nice pure-JS ergonomics, but RTL/Arabic shaping has been broken since 2019. Disqualified by the #1 constraint.
- pdfmake / PDFKit — no genuine bidi or Arabic shaping. Same disqualifier.
- wkhtmltopdf — archived/unmaintained, ancient WebKit, weak bidi. Do not adopt new in 2026.
- Typst — fast and elegant, but native layout is LTR-only and relies on immature community bidi packages. Too risky when Hebrew is the default language.
- Carbone / LibreOffice templates — only worth it if reports were authored as Word templates; adds a heavy dependency for no bidi advantage over Chromium.
- Raw Puppeteer/Playwright in the worker — same Chromium quality, but you inherit the leak/zombie/CVE lifecycle burden that Gotenberg exists to remove.
- WeasyPrint (Python/Pango) — the one defensible non-Chromium option (Pango gives real bidi), but it has documented RTL layout bugs (lists, flex
gap, columns) and a repeating-header-vs-collapsed-borders limitation, and it's a Python sidecar in a Node shop. Kept as a one-day fallback spike only.
Honest tradeoffs
- A separate Gotenberg service is one more thing to run and patch — accepted, because it isolates the Chromium blast radius and is far less work than hand-nursing Puppeteer.
- Mixed He↔En numeric/parenthesis/date edge cases occasionally need CSS
unicode-bidi: isolate/directionhints around inserted LTR data (IDs, dates, URLs) inside RTL paragraphs — validate with a real Hebrew+English render early.
Field signal
HTML+CSS rendered by headless Chromium (Puppeteer/Playwright or Gotenberg/browserless) is the modal choice for pixel-perfect branded multi-page PDFs in Node SaaS across 2025–2026 guidance. The consistently reported regret is self-hosting raw Puppeteer at scale (memory leaks, zombie processes, Docker/font/glibc fragility, CVE churn) — which is exactly why the market moved to isolated browser services like Gotenberg that keep Chromium quality while outsourcing the lifecycle pain to a container you still own.