Files
HealthLog/src/lib/documents/rasterize-pdf.ts
T
Marc-André Bombeck df6d20b696 fix(documents): read a PDF the server cannot render by its text layer
On a host whose CPU cannot run the page renderer (x64 without AVX2, arm64
musl without LSE/RCpc), every AI read of a PDF for a provider that only
takes images failed with "Scanning PDFs needs a Claude vision provider",
which says nothing about the cause. Most medical PDFs are produced
digitally and carry every word in their text layer, which pdf.js reads
without rendering.

`prepareVisionInput` now answers a text read (`mode: "text"`) when the
renderer is unavailable on this host, the provider cannot take the PDF
whole and the PDF has a usable text layer. Summary, suggest and the
background summary send that text through their existing text mode;
"Read with AI" and the index backfill write it as the local text index it
is, with no provider call; the automatic index leaves it to its local path.
`planVisionReads` does the same for the extraction and the lab scan: text
reads by page group, with anchored provenance, and the pages without a text
layer reported as `extractionIncomplete` on a document and as `unreadPages`
on a lab scan. A supplement label PDF is read from its text layer too.

A scan on such a host now answers `documents.inbound.pdfRendererUnavailable`
and `labs.ocr.pdfRendererUnavailable` (422): this server's processor cannot
run the page renderer, so a scanned PDF can only be read by a provider that
takes PDF files directly, or as photos. The `pdfNeedsAnthropic` codes are no
longer emitted, since with a working renderer every provider reads a PDF;
the recorded index outcome keeps its `pdf-needs-anthropic` value with
corrected copy.

The extraction path no longer folds a render failure of one document into
the capability error: like `prepareVisionInput` since #776 it reports
"The PDF pages couldn't be rendered for scanning." (`extractFailed`), and
the lab scan answers `labs.ocr.extractFailed`.

Hosts that render, and providers that take PDFs whole, are unchanged.

Refs #1311
2026-11-03 22:07:22 +01:00

255 lines
9.8 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).
*
* The rendering itself runs in a child process under a memory ceiling and a
* time limit (`./pdf-isolation.ts`), through the one pdf.js loader
* (`./pdf-engine.mjs`) the local text read uses too. A document built to
* exhaust memory or time ends that child and answers `resource-limit`; the
* server keeps running. 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";
import { runPdfChild } from "./pdf-isolation";
/**
* 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` → `rasterFailed`, or on a server that cannot render at
* all the text layer and else `pdfRendererUnavailable`).
*/
export const RASTERIZATION_AVAILABLE = true;
/** Render at most the first N pages — the token-cost / DoS governor. */
export const RASTER_MAX_PAGES = 10;
/**
* The ceiling a caller may raise the page count to. The lab scan reads a
* multi-panel report page by page and asks for more than the document read's
* default; nothing may ask for more than this.
*/
export const RASTER_HARD_MAX_PAGES = 20;
/**
* 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;
/**
* A rendered page counts as blank when fewer than this share of its pixels
* are dark. One short line of 16 pt text on an A4 page is ~0.1 %; a page
* number alone sits near this floor. A page set that is blank throughout is
* not a reading of the document, whatever the provider would make of it.
*/
export const RASTER_MIN_INK_RATIO = 0.0001;
/** 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. Refs #776: a
* failure names its class so the caller can tell "this build cannot raster
* at all" (`unsupported` — the native canvas binary is unusable, a capability
* gap Anthropic's native PDF path would cover) from "this particular PDF
* would not render" (`render-failed` — malformed / encrypted / zero pages),
* and both from "this PDF crossed the renderer's memory or time bound"
* (`resource-limit`), which the same bytes will cross again.
*/
export type RasterResult =
| {
ok: true;
images: RasterImage[];
/**
* Pages in the PDF. Above `images.length` when the page cap cut the
* render short, which a caller showing the result has to say: the
* reading covers the first pages only.
*/
pageCount: number;
/**
* Only with `withText`: each rendered page's text layer, in order and
* aligned with `images`. Empty for a page that has none (a scan) or
* whose text could not be read.
*/
texts?: string[];
}
| {
ok: false;
reason:
| "unsupported"
| "render-failed"
| "resource-limit"
/**
* Not an answer about this document: the reader was busy past the
* wait this work may spend, or its process ended for no reason it
* measured. The caller tries again later.
*/
| "busy"
| "interrupted";
};
export {
pageText,
RASTER_PAGE_TEXT_MAX,
TEXT_LAYER_CUT_MARK,
} from "./pdf-engine.mjs";
/**
* How long a render may take: a base for opening the document plus a share
* per page. A page that takes longer than this is not a page a reader waits
* on, and the time limit is what ends a document built to keep the renderer
* busy.
*/
export const RASTER_TIMEOUT_BASE_MS = 15_000;
export const RASTER_TIMEOUT_PER_PAGE_MS = 5_000;
/**
* Render the first `RASTER_MAX_PAGES` pages of a PDF to bounded JPEG images
* (or another count when `maxPages` is passed — the thumbnail path renders
* page 1 only, the lab scan up to `RASTER_HARD_MAX_PAGES`). 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,
opts: { withText?: boolean } = {},
): Promise<RasterResult> {
// CPU-feature gate: pdfjs renders through @napi-rs/canvas (Skia), whose x64
// build uses AVX2 — an unsupported CPU dies with 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, reason: "unsupported" };
}
// Never exceed the standing DoS ceiling, even if a caller asks for more.
const cap = Math.min(Math.max(1, maxPages), RASTER_HARD_MAX_PAGES);
const result = await runPdfChild(
{
op: "render",
data: buffer,
options: {
maxPages: cap,
targetLongEdge: RASTER_TARGET_LONG_EDGE,
jpegQuality: RASTER_JPEG_QUALITY,
minInkRatio: RASTER_MIN_INK_RATIO,
withText: opts.withText === true,
},
},
{ timeoutMs: RASTER_TIMEOUT_BASE_MS + cap * RASTER_TIMEOUT_PER_PAGE_MS },
);
if (!result.ok) {
if (result.reason === "busy" || result.reason === "interrupted") {
annotate({
action: { name: "documents.rasterize.deferred" },
meta: { reason: result.reason },
});
return { ok: false, reason: result.reason };
}
if (result.reason === "limit") {
annotate({
action: { name: "documents.rasterize.failed" },
meta: { reason: "resource_limit", limit: result.limit },
});
return { ok: false, reason: "resource-limit" };
}
annotate({
action: { name: "documents.rasterize.failed" },
meta: {
reason: result.name,
// The message pins WHY a render failed (an encrypted or odd PDF, a
// font issue) — the name alone is opaque. Bounded so a pathological
// message can't bloat the wide event.
message: result.message.slice(0, 300),
},
});
return { ok: false, reason: "render-failed" };
}
const doc = result.value;
if (doc.assetsMissing) {
annotate({
action: { name: "documents.rasterize.assetsMissing" },
meta: { reason: "pdfjs_assets_unresolved" },
});
}
if (doc.pages.length === 0) {
// Refs #776 — the zero-pages shape must be as loud as a throw: a
// document that "rendered" nothing is a failed rasterization, and the
// wide event is what lets an operator see WHY a scan stayed unread.
annotate({
action: { name: "documents.rasterize.failed" },
meta: { reason: "zero_pages", pageCount: doc.pageCount },
});
return { ok: false, reason: "render-failed" };
}
const blankPages = doc.pages.filter((page) => page.blank).length;
if (blankPages === doc.pages.length) {
// Every rendered page came out empty. Sending those to a vision model
// buys a transcript of nothing that would be indexed as a success; say
// the render failed instead, so the read reports `raster-failed`.
annotate({
action: { name: "documents.rasterize.failed" },
meta: { reason: "blank_pages", pages: doc.pages.length },
});
return { ok: false, reason: "render-failed" };
}
annotate({
action: { name: "documents.rasterize.ok" },
meta: { pages: doc.pages.length, cappedFrom: doc.pageCount, blankPages },
});
const images: RasterImage[] = doc.pages.map((page) => ({
mediaType: "image/jpeg",
dataBase64: page.jpeg.toString("base64"),
}));
return {
ok: true,
images,
pageCount: doc.pageCount,
...(opts.withText
? { texts: doc.pages.map((page) => page.text ?? "") }
: {}),
};
}