diff --git a/docs/DEPS.md b/docs/DEPS.md index a229888ee1..2d60f7f960 100644 --- a/docs/DEPS.md +++ b/docs/DEPS.md @@ -2,8 +2,8 @@ Living reference of every third-party dependency in PortOS, why it's kept, and what the current verdict is. Updated by `/do:depfree` runs. -**Last audited:** 2026-08-04 (scoped audit of the `keyv`/`cacheable` supply-chain compromise); prior follow-up 2026-07-14 (issue #2547), prior full audit 2026-04-28 (default mode), tables corrected 2026-07-01 during a docs audit. -**Verdict:** All dependencies justified. The 2026-08-04 audit replaced the entire `eslint` stack with `@biomejs/biome`, dropping 110 net client packages including the `file-entry-cache → flat-cache → keyv` chain named in the August 2026 Shai-Hulud npm compromise (PortOS held safe versions throughout — see the detailed finding below). The same pass closed a latent hole where `ignore-scripts=true` was only active for repo-root installs, not for any workspace install or CI. Since the last full audit: `sax` was removed (replaced with an owned parser, issue #1824), `portos-ai-toolkit` was vendored in-tree (`server/lib/aiToolkit/`), and monolithic `googleapis` was replaced with scoped `@googleapis/*` packages. The 2026-07-14 follow-up bumped `kokoro-js` to its latest patch `1.2.1` (still on maintenance watch — no publish since 2025-05) and aligned the dual `pm2` pins (root + server both `7.0.4`). +**Last audited:** 2026-09-02 (scoped: `pdf-lib` → `@cantoo/pdf-lib`, issue #5672); prior 2026-08-04 (scoped audit of the `keyv`/`cacheable` supply-chain compromise); prior follow-up 2026-07-14 (issue #2547), prior full audit 2026-04-28 (default mode), tables corrected 2026-07-01 during a docs audit. +**Verdict:** All dependencies justified. The 2026-08-04 audit replaced the entire `eslint` stack with `@biomejs/biome`, dropping 110 net client packages including the `file-entry-cache → flat-cache → keyv` chain named in the August 2026 Shai-Hulud npm compromise (PortOS held safe versions throughout — see the detailed finding below). The same pass closed a latent hole where `ignore-scripts=true` was only active for repo-root installs, not for any workspace install or CI. Since the last full audit: `sax` was removed (replaced with an owned parser, issue #1824), `portos-ai-toolkit` was vendored in-tree (`server/lib/aiToolkit/`), and monolithic `googleapis` was replaced with scoped `@googleapis/*` packages. The 2026-07-14 follow-up bumped `kokoro-js` to its latest patch `1.2.1` (still on maintenance watch — no publish since 2025-05) and aligned the dual `pm2` pins (root + server both `7.0.4`). The 2026-09-02 follow-up replaced abandoned `pdf-lib` (no publish since 2022-05) with the maintained MIT fork `@cantoo/pdf-lib@2.9.1` — a same-public-API swap across the four export paths, done while `npm audit` was still clean rather than under advisory pressure. ## Audit Methodology @@ -22,6 +22,8 @@ Before removing a Tier 3 candidate, run a transitive-dep check (`npm ls `). | **Root deps** | | | | | | `pm2` | 1 | KEEP | top-level scripts | Process manager, foundational. Declared in root `dependencies` (the root manifest has no devDependencies). Pinned `7.0.4` (aligned with server pin) | | **Server deps** | | | | | +| `@cantoo/pdf-lib` | 2 | KEEP | PDF generation for the volume / comic / prose / legacy-archive exports | Maintained MIT fork of `pdf-lib`, which has had no publish since 2022-05. Same public API (`PDFDocument`/`rgb`/`StandardFonts`); the swap was 4 import lines. On maintenance watch — see the detailed finding | +| `pdf-lib` | — | REPLACED | PDF generation | 2026-09-02 → `@cantoo/pdf-lib` (issue #5672). Abandoned upstream: `1.17.1` is simultaneously the pinned and the latest published version, unchanged since 2022-05-12. Not deprecated and never flagged by `npm audit` — replaced ahead of an advisory, not in response to one | | `@novnc/novnc` | 1 | KEEP | PortDeck remote desktop viewer | Mature RFB/VNC protocol implementation; replacing it would require owning multiple security types and framebuffer encodings | | `@googleapis/calendar` | 1 | KEEP | Calendar integration | Scoped official Google SDK (replaced monolithic `googleapis`) | | `@googleapis/gmail` | 1 | KEEP | Messages/Gmail integration | Scoped official Google SDK | @@ -30,7 +32,6 @@ Before removing a Tier 3 candidate, run a transitive-dep check (`npm ls `). | `google-auth-library` | 1 | KEEP | Google OAuth | Pairs with `@googleapis/*` | | `kokoro-js` | 2 | KEEP | `server/services/voice/tts-kokoro.js` | Only pure-JS in-process TTS; replacement = Python subprocess + pooling | | `node-pty` | 1 | KEEP | shell/terminal services | Native PTY binding (N-API) | -| `pdf-lib` | 1 | KEEP | PDF generation/manipulation | | | `pg` | 1 | KEEP | Postgres access | Official `pg` driver | | `pm2` | 1 | KEEP | app lifecycle | Process manager. Pinned `7.0.4` (aligned with root pin) | | `sharp` | 1 | KEEP | image processing | Native, widely-audited | @@ -84,6 +85,17 @@ Before removing a Tier 3 candidate, run a transitive-dep check (`npm ls `). ## Detailed Findings — Tier 2/3 Audits +### `@cantoo/pdf-lib` — KEEP (Tier 2), replaced `pdf-lib` 2026-09-02 (issue #5672) + +- **Usage**: 4 import sites, all the same named import — `server/services/pipeline/volumePdf.js`, `comicPdf.js`, `proseExport.js`, and `server/services/legacyExport.js` (`import { PDFDocument, rgb, StandardFonts }`). ~40 call sites across them, all in the narrow high-level core: `PDFDocument.create`, `addPage`, `embedFont`, `drawText`, `drawImage`, `widthOfTextAtSize`, `save`, plus `rgb()` and 9 `StandardFonts` constants. No low-level `PDFDict`/`PDFRef` work, no form filling, no encryption, no incremental update. +- **Why the swap**: `pdf-lib@1.17.1` has had **no npm publish since 2022-05-12** — the pinned version and the latest published version are the same release. It is not deprecated and `npm audit` was clean, but it is a parser/serializer for a hostile binary format sitting on the export path for four user-facing pipelines. When a PDF advisory lands there is no upstream release to take. Migrating while audit is green is a planned change; migrating after an advisory is an emergency. +- **The fork**: `@cantoo/pdf-lib` (`github.com/cantoo-scribe/pdf-lib`), same MIT license, pinned `2.9.1`, published 2026-08. It preserves the `PDFDocument` / `rgb` / `StandardFonts` public API, so the migration was 4 import lines + 1 manifest line + the lockfile — no output, layout, font, or page-geometry change. +- **Transitive cost**: `server/package-lock.json` goes 380 → 385 packages (**+5 net**). In: the fork plus `color`, `color-string`, `is-arrayish`, `simple-swizzle`, `html-entities`, `node-html-better-parser`, and a top-level `pako`. Out: `pdf-lib` and its nested `pako` / `tslib` copies. `@pdf-lib/standard-fonts` and `@pdf-lib/upng` **stay** — the fork depends on them too — and so does the top-level `tslib`, which other packages pull. `npm audit` stays at 0 vulnerabilities. +- **Replacement complexity if owned in-tree instead**: Infeasible (300+ lines of font metrics, xref tables, and content-stream encoding) — which is why a maintained fork, not a rewrite, was the decision. +- **Regression cover**: two boundary tests assert real output bytes rather than helper behaviour, because a same-API fork can only regress below the helper layer — `proseExport.test.js` (`buildProsePdf` returns a `Uint8Array` starting `%PDF-` and ending `%%EOF`) and `comicPdf.test.js` (image XObject count scales with the number of embedded pages, catching a silently-dropped `drawImage` payload). +- **Grep caveat for the next audit**: `server/services/legacyExport.js` contains a byte sequence that makes `file(1)` classify it as `data`, so plain `grep -r` **silently skips it** — a repo-wide dependency sweep must use `grep -ra`. That is exactly how the fourth import site was missed when this migration was first scoped; it surfaced only as an `ERR_MODULE_NOT_FOUND` in the suite. +- **Re-audit trigger**: revisit if `@cantoo/pdf-lib` itself goes >12 months without a publish, or on any CVE against it. The fallback is the same shape as the swap in: another maintained fork, or upstream `pdf-lib` if it ever resumes releases. + ### `kokoro-js` — KEEP (Tier 2) - **Usage**: 1 dynamic import in `server/services/voice/tts-kokoro.js` (~80 LOC module). 3 call sites: `KokoroTTS.from_pretrained()`, `tts.generate(text, {voice, speed})`, `audio.toWav()`. diff --git a/server/lib/pdfImageEmbed.js b/server/lib/pdfImageEmbed.js index 1f359b5caa..6b153376e0 100644 --- a/server/lib/pdfImageEmbed.js +++ b/server/lib/pdfImageEmbed.js @@ -1,7 +1,7 @@ /** * PDF image-embed helpers shared between the issue-level comic PDF and the * volume-level trade-paperback PDF. Both assemblies read PNG/JPEG files - * from PATHS.images, embed them into a pdf-lib document, and fit each + * from PATHS.images, embed them into a @cantoo/pdf-lib document, and fit each * image to a printable page with a small white margin. * * Caller passes its own error codes / subject strings so a failure inside diff --git a/server/lib/proseExportSettings.js b/server/lib/proseExportSettings.js index 1a66ca88e8..3bec20dace 100644 --- a/server/lib/proseExportSettings.js +++ b/server/lib/proseExportSettings.js @@ -23,7 +23,7 @@ export const TRIM_SIZES = Object.freeze({ }); export const DEFAULT_TRIM_SIZE = 'us-trade'; -// Interior body fonts — the pdf-lib StandardFonts a print interior can use +// Interior body fonts — the @cantoo/pdf-lib StandardFonts a print interior can use // without embedding a font file. Times is the traditional book-interior serif. export const INTERIOR_FONTS = Object.freeze(['times', 'helvetica', 'courier']); export const DEFAULT_INTERIOR_FONT = 'times'; diff --git a/server/package-lock.json b/server/package-lock.json index 2cfd3fa6be..7155fa06d4 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,6 +8,7 @@ "name": "portos-server", "version": "1.7.1", "dependencies": { + "@cantoo/pdf-lib": "2.9.1", "@googleapis/calendar": "^16.0.0", "@googleapis/gmail": "^18.0.0", "@novnc/novnc": "1.7.0", @@ -16,7 +17,6 @@ "google-auth-library": "^11.0.2", "kokoro-js": "1.2.1", "node-pty": "1.2.0-beta.15", - "pdf-lib": "^1.17.1", "pg": "8.23.0", "playwright-core": "1.62.1", "pm2": "7.0.4", @@ -96,6 +96,24 @@ "node": ">=18" } }, + "node_modules/@cantoo/pdf-lib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.9.1.tgz", + "integrity": "sha512-wAKXXDjp5lO0wS6d+k0FRpmWsULb95WhHKYx2fonTbv4zIfuIdCBz2nyTVVDGJpNDLl6o4w9aSTxQisBvvd60A==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "color": "^4.2.3", + "html-entities": "^2.3.2", + "node-html-better-parser": ">=1.5.9", + "pako": "^2.2.0", + "tslib": ">=2" + }, + "peerDependencies": { + "html-entities": "^2.3.2" + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -1713,6 +1731,19 @@ "node": ">=8.10.0" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1731,6 +1762,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/commander": { "version": "2.15.1", "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", @@ -2678,6 +2719,22 @@ "node": ">= 0.4" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -2771,6 +2828,12 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3449,6 +3512,15 @@ "node": ">= 12" } }, + "node_modules/node-html-better-parser": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.9.tgz", + "integrity": "sha512-z1I5UINMezJXYL9cH3h0a9KBth2G978gSLlfkpQ+CQzzVHVQy9gpARgm9eDsz1O4gn1HtgUqjdAIYxKFZm6uHQ==", + "license": "MIT", + "dependencies": { + "html-entities": "^2.3.2" + } + }, "node_modules/node-pty": { "version": "1.2.0-beta.15", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz", @@ -3608,6 +3680,22 @@ "node": ">= 14" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3634,30 +3722,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pdf-lib": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", - "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", - "license": "MIT", - "dependencies": { - "@pdf-lib/standard-fonts": "^1.0.0", - "@pdf-lib/upng": "^1.0.1", - "pako": "^1.0.11", - "tslib": "^1.11.1" - } - }, - "node_modules/pdf-lib/node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, - "node_modules/pdf-lib/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, "node_modules/pg": { "version": "8.23.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", @@ -4422,6 +4486,15 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", diff --git a/server/package.json b/server/package.json index b001387fdb..5ef0c170df 100644 --- a/server/package.json +++ b/server/package.json @@ -21,6 +21,7 @@ "test:db:ci": "vitest run --config vitest.config.db.js --bail=1 --reporter=default --reporter=github-actions" }, "dependencies": { + "@cantoo/pdf-lib": "2.9.1", "@googleapis/calendar": "^16.0.0", "@googleapis/gmail": "^18.0.0", "@novnc/novnc": "1.7.0", @@ -29,7 +30,6 @@ "google-auth-library": "^11.0.2", "kokoro-js": "1.2.1", "node-pty": "1.2.0-beta.15", - "pdf-lib": "^1.17.1", "pg": "8.23.0", "playwright-core": "1.62.1", "pm2": "7.0.4", diff --git a/server/services/legacyExport.js b/server/services/legacyExport.js index c343e6e569..b6869df646 100644 --- a/server/services/legacyExport.js +++ b/server/services/legacyExport.js @@ -18,7 +18,7 @@ import { createHash } from 'crypto'; import { hostname } from 'os'; -import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; +import { PDFDocument, rgb, StandardFonts } from '@cantoo/pdf-lib'; import { createZip } from '../lib/zipWriter.js'; import { getCurrentVersion } from './updateChecker.js'; import { exportDigitalTwin } from './digital-twin-export.js'; @@ -529,7 +529,7 @@ export function buildManifest(files, { sections, portosVersion, generatedAt, pdf // === PDF rendering (Phase 2) === // A rendered, human-readable portrait of the same per-section Markdown the -// bundle already produces. Uses pdf-lib (already a dependency, shared with +// bundle already produces. Uses @cantoo/pdf-lib (already a dependency, shared with // `pipeline/comicPdf.js`) — Helvetica family, basic word-wrap, heading sizing. // No images, no external fonts: the Markdown bundle stays the primary artifact; // the PDF is a convenience for reading/printing offline. @@ -543,7 +543,7 @@ const PDF_TEXT_WIDTH = PDF_PAGE.width - PDF_MARGIN * 2; const PDF_HEADING_SIZE = { 1: 22, 2: 16, 3: 13 }; const PDF_BODY_SIZE = 11; -// Strip the inline Markdown emphasis/link syntax pdf-lib can't render so it +// Strip the inline Markdown emphasis/link syntax @cantoo/pdf-lib can't render so it // doesn't print literal `**`/`*`/`[text](url)` markers. Pure. function stripInlineMarkdown(text) { return String(text) @@ -553,12 +553,12 @@ function stripInlineMarkdown(text) { .replace(/`([^`]+)`/g, '$1'); // `code` } -// WinAnsi (pdf-lib's StandardFont encoding) can't encode arbitrary Unicode — +// WinAnsi (@cantoo/pdf-lib's StandardFont encoding) can't encode arbitrary Unicode — // an unencodable glyph throws at draw time. Map the few non-ASCII characters // our Markdown builders actually emit (VO₂, ⚠️, smart quotes) to safe ASCII, // then drop anything still outside the encodable range. Pure. // -// pdf-lib's WinAnsi encoder also throws on control bytes that ARE ≤ 0xFF — +// @cantoo/pdf-lib's WinAnsi encoder also throws on control bytes that ARE ≤ 0xFF — // the C0 controls (0x00–0x1F), DEL (0x7F), and the undefined C1 range // (0x80–0x9F) — so a stray control char pasted into free-text identity content // (brain notes, journals, autobiography) must be stripped BEFORE it reaches @@ -617,7 +617,7 @@ function wrapLine(text, font, size, maxWidth) { /** * Render the per-section Markdown into a single PDF. Pure-ish (async only for - * pdf-lib's font embedding) — takes the same `{ name, data }` content files + * @cantoo/pdf-lib's font embedding) — takes the same `{ name, data }` content files * `buildBundleFiles` produces and walks the `.md` ones in bundle order. * Returns the PDF bytes (Uint8Array). `meta` stamps the title page. * diff --git a/server/services/legacyExport.test.js b/server/services/legacyExport.test.js index 7d191c72d2..bf14edebf4 100644 --- a/server/services/legacyExport.test.js +++ b/server/services/legacyExport.test.js @@ -325,7 +325,7 @@ describe('buildLegacyPdf', () => { it('does not throw on control bytes WinAnsi cannot encode (NUL, VT, ESC, DEL, C1)', async () => { // Stray control chars pasted into free-text identity content (a note, a - // journal) must not crash the whole PDF render. pdf-lib's WinAnsi encoder + // journal) must not crash the whole PDF render. @cantoo/pdf-lib's WinAnsi encoder // throws on 0x00–0x1F / 0x7F / 0x80–0x9F even though they're ≤ 0xFF. const dirty = 'before\x00\x0B\x1B\x7F\x9Dafter and a\ttab'; const contentFiles = [{ name: 'brain/journals.md', data: Buffer.from(`# Journal\n\n${dirty}\n`) }]; diff --git a/server/services/pipeline/comicPdf.js b/server/services/pipeline/comicPdf.js index d155239c0d..b7c11d122e 100644 --- a/server/services/pipeline/comicPdf.js +++ b/server/services/pipeline/comicPdf.js @@ -7,7 +7,7 @@ * route surfaces "X of Y rendered" to the user before download. */ -import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; +import { PDFDocument, rgb, StandardFonts } from '@cantoo/pdf-lib'; import { slugifyForFilename } from '../../lib/civitai.js'; import { getIssue } from './issues.js'; import { getSeries } from './series.js'; @@ -62,7 +62,7 @@ export async function buildComicPdf(issueId, opts = {}) { throw makeErr('Issue has no rendered pages or cover yet', ERR_NO_RENDERED_PAGES); } - // Read all files concurrently — disk I/O parallelizes cleanly; pdf-lib's + // Read all files concurrently — disk I/O parallelizes cleanly; @cantoo/pdf-lib's // embed is CPU-bound on the event loop so embedding stays sequential below. // One bad page must not fail the whole download, so errors are captured // alongside the bytes and logged when the loop reaches them. diff --git a/server/services/pipeline/comicPdf.test.js b/server/services/pipeline/comicPdf.test.js index 25d4216179..8c706f5d62 100644 --- a/server/services/pipeline/comicPdf.test.js +++ b/server/services/pipeline/comicPdf.test.js @@ -14,7 +14,7 @@ vi.mock('../../lib/fileUtils.js', async () => { }; }); -// 1×1 RGBA PNG — smallest valid PNG pdf-lib will accept via embedPng. +// 1×1 RGBA PNG — smallest valid PNG @cantoo/pdf-lib will accept via embedPng. const TINY_PNG = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, @@ -174,6 +174,42 @@ describe('buildComicPdf — happy path', () => { }); }); +describe('buildComicPdf — embedded-image contract', () => { + // `drawImage` is the only API in this pipeline that carries binary payloads + // into the PDF, so it is the one path a same-API library swap (issue #5672, + // `pdf-lib` → the maintained `@cantoo/pdf-lib` fork) can regress while every + // page-count assertion above still passes. A fork that silently dropped the + // image XObject would emit a structurally valid PDF of near-constant size. + const coverOnlyIssue = () => ({ + ...structuredClone(mockIssue), + stages: { comicPages: { cover: { filename: 'cover.png' }, pages: [] } }, + }); + + const imageXObjects = (bytes) => + (Buffer.from(bytes).toString('latin1').match(/\/Subtype\s*\/Image/g) || []).length; + + it('scales embedded image XObjects with the page count and emits a byte-valid PDF', async () => { + getIssueMock.mockResolvedValueOnce(coverOnlyIssue()); + const one = await buildComicPdf('iss-test', { includeColophon: false }); + const three = await buildComicPdf('iss-test', { includeColophon: false }); + + expect(one.pageCount).toBe(1); + expect(three.pageCount).toBe(3); + // Counting the XObjects (not the byte length) is what separates "the image + // data made it in" from "three blank pages were emitted". Each RGBA PNG + // costs more than one image object (the alpha channel rides along as an + // SMask), so assert the ratio rather than a hard-coded object count. + const perPage = imageXObjects(one.bytes); + expect(perPage).toBeGreaterThan(0); + expect(imageXObjects(three.bytes)).toBe(perPage * 3); + expect(three.bytes.length).toBeGreaterThan(one.bytes.length); + // Container type: a fork returning a Buffer/ArrayBuffer here would break + // every streaming caller without failing a page-count assertion. + expect(three.bytes).toBeInstanceOf(Uint8Array); + expect(Buffer.from(three.bytes).subarray(-1024).toString('latin1')).toContain('%%EOF'); + }); +}); + describe('buildComicPdf — rejection paths', () => { it('throws ERR_NO_RENDERED_PAGES when neither cover nor pages have filenames', async () => { getIssueMock.mockResolvedValueOnce({ diff --git a/server/services/pipeline/proseExport.js b/server/services/pipeline/proseExport.js index 3d0faf29a2..93a70cbb3d 100644 --- a/server/services/pipeline/proseExport.js +++ b/server/services/pipeline/proseExport.js @@ -15,7 +15,7 @@ * plus the cover image from the existing cover stage. Packaged in-repo with * `server/lib/zipWriter.js` (no epub dependency, per the dependency policy). * 3. Print-interior PDF — a trade-format interior (trim size, margins, - * running heads, title page, chapter openers) built on the same `pdf-lib` + * running heads, title page, chapter openers) built on the same `@cantoo/pdf-lib` * plumbing as `volumePdf.js` / `comicPdf.js`. * * All three are assembled on demand and streamed straight to the response — no @@ -25,7 +25,7 @@ * via `__testing` so the export shape is unit-tested without touching disk. */ -import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; +import { PDFDocument, rgb, StandardFonts } from '@cantoo/pdf-lib'; import { createZip } from '../../lib/zipWriter.js'; import { slugifyForFilename } from '../../lib/civitai.js'; import { readImageFromMedia, detectImageKind } from '../../lib/pdfImageEmbed.js'; @@ -317,7 +317,7 @@ export async function buildEpub(seriesId) { // 3) Print-interior PDF // --------------------------------------------------------------------------- -// Greedy word-wrap a paragraph to `maxWidth` at `size` using pdf-lib font +// Greedy word-wrap a paragraph to `maxWidth` at `size` using @cantoo/pdf-lib font // metrics. Returns an array of lines. Pure over the font (exported via // __testing with a stub `widthOfTextAtSize`). export function wrapText(text, font, size, maxWidth) { @@ -347,7 +347,7 @@ const PDF_FONT_MAP = { * Build the print-interior PDF as a Uint8Array. Trade-format interior: title * page → per-volume/-chapter body with running heads (author verso, title * recto) and page numbers, wrapping the prose to the text block. Reuses the - * `pdf-lib` plumbing from volumePdf.js. Returns `{ bytes, pageCount, filename }`. + * `@cantoo/pdf-lib` plumbing from volumePdf.js. Returns `{ bytes, pageCount, filename }`. */ export async function buildProsePdf(seriesId) { const { series, volumes } = await gatherProse(seriesId); diff --git a/server/services/pipeline/proseExport.test.js b/server/services/pipeline/proseExport.test.js index 35b226eba0..2e8310ed01 100644 --- a/server/services/pipeline/proseExport.test.js +++ b/server/services/pipeline/proseExport.test.js @@ -4,12 +4,27 @@ * word-wrap, and a full ePub zip round-trip through the parser. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; + +// Store mocks for the `buildProsePdf` boundary test at the bottom of this file. +// `vi.mock` is hoisted above every import, so each factory is self-contained — +// it must not close over a `const` declared later in the file. +vi.mock('./series.js', () => ({ + getSeries: async () => ({ id: 's1', name: 'Example Series', logline: 'A subtitle.', author: 'A. Author' }), +})); +vi.mock('./seasons.js', () => ({ listSeasons: async () => [] })); +vi.mock('./arcPlanner.js', () => ({ + collectManuscriptSections: async () => [ + { issueId: 'i1', seasonId: null, number: 1, title: 'One', stageId: 'prose', content: 'Body one.\n\nSecond paragraph.' }, + { issueId: 'i2', seasonId: null, number: 2, title: 'Two', stageId: 'prose', content: 'Body two.' }, + ], +})); import { Readable, Writable } from 'stream'; import { __testing } from './proseExport.js'; import { createZip } from '../../lib/zipWriter.js'; import { parseZip } from '../../lib/zipStream.js'; import { + buildProsePdf, buildOpf as buildOpfExport, buildNavXhtml as buildNavXhtmlExport, buildChapterXhtml as buildChapterXhtmlExport, @@ -219,3 +234,27 @@ describe('ePub OCF zip round-trip', () => { expect(byPath['OEBPS/vol1.xhtml']).toContain('

Body one.

'); }); }); + +// --------------------------------------------------------------------------- +// PDF boundary test. `buildProsePdf` is the only path that leaves JS and enters +// the PDF library, so it is the one place a same-API library swap (issue #5672, +// `pdf-lib` → the maintained `@cantoo/pdf-lib` fork) can regress invisibly: +// every helper above would still pass while `save()` returned a different +// container type or a structurally invalid document. Assert the real bytes. +// --------------------------------------------------------------------------- + +describe('buildProsePdf — byte-level output contract', () => { + it('returns a byte-valid PDF as a Uint8Array', async () => { + const { bytes, pageCount, filename } = await buildProsePdf('s1'); + // Container type: a fork returning a Buffer/ArrayBuffer/string here would + // break every streaming caller without failing any helper test. + expect(bytes).toBeInstanceOf(Uint8Array); + expect(Buffer.from(bytes).subarray(0, 5).toString('latin1')).toBe('%PDF-'); + expect(Buffer.from(bytes).subarray(-1024).toString('latin1')).toContain('%%EOF'); + // Title page + one opener page per chapter. + expect(pageCount).toBe(3); + // Comfortably above an empty-document floor (a blank one-page PDF is <1KB). + expect(bytes.length).toBeGreaterThan(2000); + expect(filename).toBe('example-series-interior.pdf'); + }); +}); diff --git a/server/services/pipeline/volumePdf.js b/server/services/pipeline/volumePdf.js index b04fab0916..942008992d 100644 --- a/server/services/pipeline/volumePdf.js +++ b/server/services/pipeline/volumePdf.js @@ -15,7 +15,7 @@ * so the route surfaces a 409 with ERR_NO_VOLUME_COVER instead. */ -import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; +import { PDFDocument, rgb, StandardFonts } from '@cantoo/pdf-lib'; import { slugifyForFilename } from '../../lib/civitai.js'; import { pickRenderedFilename } from '../../lib/renderSlot.js'; import { readImageFromMedia, embedImageBytes, fitImage } from '../../lib/pdfImageEmbed.js'; @@ -105,7 +105,7 @@ export async function buildVolumePdf(seriesId, seasonId, opts = {}) { ); } - // Parallel disk reads; embed sequentially because pdf-lib is single-threaded. + // Parallel disk reads; embed sequentially because @cantoo/pdf-lib is single-threaded. // A failed read for one image must not fail the whole download. const loaded = await Promise.all(targets.map(({ filename, kind }) => readImageFromMedia(filename, READ_OPTS)