From 22d9683eade717789c316ee6ff526300cea7ed47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:09:14 +0000 Subject: [PATCH 1/7] Keep one PDF engine: run the JavaScript PDF fallback on the app's own pdfjs-dist pdf-parse 2.4.5 pinned its own pdfjs-dist 5.4 and canvas, so two PDF engines shipped. The fallback now reads PDFs with the pdfjs-dist 6.4 copy the viewer and roster import already use, following pdf-parse's text and image defaults. On every readable PDF in the repo the text is byte-identical and decoded image pixels are identical. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QevAacecJBjQqMcn5LoexE --- .github/workflows/sast.yml | 2 +- deno.lock | 1 - ...work-dependency-modernization-checklist.md | 2 +- docs/process-hardening.md | 2 +- package-lock.json | 217 --------------- package.json | 1 - src/lib/extractors/document.ts | 10 +- src/lib/extractors/pdfjs-fallback.ts | 254 ++++++++++++++++++ tests/pdf-extraction-budget.test.ts | 20 +- tests/pdf-source-provenance.test.ts | 4 +- tests/text-source-provenance.test.ts | 4 +- 11 files changed, 275 insertions(+), 242 deletions(-) create mode 100644 src/lib/extractors/pdfjs-fallback.ts diff --git a/.github/workflows/sast.yml b/.github/workflows/sast.yml index 6de8101aab..119138c645 100644 --- a/.github/workflows/sast.yml +++ b/.github/workflows/sast.yml @@ -18,7 +18,7 @@ jobs: # Advisory static application security testing. Semgrep is used instead of CodeQL # because this repository is private without GitHub Advanced Security, so the # code-scanning SARIF upload CodeQL relies on is unavailable. The document parsers - # (pdf-parse, pdfjs-dist, mammoth, exceljs, jszip) and the answer/ingestion + # (pdfjs-dist, mammoth, exceljs, jszip) and the answer/ingestion # paths process untrusted uploaded documents, so ERROR-severity findings remain # visible without making mutable registry rules a required merge dependency. # Runs OSS-engine only — no Semgrep account or token required. diff --git a/deno.lock b/deno.lock index ed14a2cbb4..5507fd48bd 100644 --- a/deno.lock +++ b/deno.lock @@ -312,7 +312,6 @@ "npm:mammoth@^1.13.0", "npm:next@16.4.0", "npm:openai@^7.30.1", - "npm:pdf-parse@^2.4.5", "npm:pdfjs-dist@^6.4.299", "npm:pdfkit@~0.20.2", "npm:playwright-core@1.63.0", diff --git a/docs/framework-dependency-modernization-checklist.md b/docs/framework-dependency-modernization-checklist.md index df13b7bc80..88cc50a5fb 100644 --- a/docs/framework-dependency-modernization-checklist.md +++ b/docs/framework-dependency-modernization-checklist.md @@ -222,7 +222,7 @@ responseHeaders)` and copy all supplied headers onto every rebuilt Testing Library, Playwright, and axe. Respect peer ceilings. - [ ] Backend SDK group: Supabase SSR/client/realtime, OpenAI, and Zod. Run auth, validation, rate-limit, privacy, and public-error contract tests. -- [ ] Document group: pdf-parse, pdfjs-dist, PDFKit, Mammoth, ExcelJS, and JSZip. +- [ ] Document group: pdfjs-dist, PDFKit, Mammoth, ExcelJS, and JSZip. Run adversarial resource-budget fixtures before normal happy paths. - [ ] UI group: Tailwind/PostCSS and lucide-react. Run generated CSS, forced colors, reduced motion, icon-scale, and representative browser checks. diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 0de7711c3f..788a439dd6 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -665,7 +665,7 @@ passes `p_worker_id`. Ordered apply steps, R17 manual `CONCURRENTLY` index, and ## Repository hygiene + production surface pass (2026-07-06) - **Mockup routes gated out of production:** `/mockups/*` (27 design-exploration pages) previously shipped in production builds, hidden only by a `robots.ts` disallow. `src/app/mockups/layout.tsx` now calls `notFound()` unless `mockupsEnabled()` (`src/lib/env.ts`) — always on in dev/test, production requires explicit `NEXT_PUBLIC_MOCKUPS_ENABLED=true`. Verified end-to-end: production build serves HTTP 404 on mockup routes and 200 on real routes; `tests/env-mockups-gate.test.ts` guards the flag logic. -- **Dependency vulnerability gate in CI:** the `verify` job now runs `npm audit --omit=dev --audit-level=high` after runtime alignment. Rationale: the document parsers (`pdf-parse`, `pdfjs-dist`, `mammoth`, `exceljs`, `jszip`) process untrusted uploads, so high/critical advisories in the production tree should block merges rather than wait for the weekly Dependabot pass. +- **Dependency vulnerability gate in CI:** the `verify` job now runs `npm audit --omit=dev --audit-level=high` after runtime alignment. Rationale: the document parsers (`pdfjs-dist`, `mammoth`, `exceljs`, `jszip`) process untrusted uploads, so high/critical advisories in the production tree should block merges rather than wait for the weekly Dependabot pass. - **Scratch artifacts untracked:** 24 committed `.tmp-visual/` files (visual-QA capture scripts + PNGs) removed from tracking; `.tmp-visual/` added to `.gitignore` (it was already Prettier-ignored). - **Docs archived:** superseded/completed planning docs moved to `docs/archive/` — root `COLOR_REDESIGN_PLAN.md` (superseded), `TOOLS_CONTEXT_FOR_NEW_CHAT.md` + `design-qa.md` (chat handoffs referencing machine-local paths), the completed `search-rag-phase-0…5.5b` + pre-phase-2 series, and the completed `clinical-chat-ui-*` phase docs. Living docs (`search-rag-master-plan.md`, `search-rag-master-context.md`, `clinical-chat-ui-component-map.md`) stay in `docs/`; no inbound references were broken (verified by repo-wide grep) and relative links inside moved files were retargeted. - **Playwright port-finder IPv6 fix:** superseded — an equivalent fix landed on `main` first (`71e0ab0`, which also adds a Chromium executable override); `main`'s version is kept in the merge. diff --git a/package-lock.json b/package-lock.json index 54f0da63aa..3ac93a7815 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "mammoth": "^1.13.0", "next": "16.4.0", "openai": "^7.30.1", - "pdf-parse": "^2.4.5", "pdfjs-dist": "^6.4.299", "pdfkit": "^0.20.2", "postcss": "^8.5.18", @@ -2137,174 +2136,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/canvas": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", - "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", - "license": "MIT", - "workspaces": [ - "e2e/*" - ], - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.80", - "@napi-rs/canvas-darwin-arm64": "0.1.80", - "@napi-rs/canvas-darwin-x64": "0.1.80", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", - "@napi-rs/canvas-linux-arm64-musl": "0.1.80", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", - "@napi-rs/canvas-linux-x64-gnu": "0.1.80", - "@napi-rs/canvas-linux-x64-musl": "0.1.80", - "@napi-rs/canvas-win32-x64-msvc": "0.1.80" - } - }, - "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", - "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", - "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", - "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", - "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", - "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", - "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", - "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", - "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", - "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@napi-rs/canvas-win32-arm64-msvc": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.11.tgz", @@ -2325,22 +2156,6 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", - "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -11275,38 +11090,6 @@ "node": "20 || >=22" } }, - "node_modules/pdf-parse": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", - "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", - "license": "Apache-2.0", - "dependencies": { - "@napi-rs/canvas": "0.1.80", - "pdfjs-dist": "5.4.296" - }, - "bin": { - "pdf-parse": "bin/cli.mjs" - }, - "engines": { - "node": ">=20.16.0 <21 || >=22.3.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/mehmet-kozan" - } - }, - "node_modules/pdf-parse/node_modules/pdfjs-dist": { - "version": "5.4.296", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", - "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.16.0 || >=22.3.0" - }, - "optionalDependencies": { - "@napi-rs/canvas": "^0.1.80" - } - }, "node_modules/pdfjs-dist": { "version": "6.4.299", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz", diff --git a/package.json b/package.json index b5514983bc..f1a776701d 100644 --- a/package.json +++ b/package.json @@ -353,7 +353,6 @@ "mammoth": "^1.13.0", "next": "16.4.0", "openai": "^7.30.1", - "pdf-parse": "^2.4.5", "pdfjs-dist": "^6.4.299", "pdfkit": "^0.20.2", "postcss": "^8.5.18", diff --git a/src/lib/extractors/document.ts b/src/lib/extractors/document.ts index 96eb92fc22..d8b8649689 100644 --- a/src/lib/extractors/document.ts +++ b/src/lib/extractors/document.ts @@ -26,7 +26,6 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" import { tmpdir } from "node:os"; import path from "node:path"; import mammoth from "mammoth"; -import { PDFParse } from "pdf-parse"; import JSZip from "jszip"; import { z } from "zod"; import type { ExtractedDocument, ExtractedPage } from "@/lib/types"; @@ -47,6 +46,7 @@ import { type PdfExtractionBudget, } from "@/lib/extractors/pdf-extraction-budget"; import { resolvePythonBin } from "@/lib/python-bin"; +import { PdfjsFallbackParser } from "@/lib/extractors/pdfjs-fallback"; const extractedPageSchema = z.object({ pageNumber: z.number().int().positive(), @@ -448,11 +448,11 @@ export async function extractPdf( // Fallback for developer machines without PyMuPDF/pytesseract. It still // indexes text PDFs, but scanned PDFs and image extraction need the Python // helper dependencies listed in worker/python/requirements.txt. - const textParser = new PDFParse({ + const textParser = new PdfjsFallbackParser({ data: buffer, maxImageSize: limits.maxRenderPixels, }); - const imageParser = new PDFParse({ + const imageParser = new PdfjsFallbackParser({ data: buffer, maxImageSize: limits.maxRenderPixels, // pdf.js only raises its pre-decode maxImageSize rejection when parse @@ -480,12 +480,10 @@ export async function extractPdf( // Extract one page at a time so aggregate limits can stop subsequent decoding, and // request only binary data to avoid holding a duplicate base64 representation. for (const rawPage of rawPages) { - let imageResult: Awaited>; + let imageResult: Awaited>; try { imageResult = await imageParser.getImage({ partial: [rawPage.pageNumber], - imageBuffer: true, - imageDataUrl: false, imageThreshold: 20, }); } catch (imageError) { diff --git a/src/lib/extractors/pdfjs-fallback.ts b/src/lib/extractors/pdfjs-fallback.ts new file mode 100644 index 0000000000..3257c95ec7 --- /dev/null +++ b/src/lib/extractors/pdfjs-fallback.ts @@ -0,0 +1,254 @@ +// JavaScript PDF fallback used when the Python PDF helper is unavailable (developer machines +// without PyMuPDF). It reads the PDF with the same pdfjs-dist copy the app already ships for the +// viewer and the roster import, so only one PDF engine is installed. Text joining and image +// decoding follow the pdf-parse 2.4 defaults this replaced, so fallback output is unchanged. + +type PdfjsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs"); +type PdfLoadingTask = ReturnType; +type PdfDocument = Awaited; +type PdfPage = Awaited>; + +type CanvasAndContext = { + canvas: { toBuffer?: (mime: string) => Buffer }; + context: { + createImageData(width: number, height: number): { data: Uint8ClampedArray }; + putImageData(data: unknown, x: number, y: number): void; + drawImage(image: unknown, x: number, y: number): void; + getImageData(x: number, y: number, width: number, height: number): { data: Uint8ClampedArray }; + }; +}; +type CanvasFactory = { create(width: number, height: number): CanvasAndContext }; + +type EmbeddedImage = { width: number; height: number; kind: number | null | undefined; data: Uint8Array }; + +export type PdfjsFallbackOptions = { + data: Uint8Array | Buffer; + /** Largest decoded image, in pixels, pdf.js may allocate. */ + maxImageSize?: number; + /** Raise recoverable parse errors instead of skipping them. pdf.js only enforces maxImageSize when set. */ + stopAtErrors?: boolean; +}; + +export type PdfjsTextResult = { pages: { num: number; text: string }[]; text: string; total: number }; + +export type PdfjsImageResult = { + pages: { pageNumber: number; images: { data: Uint8Array; name: string; width: number; height: number }[] }[]; + total: number; +}; + +const LINE_THRESHOLD = 4.6; +const CELL_THRESHOLD = 7; +const CELL_SEPARATOR = "\t"; + +let pdfjsModule: Promise | undefined; + +function loadPdfjs() { + pdfjsModule ??= import("pdfjs-dist/legacy/build/pdf.mjs"); + return pdfjsModule; +} + +export class PdfjsFallbackParser { + private readonly options: PdfjsFallbackOptions; + private task: PdfLoadingTask | undefined; + private doc: PdfDocument | undefined; + + constructor(options: PdfjsFallbackOptions) { + this.options = options; + } + + private async load(): Promise<{ pdfjs: PdfjsModule; doc: PdfDocument }> { + const pdfjs = await loadPdfjs(); + if (!this.doc) { + const data = this.options.data; + this.task = pdfjs.getDocument({ + data: Buffer.isBuffer(data) ? new Uint8Array(data) : data, + maxImageSize: this.options.maxImageSize, + stopAtErrors: this.options.stopAtErrors, + verbosity: pdfjs.VerbosityLevel.ERRORS, + }); + this.doc = await this.task.promise; + } + return { pdfjs, doc: this.doc }; + } + + async destroy() { + const task = this.task; + this.task = undefined; + this.doc = undefined; + if (task) await task.destroy(); + } + + async getText(): Promise { + const { doc } = await this.load(); + const result: PdfjsTextResult = { pages: [], text: "", total: doc.numPages }; + for (let num = 1; num <= doc.numPages; num += 1) { + const page = await doc.getPage(num); + result.pages.push({ num, text: await pageText(page) }); + page.cleanup(); + } + for (const page of result.pages) { + result.text += `${page.text}\n-- ${page.num} of ${result.total} --\n\n`; + } + return result; + } + + /** Embedded images on the requested pages, as PNG bytes, skipping images at or under `imageThreshold` px. */ + async getImage(params: { partial: number[]; imageThreshold: number }): Promise { + const { pdfjs, doc } = await this.load(); + const canvasFactory = (doc as unknown as { canvasFactory: CanvasFactory }).canvasFactory; + const result: PdfjsImageResult = { pages: [], total: doc.numPages }; + for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber += 1) { + if (!params.partial.includes(pageNumber)) continue; + const page = await doc.getPage(pageNumber); + const ops = await page.getOperatorList(); + const pageImages: PdfjsImageResult["pages"][number] = { pageNumber, images: [] }; + result.pages.push(pageImages); + for (let index = 0; index < ops.fnArray.length; index += 1) { + const fn = ops.fnArray[index]; + if (fn !== pdfjs.OPS.paintInlineImageXObject && fn !== pdfjs.OPS.paintImageXObject) continue; + const name = ops.argsArray[index][0] as string; + const objects = page.commonObjs.has(name) ? page.commonObjs : page.objs; + const { width, height, kind, data } = await resolveEmbeddedImage(objects, name, canvasFactory); + if (params.imageThreshold >= width || params.imageThreshold >= height) continue; + const { canvas, context } = canvasFactory.create(width, height); + const imageData = context.createImageData(width, height); + if (kind === pdfjs.ImageKind.RGBA_32BPP) { + imageData.data.set(data); + } else { + convertToRgba(pdfjs, { src: data, dest: new Uint32Array(imageData.data.buffer), width, height, kind }); + } + context.putImageData(imageData, 0, 0); + const png = canvas.toBuffer?.("image/png"); + pageImages.images.push({ data: png ? new Uint8Array(png) : new Uint8Array(), name, width, height }); + } + } + return result; + } +} + +async function pageText(page: PdfPage) { + const viewport = page.getViewport({ scale: 1 }); + const textContent = await page.getTextContent({ includeMarkedContent: false, disableNormalization: false }); + const parts: string[] = []; + let lastX: number | undefined; + let lastY: number | undefined; + let lineHeight = 0; + for (const item of textContent.items) { + if (!("str" in item)) continue; + let str = item.str; + const [x, y] = viewport.convertToViewportPoint(item.transform[4], item.transform[5]); + if (lastY !== undefined && Math.abs(lastY - y) > LINE_THRESHOLD) { + const lastPart = parts.at(-1); + const startsNewLine = str.startsWith("\n") || (str.trim() === "" && item.hasEOL); + if (lastPart?.endsWith("\n") === false && !startsNewLine && Math.abs(lastY - y) - 1 > lineHeight) { + parts.push("\n"); + lineHeight = 0; + } + } + if (lastY !== undefined && Math.abs(lastY - y) < LINE_THRESHOLD) { + if (lastX !== undefined && Math.abs(lastX - x) > CELL_THRESHOLD) str = `${CELL_SEPARATOR}${str}`; + } + parts.push(str); + lastX = x + item.width; + lastY = y; + lineHeight = Math.max(lineHeight, item.height); + if (item.hasEOL) parts.push("\n"); + if (item.hasEOL || str.endsWith("\n")) lineHeight = 0; + } + return parts.join(""); +} + +type PdfObjects = { get(name: string, callback: (data: unknown) => void): unknown }; + +function resolveEmbeddedImage(objects: PdfObjects, name: string, canvasFactory: CanvasFactory): Promise { + return new Promise((resolve, reject) => { + objects.get(name, (raw) => { + const image = raw as { + width: number; + height: number; + kind?: number | null; + data?: ArrayBufferView; + bitmap?: { width: number; height: number }; + } | null; + if (!image) { + reject(new Error(`Image object ${name} not found`)); + return; + } + let data: Uint8Array | undefined; + if (image.data instanceof Uint8Array) { + data = image.data; + } else if (image.data instanceof Uint8ClampedArray) { + data = new Uint8Array(image.data); + } else if (image.bitmap) { + const { context } = canvasFactory.create(image.bitmap.width, image.bitmap.height); + context.drawImage(image.bitmap, 0, 0); + data = new Uint8Array(context.getImageData(0, 0, image.bitmap.width, image.bitmap.height).data.buffer); + } else if (image.data && ArrayBuffer.isView(image.data)) { + data = new Uint8Array(image.data.buffer, image.data.byteOffset, image.data.byteLength); + } + if (!data) { + reject( + new Error( + `Image object ${name}: data field is empty or invalid. Available fields: ${Object.keys(image).join(", ")}`, + ), + ); + return; + } + if (data.length === 0) { + reject(new Error(`Image object ${name}: data buffer is empty (length: 0)`)); + return; + } + resolve({ width: image.width, height: image.height, kind: image.kind, data }); + }); + }); +} + +function convertToRgba( + pdfjs: PdfjsModule, + { + src, + dest, + width, + height, + kind, + }: { src: Uint8Array; dest: Uint32Array; width: number; height: number; kind: number | null | undefined }, +) { + const pixels = width * height; + const fromRgb = () => { + for (let i = 0, j = 0; i < src.length; i += 3, j += 1) { + dest[j] = (255 << 24) | (src[i + 2]! << 16) | (src[i + 1]! << 8) | src[i]!; + } + }; + if (kind === pdfjs.ImageKind.RGB_24BPP) { + fromRgb(); + } else if (kind === pdfjs.ImageKind.GRAYSCALE_1BPP) { + let pixel = 0; + for (const byte of src) { + for (let bit = 7; bit >= 0; bit -= 1) { + if (pixel >= pixels) break; + const gray = ((byte >> bit) & 1) === 1 ? 255 : 0; + dest[pixel++] = (255 << 24) | (gray << 16) | (gray << 8) | gray; + } + } + } else if (kind === undefined || kind === null) { + const bytesPerPixel = src.length / pixels; + if (Math.abs(bytesPerPixel - 3) < 0.1) { + fromRgb(); + } else if (Math.abs(bytesPerPixel - 4) < 0.1) { + for (let i = 0, j = 0; i < src.length; i += 4, j += 1) { + dest[j] = (src[i + 3]! << 24) | (src[i + 2]! << 16) | (src[i + 1]! << 8) | src[i]!; + } + } else if (Math.abs(bytesPerPixel - 1) < 0.1) { + for (let i = 0; i < src.length; i += 1) { + const gray = src[i]!; + dest[i] = (255 << 24) | (gray << 16) | (gray << 8) | gray; + } + } else { + throw new Error( + `convertToRGBA: Cannot infer image format. kind: ${kind}, bytesPerPixel: ${bytesPerPixel}, width: ${width}, height: ${height}, dataLength: ${src.length}`, + ); + } + } else { + throw new Error(`convertToRGBA: Unsupported image kind: ${kind}.`); + } +} diff --git a/tests/pdf-extraction-budget.test.ts b/tests/pdf-extraction-budget.test.ts index 6ad5dcfabe..6b6fb049f2 100644 --- a/tests/pdf-extraction-budget.test.ts +++ b/tests/pdf-extraction-budget.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import PDFDocument from "pdfkit"; -import { PDFParse } from "pdf-parse"; +import { PdfjsFallbackParser } from "@/lib/extractors/pdfjs-fallback"; import { afterEach, describe, expect, it, vi } from "vitest"; import { extractPdf, runPythonPdfExtractor } from "@/lib/extractors/document"; import { @@ -222,8 +222,8 @@ describe("PDF extraction budgets", () => { }); it("rejects an oversized embedded image before the JavaScript fallback decodes it", async () => { - const getTextSpy = vi.spyOn(PDFParse.prototype, "getText"); - const getImageSpy = vi.spyOn(PDFParse.prototype, "getImage"); + const getTextSpy = vi.spyOn(PdfjsFallbackParser.prototype, "getText"); + const getImageSpy = vi.spyOn(PdfjsFallbackParser.prototype, "getImage"); const root = await mkdtemp(path.join(tmpdir(), "clinical-kb-pdf-image-budget-test-")); roots.push(root); const fakeExtractor = path.join(root, "missing-dependency.py"); @@ -272,11 +272,11 @@ describe("PDF extraction budgets", () => { "import sys\nprint('PyMuPDF unavailable', file=sys.stderr)\nraise SystemExit(2)\n", "utf8", ); - vi.spyOn(PDFParse.prototype, "getText").mockResolvedValue({ + vi.spyOn(PdfjsFallbackParser.prototype, "getText").mockResolvedValue({ pages: [{ num: 1, text: "Short fallback text." }], text: "Short fallback text.", } as never); - vi.spyOn(PDFParse.prototype, "getImage").mockResolvedValue({ + vi.spyOn(PdfjsFallbackParser.prototype, "getImage").mockResolvedValue({ pages: [ { pageNumber: 1, @@ -287,7 +287,7 @@ describe("PDF extraction budgets", () => { }, ], } as never); - vi.spyOn(PDFParse.prototype, "destroy").mockResolvedValue(undefined); + vi.spyOn(PdfjsFallbackParser.prototype, "destroy").mockResolvedValue(undefined); const extracted = await extractPdf(await createTextPdf(), { scriptPathOverride: fakeExtractor }); roots.push(...(extracted.temporaryPaths ?? [])); @@ -297,7 +297,7 @@ describe("PDF extraction budgets", () => { expect(extracted.pages[0]?.needsOcr).toBe(true); }); - it("preserves text and OCR evidence when pdf-parse rejects malformed image data", async () => { + it("preserves text and OCR evidence when the PDF fallback rejects malformed image data", async () => { const root = await mkdtemp(path.join(tmpdir(), "clinical-kb-pdf-rejected-image-test-")); roots.push(root); const fakeExtractor = path.join(root, "missing-dependency.py"); @@ -306,14 +306,14 @@ describe("PDF extraction budgets", () => { "import sys\nprint('PyMuPDF unavailable', file=sys.stderr)\nraise SystemExit(2)\n", "utf8", ); - vi.spyOn(PDFParse.prototype, "getText").mockResolvedValue({ + vi.spyOn(PdfjsFallbackParser.prototype, "getText").mockResolvedValue({ pages: [{ num: 1, text: "Short fallback text." }], text: "Short fallback text.", } as never); - vi.spyOn(PDFParse.prototype, "getImage").mockRejectedValue( + vi.spyOn(PdfjsFallbackParser.prototype, "getImage").mockRejectedValue( new Error("Image object img_p0_1: data field is empty or invalid. Available fields: width, height"), ); - vi.spyOn(PDFParse.prototype, "destroy").mockResolvedValue(undefined); + vi.spyOn(PdfjsFallbackParser.prototype, "destroy").mockResolvedValue(undefined); const extracted = await extractPdf(await createTextPdf(), { scriptPathOverride: fakeExtractor }); roots.push(...(extracted.temporaryPaths ?? [])); diff --git a/tests/pdf-source-provenance.test.ts b/tests/pdf-source-provenance.test.ts index d2665e588f..b4407035f9 100644 --- a/tests/pdf-source-provenance.test.ts +++ b/tests/pdf-source-provenance.test.ts @@ -11,7 +11,7 @@ import { } from "@/lib/extractors/pdf-source-provenance"; import { extractPdf, parseExtractedDocumentPayload } from "@/lib/extractors/document"; import { buildDocumentPageRowsForPersistence } from "../worker/behavior"; -import { PDFParse } from "pdf-parse"; +import { PdfjsFallbackParser } from "@/lib/extractors/pdfjs-fallback"; function packet(): PdfPageSourceProvenance { const hash = extractedTextSha256("synthetic page"); @@ -185,7 +185,7 @@ describe("native PDF source-only provenance", () => { async ({ source, code }) => { const root = await mkdtemp(path.join(tmpdir(), "clinical-kb-pdf-provenance-test-")); const script = path.join(root, "fake.py"); - const fallback = vi.spyOn(PDFParse.prototype, "getText"); + const fallback = vi.spyOn(PdfjsFallbackParser.prototype, "getText"); try { await writeFile( script, diff --git a/tests/text-source-provenance.test.ts b/tests/text-source-provenance.test.ts index c6bd0e892e..4fdc0e1bf4 100644 --- a/tests/text-source-provenance.test.ts +++ b/tests/text-source-provenance.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import JSZip from "jszip"; import ExcelJS from "exceljs"; -import { PDFParse } from "pdf-parse"; +import { PdfjsFallbackParser } from "@/lib/extractors/pdfjs-fallback"; import { extractDocument, extractPdf, parseExtractedDocumentPayload } from "@/lib/extractors/document"; import { extractedTextSha256 } from "@/lib/extractors/docx-source-provenance"; import { @@ -231,7 +231,7 @@ describe("TXT source-only provenance", () => { it("rejects schema-valid TXT supplied by a native PDF process without fallback", async () => { const root = await mkdtemp(path.join(tmpdir(), "clinical-kb-txt-pdf-test-")); const script = path.join(root, "fake.py"); - const fallback = vi.spyOn(PDFParse.prototype, "getText"); + const fallback = vi.spyOn(PdfjsFallbackParser.prototype, "getText"); try { await writeFile( script, From ffe314a15c6a5fdb6259f081ea75d02db9ab4371 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:19:07 +0000 Subject: [PATCH 2/7] Release each page's decoded images in the PDF fallback image pass Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QevAacecJBjQqMcn5LoexE --- src/lib/extractors/pdfjs-fallback.ts | 43 ++++++++++++++++------------ 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/lib/extractors/pdfjs-fallback.ts b/src/lib/extractors/pdfjs-fallback.ts index 3257c95ec7..438c49ece8 100644 --- a/src/lib/extractors/pdfjs-fallback.ts +++ b/src/lib/extractors/pdfjs-fallback.ts @@ -100,26 +100,31 @@ export class PdfjsFallbackParser { for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber += 1) { if (!params.partial.includes(pageNumber)) continue; const page = await doc.getPage(pageNumber); - const ops = await page.getOperatorList(); - const pageImages: PdfjsImageResult["pages"][number] = { pageNumber, images: [] }; - result.pages.push(pageImages); - for (let index = 0; index < ops.fnArray.length; index += 1) { - const fn = ops.fnArray[index]; - if (fn !== pdfjs.OPS.paintInlineImageXObject && fn !== pdfjs.OPS.paintImageXObject) continue; - const name = ops.argsArray[index][0] as string; - const objects = page.commonObjs.has(name) ? page.commonObjs : page.objs; - const { width, height, kind, data } = await resolveEmbeddedImage(objects, name, canvasFactory); - if (params.imageThreshold >= width || params.imageThreshold >= height) continue; - const { canvas, context } = canvasFactory.create(width, height); - const imageData = context.createImageData(width, height); - if (kind === pdfjs.ImageKind.RGBA_32BPP) { - imageData.data.set(data); - } else { - convertToRgba(pdfjs, { src: data, dest: new Uint32Array(imageData.data.buffer), width, height, kind }); + try { + const ops = await page.getOperatorList(); + const pageImages: PdfjsImageResult["pages"][number] = { pageNumber, images: [] }; + result.pages.push(pageImages); + for (let index = 0; index < ops.fnArray.length; index += 1) { + const fn = ops.fnArray[index]; + if (fn !== pdfjs.OPS.paintInlineImageXObject && fn !== pdfjs.OPS.paintImageXObject) continue; + const name = ops.argsArray[index][0] as string; + const objects = page.commonObjs.has(name) ? page.commonObjs : page.objs; + const { width, height, kind, data } = await resolveEmbeddedImage(objects, name, canvasFactory); + if (params.imageThreshold >= width || params.imageThreshold >= height) continue; + const { canvas, context } = canvasFactory.create(width, height); + const imageData = context.createImageData(width, height); + if (kind === pdfjs.ImageKind.RGBA_32BPP) { + imageData.data.set(data); + } else { + convertToRgba(pdfjs, { src: data, dest: new Uint32Array(imageData.data.buffer), width, height, kind }); + } + context.putImageData(imageData, 0, 0); + const png = canvas.toBuffer?.("image/png"); + pageImages.images.push({ data: png ? new Uint8Array(png) : new Uint8Array(), name, width, height }); } - context.putImageData(imageData, 0, 0); - const png = canvas.toBuffer?.("image/png"); - pageImages.images.push({ data: png ? new Uint8Array(png) : new Uint8Array(), name, width, height }); + } finally { + // Release the page's decoded images before the next page, so memory stays one page deep. + page.cleanup(); } } return result; From bcad9657d231baec2f7a92849509edf8017c3f84 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:22:29 +0000 Subject: [PATCH 3/7] =?UTF-8?q?fix:=203=20findings=20=E2=80=94=20Handle=20?= =?UTF-8?q?inline=20PDF=20images=20without=20stalling;=20Exercise=20re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Handle inline PDF images without stalling - Exercise real fallback image extraction - Decode padded grayscale image rows correctly --- src/lib/extractors/pdfjs-fallback.ts | 101 +++++++++++++++------------ tests/pdf-extraction-budget.test.ts | 17 +++++ 2 files changed, 75 insertions(+), 43 deletions(-) diff --git a/src/lib/extractors/pdfjs-fallback.ts b/src/lib/extractors/pdfjs-fallback.ts index 438c49ece8..b2ae59fefd 100644 --- a/src/lib/extractors/pdfjs-fallback.ts +++ b/src/lib/extractors/pdfjs-fallback.ts @@ -107,9 +107,13 @@ export class PdfjsFallbackParser { for (let index = 0; index < ops.fnArray.length; index += 1) { const fn = ops.fnArray[index]; if (fn !== pdfjs.OPS.paintInlineImageXObject && fn !== pdfjs.OPS.paintImageXObject) continue; - const name = ops.argsArray[index][0] as string; - const objects = page.commonObjs.has(name) ? page.commonObjs : page.objs; - const { width, height, kind, data } = await resolveEmbeddedImage(objects, name, canvasFactory); + const args = ops.argsArray[index]; + const inline = fn === pdfjs.OPS.paintInlineImageXObject; + const name = inline ? `inline-${index}` : (args[0] as string); + const image = inline + ? normalizeEmbeddedImage(args[0], name, canvasFactory) + : await resolveEmbeddedImage(page.commonObjs.has(name) ? page.commonObjs : page.objs, name, canvasFactory); + const { width, height, kind, data } = image; if (params.imageThreshold >= width || params.imageThreshold >= height) continue; const { canvas, context } = canvasFactory.create(width, height); const imageData = context.createImageData(width, height); @@ -165,45 +169,56 @@ async function pageText(page: PdfPage) { type PdfObjects = { get(name: string, callback: (data: unknown) => void): unknown }; +type RawEmbeddedImage = { + width: number; + height: number; + kind?: number | null; + data?: ArrayBufferView; + bitmap?: { width: number; height: number }; +}; + +function normalizeEmbeddedImage(raw: unknown, name: string, canvasFactory: CanvasFactory): EmbeddedImage { + const image = raw as RawEmbeddedImage | null; + if (!image) throw new Error(`Image object ${name} not found`); + let data: Uint8Array | undefined; + if (image.data instanceof Uint8Array) { + data = image.data; + } else if (image.data instanceof Uint8ClampedArray) { + data = new Uint8Array(image.data); + } else if (image.bitmap) { + const { context } = canvasFactory.create(image.bitmap.width, image.bitmap.height); + context.drawImage(image.bitmap, 0, 0); + data = new Uint8Array(context.getImageData(0, 0, image.bitmap.width, image.bitmap.height).data.buffer); + } else if (image.data && ArrayBuffer.isView(image.data)) { + data = new Uint8Array(image.data.buffer, image.data.byteOffset, image.data.byteLength); + } + if (!data) { + throw new Error( + `Image object ${name}: data field is empty or invalid. Available fields: ${Object.keys(image).join(", ")}`, + ); + } + if (data.length === 0) throw new Error(`Image object ${name}: data buffer is empty (length: 0)`); + return { width: image.width, height: image.height, kind: image.kind, data }; +} + function resolveEmbeddedImage(objects: PdfObjects, name: string, canvasFactory: CanvasFactory): Promise { return new Promise((resolve, reject) => { - objects.get(name, (raw) => { - const image = raw as { - width: number; - height: number; - kind?: number | null; - data?: ArrayBufferView; - bitmap?: { width: number; height: number }; - } | null; - if (!image) { + let settled = false; + const timeout = setTimeout(() => { + if (!settled) { + settled = true; reject(new Error(`Image object ${name} not found`)); - return; - } - let data: Uint8Array | undefined; - if (image.data instanceof Uint8Array) { - data = image.data; - } else if (image.data instanceof Uint8ClampedArray) { - data = new Uint8Array(image.data); - } else if (image.bitmap) { - const { context } = canvasFactory.create(image.bitmap.width, image.bitmap.height); - context.drawImage(image.bitmap, 0, 0); - data = new Uint8Array(context.getImageData(0, 0, image.bitmap.width, image.bitmap.height).data.buffer); - } else if (image.data && ArrayBuffer.isView(image.data)) { - data = new Uint8Array(image.data.buffer, image.data.byteOffset, image.data.byteLength); - } - if (!data) { - reject( - new Error( - `Image object ${name}: data field is empty or invalid. Available fields: ${Object.keys(image).join(", ")}`, - ), - ); - return; } - if (data.length === 0) { - reject(new Error(`Image object ${name}: data buffer is empty (length: 0)`)); - return; + }, 1_000); + objects.get(name, (raw) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + try { + resolve(normalizeEmbeddedImage(raw, name, canvasFactory)); + } catch (error) { + reject(error); } - resolve({ width: image.width, height: image.height, kind: image.kind, data }); }); }); } @@ -227,12 +242,12 @@ function convertToRgba( if (kind === pdfjs.ImageKind.RGB_24BPP) { fromRgb(); } else if (kind === pdfjs.ImageKind.GRAYSCALE_1BPP) { - let pixel = 0; - for (const byte of src) { - for (let bit = 7; bit >= 0; bit -= 1) { - if (pixel >= pixels) break; - const gray = ((byte >> bit) & 1) === 1 ? 255 : 0; - dest[pixel++] = (255 << 24) | (gray << 16) | (gray << 8) | gray; + const rowBytes = (width + 7) >> 3; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const byte = src[y * rowBytes + (x >> 3)] ?? 0; + const gray = ((byte >> (7 - (x & 7))) & 1) === 1 ? 255 : 0; + dest[y * width + x] = (255 << 24) | (gray << 16) | (gray << 8) | gray; } } } else if (kind === undefined || kind === null) { diff --git a/tests/pdf-extraction-budget.test.ts b/tests/pdf-extraction-budget.test.ts index 6b6fb049f2..d6395079d8 100644 --- a/tests/pdf-extraction-budget.test.ts +++ b/tests/pdf-extraction-budget.test.ts @@ -221,6 +221,23 @@ describe("PDF extraction budgets", () => { ).rejects.toMatchObject({ code: "PDF_EXTRACTION_BUDGET_EXCEEDED" }); }); + it("extracts a real embedded image through the JavaScript fallback", async () => { + const root = await mkdtemp(path.join(tmpdir(), "clinical-kb-pdf-image-fallback-test-")); + roots.push(root); + const fakeExtractor = path.join(root, "missing-dependency.py"); + await writeFile( + fakeExtractor, + "import sys\nprint('PyMuPDF unavailable', file=sys.stderr)\nraise SystemExit(2)\n", + "utf8", + ); + + const extracted = await extractPdf(await createImagePdf(), { scriptPathOverride: fakeExtractor }); + roots.push(...(extracted.temporaryPaths ?? [])); + expect(extracted.images.length).toBeGreaterThan(0); + const imageBytes = await readFile(extracted.images[0]!.path); + expect(imageBytes.subarray(0, 8)).toEqual(Buffer.from("89504e470d0a1a0a", "hex")); + }); + it("rejects an oversized embedded image before the JavaScript fallback decodes it", async () => { const getTextSpy = vi.spyOn(PdfjsFallbackParser.prototype, "getText"); const getImageSpy = vi.spyOn(PdfjsFallbackParser.prototype, "getImage"); From 4ce60dd9140233374dbfd0e2263d5e3650f827a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:24:36 +0000 Subject: [PATCH 4/7] Read inline PDF images from the operator instead of waiting on an id An inline image (BI ... ID ... EI) carries its pixels in the paint operator. The fallback looked it up by id like an image XObject, which waits on an object pdf.js never resolves, so a PDF with an inline image hung the fallback forever. pdf-parse had the same wait. Adds a regression test with a hand-built PDF. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QevAacecJBjQqMcn5LoexE --- src/lib/extractors/pdfjs-fallback.ts | 79 +++++++++++++------------ tests/pdf-fallback-inline-image.test.ts | 47 +++++++++++++++ 2 files changed, 88 insertions(+), 38 deletions(-) create mode 100644 tests/pdf-fallback-inline-image.test.ts diff --git a/src/lib/extractors/pdfjs-fallback.ts b/src/lib/extractors/pdfjs-fallback.ts index 438c49ece8..2aa9b4e7eb 100644 --- a/src/lib/extractors/pdfjs-fallback.ts +++ b/src/lib/extractors/pdfjs-fallback.ts @@ -107,9 +107,13 @@ export class PdfjsFallbackParser { for (let index = 0; index < ops.fnArray.length; index += 1) { const fn = ops.fnArray[index]; if (fn !== pdfjs.OPS.paintInlineImageXObject && fn !== pdfjs.OPS.paintImageXObject) continue; - const name = ops.argsArray[index][0] as string; - const objects = page.commonObjs.has(name) ? page.commonObjs : page.objs; - const { width, height, kind, data } = await resolveEmbeddedImage(objects, name, canvasFactory); + // An inline image carries its pixels in the operator itself; only XObjects are looked up by id. + // Looking an inline image up by id waits on an object that never resolves, so the page would hang. + const inline = fn === pdfjs.OPS.paintInlineImageXObject; + const name = inline ? `inline-${index}` : (ops.argsArray[index][0] as string); + const { width, height, kind, data } = inline + ? toEmbeddedImage(ops.argsArray[index][0], name, canvasFactory) + : await resolveEmbeddedImage(page.commonObjs.has(name) ? page.commonObjs : page.objs, name, canvasFactory); if (params.imageThreshold >= width || params.imageThreshold >= height) continue; const { canvas, context } = canvasFactory.create(width, height); const imageData = context.createImageData(width, height); @@ -168,46 +172,45 @@ type PdfObjects = { get(name: string, callback: (data: unknown) => void): unknow function resolveEmbeddedImage(objects: PdfObjects, name: string, canvasFactory: CanvasFactory): Promise { return new Promise((resolve, reject) => { objects.get(name, (raw) => { - const image = raw as { - width: number; - height: number; - kind?: number | null; - data?: ArrayBufferView; - bitmap?: { width: number; height: number }; - } | null; - if (!image) { - reject(new Error(`Image object ${name} not found`)); - return; - } - let data: Uint8Array | undefined; - if (image.data instanceof Uint8Array) { - data = image.data; - } else if (image.data instanceof Uint8ClampedArray) { - data = new Uint8Array(image.data); - } else if (image.bitmap) { - const { context } = canvasFactory.create(image.bitmap.width, image.bitmap.height); - context.drawImage(image.bitmap, 0, 0); - data = new Uint8Array(context.getImageData(0, 0, image.bitmap.width, image.bitmap.height).data.buffer); - } else if (image.data && ArrayBuffer.isView(image.data)) { - data = new Uint8Array(image.data.buffer, image.data.byteOffset, image.data.byteLength); - } - if (!data) { - reject( - new Error( - `Image object ${name}: data field is empty or invalid. Available fields: ${Object.keys(image).join(", ")}`, - ), - ); - return; - } - if (data.length === 0) { - reject(new Error(`Image object ${name}: data buffer is empty (length: 0)`)); - return; + try { + resolve(toEmbeddedImage(raw, name, canvasFactory)); + } catch (error) { + reject(error); } - resolve({ width: image.width, height: image.height, kind: image.kind, data }); }); }); } +function toEmbeddedImage(raw: unknown, name: string, canvasFactory: CanvasFactory): EmbeddedImage { + const image = raw as { + width: number; + height: number; + kind?: number | null; + data?: ArrayBufferView; + bitmap?: { width: number; height: number }; + } | null; + if (!image) throw new Error(`Image object ${name} not found`); + let data: Uint8Array | undefined; + if (image.data instanceof Uint8Array) { + data = image.data; + } else if (image.data instanceof Uint8ClampedArray) { + data = new Uint8Array(image.data); + } else if (image.bitmap) { + const { context } = canvasFactory.create(image.bitmap.width, image.bitmap.height); + context.drawImage(image.bitmap, 0, 0); + data = new Uint8Array(context.getImageData(0, 0, image.bitmap.width, image.bitmap.height).data.buffer); + } else if (image.data && ArrayBuffer.isView(image.data)) { + data = new Uint8Array(image.data.buffer, image.data.byteOffset, image.data.byteLength); + } + if (!data) { + throw new Error( + `Image object ${name}: data field is empty or invalid. Available fields: ${Object.keys(image).join(", ")}`, + ); + } + if (data.length === 0) throw new Error(`Image object ${name}: data buffer is empty (length: 0)`); + return { width: image.width, height: image.height, kind: image.kind, data }; +} + function convertToRgba( pdfjs: PdfjsModule, { diff --git a/tests/pdf-fallback-inline-image.test.ts b/tests/pdf-fallback-inline-image.test.ts new file mode 100644 index 0000000000..017b0377d9 --- /dev/null +++ b/tests/pdf-fallback-inline-image.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { PdfjsFallbackParser } from "@/lib/extractors/pdfjs-fallback"; + +// A one-page PDF whose content stream paints a 30 x 30 RGB image inline (BI ... ID ... EI), +// rather than through a named image XObject. +function inlineImagePdf() { + const side = 30; + const pixels = Buffer.alloc(side * side * 3, 0); + for (let pixel = 0; pixel < side * side; pixel += 1) pixels[pixel * 3] = 255; + const content = `q ${side} 0 0 ${side} 10 10 cm BI /W ${side} /H ${side} /CS /RGB /BPC 8 /F /AHx ID ${pixels.toString("hex")}> EI Q`; + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << >> >>", + `<< /Length ${content.length} >>\nstream\n${content}\nendstream`, + ]; + let pdf = "%PDF-1.4\n"; + const offsets: number[] = []; + objects.forEach((object, index) => { + offsets.push(pdf.length); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + const xref = pdf.length; + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + pdf += offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join(""); + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return Buffer.from(pdf, "latin1"); +} + +describe("PdfjsFallbackParser", () => { + it("extracts an inline image instead of waiting forever for it", async () => { + const parser = new PdfjsFallbackParser({ data: inlineImagePdf(), stopAtErrors: true }); + try { + const result = await Promise.race([ + parser.getImage({ partial: [1], imageThreshold: 20 }), + new Promise<"timed out">((resolve) => setTimeout(() => resolve("timed out"), 5000)), + ]); + expect(result).not.toBe("timed out"); + if (result === "timed out") return; + const [image] = result.pages[0]?.images ?? []; + expect(image).toMatchObject({ width: 30, height: 30 }); + expect(image?.data.byteLength).toBeGreaterThan(0); + } finally { + await parser.destroy(); + } + }); +}); From 96c4c965917812d66601a302754e3ea0babde0f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:51:59 +0000 Subject: [PATCH 5/7] Flag database functions that nothing calls Adds an offline check that every function in the drift manifest has a caller in app, worker, script, edge-function code or other SQL. Six functions with no caller today are listed by name until their owners confirm them. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QevAacecJBjQqMcn5LoexE --- tests/supabase-function-callers.test.ts | 93 +++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/supabase-function-callers.test.ts diff --git a/tests/supabase-function-callers.test.ts b/tests/supabase-function-callers.test.ts new file mode 100644 index 0000000000..2a19c8269f --- /dev/null +++ b/tests/supabase-function-callers.test.ts @@ -0,0 +1,93 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +// Every database function the live schema is expected to hold should have a caller: application or +// worker code, a script, an edge function, or another piece of SQL (a function body, trigger, +// policy, view or scheduled job). A function nothing calls is dead weight in the live database, +// which is how unused versions pile up. This check is offline: it reads the drift manifest and the +// repository, and never touches the database. + +const root = path.resolve(import.meta.dirname, ".."); + +// Functions with no caller found today. Each is owned by another area and is listed here until its +// owner confirms it can be dropped (an approved database change) or names the caller this check +// cannot see. Remove an entry when that happens; never add one to silence a new function. +const KNOWN_UNCALLED = new Set([ + "activate_public_source_version", + "on_call_shifts_replace", + "record_public_source_activation", + "roster_rotation_can_manage", + "teaching_platform_command", + "transition_public_source_version", +]); + +const CODE_ROOTS = ["src", "worker", "scripts", "supabase/functions"]; +const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs", ".py", ".sql"]); +// Generated from the live schema, so it names every function whether or not anything calls it. +const GENERATED_FILES = new Set([path.join("src", "lib", "supabase", "database.types.ts")]); + +function walk(dir: string, files: string[] = []): string[] { + for (const entry of readdirSync(path.join(root, dir))) { + if (entry === "node_modules" || entry.startsWith(".")) continue; + const relative = path.join(dir, entry); + if (statSync(path.join(root, relative)).isDirectory()) walk(relative, files); + else if (CODE_EXTENSIONS.has(path.extname(entry)) && !GENERATED_FILES.has(relative)) files.push(relative); + } + return files; +} + +function expectedFunctionNames(): string[] { + const manifest = JSON.parse(readFileSync(path.join(root, "supabase/drift-manifest.json"), "utf8")) as { + snapshot: { functions: { signature: string }[] }; + }; + const names = manifest.snapshot.functions.map((fn) => fn.signature.split("(")[0]!.replace(/^public\./, "")); + return [...new Set(names)].sort(); +} + +function codeWords(): Set { + const words = new Set(); + for (const file of CODE_ROOTS.flatMap((dir) => walk(dir))) { + for (const word of readFileSync(path.join(root, file), "utf8").match(/[A-Za-z0-9_]+/g) ?? []) words.add(word); + } + return words; +} + +function migrationSqlWithoutComments(): string { + const dir = path.join(root, "supabase/migrations"); + return readdirSync(dir) + .filter((file) => file.endsWith(".sql")) + .sort() + .map((file) => readFileSync(path.join(dir, file), "utf8").replace(/--.*$/gm, "")) + .join("\n"); +} + +// Statements that name a function without calling it: defining, dropping, altering, granting or commenting. +function withoutSelfReferences(sql: string, name: string): string { + const declaring = new RegExp( + String.raw`\b(?:create\s+or\s+replace\s+function|create\s+function|drop\s+function(?:\s+if\s+exists)?|alter\s+function|on\s+function)\s+(?:public\.)?"?${name}\b`, + "gi", + ); + return sql.replace(declaring, ""); +} + +describe("database function callers", () => { + const names = expectedFunctionNames(); + const words = codeWords(); + const sql = migrationSqlWithoutComments(); + + const uncalled = names.filter((name) => { + if (words.has(name)) return false; + return !new RegExp(String.raw`(? { + expect(names.length).toBeGreaterThan(100); + expect(uncalled.filter((name) => !KNOWN_UNCALLED.has(name))).toEqual([]); + }); + + it("keeps the known-uncalled list current", () => { + const stale = [...KNOWN_UNCALLED].filter((name) => !uncalled.includes(name)); + expect(stale).toEqual([]); + }); +}); From b431d1521c3de6c2f80cfb8a2ba677c22eaa85c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:07:05 +0000 Subject: [PATCH 6/7] Count only current schema and scheduled jobs as SQL callers Old migration bodies, catalog lookups by name and quoted identity strings no longer count as calls, so the check now flags 16 functions instead of 6. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QevAacecJBjQqMcn5LoexE --- tests/supabase-function-callers.test.ts | 62 +++++++++++++++++++++---- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/tests/supabase-function-callers.test.ts b/tests/supabase-function-callers.test.ts index 2a19c8269f..67d1ec3828 100644 --- a/tests/supabase-function-callers.test.ts +++ b/tests/supabase-function-callers.test.ts @@ -3,8 +3,8 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; // Every database function the live schema is expected to hold should have a caller: application or -// worker code, a script, an edge function, or another piece of SQL (a function body, trigger, -// policy, view or scheduled job). A function nothing calls is dead weight in the live database, +// worker code, a script, an edge function, or SQL in the current schema (a function body, trigger, +// policy or view) or a scheduled job. A function nothing calls is dead weight in the live database, // which is how unused versions pile up. This check is offline: it reads the drift manifest and the // repository, and never touches the database. @@ -15,9 +15,19 @@ const root = path.resolve(import.meta.dirname, ".."); // cannot see. Remove an entry when that happens; never add one to silence a new function. const KNOWN_UNCALLED = new Set([ "activate_public_source_version", + "backfill_legacy_index_health_batch", + "create_uploaded_document_with_ingestion_job", + "document_label_metadata", + "document_summary_text", + "invoke_indexing_v3_agent", + "match_document_embedding_fields_text", "on_call_shifts_replace", + "prepare_completed_admin_text_reference", + "read_completed_admin_text_source_layout", "record_public_source_activation", "roster_rotation_can_manage", + "set_document_corpus_access_mode", + "stamp_document_deep_memory_version", "teaching_platform_command", "transition_public_source_version", ]); @@ -53,28 +63,60 @@ function codeWords(): Set { return words; } -function migrationSqlWithoutComments(): string { +// The current schema (`supabase/schema.sql`, which the drift manifest is built from) is the only SQL +// that can call anything: superseded bodies in older migrations no longer run. When schema.sql +// redefines a function, only its last definition counts. +function currentSchemaSql(): string { + const sql = readFileSync(path.join(root, "supabase/schema.sql"), "utf8").replace(/--.*$/gm, ""); + const definition = + /\bcreate\s+(?:or\s+replace\s+)?function\s+(?:public\.)?"?(\w+)"?\s*\(([^)]*)\)[\s\S]*?\bas\s+(\$\w*\$)[\s\S]*?\3/gi; + const definitions = [...sql.matchAll(definition)]; + const last = new Map(); + for (const match of definitions) + last.set(`${match[1]!.toLowerCase()}(${match[2]!.replace(/\s+/g, " ").trim()})`, match.index); + let current = ""; + let cursor = 0; + for (const match of definitions) { + const key = `${match[1]!.toLowerCase()}(${match[2]!.replace(/\s+/g, " ").trim()})`; + current += sql.slice(cursor, match.index); + if (last.get(key) === match.index) current += match[0]; + cursor = match.index + match[0].length; + } + return current + sql.slice(cursor) + "\n" + scheduledJobCommands(); +} + +// Scheduled jobs live in pg_cron's own table, not in schema.sql, so the commands migrations scheduled +// are callers too. +function scheduledJobCommands(): string { const dir = path.join(root, "supabase/migrations"); + const schedule = /\bcron\.schedule\s*\(([\s\S]*?)\)\s*;/gi; return readdirSync(dir) .filter((file) => file.endsWith(".sql")) - .sort() - .map((file) => readFileSync(path.join(dir, file), "utf8").replace(/--.*$/gm, "")) + .flatMap((file) => [...readFileSync(path.join(dir, file), "utf8").replace(/--.*$/gm, "").matchAll(schedule)]) + .map((match) => match[1]) .join("\n"); } -// Statements that name a function without calling it: defining, dropping, altering, granting or commenting. +// Text that names a function without calling it: its own definition and body, drop, alter, grant and +// comment statements, and catalog lookups by name (`to_regprocedure('public.x(uuid)')`, +// `proname = 'x'`, or a quoted `'public.x(...)'` identity string). function withoutSelfReferences(sql: string, name: string): string { - const declaring = new RegExp( - String.raw`\b(?:create\s+or\s+replace\s+function|create\s+function|drop\s+function(?:\s+if\s+exists)?|alter\s+function|on\s+function)\s+(?:public\.)?"?${name}\b`, + const ownDefinition = new RegExp( + String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+(?:public\.)?"?${name}"?\s*\([\s\S]*?\bas\s+(\$\w*\$)[\s\S]*?\1`, + "gi", + ); + const naming = new RegExp( + String.raw`\b(?:drop\s+function(?:\s+if\s+exists)?|alter\s+function|on\s+function)\s+(?:public\.)?"?${name}\b`, "gi", ); - return sql.replace(declaring, ""); + const lookups = new RegExp(String.raw`proname\s*=\s*'${name}'|'(?:public\.)?${name}(?:\([^']*\))?'(?:::\w+)?`, "gi"); + return sql.replace(ownDefinition, "").replace(naming, "").replace(lookups, ""); } describe("database function callers", () => { const names = expectedFunctionNames(); const words = codeWords(); - const sql = migrationSqlWithoutComments(); + const sql = currentSchemaSql(); const uncalled = names.filter((name) => { if (words.has(name)) return false; From a6eb09bf757a8cf831a4ce2352db458a585b75eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:18:04 +0000 Subject: [PATCH 7/7] Ignore names that only appear in code comments A function mentioned only in a comment is no longer treated as called. This flags three more functions, now 19 in total. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QevAacecJBjQqMcn5LoexE --- tests/supabase-function-callers.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/supabase-function-callers.test.ts b/tests/supabase-function-callers.test.ts index 67d1ec3828..4777b0ca15 100644 --- a/tests/supabase-function-callers.test.ts +++ b/tests/supabase-function-callers.test.ts @@ -19,13 +19,16 @@ const KNOWN_UNCALLED = new Set([ "create_uploaded_document_with_ingestion_job", "document_label_metadata", "document_summary_text", + "get_visual_evidence_cards", "invoke_indexing_v3_agent", "match_document_embedding_fields_text", "on_call_shifts_replace", "prepare_completed_admin_text_reference", "read_completed_admin_text_source_layout", "record_public_source_activation", + "repair_enrichment_quality_batch", "roster_rotation_can_manage", + "run_all_visual_eval_cases", "set_document_corpus_access_mode", "stamp_document_deep_memory_version", "teaching_platform_command", @@ -55,10 +58,21 @@ function expectedFunctionNames(): string[] { return [...new Set(names)].sort(); } +// A name that only appears in a comment is not a caller. Block comments, `//` comments that start a +// line or follow whitespace (so URLs inside strings survive), and `#` or `--` line comments in Python +// and SQL files are dropped before collecting words. +function withoutCodeComments(source: string, file: string): string { + const extension = path.extname(file); + if (extension === ".py") return source.replace(/(^|\s)#.*$/gm, "$1"); + if (extension === ".sql") return source.replace(/--.*$/gm, ""); + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1"); +} + function codeWords(): Set { const words = new Set(); for (const file of CODE_ROOTS.flatMap((dir) => walk(dir))) { - for (const word of readFileSync(path.join(root, file), "utf8").match(/[A-Za-z0-9_]+/g) ?? []) words.add(word); + const source = withoutCodeComments(readFileSync(path.join(root, file), "utf8"), file); + for (const word of source.match(/[A-Za-z0-9_]+/g) ?? []) words.add(word); } return words; }