From ed96d36b54ba529a9ce4bf4a8ee94fd4c2a50d90 Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 30 Jul 2026 10:48:42 +0000 Subject: [PATCH] Fix font path breaking every PDF render inside the Payload backend (v0.2.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fonts.ts resolved the vendored Liberation Sans .ttf files via `new URL('./assets/fonts/...', import.meta.url).pathname` — Next.js's Turbopack/webpack bundler statically rewrites exactly that expression into its own hashed static-asset system. Harmless in the frontend's own build, but the Payload backend is also a Next.js app consuming this package as a node_modules git dependency, where the hash referenced in the compiled server code didn't match what was actually emitted to disk — every invoice/correction-invoice admin download failed with ENOENT: .../LiberationSans-Regular..ttf. Not a cache issue, reproduced identically after a from-scratch --no-cache rebuild. Fixed by resolving the path via fileURLToPath(import.meta.url) + path.join instead, which Turbopack does not special-case. --- README.md | 2 ++ package.json | 2 +- src/fonts.ts | 23 +++++++++++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 09afdd3..d01d093 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ One canonical implementation, consumed by both repos, makes this class of drift **2026-07-28, embedded font (v0.2.6).** An E-Rechnung/PDF-A checker flagged that generated invoice PDFs don't embed their fonts — both `invoicePdf.tsx` and `correctionInvoicePdf.tsx` used react-pdf's built-in `Helvetica`/`Helvetica-Bold`, one of the "standard 14" PostScript fonts react-pdf never actually embeds (it just emits a `/BaseFont` reference and relies on the PDF viewer having a substitute installed). PDF/A-3 — already required by this package's own Factur-X/ZUGFeRD pipeline (see "E-invoicing" below) — has no exemption for standard fonts; every font actually used must be embedded. Fixed by vendoring `LiberationSans-Regular.ttf`/`LiberationSans-Bold.ttf` (SIL OFL-1.1, metrically identical to Helvetica/Arial — no layout shift) under `src/assets/fonts/`, registered once via `Font.register()` in the new `fonts.ts` (imported for its side effect by both PDF modules), with every `fontFamily: "Helvetica"`/`"Helvetica-Bold"` replaced by `fontFamily: "Liberation Sans"` (+ `fontWeight: "bold"` where the bold variant was used). Verified by inspecting the raw PDF bytes of a generated fixture: `/FontFile2` present, `/BaseFont` shows subset tags (e.g. `CWOPQE+LiberationSans`), `/Subtype /Type0` — i.e. actually embedded, not just referenced. +**2026-07-30, font path broke every PDF render inside the Payload backend (v0.2.8).** `fonts.ts` (added in v0.2.6 above) resolved the vendored `.ttf` files via `new URL('./assets/fonts/...', import.meta.url).pathname` — that exact expression shape is what Next.js's Turbopack/webpack bundler statically detects and rewrites into its own hashed static-asset pipeline (`.next/server/assets/.ttf`). Harmless in the frontend's own Next.js build, but the Payload backend is *also* a Next.js app (Payload 3's standard architecture) consuming this package as a `node_modules` git dependency — there, the hash Turbopack's compiled server code referenced didn't match what it actually emitted to disk, so every single invoice/correction-invoice download in the admin failed with `ENOENT: .../LiberationSans-Regular..ttf`. Confirmed not a stale-build-cache issue: reproduced identically even after a from-scratch `docker compose build --no-cache`. Fixed by resolving the fonts directory via `fileURLToPath(import.meta.url)` + `path.dirname`/`path.join` instead — Turbopack's special-casing only triggers on the literal `new URL(x, import.meta.url)` pattern, not an equivalent built from `node:url`/`node:path` primitives, so this resolves to the same correct absolute path at runtime without ever entering the bundler's asset-hashing path. Since `Font.register()`'s `src` field only accepts a `string` (path/URL), not a `Buffer`, there was no way to sidestep this by embedding the font bytes directly instead. + ## How this is consumed Not published to npm — installed as a git dependency: diff --git a/package.json b/package.json index 64895b2..b2b0c4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@einfach-produktiv/invoicing", - "version": "0.2.7", + "version": "0.2.8", "private": true, "description": "Shared invoice / correction-invoice (Stornorechnung, Gutschrift) PDF generation and VAT-breakdown math, consumed as a git dependency by both the einfach-produktiv frontend and the payload backend — not published to npm.", "type": "module", diff --git a/src/fonts.ts b/src/fonts.ts index 694e2da..db2c97f 100644 --- a/src/fonts.ts +++ b/src/fonts.ts @@ -1,4 +1,6 @@ import { Font } from "@react-pdf/renderer"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; // Registered once here (not inline in invoicePdf.tsx/correctionInvoicePdf.tsx) // and imported by both for its side effect — avoids double-registering the @@ -12,10 +14,27 @@ import { Font } from "@react-pdf/renderer"; // via Font.register. Liberation Sans is metrically identical to Helvetica/ // Arial (SIL Open Font License, see src/assets/fonts/LICENSE-OFL.txt), so // embedding it doesn't shift any existing layout. +// +// Path built via fileURLToPath+join, NOT `new URL('./file', import.meta.url)` +// — that literal pattern is exactly what Next.js's Turbopack/webpack bundler +// statically detects and rewrites into its own hashed static-asset system +// (`.next/server/assets/.ttf`). When this package is consumed as a +// node_modules git dependency inside a Next.js/Payload server build, that +// rewritten hash ended up not matching the file Turbopack actually emitted, +// so every PDF render at runtime failed with `ENOENT: .../LiberationSans- +// Regular..ttf` — reproduced even with a from-scratch `--no-cache` +// rebuild, so it wasn't a stale-cache issue, the asset-hashing itself was +// wrong for this consumption path. This file only runs server-side (a PDF +// renderer has no reason to ever reach a client bundle) and Turbopack does +// not apply the same special-cased URL rewriting to a plain +// fileURLToPath/path.join construction, so this sidesteps the bug entirely +// while still resolving to the correct absolute path at runtime. +const fontsDir = join(dirname(fileURLToPath(import.meta.url)), "assets/fonts"); + Font.register({ family: "Liberation Sans", fonts: [ - { src: new URL("./assets/fonts/LiberationSans-Regular.ttf", import.meta.url).pathname, fontWeight: "normal" }, - { src: new URL("./assets/fonts/LiberationSans-Bold.ttf", import.meta.url).pathname, fontWeight: "bold" }, + { src: join(fontsDir, "LiberationSans-Regular.ttf"), fontWeight: "normal" }, + { src: join(fontsDir, "LiberationSans-Bold.ttf"), fontWeight: "bold" }, ], });