/** * Value-independent layout fingerprint — identifies a vendor's invoice TEMPLATE * regardless of the variable values (so a misread IBAN/amount can't break * recognition). Inspired by invoice2data's keyword approach (company name + tax * number + email/website are the strongest, most stable signals), extended with * PDF metadata (Producer/Creator) and page aspect. Page COUNT is deliberately * NOT used — the same vendor sends 1 or many pages. * * Returns a stable `hash` (exact-match key) + the `tokens` set (for Jaccard * similarity matching across minor variations). * * File-signature tokens (`fnp:` file-name pattern, `emb:` / `xmp:` e-invoice markers) * feed the fuzzy token set ONLY — they are deliberately kept out of the hash basis so * `layout:` keys learned before they existed keep matching. */ import crypto from 'node:crypto' import { PDFDocument } from 'pdf-lib' const STOP = new Set([ 'rechnung', 'invoice', 'gmbh', 'und', 'der', 'die', 'das', 'für', 'fuer', 'von', 'mit', 'bei', 'the', 'and', 'for', 'inc', 'ltd', 'datum', 'seite', 'page', 'nummer', 'number', 'kunde', 'kunden' ]) export interface LayoutSignature { hash: string; tokens: string[] } const sha1 = (s: string) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 16) const MONTHS = /(januar|februar|maerz|märz|april|mai|juni|juli|august|september|oktober|november|dezember|january|february|march|may|june|july|october|december|jan|feb|mar|apr|jun|jul|aug|sept|sep|okt|oct|nov|dez|dec)/g /** "Rechnung_2026-009_LogYou_GmbH_August_2026.pdf" → "rechnung_#_#_logyou_gmbh_*_#" — stable per vendor. */ export const fileNamePattern = (filename?: string): string => { const base = String(filename || '').toLowerCase().replace(/\.[a-z0-9]{2,5}$/, '') if (!base) return '' const pat = base .replace(/[^a-z0-9äöüß]+/g, '_') .split('_').filter(Boolean) .map(w => /^\d+$/.test(w) ? '#' : w.replace(MONTHS, (m) => m.length === w.length ? '*' : m).replace(/\d+/g, '#')) .join('_') // a name that is only numbers / a generic word carries no vendor signal return /[a-zäöüß]{3,}/.test(pat.replace(/rechnung|invoice|scan|document|dokument|beleg/g, '')) ? pat.slice(0, 80) : '' } /** * @param text extracted plain text (page-1 letterhead matters most) * @param pdfBuffer original PDF (for metadata + page geometry); optional for XML * @param opts filename (file-name pattern token) / einvoice (embedded XML present) */ export const computeLayoutSignature = async ( text: string, pdfBuffer?: Buffer | null, opts: { filename?: string; einvoice?: boolean } = {} ): Promise => { const tokens = new Set() const t = text || '' const fnp = fileNamePattern(opts.filename) if (fnp) tokens.add('fnp:' + fnp) if (opts.einvoice) tokens.add('emb:einvoice') // Strongest, most stable anchors: email domains, website, VAT id, IBAN. for (const m of t.matchAll(/[a-z0-9._%+-]+@([a-z0-9.-]+\.[a-z]{2,})/gi)) tokens.add('dom:' + m[1].toLowerCase()) for (const m of t.matchAll(/\bwww\.([a-z0-9.-]+\.[a-z]{2,})/gi)) tokens.add('dom:' + m[1].toLowerCase()) for (const m of t.matchAll(/\b([A-Z]{2}\d{8,12})\b/g)) tokens.add('vat:' + m[1]) for (const m of t.matchAll(/\b([A-Z]{2}\d{2}(?:\s?[A-Z0-9]){11,30})\b/g)) tokens.add('iban:' + m[1].replace(/\s+/g, '')) // Letterhead words (first ~8 non-empty lines) — vendor name + address terms. const lines = t.split('\n').map(l => l.trim()).filter(Boolean).slice(0, 8) for (const ln of lines) { for (const w of ln.toLowerCase().split(/[^a-zäöüß0-9]+/)) { if (w.length >= 4 && !/^\d+$/.test(w) && !STOP.has(w)) tokens.add('w:' + w) } } // PDF metadata (vendor's invoicing software/template) + page aspect (NOT count). if (pdfBuffer) { try { const doc = await PDFDocument.load(pdfBuffer, { updateMetadata: false, throwOnInvalidObject: false }) for (const v of [safe(() => doc.getProducer()), safe(() => doc.getCreator()), safe(() => doc.getAuthor()), safe(() => doc.getTitle())]) { if (v) for (const w of String(v).toLowerCase().split(/[^a-z0-9]+/)) if (w.length >= 4 && !STOP.has(w)) tokens.add('m:' + w) } const p0 = doc.getPages()[0] if (p0) { const { width, height } = p0.getSize(); if (width && height) tokens.add('ar:' + (width / height).toFixed(2)) } } catch {} // e-invoice XMP markers (Factur-X / ZUGFeRD): profile level + embedded document name. // XMP is stored uncompressed, so a plain scan of the bytes is enough. try { const raw = pdfBuffer.toString('latin1') const level = raw.match(/<(?:fx|zf):ConformanceLevel>\s*([^<\s]+)\s*\s*([^<\s]+)\s* x.startsWith('dom:') || x.startsWith('vat:') || x.startsWith('iban:') || x.startsWith('m:') || x.startsWith('ar:')) const hashBasis = (keyTokens.length ? keyTokens : arr.slice(0, 20)).join('|') return { hash: arr.length ? sha1(hashBasis) : '', tokens: arr } } const safe = (fn: () => T): T | null => { try { return fn() } catch { return null } } /** Jaccard similarity over two token sets (0..1). */ export const layoutSimilarity = (a: string[] = [], b: string[] = []): number => { if (!a.length || !b.length) return 0 const B = new Set(b) let inter = 0 for (const x of new Set(a)) if (B.has(x)) inter++ const uni = new Set([...a, ...b]).size return uni ? inter / uni : 0 } export default computeLayoutSignature