/** * Shared server-side OCR core (tesseract.js, deu+eng). * * Extracted from server/api/mobile/ocr-scan.post.ts so both the mobile return * page and the incoming-invoice pipeline run the same code. Adds optional * WORD-LEVEL bounding boxes (needed by the per-vendor learning layer to remember * where each field sits on a scanned invoice). * * Reuses the same temp dir (.tmp-ocr/) and language cache (.tmp-ocr/lang) as the * original route so downloaded traineddata is shared, not re-fetched. */ import { createWorker } from 'tesseract.js' import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs' import { join } from 'path' export interface OcrWord { text: string confidence: number bbox: { x0: number; y0: number; x1: number; y1: number } } export interface OcrResult { text: string confidence: number lines: string[] words: OcrWord[] } const DEFAULT_TIMEOUT_MS = 90_000 const TESS_LANG_REMOTE = 'https://tessdata.projectnaptha.com/4.0.0' const TESS_CORE_VERSION = '7.0.0' // keep in sync with tesseract.js-core in package.json /** * Resolve where tesseract.js loads its core .wasm from. The production `.output` * bundle can omit `tesseract.js-core/*.wasm`, so prefer the wasm in the real * node_modules (fully self-hosted), and fall back to the pinned CDN (same * approach the langPath above already uses) so OCR never dies on a packaging gap. */ const resolveCorePath = (): string => { const candidates = [ join(process.cwd(), 'node_modules', 'tesseract.js-core'), join(process.cwd(), '.output', 'server', 'node_modules', 'tesseract.js-core') ] for (const dir of candidates) { if (existsSync(join(dir, 'tesseract-core.wasm'))) return dir } return `https://cdn.jsdelivr.net/npm/tesseract.js-core@${TESS_CORE_VERSION}` } const withTimeout = (promise: Promise, ms: number, label: string): Promise => new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms) promise.then( (v) => { clearTimeout(timer); resolve(v) }, (e) => { clearTimeout(timer); reject(e) } ) }) /** Flatten tesseract's block tree (when output.blocks=true) into a flat word list. */ const wordsFromData = (data: any): OcrWord[] => { const out: OcrWord[] = [] const pushWord = (w: any) => { if (!w?.text) return const b = w.bbox || {} out.push({ text: String(w.text), confidence: Number(w.confidence) || 0, bbox: { x0: b.x0 ?? 0, y0: b.y0 ?? 0, x1: b.x1 ?? 0, y1: b.y1 ?? 0 } }) } // v6/v7 with { blocks: true }: data.blocks -> paragraphs -> lines -> words if (Array.isArray(data?.blocks)) { for (const block of data.blocks) { for (const para of block?.paragraphs || []) { for (const line of para?.lines || []) { for (const w of line?.words || []) pushWord(w) } } } } // Legacy fallback: flat data.words if (out.length === 0 && Array.isArray(data?.words)) { for (const w of data.words) pushWord(w) } return out } /** * Run OCR on an image (Buffer or file path). Returns text, lines, and — when * `withWords` is set — word-level boxes. */ export const runOcr = async ( image: Buffer | string, opts: { lang?: string; timeoutMs?: number; withWords?: boolean } = {} ): Promise => { const lang = opts.lang || 'deu+eng' const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS const tmpDir = join(process.cwd(), '.tmp-ocr') if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true }) const langCache = join(tmpDir, 'lang') if (!existsSync(langCache)) mkdirSync(langCache, { recursive: true }) let tmpPath: string | null = null let imageInput: Buffer | string = image if (Buffer.isBuffer(image)) { const reqId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) tmpPath = join(tmpDir, `${reqId}.png`) writeFileSync(tmpPath, image) imageInput = tmpPath } const worker: any = await createWorker(lang, 1, { cachePath: langCache, langPath: TESS_LANG_REMOTE, corePath: resolveCorePath() } as any) try { const result: any = await withTimeout( worker.recognize(imageInput, {}, opts.withWords ? { blocks: true } : undefined), timeoutMs, 'Tesseract.recognize' ) const data = result?.data || {} const text: string = data.text || '' const lines = text .split('\n') .map((l: string) => l.trim()) .filter((l: string) => l.length > 2) return { text, confidence: Number(data.confidence) || 0, lines, words: opts.withWords ? wordsFromData(data) : [] } } finally { try { await worker.terminate() } catch {} if (tmpPath && existsSync(tmpPath)) { try { unlinkSync(tmpPath) } catch {} } } } export default runOcr