From 518ce2fd37024fb11f5396187302b91d3ccd896a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 12:23:39 +0100 Subject: [PATCH] fix(documents): read and render PDFs in a bounded child process pdf.js decodes an image at the size the file declares, whatever size the page is drawn at, and the server opened documents without an image limit. A small upload declaring a very large image made the web process allocate gigabytes until the container was killed, for every user at once, and the thumbnail job ran the same document again after each restart. An image whose soft mask declares the size does the same past pdf.js' own limit, and nothing bounded how long a text read could take. pdf.js is now opened with maxImageSize (20 million pixels, which keeps one image inside the reader's ceiling and admits a 300 dpi A3 scan), and every read and render runs in a child process, one at a time: its V8 heap is capped, its resident memory is sampled and the child killed past 640 MB, on Linux it volunteers to the out-of-memory killer first, a time limit ends a read that runs on, and it starts with an almost empty environment. The loader and the rendering moved into pdf-engine.mjs, which the child runs with plain Node and which is traced into the standalone image. A document that crosses a bound answers resource-limit. The thumbnail job records it as a final failure, so the backfill does not queue it again. --- next.config.ts | 5 + src/__tests__/single-pdfjs-guard.test.ts | 9 +- .../documents/__tests__/pdf-isolation.test.ts | 189 +++++++ .../__tests__/rasterize-pdf-cleanup.test.ts | 21 +- .../__tests__/rasterize-pdf-font-data.test.ts | 31 +- src/lib/documents/pdf-child.mjs | 67 +++ src/lib/documents/pdf-engine.mjs | 509 ++++++++++++++++++ src/lib/documents/pdf-isolation.ts | 294 ++++++++++ src/lib/documents/pdfjs.ts | 245 +-------- src/lib/documents/rasterize-pdf.ts | 351 ++++-------- src/lib/documents/thumbnail.ts | 6 + src/lib/jobs/document-thumbnail-backfill.ts | 7 +- 12 files changed, 1240 insertions(+), 494 deletions(-) create mode 100644 src/lib/documents/__tests__/pdf-isolation.test.ts create mode 100644 src/lib/documents/pdf-child.mjs create mode 100644 src/lib/documents/pdf-engine.mjs create mode 100644 src/lib/documents/pdf-isolation.ts diff --git a/next.config.ts b/next.config.ts index cbefed2c1..3a9b959db 100644 --- a/next.config.ts +++ b/next.config.ts @@ -302,6 +302,11 @@ const nextConfig: NextConfig = { outputFileTracingIncludes: { "*": [ "./src/lib/ai/prompts/safety-contracts.*.yaml", + // PDFs are read and rendered in a child process with a memory ceiling + // and a time limit (`src/lib/documents/pdf-isolation.ts`). The child is + // started from these two files by path, which the tracer cannot follow. + "./src/lib/documents/pdf-child.mjs", + "./src/lib/documents/pdf-engine.mjs", // Document AI local text extraction and rasterization both run the // app's one pdfjs-dist (`src/lib/documents/pdfjs.ts`) server-side; the // text read is the provider-free fallback that keeps content search diff --git a/src/__tests__/single-pdfjs-guard.test.ts b/src/__tests__/single-pdfjs-guard.test.ts index 6f7d67b77..11bde1c1a 100644 --- a/src/__tests__/single-pdfjs-guard.test.ts +++ b/src/__tests__/single-pdfjs-guard.test.ts @@ -26,9 +26,10 @@ * which is how the server finds it. * - No runtime module under `src/` imports pdf-parse, and none imports * pdfjs-dist (a type-only import included) except - * `src/lib/documents/pdfjs.ts`, so the text read and the rasterizer open - * documents with the same options. Comments are stripped first, so prose - * that names a package does not count. + * `src/lib/documents/pdf-engine.mjs`, so the text read and the rasterizer + * open documents with the same options, under the same image limit, in + * the bounded child process. Comments are stripped first, so prose that + * names a package does not count. * * It does not prove the image carries one copy; the Dockerfile hoists the * pinned version by name and the tracing glob can only find what the lockfile @@ -49,7 +50,7 @@ import { const ROOT = process.cwd(); /** Relative to `src/`. */ -const LOADER = "lib/documents/pdfjs.ts"; +const LOADER = "lib/documents/pdf-engine.mjs"; const pkg = JSON.parse(readSourceText(join(ROOT, "package.json"))) as { dependencies?: Record; diff --git a/src/lib/documents/__tests__/pdf-isolation.test.ts b/src/lib/documents/__tests__/pdf-isolation.test.ts new file mode 100644 index 000000000..28ba074a5 --- /dev/null +++ b/src/lib/documents/__tests__/pdf-isolation.test.ts @@ -0,0 +1,189 @@ +import { Buffer } from "node:buffer"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createDeflate } from "node:zlib"; + +import { describe, expect, it, vi } from "vitest"; + +const { annotate } = vi.hoisted(() => ({ annotate: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ annotate })); + +import { runPdfChild } from "../pdf-isolation"; +import { readPdfText } from "../pdfjs"; +import { rasterizePdf } from "../rasterize-pdf"; +import { generateThumbnail } from "../thumbnail"; + +/** + * A PDF decides how much memory and time reading it costs. A 400 KB upload + * declaring a 20000 x 20000 image made pdf.js allocate about 7 GB inside the + * web process; the container was killed for every user and the thumbnail job + * ran the same document again after the restart. pdf.js' `maxImageSize` + * stops that shape, but an image whose soft mask declares the same size still + * allocates it, so every read and render runs in a child process with a + * memory ceiling and a time limit. + * + * The documents below are built here: a page with one Flate-compressed image + * of zeros, which compresses about a thousand to one. + * + * Mutation checks: + * - drop `maxImageSize` from `openPdfDocument` → "skips an image past the + * pixel limit" crosses its 250 MB budget and answers `limit`; + * - stop sampling the child's memory in `runPdfChild` → the soft-mask case + * takes gigabytes and comes back `ok`; + * - run `rasterizePdf` in this process again → the soft-mask case grows + * this process by gigabytes. + */ + +async function deflateZeros(width: number, height: number): Promise { + const deflate = createDeflate({ level: 9 }); + const chunks: Buffer[] = []; + deflate.on("data", (chunk: Buffer) => chunks.push(chunk)); + const done = new Promise((resolve, reject) => { + deflate.on("end", resolve); + deflate.on("error", reject); + }); + const row = Buffer.alloc(width); + for (let y = 0; y < height; y++) { + if (!deflate.write(row)) { + await new Promise((resolve) => deflate.once("drain", resolve)); + } + } + deflate.end(); + await done; + return Buffer.concat(chunks); +} + +function assemble(objects: Buffer[]): Buffer { + const parts: Buffer[] = [Buffer.from("%PDF-1.4\n", "latin1")]; + let length = parts[0]!.length; + const offsets: number[] = []; + objects.forEach((body, i) => { + offsets.push(length); + const part = Buffer.concat([ + Buffer.from(`${i + 1} 0 obj\n`, "latin1"), + body, + Buffer.from("\nendobj\n", "latin1"), + ]); + parts.push(part); + length += part.length; + }); + let tail = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (const offset of offsets) { + tail += `${String(offset).padStart(10, "0")} 00000 n \n`; + } + tail += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${length}\n%%EOF\n`; + parts.push(Buffer.from(tail, "latin1")); + return Buffer.concat(parts); +} + +function stream(dict: string, data: Buffer): Buffer { + return Buffer.concat([ + Buffer.from(`<< ${dict} /Length ${data.length} >>\nstream\n`, "latin1"), + data, + Buffer.from("\nendstream", "latin1"), + ]); +} + +/** + * One page drawing one image. With `softMask`, the image is 2 x 2 and its + * soft mask carries the declared size instead. + */ +async function imagePdf(size: number, softMask = false): Promise { + const zeros = await deflateZeros(size, size); + const big = `/Type /XObject /Subtype /Image /Width ${size} /Height ${size} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode`; + const content = Buffer.from("q 612 0 0 792 0 0 cm /Im1 Do Q", "latin1"); + const objects = [ + Buffer.from("<< /Type /Catalog /Pages 2 0 R >>", "latin1"), + Buffer.from("<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "latin1"), + Buffer.from( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /XObject << /Im1 5 0 R >> >> /Contents 4 0 R >>", + "latin1", + ), + stream("", content), + softMask + ? stream( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R", + Buffer.alloc(4), + ) + : stream(big, zeros), + ]; + if (softMask) objects.push(stream(big, zeros)); + return assemble(objects); +} + +const RENDER_ONE_PAGE = { + maxPages: 1, + targetLongEdge: 1600, + jpegQuality: 80, + minInkRatio: 0.0001, +}; + +const fixture = readFileSync( + join(process.cwd(), "tests", "fixtures", "pdf", "fallback-fonts.pdf"), +); + +describe("PDF reading runs in a bounded child process", () => { + it("renders and reads an ordinary document", async () => { + const raster = await rasterizePdf(fixture, 1, { withText: true }); + expect(raster.ok).toBe(true); + if (!raster.ok) return; + expect(raster.images).toHaveLength(1); + expect(raster.texts?.[0]?.length).toBeGreaterThan(0); + await expect(readPdfText(fixture)).resolves.toMatch(/\w{4}/); + }); + + it("skips an image past the pixel limit and stays inside a small budget", async () => { + // 100 million pixels: about 1.7 GB to decode when nothing stops it. + const bomb = await imagePdf(10_000); + expect(bomb.length).toBeLessThan(200_000); + + const result = await runPdfChild( + { op: "render", data: bomb, options: RENDER_ONE_PAGE }, + { timeoutMs: 30_000, memoryMb: 250 }, + ); + expect(result).toMatchObject({ ok: true }); + if (!result.ok) return; + // The image was left out, so the page is blank. + expect(result.value.pages[0]!.blank).toBe(true); + }); + + it("ends a document that outgrows the ceiling, and the server keeps running", async () => { + const bomb = await imagePdf(20_000, true); + const before = process.memoryUsage().rss; + + const result = await runPdfChild( + { op: "render", data: bomb, options: RENDER_ONE_PAGE }, + { timeoutMs: 60_000, memoryMb: 300 }, + ); + expect(result).toEqual({ ok: false, reason: "limit", limit: "memory" }); + // Nothing of the decode landed in this process. + expect(process.memoryUsage().rss - before).toBeLessThan(200 * 1024 ** 2); + }, 90_000); + + it("ends a read that runs past its time limit", async () => { + const result = await runPdfChild( + { op: "text", data: fixture, maxPages: 1 }, + { timeoutMs: 1 }, + ); + expect(result).toEqual({ ok: false, reason: "limit", limit: "time" }); + }); + + it("reports a document that crossed a bound as final, not as a render failure", async () => { + const bomb = await imagePdf(20_000, true); + // The default ceiling: what the server runs with. + const thumb = await generateThumbnail(bomb, "application/pdf"); + expect(thumb).toEqual({ ok: false, reason: "resource-limit" }); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "documents.rasterize.failed" }, + meta: { reason: "resource_limit", limit: "memory" }, + }); + }, 90_000); + + it("names what pdf.js threw on a file that is not a PDF", async () => { + const result = await runPdfChild( + { op: "text", data: Buffer.from("not a pdf"), maxPages: 1 }, + { timeoutMs: 30_000 }, + ); + expect(result).toMatchObject({ ok: false, reason: "error" }); + }); +}); diff --git a/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts b/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts index bdafb0bfa..3c6d3a02b 100644 --- a/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts +++ b/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts @@ -6,10 +6,6 @@ const { destroyLoadingTask } = vi.hoisted(() => ({ destroyLoadingTask: vi.fn(async () => undefined), })); -vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); -vi.mock("../native-canvas-support", () => ({ - nativeCanvasSupported: () => true, -})); vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({ getDocument: () => ({ destroy: destroyLoadingTask, @@ -42,19 +38,22 @@ vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({ }), })); -import { rasterizePdf } from "../rasterize-pdf"; +import { renderPdfPages } from "../pdf-engine.mjs"; -describe("rasterizePdf PDF.js lifecycle", () => { +describe("renderPdfPages PDF.js lifecycle", () => { beforeEach(() => { destroyLoadingTask.mockClear(); }); it("destroys the loading task after rendering", async () => { - await expect(rasterizePdf(Buffer.from("synthetic"))).resolves.toMatchObject( - { - ok: true, - }, - ); + await expect( + renderPdfPages(Buffer.from("synthetic"), { + maxPages: 1, + targetLongEdge: 1600, + jpegQuality: 80, + minInkRatio: 0.0001, + }), + ).resolves.toMatchObject({ pageCount: 1 }); expect(destroyLoadingTask).toHaveBeenCalledOnce(); }); }); diff --git a/src/lib/documents/__tests__/rasterize-pdf-font-data.test.ts b/src/lib/documents/__tests__/rasterize-pdf-font-data.test.ts index 298ccf4dd..900e61653 100644 --- a/src/lib/documents/__tests__/rasterize-pdf-font-data.test.ts +++ b/src/lib/documents/__tests__/rasterize-pdf-font-data.test.ts @@ -61,7 +61,12 @@ vi.mock("@napi-rs/canvas", async (importActual) => { }; }); -import { __resetPdfjsAssetsForTests } from "../pdfjs"; +import { + __resetFallbackFamiliesForTests, + __resetPdfjsAssetsForTests, + PDF_MAX_IMAGE_PIXELS, + renderPdfPages, +} from "../pdf-engine.mjs"; import * as raster from "../rasterize-pdf"; import { generateThumbnail } from "../thumbnail"; @@ -87,15 +92,31 @@ beforeEach(() => { registerFromPath.mockClear(); getDocumentOptions.length = 0; __resetPdfjsAssetsForTests(); - raster.__resetFallbackFamiliesForTests(); + __resetFallbackFamiliesForTests(); }); +/** + * The server renders in a child process (`../pdf-isolation.ts`), where the + * mocks above cannot see. The option and registration checks therefore drive + * the engine the child runs in this process; the rendering checks go through + * `rasterizePdf` and the child, as the server does. + */ +const renderHere = (data: Buffer) => + renderPdfPages(data, { + maxPages: 1, + targetLongEdge: raster.RASTER_TARGET_LONG_EDGE, + jpegQuality: raster.RASTER_JPEG_QUALITY, + minInkRatio: raster.RASTER_MIN_INK_RATIO, + }); + describe("rasterizePdf font and decoder data (#1124)", () => { it("gives pdfjs its font, CMap and wasm directories and no system fonts", async () => { - const result = await raster.rasterizePdf(fixture("fallback-fonts.pdf"), 1); - expect(result.ok).toBe(true); + const result = await renderHere(fixture("fallback-fonts.pdf")); + expect(result.pages).toHaveLength(1); const opts = getDocumentOptions.at(-1)!; expect(opts.useSystemFonts).toBe(false); + expect(opts.disableFontFace).toBe(true); + expect(opts.maxImageSize).toBe(PDF_MAX_IMAGE_PIXELS); for (const [key, probe] of [ ["standardFontDataUrl", "FoxitSerif.pfb"], ["standardFontDataUrl", "LiberationSans-Regular.ttf"], @@ -109,7 +130,7 @@ describe("rasterizePdf font and decoder data (#1124)", () => { }); it("registers a fallback face for every generic family pdfjs falls back to", async () => { - await raster.rasterizePdf(fixture("fallback-fonts.pdf"), 1); + await renderHere(fixture("fallback-fonts.pdf")); const families = new Set(registerFromPath.mock.calls.map((c) => c[1])); expect(families).toEqual(new Set(["sans-serif", "serif", "monospace"])); for (const [path] of registerFromPath.mock.calls) { diff --git a/src/lib/documents/pdf-child.mjs b/src/lib/documents/pdf-child.mjs new file mode 100644 index 000000000..d143ad503 --- /dev/null +++ b/src/lib/documents/pdf-child.mjs @@ -0,0 +1,67 @@ +// @ts-check +/** + * The process a PDF is read and rendered in. `./pdf-isolation.ts` starts it + * for one request, hands it the bytes over the IPC channel, and takes back + * either the result or the fact that it died. It holds no secrets: it is + * started with an almost empty environment and never opens the database. + * + * On Linux it volunteers itself to the kernel's out-of-memory killer first, + * so when a document outruns the memory check between two samples and the + * container hits its limit, the kernel takes this process rather than the + * server. + */ +import { writeFileSync } from "node:fs"; + +import { readTextLayer, renderPdfPages } from "./pdf-engine.mjs"; + +try { + writeFileSync("/proc/self/oom_score_adj", "1000"); +} catch { + // Not Linux, or not permitted: the parent's memory check still applies. +} + +/** + * @typedef {{ op: "text"; data: Uint8Array; maxPages: number } + * | { op: "render"; data: Uint8Array; options: import("./pdf-engine.mjs").RenderOptions }} PdfChildRequest + */ + +/** + * @param {unknown} err + * @returns {{ ok: false; error: { name: string; message: string } }} + */ +function failure(err) { + return { + ok: false, + error: { + name: err instanceof Error ? err.name || "Error" : typeof err, + message: err instanceof Error ? err.message.slice(0, 300) : String(err), + }, + }; +} + +/** @param {PdfChildRequest} request */ +async function handle(request) { + try { + const data = new Uint8Array(request.data); + if (request.op === "text") { + return { ok: true, text: await readTextLayer(data, request.maxPages) }; + } + if (request.op === "render") { + return { + ok: true, + document: await renderPdfPages(data, request.options), + }; + } + return failure(new Error("unknown request")); + } catch (err) { + return failure(err); + } +} + +process.once("message", (message) => { + void handle(/** @type {PdfChildRequest} */ (message)).then((reply) => { + const send = process.send; + if (!send) process.exit(1); + send.call(process, reply, undefined, {}, () => process.exit(0)); + }); +}); diff --git a/src/lib/documents/pdf-engine.mjs b/src/lib/documents/pdf-engine.mjs new file mode 100644 index 000000000..a5e001325 --- /dev/null +++ b/src/lib/documents/pdf-engine.mjs @@ -0,0 +1,509 @@ +// @ts-check +/** + * The one pdf.js this server runs, the one way it is opened, and everything + * done with an open document: reading its text layer and rendering pages. + * + * The server never runs this module in its own process. A PDF is untrusted + * input whose cost is set by whoever wrote it: an image a few hundred bytes + * long can declare twenty thousand pixels square and make pdf.js allocate + * gigabytes while decoding it, and a content stream can keep the parser busy + * for minutes. Both used to happen inside the web process, where they took + * every user's requests down with them. Every caller now goes through + * `./pdf-isolation.ts`, which runs this module in a child process + * (`./pdf-child.mjs`) under a memory ceiling and a time limit; a document that + * crosses either one ends the child, not the server. The suites still import + * it directly to test the reading and rendering themselves. + * + * It is plain JavaScript because the child runs it with plain Node, from the + * checkout and from the standalone image alike; `@ts-check` keeps the + * compiler on it all the same. It imports nothing from the app, and it logs + * nothing: the caller in the server says what happened. + * + * Under Node, pdf.js does not start a worker thread: it loads its worker code + * into the same process and keeps it on `globalThis.pdfjsWorker`. Whichever + * copy of pdf.js opens a document first owns that global until the process + * exits, and any other copy then refuses every document with "The API version + * … does not match the Worker version …" (#1277). This is the only module + * that loads pdf.js; `src/__tests__/single-pdfjs-guard.test.ts` keeps a + * second copy out. + * + * pdf.js is loaded lazily with a runtime `import()`: its module top level + * reads the browser-only `DOMMatrix` global, and a static import in a module + * the server bundles would evaluate it at instantiation. + */ +import { Buffer } from "node:buffer"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +/** @typedef {import("pdfjs-dist").PDFDocumentLoadingTask} PDFDocumentLoadingTask */ +/** @typedef {import("pdfjs-dist/types/src/display/api").TextItem} TextItem */ +/** @typedef {import("pdfjs-dist/types/src/display/api").TextMarkedContent} TextMarkedContent */ + +/** + * The largest image, in pixels, pdf.js decodes; a larger one is left out of + * the page and the rest renders. pdf.js decodes an image at the size the file + * declares, whatever size the page is drawn at, so without this a 400 KB file + * declaring a 20000 x 20000 image allocated about 7 GB. Decoding costs about + * 21 bytes per pixel on top of the reader's own 120 MB, so this keeps one + * image inside the child's memory ceiling (`PDF_CHILD_MEMORY_MB` in + * `./pdf-isolation.ts`). A 300 dpi A3 scan is about 17 million pixels. + */ +export const PDF_MAX_IMAGE_PIXELS = 20_000_000; + +/** + * Where pdf.js finds the data it does not carry in its JS: the standard 14 + * fonts (a PDF that names Helvetica or Times without embedding them), the + * CMaps (CID fonts with a predefined encoding), and the wasm image decoders + * (JPEG 2000, JBIG2). Under Node they are read from disk, and without a + * directory pdf.js draws the text without glyphs and cannot map a + * predefined-CMap font to characters. The runtime image carries no system + * fonts at all, so nothing else catches that. + * + * Resolved from the working directory with Node's own resolution, which is + * what finds the hoisted `node_modules/pdfjs-dist` in the standalone image + * and the pnpm link in a checkout. The ICC profile option is not set: its + * loader needs a synchronous XMLHttpRequest that Node lacks, and pdf.js falls + * back to each colour space's alternate. + * + * @typedef {object} PdfjsAssets + * @property {string} standardFontDataUrl + * @property {string} cMapUrl + * @property {string} wasmUrl + * @property {string[]} fallbackFonts The Liberation Sans faces registered as fallback families. + */ + +/** @type {PdfjsAssets | null | undefined} */ +let cachedAssets; + +/** @returns {PdfjsAssets | null} */ +export function resolvePdfjsAssets() { + if (cachedAssets !== undefined) return cachedAssets; + cachedAssets = null; + try { + const anchor = createRequire(join(process.cwd(), "package.json")); + const root = dirname( + anchor.resolve(["pdfjs-dist", "package.json"].join("/")), + ); + const fonts = join(root, "standard_fonts"); + /** @type {PdfjsAssets} */ + const assets = { + standardFontDataUrl: `${fonts}/`, + cMapUrl: `${join(root, "cmaps")}/`, + wasmUrl: `${join(root, "wasm")}/`, + fallbackFonts: [ + "LiberationSans-Regular.ttf", + "LiberationSans-Bold.ttf", + "LiberationSans-Italic.ttf", + "LiberationSans-BoldItalic.ttf", + ].map((file) => join(fonts, file)), + }; + if ( + existsSync(join(fonts, "FoxitSerif.pfb")) && + existsSync(join(root, "cmaps", "UniJIS-UTF16-H.bcmap")) && + existsSync(join(root, "wasm", "openjpeg.wasm")) && + assets.fallbackFonts.every((file) => existsSync(file)) + ) { + cachedAssets = assets; + } + } catch { + // Unresolvable: open without the data; the caller says so. + } + return cachedAssets; +} + +/** Test hook: the asset lookup is per process. */ +export function __resetPdfjsAssetsForTests() { + cachedAssets = undefined; +} + +/** + * Start loading a PDF. The caller awaits `task.promise` and always awaits + * `task.destroy()` afterwards, which also tears the document down. + * + * Of pdf.js' resource options only `maxImageSize` acts under Node. The canvas + * area limit (`canvasMaxAreaInBytes`) only steers its OffscreenCanvas image + * resizer, which Node does not have, and pdf.js 6 has no eval path left for + * `isEvalSupported` to switch off. `disableFontFace` is already Node's + * default and is stated so a later default cannot quietly change it. + * + * @param {Uint8Array} data + * @returns {Promise} + */ +export async function openPdfDocument(data) { + const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); + const assets = resolvePdfjsAssets(); + return pdfjs.getDocument({ + // A copy: pdf.js transfers the buffer it is given to its worker. + data: new Uint8Array(data), + verbosity: 0, // VerbosityLevel.ERRORS: no console noise on odd PDFs. + // There are no system fonts to use: substitutes come from pdf.js' own + // standard font data (and, when rendering, the registered fallbacks). + useSystemFonts: false, + disableFontFace: true, + maxImageSize: PDF_MAX_IMAGE_PIXELS, + ...(assets + ? { + standardFontDataUrl: assets.standardFontDataUrl, + cMapUrl: assets.cMapUrl, + cMapPacked: true, + wasmUrl: assets.wasmUrl, + } + : {}), + }); +} + +/** + * Where a text run sits on the page. pdfjs gives each run a text matrix + * `[a, b, c, d, x, y]`: `x` and `y` are where the run starts, and `d` is its + * vertical scale, which for upright text is the font size. + * + * @typedef {object} RunPosition + * @property {number} x + * @property {number} y The baseline the run sits on. + * @property {number} end Where the run ends: its start plus its width. + * @property {number} fontSize + */ + +/** + * @param {TextItem} run + * @returns {RunPosition} + */ +function positionOf(run) { + const [, , , scaleY, x = 0, y = 0] = /** @type {number[]} */ (run.transform); + return { + x, + y, + end: x + run.width, + fontSize: Math.abs(scaleY ?? 0) || run.height || 1, + }; +} + +/** + * The next run is on a new line when its baseline moved by more than half a + * font size. Runs on one line can sit a little higher or lower (a superscript, + * a mixed font), so a small shift does not count. + * + * @param {RunPosition} previous + * @param {RunPosition} next + */ +function startsNewLine(previous, next) { + return Math.abs(next.y - previous.y) > previous.fontSize / 2; +} + +/** + * Two runs on one line are separate words when the gap between them is wider + * than a tenth of the font size. Runs that touch, such as a word drawn in two + * fonts, stay one word. + * + * @param {RunPosition} previous + * @param {RunPosition} next + */ +function leavesWordGap(previous, next) { + return next.x - previous.end > next.fontSize / 10; +} + +/** + * One page's text: its runs in order, with a line break where a run ends a + * line or the next one starts a new line, and a space where two runs on one + * line leave a gap between words. + * + * @param {Array} items + * @returns {string} + */ +export function joinTextRuns(items) { + let text = ""; + /** @type {RunPosition | null} */ + let previous = null; + + for (const item of items) { + // Marked-content markers carry no text. + if (!("str" in item)) continue; + const position = positionOf(item); + + if (previous && !text.endsWith("\n")) { + if (startsNewLine(previous, position)) { + text += "\n"; + } else if (leavesWordGap(previous, position) && !/\s$/.test(text)) { + text += " "; + } + } + text += item.str; + if (item.hasEOL && !text.endsWith("\n")) text += "\n"; + + previous = position; + } + return text; +} + +/** + * The text layer of the first `maxPages` pages, one page after another. + * Throws what pdf.js throws (a malformed file, a password, a page that will + * not parse); the document is torn down either way. + * + * @param {Uint8Array} data + * @param {number} [maxPages] + * @returns {Promise} + */ +export async function readTextLayer(data, maxPages = Number.POSITIVE_INFINITY) { + const task = await openPdfDocument(data); + try { + const doc = await task.promise; + const pageCount = Math.min(doc.numPages, maxPages); + /** @type {string[]} */ + const pages = []; + for (let n = 1; n <= pageCount; n++) { + const page = await doc.getPage(n); + try { + pages.push(joinTextRuns((await page.getTextContent()).items)); + } finally { + page.cleanup(); + } + } + return pages.join("\n"); + } finally { + try { + await task.destroy(); + } catch { + // Best-effort teardown; a destroy failure must not mask the result. + } + } +} + +/** + * The most text kept from one page's text layer when pages are rendered. + * Collection stops here while the items are read, so a PDF whose content + * streams inflate to megabytes of text cannot hold them in memory for the + * length of a run. A dense lab page is well below it. + */ +export const RASTER_PAGE_TEXT_MAX = 12_000; + +/** Marks where a text layer was cut. */ +export const TEXT_LAYER_CUT_MARK = "[text layer cut here]"; + +/** + * The head of `text` that fits `room` characters: cut at the last space before + * the limit so no number is cut in half, or hard at the limit when the text + * has no space to cut at. + * + * @param {string} text + * @param {number} room + */ +function headThatFits(text, room) { + if (text.length <= room) return text; + const head = text.slice(0, room); + const space = head.lastIndexOf(" "); + return space > 0 ? head.slice(0, space) : head; +} + +/** + * A page's text layer, as far as `pageText` reads it: text runs, and + * marked-content markers that carry only a `type` and no text. + * + * @typedef {object} TextLayerPage + * @property {() => Promise<{ items: Array<{ str?: string; hasEOL?: boolean; type?: string }> }>} getTextContent + */ + +/** + * A page's text layer, line by line: what the PDF states as characters. + * Empty for a page without one or on any failure. Past + * `RASTER_PAGE_TEXT_MAX` it is cut (at a line, else a space, else hard) and + * marked. + * + * @param {TextLayerPage} page + * @returns {Promise} + */ +export async function pageText(page) { + const max = RASTER_PAGE_TEXT_MAX; + try { + const { items } = await page.getTextContent(); + /** @type {string[]} */ + const lines = []; + let length = 0; + let line = ""; + // Cut at the last whole line; only a page whose first line alone runs + // past the cap keeps part of that line, cut at a space or hard. + const cut = () => { + if (lines.length === 0) { + const room = max - TEXT_LAYER_CUT_MARK.length - 1; + const head = headThatFits(line.replace(/\s+/g, " ").trim(), room); + if (head.trim()) lines.push(head.trim()); + } + lines.push(TEXT_LAYER_CUT_MARK); + return lines.join("\n"); + }; + /** Adds the line in hand; false when it does not fit. */ + const flush = () => { + const clean = line.replace(/\s+/g, " ").trim(); + if (!clean) { + line = ""; + return true; + } + if (length + clean.length + 1 > max - TEXT_LAYER_CUT_MARK.length - 1) { + return false; + } + lines.push(clean); + length += clean.length + 1; + line = ""; + return true; + }; + for (const item of items) { + line += `${item.str ?? ""} `; + // A line that runs on without a break is judged at the cap, so one + // endless line cannot grow past it either. + if ((item.hasEOL || line.length > max) && !flush()) return cut(); + } + if (!flush()) return cut(); + return lines.join("\n"); + } catch { + return ""; + } +} + +let fallbackFamiliesRegistered = false; + +/** + * Give the canvas a face for the generic families pdfjs falls back to. + * + * When pdfjs cannot use a font program (a damaged or unsupported embedded + * font, or a non-embedded face outside the standard 14) it draws the text + * with the canvas' own `fillText` under the PDF's font name and a generic + * family. With no system fonts in the image that text vanished. Registering + * Liberation Sans, which pdfjs ships, under `sans-serif`, `serif` and + * `monospace` makes it legible instead. Once per process. + * + * @param {PdfjsAssets} assets + */ +async function registerFallbackFamilies(assets) { + if (fallbackFamiliesRegistered) return; + fallbackFamiliesRegistered = true; + const { GlobalFonts } = await import("@napi-rs/canvas"); + for (const family of ["sans-serif", "serif", "monospace"]) { + for (const file of assets.fallbackFonts) { + GlobalFonts.registerFromPath(file, family); + } + } +} + +/** Test hook: the font registration is per process. */ +export function __resetFallbackFamiliesForTests() { + fallbackFamiliesRegistered = false; +} + +/** + * Share of dark pixels on a rendered page (luminance below half). + * + * @param {{ getImageData(x: number, y: number, w: number, h: number): { data: Uint8ClampedArray } }} context + * @param {number} width + * @param {number} height + */ +function inkRatio(context, width, height) { + const { data } = context.getImageData(0, 0, width, height); + let dark = 0; + for (let i = 0; i < data.length; i += 4) { + if ( + /** @type {number} */ (data[i]) + + /** @type {number} */ (data[i + 1]) + + /** @type {number} */ (data[i + 2]) < + 384 + ) { + dark++; + } + } + const pixels = data.length / 4; + return pixels > 0 ? dark / pixels : 0; +} + +/** + * The canvas pdf.js draws into under Node is `@napi-rs/canvas`. pdf.js' own + * declarations are written for the DOM and type the document's canvas factory + * as a bare `object`, so the slice used here is named. + * + * @typedef {object} RenderTarget + * @property {{ width: number; height: number; toBuffer(mime: "image/jpeg", quality?: number): Buffer }} canvas + * @property {{ getImageData(x: number, y: number, w: number, h: number): { data: Uint8ClampedArray } }} context + * + * @typedef {object} RenderTargetFactory + * @property {(width: number, height: number) => RenderTarget} create + * @property {(target: RenderTarget) => void} [destroy] + */ + +/** + * @typedef {object} RenderOptions + * @property {number} maxPages Pages to render from the first. + * @property {number} targetLongEdge Longest edge of a rendered page, never upscaled. + * @property {number} jpegQuality + * @property {number} minInkRatio Below this share of dark pixels a page counts as blank. + * @property {boolean} [withText] Also read each rendered page's text layer. + * + * @typedef {object} RenderedPage + * @property {Buffer} jpeg + * @property {boolean} blank + * @property {string} [text] + * + * @typedef {object} RenderedDocument + * @property {number} pageCount Pages in the document, rendered or not. + * @property {RenderedPage[]} pages + * @property {boolean} assetsMissing Whether pdf.js ran without its font and decoder data. + */ + +/** + * Render the first `maxPages` pages of a PDF to JPEG. Throws what pdf.js + * throws; the document is torn down either way. + * + * @param {Uint8Array} data + * @param {RenderOptions} options + * @returns {Promise} + */ +export async function renderPdfPages(data, options) { + const assets = resolvePdfjsAssets(); + if (assets) await registerFallbackFamilies(assets); + + const task = await openPdfDocument(data); + try { + const doc = await task.promise; + const pageCount = Math.min(doc.numPages, Math.max(1, options.maxPages)); + /** @type {RenderedPage[]} */ + const pages = []; + 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, options.targetLongEdge / longEdge) : 1; + const viewport = page.getViewport({ scale }); + + const factory = /** @type {RenderTargetFactory} */ (doc.canvasFactory); + const target = factory.create( + Math.ceil(viewport.width), + Math.ceil(viewport.height), + ); + await page.render({ + canvasContext: /** @type {never} */ (target.context), + viewport, + canvas: /** @type {never} */ (target.canvas), + }).promise; + const blank = + inkRatio(target.context, target.canvas.width, target.canvas.height) < + options.minInkRatio; + const jpeg = target.canvas.toBuffer("image/jpeg", options.jpegQuality); + factory.destroy?.(target); + /** @type {RenderedPage} */ + const rendered = { jpeg: Buffer.from(jpeg), blank }; + if (options.withText) rendered.text = await pageText(page); + pages.push(rendered); + } finally { + page.cleanup(); + } + } + return { pageCount: doc.numPages, pages, assetsMissing: !assets }; + } finally { + try { + await task.destroy(); + } catch { + // Best-effort teardown; a destroy failure must not mask the result. + } + } +} diff --git a/src/lib/documents/pdf-isolation.ts b/src/lib/documents/pdf-isolation.ts new file mode 100644 index 000000000..5149f9b3e --- /dev/null +++ b/src/lib/documents/pdf-isolation.ts @@ -0,0 +1,294 @@ +/** + * Runs pdf.js in a child process with a memory ceiling and a time limit. + * + * A PDF's cost is set by whoever wrote it. A 400 KB file can declare an image + * of twenty thousand pixels square, and pdf.js allocated about 7 GB decoding + * it; a content stream can keep the parser busy for minutes. Inside the + * server either one took the web process, every user's requests and the + * background jobs down with it, and the job queue then retried the same + * document after the restart. pdf.js' own `maxImageSize` stops the first + * shape (see `./pdf-engine.mjs`), but an image mask, a font program or a + * shading can still allocate in proportion to what the file declares. So the + * reading itself happens in `./pdf-child.mjs`, one process per document: + * + * - the child's V8 heap is capped with `--max-old-space-size`; + * - its resident memory, which also counts the image buffers outside the + * heap, is sampled from here and the child is killed past the ceiling; + * - on Linux the child marks itself as the first thing the kernel's + * out-of-memory killer takes, for an allocation faster than the sampling; + * - it is killed when the time limit runs out; + * - it starts with an almost empty environment, so it holds no key, token + * or database address; + * - one runs at a time (`MAX_PARALLEL_CHILDREN`), so a burst of uploads + * cannot multiply the ceiling. + * + * A document that crosses a bound fails with `limit` and is not something a + * retry will fix; callers record it as a final failure. + * + * This module logs nothing and imports nothing from the app, so the + * end-to-end suite can read report text through `./pdfjs.ts` as well. + */ +import { execFile, fork, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { RenderOptions, RenderedDocument } from "./pdf-engine.mjs"; + +/** Resident memory a child may reach before it is killed. */ +export const PDF_CHILD_MEMORY_MB = 640; + +/** The child's V8 heap; the rest of the ceiling is image and canvas buffers. */ +const PDF_CHILD_HEAP_MB = 256; + +/** + * Children running at once in this process. One: the server and one child at + * its ceiling fit the default container limit; two would not. + */ +export const MAX_PARALLEL_CHILDREN = 1; + +/** How often the child's resident memory is read. */ +const SAMPLE_INTERVAL_MS = 50; + +/** + * Where the child script lives, relative to the working directory: the + * checkout root in development and the suites, `/app` in the image (traced in + * by `outputFileTracingIncludes` in `next.config.ts`). The bundler is told + * not to follow it: tracing `process.cwd()` pulls the whole checkout into + * the standalone output. + */ +const CHILD_PATH = ["src", "lib", "documents", "pdf-child.mjs"]; + +export type PdfChildRequest = + | { op: "text"; data: Uint8Array; maxPages: number } + | { op: "render"; data: Uint8Array; options: RenderOptions }; + +type PdfChildValue = R extends { op: "text" } + ? string + : RenderedDocument; + +/** Why a child gave no result. */ +export type PdfChildFailure = + /** The document crossed the memory ceiling or the time limit. */ + | { ok: false; reason: "limit"; limit: "memory" | "time" | "exit" } + /** pdf.js refused the document: malformed, encrypted, unreadable. */ + | { ok: false; reason: "error"; name: string; message: string }; + +export type PdfChildResult = + { ok: true; value: PdfChildValue } | PdfChildFailure; + +export interface PdfChildLimits { + timeoutMs: number; + /** Overrides `PDF_CHILD_MEMORY_MB`; the suites use a small one. */ + memoryMb?: number; +} + +// ── Concurrency ──────────────────────────────────────────────────────────── + +let running = 0; +const waiting: Array<() => void> = []; + +async function acquireSlot(): Promise { + if (running < MAX_PARALLEL_CHILDREN) { + running++; + return; + } + await new Promise((resolve) => waiting.push(resolve)); +} + +function releaseSlot(): void { + const next = waiting.shift(); + if (next) next(); + else running--; +} + +// ── Resident memory of another process ───────────────────────────────────── + +/** The child's resident set in MB, or null where it cannot be read. */ +async function residentMb(pid: number): Promise { + if (process.platform === "linux") { + try { + const status = await readFile(`/proc/${pid}/status`, "utf8"); + const kb = /^VmRSS:\s+(\d+)\s+kB/m.exec(status)?.[1]; + return kb ? Number(kb) / 1024 : null; + } catch { + return null; + } + } + if (process.platform === "darwin") { + return new Promise((resolve) => { + execFile("ps", ["-o", "rss=", "-p", String(pid)], (err, stdout) => { + const kb = Number(stdout.trim()); + resolve(err || !Number.isFinite(kb) || kb <= 0 ? null : kb / 1024); + }); + }); + } + return null; +} + +// ── One run ──────────────────────────────────────────────────────────────── + +interface ChildReply { + ok: boolean; + text?: string; + document?: RenderedDocument; + error?: { name: string; message: string }; +} + +function decodeDocument(document: RenderedDocument): RenderedDocument { + // Buffers arrive as plain Uint8Arrays over the channel. + return { + ...document, + pages: document.pages.map((page) => ({ + ...page, + jpeg: Buffer.from( + page.jpeg.buffer, + page.jpeg.byteOffset, + page.jpeg.byteLength, + ), + })), + }; +} + +/** + * Read or render one PDF in a child process. Never throws: a document that + * crosses a bound answers `limit`, one pdf.js refuses answers `error`. + */ +export async function runPdfChild( + request: R, + limits: PdfChildLimits, +): Promise> { + const script = join(/* turbopackIgnore: true */ process.cwd(), ...CHILD_PATH); + if (!existsSync(script)) { + return { + ok: false, + reason: "error", + name: "PdfChildMissing", + message: "the PDF reader is missing from this build", + }; + } + + await acquireSlot(); + try { + return await runOnce(script, request, limits); + } finally { + releaseSlot(); + } +} + +function runOnce( + script: string, + request: R, + limits: PdfChildLimits, +): Promise> { + const memoryMb = limits.memoryMb ?? PDF_CHILD_MEMORY_MB; + const heapMb = Math.min(PDF_CHILD_HEAP_MB, Math.max(32, memoryMb / 2)); + + return new Promise((resolve) => { + let settled = false; + let killedFor: "memory" | "time" | null = null; + let stderrTail = ""; + + let child: ChildProcess; + try { + child = fork(script, [], { + cwd: /* turbopackIgnore: true */ process.cwd(), + // Nothing the server holds: no keys, no database address, no tokens. + env: { NODE_ENV: process.env.NODE_ENV || "production" }, + execArgv: [`--max-old-space-size=${heapMb}`], + serialization: "advanced", + stdio: ["ignore", "ignore", "pipe", "ipc"], + }); + } catch (err) { + resolve({ + ok: false, + reason: "error", + name: err instanceof Error ? err.name : "Error", + message: err instanceof Error ? err.message.slice(0, 300) : "", + }); + return; + } + + const finish = (result: PdfChildResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearInterval(sampler); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + resolve(result); + }; + + const kill = (why: "memory" | "time") => { + if (killedFor) return; + killedFor = why; + child.kill("SIGKILL"); + }; + + const timer = setTimeout(() => kill("time"), limits.timeoutMs); + let sampling = false; + const sampler = setInterval(() => { + if (sampling || child.pid === undefined) return; + sampling = true; + void residentMb(child.pid).then((mb) => { + sampling = false; + if (mb !== null && mb > memoryMb) kill("memory"); + }); + }, SAMPLE_INTERVAL_MS); + + child.stderr?.on("data", (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString("utf8")).slice(-2000); + }); + + child.on("message", (raw) => { + const reply = raw as ChildReply; + if (reply.ok) { + const value = + request.op === "text" + ? reply.text + : decodeDocument(reply.document as RenderedDocument); + finish({ ok: true, value: value as PdfChildValue }); + } else { + finish({ + ok: false, + reason: "error", + name: reply.error?.name ?? "Error", + message: reply.error?.message ?? "", + }); + } + }); + + child.on("error", (err) => { + finish({ + ok: false, + reason: "error", + name: err.name, + message: err.message.slice(0, 300), + }); + }); + + child.on("close", () => { + if (killedFor) { + finish({ ok: false, reason: "limit", limit: killedFor }); + return; + } + // Gone without an answer: V8 hit its heap ceiling, the kernel took it + // for memory, or it crashed in native code. Any of them is the + // document's doing. + const heap = /heap limit|heap out of memory/i.test(stderrTail); + finish({ ok: false, reason: "limit", limit: heap ? "memory" : "exit" }); + }); + + child.send(request, (err) => { + if (err) { + finish({ + ok: false, + reason: "error", + name: err.name, + message: err.message.slice(0, 300), + }); + } + }); + }); +} diff --git a/src/lib/documents/pdfjs.ts b/src/lib/documents/pdfjs.ts index 9409323e9..e106ff129 100644 --- a/src/lib/documents/pdfjs.ts +++ b/src/lib/documents/pdfjs.ts @@ -1,236 +1,49 @@ /** - * The one pdf.js this server runs, the one way it is opened, and the one way - * its text layer is read. - * - * Under Node, pdf.js does not start a worker thread: it loads its worker code - * into the same process and keeps it on `globalThis.pdfjsWorker`. Whichever - * copy of pdf.js opens a document first owns that global until the process - * exits, and any other copy then refuses every document with "The API version - * … does not match the Worker version …". The server used to load two copies, - * one for the local text read and one for rasterizing, so whichever ran first - * broke the other until a restart (#1277). Both paths now open documents - * here, through the app's own `pdfjs-dist`, with the same options; - * `src/__tests__/single-pdfjs-guard.test.ts` keeps a second copy out. - * - * pdf.js is loaded lazily with a runtime `import()`: its module top level - * reads the browser-only `DOMMatrix` global, so a static import would make the - * server chunk evaluate it at instantiation and throw `DOMMatrix is not - * defined`, taking down every route and worker sharing the chunk. The import - * resolves once and is module-cached after that. The type imports below are - * erased at build, so they load nothing; they let the compiler check every - * call here against pdf.js' own API. + * Reading a PDF's text layer, the way the server does it: in a child process + * under a memory ceiling and a time limit (`./pdf-isolation.ts`), through the + * one pdf.js loader (`./pdf-engine.mjs`). Rendering goes the same way from + * `./rasterize-pdf.ts`. * * This module has no logging of its own, so the end-to-end suite can read * report text with it; the callers say what failed. */ -import { Buffer } from "node:buffer"; -import { existsSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; +import { runPdfChild } from "./pdf-isolation"; -import type { PDFDocumentLoadingTask } from "pdfjs-dist"; -import type { - TextItem, - TextMarkedContent, -} from "pdfjs-dist/types/src/display/api"; +export { joinTextRuns } from "./pdf-engine.mjs"; /** - * Where pdf.js finds the data it does not carry in its JS: the standard 14 - * fonts (a PDF that names Helvetica or Times without embedding them), the - * CMaps (CID fonts with a predefined encoding), and the wasm image decoders - * (JPEG 2000, JBIG2). In a browser pdf.js fetches these from a URL; under - * Node they are read from disk, and without a directory pdf.js draws the text - * without glyphs and cannot map a predefined-CMap font to characters. - * - * The runtime image carries no system fonts at all, so nothing else catches - * that: a letter set in a non-embedded font rendered as an empty page in the - * container while it rendered correctly on a workstation, where the operating - * system's fonts stood in. - * - * Resolved from the working directory with Node's own resolution, which is - * what finds the hoisted `node_modules/pdfjs-dist` in the standalone image - * and the pnpm link in a checkout. The specifier is assembled at runtime so - * the bundler leaves the lookup alone. The ICC profile option is not set: its - * loader needs a synchronous XMLHttpRequest that Node lacks, and pdf.js falls - * back to each colour space's alternate. + * How long one text read may take. The page walk is capped by the caller + * (200 pages for the content index); a document still inside this after that + * many pages is one no reader should wait on. */ -export interface PdfjsAssets { - standardFontDataUrl: string; - cMapUrl: string; - wasmUrl: string; - /** The Liberation Sans faces the rasterizer registers as fallback families. */ - fallbackFonts: string[]; -} +export const PDF_TEXT_TIMEOUT_MS = 60_000; -let cachedAssets: PdfjsAssets | null | undefined; - -export function resolvePdfjsAssets(): PdfjsAssets | null { - if (cachedAssets !== undefined) return cachedAssets; - cachedAssets = null; - try { - const anchor = createRequire(join(process.cwd(), "package.json")); - const root = dirname( - anchor.resolve(["pdfjs-dist", "package.json"].join("/")), - ); - const fonts = join(root, "standard_fonts"); - const assets: PdfjsAssets = { - standardFontDataUrl: `${fonts}/`, - cMapUrl: `${join(root, "cmaps")}/`, - wasmUrl: `${join(root, "wasm")}/`, - fallbackFonts: [ - "LiberationSans-Regular.ttf", - "LiberationSans-Bold.ttf", - "LiberationSans-Italic.ttf", - "LiberationSans-BoldItalic.ttf", - ].map((file) => join(fonts, file)), - }; - if ( - existsSync(join(fonts, "FoxitSerif.pfb")) && - existsSync(join(root, "cmaps", "UniJIS-UTF16-H.bcmap")) && - existsSync(join(root, "wasm", "openjpeg.wasm")) && - assets.fallbackFonts.every((file) => existsSync(file)) - ) { - cachedAssets = assets; - } - } catch { - // Unresolvable: open without the data; the caller says so. +/** A document that crossed the memory ceiling or the time limit. */ +export class PdfResourceLimitError extends Error { + override name = "PdfResourceLimitError"; + constructor(readonly limit: "memory" | "time" | "exit") { + super(`the PDF crossed the reader's ${limit} limit`); } - return cachedAssets; -} - -/** Test hook: the asset lookup is per process. */ -export function __resetPdfjsAssetsForTests(): void { - cachedAssets = undefined; -} - -/** - * Start loading a PDF. The caller awaits `task.promise` and always awaits - * `task.destroy()` afterwards, which also tears the document down. - */ -export async function openPdfDocument( - data: Uint8Array | Buffer, -): Promise { - const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); - const assets = resolvePdfjsAssets(); - return pdfjs.getDocument({ - // A copy: pdf.js transfers the buffer it is given to its worker. - data: new Uint8Array(data), - verbosity: 0, // VerbosityLevel.ERRORS: no console noise on odd PDFs. - // There are no system fonts to use: substitutes come from pdf.js' own - // standard font data (and, when rendering, the registered fallbacks). - useSystemFonts: false, - ...(assets - ? { - standardFontDataUrl: assets.standardFontDataUrl, - cMapUrl: assets.cMapUrl, - cMapPacked: true, - wasmUrl: assets.wasmUrl, - } - : {}), - }); -} - -/** - * Where a text run sits on the page. pdfjs gives each run a text matrix - * `[a, b, c, d, x, y]`: `x` and `y` are where the run starts, and `d` is its - * vertical scale, which for upright text is the font size. - */ -interface RunPosition { - x: number; - /** The baseline the run sits on. */ - y: number; - /** Where the run ends: its start plus its width. */ - end: number; - fontSize: number; -} - -function positionOf(run: TextItem): RunPosition { - const [, , , scaleY, x = 0, y = 0] = run.transform as number[]; - return { - x, - y, - end: x + run.width, - fontSize: Math.abs(scaleY ?? 0) || run.height || 1, - }; -} - -/** - * The next run is on a new line when its baseline moved by more than half a - * font size. Runs on one line can sit a little higher or lower (a superscript, - * a mixed font), so a small shift does not count. - */ -function startsNewLine(previous: RunPosition, next: RunPosition): boolean { - return Math.abs(next.y - previous.y) > previous.fontSize / 2; -} - -/** - * Two runs on one line are separate words when the gap between them is wider - * than a tenth of the font size. Runs that touch, such as a word drawn in two - * fonts, stay one word. - */ -function leavesWordGap(previous: RunPosition, next: RunPosition): boolean { - return next.x - previous.end > next.fontSize / 10; -} - -/** - * One page's text: its runs in order, with a line break where a run ends a - * line or the next one starts a new line, and a space where two runs on one - * line leave a gap between words. - */ -export function joinTextRuns( - items: Array, -): string { - let text = ""; - let previous: RunPosition | null = null; - - for (const item of items) { - // Marked-content markers carry no text. - if (!("str" in item)) continue; - const position = positionOf(item); - - if (previous && !text.endsWith("\n")) { - if (startsNewLine(previous, position)) { - text += "\n"; - } else if (leavesWordGap(previous, position) && !/\s$/.test(text)) { - text += " "; - } - } - text += item.str; - if (item.hasEOL && !text.endsWith("\n")) text += "\n"; - - previous = position; - } - return text; } /** * The text layer of the first `maxPages` pages, one page after another. - * Throws what pdf.js throws (a malformed file, a password, a page that will - * not parse); the document is torn down either way. + * Throws what pdf.js threw (a malformed file, a password, a page that will + * not parse) under its own name, or `PdfResourceLimitError` when the + * document crossed a bound. */ export async function readPdfText( - data: Uint8Array | Buffer, + data: Uint8Array, maxPages = Number.POSITIVE_INFINITY, ): Promise { - const task = await openPdfDocument(data); - try { - const doc = await task.promise; - const pageCount = Math.min(doc.numPages, maxPages); - const pages: string[] = []; - for (let n = 1; n <= pageCount; n++) { - const page = await doc.getPage(n); - try { - pages.push(joinTextRuns((await page.getTextContent()).items)); - } finally { - page.cleanup(); - } - } - return pages.join("\n"); - } finally { - try { - await task.destroy(); - } catch { - // Best-effort teardown; a destroy failure must not mask the result. - } - } + const result = await runPdfChild( + // Infinity does not cross the channel as a page count. + { op: "text", data, maxPages: Math.min(maxPages, Number.MAX_SAFE_INTEGER) }, + { timeoutMs: PDF_TEXT_TIMEOUT_MS }, + ); + if (result.ok) return result.value; + if (result.reason === "limit") throw new PdfResourceLimitError(result.limit); + const err = new Error(result.message); + err.name = result.name; + throw err; } diff --git a/src/lib/documents/rasterize-pdf.ts b/src/lib/documents/rasterize-pdf.ts index 5bb98f32a..e410ad6e6 100644 --- a/src/lib/documents/rasterize-pdf.ts +++ b/src/lib/documents/rasterize-pdf.ts @@ -11,10 +11,11 @@ * higher-fidelity native PDF block; a text-layer PDF is still read locally for * free before we ever rasterize). * - * pdf.js is opened through `./pdfjs`, the one loader the local text read uses - * too (two copies in one process break each other, #1277). It loads pdf.js - * and `@napi-rs/canvas` (its Node canvas backend) lazily, keeping both out of - * the eager server chunk. Rendering is pure local compute — no network, + * 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 @@ -33,7 +34,7 @@ import { Buffer } from "node:buffer"; import { annotate } from "@/lib/logging/context"; import { nativeCanvasSupported } from "./native-canvas-support"; -import { openPdfDocument, resolvePdfjsAssets, type PdfjsAssets } from "./pdfjs"; +import { runPdfChild } from "./pdf-isolation"; /** * Whether the PDF rasterizer is available in this build. The native @@ -85,7 +86,9 @@ export interface RasterImage { * 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). + * 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 = | { @@ -104,158 +107,22 @@ export type RasterResult = */ texts?: string[]; } - | { ok: false; reason: "unsupported" | "render-failed" }; + | { ok: false; reason: "unsupported" | "render-failed" | "resource-limit" }; + +export { + pageText, + RASTER_PAGE_TEXT_MAX, + TEXT_LAYER_CUT_MARK, +} from "./pdf-engine.mjs"; /** - * The canvas pdf.js draws into under Node is `@napi-rs/canvas`. pdf.js' own - * declarations are written for the DOM and type the document's canvas factory - * as a bare `object`, so the slice used here is named: a DOM-shaped canvas - * and 2D context, plus the JPEG encoder the native canvas adds. + * 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. */ -interface RenderTarget { - canvas: HTMLCanvasElement & { - toBuffer(mime: "image/jpeg", quality?: number): Buffer; - }; - context: CanvasRenderingContext2D; -} -interface RenderTargetFactory { - create(width: number, height: number): RenderTarget; - destroy?(target: RenderTarget): void; -} - -/** - * A page's text layer, as far as `pageText` reads it: text runs, and - * marked-content markers that carry only a `type` and no text. - */ -interface TextLayerPage { - getTextContent(): Promise<{ - items: Array<{ str?: string; hasEOL?: boolean; type?: string }>; - }>; -} - -interface GlobalFontsLike { - registerFromPath(path: string, nameAlias?: string): unknown; -} - -let fallbackFamiliesRegistered = false; - -/** - * Give the canvas a face for the generic families pdfjs falls back to. - * - * When pdfjs cannot use a font program (a damaged or unsupported embedded - * font, or a non-embedded face outside the standard 14) it draws the text - * with the canvas' own `fillText` under the PDF's font name and a generic - * family. With no system fonts in the image that text vanished. Registering - * Liberation Sans, which pdfjs ships, under `sans-serif`, `serif` and - * `monospace` makes it legible instead. Once per process. - */ -async function registerFallbackFamilies(assets: PdfjsAssets): Promise { - if (fallbackFamiliesRegistered) return; - fallbackFamiliesRegistered = true; - const { GlobalFonts } = (await import("@napi-rs/canvas")) as unknown as { - GlobalFonts: GlobalFontsLike; - }; - for (const family of ["sans-serif", "serif", "monospace"]) { - for (const file of assets.fallbackFonts) { - GlobalFonts.registerFromPath(file, family); - } - } -} - -/** Share of dark pixels on a rendered page (luminance below half). */ -function inkRatio( - context: Pick, - width: number, - height: number, -) { - const { data } = context.getImageData(0, 0, width, height); - let dark = 0; - for (let i = 0; i < data.length; i += 4) { - if (data[i]! + data[i + 1]! + data[i + 2]! < 384) dark++; - } - const pixels = data.length / 4; - return pixels > 0 ? dark / pixels : 0; -} - -/** Test hook: the font registration is per process. */ -export function __resetFallbackFamiliesForTests(): void { - fallbackFamiliesRegistered = false; -} - -/** - * The most text kept from one page's text layer. Collection stops here while - * the items are read, so a PDF whose content streams inflate to megabytes of - * text cannot hold them in memory for the length of a run. A dense lab page - * is well below it. - */ -export const RASTER_PAGE_TEXT_MAX = 12_000; - -/** Marks where a text layer was cut. */ -export const TEXT_LAYER_CUT_MARK = "[text layer cut here]"; - -/** - * The head of `text` that fits `room` characters: cut at the last space before - * the limit so no number is cut in half, or hard at the limit when the text - * has no space to cut at. - */ -function headThatFits(text: string, room: number): string { - if (text.length <= room) return text; - const head = text.slice(0, room); - const space = head.lastIndexOf(" "); - return space > 0 ? head.slice(0, space) : head; -} - -/** - * A page's text layer, line by line: what the PDF states as characters. - * Empty for a page without one or on any failure. Past - * `RASTER_PAGE_TEXT_MAX` it is cut (at a line, else a space, else hard) and - * marked. - */ -export async function pageText(page: TextLayerPage): Promise { - const max = RASTER_PAGE_TEXT_MAX; - try { - const { items } = await page.getTextContent(); - const lines: string[] = []; - let length = 0; - let line = ""; - // Cut at the last whole line; only a page whose first line alone runs - // past the cap keeps part of that line, cut at a space or hard. - const cut = (): string => { - if (lines.length === 0) { - const room = max - TEXT_LAYER_CUT_MARK.length - 1; - const head = headThatFits(line.replace(/\s+/g, " ").trim(), room); - if (head.trim()) lines.push(head.trim()); - } - lines.push(TEXT_LAYER_CUT_MARK); - return lines.join("\n"); - }; - /** Adds the line in hand; false when it does not fit. */ - const flush = (): boolean => { - const clean = line.replace(/\s+/g, " ").trim(); - if (!clean) { - line = ""; - return true; - } - if (length + clean.length + 1 > max - TEXT_LAYER_CUT_MARK.length - 1) { - return false; - } - lines.push(clean); - length += clean.length + 1; - line = ""; - return true; - }; - for (const item of items) { - line += `${item.str ?? ""} `; - // A line that runs on without a break is judged at the cap, so one - // endless line cannot grow past it either. - if ((item.hasEOL || line.length > max) && !flush()) return cut(); - } - if (!flush()) return cut(); - return lines.join("\n"); - } catch { - return ""; - } -} +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 @@ -269,11 +136,10 @@ export async function rasterizePdf( maxPages: number = RASTER_MAX_PAGES, opts: { withText?: boolean } = {}, ): Promise { - let task: Awaited> | 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). + // 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" }, @@ -281,114 +147,87 @@ export async function rasterizePdf( }); return { ok: false, reason: "unsupported" }; } - try { - const assets = resolvePdfjsAssets(); - if (assets) { - await registerFallbackFamilies(assets); - } else { - annotate({ - action: { name: "documents.rasterize.assetsMissing" }, - meta: { reason: "pdfjs_assets_unresolved" }, - }); - } - task = await openPdfDocument(buffer); - const 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_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 }, + ); - // 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 pageCount = Math.min(doc.numPages, cap); - const images: RasterImage[] = []; - const texts: string[] = []; - let blankPages = 0; - - 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 as RenderTargetFactory; - const cc = factory.create( - Math.ceil(viewport.width), - Math.ceil(viewport.height), - ); - await page.render({ - canvasContext: cc.context, - viewport, - canvas: cc.canvas, - }).promise; - if ( - inkRatio(cc.context, cc.canvas.width, cc.canvas.height) < - RASTER_MIN_INK_RATIO - ) { - blankPages++; - } - const jpeg = cc.canvas.toBuffer("image/jpeg", RASTER_JPEG_QUALITY); - images.push({ - mediaType: "image/jpeg", - dataBase64: jpeg.toString("base64"), - }); - factory.destroy?.(cc); - if (opts.withText) texts.push(await pageText(page)); - } finally { - page.cleanup(); - } - } - - if (images.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. + if (!result.ok) { + if (result.reason === "limit") { annotate({ action: { name: "documents.rasterize.failed" }, - meta: { reason: "zero_pages", pageCount: doc.numPages }, + meta: { reason: "resource_limit", limit: result.limit }, }); - return { ok: false, reason: "render-failed" }; + return { ok: false, reason: "resource-limit" }; } - if (blankPages === images.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: images.length }, - }); - return { ok: false, reason: "render-failed" }; - } - annotate({ - action: { name: "documents.rasterize.ok" }, - meta: { pages: images.length, cappedFrom: doc.numPages, blankPages }, - }); - return { - ok: true, - images, - pageCount: doc.numPages, - ...(opts.withText ? { texts } : {}), - }; - } 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), + 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" }; - } finally { - if (task) { - try { - await task.destroy(); - } catch { - // Best-effort teardown; a destroy failure must not mask the result. - } - } } + + 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 ?? "") } + : {}), + }; } diff --git a/src/lib/documents/thumbnail.ts b/src/lib/documents/thumbnail.ts index 26ed69c1b..517011dc7 100644 --- a/src/lib/documents/thumbnail.ts +++ b/src/lib/documents/thumbnail.ts @@ -167,6 +167,7 @@ export type ThumbnailSkipReason = | "native-canvas-unsupported" | "pixel-cap" | "raster-failed" + | "resource-limit" | "empty-render" | "error"; @@ -288,6 +289,11 @@ export async function generateThumbnail( if (mimeType === "application/pdf") { // Page 1 only — a preview never renders the whole document. const raster = await rasterizePdf(bytes, 1); + if (!raster.ok && raster.reason === "resource-limit") { + // The page exhausted the renderer's memory or time bound; the same + // bytes will again, so the job records it as final. + return { ok: false, reason: "resource-limit" }; + } if (!raster.ok || raster.images.length === 0) { return { ok: false, reason: "raster-failed" }; } diff --git a/src/lib/jobs/document-thumbnail-backfill.ts b/src/lib/jobs/document-thumbnail-backfill.ts index bdd373642..e921cb7ce 100644 --- a/src/lib/jobs/document-thumbnail-backfill.ts +++ b/src/lib/jobs/document-thumbnail-backfill.ts @@ -49,13 +49,16 @@ const THUMBNAILABLE_MIMES = [ /** * Render refusals that the same bytes will always get again: the first page - * renders blank, the render comes out empty, the image is past the pixel cap. - * One attempt settles them. + * renders blank, the render comes out empty, the image is past the pixel cap, + * the PDF crossed the renderer's memory or time bound. One attempt settles + * them; queueing a document that exhausts the renderer again at every boot + * would only repeat the exhaustion. */ const FINAL_THUMBNAIL_FAILURES = [ "raster-failed", "empty-render", "pixel-cap", + "resource-limit", ] as const; /** Attempts any other failure (a throw, a key that did not open) gets. */