/** * Heuristic field extraction for German/EU supplier invoices (Tier 1 text + * Tier 2 OCR). Pure functions — no I/O. Returns a partial ZugferdInvoiceData plus * per-field confidence and (best-effort) bounding boxes. * * A vendor profile, when supplied, is applied FIRST (its learned anchors/boxes * win and carry high confidence); these generic anchors are the cold-start * fallback for unknown vendors. */ import type { ZugferdInvoiceData } from '../zugferd/buildInvoiceXml' import type { TextItem } from './pdfTextLayer' export interface FieldBox { x: number; y: number; w: number; h: number /** 1-based page + page-normalized (0..1, top-left) box for the UI overlay. */ page?: number; nx?: number; ny?: number; nw?: number; nh?: number } export interface ExtractionResult { fields: Partial confidence: Record bbox: Record } /* ------------------------------- parsing ------------------------------- */ /** Parse a German/Intl money token ("1.234,56" | "1,234.56" | "1234,56" | "1234.56"). */ export const parseAmount = (raw: string): number | null => { if (!raw) return null let s = String(raw).replace(/[^0-9.,-]/g, '') if (!s) return null const hasDot = s.includes('.') const hasComma = s.includes(',') if (hasDot && hasComma) { // last separator is the decimal one if (s.lastIndexOf(',') > s.lastIndexOf('.')) s = s.replace(/\./g, '').replace(',', '.') else s = s.replace(/,/g, '') } else if (hasComma) { // comma decimal (German) if 1-2 trailing digits, else thousands sep const m = s.match(/,(\d{1,2})$/) s = m ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, '') } const n = Number(s) return Number.isFinite(n) ? n : null } /** Parse a date string to ISO yyyy-mm-dd; supports dd.mm.yyyy, dd/mm/yyyy, yyyy-mm-dd, dd. Monat yyyy. */ export const parseDate = (raw: string): string => { if (!raw) return '' const s = String(raw).trim() let m = s.match(/(\d{4})-(\d{2})-(\d{2})/) if (m) return `${m[1]}-${m[2]}-${m[3]}` m = s.match(/(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})/) if (m) { let [, d, mo, y] = m if (y.length === 2) y = (Number(y) > 70 ? '19' : '20') + y return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}` } const months: Record = { januar: '01', jan: '01', februar: '02', feb: '02', märz: '03', maerz: '03', mar: '03', april: '04', apr: '04', mai: '05', juni: '06', jun: '06', juli: '07', jul: '07', august: '08', aug: '08', september: '09', sep: '09', oktober: '10', okt: '10', oct: '10', november: '11', nov: '11', dezember: '12', dez: '12', dec: '12' } m = s.match(/(\d{1,2})\.?\s+([A-Za-zäöü]+)\s+(\d{4})/) if (m && months[m[2].toLowerCase()]) { return `${m[3]}-${months[m[2].toLowerCase()]}-${m[1].padStart(2, '0')}` } return '' } const AMOUNT_RE = '([0-9][0-9.,]*[0-9]|[0-9])' const DATE_RE = '(\\d{4}-\\d{2}-\\d{2}|\\d{1,2}[.\\/-]\\d{1,2}[.\\/-]\\d{2,4}|\\d{1,2}\\.?\\s+[A-Za-zäöü]+\\s+\\d{4})' /** Find a labeled value: