# PDF/A-2b in JavaScript, shared by device and Worker

The repository's architectural decision, rationale and consequences.

# PDF/A-2b in JavaScript, shared by device and Worker

**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 [#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 [#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 [#what-pdfmake-03x-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.mjs` under the default export condition. That module computes `ICC_PROFILE_PATH = new URL("./data/sRGB_IEC61966_2_1.icc", import.meta.url).href` at **module load**. Under esbuild-bundled workerd, `import.meta.url` is not a valid URL and the module throws `TypeError: Invalid URL string` before `fetch` is ever called.
- Aliasing `pdfkit` → the Node CJS build (`pdfkit/js/pdfkit.js`) fixes the init crash but exposes a second runtime path (`fs.readFileSync` on the ICC profile, dynamic `require$1("#standard-fonts/Helvetica")` subpath imports) that are not reachable from a bundled Worker. The fallback suggested by the ticket — `@cantoo/pdf-lib` with 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 [#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 [#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 [#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 [#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 [#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 [#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-lib` with 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.

