Status: accepted · Date: 2026-10-31
Enforcement: @wundklare/report's buildRepresentativeReport() is the one engine entrypoint; packages/report/src/engine.test.ts gates the Node output against veraPDF (verapdf/cli --flavour 2b) in CI and the bench harness drops the workerd and Expo-web PDFs into the same evidence folder for the same gate; packages/report/bench/run-workerd.mjs is a throwaway wrangler dev --local worker that is never deployed.
Context
Wundklar needs one PDF/A-2b path that runs in the three places reports are built: Node (tests, seed snapshots), workerd (server-built reports and MD exports from the one cron tick, ADR-0022) and Expo web (device-built reports when the nurse is offline). RNF-02 requires embedded fonts with correct ToUnicode so German characters survive text extraction, and R-B names "PDF/A in workerd and Expo web" as the single highest-risk assumption we had to disprove before any report work started. Running a headless Chromium is not an option in a Worker or on the device.
Decision
The engine is pdfkit 0.20 driven directly, wrapped by @wundklare/report's buildRepresentativeReport(). Fonts (Instrument Sans + Azeret Mono, SIL OFL) and the sRGB IEC61966-2.1 ICC profile are base64-embedded in the package so no runtime filesystem access is needed. pdfkit's subset: "PDF/A-2b" option provides the XMP metadata and OutputIntent scaffolding; a single instance-level override replaces pdfkit's _addColorOutputIntent, which otherwise tries to read the ICC profile from disk.
veraPDF is the gate: docker run --rm -v "<dir>:/data" verapdf/cli --flavour 2b /data/<pdf> must return isCompliant="true" in Node, workerd and Expo web. The engine ships the output deterministically for fixed inputs (same SHA-256 across repeated calls in the same runtime) so the Node bytes also act as a golden file for the seed snapshot ticket.
What pdfmake (0.3.x) could not do
Ticket 07 originally called for pdfmake 0.3 so the report could keep a declarative layout. In practice:
- pdfmake wraps pdfkit's
js/pdfkit.browser.mjsunder the default export condition. That module computesICC_PROFILE_PATH = new URL("./data/sRGB_IEC61966_2_1.icc", import.meta.url).hrefat module load. Under esbuild-bundled workerd,import.meta.urlis not a valid URL and the module throwsTypeError: Invalid URL stringbeforefetchis ever called. - Aliasing
pdfkit→ the Node CJS build (pdfkit/js/pdfkit.js) fixes the init crash but exposes a second runtime path (fs.readFileSyncon the ICC profile, dynamicrequire$1("#standard-fonts/Helvetica")subpath imports) that are not reachable from a bundled Worker. The fallback suggested by the ticket —@cantoo/pdf-libwith a hand-written XMP + OutputIntent — needs a separate fontkit install for custom-TTF embedding; ticket 02 owns the manifest, so adding that is a deviation.
How we kept the manifest intact
Because pdfkit is a transitive dependency of pdfmake, we reach it through pdfmake's PDFDocument class (class PDFDocument extends _pdfkit.PDFDocument): Object.getPrototypeOf(PdfmakePDFDocument) yields pdfkit's constructor and is what the engine uses. pdfmake itself is now the unused dependency the ticket text asked us to flag; the coordinator can retire it in a later manifest-only commit.
Why patching the instance, not the prototype
pdfkit's PDF/A support is a module-local PDFA = { … } mixin that is copied onto the live PDFDocument via _importSubset(PDFA) → Object.assign(this, PDFA). The methods therefore live as own instance properties, so a prototype-level override is shadowed. The engine replaces _addColorOutputIntent on each instance between new PDFDocument(…) and doc.end().
Workerd bundling
packages/report/bench/wrangler.jsonc aliases pdfkit to a one-line CJS entry that stubs globalThis.__filename and __dirname (esbuild does not inject them for bundled CJS modules) and then re-exports pdfkit/js/pdfkit.js. nodejs_compat lets module.createRequire and fs behave well enough for pdfkit's Node CJS bundle; the ICC and fonts are satisfied by our in-package base64 blobs via the instance override and doc.registerFont("Instrument", …) + doc.registerFont("Mono", …). pdfkit's default Helvetica loader is bypassed by passing font: null in the options, because #standard-fonts/Helvetica is a dynamic subpath import that esbuild cannot resolve.
Metro bundling
Expo web resolves pdfkit through its default (browser) condition, where the ICC profile is already registered in pdfkit's in-memory file system via registerFile(ICC_PROFILE_PATH, …). Our engine still calls the same instance-level override so the code path is identical across runtimes and the sRGB bytes come from the same embedded source.
Consequences
Good. One @wundklare/report entrypoint renders bytes anywhere a nurse's device, a Worker or a Node test can run. The engine returns a Uint8Array, so the sync and report pipelines can hash the output, feed it to the share-bundle encryption without copying through strings, and send the same bytes to send_email, R2 and the device all in one path. veraPDF compliance is a line in the test suite, not a manual step. Byte-level determinism (Node ↔ workerd ↔ Expo web are PDF/A-2b compliant in all three runtimes; Node and workerd are also byte-identical for the representative input) locks the seed snapshot and keeps the acceptance test cheap.
Bad. Report layout is now imperative pdfkit calls instead of pdfmake's declarative tree, so the content ticket (54) has to write a small layout pass per section (chart, table, image row). pdfmake stays in the dependency manifest unused until a later manifest commit removes it. Instrument Sans does not cover ²; the engine displays cm2 for now (ticket 54 will attach a superscript-covering supplement font or render it as a glyph composition).
Alternatives considered
- pdfmake 0.3 in all three runtimes. Fails at module load under workerd (ICC URL hack); fails under Metro web the same way with an added bundle penalty.
- Headless Chromium for PDF. Impossible on the device and in a Worker.
@cantoo/pdf-libwith hand-written XMP and OutputIntent. Supported in principle, but custom-TTF embedding needs a fontkit instance that ticket 02 does not pin; adding it is a manifest change we did not make.- Keep pdfmake in Node, swap for pdf-lib in the other two runtimes. Two engines means two layout code paths and two sets of veraPDF gates; the whole point of ticket 07 is to prove one engine runs in all three places.