mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* fix: make OCR portable and reliable * fix: harden OCR installation portability * fix: pin OCR partials across downloads * fix: make OCR execution reliably asynchronous * fix: harden OCR portability and docs routes * fix: preserve decoder and docs safeguards
499 lines
17 KiB
TypeScript
499 lines
17 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
import { existsSync, mkdtempSync, readdirSync, rmSync, truncateSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, join } from "node:path";
|
|
import { performance } from "node:perf_hooks";
|
|
import { PassThrough } from "node:stream";
|
|
import sharp from "sharp";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { mockSpawn, mockRunAdaptiveTesseract, mockStatfs } = vi.hoisted(() => ({
|
|
mockSpawn: vi.fn(),
|
|
mockRunAdaptiveTesseract: vi.fn(),
|
|
mockStatfs: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
|
|
vi.mock("node:fs/promises", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("node:fs/promises")>()),
|
|
statfs: mockStatfs,
|
|
}));
|
|
vi.mock("../../../packages/ai/src/tesseract.js", () => ({
|
|
runAdaptiveTesseract: mockRunAdaptiveTesseract,
|
|
runTesseract: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
MAX_PDF_OCR_OUTPUT_BYTES,
|
|
MAX_PDF_OCR_PAGES,
|
|
parsePdfPageSpec,
|
|
preparePdfOcrPages,
|
|
runTesseractPdf,
|
|
} from "../../../packages/ai/src/tesseract-pdf.js";
|
|
|
|
interface MockChild extends EventEmitter {
|
|
stdout: PassThrough;
|
|
stderr: PassThrough;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
function createMockChild(): MockChild {
|
|
const child = new EventEmitter() as MockChild;
|
|
child.stdout = new PassThrough();
|
|
child.stderr = new PassThrough();
|
|
child.kill = vi.fn(() => true);
|
|
return child;
|
|
}
|
|
|
|
function completeChild(
|
|
child: MockChild,
|
|
{ stdout = "", stderr = "", code = 0 }: { stdout?: string; stderr?: string; code?: number } = {},
|
|
) {
|
|
queueMicrotask(() => {
|
|
if (stdout) child.stdout.write(stdout);
|
|
if (stderr) child.stderr.write(stderr);
|
|
child.emit("close", code, null);
|
|
});
|
|
}
|
|
|
|
function mockSuccessfulGhostscript(totalPages = 3, pageBox = "[0 0 612 792]") {
|
|
mockSpawn.mockImplementation((_executable: string, args: string[]) => {
|
|
const child = createMockChild();
|
|
if (args.some((arg) => arg.includes("pdfpagecount"))) {
|
|
completeChild(child, { stdout: `${totalPages}\n` });
|
|
} else if (args.some((arg) => arg.includes("/CropBox"))) {
|
|
completeChild(child, { stdout: `${pageBox}\n` });
|
|
} else {
|
|
const outputArg = args.find((arg) => arg.startsWith("-sOutputFile="));
|
|
if (!outputArg) throw new Error("render command did not include an output file");
|
|
writeFileSync(outputArg.slice("-sOutputFile=".length), rasterBuffer);
|
|
completeChild(child);
|
|
}
|
|
return child;
|
|
});
|
|
}
|
|
|
|
let scratchDir: string;
|
|
let inputPath: string;
|
|
let rasterBuffer: Buffer;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
mockStatfs.mockResolvedValue({ bavail: 1_000_000n, bsize: 4_096n });
|
|
scratchDir = mkdtempSync(join(tmpdir(), "snapotter-tesseract-pdf-"));
|
|
inputPath = join(scratchDir, "input with spaces.pdf");
|
|
writeFileSync(inputPath, "%PDF mock");
|
|
rasterBuffer = await sharp({
|
|
create: { width: 1, height: 1, channels: 3, background: "white" },
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
mockRunAdaptiveTesseract.mockImplementation(async (pagePath: string) => ({
|
|
text: `Text from ${basename(pagePath)}\n`,
|
|
engine: "tesseract",
|
|
provider: "native",
|
|
device: "cpu",
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
rmSync(scratchDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("parsePdfPageSpec", () => {
|
|
it("selects every page for all", () => {
|
|
expect(parsePdfPageSpec(" all ", 4)).toEqual([1, 2, 3, 4]);
|
|
});
|
|
|
|
it("expands ranges, removes duplicates, and restores document order", () => {
|
|
expect(parsePdfPageSpec("5, 1-3, 2, 7-8", 10)).toEqual([1, 2, 3, 5, 7, 8]);
|
|
});
|
|
|
|
it.each([
|
|
"",
|
|
"1,,2",
|
|
"0",
|
|
"-1",
|
|
"3-1",
|
|
"1-a",
|
|
"1-2-3",
|
|
"2,",
|
|
])("rejects invalid page selection %j", (spec) => {
|
|
expect(() => parsePdfPageSpec(spec, 10)).toThrow(/Invalid|No pages/);
|
|
});
|
|
|
|
it("rejects pages outside the document", () => {
|
|
expect(() => parsePdfPageSpec("1,11", 10)).toThrow("document has 10 pages");
|
|
});
|
|
|
|
it("rejects selections over the fixed safety cap before rasterization", () => {
|
|
expect(MAX_PDF_OCR_PAGES).toBe(50);
|
|
expect(() => parsePdfPageSpec("1-51", 100)).toThrow("Too many pages for OCR (max 50)");
|
|
expect(() => parsePdfPageSpec("all", 51)).toThrow("Too many pages for OCR (max 50)");
|
|
});
|
|
});
|
|
|
|
describe("runTesseractPdf", () => {
|
|
it("can prepare ordered raster pages for the isolated accurate runtime", async () => {
|
|
mockSuccessfulGhostscript(3);
|
|
|
|
const prepared = await preparePdfOcrPages(inputPath, scratchDir, { pages: "3,1" });
|
|
|
|
expect(prepared.pages.map(({ page }) => page)).toEqual([1, 3]);
|
|
expect(prepared.pages.every(({ path }) => existsSync(path))).toBe(true);
|
|
expect(prepared.remainingTimeoutMs()).toBeGreaterThan(0);
|
|
await prepared.cleanup();
|
|
expect(readdirSync(scratchDir)).toEqual(["input with spaces.pdf"]);
|
|
});
|
|
|
|
it("rejects accurate PDF rasters that exceed the aggregate scratch-byte cap", async () => {
|
|
mockSpawn.mockImplementation((_executable: string, args: string[]) => {
|
|
const child = createMockChild();
|
|
if (args.some((arg) => arg.includes("pdfpagecount"))) {
|
|
completeChild(child, { stdout: "2\n" });
|
|
} else if (args.some((arg) => arg.includes("/CropBox"))) {
|
|
completeChild(child, { stdout: "[0 0 612 792]\n" });
|
|
} else {
|
|
const outputArg = args.find((arg) => arg.startsWith("-sOutputFile="));
|
|
if (!outputArg) throw new Error("render command did not include an output file");
|
|
const outputPath = outputArg.slice("-sOutputFile=".length);
|
|
writeFileSync(outputPath, rasterBuffer);
|
|
truncateSync(outputPath, 300 * 1024 * 1024);
|
|
completeChild(child);
|
|
}
|
|
return child;
|
|
});
|
|
|
|
await expect(preparePdfOcrPages(inputPath, scratchDir)).rejects.toThrow(
|
|
"aggregate scratch limit",
|
|
);
|
|
|
|
expect(readdirSync(scratchDir)).toEqual(["input with spaces.pdf"]);
|
|
});
|
|
|
|
it("rejects an accurate PDF raster when scratch free space falls below its reserve", async () => {
|
|
mockSuccessfulGhostscript(1);
|
|
mockStatfs.mockResolvedValue({ bavail: 1n, bsize: 4_096n });
|
|
|
|
await expect(preparePdfOcrPages(inputPath, scratchDir)).rejects.toThrow(
|
|
"free scratch space reserve",
|
|
);
|
|
|
|
expect(readdirSync(scratchDir)).toEqual(["input with spaces.pdf"]);
|
|
});
|
|
|
|
it("rasterizes selected pages without a shell and returns ordered page text and metadata", async () => {
|
|
mockSuccessfulGhostscript();
|
|
const onProgress = vi.fn();
|
|
|
|
const result = await runTesseractPdf(inputPath, scratchDir, {
|
|
pages: "3,1",
|
|
language: "ja",
|
|
ghostscriptPath: "/usr/local/bin/gs",
|
|
tesseractPath: "/usr/local/bin/tesseract",
|
|
onProgress,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
text: "--- Page 1 ---\n\nText from page-1.png\n\n--- Page 3 ---\n\nText from page-3.png",
|
|
pages: 2,
|
|
pageNumbers: [1, 3],
|
|
engine: "tesseract",
|
|
provider: "native",
|
|
device: "cpu",
|
|
});
|
|
expect(mockRunAdaptiveTesseract).toHaveBeenCalledTimes(2);
|
|
expect(mockRunAdaptiveTesseract.mock.calls[0][1]).toMatchObject({
|
|
language: "ja",
|
|
tesseractPath: "/usr/local/bin/tesseract",
|
|
});
|
|
|
|
for (const [executable, args, options] of mockSpawn.mock.calls) {
|
|
expect(executable).toBe("/usr/local/bin/gs");
|
|
expect(options).toMatchObject({ shell: false, windowsHide: true });
|
|
expect(args).toContain("-dSAFER");
|
|
}
|
|
const renderCalls = mockSpawn.mock.calls.filter(([, args]) =>
|
|
(args as string[]).some((arg) => arg.startsWith("-sOutputFile=")),
|
|
);
|
|
expect(renderCalls).toHaveLength(2);
|
|
expect(renderCalls[0][1]).toEqual(
|
|
expect.arrayContaining(["-dFirstPage=1", "-dLastPage=1", "-r300"]),
|
|
);
|
|
expect(renderCalls[1][1]).toEqual(
|
|
expect.arrayContaining(["-dFirstPage=3", "-dLastPage=3", "-r300"]),
|
|
);
|
|
expect(onProgress).toHaveBeenCalledWith(100, "Tesseract PDF OCR complete");
|
|
});
|
|
|
|
it("applies requested local-contrast preprocessing to Fast PDF pages", async () => {
|
|
rasterBuffer = await sharp({
|
|
create: { width: 800, height: 600, channels: 3, background: "#888888" },
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
mockSuccessfulGhostscript(1);
|
|
|
|
await runTesseractPdf(inputPath, scratchDir, { pages: "1", enhance: true });
|
|
|
|
expect(basename(mockRunAdaptiveTesseract.mock.calls[0][0])).toBe("enhanced-page-1.png");
|
|
});
|
|
|
|
it("reduces DPI to keep oversized pages under dimension and pixel limits", async () => {
|
|
mockSuccessfulGhostscript(1, "[0 0 3600 3600]");
|
|
|
|
await runTesseractPdf(inputPath, scratchDir, { pages: "1", dpi: 300 });
|
|
|
|
const renderCall = mockSpawn.mock.calls.find(([, args]) =>
|
|
(args as string[]).some((arg) => arg.startsWith("-sOutputFile=")),
|
|
);
|
|
expect(renderCall?.[1]).toContain("-r100");
|
|
});
|
|
|
|
it("rejects pages that cannot meet the minimum OCR raster quality", async () => {
|
|
mockSuccessfulGhostscript(1, "[0 0 7200 7200]");
|
|
|
|
await expect(runTesseractPdf(inputPath, scratchDir, { pages: "1", dpi: 300 })).rejects.toThrow(
|
|
"72 DPI quality floor",
|
|
);
|
|
expect(mockRunAdaptiveTesseract).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("applies PDF UserUnit before calculating the safe raster DPI", async () => {
|
|
mockSuccessfulGhostscript(1, "[0 0 72 72]\n100");
|
|
|
|
await expect(runTesseractPdf(inputPath, scratchDir, { pages: "1", dpi: 300 })).rejects.toThrow(
|
|
"72 DPI quality floor",
|
|
);
|
|
expect(
|
|
mockSpawn.mock.calls.some(([, args]) =>
|
|
(args as string[]).some((arg) => arg.startsWith("-sOutputFile=")),
|
|
),
|
|
).toBe(false);
|
|
expect(mockRunAdaptiveTesseract).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a raster whose actual dimensions exceed the calculated cap", async () => {
|
|
rasterBuffer = await sharp({
|
|
create: { width: 6_001, height: 1, channels: 3, background: "white" },
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
mockSuccessfulGhostscript(1);
|
|
|
|
await expect(runTesseractPdf(inputPath, scratchDir, { pages: "1" })).rejects.toThrow(
|
|
"unsafe raster dimensions",
|
|
);
|
|
expect(mockRunAdaptiveTesseract).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes one deadline and AbortSignal through to every Tesseract page", async () => {
|
|
mockSuccessfulGhostscript(2);
|
|
const controller = new AbortController();
|
|
|
|
await runTesseractPdf(inputPath, scratchDir, {
|
|
timeoutMs: 12_000,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
expect(mockRunAdaptiveTesseract).toHaveBeenCalledTimes(2);
|
|
for (const [, options] of mockRunAdaptiveTesseract.mock.calls) {
|
|
expect(options.signal).toBe(controller.signal);
|
|
expect(options.timeoutMs).toBeGreaterThan(0);
|
|
expect(options.timeoutMs).toBeLessThanOrEqual(12_000);
|
|
}
|
|
});
|
|
|
|
it("uses a monotonic deadline for prepared accurate-runtime pages", async () => {
|
|
vi.useFakeTimers();
|
|
const monotonicNow = vi.spyOn(performance, "now").mockReturnValue(0);
|
|
vi.setSystemTime(new Date("2026-07-13T00:00:00.000Z"));
|
|
mockSuccessfulGhostscript(1);
|
|
|
|
const prepared = await preparePdfOcrPages(inputPath, scratchDir, { timeoutMs: 100 });
|
|
monotonicNow.mockReturnValue(25);
|
|
vi.setSystemTime(new Date("2026-07-12T00:00:00.000Z"));
|
|
|
|
expect(prepared.remainingTimeoutMs()).toBe(75);
|
|
await prepared.cleanup();
|
|
});
|
|
|
|
it("uses a monotonic aggregate deadline across PDF OCR wall-clock jumps", async () => {
|
|
vi.useFakeTimers();
|
|
const monotonicNow = vi.spyOn(performance, "now").mockReturnValue(0);
|
|
vi.setSystemTime(new Date("2026-07-13T00:00:00.000Z"));
|
|
mockSuccessfulGhostscript(2);
|
|
mockRunAdaptiveTesseract.mockImplementation(async (pagePath: string) => {
|
|
if (mockRunAdaptiveTesseract.mock.calls.length === 1) {
|
|
monotonicNow.mockReturnValue(60);
|
|
vi.setSystemTime(new Date("2026-07-12T00:00:00.000Z"));
|
|
}
|
|
return {
|
|
text: `Text from ${basename(pagePath)}\n`,
|
|
engine: "tesseract",
|
|
provider: "native",
|
|
device: "cpu",
|
|
};
|
|
});
|
|
|
|
await runTesseractPdf(inputPath, scratchDir, { timeoutMs: 100 });
|
|
|
|
expect(mockRunAdaptiveTesseract.mock.calls[0]?.[1].timeoutMs).toBe(100);
|
|
expect(mockRunAdaptiveTesseract.mock.calls[1]?.[1].timeoutMs).toBe(40);
|
|
});
|
|
|
|
it("enforces one aggregate text budget across a 50-page PDF", async () => {
|
|
mockSuccessfulGhostscript(50);
|
|
const textPerPage = "x".repeat(Math.ceil(MAX_PDF_OCR_OUTPUT_BYTES / 50));
|
|
mockRunAdaptiveTesseract.mockResolvedValue({
|
|
text: textPerPage,
|
|
engine: "tesseract",
|
|
provider: "native",
|
|
device: "cpu",
|
|
});
|
|
|
|
await expect(runTesseractPdf(inputPath, scratchDir, { pages: "all" })).rejects.toThrow(
|
|
"aggregate output limit",
|
|
);
|
|
|
|
expect(mockRunAdaptiveTesseract).toHaveBeenCalledTimes(50);
|
|
});
|
|
|
|
it("gives each Tesseract page only the remaining aggregate stdout budget", async () => {
|
|
mockSuccessfulGhostscript(2);
|
|
mockRunAdaptiveTesseract
|
|
.mockResolvedValueOnce({
|
|
text: "x".repeat(100),
|
|
engine: "tesseract",
|
|
provider: "native",
|
|
device: "cpu",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
text: "done",
|
|
engine: "tesseract",
|
|
provider: "native",
|
|
device: "cpu",
|
|
});
|
|
|
|
await runTesseractPdf(inputPath, scratchDir, { pages: "all" });
|
|
|
|
const firstBudget = mockRunAdaptiveTesseract.mock.calls[0][1].maxStdoutBytes;
|
|
const secondBudget = mockRunAdaptiveTesseract.mock.calls[1][1].maxStdoutBytes;
|
|
expect(firstBudget).toBeLessThan(MAX_PDF_OCR_OUTPUT_BYTES);
|
|
expect(secondBudget).toBe(
|
|
firstBudget - Buffer.byteLength(`${"x".repeat(100)}\n\n--- Page 2 ---\n\n`),
|
|
);
|
|
});
|
|
|
|
it("cleans generated page files when page OCR fails", async () => {
|
|
mockSuccessfulGhostscript(1);
|
|
let generatedPage = "";
|
|
mockRunAdaptiveTesseract.mockImplementation(async (pagePath: string) => {
|
|
generatedPage = pagePath;
|
|
expect(existsSync(pagePath)).toBe(true);
|
|
throw new Error("OCR failed");
|
|
});
|
|
|
|
await expect(runTesseractPdf(inputPath, scratchDir)).rejects.toThrow("OCR failed");
|
|
|
|
expect(generatedPage).not.toBe("");
|
|
expect(existsSync(generatedPage)).toBe(false);
|
|
expect(readdirSync(scratchDir)).toEqual(["input with spaces.pdf"]);
|
|
});
|
|
|
|
it("rejects invalid page specs before any raster page is created", async () => {
|
|
mockSuccessfulGhostscript(3);
|
|
|
|
await expect(runTesseractPdf(inputPath, scratchDir, { pages: "1,,2" })).rejects.toThrow(
|
|
"Invalid page selection",
|
|
);
|
|
|
|
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
expect(mockRunAdaptiveTesseract).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports an actionable error when Ghostscript is missing", async () => {
|
|
mockSpawn.mockImplementation(() => {
|
|
const child = createMockChild();
|
|
queueMicrotask(() => {
|
|
child.emit("error", Object.assign(new Error("spawn gs ENOENT"), { code: "ENOENT" }));
|
|
});
|
|
return child;
|
|
});
|
|
|
|
await expect(runTesseractPdf(inputPath, scratchDir)).rejects.toThrow(
|
|
"Ghostscript executable not found. Install Ghostscript or set GS_PATH.",
|
|
);
|
|
});
|
|
|
|
it("cancels an in-flight Ghostscript process and removes scratch output", async () => {
|
|
const child = createMockChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const controller = new AbortController();
|
|
|
|
const resultPromise = runTesseractPdf(inputPath, scratchDir, { signal: controller.signal });
|
|
await vi.waitFor(() => expect(spawn).toHaveBeenCalled());
|
|
controller.abort();
|
|
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
child.emit("close", null, "SIGTERM");
|
|
await expect(resultPromise).rejects.toMatchObject({ name: "AbortError" });
|
|
expect(readdirSync(scratchDir)).toEqual(["input with spaces.pdf"]);
|
|
});
|
|
|
|
it("does not miss cancellation that races with Ghostscript startup", async () => {
|
|
const child = createMockChild();
|
|
const controller = new AbortController();
|
|
mockSpawn.mockImplementation(() => {
|
|
controller.abort();
|
|
return child;
|
|
});
|
|
|
|
const resultPromise = runTesseractPdf(inputPath, scratchDir, { signal: controller.signal });
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
|
|
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
child.emit("close", null, "SIGTERM");
|
|
await expect(resultPromise).rejects.toMatchObject({ name: "AbortError" });
|
|
});
|
|
|
|
it("cleans scratch output even when a progress callback throws", async () => {
|
|
mockSuccessfulGhostscript(1);
|
|
|
|
await expect(
|
|
runTesseractPdf(inputPath, scratchDir, {
|
|
onProgress: () => {
|
|
throw new Error("progress failed");
|
|
},
|
|
}),
|
|
).rejects.toThrow("progress failed");
|
|
|
|
expect(readdirSync(scratchDir)).toEqual(["input with spaces.pdf"]);
|
|
});
|
|
|
|
it("force-kills a Ghostscript process that ignores the overall timeout", async () => {
|
|
vi.useFakeTimers();
|
|
const child = createMockChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
|
|
const resultPromise = runTesseractPdf(inputPath, scratchDir, { timeoutMs: 100 });
|
|
let settled = false;
|
|
void resultPromise
|
|
.finally(() => {
|
|
settled = true;
|
|
})
|
|
.catch(() => {});
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
expect(child.kill).toHaveBeenLastCalledWith("SIGKILL");
|
|
expect(settled).toBe(false);
|
|
child.emit("close", null, "SIGKILL");
|
|
await expect(resultPromise).rejects.toThrow("PDF OCR timed out after 100ms");
|
|
});
|
|
});
|