Files
betreuungs-cockpit/src/lib/ocr/pdf-text.ts
T
Florian Wilde 676fbb8ee4 Texterkennung in Grenzen zwingen, die das Dokument nicht selbst setzt
Zwei Wege, auf denen ein hochgeladenes Dokument den Server umbringen
konnte. Beide haben gemeinsam, dass die Groesse aus der Datei kam.

1. Dekompressionsbombe. Die PDF-Stroeme wurden mit inflateSync ohne
   maxOutputLength ausgepackt. Ein Flate-Strom sagt nicht, wie gross er
   ausgepackt wird - ein paar Kilobyte Fuellung blaehen sich auf Gigabyte
   auf. Jetzt ein Deckel je Strom (8 MB) und einer ueber die Summe
   (64 MB); zlib bricht selbst ab, der Strom wird verworfen, der Text ist
   eben unvollstaendig.

2. Riesenseiten. "pdftoppm -r 300" rendert mit fester Aufloesung, aber
   die Seitengroesse steht im PDF. Bei 200 x 200 Zoll ergeben 300 dpi ein
   Bild von 60000 x 60000 Pixeln, und zwar je Seite. Jetzt "-scale-to"
   statt fester dpi: Die laengste Kante bekommt eine feste Pixelzahl,
   unabhaengig davon, was im Dokument als Papierformat steht. Fuer A4
   entspricht das etwa den bisherigen 300 dpi. Dazu ein Deckel auf die
   Gesamtgroesse der gerenderten Dateien.

Der erste Bombentest war wertlos: Die Nutzlast enthielt keine
PDF-Textoperatoren, also war das Ergebnis mit und ohne Grenze leer. Mit
einer Marke in der Nutzlast und einer Gegenprobe knapp unter der Grenze
schlagen beide Mutationen an.

Refs #38
2026-08-24 09:41:35 +02:00

276 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { inflateSync, inflateRawSync, unzipSync } from 'node:zlib'
/**
* Notnagel: den bereits im PDF eingebetteten Text lesen.
*
* Der Regelweg ist Tesseract. Fehlt auf dem System auch `pdftoppm`, um das
* PDF in Bilder zu wandeln, bleibt nur dieser Weg - und er reicht
* ueberraschend oft: Bescheide von Behoerden und Rechnungen kommen heute
* meist als "digitales" PDF mit echtem Text, nicht als Scan.
*
* Es ist bewusst ein einfacher Extraktor, kein PDF-Renderer:
* unkomprimierte und Flate-komprimierte Inhaltsstroeme werden ausgepackt
* und die Text-Operatoren (Tj, TJ, ', ") daraus gelesen. Was er nicht kann,
* meldet er als "kein Text" - dann bleibt der ocrStatus eben
* "nicht_verfuegbar", und der Nutzer traegt die Metadaten von Hand ein.
*
* Rein, ohne Seiteneffekte, ohne Netz.
*
* ## Grenzen, und warum es sie braucht
*
* Ein Flate-Strom sagt nicht, wie gross er ausgepackt wird. Ein paar
* Kilobyte Nullen komprimieren auf fast nichts und blaehen sich beim
* Auspacken auf Gigabyte auf - die klassische Dekompressionsbombe. Vorher
* lief das Auspacken ohne Obergrenze, und ein Dokument, das jemand in die
* Akte legt, haette den Server damit aus dem Speicher werfen koennen.
*
* Deshalb drei Deckel: einer je Strom, einer fuer die Summe, und die
* bestehende Obergrenze von 2000 Stroemen. Wird einer erreicht, bricht das
* Auspacken ab und der Text ist eben unvollstaendig - besser ein
* "nicht_verfuegbar" als ein Serverneustart.
*/
/**
* Wie gross ein einzelner ausgepackter Strom werden darf.
*
* Acht Megabyte sind fuer eine Seite Text absurd viel - ein
* Behoerdenbescheid liegt bei einigen Kilobyte. Die Grenze ist bewusst
* grosszuegig: Sie soll Bomben abfangen, nicht ungewoehnliche PDFs.
*/
const MAX_STROM_BYTES = 8 * 1024 * 1024
/** Und wie viel es ueber alle Stroeme zusammen sein darf. */
const MAX_GESAMT_BYTES = 64 * 1024 * 1024
/** Oktal-Escapes und Standard-Escapes in PDF-Literalstrings. */
function literalEntpacken(roh: string): string {
let ergebnis = ''
for (let i = 0; i < roh.length; i++) {
const zeichen = roh[i]!
if (zeichen !== '\\') {
ergebnis += zeichen
continue
}
const naechstes = roh[++i]
if (naechstes === undefined) break
switch (naechstes) {
case 'n':
ergebnis += '\n'
break
case 'r':
ergebnis += '\n'
break
case 't':
ergebnis += '\t'
break
case 'b':
case 'f':
ergebnis += ' '
break
case '\n':
break // Zeilenumbruch im Quelltext, kein Zeichen
default:
if (naechstes >= '0' && naechstes <= '7') {
let oktal = naechstes
while (oktal.length < 3 && roh[i + 1] !== undefined && roh[i + 1]! >= '0' && roh[i + 1]! <= '7') {
oktal += roh[++i]!
}
ergebnis += String.fromCharCode(Number.parseInt(oktal, 8))
} else {
ergebnis += naechstes
}
}
}
return ergebnis
}
/**
* PDF-Bytes in Zeichen wandeln. Ohne die Font-Tabelle des Dokuments ist
* das eine Naeherung; fuer WinAnsi (der Normalfall bei deutschen Texten)
* stimmt sie, inklusive Umlauten.
*/
const WINANSI_SONDER: Record<number, string> = {
0x80: '€',
0x82: '‚',
0x84: '„',
0x85: '…',
0x91: '‘',
0x92: '’',
0x93: '“',
0x94: '”',
0x95: '•',
0x96: '–',
0x97: '—',
0x9c: 'œ',
0x9f: 'Ÿ',
}
function bytesZuText(roh: string): string {
let ergebnis = ''
for (let i = 0; i < roh.length; i++) {
const code = roh.charCodeAt(i)
ergebnis += WINANSI_SONDER[code] ?? String.fromCharCode(code)
}
return ergebnis
}
/** Hex-String <48616C6C6F> */
function hexEntpacken(roh: string): string {
const sauber = roh.replace(/[^0-9a-fA-F]/g, '')
let ergebnis = ''
for (let i = 0; i + 1 < sauber.length; i += 2) {
ergebnis += String.fromCharCode(Number.parseInt(sauber.slice(i, i + 2), 16))
}
// Ungerade Laenge: das letzte Nibble wird laut Spezifikation mit 0 aufgefuellt.
if (sauber.length % 2 === 1) {
ergebnis += String.fromCharCode(Number.parseInt(sauber.slice(-1) + '0', 16))
}
return ergebnis
}
/** Holt alle Streams aus dem PDF und packt sie aus, soweit moeglich. */
function stroemeAuspacken(pdf: Buffer): string[] {
const roh = pdf.toString('latin1')
const stroeme: string[] = []
let suchAb = 0
let gesamt = 0
while (stroeme.length < 2000 && gesamt < MAX_GESAMT_BYTES) {
const beginn = roh.indexOf('stream', suchAb)
if (beginn < 0) break
let inhaltVon = beginn + 'stream'.length
if (roh[inhaltVon] === '\r') inhaltVon++
if (roh[inhaltVon] === '\n') inhaltVon++
const ende = roh.indexOf('endstream', inhaltVon)
if (ende < 0) break
suchAb = ende + 'endstream'.length
const daten = Buffer.from(roh.slice(inhaltVon, ende), 'latin1')
if (daten.length === 0) continue
// Nur der Kopf des Objekts direkt davor sagt, wie komprimiert wurde.
const kopf = roh.slice(Math.max(0, beginn - 500), beginn)
if (/\/Filter\s*\/(DCTDecode|JPXDecode|CCITTFaxDecode|JBIG2Decode|RunLengthDecode)/.test(kopf)) {
continue // Bilddaten - hier ist kein Text zu holen
}
if (/\/Filter\s*(\/FlateDecode|\[\s*\/FlateDecode)/.test(kopf)) {
const entpackt = flateVersuchen(daten, MAX_GESAMT_BYTES - gesamt)
if (entpackt) {
gesamt += entpackt.length
stroeme.push(entpackt.toString('latin1'))
}
continue
}
if (!/\/Filter/.test(kopf)) {
// Unkomprimiert: Hier gibt es nichts, was sich aufblaehen koennte -
// die Bytes stehen so in der Datei. Sie zaehlen trotzdem mit, damit
// die Summe stimmt.
gesamt += daten.length
stroeme.push(daten.toString('latin1'))
continue
}
// Unbekannter Filter: trotzdem einen Flate-Versuch wagen.
const entpackt = flateVersuchen(daten, MAX_GESAMT_BYTES - gesamt)
if (entpackt) {
gesamt += entpackt.length
stroeme.push(entpackt.toString('latin1'))
}
}
return stroeme
}
/**
* Packt einen Strom aus - mit Deckel.
*
* maxOutputLength laesst zlib abbrechen, sobald die Ausgabe die Grenze
* ueberschreitet; der Fehler landet im catch wie jeder andere. Ohne diese
* Angabe entpackt Node so lange, wie Speicher da ist.
*
* @param rest Wie viele Bytes ueber alle Stroeme noch uebrig sind.
*/
function flateVersuchen(daten: Buffer, rest: number): Buffer | null {
const grenze = Math.min(MAX_STROM_BYTES, rest)
if (grenze <= 0) return null
for (const fn of [inflateSync, unzipSync, inflateRawSync]) {
try {
return fn(daten, { maxOutputLength: grenze })
} catch {
// naechster Versuch
}
}
return null
}
/** Liest die Text-Operatoren aus einem ausgepackten Inhaltsstrom. */
function textAusStrom(strom: string): string {
if (!/\bTj\b|\bTJ\b|\bTd\b|\bTf\b/.test(strom)) return ''
let ergebnis = ''
const muster = /\((?:\\.|[^\\()])*\)|<[0-9a-fA-F\s]*>|\bTJ\b|\bTj\b|\bT\*\b|\bTd\b|\bTD\b|\bET\b|'|"/g
let treffer: RegExpExecArray | null
while ((treffer = muster.exec(strom)) !== null) {
const wert = treffer[0]!
if (wert.startsWith('(')) {
ergebnis += bytesZuText(literalEntpacken(wert.slice(1, -1)))
} else if (wert.startsWith('<')) {
ergebnis += bytesZuText(hexEntpacken(wert.slice(1, -1)))
} else if (wert === 'T*' || wert === 'TD' || wert === 'Td' || wert === 'ET' || wert === "'" || wert === '"') {
ergebnis += '\n'
} else if (wert === 'TJ' || wert === 'Tj') {
ergebnis += ' '
}
}
return ergebnis
}
/**
* Zieht den eingebetteten Text aus einem PDF. Gibt eine leere Zeichenkette
* zurueck, wenn nichts zu holen ist (reiner Scan, verschluesseltes PDF,
* exotische Kodierung) - nie einen Fehler.
*/
export function pdfTextLesen(pdf: Buffer): string {
try {
const teile = stroemeAuspacken(pdf).map(textAusStrom).filter((t) => t.trim().length > 0)
return textAufraeumen(teile.join('\n'))
} catch {
return ''
}
}
/** Vereinheitlicht Leerraum, ohne Zeilenstruktur zu zerstoeren. */
export function textAufraeumen(roh: string): string {
return roh
.replace(/\r\n?/g, '\n')
// Nullbytes, weiche Trennstriche und Zero-Width-Zeichen entfernen.
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u00ad\u200b-\u200d\ufeff]/g, '')
.split('\n')
.map((zeile) => zeile.replace(/[ \t\u00a0]+/g, ' ').trim())
.filter((zeile, i, alle) => zeile !== '' || (alle[i - 1] ?? '') !== '')
.join('\n')
.trim()
}
/**
* Grobe Seitenzahl aus der PDF-Struktur. Nutzt zuerst /Count aus dem
* Seitenbaum, sonst die Zahl der /Type /Page-Objekte.
*/
export function pdfSeitenzahlSchaetzen(pdf: Buffer): number | null {
const roh = pdf.toString('latin1')
const zaehler = [...roh.matchAll(/\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/g)].map((t) =>
Number.parseInt(t[1]!, 10),
)
if (zaehler.length > 0) return Math.max(...zaehler)
const seiten = roh.match(/\/Type\s*\/Page[^s]/g)
return seiten ? seiten.length : null
}