Files
gupi-osint-board/server/ocr.ts

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' }
}
},
}
}