diff --git a/src/lib/documents/__tests__/prepare-vision-raster-failure.test.ts b/src/lib/documents/__tests__/prepare-vision-raster-failure.test.ts index 05af83fdc..3e01f2d75 100644 --- a/src/lib/documents/__tests__/prepare-vision-raster-failure.test.ts +++ b/src/lib/documents/__tests__/prepare-vision-raster-failure.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { Buffer } from "node:buffer"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; /** * Refs #776 — the raster-failed chain, end to end through the REAL @@ -68,4 +70,17 @@ describe("raster-failed end to end (#776)", () => { const vision = await prepareVisionInput(DOC as never, true); expect(vision).toMatchObject({ ok: true }); }); + + it("a PDF whose pages all render blank is rasterFailed, not an image of nothing (#1124)", async () => { + // Text in render mode 3 only: pdfjs opens it and draws an empty page. + // Before the blank-page guard this went to the provider as one white + // JPEG and came back as a near-empty transcript indexed as a success. + vi.mocked(decryptDocumentContent).mockReturnValue( + readFileSync( + join(process.cwd(), "tests/fixtures/pdf/invisible-text-only.pdf"), + ) as never, + ); + const vision = await prepareVisionInput(DOC as never, false); + expect(vision).toEqual({ ok: false, reason: "rasterFailed" }); + }); }); diff --git a/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts b/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts index 4e0d2cdba..bdafb0bfa 100644 --- a/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts +++ b/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts @@ -18,9 +18,16 @@ vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({ canvasFactory: { create: () => ({ canvas: { + width: 1, + height: 1, toBuffer: () => Buffer.from([0xff, 0xd8]), }, - context: {}, + // One black pixel: an inked page, so the blank-page guard passes. + context: { + getImageData: () => ({ + data: new Uint8ClampedArray([0, 0, 0, 255]), + }), + }, }), }, getPage: async () => ({ diff --git a/src/lib/documents/__tests__/rasterize-pdf-font-data.test.ts b/src/lib/documents/__tests__/rasterize-pdf-font-data.test.ts new file mode 100644 index 000000000..86fd7c5fe --- /dev/null +++ b/src/lib/documents/__tests__/rasterize-pdf-font-data.test.ts @@ -0,0 +1,155 @@ +import { Buffer } from "node:buffer"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { createCanvas, loadImage } from "@napi-rs/canvas"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * GitHub #1124 — PDFs from older OCRmyPDF archives got no thumbnail and an + * image-only vision read of them came back nearly empty. + * + * The runtime image has no system fonts. pdfjs was opened without its own + * font data and told to lean on system fonts, so any text it could not draw + * from an embedded program (a non-embedded standard font, or an embedded + * program it cannot parse) came out as nothing, and the page that reached + * the provider was the background alone. A workstation hides this because + * its own fonts stand in, so the rendering assertions below are regression + * guards here and the container is where they were proven red; the option + * and registration assertions are red on any host. + * + * Fixtures (`tests/fixtures/pdf/`, synthetic text only): + * - `ocrmypdf11-two-images-type1c.pdf` — the reporter's shape: PDF 1.7, + * pikepdf producer, one 630.5 x 861.6 pt page with a 150 ppi RGB JPEG and + * a 75 ppi RGB Flate image, Type 1C Helvetica and Palatino-Roman, and an + * invisible OCR text layer. + * - `fallback-fonts.pdf` — a letter in non-embedded Helvetica and in a + * Type 1C Palatino-Roman whose program pdfjs cannot parse. + * - `invisible-text-only.pdf` — text in render mode 3 and nothing else, so + * every page renders blank. + */ + +const { annotate, registerFromPath } = vi.hoisted(() => ({ + annotate: vi.fn(), + registerFromPath: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ annotate })); + +const getDocumentOptions: Record[] = []; +vi.mock("pdfjs-dist/legacy/build/pdf.mjs", async (importActual) => { + const actual = + await importActual(); + return { + ...actual, + getDocument: (opts: Record) => { + getDocumentOptions.push(opts); + return actual.getDocument(opts as never); + }, + }; +}); +vi.mock("@napi-rs/canvas", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + GlobalFonts: { + ...actual.GlobalFonts, + registerFromPath: (path: string, alias?: string) => { + registerFromPath(path, alias); + return actual.GlobalFonts.registerFromPath(path, alias); + }, + }, + }; +}); + +import * as raster from "../rasterize-pdf"; +import { generateThumbnail } from "../thumbnail"; + +const FIXTURES = join(process.cwd(), "tests", "fixtures", "pdf"); +const fixture = (name: string) => readFileSync(join(FIXTURES, name)); + +/** Share of dark pixels in a JPEG, measured the way the rasterizer does. */ +async function inkOf(base64: string): Promise { + const image = await loadImage(Buffer.from(base64, "base64")); + const canvas = createCanvas(image.width, image.height); + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0); + const { data } = ctx.getImageData(0, 0, image.width, image.height); + let dark = 0; + for (let i = 0; i < data.length; i += 4) { + if (data[i]! + data[i + 1]! + data[i + 2]! < 384) dark++; + } + return dark / (data.length / 4); +} + +beforeEach(() => { + annotate.mockClear(); + registerFromPath.mockClear(); + getDocumentOptions.length = 0; + raster.__resetPdfjsAssetsForTests(); +}); + +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 opts = getDocumentOptions.at(-1)!; + expect(opts.useSystemFonts).toBe(false); + for (const [key, probe] of [ + ["standardFontDataUrl", "FoxitSerif.pfb"], + ["standardFontDataUrl", "LiberationSans-Regular.ttf"], + ["cMapUrl", "UniJIS-UTF16-H.bcmap"], + ["wasmUrl", "openjpeg.wasm"], + ] as const) { + expect(typeof opts[key], key).toBe("string"); + expect(existsSync(`${opts[key] as string}${probe}`), probe).toBe(true); + } + expect(opts.cMapPacked).toBe(true); + }); + + it("registers a fallback face for every generic family pdfjs falls back to", async () => { + await raster.rasterizePdf(fixture("fallback-fonts.pdf"), 1); + 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) { + expect(existsSync(path as string)).toBe(true); + } + }); + + it("renders the OCRmyPDF page with its scan and its text", async () => { + const result = await raster.rasterizePdf( + fixture("ocrmypdf11-two-images-type1c.pdf"), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.images).toHaveLength(1); + expect(await inkOf(result.images[0]!.dataBase64)).toBeGreaterThan(0.005); + }); + + it("renders a letter whose fonts pdfjs has to substitute", async () => { + const result = await raster.rasterizePdf(fixture("fallback-fonts.pdf")); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(await inkOf(result.images[0]!.dataBase64)).toBeGreaterThan(0.001); + }); + + it("reports a document whose every page renders blank as render-failed", async () => { + const result = await raster.rasterizePdf( + fixture("invisible-text-only.pdf"), + ); + expect(result).toEqual({ ok: false, reason: "render-failed" }); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "documents.rasterize.failed" }, + meta: { reason: "blank_pages", pages: 1 }, + }); + }); +}); + +describe("generateThumbnail on the OCRmyPDF shape (#1124)", () => { + it("produces a preview of the first page", async () => { + const result = await generateThumbnail( + fixture("ocrmypdf11-two-images-type1c.pdf"), + "application/pdf", + ); + expect(result.ok).toBe(true); + }); +}); diff --git a/src/lib/documents/rasterize-pdf.ts b/src/lib/documents/rasterize-pdf.ts index f61e74fc3..452ddfae5 100644 --- a/src/lib/documents/rasterize-pdf.ts +++ b/src/lib/documents/rasterize-pdf.ts @@ -31,6 +31,9 @@ * preserves the "a bad document never aborts an upload/batch" contract. */ import { Buffer } from "node:buffer"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; import { annotate } from "@/lib/logging/context"; @@ -60,6 +63,14 @@ export const RASTER_TARGET_LONG_EDGE = 1600; /** JPEG quality (0-100). ~80 keeps text legible at a fraction of PNG's bytes. */ export const RASTER_JPEG_QUALITY = 80; +/** + * A rendered page counts as blank when fewer than this share of its pixels + * are dark. One short line of 16 pt text on an A4 page is ~0.1 %; a page + * number alone sits near this floor. A page set that is blank throughout is + * not a reading of the document, whatever the provider would make of it. + */ +export const RASTER_MIN_INK_RATIO = 0.0001; + /** One rasterized page, shaped as a vision `input_image` part. */ export interface RasterImage { mediaType: "image/jpeg"; @@ -98,9 +109,17 @@ interface PdfCanvas { height: number; toBuffer(mime: "image/jpeg", quality?: number): Buffer; } +interface PdfContext2D { + getImageData( + x: number, + y: number, + width: number, + height: number, + ): { data: Uint8ClampedArray }; +} interface PdfCanvasAndContext { canvas: PdfCanvas; - context: unknown; + context: PdfContext2D; } interface PdfCanvasFactory { create(width: number, height: number): PdfCanvasAndContext; @@ -125,12 +144,127 @@ interface PdfLoadingTask { destroy(): Promise; } interface PdfjsModule { - getDocument(opts: { - data: Uint8Array; - verbosity?: number; - isEvalSupported?: boolean; - useSystemFonts?: boolean; - }): PdfLoadingTask; + getDocument(opts: PdfDocumentOptions): PdfLoadingTask; +} +interface PdfDocumentOptions { + data: Uint8Array; + verbosity?: number; + isEvalSupported?: boolean; + useSystemFonts?: boolean; + standardFontDataUrl?: string; + cMapUrl?: string; + cMapPacked?: boolean; + wasmUrl?: string; +} +interface GlobalFontsLike { + registerFromPath(path: string, nameAlias?: string): unknown; +} + +/** + * Where pdfjs 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 pdfjs fetches these from a URL; under Node + * they are read from disk, and without a directory pdfjs simply draws the text + * without glyphs. + * + * 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. The image-only provider then read the empty page + * and returned a line of nothing. + * + * 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 pdfjs falls + * back to each colour space's alternate. + */ +export interface PdfjsAssets { + standardFontDataUrl: string; + cMapUrl: string; + wasmUrl: string; + /** The Liberation Sans faces registered as the generic fallback families. */ + fallbackFonts: string[]; +} + +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: render without the data, as before, and say so below. + } + return cachedAssets; +} + +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: PdfContext2D, 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 asset lookup and the font registration are per process. */ +export function __resetPdfjsAssetsForTests(): void { + cachedAssets = undefined; + fallbackFamiliesRegistered = false; } /** @@ -163,11 +297,31 @@ export async function rasterizePdf( const pdfjs = (await import("pdfjs-dist/legacy/build/pdf.mjs")) as unknown as PdfjsModule; + const assets = resolvePdfjsAssets(); + if (assets) { + await registerFallbackFamilies(assets); + } else { + annotate({ + action: { name: "documents.rasterize.assetsMissing" }, + meta: { reason: "pdfjs_assets_unresolved" }, + }); + } + task = pdfjs.getDocument({ data: new Uint8Array(buffer), verbosity: 0, // VerbosityLevel.ERRORS — no console spam on odd PDFs. isEvalSupported: false, // never eval font programs (defence in depth). - useSystemFonts: true, + // There are no system fonts to use: substitutes come from pdfjs' own + // standard font data and the fallback families registered above. + useSystemFonts: false, + ...(assets + ? { + standardFontDataUrl: assets.standardFontDataUrl, + cMapUrl: assets.cMapUrl, + cMapPacked: true, + wasmUrl: assets.wasmUrl, + } + : {}), }); doc = await task.promise; @@ -175,6 +329,7 @@ export async function rasterizePdf( const cap = Math.min(Math.max(1, maxPages), RASTER_MAX_PAGES); const pageCount = Math.min(doc.numPages, cap); const images: RasterImage[] = []; + let blankPages = 0; for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) { const page = await doc.getPage(pageNumber); @@ -196,6 +351,12 @@ export async function rasterizePdf( 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", @@ -217,9 +378,19 @@ export async function rasterizePdf( }); return { ok: false, reason: "render-failed" }; } + 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 }, + meta: { pages: images.length, cappedFrom: doc.numPages, blankPages }, }); return { ok: true, images, pageCount: doc.numPages }; } catch (err) { diff --git a/tests/fixtures/pdf/fallback-fonts.pdf b/tests/fixtures/pdf/fallback-fonts.pdf new file mode 100644 index 000000000..ee01b4fa0 Binary files /dev/null and b/tests/fixtures/pdf/fallback-fonts.pdf differ diff --git a/tests/fixtures/pdf/invisible-text-only.pdf b/tests/fixtures/pdf/invisible-text-only.pdf new file mode 100644 index 000000000..929c6870c Binary files /dev/null and b/tests/fixtures/pdf/invisible-text-only.pdf differ diff --git a/tests/fixtures/pdf/ocrmypdf11-two-images-type1c.pdf b/tests/fixtures/pdf/ocrmypdf11-two-images-type1c.pdf new file mode 100644 index 000000000..b9b9bd6a6 Binary files /dev/null and b/tests/fixtures/pdf/ocrmypdf11-two-images-type1c.pdf differ