Merge pull request #1128 from MBombeck/fix/pdf-raster-ocrmypdf

fix(documents): hoist the pinned canvas binary and render PDFs with pdfjs font data
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-04 11:13:29 +02:00
commit ecddcb3fa5
14 files changed
+614 -39

No files matched your search

+37 -14
View File
@@ -54,6 +54,9 @@ RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build
# name; see the note there for why guessing was wrong.
RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \
> /app/.pdfjs-version
# The same for @napi-rs/canvas, whose native binary must match its JS loader.
RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \
> /app/.canvas-version
# ── Stage 3: Production runner ─────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
@@ -120,21 +123,41 @@ RUN node -e "require.resolve('pg-boss'); require.resolve('pg')"
# @napi-rs/canvas" → "Cannot polyfill DOMMatrix" → rasterize ReferenceError);
# (2) @napi-rs/canvas's own loader then resolves its platform binary. Hoist BOTH
# the canvas package and the musl binary package to /app/node_modules/@napi-rs
# via symlink into the .pnpm store, and also drop the prebuilt .node into the
# canvas dir so the loader's local-file fallback (`require('./skia.<triple>.node')`)
# is belt-and-suspenders. Per-arch buildx installs only the matching musl triple.
# via symlink into the .pnpm store, and drop the prebuilt .node into the canvas
# dir: the loader tries that local file (`require('./skia.<triple>.node')`)
# before the platform package, so it decides which binary runs.
#
# Hoist the version the app pins, by name, exactly like pdfjs-dist below. The
# store holds a second, older canvas that pdf-parse's own pdfjs-dist pulls in,
# and this step used to take whichever `find | head -1` returned first. It
# returned the old binary, which then sat next to the new loader: rendering
# still worked, but `loadImage` called an `Image.decode` the old binary does
# not have, and every preview thumbnail failed with "image.decode is not a
# function". Name the version, and fail the build when the binary that lands
# in the canvas dir is not that version's.
COPY --from=builder /app/.canvas-version /tmp/.canvas-version
RUN set -e; \
CANVAS_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas@*/node_modules/@napi-rs/canvas' 2>/dev/null | head -1)"; \
MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas-linux-*-musl@*/node_modules/@napi-rs/canvas-linux-*-musl' 2>/dev/null | head -1)"; \
NODE_BIN="$(find /app/node_modules/.pnpm -maxdepth 5 -name 'skia.linux-*-musl.node' 2>/dev/null | head -1)"; \
if [ -n "$CANVAS_DIR" ]; then \
mkdir -p /app/node_modules/@napi-rs; \
ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \
[ -n "$MUSL_DIR" ] && ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \
[ -n "$NODE_BIN" ] && cp "$NODE_BIN" "$CANVAS_DIR/"; \
chown -R nextjs:nodejs /app/node_modules/@napi-rs; \
echo "canvas hoisted for pdfjs: $CANVAS_DIR (musl=$MUSL_DIR)"; \
else echo "WARN: @napi-rs/canvas not found; PDF rasterization will degrade to local text"; fi
CANVAS_VERSION="$(cat /tmp/.canvas-version)"; \
rm -f /tmp/.canvas-version; \
CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/node_modules/@napi-rs/canvas"; \
MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-musl@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-musl" 2>/dev/null | head -1)"; \
if [ ! -d "$CANVAS_DIR" ] || [ -z "$MUSL_DIR" ]; then \
echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its musl binary did not reach the traced image."; \
echo "Present instead:"; \
find /app/node_modules/.pnpm -maxdepth 1 -name '@napi-rs+canvas*' 2>/dev/null; \
exit 1; \
fi; \
NODE_BIN="$(find "$MUSL_DIR" -maxdepth 1 -name 'skia.linux-*-musl.node' | head -1)"; \
[ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $MUSL_DIR"; exit 1; }; \
mkdir -p /app/node_modules/@napi-rs; \
ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \
ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \
cp "$NODE_BIN" "$CANVAS_DIR/"; \
chown -R nextjs:nodejs /app/node_modules/@napi-rs; \
BIN_VERSION="$(node -p "require('$MUSL_DIR/package.json').version")"; \
[ "$BIN_VERSION" = "$CANVAS_VERSION" ] || { echo "ERROR: canvas binary is $BIN_VERSION, loader is $CANVAS_VERSION"; exit 1; }; \
cmp -s "$NODE_BIN" "$CANVAS_DIR/$(basename "$NODE_BIN")" || { echo "ERROR: the canvas dir holds a different binary"; exit 1; }; \
echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $MUSL_DIR)"
# pdfjs-dist is `serverExternalPackages` (NOT bundled — the bundled copy's render
# path breaks in the standalone image), so the server does a runtime bare
@@ -0,0 +1,65 @@
/**
* Structural guard: the runtime image puts the @napi-rs/canvas native binary
* of the version the app pins next to that version's loader.
*
* ## The failure this freezes (GitHub #1124)
*
* The pnpm store holds two canvas versions: the one in package.json and an
* older one that pdf-parse's own pdfjs-dist pulls in. The runner stage picked
* the musl binary with `find | head -1`, got the old one, and copied it into
* the new loader's directory, where the loader looks first. PDF rendering
* still worked, so nothing looked wrong; `loadImage` called an
* `Image.decode` the old binary lacks, and every preview thumbnail failed
* with "image.decode is not a function" on both architectures.
*
* ## What it proves and what it does not
*
* The builder records the resolved canvas version, the runner hoists by that
* name, and the build stops when the copied binary's package is a different
* version. Whether the binary loads is proved when the image runs; this file
* reads the Dockerfile only.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const DOCKERFILE = readFileSync(join(process.cwd(), "Dockerfile"), "utf8");
function stage(name: string): string {
const start = DOCKERFILE.search(new RegExp(`^FROM .* AS ${name}$`, "m"));
expect(start, `the Dockerfile has a ${name} stage`).toBeGreaterThan(0);
const next = DOCKERFILE.slice(start + 1).search(/^FROM /m);
return next < 0
? DOCKERFILE.slice(start)
: DOCKERFILE.slice(start, start + 1 + next);
}
describe("canvas binary hoist", () => {
it("records the resolved canvas version in the builder", () => {
expect(stage("builder")).toMatch(
/require\('\/app\/node_modules\/@napi-rs\/canvas\/package\.json'\)\.version"[\s\\]*>\s*\/app\/\.canvas-version/,
);
});
it("hoists the loader and the musl binary of that version only", () => {
const runner = stage("runner");
expect(runner).toContain(
"COPY --from=builder /app/.canvas-version /tmp/.canvas-version",
);
expect(runner).toContain(
'CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/',
);
expect(runner).toContain("canvas-linux-*-musl@${CANVAS_VERSION}/");
// A version-blind pick is the defect: any `canvas…@*` glob decides by
// readdir order which of the store's copies wins.
expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/);
});
it("fails the build when the copied binary is another version", () => {
const runner = stage("runner");
expect(runner).toMatch(
/\[ "\$BIN_VERSION" = "\$CANVAS_VERSION" \] \|\| \{[^}]*exit 1; \}/,
);
});
});
@@ -115,6 +115,6 @@ describe("generateThumbnail behind a closed gate", () => {
Buffer.from([0xff, 0xd8, 0xff]),
"image/jpeg",
);
expect(result).toEqual({ ok: false });
expect(result).toEqual({ ok: false, reason: "native-canvas-unsupported" });
});
});
@@ -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" });
});
});
@@ -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 () => ({
@@ -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<string, unknown>[] = [];
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", async (importActual) => {
const actual =
await importActual<typeof import("pdfjs-dist/legacy/build/pdf.mjs")>();
return {
...actual,
getDocument: (opts: Record<string, unknown>) => {
getDocumentOptions.push(opts);
return actual.getDocument(opts as never);
},
};
});
vi.mock("@napi-rs/canvas", async (importActual) => {
const actual = await importActual<typeof import("@napi-rs/canvas")>();
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<number> {
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);
});
});
@@ -126,19 +126,19 @@ describe("generateThumbnail", () => {
it("returns { ok: false } for an unsupported MIME type", async () => {
await expect(
generateThumbnail(Buffer.from("plain text"), "text/plain"),
).resolves.toEqual({ ok: false });
).resolves.toEqual({ ok: false, reason: "unsupported-type" });
});
it("returns { ok: false } and never throws on malformed image bytes", async () => {
await expect(
generateThumbnail(Buffer.from("not a real image"), "image/png"),
).resolves.toEqual({ ok: false });
).resolves.toEqual({ ok: false, reason: "error" });
});
it("returns { ok: false } on a malformed PDF", async () => {
await expect(
generateThumbnail(Buffer.from("not a pdf"), "application/pdf"),
).resolves.toEqual({ ok: false });
).resolves.toEqual({ ok: false, reason: "raster-failed" });
});
it("refuses a decompression bomb before decoding (pixel cap)", async () => {
@@ -153,6 +153,7 @@ describe("generateThumbnail", () => {
bomb.writeUInt32BE(30_000, 20); // height
await expect(generateThumbnail(bomb, "image/png")).resolves.toEqual({
ok: false,
reason: "pixel-cap",
});
});
@@ -164,6 +165,6 @@ describe("generateThumbnail", () => {
loadImageMock.mockResolvedValueOnce({ width: 30_000, height: 30_000 });
await expect(
generateThumbnail(Buffer.from("desynced jpeg bytes"), "image/jpeg"),
).resolves.toEqual({ ok: false });
).resolves.toEqual({ ok: false, reason: "pixel-cap" });
});
});
+180 -9
View File
@@ -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<void>;
}
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<void> {
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) {
+25 -9
View File
@@ -157,9 +157,23 @@ export interface Thumbnail {
height: number;
}
/**
* Why no preview was made. `unsupported-type` is the expected answer for an
* Office file or a text document; every other reason means a preview should
* have existed, so the thumbnail job logs it as a warning.
*/
export type ThumbnailSkipReason =
| "unsupported-type"
| "native-canvas-unsupported"
| "pixel-cap"
| "raster-failed"
| "empty-render"
| "error";
/** Best-effort generation outcome — never an exception. */
export type ThumbnailResult =
{ ok: true; thumbnail: Thumbnail } | { ok: false };
| { ok: true; thumbnail: Thumbnail }
| { ok: false; reason: ThumbnailSkipReason };
// Minimal structural types for the slice of `@napi-rs/canvas` this module uses.
// A type-only shape (erased at build) so we never eagerly evaluate the module
@@ -229,7 +243,7 @@ export async function generateThumbnail(
action: { name: "documents.thumbnail.rejected" },
meta: { reason: "native_canvas_unsupported" },
});
return { ok: false };
return { ok: false, reason: "native-canvas-unsupported" };
}
// Lazy import: keeps @napi-rs/canvas (+ transitively pdfjs on the PDF path)
// out of the eager server chunk. Resolved on first generation, then cached.
@@ -244,7 +258,7 @@ export async function generateThumbnail(
action: { name: "documents.thumbnail.rejected" },
meta: { source: "image", reason: "pixel_cap", pixels },
});
return { ok: false };
return { ok: false, reason: "pixel-cap" };
}
const image = await napi.loadImage(bytes);
// Post-decode backstop: the header sniff returns null on any marker
@@ -260,10 +274,10 @@ export async function generateThumbnail(
pixels: image.width * image.height,
},
});
return { ok: false };
return { ok: false, reason: "pixel-cap" };
}
const thumb = drawScaledJpeg(napi, image);
if (!thumb) return { ok: false };
if (!thumb) return { ok: false, reason: "empty-render" };
annotate({
action: { name: "documents.thumbnail.ok" },
meta: { source: "image", width: thumb.width, height: thumb.height },
@@ -274,11 +288,13 @@ 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.images.length === 0) return { ok: false };
if (!raster.ok || raster.images.length === 0) {
return { ok: false, reason: "raster-failed" };
}
const pageJpeg = Buffer.from(raster.images[0]!.dataBase64, "base64");
const image = await napi.loadImage(pageJpeg);
const thumb = drawScaledJpeg(napi, image);
if (!thumb) return { ok: false };
if (!thumb) return { ok: false, reason: "empty-render" };
annotate({
action: { name: "documents.thumbnail.ok" },
meta: { source: "pdf", width: thumb.width, height: thumb.height },
@@ -287,7 +303,7 @@ export async function generateThumbnail(
}
// Every other MIME (Office/text/TIFF/HEIC/XML/JSON) — no preview.
return { ok: false };
return { ok: false, reason: "unsupported-type" };
} catch (err) {
annotate({
action: { name: "documents.thumbnail.failed" },
@@ -297,6 +313,6 @@ export async function generateThumbnail(
message: err instanceof Error ? err.message.slice(0, 300) : String(err),
},
});
return { ok: false };
return { ok: false, reason: "error" };
}
}
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* GitHub #1124 — the thumbnail job ran 355 times on one instance, left
* sixteen PDFs without a preview, and the log held not one line about it:
* the job opened no wide event, so every annotation went nowhere. Pins: each
* run is a `job.document_thumbnail` background event; a run that should have
* made a preview and did not is raised to `warn` with the reason; a type that
* never has a preview stays at info.
*/
const { evt, findFirst, upsert, generateThumbnail, withBackgroundEvent } =
vi.hoisted(() => {
const evt = { addMeta: vi.fn(), elevateLevel: vi.fn() };
return {
evt,
findFirst: vi.fn(),
upsert: vi.fn(),
generateThumbnail: vi.fn(),
withBackgroundEvent: vi.fn(
async (_name: string, fn: (e: typeof evt) => Promise<unknown>) =>
fn(evt),
),
};
});
vi.mock("@/lib/db", () => ({
prisma: {
inboundDocument: { findFirst },
documentThumbnail: { upsert },
},
}));
vi.mock("@/lib/documents/store", () => ({
decryptDocumentContent: vi.fn(() => Buffer.from("%PDF-1.7")),
encryptThumbnail: vi.fn(() => new Uint8Array([1])),
}));
vi.mock("@/lib/documents/thumbnail", () => ({ generateThumbnail }));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/logging/background", () => ({ withBackgroundEvent }));
import { runDocumentThumbnail } from "../document-thumbnail";
const DOC = {
id: "doc-1",
mimeType: "application/pdf",
contentEncrypted: new Uint8Array([1]),
contentCodec: "binary2",
thumbnail: null,
};
beforeEach(() => {
vi.clearAllMocks();
findFirst.mockResolvedValue(DOC);
});
describe("runDocumentThumbnail wide event", () => {
it("runs inside a background event named for the job", async () => {
generateThumbnail.mockResolvedValue({ ok: false, reason: "raster-failed" });
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
expect(withBackgroundEvent).toHaveBeenCalledWith(
"job.document_thumbnail",
expect.any(Function),
);
expect(evt.addMeta).toHaveBeenCalledWith("document_id", "doc-1");
});
it("warns with the reason when a PDF gets no preview", async () => {
generateThumbnail.mockResolvedValue({ ok: false, reason: "error" });
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
expect(evt.elevateLevel).toHaveBeenCalledWith("warn");
expect(evt.addMeta).toHaveBeenCalledWith(
"thumbnail_missing_reason",
"error",
);
expect(upsert).not.toHaveBeenCalled();
});
it("stays at info for a type that never has a preview", async () => {
findFirst.mockResolvedValue({ ...DOC, mimeType: "text/plain" });
generateThumbnail.mockResolvedValue({
ok: false,
reason: "unsupported-type",
});
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
expect(evt.elevateLevel).not.toHaveBeenCalled();
});
it("stores the preview and stays at info when one is made", async () => {
generateThumbnail.mockResolvedValue({
ok: true,
thumbnail: { jpeg: Buffer.from([0xff, 0xd8]), width: 2, height: 3 },
});
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
expect(upsert).toHaveBeenCalledOnce();
expect(evt.elevateLevel).not.toHaveBeenCalled();
});
});
+25 -1
View File
@@ -15,6 +15,12 @@
* kind icon), and `runDocumentThumbnail` returns cleanly so pg-boss does not
* retry it. Only an unexpected throw (a DB hiccup) propagates for retry.
*
* Each run is one background wide event (`job.document_thumbnail`). A run
* that should have produced a preview and did not is raised to `warn` with
* the reason, so a document left with its kind icon is explained in the log.
* Without the event every annotation below went nowhere: a whole class of
* PDFs went without previews for weeks and the log never said so.
*
* The queue MUST be registered in the maintenance registrar
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
*/
@@ -25,6 +31,7 @@ import {
} from "@/lib/documents/store";
import { generateThumbnail } from "@/lib/documents/thumbnail";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { withBackgroundEvent } from "@/lib/logging/background";
import { annotate } from "@/lib/logging/context";
export const DOCUMENT_THUMBNAIL_QUEUE = "document-thumbnail";
@@ -49,7 +56,20 @@ export async function runDocumentThumbnail(
): Promise<void> {
const { userId, documentId } = payload;
if (!userId || !documentId) return;
await withBackgroundEvent("job.document_thumbnail", async (evt) => {
evt.addMeta("document_id", documentId);
await renderThumbnail(userId, documentId, (reason) => {
evt.addMeta("thumbnail_missing_reason", reason);
evt.elevateLevel("warn");
});
});
}
async function renderThumbnail(
userId: string,
documentId: string,
warn: (reason: string) => void,
): Promise<void> {
// Owner-scoped load; the blob column is selected ONLY here (the job that
// renders the preview), never in the list/detail queries. Skip a document
// that already has a thumbnail so a re-enqueue is a cheap no-op.
@@ -84,6 +104,7 @@ export async function runDocumentThumbnail(
action: { name: "documents.thumbnail.decryptFailed" },
meta: { documentId },
});
warn("decrypt-failed");
return;
}
@@ -91,8 +112,11 @@ export async function runDocumentThumbnail(
if (!result.ok) {
annotate({
action: { name: "documents.thumbnail.run" },
meta: { documentId, generated: false },
meta: { documentId, generated: false, mimeType: document.mimeType },
});
// An Office or text document has no preview by design; anything else
// was meant to get one.
if (result.reason !== "unsupported-type") warn(result.reason);
return;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.