79 lines
3.6 KiB
TypeScript
79 lines
3.6 KiB
TypeScript
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<TextExtractionResult>
|
|
}
|
|
|
|
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<string>((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' }
|
|
}
|
|
},
|
|
}
|
|
}
|