import 'server-only' import { execFile } from 'node:child_process' import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' import { env } from '@/lib/env' import { typErkennen } from '@/lib/speicher/dateityp' import { pdfSeitenzahlSchaetzen, pdfTextLesen, textAufraeumen } from '@/lib/ocr/pdf-text' /** * Texterkennung ueber das System-Binary `tesseract` (Sprache deutsch). * * Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 3. * * Die wichtigste Eigenschaft dieser Datei: **sie wirft nie**. Ein fehlendes * Tesseract auf dem Entwicklungsrechner, ein kaputtes Foto oder ein * verschluesseltes PDF darf niemals dazu fuehren, dass ein Beleg nicht * gespeichert wird. Im Zweifel kommt `nicht_verfuegbar` zurueck, das * Dokument liegt trotzdem in der Akte, und die Metadaten traegt der Nutzer * von Hand ein. * * Ablauf: * Bild -> tesseract * PDF -> pdftoppm (Seiten als PNG) -> tesseract je Seite * PDF ohne pdftoppm -> eingebetteten Text lesen */ const ausfuehren = promisify(execFile) export type OcrStatus = 'fertig' | 'fehlgeschlagen' | 'nicht_verfuegbar' export type OcrErgebnis = { text: string status: OcrStatus seitenzahl?: number meldung?: string } /** Ein Scan mit 20 Seiten darf den Server nicht eine Stunde beschaeftigen. */ const ZEITGRENZE_JE_SEITE_MS = 60_000 const MAX_SEITEN = 30 /** Mehr als das braucht keine Volltextsuche - und die Spalte bleibt handhabbar. */ const MAX_ZEICHEN = 400_000 /** * Laengste Kante einer gerenderten Seite in Pixeln. * * Vorher wurde mit festen 300 dpi gerendert. Bei einer normalen A4-Seite * sind das rund 2480 x 3510 Pixel - unproblematisch. Die Seitengroesse * steht aber im PDF und kann beliebig gross angegeben werden: Bei einer * Seite von 200 x 200 Zoll ergeben 300 dpi ein Bild von 60000 x 60000 * Pixeln, also mehrere Gigabyte auf der Platte, und das je Seite. Ein * Dokument, das jemand in die Akte legt, haette so den Server vollschreiben * koennen. * * "-scale-to" haengt die Aufloesung an die Pixelzahl statt an die * angegebene Papiergroesse. 3500 Pixel entsprechen bei A4 etwa 300 dpi - * fuer den Regelfall bleibt also alles wie vorher. */ const MAX_KANTE_PIXEL = 3500 /** Wie viel die gerenderten Seiten zusammen belegen duerfen. */ const MAX_BILDER_BYTES = 512 * 1024 * 1024 // ----------------------------------------------------------------------------- // Verfuegbarkeit // ----------------------------------------------------------------------------- let tesseractGemerkt: boolean | null = null let pdftoppmGemerkt: boolean | null = null async function binaerVorhanden(befehl: string, argumente: string[]): Promise { try { await ausfuehren(befehl, argumente, { timeout: 10_000, windowsHide: true }) return true } catch (fehler) { // Manche Binaries melden --version mit Exitcode != 0, liefern aber // Ausgabe. Nur "gibt es nicht" zaehlt als nicht vorhanden. const code = (fehler as NodeJS.ErrnoException | undefined)?.code if (code === 'ENOENT') return false const stdout = (fehler as { stdout?: string } | undefined)?.stdout ?? '' const stderr = (fehler as { stderr?: string } | undefined)?.stderr ?? '' return `${stdout}${stderr}`.trim().length > 0 } } /** Ist Tesseract auf diesem System vorhanden? */ export async function ocrVerfuegbar(): Promise { if (!env.ocrAktiv) return false if (tesseractGemerkt !== null) return tesseractGemerkt tesseractGemerkt = await binaerVorhanden(env.tesseractBefehl, ['--version']) return tesseractGemerkt } /** Kann pdftoppm PDF-Seiten in Bilder wandeln? (Teil von poppler-utils) */ export async function pdfWandlerVerfuegbar(): Promise { if (pdftoppmGemerkt !== null) return pdftoppmGemerkt pdftoppmGemerkt = await binaerVorhanden('pdftoppm', ['-v']) return pdftoppmGemerkt } /** Nur fuer Tests und den Diagnosebereich: gemerkte Antworten vergessen. */ export function verfuegbarkeitVergessen(): void { tesseractGemerkt = null pdftoppmGemerkt = null } /** Welche Sprachpakete kennt Tesseract? Fuer die Diagnoseseite. */ export async function ocrSprachen(): Promise { try { const { stdout } = await ausfuehren(env.tesseractBefehl, ['--list-langs'], { timeout: 10_000, windowsHide: true, }) return stdout .split(/\r?\n/) .slice(1) .map((z) => z.trim()) .filter(Boolean) } catch { return [] } } // ----------------------------------------------------------------------------- // Erkennung // ----------------------------------------------------------------------------- async function tesseractAufDatei(pfad: string): Promise<{ text: string; meldung?: string }> { const grundArgumente = [pfad, 'stdout', '--psm', '3'] try { const { stdout } = await ausfuehren(env.tesseractBefehl, ['-l', 'deu', ...grundArgumente], { timeout: ZEITGRENZE_JE_SEITE_MS, maxBuffer: 32 * 1024 * 1024, windowsHide: true, }) return { text: stdout } } catch (fehler) { const meldung = String((fehler as { stderr?: string } | undefined)?.stderr ?? fehler) // Haeufigster Stolperstein: Tesseract ist da, das deutsche Sprachpaket // fehlt. Dann lieber englisch erkennen als gar nichts - mit Hinweis. if (/Failed loading language|Could not initialize tesseract|tessdata/i.test(meldung)) { try { const { stdout } = await ausfuehren(env.tesseractBefehl, grundArgumente, { timeout: ZEITGRENZE_JE_SEITE_MS, maxBuffer: 32 * 1024 * 1024, windowsHide: true, }) return { text: stdout, meldung: 'Das deutsche Sprachpaket (tesseract-ocr-deu) fehlt. Erkannt wurde mit der ' + 'Standardsprache - Umlaute und Fachbegriffe sind dadurch oft falsch.', } } catch { throw fehler } } throw fehler } } async function pdfInBilder(verzeichnis: string, pdfPfad: string): Promise { await ausfuehren( 'pdftoppm', [ // Statt fester dpi: Die laengste Kante wird auf eine feste Pixelzahl // gebracht. Siehe MAX_KANTE_PIXEL - eine im PDF angegebene Riesenseite // ergibt sonst ein Riesenbild. '-scale-to', String(MAX_KANTE_PIXEL), '-gray', '-png', '-l', String(MAX_SEITEN), pdfPfad, join(verzeichnis, 'seite'), ], { timeout: ZEITGRENZE_JE_SEITE_MS * 4, maxBuffer: 1024 * 1024, windowsHide: true }, ) const dateien = (await readdir(verzeichnis)) .filter((name) => name.startsWith('seite') && name.endsWith('.png')) .sort() .map((name) => join(verzeichnis, name)) // Zweiter Deckel, hinterher: Was pdftoppm trotz -scale-to abgelegt hat, // wird nur bis zu einer Gesamtgroesse weiterverarbeitet. Der Rest bleibt // liegen und wird mit dem Arbeitsverzeichnis geloescht. const genommen: string[] = [] let gesamt = 0 for (const pfad of dateien) { const { size } = await stat(pfad) if (gesamt + size > MAX_BILDER_BYTES) break gesamt += size genommen.push(pfad) } return genommen } /** * Ruft Tesseract (deutsch) auf. Nie werfen - Status zurueckgeben. */ export async function ocrLesen(inhalt: Buffer, mimeTyp: string): Promise { if (!env.ocrAktiv) { return { text: '', status: 'nicht_verfuegbar', meldung: 'Die Texterkennung ist abgeschaltet.' } } // Der gemeldete MIME-Typ ist nur ein Hinweis; entschieden wird am Inhalt. const erkannt = typErkennen(inhalt) const istPdf = erkannt ? erkannt.mimeTyp === 'application/pdf' : mimeTyp === 'application/pdf' if (istPdf) return pdfLesen(inhalt) if (erkannt && erkannt.istBild) return bildLesen(inhalt, erkannt.endung) return { text: '', status: 'nicht_verfuegbar', meldung: 'Aus diesem Dateityp lässt sich kein Text lesen.', } } async function bildLesen(inhalt: Buffer, endung: string): Promise { if (!(await ocrVerfuegbar())) { return { text: '', status: 'nicht_verfuegbar', seitenzahl: 1, meldung: 'Tesseract ist auf diesem System nicht installiert. Das Dokument ist gespeichert, ' + 'nur der Volltext fehlt - trage Absender und Datum bitte von Hand ein.', } } let verzeichnis: string | null = null try { verzeichnis = await mkdtemp(join(tmpdir(), 'bc-ocr-')) const pfad = join(verzeichnis, `bild.${endung}`) await writeFile(pfad, inhalt) const { text, meldung } = await tesseractAufDatei(pfad) return { text: kuerzen(textAufraeumen(text)), status: 'fertig', seitenzahl: 1, meldung, } } catch (fehler) { return { text: '', status: 'fehlgeschlagen', seitenzahl: 1, meldung: fehlermeldung(fehler), } } finally { await aufraeumen(verzeichnis) } } async function pdfLesen(inhalt: Buffer): Promise { const seitenzahl = pdfSeitenzahlSchaetzen(inhalt) ?? undefined // Weg 1: Seiten rendern und erkennen - funktioniert auch bei reinen Scans. if ((await ocrVerfuegbar()) && (await pdfWandlerVerfuegbar())) { let verzeichnis: string | null = null try { verzeichnis = await mkdtemp(join(tmpdir(), 'bc-ocr-')) const pdfPfad = join(verzeichnis, 'quelle.pdf') await writeFile(pdfPfad, inhalt) const bilder = await pdfInBilder(verzeichnis, pdfPfad) if (bilder.length === 0) throw new Error('pdftoppm hat keine Seiten erzeugt.') const teile: string[] = [] let hinweis: string | undefined for (const bild of bilder) { const ergebnis = await tesseractAufDatei(bild) teile.push(ergebnis.text) hinweis ??= ergebnis.meldung } const text = kuerzen(textAufraeumen(teile.join('\n\n'))) const abgeschnitten = seitenzahl !== undefined && seitenzahl > bilder.length ? `Erkannt wurden die ersten ${bilder.length} von ${seitenzahl} Seiten.` : undefined return { text, status: 'fertig', seitenzahl: seitenzahl ?? bilder.length, meldung: [hinweis, abgeschnitten].filter(Boolean).join(' ') || undefined, } } catch (fehler) { // Weg 2 als Rettung: vielleicht steckt ohnehin Text im PDF. const eingebettet = pdfTextLesen(inhalt) if (eingebettet.length > 40) { return { text: kuerzen(eingebettet), status: 'fertig', seitenzahl, meldung: 'Die Bilderkennung ist fehlgeschlagen; gelesen wurde der im PDF eingebettete Text.', } } return { text: '', status: 'fehlgeschlagen', seitenzahl, meldung: fehlermeldung(fehler) } } finally { await aufraeumen(verzeichnis) } } // Weg 2: kein Tesseract oder kein pdftoppm - eingebetteten Text lesen. const eingebettet = pdfTextLesen(inhalt) if (eingebettet.length > 40) { return { text: kuerzen(eingebettet), status: 'fertig', seitenzahl, meldung: 'Gelesen wurde der im PDF eingebettete Text. Für gescannte Seiten ohne Text ' + 'wird zusätzlich Tesseract benötigt.', } } return { text: '', status: 'nicht_verfuegbar', seitenzahl, meldung: 'Dieses PDF enthält keinen lesbaren Text, und für die Bilderkennung fehlen ' + 'Tesseract bzw. pdftoppm. Das Dokument ist gespeichert - trage Absender und ' + 'Datum bitte von Hand ein.', } } function kuerzen(text: string): string { return text.length > MAX_ZEICHEN ? `${text.slice(0, MAX_ZEICHEN)}\n…` : text } function fehlermeldung(fehler: unknown): string { const roh = (fehler as { stderr?: string } | undefined)?.stderr || (fehler instanceof Error ? fehler.message : String(fehler)) return roh.trim().slice(0, 300) || 'Unbekannter Fehler bei der Texterkennung.' } async function aufraeumen(verzeichnis: string | null): Promise { if (!verzeichnis) return try { await rm(verzeichnis, { recursive: true, force: true }) } catch { // Temporaeres Verzeichnis bleibt liegen - kein Grund, die Aktion abzubrechen. } } /** Kleiner Helfer fuer die Diagnoseseite: liest eine Datei und erkennt sie. */ export async function ocrAusDatei(pfad: string, mimeTyp: string): Promise { try { return await ocrLesen(await readFile(pfad), mimeTyp) } catch (fehler) { return { text: '', status: 'fehlgeschlagen', meldung: fehlermeldung(fehler) } } }