import * as crypto from 'crypto'; import { PDFParse } from 'pdf-parse'; import { istVerschluesselt, ueberschreitetDekompressionsLimit, } from '../common/pdf-dekompression.util'; /** * Unterhalb dieser Textlänge gilt ein PDF als "ohne Textebene" (z.B. Scan) – * ein paar Zeichen reichen nicht, um Dokumente sicher zu unterscheiden. */ const MIN_TEXT_LENGTH = 20; /** * Inhaltsbasierter Fingerprint eines PDFs: SHA-256 über den extrahierten, * whitespace-normalisierten Text. Anders als die Datei-Checksumme bleibt er * gleich, wenn ein Absender dasselbe Dokument neu erzeugt (andere * Erstellungszeit, Dokument-ID o.ä.). * * @returns Fingerprint (64 Hex-Zeichen), `''` bei PDFs ohne verwertbaren Text * (bereits berechnet, aber nicht vergleichbar) oder `null`, wenn das * PDF nicht gelesen werden konnte. Verschlüsselte PDFs und solche, * die entpackt zu groß wären, liefern ebenfalls `''`. */ export async function computeContentFingerprint( pdf: Buffer, ): Promise { // pdf.js entpackt ohne Limit – Dekompressionsbomben gar nicht erst laden. // Verschlüsselte Streams entschlüsselt pdf.js erst intern; vorab sind sie // nicht prüfbar. Beides gilt als "nicht vergleichbar" (''). if ( istVerschluesselt(pdf) || (await ueberschreitetDekompressionsLimit(pdf)) ) { return ''; } // pdf.js übernimmt den Puffer, deshalb eine Kopie übergeben const parser = new PDFParse({ data: new Uint8Array(pdf) }); try { const { text } = await parser.getText(); const normalized = text.replace(/\s+/g, ' ').trim(); // Seitenmarker ("-- 1 of 1 --") zählen nicht als Inhalt const inhalt = normalized.replace(/-- \d+ of \d+ --/g, '').trim(); if (inhalt.length < MIN_TEXT_LENGTH) return ''; return crypto.createHash('sha256').update(normalized).digest('hex'); } catch { return null; } finally { await parser.destroy().catch(() => undefined); } }