import { spawn } from 'node:child_process' export type TextExtractionResult = { extractor: string extractorVersion: string language: string status: 'succeeded' | 'unsupported' | 'failed' text: string error?: string } export interface TextExtractor { provider: string extract(file: { buffer: Buffer; mimetype: string }): Promise } function plainText(file: { buffer: Buffer; mimetype: string }, maximumCharacters: number): TextExtractionResult | null { if (!file.mimetype.startsWith('text/')) return null return { extractor: 'plain-text', extractorVersion: '1', language: 'und', status: 'succeeded', text: file.buffer.toString('utf8').slice(0, maximumCharacters) } } function runTesseract(command: string, buffer: Buffer, languages: string, pageSegmentationMode: string, timeoutMs: number) { return new Promise((resolve, reject) => { const child = spawn(command, ['stdin', 'stdout', '-l', languages, '--psm', pageSegmentationMode], { stdio: ['pipe', 'pipe', 'pipe'] }) const stdout: Buffer[] = [] const stderr: Buffer[] = [] let settled = false const timer = setTimeout(() => { if (settled) return settled = true child.kill('SIGKILL') reject(new Error(`OCR timed out after ${timeoutMs}ms`)) }, timeoutMs) child.stdout.on('data', chunk => stdout.push(Buffer.from(chunk))) child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk))) child.once('error', error => { if (settled) return settled = true clearTimeout(timer) reject(error) }) child.once('close', code => { if (settled) return settled = true clearTimeout(timer) if (code === 0) resolve(Buffer.concat(stdout).toString('utf8').trim()) else reject(new Error(Buffer.concat(stderr).toString('utf8').trim() || `OCR exited with status ${code}`)) }) child.stdin.end(buffer) }) } export function createTextExtractorFromEnv(): TextExtractor { const enabled = process.env.OCR_ENABLED !== 'false' const command = process.env.OCR_COMMAND || 'tesseract' const languages = process.env.OCR_LANGUAGES || 'nor+eng' const extractorVersion = process.env.OCR_ENGINE_VERSION || 'tesseract-cli-5' const pageSegmentationMode = process.env.OCR_PAGE_SEGMENTATION_MODE || '3' const timeoutMs = Math.max(1_000, Number(process.env.OCR_TIMEOUT_MS || 20_000)) const maximumBytes = Math.max(1, Number(process.env.MAX_OCR_BYTES || 15 * 1024 * 1024)) const maximumCharacters = Math.max(1_000, Number(process.env.MAX_EXTRACTED_TEXT_CHARACTERS || 200_000)) return { provider: enabled ? 'tesseract' : 'disabled', async extract(file) { const direct = plainText(file, maximumCharacters) if (direct) return direct if (!file.mimetype.startsWith('image/')) return { extractor: 'none', extractorVersion: '1', language: 'und', status: 'unsupported', text: '' } if (!enabled) return { extractor: 'tesseract', extractorVersion, language: languages, status: 'unsupported', text: '' } if (file.buffer.byteLength > maximumBytes) return { extractor: 'tesseract', extractorVersion, language: languages, status: 'failed', text: '', error: `Image exceeds the ${maximumBytes}-byte OCR limit` } try { const text = (await runTesseract(command, file.buffer, languages, pageSegmentationMode, timeoutMs)).slice(0, maximumCharacters) return { extractor: 'tesseract', extractorVersion, language: languages, status: 'succeeded', text } } catch (error) { return { extractor: 'tesseract', extractorVersion, language: languages, status: 'failed', text: '', error: error instanceof Error ? error.message : 'OCR failed' } } }, } }