diff --git a/src/tools/image/receipt.lib.test.ts b/src/tools/image/receipt.lib.test.ts index fcc0512..b480b46 100644 --- a/src/tools/image/receipt.lib.test.ts +++ b/src/tools/image/receipt.lib.test.ts @@ -12,6 +12,17 @@ function ocr(rows: [string, number][]): OcrResult { return { text: rows.map((r) => r[0]).join('\n'), lines, backend: 'wasm' }; } +// Build an OcrResult from [text, x, y] triples — lets a label and its amount be +// two separate detections on the same row, as real PP-OCR output often is. +function ocrXY(rows: [string, number, number][]): OcrResult { + const lines: OcrLine[] = rows.map(([text, x, y]) => ({ + text, + box: { x, y, width: 40, height: 12 }, + confidence: 0.9, + })); + return { text: rows.map((r) => r[0]).join('\n'), lines, backend: 'wasm' }; +} + describe('parseAmount', () => { const cases: [string, number | null][] = [ ['$12.34', 12.34], @@ -82,6 +93,24 @@ describe('parseReceipt', () => { }); }); + it('groups label + amount detected as separate boxes on the same row', () => { + // Real PP-OCR splits "Subtotal $100.00" into two boxes at the same y. + const r = parseReceipt( + ocrXY([ + ['Invoice', 0, 0], + ['Subtotal', 0, 60], + ['$100.00', 200, 60], + ['Total', 0, 74], + ['$100.00', 200, 74], + ['Amount due', 0, 88], + ['$100.00 USD', 200, 88], + ]), + ); + expect(r.subtotal).toBe(100); + expect(r.total).toBe(100); + expect(r.currency).toBe('$'); + }); + it('accepts a custom keyword set', () => { const idKeywords = { ...EN_KEYWORDS, total: ['jumlah'], totalExclude: ['pajak'] }; const r = parseReceipt(ocr([['Jumlah 15.00', 50]]), idKeywords); diff --git a/src/tools/image/receipt.lib.ts b/src/tools/image/receipt.lib.ts index 38ca57e..6141f7e 100644 --- a/src/tools/image/receipt.lib.ts +++ b/src/tools/image/receipt.lib.ts @@ -122,16 +122,49 @@ function findMerchant(lines: OcrLine[], kw: ReceiptKeywords): string | null { return null; } +// Merge a set of same-row segments into one line: text joined left-to-right, +// box spanning them all, lowest confidence kept. +function mergeRow(seg: OcrLine[]): OcrLine { + const ordered = [...seg].sort((a, b) => a.box.x - b.box.x); + const x = Math.min(...ordered.map((l) => l.box.x)); + const y = Math.min(...ordered.map((l) => l.box.y)); + const right = Math.max(...ordered.map((l) => l.box.x + l.box.width)); + const bottom = Math.max(...ordered.map((l) => l.box.y + l.box.height)); + return { + text: ordered.map((l) => l.text).join(' '), + box: { x, y, width: right - x, height: bottom - y }, + confidence: Math.min(...ordered.map((l) => l.confidence)), + }; +} + +// Group detections into visual rows by vertical overlap. PP-OCR often emits a +// label and its amount as separate boxes on the same line; the finders need +// them combined so "Subtotal" and "$100.00" match as one row. +export function groupRows(lines: OcrLine[]): OcrLine[] { + const sorted = [...lines].sort((a, b) => a.box.y - b.box.y || a.box.x - b.box.x); + const groups: OcrLine[][] = []; + for (const l of sorted) { + const anchor = groups[groups.length - 1]?.[0]; + const tol = anchor ? Math.min(anchor.box.height, l.box.height) * 0.6 : 0; + if (anchor && Math.abs(l.box.y - anchor.box.y) <= tol) { + groups[groups.length - 1].push(l); + } else { + groups.push([l]); + } + } + return groups.map(mergeRow); +} + export function parseReceipt(ocr: OcrResult, keywords: ReceiptKeywords = EN_KEYWORDS): ReceiptData { - const { lines } = ocr; - const date = parseDate(lines.map((l) => l.text).join('\n')); + const rows = groupRows(ocr.lines); + const date = parseDate(rows.map((l) => l.text).join('\n')); return { - merchant: findMerchant(lines, keywords), + merchant: findMerchant(rows, keywords), dateRaw: date?.raw ?? null, dateIso: date?.iso ?? null, - currency: findCurrency(lines), - subtotal: firstAmount(lines, keywords.subtotal), - tax: firstAmount(lines, keywords.tax), - total: findTotal(lines, keywords), + currency: findCurrency(rows), + subtotal: firstAmount(rows, keywords.subtotal), + tax: firstAmount(rows, keywords.tax), + total: findTotal(rows, keywords), }; }