/// Spatial receipt parser: row clustering, column logic, and lookahead buffer. library; import '../models/line_item.dart'; import '../models/ocr_element.dart'; import '../models/receipt_bounds.dart'; import '../models/receipt_row.dart'; import '../utils/money.dart'; import 'receipt_parse_config.dart'; import 'receipt_row_builder.dart'; import 'receipt_text_parser.dart'; class _PendingItem { _PendingItem({ required this.name, this.unitPrice, this.lineTotal, this.quantity = 1, this.bounds, }); String name; double? unitPrice; double? lineTotal; int quantity; double discount = 0; ReceiptBounds? bounds; void mergeBounds(ReceiptBounds? other) { if (other == null) return; bounds = bounds == null ? other : bounds!.union(other); } } class ReceiptSpatialParser { ReceiptSpatialParser._(); static int _nextId = 0; static String _id() => 'item-${++_nextId}'; static ReceiptParseResult parse(OcrDocument document) { _nextId = 0; final config = receiptParseConfig; final rows = ReceiptRowBuilder(config).buildRows(document); return _parseRows( rows, rawText: document.flattenedText, imageWidth: document.imageWidth, imageHeight: document.imageHeight, ); } static ReceiptParseResult _parseRows( List rows, { required String rawText, required double imageWidth, required double imageHeight, }) { final config = receiptParseConfig; final items = []; _PendingItem? pending; var receiptTotal = _readTotalFromRows(rows, config); var totalDiscount = 0.0; var stop = false; void emitPending({required bool needsReview}) { final current = pending; pending = null; if (current == null) return; final total = current.lineTotal ?? current.unitPrice; if (total == null) { items.add( LineItem( id: _id(), name: current.name, price: 0, sourceBounds: current.bounds, needsManualReview: true, ), ); return; } items.add( LineItem( id: _id(), name: current.name, price: roundMoney(total), quantity: current.quantity == 1 ? null : current.quantity, unitPrice: current.quantity == 1 ? null : current.unitPrice, discount: current.discount == 0 ? 0 : roundMoney(current.discount), sourceBounds: current.bounds, needsManualReview: needsReview, ), ); } for (var i = 0; i < rows.length && !stop; i++) { final row = rows[i]; switch (row.kind) { case ReceiptRowKind.skip: continue; case ReceiptRowKind.quantityModifier: if (pending != null && row.quantity != null && row.unitPrice != null) { pending! ..quantity = row.quantity! ..unitPrice = row.unitPrice ..lineTotal = row.lineTotal ?? roundMoney(row.quantity! * row.unitPrice!); pending!.mergeBounds(row.bounds); emitPending(needsReview: false); } continue; case ReceiptRowKind.nameOnly: emitPending(needsReview: pending?.lineTotal == null); pending = _PendingItem(name: row.nameText, bounds: row.bounds); final next = i + 1 < rows.length ? rows[i + 1] : null; if (next?.kind == ReceiptRowKind.quantityModifier) { continue; } emitPending(needsReview: true); continue; case ReceiptRowKind.item: emitPending(needsReview: pending?.lineTotal == null); pending = _PendingItem( name: row.nameText, unitPrice: row.unitPrice ?? row.price, lineTotal: row.lineTotal ?? row.price, quantity: row.quantity ?? 1, bounds: row.bounds, ); if (row.quantity != null) { pending!.unitPrice = row.unitPrice; } final nextItem = i + 1 < rows.length ? rows[i + 1] : null; if (nextItem?.kind == ReceiptRowKind.quantityModifier) { continue; } emitPending(needsReview: false); continue; case ReceiptRowKind.discount: var amount = row.price; if (amount == null && i + 1 < rows.length) { amount = rows[i + 1].price; if (amount != null) i++; } if (amount == null) continue; if (amount > 0) amount = -amount; if (pending != null) { pending!.discount += amount; pending!.lineTotal = roundMoney( (pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount, ); pending!.mergeBounds(row.bounds); } else { totalDiscount += amount; } continue; case ReceiptRowKind.totalDiscount: var amount = row.price; if (amount == null && i + 1 < rows.length) { amount = rows[i + 1].price; if (amount != null) i++; } if (amount != null) { totalDiscount += amount > 0 ? -amount : amount; } continue; case ReceiptRowKind.grandTotal: emitPending(needsReview: pending?.lineTotal == null); if (row.price != null) { receiptTotal = row.price; stop = true; } continue; } } emitPending(needsReview: pending?.lineTotal == null); final expectedItemCount = _readExpectedItemCount(rows, config); return _finalize( items: items, rawText: rawText, receiptTotal: receiptTotal, totalDiscount: totalDiscount, expectedItemCount: expectedItemCount, imageWidth: imageWidth, imageHeight: imageHeight, ); } static double? _readTotalFromRows( List rows, ReceiptParseConfig config, ) { for (final row in rows) { if (row.kind != ReceiptRowKind.grandTotal) continue; if (config.matchesAny(row.text, [ ...config.totalDiscountKeywords, ...config.taxInclusiveTotalKeywords, ...config.subtotalKeywords, ])) { continue; } return row.price; } return null; } static int? _readExpectedItemCount( List rows, ReceiptParseConfig config, ) { final pattern = RegExp( r'(\d+)\s*$', caseSensitive: false, ); for (final row in rows) { if (!config.matchesAny(row.text, config.itemCountKeywords)) continue; final match = pattern.firstMatch(row.text); if (match != null) { return int.tryParse(match.group(1)!); } } return null; } static ReceiptParseResult _finalize({ required List items, required String rawText, double? receiptTotal, double totalDiscount = 0, int? expectedItemCount, required double imageWidth, required double imageHeight, }) { final kept = items .where( (item) => item.name.trim().isNotEmpty && (item.price > 0 || item.needsManualReview), ) .toList(); final sum = roundMoney( kept .where((item) => item.price > 0) .fold(0, (total, item) => total + item.price), ); var passed = true; if (receiptTotal != null) { passed = (sum - receiptTotal).abs() <= 0.01 || (sum + totalDiscount - receiptTotal).abs() <= 0.01; } if (expectedItemCount != null && kept.length != expectedItemCount) { passed = false; } if (kept.any((item) => item.needsManualReview)) { passed = false; } return ReceiptParseResult( items: kept, store: ReceiptTextParser.detectStore(rawText), receiptTotal: receiptTotal, totalDiscount: totalDiscount, validationPassed: passed, expectedItemCount: expectedItemCount, ocrImageWidth: imageWidth, ocrImageHeight: imageHeight, ); } }