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. */