/** * Server-side PDF rasterization for the document vault. * * The OAuth/subscription wire (codex) — and every other image-only-wire provider * — cannot receive a native PDF `document` block; only Anthropic accepts one. * This module renders a PDF's pages to raster JPEG images so those providers can * still READ a scanned/image-only PDF: each page image goes out as an ordinary * `input_image` part the codex client already handles, with no client-interface * change. Only invoked (on the auto-read path) when the toggle authorises the * external egress and the picked provider is NOT Anthropic (Anthropic keeps its * higher-fidelity native PDF block; a text-layer PDF is still read locally for * free before we ever rasterize). * * pdfjs is loaded LAZILY via a runtime `import()` — its module top-level * references the browser-only `DOMMatrix` global, so a STATIC import would make * the Turbopack server chunk evaluate that reference at instantiation and throw * `DOMMatrix is not defined`, taking down every route/worker sharing the chunk. * The lazy import (the exact pattern `local-extract.ts` documents) keeps pdfjs + * `@napi-rs/canvas` (its Node canvas backend) out of the eager chunk. Rendering * is pure local compute — no network, nothing written to disk. * * Bounds (denial-of-service + vision token cost): render at most the first * `RASTER_MAX_PAGES` pages, each capped to `RASTER_TARGET_LONG_EDGE` on its long * edge (never upscaled), JPEG at `RASTER_JPEG_QUALITY`. A discharge letter / lab * is 1-4 pages; the cap keeps a pathological 200-page PDF from draining the * subscription allowance in one job. * * NEVER throws: a malformed / encrypted / unrenderable PDF, a missing native * binary, or any render error resolves to `{ ok: false }`, and the caller falls * back to the local text-layer path or leaves the document un-indexed. This * preserves the "a bad document never aborts an upload/batch" contract. */ import { Buffer } from "node:buffer"; import { annotate } from "@/lib/logging/context"; import { nativeCanvasSupported } from "./native-canvas-support"; /** * Whether the PDF rasterizer is available in this build. The native * `@napi-rs/canvas` binary is compiled in and traced into the standalone image * (see `next.config.ts`), so rasterization is a standing capability — every * vision provider can read a PDF (Anthropic natively, all others via raster). * The document capability DTO reads this so the UI offers a PDF read for a * non-Anthropic provider; a runtime render failure still degrades gracefully * (`prepareVisionInput` → `pdfNeedsAnthropic` → local text or un-indexed). */ export const RASTERIZATION_AVAILABLE = true; /** Render at most the first N pages — the token-cost / DoS governor. */ export const RASTER_MAX_PAGES = 10; /** * Longest-edge pixel cap per page. Tighter than the client upload downscale * ceiling because these feed a token-metered vision model; ~1600px is legible * for a document read without blowing up the tile count. */ export const RASTER_TARGET_LONG_EDGE = 1600; /** JPEG quality (0-100). ~80 keeps text legible at a fraction of PNG's bytes. */ export const RASTER_JPEG_QUALITY = 80; /** One rasterized page, shaped as a vision `input_image` part. */ export interface RasterImage { mediaType: "image/jpeg"; dataBase64: string; } /** Best-effort rasterization outcome — never an exception. */ export type RasterResult = { ok: true; images: RasterImage[] } | { ok: false }; // Minimal structural types for the slice of the pdfjs API this module uses. A // type-only shape (erased at build) so we never eagerly evaluate the pdfjs // module for its types; the real module is pulled at runtime via `import()`. interface PdfViewport { width: number; height: number; } interface PdfCanvas { width: number; height: number; toBuffer(mime: "image/jpeg", quality?: number): Buffer; } interface PdfCanvasAndContext { canvas: PdfCanvas; context: unknown; } interface PdfCanvasFactory { create(width: number, height: number): PdfCanvasAndContext; destroy?(target: PdfCanvasAndContext): void; } interface PdfPage { getViewport(opts: { scale: number }): PdfViewport; render(opts: { canvasContext: unknown; viewport: PdfViewport; canvas: unknown; }): { promise: Promise }; cleanup(): void; } interface PdfDocument { numPages: number; canvasFactory: PdfCanvasFactory; getPage(pageNumber: number): Promise; } interface PdfLoadingTask { promise: Promise; destroy(): Promise; } interface PdfjsModule { getDocument(opts: { data: Uint8Array; verbosity?: number; isEvalSupported?: boolean; useSystemFonts?: boolean; }): PdfLoadingTask; } /** * Render the first `RASTER_MAX_PAGES` pages of a PDF to bounded JPEG images * (or fewer when `maxPages` is passed — the thumbnail path renders page 1 * only). Returns `{ ok: false }` on any failure (malformed PDF, render throw, * missing binary, or zero renderable pages) — the caller degrades to local * text or un-indexed. Never throws. */ export async function rasterizePdf( buffer: Buffer, maxPages: number = RASTER_MAX_PAGES, ): Promise { let doc: PdfDocument | null = null; let task: PdfLoadingTask | null = null; // CPU-feature gate: pdfjs renders through @napi-rs/canvas (Skia), whose x64 // build uses AVX2 — an unsupported CPU dies with an uncatchable SIGILL on // the first render. Degrade to the existing no-raster path instead (scanned // PDFs stay unreadable for image-only providers; everything else works). if (!nativeCanvasSupported()) { annotate({ action: { name: "documents.rasterize.failed" }, meta: { reason: "native_canvas_unsupported" }, }); return { ok: false }; } try { // Lazy import: keeps pdfjs (DOMMatrix) + @napi-rs/canvas out of the eager // server chunk. Resolved at first rasterization, then module-cached. const pdfjs = (await import("pdfjs-dist/legacy/build/pdf.mjs")) as unknown as PdfjsModule; task = pdfjs.getDocument({ data: new Uint8Array(buffer), verbosity: 0, // VerbosityLevel.ERRORS — no console spam on odd PDFs. isEvalSupported: false, // never eval font programs (defence in depth). useSystemFonts: true, }); doc = await task.promise; // Never exceed the standing DoS ceiling, even if a caller asks for more. const cap = Math.min(Math.max(1, maxPages), RASTER_MAX_PAGES); const pageCount = Math.min(doc.numPages, cap); const images: RasterImage[] = []; for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) { const page = await doc.getPage(pageNumber); try { const base = page.getViewport({ scale: 1 }); const longEdge = Math.max(base.width, base.height); // Cap the long edge, never upscale (scale ≤ 1). const scale = longEdge > 0 ? Math.min(1, RASTER_TARGET_LONG_EDGE / longEdge) : 1; const viewport = page.getViewport({ scale }); const factory = doc.canvasFactory; const cc = factory.create( Math.ceil(viewport.width), Math.ceil(viewport.height), ); await page.render({ canvasContext: cc.context, viewport, canvas: cc.canvas, }).promise; const jpeg = cc.canvas.toBuffer("image/jpeg", RASTER_JPEG_QUALITY); images.push({ mediaType: "image/jpeg", dataBase64: jpeg.toString("base64"), }); factory.destroy?.(cc); } finally { page.cleanup(); } } if (images.length === 0) return { ok: false }; annotate({ action: { name: "documents.rasterize.ok" }, meta: { pages: images.length, cappedFrom: doc.numPages }, }); return { ok: true, images }; } catch (err) { annotate({ action: { name: "documents.rasterize.failed" }, meta: { reason: err instanceof Error ? err.name : "unknown", // The message pins WHY a render failed (bundled-module mismatch, an // encrypted/odd PDF, a font issue) — the name alone is opaque. Bounded // so a pathological message can't bloat the wide event. message: err instanceof Error ? err.message.slice(0, 300) : String(err), }, }); return { ok: false }; } finally { if (task) { try { await task.destroy(); } catch { // Best-effort teardown; a destroy failure must not mask the result. } } } }