mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
14 files changed
+614
-39
No files matched your search
+37
-14
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user