fix: make OCR portable and reliable across AMD64 and ARM64 (#519)

* 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
This commit is contained in:
SnapOtter
2026-07-15 03:34:24 +08:00
committed by GitHub
parent 58121f205f
commit 991c981529
409 changed files with 67151 additions and 8076 deletions
+2 -3
View File
@@ -289,9 +289,8 @@ export class PythonDispatcher {
continue;
}
// Diagnostic notices (e.g. ocr.py's GPU-to-tesseract downgrade
// notice) - forward so they reach docker logs instead of being
// silently dropped for matching neither shape above.
// Diagnostic notices are forwarded so they reach docker logs
// instead of being silently dropped for matching neither shape.
if (typeof parsed.info === "string") {
console.log(`[python] ${parsed.info}`);
continue;
-2
View File
@@ -23,8 +23,6 @@ export const SCRIPT_BUNDLE_MAP: Record<string, string> = {
enhance_faces: "upscale-enhance",
noise_removal: "upscale-enhance",
restore: "photo-restoration",
ocr: "ocr",
ocr_pdf: "ocr",
transcribe: "transcription",
};
+93 -2
View File
@@ -29,13 +29,104 @@ export { detectFaceLandmarks } from "./face-landmarks.js";
export { missingBundleForScript, SCRIPT_BUNDLE_MAP } from "./feature-gate.js";
export { inpaint } from "./inpainting.js";
export { noiseRemoval } from "./noise-removal.js";
export type { PdfOcrOptions, PdfOcrResult } from "./ocr.js";
export { extractPdfText, extractText } from "./ocr.js";
export type {
OcrExecutionMetadata,
OcrOptions,
OcrQuality,
OcrResult,
PdfOcrOptions,
PdfOcrResult,
} from "./ocr.js";
export {
extractPdfText,
extractText,
FAST_KOREAN_UNSUPPORTED_REASON,
MAX_OCR_INPUT_DIMENSION,
MAX_OCR_INPUT_PIXELS,
} from "./ocr.js";
export type {
OcrRuntimeRunOptions,
OcrRuntimeRunResult,
OcrRuntimeScript,
} from "./ocr-runtime-dispatcher.js";
export {
drainOcrDispatcher,
handoffOcrDispatcher,
probeOcrDispatcher,
rotateOcrDispatcher,
runOcrRuntime,
shutdownOcrDispatcher,
} from "./ocr-runtime-dispatcher.js";
export type { OutpaintOptions } from "./outpainting.js";
export { outpaint } from "./outpainting.js";
export { removeRedEye } from "./red-eye-removal.js";
export { restorePhoto } from "./restoration.js";
export type {
OcrRuntimeTrustKey,
VerifiedOcrRuntimeIndex,
} from "./runtime-index.js";
export {
canonicalRuntimeJson,
loadOcrRuntimeTrustKeys,
OCR_RUNTIME_INDEX_MAX_BYTES,
verifyRuntimeIndex,
} from "./runtime-index.js";
export type { OcrRuntimeMemoryOptions } from "./runtime-resources.js";
export {
assertOcrRuntimeMemory,
getOcrRuntimeEffectiveMemoryBytes,
hasOcrRuntimeMemory,
OCR_RUNTIME_MINIMUM_MEMORY_BYTES,
} from "./runtime-resources.js";
export type {
ActiveRuntimeDescriptor,
OcrRuntimeActivationIdentity,
OcrRuntimeCapability,
OcrRuntimeQuality,
OcrRuntimeTarget,
RuntimeIntegrityFile,
RuntimeIntegrityFileId,
RuntimePlatformOptions,
RuntimeSignedIndex,
RuntimeStateOptions,
} from "./runtime-state.js";
export {
getOcrRuntimeCapability,
OCR_RUNTIME_PROTOCOL_VERSION,
readActiveRuntime,
readCommittedOcrRuntimeActivationIdentity,
readPendingOcrRuntimeActivationIdentity,
resolveAiDataDir,
selectOcrRuntimeTarget,
} from "./runtime-state.js";
export { seamCarve } from "./seam-carving.js";
export type {
RunTesseractOptions,
TesseractLanguage,
TesseractResult,
TesseractRuntimeMetadata,
} from "./tesseract.js";
export {
getTesseractRuntimeMetadata,
resolveTesseractLanguage,
runAdaptiveTesseract,
runTesseract,
selectTesseractLanguageFamily,
selectTesseractLayout,
TESSERACT_LANGUAGE_MAP,
} from "./tesseract.js";
export type {
PreparedPdfOcrPage,
PreparedPdfOcrPages,
RunTesseractPdfOptions,
TesseractPdfResult,
} from "./tesseract-pdf.js";
export {
MAX_PDF_OCR_PAGES,
parsePdfPageSpec,
preparePdfOcrPages,
runTesseractPdf,
} from "./tesseract-pdf.js";
export type { TranscribeOptions, TranscriptionResult, TranscriptSegment } from "./transcription.js";
export { transcribeAudio } from "./transcription.js";
export { upscale } from "./upscaling.js";
File diff suppressed because it is too large Load Diff
+379 -48
View File
@@ -1,9 +1,32 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join } from "node:path";
import sharp from "sharp";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
import type { ProgressCallback } from "./bridge.js";
import { runOcrRuntime } from "./ocr-runtime-dispatcher.js";
import { runAdaptiveTesseract, type TesseractLanguage } from "./tesseract.js";
import { preparePdfOcrPages, runTesseractPdf } from "./tesseract-pdf.js";
export type OcrQuality = "fast" | "balanced" | "best";
export const FAST_KOREAN_UNSUPPORTED_REASON =
"Fast OCR does not support Korean. Install the Accurate OCR bundle and choose Balanced or Best.";
export const MAX_OCR_INPUT_PIXELS = 40_000_000;
/** Bound pathological aspect ratios so tiled OCR cannot fan out into thousands of sessions. */
export const MAX_OCR_INPUT_DIMENSION = 40_000;
/** Keep Fast aligned with the accurate runtime and durable-result database budget. */
export const MAX_OCR_OUTPUT_BYTES = 1_000_000;
const OCR_PROGRESS_HEARTBEAT_MS = 30_000;
const FAST_LOW_CONTRAST_MAX_SHORT_SIDE = 512;
const FAST_LOW_CONTRAST_MAX_LONG_SIDE = 1_024;
const FAST_LOW_CONTRAST_MIN_MEAN = 200;
const FAST_LOW_CONTRAST_MAX_STDEV = 20;
const FAST_LOW_CONTRAST_GAIN = 4;
const FAST_LOW_CONTRAST_TARGET_BACKGROUND = 250;
const FAST_CJK_SCENE_MIN_PIXELS = 1_500_000;
const FAST_CJK_SCENE_MIN_WIDTH = 1_000;
const FAST_CJK_SCENE_MIN_HEIGHT = 1_200;
const FAST_DENSE_CJK_MIN_PIXELS = 1_000_000;
const FAST_DENSE_CJK_MAX_PIXELS = 2_500_000;
const FAST_DENSE_CJK_MIN_WIDTH = 1_000;
const FAST_DENSE_CJK_MIN_HEIGHT = 1_000;
export interface OcrOptions {
quality?: OcrQuality;
@@ -11,11 +34,116 @@ export interface OcrOptions {
enhance?: boolean;
/** @deprecated Use quality instead. Kept for backward compat. */
engine?: "tesseract" | "paddleocr";
signal?: AbortSignal;
}
export interface OcrResult {
export interface OcrExecutionMetadata {
engine: string;
requestedQuality: OcrQuality;
actualQuality: OcrQuality;
device: "cpu" | "cuda";
degraded: boolean;
warnings: string[];
provider: string;
runtimeVersion?: string;
modelVersion?: string;
}
export interface OcrResult extends OcrExecutionMetadata {
text: string;
engine?: string;
}
function resolveQuality(options: OcrOptions): OcrQuality {
if (options.quality) return options.quality;
if (options.engine) return options.engine === "tesseract" ? "fast" : "balanced";
return "fast";
}
function assertFastLanguageSupported(quality: OcrQuality, language: string | undefined): void {
if (quality === "fast" && language === "ko") {
throw new Error(FAST_KOREAN_UNSUPPORTED_REASON);
}
}
function parseAccurateResult(resultValue: unknown, quality: OcrQuality): OcrResult {
if (typeof resultValue !== "object" || resultValue === null || Array.isArray(resultValue)) {
throw new Error("OCR runtime returned invalid metadata");
}
const result = resultValue as Record<string, unknown>;
if (result.success !== true) {
throw new Error((result.error as string | undefined) || "OCR failed");
}
const metadataValid =
typeof result.text === "string" &&
typeof result.engine === "string" &&
(result.requestedQuality === "fast" ||
result.requestedQuality === "balanced" ||
result.requestedQuality === "best") &&
(result.actualQuality === "fast" ||
result.actualQuality === "balanced" ||
result.actualQuality === "best") &&
(result.device === "cpu" || result.device === "cuda") &&
typeof result.provider === "string" &&
typeof result.degraded === "boolean" &&
Array.isArray(result.warnings) &&
result.warnings.every((warning) => typeof warning === "string") &&
(result.runtimeVersion === undefined || typeof result.runtimeVersion === "string") &&
(result.modelVersion === undefined || typeof result.modelVersion === "string");
if (!metadataValid) {
throw new Error("OCR runtime returned invalid metadata");
}
if (result.requestedQuality !== quality || result.actualQuality !== quality) {
throw new Error(
`OCR runtime tier mismatch: requested ${quality}, reported ${String(result.requestedQuality)}/${String(result.actualQuality)}`,
);
}
if (Buffer.byteLength(result.text as string, "utf8") > MAX_OCR_OUTPUT_BYTES) {
throw new Error(
`OCR runtime output exceeds the ${MAX_OCR_OUTPUT_BYTES.toLocaleString("en-US")} byte safety limit`,
);
}
return {
text: result.text as string,
engine: result.engine as string,
requestedQuality: quality,
actualQuality: quality,
device: result.device as "cpu" | "cuda",
provider: result.provider as string,
degraded: result.degraded as boolean,
warnings: result.warnings as string[],
...(typeof result.runtimeVersion === "string" && { runtimeVersion: result.runtimeVersion }),
...(typeof result.modelVersion === "string" && { modelVersion: result.modelVersion }),
};
}
async function withProgressHeartbeat<T>(
operation: (progress: ProgressCallback | undefined) => Promise<T>,
onProgress: ProgressCallback | undefined,
percent: number,
stage: string,
): Promise<T> {
if (!onProgress) return operation(undefined);
let latestPercent = percent;
let latestStage = stage;
const relayProgress: ProgressCallback = (nextPercent, nextStage) => {
latestPercent = nextPercent;
latestStage = nextStage;
onProgress(nextPercent, nextStage);
};
const heartbeat = setInterval(
() => onProgress(latestPercent, latestStage),
OCR_PROGRESS_HEARTBEAT_MS,
);
heartbeat.unref();
try {
return await operation(relayProgress);
} finally {
clearInterval(heartbeat);
}
}
export async function extractText(
@@ -25,33 +153,181 @@ export async function extractText(
onProgress?: ProgressCallback,
): Promise<OcrResult> {
const inputPath = join(outputDir, "input_ocr.png");
const quality = resolveQuality(options);
assertFastLanguageSupported(quality, options.language);
// Convert to PNG and cap at 2048px to prevent PaddleOCR OOM on large images.
const MAX_OCR_DIM = 2048;
const pngBuffer = await sharp(inputBuffer)
.resize({ width: MAX_OCR_DIM, height: MAX_OCR_DIM, fit: "inside", withoutEnlargement: true })
.png()
.toBuffer();
await writeFile(inputPath, pngBuffer);
// Normalize the format without discarding source pixels. The old 2048px cap
// made small text permanently unreadable before either OCR engine saw it.
const image = sharp(inputBuffer);
const meta = await image.metadata();
const width = meta.width ?? 0;
const height = meta.height ?? 0;
const pixels = width * height;
if (!Number.isSafeInteger(pixels) || pixels <= 0) {
throw new Error("OCR input has invalid image dimensions");
}
if (width > MAX_OCR_INPUT_DIMENSION || height > MAX_OCR_INPUT_DIMENSION) {
throw new Error(
`OCR input exceeds the ${MAX_OCR_INPUT_DIMENSION.toLocaleString("en-US")} pixel dimension safety limit`,
);
}
if (pixels > MAX_OCR_INPUT_PIXELS) {
throw new Error(
`OCR input exceeds the ${MAX_OCR_INPUT_PIXELS.toLocaleString("en-US")} pixel safety limit`,
);
}
let recognitionImage: ReturnType<typeof sharp> | undefined;
let automaticLowContrast = false;
if (
quality === "fast" &&
Math.min(width, height) <= FAST_LOW_CONTRAST_MAX_SHORT_SIDE &&
Math.max(width, height) <= FAST_LOW_CONTRAST_MAX_LONG_SIDE
) {
const stats = await image.clone().grayscale().stats();
const luminance = stats.channels[0];
if (
luminance &&
Number.isFinite(luminance.mean) &&
Number.isFinite(luminance.stdev) &&
luminance.mean >= FAST_LOW_CONTRAST_MIN_MEAN &&
luminance.stdev <= FAST_LOW_CONTRAST_MAX_STDEV
) {
// Development receipts showed that a fixed gain with a mean-derived
// offset recovers faint thermal text without upscaling or threshold
// artifacts. The strict size/statistics gate leaves ordinary images
// byte-for-byte equivalent apart from the existing PNG normalization.
recognitionImage = image
.clone()
.grayscale()
.linear(
FAST_LOW_CONTRAST_GAIN,
Math.round(FAST_LOW_CONTRAST_TARGET_BACKGROUND - FAST_LOW_CONTRAST_GAIN * luminance.mean),
);
automaticLowContrast = true;
}
}
if (quality === "fast" && options.enhance && !automaticLowContrast) {
recognitionImage = image.clone().clahe({
width: Math.max(1, Math.min(256, Math.round(width / 8))),
height: Math.max(1, Math.min(256, Math.round(height / 8))),
maxSlope: 2,
});
}
// Stream normalized rasters straight to scratch. When preprocessing is
// active, preserve an original PNG for auto script-family probes and keep
// the enhanced raster separate for recognition. This prevents contrast
// transforms from turning faint Latin noise into false CJK evidence.
await image.png().toFile(inputPath);
const recognitionInputPath = recognitionImage
? join(outputDir, "input_ocr_recognition.png")
: inputPath;
if (recognitionImage) await recognitionImage.png().toFile(recognitionInputPath);
const meta = await sharp(pngBuffer).metadata();
const megapixels = ((meta.width ?? 0) * (meta.height ?? 0)) / 1_000_000;
const selectedLanguage = options.language ?? "auto";
const canContainCjkSceneText =
selectedLanguage === "auto" || selectedLanguage === "ja" || selectedLanguage === "zh";
const fallbackInputProvider =
quality === "fast" &&
canContainCjkSceneText &&
pixels >= FAST_CJK_SCENE_MIN_PIXELS &&
width >= FAST_CJK_SCENE_MIN_WIDTH &&
height >= FAST_CJK_SCENE_MIN_HEIGHT
? async () => {
// A whole mixed-polarity scene can hide dense light-on-dark CJK text
// from Tesseract even when each local band is clean. Split lazily so
// ordinary and strong primary results pay no extra raster/process cost.
const splitY = Math.floor(height / 2);
const paths = [
join(outputDir, "input_ocr_scene_upper.png"),
join(outputDir, "input_ocr_scene_lower.png"),
] as const;
await sharp(recognitionInputPath)
.extract({ left: 0, top: 0, width, height: splitY })
.png()
.toFile(paths[0]);
await sharp(recognitionInputPath)
.extract({ left: 0, top: splitY, width, height: height - splitY })
.png()
.toFile(paths[1]);
return paths;
}
: undefined;
const denseCjkInputProvider =
quality === "fast" &&
canContainCjkSceneText &&
pixels >= FAST_DENSE_CJK_MIN_PIXELS &&
pixels <= FAST_DENSE_CJK_MAX_PIXELS &&
width >= FAST_DENSE_CJK_MIN_WIDTH &&
height >= FAST_DENSE_CJK_MIN_HEIGHT
? async () => {
// Small, dense CJK boards are the one scene class where a confident
// sparse fragment can hide most of the page. Build this candidate
// lazily only after the primary pass is weak; the adaptive runner
// accepts it only when confidence and recovered coverage both rise.
const denseCjkInputPath = join(outputDir, "input_ocr_dense_cjk.png");
await sharp(inputPath)
.grayscale()
.clahe({
width: Math.max(1, Math.min(256, Math.round(width / 8))),
height: Math.max(1, Math.min(256, Math.round(height / 8))),
maxSlope: 2,
})
.sharpen({ sigma: 1 })
.png()
.toFile(denseCjkInputPath);
return denseCjkInputPath;
}
: undefined;
const megapixels = pixels / 1_000_000;
const timeout = Math.max(600_000, megapixels * 30 * 1000);
const { stdout } = await runPythonWithProgress("ocr.py", [inputPath, JSON.stringify(options)], {
onProgress,
timeout,
});
const result = parseStdoutJson(stdout);
if (!result.success) {
throw new Error(result.error || "OCR failed");
if (quality === "fast") {
const result = await withProgressHeartbeat(
(heartbeatProgress) =>
runAdaptiveTesseract(inputPath, {
language: (options.language ?? "auto") as TesseractLanguage,
...(automaticLowContrast && { blockLayoutOnly: true }),
...(recognitionInputPath !== inputPath && { recognitionInputPath }),
...(fallbackInputProvider && { fallbackInputProvider }),
...(denseCjkInputProvider && { denseCjkInputProvider }),
timeoutMs: timeout,
maxStdoutBytes: MAX_OCR_OUTPUT_BYTES,
signal: options.signal,
onProgress: heartbeatProgress,
}),
onProgress,
10,
"Running Fast OCR",
);
return {
...result,
requestedQuality: "fast",
actualQuality: "fast",
degraded: false,
warnings: automaticLowContrast ? ["Applied automatic low-contrast OCR preprocessing."] : [],
};
}
return {
text: result.text,
engine: result.engine,
const runtimeOptions = {
quality,
...(options.language !== undefined && { language: options.language }),
enhance: options.enhance ?? quality === "best",
};
onProgress?.(10, "Starting accurate OCR");
const { result } = await withProgressHeartbeat(
() =>
runOcrRuntime("ocr", [inputPath, JSON.stringify(runtimeOptions)], {
timeoutMs: timeout,
signal: options.signal,
}),
onProgress,
10,
"Running accurate OCR",
);
onProgress?.(100, "Accurate OCR complete");
return parseAccurateResult(result, quality);
}
// ── PDF OCR ───────────────────────────────────────────────────────────
@@ -60,11 +336,12 @@ export interface PdfOcrOptions {
quality?: OcrQuality;
language?: string;
pages?: string;
enhance?: boolean;
signal?: AbortSignal;
}
export interface PdfOcrResult {
export interface PdfOcrResult extends OcrExecutionMetadata {
text: string;
engine: string;
pages: number;
}
@@ -73,28 +350,82 @@ export async function extractPdfText(
opts: PdfOcrOptions = {},
onProgress?: ProgressCallback,
): Promise<PdfOcrResult> {
const optionsJson = JSON.stringify({
quality: opts.quality ?? "balanced",
language: opts.language ?? "auto",
pages: opts.pages ?? "all",
});
const quality = opts.quality ?? "fast";
assertFastLanguageSupported(quality, opts.language);
if (quality === "fast") {
const result = await withProgressHeartbeat(
(heartbeatProgress) =>
runTesseractPdf(inputPath, dirname(inputPath), {
pages: opts.pages ?? "all",
language: (opts.language ?? "auto") as TesseractLanguage,
enhance: opts.enhance ?? false,
signal: opts.signal,
onProgress: heartbeatProgress,
}),
onProgress,
10,
"Running Fast PDF OCR",
);
return {
text: result.text,
pages: result.pages,
engine: result.engine,
provider: result.provider,
device: result.device,
requestedQuality: "fast",
actualQuality: "fast",
degraded: false,
warnings: [],
};
}
const { stdout } = await runPythonWithProgress("ocr_pdf.py", [inputPath, optionsJson], {
timeout: 30 * 60_000,
const prepared = await withProgressHeartbeat(
(heartbeatProgress) =>
preparePdfOcrPages(inputPath, dirname(inputPath), {
pages: opts.pages ?? "all",
signal: opts.signal,
onProgress: heartbeatProgress,
}),
onProgress,
});
const result = parseStdoutJson(stdout);
if (result.error) {
throw new Error(result.error);
10,
"Preparing accurate PDF OCR",
);
try {
onProgress?.(50, "Starting accurate PDF OCR");
const { result } = await withProgressHeartbeat(
() =>
runOcrRuntime(
"ocr_pdf",
[
JSON.stringify(prepared.pages),
JSON.stringify({
quality,
language: opts.language ?? "auto",
enhance: opts.enhance ?? quality === "best",
}),
],
{
timeoutMs: prepared.remainingTimeoutMs(),
signal: opts.signal,
},
),
onProgress,
50,
"Running accurate PDF OCR",
);
const ocr = parseAccurateResult(result, quality);
const resultRecord = result as Record<string, unknown>;
if (resultRecord.pages !== prepared.pages.length) {
throw new Error(
`PDF OCR runtime page count mismatch: expected ${prepared.pages.length}, received ${String(resultRecord.pages)}`,
);
}
onProgress?.(100, "Accurate PDF OCR complete");
return {
...ocr,
pages: resultRecord.pages as number,
};
} finally {
await prepared.cleanup();
}
if (!result.success) {
throw new Error(result.error || "PDF OCR failed");
}
return {
text: result.text ?? "",
engine: result.engine ?? "unknown",
pages: result.pages ?? 0,
};
}
+276
View File
@@ -0,0 +1,276 @@
import { createPublicKey, verify } from "node:crypto";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export type OcrRuntimeTarget = "linux-amd64-cpu-py312" | "linux-arm64-cpu-py311";
export interface OcrRuntimeTrustKey {
keyId: string;
algorithm: "ed25519";
publicKey: string;
}
export interface VerifiedOcrRuntimeIndex {
artifact: Record<string, unknown>;
canonicalIndex: Buffer;
archiveFile: string;
archiveSha256: string;
archiveSize: number;
archiveExpandedSize: number;
minimumMemoryBytes: number;
}
export const OCR_RUNTIME_INDEX_MAX_BYTES = 16 * 1024 * 1024;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const SAFE_COMPONENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const PROJECT_ROOT = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function sortJson(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortJson);
if (!isRecord(value)) return value;
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, sortJson(value[key])]),
);
}
/** Canonical representation shared with install_runtime.py and release signing. */
export function canonicalRuntimeJson(value: unknown): string {
return `${JSON.stringify(sortJson(value))}\n`;
}
/** Resolve either the image-pinned release key or an operator-supplied trust store. */
export function loadOcrRuntimeTrustKeys(path?: string): OcrRuntimeTrustKey[] {
if (!path) {
const keyId = process.env.OCR_RUNTIME_INDEX_KEY_ID;
const encodedPublicKey = process.env.OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64;
if (keyId || encodedPublicKey) {
if (
!keyId ||
!SAFE_COMPONENT_PATTERN.test(keyId) ||
!encodedPublicKey ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encodedPublicKey)
) {
throw new Error("OCR runtime trust environment is incomplete or invalid");
}
const decoded = Buffer.from(encodedPublicKey, "base64");
if (decoded.toString("base64") !== encodedPublicKey) {
throw new Error("OCR runtime public key is not canonical base64");
}
return [{ keyId, algorithm: "ed25519", publicKey: decoded.toString("utf8") }];
}
}
const trustPath = path ?? join(PROJECT_ROOT, "docker", "ocr-runtime-trust.json");
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(trustPath, "utf8"));
} catch (error) {
throw new Error(`Unable to read the OCR runtime trust store at ${trustPath}`, { cause: error });
}
if (!isRecord(parsed) || parsed.schemaVersion !== 1 || !Array.isArray(parsed.keys)) {
throw new Error("OCR runtime trust store uses an unsupported schema");
}
const keys: OcrRuntimeTrustKey[] = [];
const seen = new Set<string>();
for (const value of parsed.keys) {
if (
!isRecord(value) ||
typeof value.keyId !== "string" ||
!SAFE_COMPONENT_PATTERN.test(value.keyId) ||
value.algorithm !== "ed25519" ||
typeof value.publicKey !== "string" ||
!value.publicKey
) {
throw new Error("OCR runtime trust store contains an invalid key");
}
if (seen.has(value.keyId)) throw new Error(`Duplicate OCR runtime trust key: ${value.keyId}`);
seen.add(value.keyId);
keys.push({
keyId: value.keyId,
algorithm: "ed25519",
publicKey: value.publicKey,
});
}
if (keys.length === 0) throw new Error("OCR runtime trust store contains no keys");
return keys;
}
function safeRelativeReleasePath(value: unknown, label: string): string {
if (typeof value !== "string" || !value || value.includes("\\") || value.includes("\0")) {
throw new Error(`OCR runtime index contains an invalid ${label}`);
}
const parts = value.split("/");
if (
value.startsWith("/") ||
value.endsWith("/") ||
parts.some((part) => !SAFE_COMPONENT_PATTERN.test(part)) ||
value.includes("://")
) {
throw new Error(`OCR runtime index contains an unsafe ${label}`);
}
return parts.join("/");
}
function decodeSignature(value: unknown): Buffer {
if (
typeof value !== "string" ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
) {
throw new Error("OCR runtime index contains an invalid signature encoding");
}
const decoded = Buffer.from(value, "base64");
if (decoded.length !== 64 || decoded.toString("base64") !== value) {
throw new Error("OCR runtime index contains an invalid Ed25519 signature");
}
return decoded;
}
/** Authenticate a canonical release index and select its one compatible OCR artifact. */
export function verifyRuntimeIndex(
raw: Buffer,
target: OcrRuntimeTarget,
trustKeys: readonly OcrRuntimeTrustKey[],
snapotterVersion: string,
): VerifiedOcrRuntimeIndex {
if (raw.length === 0 || raw.length > OCR_RUNTIME_INDEX_MAX_BYTES) {
throw new Error("OCR runtime index exceeds its size limit");
}
if (raw.some((byte) => byte > 0x7f)) {
throw new Error("OCR runtime index metadata must be canonical ASCII JSON");
}
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString("utf8"));
} catch (error) {
throw new Error("OCR runtime index is not valid JSON", { cause: error });
}
if (!isRecord(parsed) || parsed.schemaVersion !== 1) {
throw new Error("OCR runtime index uses an unsupported schema");
}
if (!raw.equals(Buffer.from(canonicalRuntimeJson(parsed)))) {
throw new Error("OCR runtime index is not canonical JSON");
}
const signature = parsed.signature;
if (
!isRecord(signature) ||
typeof signature.keyId !== "string" ||
signature.algorithm !== "ed25519"
) {
throw new Error("OCR runtime index has an invalid signature envelope");
}
const trustKey = trustKeys.find(
(candidate) =>
candidate.keyId === signature.keyId && candidate.algorithm === signature.algorithm,
);
if (!trustKey) {
throw new Error(`OCR runtime index key "${signature.keyId}" is not trusted`);
}
const unsigned = { ...parsed };
delete unsigned.signature;
let publicKey: ReturnType<typeof createPublicKey>;
try {
publicKey = createPublicKey(trustKey.publicKey);
} catch (error) {
throw new Error(`Trusted OCR runtime key "${trustKey.keyId}" is invalid`, { cause: error });
}
if (publicKey.asymmetricKeyType !== "ed25519") {
throw new Error(`Trusted OCR runtime key "${trustKey.keyId}" is not Ed25519`);
}
const valid = verify(
null,
Buffer.from(canonicalRuntimeJson(unsigned)),
publicKey,
decodeSignature(signature.value),
);
if (!valid) throw new Error("OCR runtime index signature verification failed");
if (!Array.isArray(parsed.artifacts)) {
throw new Error("OCR runtime index artifacts must be an array");
}
const matches = parsed.artifacts.filter(
(artifact): artifact is Record<string, unknown> =>
isRecord(artifact) && artifact.family === "ocr" && artifact.target === target,
);
if (matches.length !== 1) {
throw new Error(`OCR runtime index must contain exactly one artifact for ocr/${target}`);
}
const artifact = matches[0];
const compatibility = artifact.compatibility;
if (
!isRecord(compatibility) ||
compatibility.protocolVersion !== 1 ||
compatibility.snapotterVersion !== snapotterVersion ||
artifact.version !== snapotterVersion
) {
throw new Error(
`OCR runtime artifact version is incompatible with SnapOtter ${snapotterVersion}`,
);
}
const expectedArch = target === "linux-amd64-cpu-py312" ? "amd64" : "arm64";
if (artifact.platform !== "linux" || artifact.arch !== expectedArch) {
throw new Error("OCR runtime artifact platform does not match the selected target");
}
const capabilities = artifact.capabilities;
if (
!isRecord(capabilities) ||
!Array.isArray(capabilities.qualities) ||
capabilities.qualities.length !== 2 ||
!capabilities.qualities.includes("balanced") ||
!capabilities.qualities.includes("best") ||
!Array.isArray(capabilities.providers) ||
capabilities.providers.length !== 1 ||
capabilities.providers[0] !== "CPUExecutionProvider"
) {
throw new Error("OCR runtime artifact declares unsupported capabilities");
}
const resources = artifact.resources;
if (
!isRecord(resources) ||
typeof resources.minimumMemoryBytes !== "number" ||
!Number.isSafeInteger(resources.minimumMemoryBytes) ||
resources.minimumMemoryBytes <= 0
) {
throw new Error("OCR runtime artifact has an invalid minimum memory requirement");
}
const archive = artifact.archive;
if (!isRecord(archive)) throw new Error("OCR runtime artifact has no archive metadata");
const archiveFile = safeRelativeReleasePath(archive.file, "archive file");
if (typeof archive.sha256 !== "string" || !SHA256_PATTERN.test(archive.sha256)) {
throw new Error("OCR runtime artifact has an invalid archive digest");
}
if (
typeof archive.size !== "number" ||
!Number.isSafeInteger(archive.size) ||
archive.size <= 0
) {
throw new Error("OCR runtime artifact has an invalid archive size");
}
if (
typeof archive.expandedSize !== "number" ||
!Number.isSafeInteger(archive.expandedSize) ||
archive.expandedSize < 0
) {
throw new Error("OCR runtime artifact has an invalid expanded archive size");
}
return {
artifact,
canonicalIndex: raw,
archiveFile,
archiveSha256: archive.sha256,
archiveSize: archive.size,
archiveExpandedSize: archive.expandedSize,
minimumMemoryBytes: resources.minimumMemoryBytes,
};
}
+486
View File
@@ -0,0 +1,486 @@
import { readFileSync } from "node:fs";
import { totalmem } from "node:os";
import { posix } from "node:path";
export const OCR_RUNTIME_MINIMUM_MEMORY_BYTES = 4 * 1024 * 1024 * 1024;
const CGROUP_MEMORY_LIMIT_PATHS = [
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
"/sys/fs/cgroup/memory.limit_in_bytes",
] as const;
const CGROUP_MEMBERSHIP_RESOLUTION_ATTEMPTS = 3;
export interface OcrRuntimeMemoryOptions {
/** Exact test/caller override after physical and cgroup limits are resolved. */
effectiveMemoryBytes?: number;
/** Test seam for the host's configured physical capacity. */
physicalMemoryBytes?: number;
/** Test seam for cgroup v1/v2 capacity files. */
readTextFile?: (path: string) => string;
/** Test seam for Linux fail-closed cgroup discovery. */
hostPlatform?: NodeJS.Platform;
}
function positiveSafeBytes(value: number, label: string): number {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${label} must be a positive safe integer`);
}
return value;
}
function parseCgroupLimit(raw: string, zeroIsLimit = false): bigint | null {
const value = raw.trim();
if (value === "max" || !/^[0-9]+$/.test(value)) return null;
const parsed = BigInt(value);
return parsed > 0n || zeroIsLimit ? parsed : null;
}
function hasErrorCode(error: unknown, code: string): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === code;
}
function hasParentPathSegment(value: string): boolean {
return value.split("/").includes("..");
}
function decodeMountInfoPath(value: string): string {
return value.replace(/\\([0-7]{3})/g, (_match, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
);
}
interface MountInfoRecord {
id: number;
parentId: number;
device: string;
filesystem: string;
root: string;
mountPoint: string;
controllers: Set<string>;
}
interface CgroupMount extends MountInfoRecord {
filesystem: "cgroup" | "cgroup2";
}
function parseMountInfo(raw: string): MountInfoRecord[] {
const mounts: MountInfoRecord[] = [];
for (const line of raw.split("\n")) {
if (!line) continue;
const fields = line.split(" ");
const separator = fields.indexOf("-");
if (
separator < 6 ||
!/^[1-9][0-9]*$/.test(fields[0]) ||
!/^[1-9][0-9]*$/.test(fields[1]) ||
!/^[0-9]+:[0-9]+$/.test(fields[2])
) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
const id = Number(fields[0]);
const parentId = Number(fields[1]);
if (!Number.isSafeInteger(id) || !Number.isSafeInteger(parentId)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
const filesystem = fields[separator + 1];
const root = decodeMountInfoPath(fields[3]);
const decodedMountPoint = decodeMountInfoPath(fields[4]);
if (!decodedMountPoint.startsWith("/") || hasParentPathSegment(decodedMountPoint)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
mounts.push({
id,
parentId,
device: fields[2],
filesystem,
root,
mountPoint: posix.normalize(decodedMountPoint),
controllers: new Set(
fields
.slice(separator + 3)
.join(",")
.split(","),
),
});
}
return mounts;
}
function isStrictPathPrefix(parent: string, child: string): boolean {
return parent === "/" ? child !== "/" : child.startsWith(`${parent}/`);
}
function pathDepth(value: string): number {
return value.split("/").filter(Boolean).length;
}
function parseVisibleMounts(raw: string): MountInfoRecord[] {
const mounts = parseMountInfo(raw);
const mountsById = new Map<number, MountInfoRecord>();
for (const mount of mounts) {
if (mountsById.has(mount.id)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
mountsById.set(mount.id, mount);
}
const parentStates = new Map<number, "visiting" | "visited">();
const validateParentChain = (mount: MountInfoRecord): void => {
if (mount.parentId === mount.id) {
if (mount.mountPoint !== "/") {
throw new Error("unable to resolve the process cgroup memory capacity");
}
parentStates.set(mount.id, "visited");
return;
}
const state = parentStates.get(mount.id);
if (state === "visiting") {
throw new Error("unable to resolve the process cgroup memory capacity");
}
if (state === "visited") return;
parentStates.set(mount.id, "visiting");
const parent = mountsById.get(mount.parentId);
if (parent) validateParentChain(parent);
parentStates.set(mount.id, "visited");
};
for (const mount of mounts) validateParentChain(mount);
const coveredIds = new Set<number>();
for (const mount of mounts) {
const parent = mountsById.get(mount.parentId);
if (parent && parent.id !== mount.id && parent.mountPoint === mount.mountPoint) {
coveredIds.add(parent.id);
}
}
const visibleMounts: MountInfoRecord[] = [];
const visibleIds = new Set<number>();
const topMounts = mounts
.filter((mount) => !coveredIds.has(mount.id))
.sort((left, right) => pathDepth(left.mountPoint) - pathDepth(right.mountPoint));
for (const mount of topMounts) {
let containingParent = mountsById.get(mount.parentId);
while (containingParent?.mountPoint === mount.mountPoint) {
if (containingParent.parentId === containingParent.id) {
containingParent = undefined;
break;
}
containingParent = mountsById.get(containingParent.parentId);
}
let longestVisiblePrefix: MountInfoRecord | undefined;
for (const visibleMount of visibleMounts) {
if (
isStrictPathPrefix(visibleMount.mountPoint, mount.mountPoint) &&
(!longestVisiblePrefix ||
visibleMount.mountPoint.length > longestVisiblePrefix.mountPoint.length)
) {
longestVisiblePrefix = visibleMount;
}
}
const visible = containingParent
? visibleIds.has(containingParent.id) && longestVisiblePrefix?.id === containingParent.id
: longestVisiblePrefix === undefined;
if (visible) {
if (visibleMounts.some((candidate) => candidate.mountPoint === mount.mountPoint)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
visibleMounts.push(mount);
visibleIds.add(mount.id);
}
}
return visibleMounts;
}
function isCgroupMount(mount: MountInfoRecord): mount is CgroupMount {
return mount.filesystem === "cgroup" || mount.filesystem === "cgroup2";
}
function isPathPrefix(parent: string, child: string): boolean {
return parent === child || isStrictPathPrefix(parent, child);
}
function normalizedAbsolutePath(value: string): string | null {
if (!value.startsWith("/") || hasParentPathSegment(value)) return null;
return posix.normalize(value);
}
function hasConsistentCgroupPath(
selected: CgroupMount,
path: string,
visibleMounts: MountInfoRecord[],
): boolean {
let owner: MountInfoRecord | undefined;
for (const mount of visibleMounts) {
if (
isPathPrefix(mount.mountPoint, path) &&
(!owner || mount.mountPoint.length > owner.mountPoint.length)
) {
owner = mount;
}
}
if (!owner) return false;
if (owner.id === selected.id) return true;
if (
owner.filesystem !== selected.filesystem ||
owner.device !== selected.device ||
!isStrictPathPrefix(selected.mountPoint, owner.mountPoint)
) {
return false;
}
const selectedRoot = normalizedAbsolutePath(selected.root);
const ownerRoot = normalizedAbsolutePath(owner.root);
if (!selectedRoot || !ownerRoot) return false;
const relativeMountPoint = posix.relative(selected.mountPoint, owner.mountPoint);
return ownerRoot === posix.normalize(posix.join(selectedRoot, relativeMountPoint));
}
function validLinuxMembership(fields: RegExpExecArray | null): fields is RegExpExecArray {
if (fields === null || !fields[3]?.startsWith("/") || hasParentPathSegment(fields[3])) {
return false;
}
const hierarchy = fields[1];
const controllers = fields[2];
return hierarchy === "0"
? controllers === ""
: controllers.length > 0 && /^[1-9][0-9]*$/.test(hierarchy);
}
function cgroupProcessPath(mount: CgroupMount, membership: string): string | null {
if (
hasParentPathSegment(mount.root) ||
hasParentPathSegment(mount.mountPoint) ||
hasParentPathSegment(membership)
) {
return null;
}
const root = posix.normalize(mount.root);
const member = posix.normalize(membership);
if (!root.startsWith("/") || !member.startsWith("/") || !mount.mountPoint.startsWith("/")) {
return null;
}
let suffix: string;
if (root === "/") suffix = member.slice(1);
else if (member === root) suffix = "";
else if (member.startsWith(`${root}/`)) suffix = member.slice(root.length + 1);
else return null;
const candidate = posix.normalize(posix.join(mount.mountPoint, suffix));
return candidate === mount.mountPoint || candidate.startsWith(`${mount.mountPoint}/`)
? candidate
: null;
}
function resolveMembershipMemoryLimits(
readTextFile: (path: string) => string,
failClosed: boolean,
membershipRaw: string,
): bigint[] | null {
const membershipLines = membershipRaw.split("\n").filter(Boolean);
if (failClosed && membershipLines.length === 0) {
throw new Error("unable to read the process cgroup memory capacity");
}
const parsedMemberships = membershipLines.map((line) => /^([^:]*):([^:]*):(.*)$/.exec(line));
if (parsedMemberships.some((fields) => fields !== null && hasParentPathSegment(fields[3]))) {
throw new Error("unable to read the process cgroup memory capacity");
}
if (failClosed && parsedMemberships.some((fields) => !validLinuxMembership(fields))) {
throw new Error("unable to read the process cgroup memory capacity");
}
const allMemberships = parsedMemberships
.filter(
(fields): fields is RegExpExecArray => fields !== null && fields[3]?.startsWith("/") === true,
)
.map(([, hierarchy, controllers, membershipPath]) => ({
kind: hierarchy === "0" && controllers === "" ? "cgroup2" : "cgroup",
controllers: new Set(controllers.split(",").filter(Boolean)),
path: membershipPath,
}));
const v1MemoryMemberships = allMemberships.filter(
(membership) => membership.kind === "cgroup" && membership.controllers.has("memory"),
);
const memberships =
v1MemoryMemberships.length > 0
? v1MemoryMemberships
: allMemberships.filter((membership) => membership.kind === "cgroup2");
if (memberships.length === 0) return null;
let mountInfoRaw: string;
try {
mountInfoRaw = readTextFile("/proc/self/mountinfo");
} catch {
throw new Error("unable to resolve the process cgroup memory capacity");
}
const visibleMounts = parseVisibleMounts(mountInfoRaw);
const mounts = visibleMounts.filter(isCgroupMount);
const selectedMemberships = memberships.flatMap((membership) => {
const selected: Array<{ mount: CgroupMount; processPath: string; limitFile: string }> = [];
for (const mount of mounts) {
if (
mount.filesystem !== membership.kind ||
(mount.filesystem === "cgroup" && !mount.controllers.has("memory"))
) {
continue;
}
const processPath = cgroupProcessPath(mount, membership.path);
if (!processPath) continue;
selected.push({
mount,
processPath,
limitFile: mount.filesystem === "cgroup2" ? "memory.max" : "memory.limit_in_bytes",
});
}
if (selected.length === 0) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
return selected;
});
const limits: bigint[] = [];
for (const selected of selectedMemberships) {
if (!hasConsistentCgroupPath(selected.mount, selected.processPath, visibleMounts)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
let current = selected.processPath;
while (true) {
let raw: string | undefined;
const limitPath = posix.join(current, selected.limitFile);
if (!hasConsistentCgroupPath(selected.mount, limitPath, visibleMounts)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
try {
raw = readTextFile(limitPath);
} catch (error) {
const absentV2Limit =
selected.mount.filesystem === "cgroup2" && hasErrorCode(error, "ENOENT");
if (absentV2Limit) {
const controllersPath = posix.join(current, "cgroup.controllers");
if (!hasConsistentCgroupPath(selected.mount, controllersPath, visibleMounts)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
try {
readTextFile(controllersPath);
} catch {
throw new Error("unable to read the process cgroup memory capacity");
}
} else {
throw new Error("unable to read the process cgroup memory capacity");
}
}
if (raw !== undefined) {
const normalized = raw.trim();
if (normalized !== "max" && !/^[0-9]+$/.test(normalized)) {
throw new Error("malformed cgroup memory capacity");
}
const limit = parseCgroupLimit(raw, true);
if (limit !== null) limits.push(limit);
}
if (current === selected.mount.mountPoint) break;
const parent = posix.dirname(current);
if (parent === current || !parent.startsWith(selected.mount.mountPoint)) break;
current = parent;
}
}
return limits;
}
function membershipMemoryLimits(
readTextFile: (path: string) => string,
failClosed: boolean,
): bigint[] | null {
for (let attempt = 0; attempt < CGROUP_MEMBERSHIP_RESOLUTION_ATTEMPTS; attempt += 1) {
let membershipRaw: string;
try {
membershipRaw = readTextFile("/proc/self/cgroup");
} catch {
if (failClosed || attempt > 0) {
throw new Error("unable to read the process cgroup memory capacity");
}
return null;
}
let limits: bigint[] | null;
try {
limits = resolveMembershipMemoryLimits(readTextFile, failClosed, membershipRaw);
} catch (error) {
let failedMembershipRaw: string;
try {
failedMembershipRaw = readTextFile("/proc/self/cgroup");
} catch {
throw new Error("unable to read the process cgroup memory capacity");
}
if (failedMembershipRaw === membershipRaw) throw error;
continue;
}
let confirmedMembershipRaw: string;
try {
confirmedMembershipRaw = readTextFile("/proc/self/cgroup");
} catch {
throw new Error("unable to read the process cgroup memory capacity");
}
if (confirmedMembershipRaw === membershipRaw) return limits;
}
throw new Error("unable to read a stable process cgroup memory capacity");
}
/** Configured capacity available to this process, including container limits. */
export function getOcrRuntimeEffectiveMemoryBytes(options: OcrRuntimeMemoryOptions = {}): number {
if (options.effectiveMemoryBytes !== undefined) {
return positiveSafeBytes(options.effectiveMemoryBytes, "effective OCR runtime memory");
}
const physical = positiveSafeBytes(
options.physicalMemoryBytes ?? totalmem(),
"physical OCR runtime memory",
);
let effective = BigInt(physical);
const readTextFile = options.readTextFile ?? ((path: string) => readFileSync(path, "utf8"));
const hostPlatform = options.hostPlatform ?? process.platform;
const isLinux = hostPlatform === "linux";
const membershipLimits = membershipMemoryLimits(readTextFile, isLinux);
if (membershipLimits === null) {
for (const path of CGROUP_MEMORY_LIMIT_PATHS) {
try {
const limit = parseCgroupLimit(readTextFile(path), isLinux);
if (limit !== null && limit < effective) effective = limit;
} catch {
// A host normally exposes either cgroup v2, one v1 layout, or neither.
}
}
} else {
for (const limit of membershipLimits) if (limit < effective) effective = limit;
}
const constrained =
options.physicalMemoryBytes === undefined && typeof process.constrainedMemory === "function"
? process.constrainedMemory()
: undefined;
if (constrained && Number.isSafeInteger(constrained) && constrained > 0) {
effective = effective < BigInt(constrained) ? effective : BigInt(constrained);
}
return Number(effective);
}
export function hasOcrRuntimeMemory(
minimumMemoryBytes: number,
options: OcrRuntimeMemoryOptions = {},
): boolean {
positiveSafeBytes(minimumMemoryBytes, "OCR runtime minimum memory");
return getOcrRuntimeEffectiveMemoryBytes(options) >= minimumMemoryBytes;
}
export function assertOcrRuntimeMemory(
minimumMemoryBytes: number,
options: OcrRuntimeMemoryOptions = {},
): void {
positiveSafeBytes(minimumMemoryBytes, "OCR runtime minimum memory");
const effectiveMemoryBytes = getOcrRuntimeEffectiveMemoryBytes(options);
if (effectiveMemoryBytes < minimumMemoryBytes) {
throw new Error(
`insufficient memory for accurate OCR runtime: ${minimumMemoryBytes} bytes required, ${effectiveMemoryBytes} available; Fast OCR remains available`,
);
}
}
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
import { spawn } from "node:child_process";
const MAX_INVENTORY_OUTPUT_BYTES = 64 * 1024;
const FORCE_KILL_DELAY_MS = 1_000;
export const SUPPORTED_TESSERACT_TRAINEDDATA = [
"eng",
"deu",
"fra",
"spa",
"chi_sim",
"jpn",
] as const;
export interface TesseractLanguageInventoryOptions {
executable: string;
timeoutMs: number;
signal?: AbortSignal;
}
const inventoryCache = new Map<string, ReadonlySet<string>>();
function cacheKey(executable: string): string {
return `${executable}\0${process.env.TESSDATA_PREFIX ?? ""}`;
}
/** Clear the process-local inventory after an installation or in isolated tests. */
export function clearTesseractLanguageInventoryCache(): void {
inventoryCache.clear();
}
/** Return a defensive copy when this executable was already preflighted. */
export function getCachedTesseractLanguages(executable: string): ReadonlySet<string> | undefined {
const cached = inventoryCache.get(cacheKey(executable));
return cached ? new Set(cached) : undefined;
}
function abortError(): Error {
const error = new Error("Tesseract language-pack preflight was canceled");
error.name = "AbortError";
return error;
}
function parseLanguageInventory(stdout: string): ReadonlySet<string> {
const lines = stdout.replaceAll("\r\n", "\n").split("\n");
while (lines.at(-1) === "") lines.pop();
const header = lines.shift();
const match = header?.match(/^List of available languages in .+ \((\d+)\):$/u);
if (!match) {
throw new Error(
"Tesseract --list-langs returned malformed output; cannot verify installed traineddata.",
);
}
const declaredCount = Number(match[1]);
if (
!Number.isSafeInteger(declaredCount) ||
lines.length !== declaredCount ||
lines.some(
(language) =>
!/^[A-Za-z0-9][A-Za-z0-9_./-]*$/u.test(language) ||
language.includes("..") ||
language.endsWith("/"),
) ||
new Set(lines).size !== lines.length
) {
if (Number.isSafeInteger(declaredCount) && lines.length !== declaredCount) {
throw new Error(
`Tesseract --list-langs declared ${declaredCount} languages but returned ${lines.length}.`,
);
}
throw new Error(
"Tesseract --list-langs returned malformed output; cannot verify installed traineddata.",
);
}
return new Set(lines);
}
/**
* Ask the selected executable which traineddata it can actually load. Successful
* inventories are cached per executable and TESSDATA_PREFIX for the process.
*/
export async function getInstalledTesseractLanguages(
options: TesseractLanguageInventoryOptions,
): Promise<ReadonlySet<string>> {
if (!options.executable) throw new Error("Tesseract executable path is empty");
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
throw new Error("Tesseract language-pack preflight timeout must be positive");
}
if (options.signal?.aborted) throw abortError();
const key = cacheKey(options.executable);
const cached = inventoryCache.get(key);
if (cached) return new Set(cached);
const installed = await new Promise<ReadonlySet<string>>((resolve, reject) => {
const child = spawn(options.executable, ["--list-langs"], {
shell: false,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let settled = false;
let terminationError: Error | undefined;
let forceKillTimer: NodeJS.Timeout | undefined;
const timeoutTimer = setTimeout(() => {
terminate(
new Error(
`Tesseract language-pack preflight timed out after ${Math.floor(options.timeoutMs)}ms`,
),
);
}, options.timeoutMs);
timeoutTimer.unref();
const cleanup = () => {
clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
options.signal?.removeEventListener("abort", onAbort);
};
const finish = (error?: Error, result?: ReadonlySet<string>) => {
if (settled) return;
settled = true;
cleanup();
if (error) reject(error);
else resolve(result as ReadonlySet<string>);
};
function terminate(error: Error) {
if (settled || terminationError) return;
terminationError = error;
try {
child.kill("SIGTERM");
} catch {
// The close/error event retains process ownership and settles the call.
}
forceKillTimer = setTimeout(() => {
if (!settled) {
try {
child.kill("SIGKILL");
} catch {
// Wait for close before releasing the request.
}
}
}, FORCE_KILL_DELAY_MS);
forceKillTimer.unref();
}
const onAbort = () => terminate(abortError());
options.signal?.addEventListener("abort", onAbort, { once: true });
if (options.signal?.aborted) terminate(abortError());
child.stdout.on("data", (chunk: Buffer | string) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
stdoutBytes += buffer.length;
if (stdoutBytes > MAX_INVENTORY_OUTPUT_BYTES) {
terminate(new Error("Tesseract --list-langs stdout exceeded 65536 bytes"));
return;
}
stdoutChunks.push(buffer);
});
child.stderr.on("data", (chunk: Buffer | string) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
stderrBytes += buffer.length;
if (stderrBytes > MAX_INVENTORY_OUTPUT_BYTES) {
terminate(new Error("Tesseract --list-langs stderr exceeded 65536 bytes"));
return;
}
stderrChunks.push(buffer);
});
child.once("error", (error: NodeJS.ErrnoException) => {
if (terminationError) return;
if (error.code === "ENOENT") {
finish(
new Error(
"Tesseract executable not found while checking installed language packs. Install Tesseract or set TESSERACT_PATH.",
{ cause: error },
),
);
return;
}
finish(new Error(`Unable to run Tesseract --list-langs: ${error.message}`, { cause: error }));
});
child.once("close", (code, signal) => {
if (terminationError) {
finish(terminationError);
return;
}
if (code !== 0) {
const detail = Buffer.concat(stderrChunks).toString("utf8").trim();
const status = code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`;
finish(
new Error(
`Unable to inspect Tesseract language packs: --list-langs exited with ${status}${detail ? `: ${detail}` : ""}`,
),
);
return;
}
try {
finish(undefined, parseLanguageInventory(Buffer.concat(stdoutChunks).toString("utf8")));
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
});
});
inventoryCache.set(key, installed);
return new Set(installed);
}
+624
View File
@@ -0,0 +1,624 @@
import { spawn } from "node:child_process";
import { mkdir, mkdtemp, realpath, rm, stat, statfs } from "node:fs/promises";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import sharp from "sharp";
import {
type RunTesseractOptions,
runAdaptiveTesseract,
type TesseractLanguage,
type TesseractRuntimeMetadata,
} from "./tesseract.js";
export const MAX_PDF_OCR_PAGES = 50;
/** Shared Fast/accurate UTF-8 text ceiling, including PDF page headings. */
export const MAX_PDF_OCR_OUTPUT_BYTES = 1_000_000;
const DEFAULT_DPI = 300;
const MIN_DPI = 72;
const MAX_DPI = 600;
const DEFAULT_TIMEOUT_MS = 30 * 60_000;
const FORCE_KILL_DELAY_MS = 1_000;
const MAX_RASTER_DIMENSION = 6_000;
const MAX_RASTER_PIXELS = 25_000_000;
const MAX_PREPARED_RASTER_BYTES = 512n * 1024n * 1024n;
const MIN_SCRATCH_FREE_BYTES = 256n * 1024n * 1024n;
const MAX_DIAGNOSTIC_OUTPUT = 16_384;
const PAGE_COUNT_PROGRAM = "PDFname (r) file runpdfbegin pdfpagecount = quit";
const PAGE_BOX_PROGRAM =
"PDFname (r) file runpdfbegin /page PageNumber pdfgetpage def /box page /CropBox known { page /CropBox get } { page /MediaBox get } ifelse def box == page /UserUnit known { page /UserUnit get } { 1 } ifelse == quit";
export interface RunTesseractPdfOptions {
pages?: string;
language?: TesseractLanguage;
/** Apply conservative local-contrast preprocessing before Tesseract. */
enhance?: boolean;
/** Requested raster resolution. Oversized pages are automatically rendered lower. */
dpi?: number;
timeoutMs?: number;
signal?: AbortSignal;
onProgress?: (progress: number, stage: string) => void;
/** Override for deployments where Ghostscript is not on PATH. */
ghostscriptPath?: string;
/** Override for deployments where Tesseract is not on PATH. */
tesseractPath?: string;
}
export interface TesseractPdfResult extends TesseractRuntimeMetadata {
text: string;
pages: number;
pageNumbers: number[];
}
export interface PreparedPdfOcrPage {
page: number;
path: string;
}
export interface PreparedPdfOcrPages {
pages: PreparedPdfOcrPage[];
totalPages: number;
remainingTimeoutMs: () => number;
cleanup: () => Promise<void>;
}
interface ProcessResult {
stdout: string;
stderr: string;
}
interface PageBox {
widthPoints: number;
heightPoints: number;
}
function abortError(): Error {
const error = new Error("PDF OCR was canceled");
error.name = "AbortError";
return error;
}
function validatePositiveInteger(value: number, label: string, maximum?: number): number {
if (!Number.isInteger(value) || value <= 0 || (maximum !== undefined && value > maximum)) {
const range = maximum === undefined ? "a positive integer" : `an integer from 1 to ${maximum}`;
throw new Error(`${label} must be ${range}`);
}
return value;
}
/** Parse a strict 1-based page list such as `all`, `1-3,5`, or `2,4-6`. */
export function parsePdfPageSpec(spec: string, totalPages: number): number[] {
validatePositiveInteger(totalPages, "PDF page count");
const normalized = spec.trim();
if (!normalized) throw new Error("No pages specified");
if (normalized.toLowerCase() === "all") {
if (totalPages > MAX_PDF_OCR_PAGES) {
throw new Error(`Too many pages for OCR (max ${MAX_PDF_OCR_PAGES})`);
}
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
const selected = new Set<number>();
for (const rawPart of normalized.split(",")) {
const part = rawPart.trim();
if (!part) throw new Error(`Invalid page selection: "${spec}"`);
const match = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(part);
if (!match) throw new Error(`Invalid page selection: "${part}"`);
const start = Number(match[1]);
const end = match[2] === undefined ? start : Number(match[2]);
if (start < 1 || end < 1) {
throw new Error(`Invalid page selection: "${part}" (pages start at 1)`);
}
if (start > end) {
throw new Error(`Invalid page selection: "${part}" (range start is after range end)`);
}
if (end > totalPages) {
throw new Error(`Invalid page selection: "${part}" (document has ${totalPages} pages)`);
}
for (let page = start; page <= end; page += 1) {
selected.add(page);
if (selected.size > MAX_PDF_OCR_PAGES) {
throw new Error(`Too many pages for OCR (max ${MAX_PDF_OCR_PAGES})`);
}
}
}
if (selected.size === 0) throw new Error("No pages specified");
return [...selected].sort((left, right) => left - right);
}
function appendDiagnostic(current: string, chunk: Buffer | string): string {
const next = current + chunk.toString();
return next.length <= MAX_DIAGNOSTIC_OUTPUT ? next : next.slice(-MAX_DIAGNOSTIC_OUTPUT);
}
function runGhostscript(
executable: string,
args: string[],
timeoutMs: number,
totalTimeoutMs: number,
signal?: AbortSignal,
): Promise<ProcessResult> {
if (signal?.aborted) return Promise.reject(abortError());
return new Promise((resolve, reject) => {
const child = spawn(executable, args, {
shell: false,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
let settled = false;
let termination: "abort" | "timeout" | undefined;
let forceKillTimer: NodeJS.Timeout | undefined;
const timeoutTimer = setTimeout(() => terminate("timeout"), timeoutMs);
timeoutTimer.unref();
const cleanup = () => {
clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
signal?.removeEventListener("abort", onAbort);
};
const finish = (error?: Error, result?: ProcessResult) => {
if (settled) return;
settled = true;
cleanup();
if (error) reject(error);
else resolve(result as ProcessResult);
};
const finishTermination = () => {
if (termination === "abort") finish(abortError());
else if (termination === "timeout") {
finish(new Error(`PDF OCR timed out after ${totalTimeoutMs}ms`));
}
};
function terminate(reason: "abort" | "timeout") {
if (settled || termination) return;
termination = reason;
try {
child.kill("SIGTERM");
} catch {
// A concurrent process exit owns settlement through close/error.
}
forceKillTimer = setTimeout(() => {
if (!settled) {
try {
child.kill("SIGKILL");
} catch {
// Wait for close before deleting the raster scratch directory.
}
}
}, FORCE_KILL_DELAY_MS);
forceKillTimer.unref();
}
const onAbort = () => terminate("abort");
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) terminate("abort");
child.stdout.on("data", (chunk: Buffer | string) => {
stdout = appendDiagnostic(stdout, chunk);
});
child.stderr.on("data", (chunk: Buffer | string) => {
stderr = appendDiagnostic(stderr, chunk);
});
child.once("error", (error: NodeJS.ErrnoException) => {
if (termination) return;
if (error.code === "ENOENT") {
finish(
new Error("Ghostscript executable not found. Install Ghostscript or set GS_PATH.", {
cause: error,
}),
);
return;
}
finish(new Error(`Unable to start Ghostscript: ${error.message}`, { cause: error }));
});
child.once("close", (code, closeSignal) => {
if (termination) {
finishTermination();
return;
}
if (code !== 0) {
const detail = stderr.trim();
const status = code === null ? `signal ${closeSignal ?? "unknown"}` : `code ${code}`;
finish(new Error(`Ghostscript exited with ${status}${detail ? `: ${detail}` : ""}`));
return;
}
finish(undefined, { stdout, stderr });
});
});
}
function parsePageCount(stdout: string): number {
const count = Number(stdout.trim());
if (!Number.isSafeInteger(count) || count < 1) {
throw new Error("Ghostscript returned an invalid or empty PDF page count");
}
return count;
}
function parsePageBox(stdout: string, pageNumber: number): PageBox {
const arrayMatch = stdout.match(/\[([^\]]+)\]\s*$/m);
const values = arrayMatch?.[1]
.trim()
.split(/\s+/)
.map((value) => Number(value));
if (values?.length !== 4 || values.some((value) => !Number.isFinite(value))) {
throw new Error(`Ghostscript returned invalid dimensions for PDF page ${pageNumber}`);
}
const userUnitMatch = stdout.match(/\]\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)\s*$/);
const userUnit = userUnitMatch ? Number(userUnitMatch[1]) : 1;
if (!Number.isFinite(userUnit) || userUnit <= 0) {
throw new Error(`PDF page ${pageNumber} has an invalid UserUnit`);
}
const widthPoints = Math.abs(values[2] - values[0]) * userUnit;
const heightPoints = Math.abs(values[3] - values[1]) * userUnit;
if (widthPoints <= 0 || heightPoints <= 0) {
throw new Error(`PDF page ${pageNumber} has invalid dimensions`);
}
return { widthPoints, heightPoints };
}
async function validateRasterPage(path: string, pageNumber: number): Promise<string> {
const canonicalPath = await realpath(path);
let metadata: Awaited<ReturnType<ReturnType<typeof sharp>["metadata"]>>;
try {
metadata = await sharp(canonicalPath, { limitInputPixels: MAX_RASTER_PIXELS }).metadata();
} catch (error) {
throw new Error(`PDF page ${pageNumber} produced an unsafe or invalid OCR raster`, {
cause: error,
});
}
const width = metadata.width ?? 0;
const height = metadata.height ?? 0;
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width <= 0 ||
height <= 0 ||
width > MAX_RASTER_DIMENSION ||
height > MAX_RASTER_DIMENSION ||
width * height > MAX_RASTER_PIXELS
) {
throw new Error(`PDF page ${pageNumber} produced unsafe raster dimensions`);
}
return canonicalPath;
}
async function retainRasterWithinScratchBudget(
path: string,
scratchDir: string,
retainedBytes: bigint,
): Promise<bigint> {
const rasterInfo = await stat(path, { bigint: true });
if (!rasterInfo.isFile()) throw new Error("PDF OCR produced a non-regular raster file");
const nextRetainedBytes = retainedBytes + rasterInfo.size;
if (nextRetainedBytes > MAX_PREPARED_RASTER_BYTES) {
throw new Error("PDF OCR rasters exceed the 512 MiB aggregate scratch limit");
}
const scratchInfo = await statfs(scratchDir, { bigint: true });
const availableBytes = scratchInfo.bavail * scratchInfo.bsize;
if (availableBytes < MIN_SCRATCH_FREE_BYTES) {
throw new Error("PDF OCR cannot preserve the 256 MiB free scratch space reserve");
}
return nextRetainedBytes;
}
function safeDpi(pageBox: PageBox, requestedDpi: number, pageNumber: number): number {
const requestedWidth = (pageBox.widthPoints / 72) * requestedDpi;
const requestedHeight = (pageBox.heightPoints / 72) * requestedDpi;
const dimensionScale = Math.min(
1,
MAX_RASTER_DIMENSION / requestedWidth,
MAX_RASTER_DIMENSION / requestedHeight,
);
const pixelScale = Math.min(1, Math.sqrt(MAX_RASTER_PIXELS / (requestedWidth * requestedHeight)));
const dpi = Math.max(1, Math.floor(requestedDpi * Math.min(dimensionScale, pixelScale)));
if (dpi < MIN_DPI) {
throw new Error(
`PDF page ${pageNumber} is too large to rasterize at the ${MIN_DPI} DPI quality floor`,
);
}
const width = Math.ceil((pageBox.widthPoints / 72) * dpi);
const height = Math.ceil((pageBox.heightPoints / 72) * dpi);
if (
width > MAX_RASTER_DIMENSION ||
height > MAX_RASTER_DIMENSION ||
width * height > MAX_RASTER_PIXELS
) {
throw new Error(`PDF page ${pageNumber} is too large to rasterize safely`);
}
return dpi;
}
function remainingTimeout(deadline: number, totalTimeoutMs: number): number {
const remaining = deadline - performance.now();
if (remaining <= 0) throw new Error(`PDF OCR timed out after ${totalTimeoutMs}ms`);
return remaining;
}
function ghostscriptBaseArgs(inputPath: string): string[] {
return [
"-q",
"-dNODISPLAY",
"-dBATCH",
"-dSAFER",
`--permit-file-read=${inputPath}`,
`-sPDFname=${inputPath}`,
];
}
/**
* Rasterize validated, selected PDF pages for an OCR engine. The caller owns
* the returned lease-like object and must invoke cleanup in a finally block.
*/
export async function preparePdfOcrPages(
inputPath: string,
scratchDir: string,
options: Pick<
RunTesseractPdfOptions,
"pages" | "dpi" | "timeoutMs" | "signal" | "onProgress" | "ghostscriptPath"
> = {},
): Promise<PreparedPdfOcrPages> {
if (options.signal?.aborted) throw abortError();
const requestedDpi = options.dpi ?? DEFAULT_DPI;
if (!Number.isInteger(requestedDpi) || requestedDpi < MIN_DPI || requestedDpi > MAX_DPI) {
throw new Error(`PDF OCR DPI must be an integer from ${MIN_DPI} to ${MAX_DPI}`);
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error("PDF OCR timeout must be a positive number");
}
const deadline = performance.now() + timeoutMs;
const executable = options.ghostscriptPath ?? process.env.GS_PATH ?? "gs";
const resolvedInputPath = await realpath(inputPath);
await mkdir(scratchDir, { recursive: true });
const jobScratchDir = await mkdtemp(join(scratchDir, "ocr-pdf-pages-"));
const cleanup = () => rm(jobScratchDir, { recursive: true, force: true }).catch(() => {});
try {
options.onProgress?.(0, "Opening PDF");
const countResult = await runGhostscript(
executable,
[...ghostscriptBaseArgs(resolvedInputPath), "-c", PAGE_COUNT_PROGRAM],
remainingTimeout(deadline, timeoutMs),
timeoutMs,
options.signal,
);
const totalPages = parsePageCount(countResult.stdout);
const pageNumbers = parsePdfPageSpec(options.pages ?? "all", totalPages);
const pages: PreparedPdfOcrPage[] = [];
let retainedRasterBytes = 0n;
for (const [index, pageNumber] of pageNumbers.entries()) {
if (options.signal?.aborted) throw abortError();
options.onProgress?.(
5 + Math.floor((index / pageNumbers.length) * 40),
`Rasterizing PDF page ${pageNumber}`,
);
const boxResult = await runGhostscript(
executable,
[
...ghostscriptBaseArgs(resolvedInputPath),
`-dPageNumber=${pageNumber}`,
"-c",
PAGE_BOX_PROGRAM,
],
remainingTimeout(deadline, timeoutMs),
timeoutMs,
options.signal,
);
const dpi = safeDpi(parsePageBox(boxResult.stdout, pageNumber), requestedDpi, pageNumber);
const pagePath = join(jobScratchDir, `page-${pageNumber}.png`);
await runGhostscript(
executable,
[
"-q",
"-dBATCH",
"-dNOPAUSE",
"-dSAFER",
"-dUseCropBox",
`-dFirstPage=${pageNumber}`,
`-dLastPage=${pageNumber}`,
"-sDEVICE=pnggray",
"-dTextAlphaBits=4",
"-dGraphicsAlphaBits=4",
`-r${dpi}`,
`-sOutputFile=${pagePath}`,
resolvedInputPath,
],
remainingTimeout(deadline, timeoutMs),
timeoutMs,
options.signal,
);
const rasterPath = await validateRasterPage(pagePath, pageNumber);
retainedRasterBytes = await retainRasterWithinScratchBudget(
rasterPath,
jobScratchDir,
retainedRasterBytes,
);
pages.push({ page: pageNumber, path: rasterPath });
}
return {
pages,
totalPages,
remainingTimeoutMs: () => remainingTimeout(deadline, timeoutMs),
cleanup,
};
} catch (error) {
await cleanup();
throw error;
}
}
/** Rasterize selected PDF pages with Ghostscript and OCR them with built-in Tesseract. */
export async function runTesseractPdf(
inputPath: string,
scratchDir: string,
options: RunTesseractPdfOptions = {},
): Promise<TesseractPdfResult> {
if (options.signal?.aborted) throw abortError();
const requestedDpi = options.dpi ?? DEFAULT_DPI;
if (!Number.isInteger(requestedDpi) || requestedDpi < MIN_DPI || requestedDpi > MAX_DPI) {
throw new Error(`PDF OCR DPI must be an integer from ${MIN_DPI} to ${MAX_DPI}`);
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error("PDF OCR timeout must be a positive number");
}
const deadline = performance.now() + timeoutMs;
const executable = options.ghostscriptPath ?? process.env.GS_PATH ?? "gs";
const resolvedInputPath = await realpath(inputPath);
await mkdir(scratchDir, { recursive: true });
const jobScratchDir = await mkdtemp(join(scratchDir, "ocr-pdf-"));
try {
options.onProgress?.(0, "Opening PDF");
const pageCountResult = await runGhostscript(
executable,
[...ghostscriptBaseArgs(resolvedInputPath), "-c", PAGE_COUNT_PROGRAM],
remainingTimeout(deadline, timeoutMs),
timeoutMs,
options.signal,
);
const totalPages = parsePageCount(pageCountResult.stdout);
const pageNumbers = parsePdfPageSpec(options.pages ?? "all", totalPages);
const pageTexts: string[] = [];
let retainedOutputBytes = 0;
for (const [index, pageNumber] of pageNumbers.entries()) {
if (options.signal?.aborted) throw abortError();
const pageBaseProgress = 5 + Math.floor((index / pageNumbers.length) * 90);
options.onProgress?.(pageBaseProgress, `Rasterizing PDF page ${pageNumber}`);
const pageBoxResult = await runGhostscript(
executable,
[
...ghostscriptBaseArgs(resolvedInputPath),
`-dPageNumber=${pageNumber}`,
"-c",
PAGE_BOX_PROGRAM,
],
remainingTimeout(deadline, timeoutMs),
timeoutMs,
options.signal,
);
const dpi = safeDpi(parsePageBox(pageBoxResult.stdout, pageNumber), requestedDpi, pageNumber);
const pagePath = join(jobScratchDir, `page-${pageNumber}.png`);
await runGhostscript(
executable,
[
"-q",
"-dBATCH",
"-dNOPAUSE",
"-dSAFER",
"-dUseCropBox",
`-dFirstPage=${pageNumber}`,
`-dLastPage=${pageNumber}`,
"-sDEVICE=pnggray",
"-dTextAlphaBits=4",
"-dGraphicsAlphaBits=4",
`-r${dpi}`,
`-sOutputFile=${pagePath}`,
resolvedInputPath,
],
remainingTimeout(deadline, timeoutMs),
timeoutMs,
options.signal,
);
const separator = pageTexts.length === 0 ? "" : "\n\n";
const pageHeading = `--- Page ${pageNumber} ---\n\n`;
const framingBytes = Buffer.byteLength(separator) + Buffer.byteLength(pageHeading);
const remainingOutputBytes = MAX_PDF_OCR_OUTPUT_BYTES - retainedOutputBytes - framingBytes;
if (remainingOutputBytes <= 0) {
throw new Error(
`PDF OCR exceeded the ${MAX_PDF_OCR_OUTPUT_BYTES} byte aggregate output limit`,
);
}
const tesseractOptions: RunTesseractOptions = {
language: options.language ?? "auto",
timeoutMs: remainingTimeout(deadline, timeoutMs),
signal: options.signal,
tesseractPath: options.tesseractPath,
maxStdoutBytes: remainingOutputBytes,
onProgress: (progress, stage) => {
const pageShare = 90 / pageNumbers.length;
options.onProgress?.(
Math.min(95, Math.floor(pageBaseProgress + (progress / 100) * pageShare)),
stage,
);
},
};
const rasterPath = await validateRasterPage(pagePath, pageNumber);
let ocrPath = rasterPath;
if (options.enhance) {
options.onProgress?.(pageBaseProgress, `Enhancing PDF page ${pageNumber}`);
const metadata = await sharp(rasterPath, {
limitInputPixels: MAX_RASTER_PIXELS,
}).metadata();
const width = metadata.width ?? 0;
const height = metadata.height ?? 0;
const enhancedPath = join(jobScratchDir, `enhanced-page-${pageNumber}.png`);
await sharp(rasterPath, { limitInputPixels: MAX_RASTER_PIXELS })
.clahe({
width: Math.max(1, Math.min(256, Math.round(width / 8))),
height: Math.max(1, Math.min(256, Math.round(height / 8))),
maxSlope: 2,
})
.png()
.toFile(enhancedPath);
ocrPath = await validateRasterPage(enhancedPath, pageNumber);
await rm(rasterPath, { force: true });
}
const result = await runAdaptiveTesseract(ocrPath, tesseractOptions).finally(() =>
rm(ocrPath, { force: true }).catch(() => {}),
);
const pageText = `${pageHeading}${result.text.trim()}`;
const addedBytes = Buffer.byteLength(separator) + Buffer.byteLength(pageText);
if (retainedOutputBytes + addedBytes > MAX_PDF_OCR_OUTPUT_BYTES) {
throw new Error(
`PDF OCR exceeded the ${MAX_PDF_OCR_OUTPUT_BYTES} byte aggregate output limit`,
);
}
pageTexts.push(pageText);
retainedOutputBytes += addedBytes;
}
options.onProgress?.(100, "Tesseract PDF OCR complete");
return {
text: pageTexts.join("\n\n"),
pages: pageNumbers.length,
pageNumbers,
engine: "tesseract",
provider: "native",
device: "cpu",
};
} finally {
await rm(jobScratchDir, { recursive: true, force: true }).catch(() => {});
}
}
+803
View File
@@ -0,0 +1,803 @@
import { spawn } from "node:child_process";
import { performance } from "node:perf_hooks";
import {
getCachedTesseractLanguages,
getInstalledTesseractLanguages,
} from "./tesseract-languages.js";
export type TesseractLanguage = "auto" | "en" | "de" | "fr" | "es" | "zh" | "ja";
export const TESSERACT_LANGUAGE_MAP = {
en: "eng",
de: "deu",
fr: "fra",
es: "spa",
zh: "chi_sim",
ja: "jpn",
} as const satisfies Record<Exclude<TesseractLanguage, "auto">, string>;
const ALL_TESSERACT_LANGUAGES = Object.values(TESSERACT_LANGUAGE_MAP).join("+");
export function resolveTesseractLanguage(language: TesseractLanguage): string {
if (language === "auto") return ALL_TESSERACT_LANGUAGES;
const mapped = TESSERACT_LANGUAGE_MAP[language];
if (!mapped) {
throw new Error(`Unsupported OCR language "${language}"`);
}
return mapped;
}
export interface TesseractRuntimeMetadata {
engine: "tesseract";
provider: "native";
device: "cpu";
}
export interface TesseractResult extends TesseractRuntimeMetadata {
text: string;
}
export interface RunTesseractOptions {
language?: TesseractLanguage;
timeoutMs?: number;
signal?: AbortSignal;
onProgress?: (progress: number, stage: string) => void;
/** Override for deployments where Tesseract is not on PATH. */
tesseractPath?: string;
/** Maximum bytes retained independently for stdout and stderr. */
maxOutputBytes?: number;
/** Maximum stdout bytes retained; overrides maxOutputBytes for stdout only. */
maxStdoutBytes?: number;
/** Maximum stderr bytes retained; overrides maxOutputBytes for stderr only. */
maxStderrBytes?: number;
/** Internal page segmentation override used by the bounded adaptive runner. */
pageSegmentationMode?: 6 | 11;
/** Internal renderer override used to obtain confidence-bearing TSV output. */
outputFormat?: "text" | "tsv";
/** Internal, whitelisted language family used by adaptive auto detection. */
tesseractLanguages?: string;
/** Internal process-ownership grace reserved by the aggregate adaptive deadline. */
terminationGraceMs?: number;
/** Internal preprocessed raster; auto script probing remains on inputPath. */
recognitionInputPath?: string;
/** Internal calibrated mode for dense, faint low-resolution documents. */
blockLayoutOnly?: boolean;
/** Internal pre-split scene rasters used only when primary CJK evidence is weak. */
fallbackInputPaths?: readonly string[];
/** Internal lazy provider that avoids splitting a strong or Latin primary image. */
fallbackInputProvider?: () => Promise<readonly string[]>;
/** Internal lazy provider for one bounded dense-CJK preprocessing candidate. */
denseCjkInputProvider?: () => Promise<string>;
}
const DEFAULT_TIMEOUT_MS = 120_000;
const DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
const FORCE_KILL_DELAY_MS = 1_000;
const AUTO_LATIN_LANGUAGES = "eng+deu+fra+spa";
const AUTO_CJK_LANGUAGES = "jpn+chi_sim";
const AUTO_LATIN_LANGUAGE_CODES = AUTO_LATIN_LANGUAGES.split("+");
const AUTO_CJK_LANGUAGE_CODES = AUTO_CJK_LANGUAGES.split("+");
const ALL_TESSERACT_LANGUAGE_CODES = ALL_TESSERACT_LANGUAGES.split("+");
// CJK packs can hallucinate ideographs on faint Latin receipts. Require a
// baseline script density, then either strong density or a material run whose
// confidence-weighted score beats the Latin probe. This retains genuine mixed
// CJK receipts dominated by addresses, prices, Latin text, and digits.
const AUTO_CJK_MIN_SCRIPT_RATIO = 0.18;
const AUTO_CJK_STRONG_SCRIPT_RATIO = 0.3;
const AUTO_CJK_COMPARATIVE_MIN_CHARACTERS = 5;
// Development-corpus calibration: smaller score gains were caused by sparse
// layout emitting extra low-value tokens. Only a substantial gain justifies
// replacing the stable block-layout result.
const SPARSE_LAYOUT_MIN_SCORE_GAIN = 0.25;
const CJK_SCENE_FALLBACK_MAX_PATHS = 2;
const CJK_SCENE_FALLBACK_MAX_PRIMARY_SCORE = 1.5;
const CJK_SCENE_FALLBACK_MAX_PRIMARY_CHARACTERS = 128;
const CJK_SCENE_FALLBACK_MIN_SCORE_GAIN = 0.5;
const CJK_SCENE_FALLBACK_MIN_CHARACTER_GAIN = 64;
// A development-only board cohort showed a narrow failure mode where sparse
// segmentation retained a confident fragment while losing most dense text.
// Only try one enhanced block pass for a moderately weak CJK primary, and only
// retain it when it carries both credible confidence and substantially more
// text. Strong pages and low-confidence noise remain byte-for-byte unchanged.
const CJK_DENSE_ENHANCEMENT_MAX_PRIMARY_SCORE = 2.2;
const CJK_DENSE_ENHANCEMENT_MIN_BLOCK_SCORE = 1.5;
const CJK_DENSE_ENHANCEMENT_MIN_CHARACTER_GAIN = 64;
const DEBIAN_TESSERACT_PACKAGE_SUFFIX: Readonly<Record<string, string>> = {
chi_sim: "chi-sim",
};
function installedSubset(languageCodes: readonly string[], installed: ReadonlySet<string>): string {
return languageCodes.filter((language) => installed.has(language)).join("+");
}
function isAllowedInternalLanguageSet(languageSet: string): boolean {
const languages = languageSet.split("+");
const isOrderedSubset = (allowed: readonly string[]) => {
let previousIndex = -1;
for (const language of languages) {
const index = allowed.indexOf(language);
if (index <= previousIndex) return false;
previousIndex = index;
}
return true;
};
return (
languages.length > 0 &&
(isOrderedSubset(ALL_TESSERACT_LANGUAGE_CODES) ||
isOrderedSubset(AUTO_LATIN_LANGUAGE_CODES) ||
isOrderedSubset(AUTO_CJK_LANGUAGE_CODES))
);
}
function missingLanguagePackError(
requestedLanguage: TesseractLanguage,
missingTraineddata: readonly string[],
): Error {
if (requestedLanguage !== "auto" && missingTraineddata.length === 1) {
const traineddata = missingTraineddata[0];
const debianPackageSuffix = DEBIAN_TESSERACT_PACKAGE_SUFFIX[traineddata] ?? traineddata;
return new Error(
`Tesseract language "${requestedLanguage}" is unavailable: missing traineddata "${traineddata}". Install Debian/Ubuntu package tesseract-ocr-${debianPackageSuffix} or the equivalent traineddata pack (Homebrew: brew install tesseract-lang), then restart SnapOtter.`,
);
}
return new Error(
`Tesseract is missing required traineddata: ${missingTraineddata.join(", ")}. Install the matching tesseract-ocr-<lang> packages or the equivalent platform language pack, then restart SnapOtter.`,
);
}
function requireSupportedAutoLanguages(installed: ReadonlySet<string>): {
latin: string;
cjk: string;
} {
const latin = installedSubset(AUTO_LATIN_LANGUAGE_CODES, installed);
const cjk = installedSubset(AUTO_CJK_LANGUAGE_CODES, installed);
if (!latin && !cjk) {
throw new Error(
"Tesseract has no supported traineddata installed. Install at least tesseract-ocr-eng (or the equivalent platform language pack), then restart SnapOtter.",
);
}
return { latin, cjk };
}
function requireInstalledExplicitLanguage(
requestedLanguage: Exclude<TesseractLanguage, "auto">,
installed: ReadonlySet<string>,
): string {
const traineddata = resolveTesseractLanguage(requestedLanguage);
if (!installed.has(traineddata)) {
throw missingLanguagePackError(requestedLanguage, [traineddata]);
}
return traineddata;
}
interface TesseractLayoutCandidate {
text: string;
score: number;
cjkCharacters: number;
visibleCharacters: number;
}
interface TesseractLayoutSelection extends TesseractLayoutCandidate {
pageSegmentationMode: 6 | 11;
}
function isCjkCharacter(character: string): boolean {
return (
(character >= "\u3040" && character <= "\u30ff") ||
(character >= "\u3400" && character <= "\u9fff") ||
(character >= "\uac00" && character <= "\ud7af") ||
(character >= "\u1100" && character <= "\u11ff")
);
}
function parseTesseractTsv(
tsv: string,
options: { stripStandaloneRuleArtifacts?: boolean } = {},
): TesseractLayoutCandidate {
const rows = tsv.split(/\r?\n/u);
if (
rows[0] !==
"level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext"
) {
throw new Error("Tesseract returned malformed TSV output");
}
const lines = new Map<string, string[]>();
const words: Array<{ characters: number; confidence: number }> = [];
for (const row of rows.slice(1)) {
if (!row) continue;
const fields = row.split("\t");
if (
fields.length < 12 ||
!/^[1-5]$/u.test(fields[0]) ||
fields.slice(1, 6).some((value) => !/^\d+$/u.test(value))
) {
throw new Error("Tesseract returned malformed TSV output");
}
if (fields[0] !== "5") continue;
const text = fields.slice(11).join("\t").trim();
const confidence = Number(fields[10]);
if (!text) continue;
if (options.stripStandaloneRuleArtifacts && /^\|+$/u.test(text)) continue;
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 100) {
throw new Error("Tesseract returned malformed TSV confidence");
}
const lineKey = fields.slice(1, 5).join(":");
const line = lines.get(lineKey) ?? [];
line.push(text);
lines.set(lineKey, line);
words.push({
characters: Array.from(text.replace(/\s/gu, "")).length,
confidence: confidence / 100,
});
}
const text = Array.from(lines.values(), (line) => line.join(" ")).join("\n");
const visible = Array.from(text).filter((character) => !/\s/u.test(character));
const scriptEvidence = {
cjkCharacters: visible.filter(isCjkCharacter).length,
visibleCharacters: visible.length,
};
const characterCount = words.reduce((sum, word) => sum + word.characters, 0);
if (characterCount === 0) return { text, score: 0, ...scriptEvidence };
const confidenceCoverage =
words.reduce((sum, word) => sum + word.characters * word.confidence, 0) / characterCount;
const highConfidenceCharacters = words.reduce(
(sum, word) => sum + word.characters * Math.max(0, Math.min(1, (word.confidence - 0.3) / 0.7)),
0,
);
return {
text,
score: confidenceCoverage * Math.log1p(highConfidenceCharacters),
...scriptEvidence,
};
}
export function selectTesseractLanguageFamily(
latinTsv: string,
cjkTsv: string,
): typeof AUTO_LATIN_LANGUAGES | typeof AUTO_CJK_LANGUAGES {
const latin = parseTesseractTsv(latinTsv);
const cjk = parseTesseractTsv(cjkTsv);
const cjkRatio = cjk.cjkCharacters / Math.max(cjk.visibleCharacters, 1);
const hasBaselineEvidence = cjk.cjkCharacters >= 2 && cjkRatio >= AUTO_CJK_MIN_SCRIPT_RATIO;
const hasStrongDensity = cjkRatio >= AUTO_CJK_STRONG_SCRIPT_RATIO;
const hasStrongerComparativeEvidence =
cjk.cjkCharacters >= AUTO_CJK_COMPARATIVE_MIN_CHARACTERS && cjk.score >= latin.score;
return hasBaselineEvidence && (hasStrongDensity || hasStrongerComparativeEvidence)
? AUTO_CJK_LANGUAGES
: AUTO_LATIN_LANGUAGES;
}
export function selectTesseractLayout(
blockTsv: string,
sparseTsv: string,
): { pageSegmentationMode: 6 | 11; text: string } {
const selected = selectTesseractLayoutCandidate(blockTsv, sparseTsv);
return {
pageSegmentationMode: selected.pageSegmentationMode,
text: selected.text,
};
}
function selectTesseractLayoutCandidate(
blockTsv: string,
sparseTsv: string,
): TesseractLayoutSelection {
const block = parseTesseractTsv(blockTsv);
const sparse = parseTesseractTsv(sparseTsv);
return sparse.score >= block.score + SPARSE_LAYOUT_MIN_SCORE_GAIN
? { pageSegmentationMode: 11, ...sparse }
: { pageSegmentationMode: 6, ...block };
}
function remainingTimeout(deadline: number, timeoutMs: number, terminationGraceMs: number): number {
const remaining = deadline - performance.now() - terminationGraceMs;
const bounded = Math.floor(remaining);
if (bounded <= 0) {
throw new Error(`Tesseract OCR timed out after ${timeoutMs}ms`);
}
return bounded;
}
/** Run bounded block and sparse-layout candidates and retain the calibrated winner. */
export async function runAdaptiveTesseract(
inputPath: string,
options: RunTesseractOptions = {},
): Promise<TesseractResult> {
const requestedLanguage = options.language ?? "auto";
resolveTesseractLanguage(requestedLanguage);
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error("Tesseract timeout must be a positive number");
}
const maxTextBytes = options.maxStdoutBytes ?? options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
if (!Number.isSafeInteger(maxTextBytes) || maxTextBytes <= 0) {
throw new Error("Tesseract output limit must be a positive integer");
}
const maxTsvBytes = Math.max(1024 * 1024, Math.min(DEFAULT_MAX_OUTPUT_BYTES, maxTextBytes * 8));
// Reserve the actual per-process SIGTERM-to-SIGKILL grace once against the
// shared monotonic deadline. Every sequential candidate receives only the
// execution time left before that reserve, so cleanup cannot stack one
// second of overrun per candidate.
const terminationGraceMs = Math.min(FORCE_KILL_DELAY_MS, Math.floor(timeoutMs / 4));
const deadline = performance.now() + timeoutMs;
const executable = options.tesseractPath ?? process.env.TESSERACT_PATH ?? "tesseract";
const installedLanguages = await getInstalledTesseractLanguages({
executable,
timeoutMs: remainingTimeout(deadline, timeoutMs, terminationGraceMs),
signal: options.signal,
});
const autoFamilies =
requestedLanguage === "auto" ? requireSupportedAutoLanguages(installedLanguages) : undefined;
const explicitLanguage =
requestedLanguage === "auto"
? undefined
: requireInstalledExplicitLanguage(requestedLanguage, installedLanguages);
let fallbackInputPaths = options.fallbackInputPaths ?? [];
if (
!Array.isArray(fallbackInputPaths) ||
fallbackInputPaths.length > CJK_SCENE_FALLBACK_MAX_PATHS ||
fallbackInputPaths.some((path) => typeof path !== "string" || path.length === 0)
) {
throw new Error("Tesseract scene fallback paths are invalid");
}
if (
options.fallbackInputProvider !== undefined &&
typeof options.fallbackInputProvider !== "function"
) {
throw new Error("Tesseract scene fallback provider is invalid");
}
if (fallbackInputPaths.length > 0 && options.fallbackInputProvider) {
throw new Error("Tesseract scene fallback inputs are ambiguous");
}
if (
options.denseCjkInputProvider !== undefined &&
typeof options.denseCjkInputProvider !== "function"
) {
throw new Error("Tesseract dense CJK input provider is invalid");
}
const hasSceneFallback =
fallbackInputPaths.length > 0 || options.fallbackInputProvider !== undefined;
const hasFallback = hasSceneFallback || options.denseCjkInputProvider !== undefined;
const primaryProgressScale = hasFallback ? 0.5 : 1;
const runCandidate = (
candidateInputPath: string,
pageSegmentationMode: 6 | 11,
progressBase: number,
progressSpan: number,
tesseractLanguages?: string,
) =>
runTesseract(candidateInputPath, {
...options,
timeoutMs: remainingTimeout(deadline, timeoutMs, terminationGraceMs),
terminationGraceMs,
maxStdoutBytes: maxTsvBytes,
pageSegmentationMode,
outputFormat: "tsv",
...(tesseractLanguages !== undefined && { tesseractLanguages }),
onProgress: (progress, stage) =>
options.onProgress?.(Math.min(100, progressBase + (progress / 100) * progressSpan), stage),
});
let block: TesseractResult;
let sparse: TesseractResult;
let selectedLanguages: string;
const recognitionInputPath = options.recognitionInputPath ?? inputPath;
if (!recognitionInputPath) throw new Error("Tesseract recognition input path is invalid");
if (requestedLanguage === "auto") {
if (!autoFamilies) throw new Error("Tesseract auto language inventory is unavailable");
const separateRecognitionInput = recognitionInputPath !== inputPath;
if (autoFamilies.latin && autoFamilies.cjk) {
const probeSpan = separateRecognitionInput ? 25 : 33;
const latinBlock = await runCandidate(
inputPath,
6,
0,
probeSpan * primaryProgressScale,
autoFamilies.latin,
);
const cjkBlock = await runCandidate(
inputPath,
6,
probeSpan * primaryProgressScale,
probeSpan * primaryProgressScale,
autoFamilies.cjk,
);
selectedLanguages =
selectTesseractLanguageFamily(latinBlock.text, cjkBlock.text) === AUTO_CJK_LANGUAGES
? autoFamilies.cjk
: autoFamilies.latin;
if (separateRecognitionInput) {
block = await runCandidate(
recognitionInputPath,
6,
50 * primaryProgressScale,
25 * primaryProgressScale,
selectedLanguages,
);
sparse = options.blockLayoutOnly
? block
: await runCandidate(
recognitionInputPath,
11,
75 * primaryProgressScale,
25 * primaryProgressScale,
selectedLanguages,
);
} else {
block = selectedLanguages === autoFamilies.cjk ? cjkBlock : latinBlock;
sparse = options.blockLayoutOnly
? block
: await runCandidate(
inputPath,
11,
66 * primaryProgressScale,
34 * primaryProgressScale,
selectedLanguages,
);
}
} else {
selectedLanguages = autoFamilies.latin || autoFamilies.cjk;
block = await runCandidate(
recognitionInputPath,
6,
0,
50 * primaryProgressScale,
selectedLanguages,
);
sparse = options.blockLayoutOnly
? block
: await runCandidate(
recognitionInputPath,
11,
50 * primaryProgressScale,
50 * primaryProgressScale,
selectedLanguages,
);
}
} else {
if (!explicitLanguage) throw new Error("Tesseract explicit language inventory is unavailable");
selectedLanguages = explicitLanguage;
block = await runCandidate(
recognitionInputPath,
6,
0,
50 * primaryProgressScale,
selectedLanguages,
);
sparse = options.blockLayoutOnly
? block
: await runCandidate(
recognitionInputPath,
11,
50 * primaryProgressScale,
50 * primaryProgressScale,
selectedLanguages,
);
}
let selected = selectTesseractLayoutCandidate(block.text, sparse.text);
const selectedCjkLanguages = selectedLanguages
.split("+")
.some((language) => AUTO_CJK_LANGUAGE_CODES.includes(language));
let fallbackProgressBase = 50;
const denseCjkInputProvider = options.denseCjkInputProvider;
const weakDenseCjkScene =
denseCjkInputProvider !== undefined &&
selectedCjkLanguages &&
selected.score < CJK_DENSE_ENHANCEMENT_MAX_PRIMARY_SCORE;
if (weakDenseCjkScene) {
const denseCjkInputPath = await denseCjkInputProvider();
if (typeof denseCjkInputPath !== "string" || denseCjkInputPath.length === 0) {
throw new Error("Tesseract dense CJK input path is invalid");
}
const denseProgressSpan = hasSceneFallback ? 20 : 50;
const denseBlock = await runCandidate(
denseCjkInputPath,
6,
fallbackProgressBase,
denseProgressSpan,
selectedLanguages,
);
fallbackProgressBase += denseProgressSpan;
const denseCandidate = parseTesseractTsv(denseBlock.text, {
stripStandaloneRuleArtifacts: true,
});
if (
denseCandidate.score >= CJK_DENSE_ENHANCEMENT_MIN_BLOCK_SCORE &&
denseCandidate.visibleCharacters >=
selected.visibleCharacters + CJK_DENSE_ENHANCEMENT_MIN_CHARACTER_GAIN
) {
selected = { pageSegmentationMode: 6, ...denseCandidate };
}
}
const weakPrimaryCjkScene =
hasSceneFallback &&
selectedCjkLanguages &&
selected.score < CJK_SCENE_FALLBACK_MAX_PRIMARY_SCORE &&
selected.visibleCharacters < CJK_SCENE_FALLBACK_MAX_PRIMARY_CHARACTERS;
if (weakPrimaryCjkScene) {
if (options.fallbackInputProvider) {
fallbackInputPaths = await options.fallbackInputProvider();
if (
!Array.isArray(fallbackInputPaths) ||
fallbackInputPaths.length === 0 ||
fallbackInputPaths.length > CJK_SCENE_FALLBACK_MAX_PATHS ||
fallbackInputPaths.some((path) => typeof path !== "string" || path.length === 0)
) {
throw new Error("Tesseract scene fallback paths are invalid");
}
}
const tiledSelections: TesseractLayoutSelection[] = [];
const tileProgressSpan = (100 - fallbackProgressBase) / fallbackInputPaths.length;
for (const [index, fallbackInputPath] of fallbackInputPaths.entries()) {
const tileProgressBase = fallbackProgressBase + index * tileProgressSpan;
const tileBlock = await runCandidate(
fallbackInputPath,
6,
tileProgressBase,
tileProgressSpan / (options.blockLayoutOnly ? 1 : 2),
selectedLanguages,
);
const tileSparse = options.blockLayoutOnly
? tileBlock
: await runCandidate(
fallbackInputPath,
11,
tileProgressBase + tileProgressSpan / 2,
tileProgressSpan / 2,
selectedLanguages,
);
tiledSelections.push(selectTesseractLayoutCandidate(tileBlock.text, tileSparse.text));
}
const tiledScore = tiledSelections.reduce((sum, candidate) => sum + candidate.score, 0);
const tiledVisibleCharacters = tiledSelections.reduce(
(sum, candidate) => sum + candidate.visibleCharacters,
0,
);
if (
tiledScore >= selected.score + CJK_SCENE_FALLBACK_MIN_SCORE_GAIN &&
tiledVisibleCharacters >= selected.visibleCharacters + CJK_SCENE_FALLBACK_MIN_CHARACTER_GAIN
) {
selected = {
pageSegmentationMode: 6,
text: tiledSelections
.map((candidate) => candidate.text)
.filter(Boolean)
.join("\n"),
score: tiledScore,
cjkCharacters: tiledSelections.reduce((sum, candidate) => sum + candidate.cjkCharacters, 0),
visibleCharacters: tiledVisibleCharacters,
};
}
}
if (Buffer.byteLength(selected.text, "utf8") > maxTextBytes) {
throw new Error(`Tesseract stdout exceeded ${maxTextBytes} bytes`);
}
options.onProgress?.(100, "Tesseract OCR complete");
return {
text: selected.text,
...getTesseractRuntimeMetadata(),
};
}
export function getTesseractRuntimeMetadata(): TesseractRuntimeMetadata {
return {
engine: "tesseract",
provider: "native",
device: "cpu",
};
}
function abortError(): Error {
const error = new Error("Tesseract OCR was canceled");
error.name = "AbortError";
return error;
}
/** Run the built-in Tesseract binary without involving the Python AI runtime. */
export function runTesseract(
inputPath: string,
options: RunTesseractOptions = {},
): Promise<TesseractResult> {
if (options.signal?.aborted) return Promise.reject(abortError());
const requestedLanguage = options.language ?? "auto";
try {
resolveTesseractLanguage(requestedLanguage);
} catch (error) {
return Promise.reject(error);
}
if (
options.tesseractLanguages !== undefined &&
!isAllowedInternalLanguageSet(options.tesseractLanguages)
) {
return Promise.reject(new Error("Unsupported internal Tesseract language set"));
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return Promise.reject(new Error("Tesseract timeout must be a positive number"));
}
const terminationGraceMs = options.terminationGraceMs ?? FORCE_KILL_DELAY_MS;
if (
!Number.isSafeInteger(terminationGraceMs) ||
terminationGraceMs < 0 ||
terminationGraceMs > FORCE_KILL_DELAY_MS
) {
return Promise.reject(new Error("Tesseract termination grace is invalid"));
}
const maxStdoutBytes =
options.maxStdoutBytes ?? options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
const maxStderrBytes =
options.maxStderrBytes ?? options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
if (
!Number.isSafeInteger(maxStdoutBytes) ||
maxStdoutBytes <= 0 ||
!Number.isSafeInteger(maxStderrBytes) ||
maxStderrBytes <= 0
) {
return Promise.reject(new Error("Tesseract output limit must be a positive integer"));
}
const executable = options.tesseractPath ?? process.env.TESSERACT_PATH ?? "tesseract";
const installedLanguages = getCachedTesseractLanguages(executable);
if (!installedLanguages) {
const preflightStarted = performance.now();
return getInstalledTesseractLanguages({
executable,
timeoutMs,
signal: options.signal,
}).then(() => {
const remainingMs = Math.floor(timeoutMs - (performance.now() - preflightStarted));
if (remainingMs <= 0) {
throw new Error(`Tesseract OCR timed out after ${timeoutMs}ms`);
}
return runTesseract(inputPath, { ...options, timeoutMs: remainingMs });
});
}
let language: string;
if (options.tesseractLanguages !== undefined) {
language = options.tesseractLanguages;
const missing = language
.split("+")
.filter((traineddata) => !installedLanguages.has(traineddata));
if (missing.length > 0) {
return Promise.reject(missingLanguagePackError(requestedLanguage, missing));
}
} else if (requestedLanguage === "auto") {
try {
requireSupportedAutoLanguages(installedLanguages);
} catch (error) {
return Promise.reject(error);
}
language = installedSubset(ALL_TESSERACT_LANGUAGE_CODES, installedLanguages);
} else {
try {
language = requireInstalledExplicitLanguage(requestedLanguage, installedLanguages);
} catch (error) {
return Promise.reject(error);
}
}
options.onProgress?.(0, "Starting Tesseract OCR");
return new Promise((resolve, reject) => {
const args = [inputPath, "stdout", "-l", language];
if (options.pageSegmentationMode !== undefined) {
args.push("--psm", String(options.pageSegmentationMode));
}
if (options.outputFormat === "tsv") args.push("tsv");
const child = spawn(executable, args, {
shell: false,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let settled = false;
let terminationError: Error | undefined;
let forceKillTimer: NodeJS.Timeout | undefined;
const timeoutTimer = setTimeout(() => {
terminate(new Error(`Tesseract OCR timed out after ${timeoutMs}ms`));
}, timeoutMs);
timeoutTimer.unref();
const cleanup = () => {
clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
options.signal?.removeEventListener("abort", onAbort);
};
const finish = (error?: Error, result?: TesseractResult) => {
if (settled) return;
settled = true;
cleanup();
if (error) reject(error);
else resolve(result as TesseractResult);
};
const finishTermination = () => {
if (terminationError) finish(terminationError);
};
function terminate(error: Error) {
if (settled || terminationError) return;
terminationError = error;
try {
child.kill("SIGTERM");
} catch {
// A concurrent process exit owns settlement through close/error.
}
if (settled) return;
forceKillTimer = setTimeout(() => {
if (!settled) {
try {
child.kill("SIGKILL");
} catch {
// Wait for close before releasing request-owned scratch state.
}
}
}, terminationGraceMs);
forceKillTimer.unref();
}
const onAbort = () => terminate(abortError());
options.signal?.addEventListener("abort", onAbort, { once: true });
if (options.signal?.aborted) terminate(abortError());
child.stdout.on("data", (chunk: Buffer | string) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
stdoutBytes += buffer.length;
if (stdoutBytes > maxStdoutBytes) {
terminate(new Error(`Tesseract stdout exceeded ${maxStdoutBytes} bytes`));
return;
}
stdoutChunks.push(buffer);
});
child.stderr.on("data", (chunk: Buffer | string) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
stderrBytes += buffer.length;
if (stderrBytes > maxStderrBytes) {
terminate(new Error(`Tesseract stderr exceeded ${maxStderrBytes} bytes`));
return;
}
stderrChunks.push(buffer);
});
child.once("error", (error: NodeJS.ErrnoException) => {
if (terminationError) {
return;
}
if (error.code === "ENOENT") {
finish(
new Error("Tesseract executable not found. Install Tesseract or set TESSERACT_PATH.", {
cause: error,
}),
);
return;
}
finish(new Error(`Unable to start Tesseract: ${error.message}`, { cause: error }));
});
child.once("close", (code, signal) => {
if (terminationError) {
finishTermination();
return;
}
if (code !== 0) {
const detail = Buffer.concat(stderrChunks).toString("utf8").trim();
const status = code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`;
finish(new Error(`Tesseract exited with ${status}${detail ? `: ${detail}` : ""}`));
return;
}
options.onProgress?.(100, "Tesseract OCR complete");
finish(undefined, {
text: Buffer.concat(stdoutChunks).toString("utf8"),
...getTesseractRuntimeMetadata(),
});
});
});
}