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 = { 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 }