diff --git a/src/lib/documents/__tests__/pdfjs-text-layer.test.ts b/src/lib/documents/__tests__/pdfjs-text-layer.test.ts index aae80e893..ad331d608 100644 --- a/src/lib/documents/__tests__/pdfjs-text-layer.test.ts +++ b/src/lib/documents/__tests__/pdfjs-text-layer.test.ts @@ -1,80 +1,89 @@ import { jsPDF } from "jspdf"; +import type { + TextItem, + TextMarkedContent, +} from "pdfjs-dist/types/src/display/api"; import { describe, expect, it } from "vitest"; -import { pageTextLayer, readPdfText, type PdfTextItem } from "../pdfjs"; +import { joinTextRuns, readPdfText } from "../pdfjs"; /** - * The text layer as the content index reads it. The layout is the one the - * local text read produced before it moved onto the app's own pdf.js (#1277): - * a break where pdf.js marks one or the next item sits on a lower line, a tab - * between far-apart items on one line, each page followed by a blank line. + * The text layer as the content index reads it (#1277): runs in order, a line + * break where a run ends its line or the next one sits on another, a space + * where two runs on one line leave a gap, and nothing where they touch, so a + * word drawn in two fonts stays one word and a search for it still finds it. */ -/** A page whose viewport maps PDF space to itself, flipped like pdf.js. */ -function pageOf(items: PdfTextItem[]) { - return { - getViewport: () => ({ - width: 600, - height: 800, - convertToViewportPoint: (x: number, y: number) => [x, 800 - y], - }), - getTextContent: async () => ({ items }), - }; -} - -function item( +/** A 10 pt run starting at (x, y), 5 pt per character. */ +function run( str: string, x: number, y: number, - extra: Partial = {}, -): PdfTextItem { + extra: Partial = {}, +): TextItem { return { str, + dir: "ltr", transform: [10, 0, 0, 10, x, y], width: str.length * 5, height: 10, + fontName: "g_d0_f1", hasEOL: false, ...extra, }; } -describe("pageTextLayer", () => { - it("breaks once where pdf.js marks the end of a line", async () => { - const text = await pageTextLayer( - pageOf([ - item("Haemoglobin", 10, 700, { hasEOL: true }), - item("14.2 g/dL", 10, 688), +describe("joinTextRuns", () => { + it("keeps a word drawn in two fonts whole", () => { + expect( + joinTextRuns([ + run("Haem", 10, 700), + run("oglobin", 30, 700, { fontName: "g_d0_f2" }), ]), - ); - expect(text).toBe("Haemoglobin\n14.2 g/dL"); + ).toBe("Haemoglobin"); }); - it("breaks when the next item sits a line lower without a marked end", async () => { - const text = await pageTextLayer( - pageOf([item("Creatinine", 10, 700), item("0.9 mg/dL", 10, 680)]), + it("puts a space between runs on one line that leave a gap", () => { + expect(joinTextRuns([run("Glucose", 10, 700), run("92", 200, 700)])).toBe( + "Glucose 92", ); - expect(text).toBe("Creatinine\n0.9 mg/dL"); }); - it("does not break for a small baseline shift on one line", async () => { - const text = await pageTextLayer( - pageOf([item("10", 10, 700), item("3", 21, 703)]), - ); - expect(text).toBe("103"); + it("breaks once where a run ends its line", () => { + expect( + joinTextRuns([ + run("Haemoglobin", 10, 700, { hasEOL: true }), + run("14.2 g/dL", 10, 688), + ]), + ).toBe("Haemoglobin\n14.2 g/dL"); }); - it("separates far-apart items on one line with a tab", async () => { - const text = await pageTextLayer( - pageOf([item("Glucose", 10, 700), item("92", 200, 700)]), - ); - expect(text).toBe("Glucose\t92"); + it("breaks where the next run sits on another line without a marked end", () => { + expect( + joinTextRuns([run("Creatinine", 10, 700), run("0.9", 10, 680)]), + ).toBe("Creatinine\n0.9"); }); - it("skips marked-content items, which carry no text", async () => { - const text = await pageTextLayer( - pageOf([{ hasEOL: false } as PdfTextItem, item("Text", 10, 700)]), - ); - expect(text).toBe("Text"); + it("keeps a raised run on its line", () => { + expect(joinTextRuns([run("10", 10, 700), run("9", 20, 703)])).toBe("109"); + }); + + it("adds no blank line for an empty run that only ends a line", () => { + expect( + joinTextRuns([ + run("Line", 10, 700, { hasEOL: true }), + run("", 30, 700, { hasEOL: true }), + run("Next", 10, 688), + ]), + ).toBe("Line\nNext"); + }); + + it("skips marked-content markers, which carry no text", () => { + const marker: TextMarkedContent = { + type: "beginMarkedContent", + id: "mc0", + }; + expect(joinTextRuns([marker, run("Text", 10, 700)])).toBe("Text"); }); }); @@ -89,10 +98,10 @@ describe("readPdfText", () => { return Buffer.from(doc.output("arraybuffer")); } - it("joins pages with a blank line and adds no page markers", async () => { + it("reads the pages one after another and adds no page markers", async () => { const text = await readPdfText(pages(2)); expect(text).toBe( - "Page 1 lab report\nsecond line\n\nPage 2 lab report\nsecond line\n\n", + "Page 1 lab report\nsecond line\nPage 2 lab report\nsecond line", ); }); @@ -102,6 +111,17 @@ describe("readPdfText", () => { expect(text).not.toContain("Page 3"); }); + it("keeps a word whole that a real PDF draws in two fonts", async () => { + const doc = new jsPDF(); + doc.setFont("helvetica", "bold"); + doc.text("Haem", 10, 10); + const joint = 10 + doc.getTextWidth("Haem"); + doc.setFont("times", "normal"); + doc.text("oglobin 14.2 g/dL", joint, 10); + const text = await readPdfText(Buffer.from(doc.output("arraybuffer"))); + expect(text).toBe("Haemoglobin 14.2 g/dL"); + }); + it("throws what pdf.js throws on a file that is not a PDF", async () => { await expect(readPdfText(Buffer.from("not a pdf"))).rejects.toThrow(); }); diff --git a/src/lib/documents/local-extract.ts b/src/lib/documents/local-extract.ts index e726f4a27..327f8a8b9 100644 --- a/src/lib/documents/local-extract.ts +++ b/src/lib/documents/local-extract.ts @@ -7,13 +7,14 @@ * never the native `@napi-rs/canvas`), in milliseconds, with no OCR and no * third-party egress. * - * It reads through the app's own `pdfjs-dist`, the copy `./rasterize-pdf` - * renders with. It used to go through `pdf-parse`, which brings a second - * pdfjs of its own, and the two cannot share a process: both take their - * worker from `globalThis.pdfjsWorker`, so whichever ran first broke the - * other ("The API version "6.4.299" does not match the Worker version - * "5.4.296""). Thumbnails and vision reads failed after a text extraction, - * and the text extraction failed after a raster. + * It reads through `./pdfjs`, the one loader `./rasterize-pdf` opens documents + * with too, so both run the app's own `pdfjs-dist` with the same options. It + * used to go through `pdf-parse`, which brings a second pdfjs of its own, and + * the two cannot share a process: both take their worker from + * `globalThis.pdfjsWorker`, so whichever ran first broke the other ("The API + * version "6.4.299" does not match the Worker version "5.4.296""). Thumbnails + * and vision reads failed after a text extraction, and the text extraction + * failed after a raster. * The extracted text feeds the SAME blind, encrypted content index the provider * path writes (`upsertContentIndex`), so nothing readable is stored at rest. * @@ -28,92 +29,10 @@ */ import { Buffer } from "node:buffer"; -import type { PDFDocumentLoadingTask } from "pdfjs-dist"; -import type { - TextItem, - TextMarkedContent, -} from "pdfjs-dist/types/src/display/api"; - import { annotate } from "@/lib/logging/context"; +import { logCaught } from "@/lib/logging/signal"; -// pdfjs' module top-level references the browser-only `DOMMatrix` global. A -// STATIC import makes the Turbopack server chunk evaluate that reference at -// instantiation and throw `DOMMatrix is not defined`, taking down every -// route/worker that shares the chunk (the document routes among them). The -// module is therefore loaded lazily inside `extractPdfText` via a runtime -// `import()`. The type imports above are erased at build, so they load -// nothing; they let the compiler check this file against pdfjs' own API. - -/** - * 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. - */ -function pageText(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; -} +import { readPdfText } from "./pdfjs"; /** * Minimum joined text length for a PDF to count as a real text-layer document. @@ -145,49 +64,32 @@ export type LocalExtractResult = * Extract a text-layer PDF's embedded text server-side. Returns `empty` for a * scanned/no-text-layer PDF and `error` for a malformed/encrypted one — never * throws, so a bad document can never abort the caller (the upload/backfill job - * just leaves it un-indexed). + * just leaves it un-indexed). An `error` leaves a `warn` line naming what + * pdf.js threw, because the outcome alone does not say why a document stayed + * out of the index. */ export async function extractPdfText( buffer: Buffer, ): Promise { - let task: PDFDocumentLoadingTask | null = null; + let text: string; try { - // Lazy load: keeps pdfjs/DOMMatrix out of the eager server chunk (see the - // import-site note). Resolved at first PDF extraction, then module-cached. - const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); - task = pdfjs.getDocument({ - data: new Uint8Array(buffer), - verbosity: 0, // VerbosityLevel.ERRORS — no console spam on odd PDFs. - useSystemFonts: false, - }); - const doc = await task.promise; // Bound the page walk so a pathological many-page PDF costs no more. - const pageCount = Math.min(doc.numPages, LOCAL_MAX_PAGES); - const pages: string[] = []; - for (let n = 1; n <= pageCount; n++) { - const page = await doc.getPage(n); - try { - pages.push(pageText((await page.getTextContent()).items)); - } finally { - page.cleanup(); - } - } - const text = pages.join("\n").trim(); - if (text.length < LOCAL_TEXT_MIN_CHARS) { - return { ok: false, reason: "empty" }; - } - return { ok: true, text, source: "local-pdf" }; - } catch { + text = (await readPdfText(buffer, LOCAL_MAX_PAGES)).trim(); + } catch (err) { + const errorType = err instanceof Error ? err.name || "Error" : typeof err; + annotate({ + action: { name: "documents.localIndex.extractFailed" }, + meta: { localExtractError: errorType }, + }); + logCaught("documents.local_extract.failed", err, { + bytes: buffer.length, + }); return { ok: false, reason: "error" }; - } finally { - if (task) { - try { - await task.destroy(); - } catch { - // Best-effort teardown; a destroy failure must not mask the result. - } - } } + if (text.length < LOCAL_TEXT_MIN_CHARS) { + return { ok: false, reason: "empty" }; + } + return { ok: true, text, source: "local-pdf" }; } /** diff --git a/src/lib/documents/pdfjs.ts b/src/lib/documents/pdfjs.ts index 5d44c1f31..9409323e9 100644 --- a/src/lib/documents/pdfjs.ts +++ b/src/lib/documents/pdfjs.ts @@ -1,5 +1,6 @@ /** - * The one pdf.js this server runs, and the one way it is opened. + * 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 @@ -15,7 +16,9 @@ * 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. + * 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. * * 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. @@ -25,74 +28,11 @@ import { existsSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join } from "node:path"; -// Structural types for the slice of the pdf.js API used here and in the -// rasterizer. Type-only, so nothing evaluates pdf.js for its types. -export interface PdfViewport { - width: number; - height: number; - convertToViewportPoint(x: number, y: number): number[]; -} -export interface PdfCanvas { - width: number; - height: number; - toBuffer(mime: "image/jpeg", quality?: number): Buffer; -} -export interface PdfContext2D { - getImageData( - x: number, - y: number, - width: number, - height: number, - ): { data: Uint8ClampedArray }; -} -interface PdfCanvasAndContext { - canvas: PdfCanvas; - context: PdfContext2D; -} -interface PdfCanvasFactory { - create(width: number, height: number): PdfCanvasAndContext; - destroy?(target: PdfCanvasAndContext): void; -} -/** A text-layer item; marked-content items carry no `str` and are skipped. */ -export interface PdfTextItem { - str?: string; - hasEOL?: boolean; - transform?: number[]; - width?: number; - height?: number; -} -export interface PdfPage { - getViewport(opts: { scale: number }): PdfViewport; - render(opts: { - canvasContext: unknown; - viewport: PdfViewport; - canvas: unknown; - }): { promise: Promise }; - cleanup(): void; - getTextContent(): Promise<{ items: PdfTextItem[] }>; -} -export interface PdfDocument { - numPages: number; - canvasFactory: PdfCanvasFactory; - getPage(pageNumber: number): Promise; -} -export interface PdfLoadingTask { - promise: Promise; - destroy(): Promise; -} -interface PdfDocumentOptions { - data: Uint8Array; - verbosity?: number; - isEvalSupported?: boolean; - useSystemFonts?: boolean; - standardFontDataUrl?: string; - cMapUrl?: string; - cMapPacked?: boolean; - wasmUrl?: string; -} -interface PdfjsModule { - getDocument(opts: PdfDocumentOptions): PdfLoadingTask; -} +import type { PDFDocumentLoadingTask } from "pdfjs-dist"; +import type { + TextItem, + TextMarkedContent, +} from "pdfjs-dist/types/src/display/api"; /** * Where pdf.js finds the data it does not carry in its JS: the standard 14 @@ -169,15 +109,13 @@ export function __resetPdfjsAssetsForTests(): void { */ export async function openPdfDocument( data: Uint8Array | Buffer, -): Promise { - const pdfjs = - (await import("pdfjs-dist/legacy/build/pdf.mjs")) as unknown as PdfjsModule; +): 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. - isEvalSupported: false, // never eval font programs (defence in depth). // There are no system fonts to use: substitutes come from pdf.js' own // standard font data (and, when rendering, the registered fallbacks). useSystemFonts: false, @@ -192,73 +130,83 @@ export async function openPdfDocument( }); } -/** Vertical gap (pt) past which the next item starts a new line. */ -const LINE_THRESHOLD = 4.6; -/** Horizontal gap (pt) on one line past which the next item is a new cell. */ -const CELL_THRESHOLD = 7; - /** - * One page's text layer as plain text: items in content order, a line break - * where pdf.js marks one or where the next item sits on a lower line, and a - * tab between items on one line that are far apart (table cells). - * - * This is the layout `pdf-parse` produced with its defaults, kept so that - * swapping it out did not change what the content index sees. + * 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. */ -export async function pageTextLayer( - page: Pick, -): Promise { - const viewport = page.getViewport({ scale: 1 }); - const { items } = await page.getTextContent(); - const parts: string[] = []; - let lastX: number | undefined; - let lastY: number | undefined; - let lineHeight = 0; - for (const item of items) { - if (typeof item.str !== "string") continue; - const transform = item.transform ?? [1, 0, 0, 1, 0, 0]; - const [x = 0, y = 0] = viewport.convertToViewportPoint( - transform[4] ?? 0, - transform[5] ?? 0, - ); - let str = item.str; - if (lastY !== undefined) { - const gap = Math.abs(lastY - y); - if (gap > LINE_THRESHOLD) { - const previous = parts.at(-1); - const opensLine = - str.startsWith("\n") || (str.trim() === "" && item.hasEOL === true); - if ( - previous !== undefined && - !previous.endsWith("\n") && - !opensLine && - gap - 1 > lineHeight - ) { - parts.push("\n"); - lineHeight = 0; - } - } else if ( - gap < LINE_THRESHOLD && - lastX !== undefined && - Math.abs(lastX - x) > CELL_THRESHOLD - ) { - str = `\t${str}`; - } - } - parts.push(str); - lastX = x + (item.width ?? 0); - lastY = y; - lineHeight = Math.max(lineHeight, item.height ?? 0); - if (item.hasEOL) parts.push("\n"); - if (item.hasEOL || str.endsWith("\n")) lineHeight = 0; - } - return parts.join(""); +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 text layer of the first `maxPages` pages, each page followed by a blank - * line. Throws what pdf.js throws (a malformed file, a password, a page that - * will not parse); the document is torn down either way. + * 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. */ export async function readPdfText( data: Uint8Array | Buffer, @@ -267,17 +215,17 @@ export async function readPdfText( const task = await openPdfDocument(data); try { const doc = await task.promise; - const last = Math.min(doc.numPages, maxPages); - let text = ""; - for (let pageNumber = 1; pageNumber <= last; pageNumber++) { - const page = await doc.getPage(pageNumber); + const pageCount = Math.min(doc.numPages, maxPages); + const pages: string[] = []; + for (let n = 1; n <= pageCount; n++) { + const page = await doc.getPage(n); try { - text += `${await pageTextLayer(page)}\n\n`; + pages.push(joinTextRuns((await page.getTextContent()).items)); } finally { page.cleanup(); } } - return text; + return pages.join("\n"); } finally { try { await task.destroy(); diff --git a/src/lib/documents/rasterize-pdf.ts b/src/lib/documents/rasterize-pdf.ts index f23730e14..5bb98f32a 100644 --- a/src/lib/documents/rasterize-pdf.ts +++ b/src/lib/documents/rasterize-pdf.ts @@ -33,15 +33,7 @@ import { Buffer } from "node:buffer"; import { annotate } from "@/lib/logging/context"; import { nativeCanvasSupported } from "./native-canvas-support"; -import { - openPdfDocument, - resolvePdfjsAssets, - type PdfContext2D, - type PdfDocument, - type PdfjsAssets, - type PdfLoadingTask, - type PdfPage, -} from "./pdfjs"; +import { openPdfDocument, resolvePdfjsAssets, type PdfjsAssets } from "./pdfjs"; /** * Whether the PDF rasterizer is available in this build. The native @@ -114,6 +106,33 @@ export type RasterResult = } | { ok: false; reason: "unsupported" | "render-failed" }; +/** + * 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. + */ +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; } @@ -144,7 +163,11 @@ async function registerFallbackFamilies(assets: PdfjsAssets): Promise { } /** Share of dark pixels on a rendered page (luminance below half). */ -function inkRatio(context: PdfContext2D, width: number, height: number) { +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) { @@ -188,9 +211,7 @@ function headThatFits(text: string, room: number): string { * `RASTER_PAGE_TEXT_MAX` it is cut (at a line, else a space, else hard) and * marked. */ -export async function pageText( - page: Pick, -): Promise { +export async function pageText(page: TextLayerPage): Promise { const max = RASTER_PAGE_TEXT_MAX; try { const { items } = await page.getTextContent(); @@ -248,8 +269,7 @@ export async function rasterizePdf( maxPages: number = RASTER_MAX_PAGES, opts: { withText?: boolean } = {}, ): Promise { - let doc: PdfDocument | null = null; - let task: PdfLoadingTask | null = null; + 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 @@ -273,7 +293,7 @@ export async function rasterizePdf( } task = await openPdfDocument(buffer); - doc = await task.promise; + 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); @@ -292,7 +312,7 @@ export async function rasterizePdf( longEdge > 0 ? Math.min(1, RASTER_TARGET_LONG_EDGE / longEdge) : 1; const viewport = page.getViewport({ scale }); - const factory = doc.canvasFactory; + const factory = doc.canvasFactory as RenderTargetFactory; const cc = factory.create( Math.ceil(viewport.width), Math.ceil(viewport.height),