Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/tools/image/receipt.lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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);
Expand Down
47 changes: 40 additions & 7 deletions src/tools/image/receipt.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}
Loading