Files
HealthLog/src/lib/documents/rasterize-pdf.ts
T

221 lines
8.4 KiB
TypeScript

/**
* 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<void> };
cleanup(): void;
}
interface PdfDocument {
numPages: number;
canvasFactory: PdfCanvasFactory;
getPage(pageNumber: number): Promise<PdfPage>;
}
interface PdfLoadingTask {
promise: Promise<PdfDocument>;
destroy(): Promise<void>;
}
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<RasterResult> {
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.
}
}
}
}