diff --git a/lib/models/ocr_element.dart b/lib/models/ocr_element.dart new file mode 100644 index 0000000..9c7106d --- /dev/null +++ b/lib/models/ocr_element.dart @@ -0,0 +1,38 @@ +/// One OCR text fragment with pixel bounding box on the receipt image. +library; + +class OcrElement { + const OcrElement({ + required this.text, + required this.left, + required this.top, + required this.right, + required this.bottom, + }); + + final String text; + final double left; + final double top; + final double right; + final double bottom; + + double get centerX => (left + right) / 2; + double get centerY => (top + bottom) / 2; + double get height => (bottom - top).abs(); + double get width => (right - left).abs(); +} + +/// All positioned elements from one ML Kit recognition pass. +class OcrDocument { + const OcrDocument({ + required this.elements, + required this.imageWidth, + required this.imageHeight, + required this.flattenedText, + }); + + final List elements; + final double imageWidth; + final double imageHeight; + final String flattenedText; +} diff --git a/lib/models/receipt_bounds.dart b/lib/models/receipt_bounds.dart new file mode 100644 index 0000000..2643669 --- /dev/null +++ b/lib/models/receipt_bounds.dart @@ -0,0 +1,57 @@ +/// Normalized rectangle (0–1) on the receipt photo for review highlights. +library; + +class ReceiptBounds { + const ReceiptBounds({ + required this.left, + required this.top, + required this.right, + required this.bottom, + }); + + final double left; + final double top; + final double right; + final double bottom; + + bool get isEmpty => right <= left || bottom <= top; + + ReceiptBounds union(ReceiptBounds other) { + if (isEmpty) return other; + if (other.isEmpty) return this; + return ReceiptBounds( + left: left < other.left ? left : other.left, + top: top < other.top ? top : other.top, + right: right > other.right ? right : other.right, + bottom: bottom > other.bottom ? bottom : other.bottom, + ); + } + + static ReceiptBounds? merge(Iterable bounds) { + ReceiptBounds? merged; + for (final box in bounds) { + if (box.isEmpty) continue; + merged = merged == null ? box : merged.union(box); + } + return merged; + } + + static ReceiptBounds fromPixelRect({ + required double left, + required double top, + required double right, + required double bottom, + required double imageWidth, + required double imageHeight, + }) { + if (imageWidth <= 0 || imageHeight <= 0) { + return const ReceiptBounds(left: 0, top: 0, right: 0, bottom: 0); + } + return ReceiptBounds( + left: left / imageWidth, + top: top / imageHeight, + right: right / imageWidth, + bottom: bottom / imageHeight, + ); + } +} diff --git a/lib/models/receipt_row.dart b/lib/models/receipt_row.dart new file mode 100644 index 0000000..12380f7 --- /dev/null +++ b/lib/models/receipt_row.dart @@ -0,0 +1,39 @@ +/// One visually reconstructed receipt row from clustered OCR elements. +library; + +import 'ocr_element.dart'; +import 'receipt_bounds.dart'; + +enum ReceiptRowKind { + skip, + quantityModifier, + nameOnly, + item, + discount, + grandTotal, + totalDiscount, +} + +class ReceiptRow { + const ReceiptRow({ + required this.kind, + required this.text, + required this.nameText, + this.price, + this.quantity, + this.unitPrice, + this.lineTotal, + this.elements = const [], + this.bounds, + }); + + final ReceiptRowKind kind; + final String text; + final String nameText; + final double? price; + final int? quantity; + final double? unitPrice; + final double? lineTotal; + final List elements; + final ReceiptBounds? bounds; +} diff --git a/lib/services/receipt_ocr.dart b/lib/services/receipt_ocr.dart new file mode 100644 index 0000000..cb38b82 --- /dev/null +++ b/lib/services/receipt_ocr.dart @@ -0,0 +1,66 @@ +/// Extract positioned OCR elements from ML Kit structured output. +library; + +import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; + +import '../models/ocr_element.dart'; + +class ReceiptOcr { + ReceiptOcr._(); + + /// Reads blocks → lines → elements. Falls back to line-level boxes when a + /// line has no element children. + static OcrDocument fromRecognizedText( + RecognizedText recognized, { + required double imageWidth, + required double imageHeight, + }) { + final elements = []; + var maxRight = 0.0; + var maxBottom = 0.0; + + for (final block in recognized.blocks) { + for (final line in block.lines) { + if (line.elements.isNotEmpty) { + for (final element in line.elements) { + final box = element.boundingBox; + elements.add( + OcrElement( + text: element.text, + left: box.left, + top: box.top, + right: box.right, + bottom: box.bottom, + ), + ); + if (box.right > maxRight) maxRight = box.right; + if (box.bottom > maxBottom) maxBottom = box.bottom; + } + } else { + final box = line.boundingBox; + elements.add( + OcrElement( + text: line.text, + left: box.left, + top: box.top, + right: box.right, + bottom: box.bottom, + ), + ); + if (box.right > maxRight) maxRight = box.right; + if (box.bottom > maxBottom) maxBottom = box.bottom; + } + } + } + + final width = imageWidth > 0 ? imageWidth : maxRight; + final height = imageHeight > 0 ? imageHeight : maxBottom; + + return OcrDocument( + elements: elements, + imageWidth: width, + imageHeight: height, + flattenedText: recognized.text.trim(), + ); + } +} diff --git a/lib/services/receipt_row_builder.dart b/lib/services/receipt_row_builder.dart new file mode 100644 index 0000000..21b9c7c --- /dev/null +++ b/lib/services/receipt_row_builder.dart @@ -0,0 +1,346 @@ +/// Cluster OCR elements into rows and classify using column positions. +library; + +import '../models/ocr_element.dart'; +import '../models/receipt_bounds.dart'; +import '../models/receipt_row.dart'; +import '../utils/money.dart'; +import 'receipt_parse_config.dart'; + +final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b'); +final _phonePattern = RegExp(r'^\d{8,}$'); +final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}[A-Za-z]?$"); +final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false); + +/// Pure quantity row — checked before any other row classification. +final _pureQuantityRow = RegExp( + r'^\s*(\d+)\s*[xX×]\s*(\d+[,.]\d{2})\s*(PER\s+STUK|PER\s+KG|ST\.?|STUKS?)?\s*$', + caseSensitive: false, +); + +final _priceToken = RegExp(r'^-?\d+[,.]\d{2}$'); + +class ReceiptRowBuilder { + ReceiptRowBuilder(this.config); + + final ReceiptParseConfig config; + + List buildRows(OcrDocument document) { + if (document.elements.isEmpty) return const []; + + final rowClusterThreshold = _rowClusterThreshold(document.elements); + final clustered = _clusterByY(document.elements, rowClusterThreshold); + final contentWidth = _contentWidth(document.elements); + final contentLeft = _contentLeft(document.elements); + final priceColumnStart = + contentLeft + contentWidth * (1 - config.priceColumnWidthFraction); + + return [ + for (final rowElements in clustered) + _classifyRow( + rowElements, + priceColumnStart: priceColumnStart, + imageWidth: document.imageWidth, + imageHeight: document.imageHeight, + ), + ]; + } + + double _rowClusterThreshold(List elements) { + final heights = [ + for (final element in elements) + if (element.height > 0) element.height, + ]..sort(); + if (heights.isEmpty) return 12; + final median = heights[heights.length ~/ 2]; + return median * config.rowClusterHeightFactor; + } + + List> _clusterByY( + List elements, + double threshold, + ) { + final sorted = [...elements]..sort((a, b) { + final dy = a.centerY.compareTo(b.centerY); + if (dy != 0) return dy; + return a.left.compareTo(b.left); + }); + + final rows = >[]; + List? current; + double? anchorY; + + for (final element in sorted) { + if (current == null) { + current = [element]; + anchorY = element.centerY; + continue; + } + if ((element.centerY - anchorY!).abs() <= threshold) { + current.add(element); + } else { + current.sort((a, b) => a.left.compareTo(b.left)); + rows.add(current); + current = [element]; + anchorY = element.centerY; + } + } + if (current != null) { + current.sort((a, b) => a.left.compareTo(b.left)); + rows.add(current); + } + return rows; + } + + double _contentWidth(List elements) { + var minLeft = double.infinity; + var maxRight = 0.0; + for (final element in elements) { + if (element.left < minLeft) minLeft = element.left; + if (element.right > maxRight) maxRight = element.right; + } + if (minLeft == double.infinity) return 1; + return (maxRight - minLeft).clamp(1, double.infinity); + } + + double _contentLeft(List elements) { + var minLeft = double.infinity; + for (final element in elements) { + if (element.left < minLeft) minLeft = element.left; + } + return minLeft == double.infinity ? 0 : minLeft; + } + + ReceiptRow _classifyRow( + List rowElements, { + required double priceColumnStart, + required double imageWidth, + required double imageHeight, + }) { + final text = rowElements.map((e) => e.text).join(' ').trim(); + final normalized = _preprocess(text); + final bounds = _rowBounds(rowElements, imageWidth, imageHeight); + + // 4. Quantity modifier — entire row, checked first. + final pureQty = _pureQuantityRow.firstMatch(normalized); + if (pureQty != null) { + final count = int.tryParse(pureQty.group(1)!); + final unit = tryParsePrice(pureQty.group(2)!); + return ReceiptRow( + kind: ReceiptRowKind.quantityModifier, + text: normalized, + nameText: '', + quantity: count, + unitPrice: unit, + lineTotal: count != null && unit != null + ? roundMoney(count * unit) + : null, + elements: rowElements, + bounds: bounds, + ); + } + + if (_isIgnorable(normalized)) { + return ReceiptRow( + kind: ReceiptRowKind.skip, + text: normalized, + nameText: '', + elements: rowElements, + bounds: bounds, + ); + } + + if (config.matchesAny(normalized, config.totalDiscountKeywords)) { + return ReceiptRow( + kind: ReceiptRowKind.totalDiscount, + text: normalized, + nameText: normalized, + price: _rowPrice(rowElements, priceColumnStart) ?? + _trailingPrice(normalized), + elements: rowElements, + bounds: bounds, + ); + } + + if (config.matchesAny(normalized, config.taxInclusiveTotalKeywords) || + config.matchesAny(normalized, config.subtotalKeywords)) { + return ReceiptRow( + kind: ReceiptRowKind.skip, + text: normalized, + nameText: '', + elements: rowElements, + bounds: bounds, + ); + } + + if (config.matchesAny(normalized, config.grandTotalKeywords)) { + return ReceiptRow( + kind: ReceiptRowKind.grandTotal, + text: normalized, + nameText: normalized, + price: _rowPrice(rowElements, priceColumnStart) ?? + _trailingPrice(normalized), + elements: rowElements, + bounds: bounds, + ); + } + + final isDiscount = + config.matchesAny(normalized, config.discountKeywords) || + config.negativeAmount.hasMatch(normalized); + if (isDiscount) { + return ReceiptRow( + kind: ReceiptRowKind.discount, + text: normalized, + nameText: normalized, + price: _rowPrice(rowElements, priceColumnStart) ?? + _trailingPrice(normalized) ?? + tryParsePrice(normalized), + elements: rowElements, + bounds: bounds, + ); + } + + final price = _rowPrice(rowElements, priceColumnStart); + final inlineQty = config.quantityInline.firstMatch(normalized); + var nameText = _nameFromElements(rowElements, priceColumnStart); + + if (price != null) { + if (inlineQty != null) { + nameText = normalized.substring(0, inlineQty.start).trim(); + final count = int.tryParse(inlineQty.group(1)!); + final unit = tryParsePrice(inlineQty.group(2)!); + if (nameText.isNotEmpty && count != null && unit != null) { + return ReceiptRow( + kind: ReceiptRowKind.item, + text: normalized, + nameText: nameText, + price: price, + quantity: count, + unitPrice: unit, + lineTotal: price, + elements: rowElements, + bounds: bounds, + ); + } + } + if (nameText.isEmpty || _isIgnorable(nameText)) { + return ReceiptRow( + kind: ReceiptRowKind.skip, + text: normalized, + nameText: nameText, + elements: rowElements, + bounds: bounds, + ); + } + return ReceiptRow( + kind: ReceiptRowKind.item, + text: normalized, + nameText: nameText, + price: price, + lineTotal: price, + elements: rowElements, + bounds: bounds, + ); + } + + if (nameText.isEmpty) nameText = normalized; + return ReceiptRow( + kind: ReceiptRowKind.nameOnly, + text: normalized, + nameText: nameText, + elements: rowElements, + bounds: bounds, + ); + } + + double? _rowPrice(List elements, double priceColumnStart) { + double? best; + var bestX = -1.0; + for (final element in elements) { + if (element.centerX < priceColumnStart) continue; + final token = element.text.trim().replaceAll( + config.trailingCategoryLetter, + '', + ); + if (!_priceToken.hasMatch(token)) continue; + final value = tryParsePrice(token); + if (value == null) continue; + if (element.centerX >= bestX) { + bestX = element.centerX; + best = value; + } + } + return best; + } + + String _nameFromElements(List elements, double priceColumnStart) { + final parts = []; + for (final element in elements) { + if (element.centerX >= priceColumnStart && + _priceToken.hasMatch(element.text.trim())) { + continue; + } + final cleaned = element.text + .replaceFirst(config.trailingCategoryLetter, '') + .trim(); + if (cleaned.isNotEmpty) parts.add(cleaned); + } + return _preprocess(parts.join(' ')); + } + + ReceiptBounds? _rowBounds( + List elements, + double imageWidth, + double imageHeight, + ) { + if (elements.isEmpty || imageWidth <= 0 || imageHeight <= 0) return null; + var left = double.infinity; + var top = double.infinity; + var right = 0.0; + var bottom = 0.0; + for (final element in elements) { + if (element.left < left) left = element.left; + if (element.top < top) top = element.top; + if (element.right > right) right = element.right; + if (element.bottom > bottom) bottom = element.bottom; + } + return ReceiptBounds.fromPixelRect( + left: left, + top: top, + right: right, + bottom: bottom, + imageWidth: imageWidth, + imageHeight: imageHeight, + ); + } + + String _preprocess(String line) => + line.trim().replaceAll(RegExp(r'\s+'), ' '); + + double? _trailingPrice(String line) { + final stripped = line.replaceFirst(config.trailingCategoryLetter, '').trim(); + final match = RegExp(r'(-?\d+[.,]\d{2})\s*$').firstMatch(stripped); + if (match == null) return null; + return tryParsePrice(match.group(1)!); + } + + bool _isIgnorable(String line) { + if (config.separatorPattern.hasMatch(line)) return true; + if (_urlPattern.hasMatch(line)) return true; + if (_postalPattern.hasMatch(line)) return true; + if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) return true; + if (_streetPattern.hasMatch(line)) return true; + if (config.categoryRow.hasMatch(line)) return true; + if (config.isStandaloneKeywordLine(line, config.columnHeaderKeywords)) { + return true; + } + if (config.isStandaloneKeywordLine(line, config.itemCountKeywords)) { + return true; + } + if (config.matchesAny(line, config.taxKeywords)) return true; + if (config.matchesAny(line, config.paymentKeywords)) return true; + if (config.matchesAny(line, config.noiseKeywords)) return true; + return false; + } +} diff --git a/lib/services/receipt_spatial_parser.dart b/lib/services/receipt_spatial_parser.dart new file mode 100644 index 0000000..bace31d --- /dev/null +++ b/lib/services/receipt_spatial_parser.dart @@ -0,0 +1,274 @@ +/// 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, + ); + } +} diff --git a/lib/services/receipt_text_parser.dart b/lib/services/receipt_text_parser.dart new file mode 100644 index 0000000..5274744 --- /dev/null +++ b/lib/services/receipt_text_parser.dart @@ -0,0 +1,638 @@ +/// Flat text-line receipt parser (fallback when spatial OCR is unavailable). +library; + +import '../models/line_item.dart'; +import '../utils/money.dart'; +import 'receipt_parse_config.dart'; + +/// Result of a full receipt parse, including totals used for review. +class ReceiptParseResult { + const ReceiptParseResult({ + required this.items, + this.store, + this.receiptTotal, + this.totalDiscount = 0, + this.validationPassed = true, + this.expectedItemCount, + this.ocrImageWidth, + this.ocrImageHeight, + }); + + final List items; + final String? store; + final double? receiptTotal; + final double totalDiscount; + final bool validationPassed; + final int? expectedItemCount; + final double? ocrImageWidth; + final double? ocrImageHeight; +} + +class _Qty { + const _Qty({required this.count, required this.unitPrice, this.total}); + + final int count; + final double unitPrice; + final double? total; + + double get lineTotal => total ?? roundMoney(count * unitPrice); +} + +class _Pending { + _Pending({ + required this.name, + this.unitPrice, + this.quantity = 1, + this.lineTotal, + }); + + String name; + double? unitPrice; + int quantity; + double? lineTotal; + double discount = 0; +} + +enum _Kind { + skip, + item, + nameOnly, + quantity, + discount, + grandTotal, + totalDiscount, + priceOnly, +} + +class _Line { + const _Line({ + required this.kind, + required this.raw, + this.name, + this.amount, + this.qty, + }); + + final _Kind kind; + final String raw; + final String? name; + final double? amount; + final _Qty? qty; +} + +final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b'); +final _phonePattern = RegExp(r'^\d{8,}$'); +final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}[A-Za-z]?$"); +final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false); + +/// Jumbo-style OCR often adds a line total after `N X unit`. +final _qtyWithTotal = RegExp( + r'^\s*(\d+)\s*[xX×]\s*(-?\d+[,.]\d{2})(?:\s+(?:PER\s+STUK|PER\s+KG|ST\.?|STUKS?))?(?:\s+(-?\d+[,.]\d{2}))?\s*$', + caseSensitive: false, +); + +class ReceiptTextParser { + ReceiptTextParser._(); + + static int _nextId = 0; + + static String _id() => 'item-${++_nextId}'; + + static Future loadConfig() => loadReceiptParseConfig(); + + static List parse(String rawText) => parseReceipt(rawText).items; + + static ReceiptParseResult parseReceipt(String rawText) { + final config = receiptParseConfig; + final lines = rawText + .split('\n') + .map(_preprocess) + .where((line) => line.isNotEmpty) + .toList(); + + if (_looksLikeColumnOcr(lines, config)) { + return _parseColumns(lines, config, rawText); + } + return _parseSequential(lines, config, rawText); + } + + static String _preprocess(String line) => + line.trim().replaceAll(RegExp(r'\s+'), ' '); + + static String _stripTax(String line, ReceiptParseConfig config) { + return line.replaceFirst(config.trailingCategoryLetter, '').trim(); + } + + static bool _looksLikeColumnOcr( + List lines, + ReceiptParseConfig config, + ) { + var run = 0; + var maxRun = 0; + var prices = 0; + var names = 0; + for (final line in lines) { + final stripped = _stripTax(line, config); + if (isPriceOnly(stripped)) { + prices++; + run++; + if (run > maxRun) maxRun = run; + } else { + run = 0; + if (_parseQty(stripped, config) == null && + !_isIgnorable(stripped, config) && + !config.matchesAny(stripped, [ + ...config.grandTotalKeywords, + ...config.subtotalKeywords, + ...config.taxInclusiveTotalKeywords, + ...config.totalDiscountKeywords, + ]) && + _trailingPrice(stripped, config) == null) { + names++; + } + } + } + if (maxRun >= 4 && prices >= 4 && names >= 4) return true; + // ML Kit sometimes splits a short receipt into names, then prices (2–3 items). + return maxRun >= 2 && prices >= 2 && names >= prices && maxRun >= prices; + } + + static ReceiptParseResult _parseSequential( + List lines, + ReceiptParseConfig config, + String rawText, + ) { + final items = []; + _Pending? pending; + var receiptTotal = _readAmountAfterKeyword( + lines, + config, + config.grandTotalKeywords, + skipIf: (line) => config.matchesAny(line, [ + ...config.totalDiscountKeywords, + ...config.taxInclusiveTotalKeywords, + ...config.subtotalKeywords, + ]), + ); + var totalDiscount = 0.0; + var stop = false; + + void flush() { + final current = pending; + pending = null; + if (current == null) return; + final total = current.lineTotal ?? current.unitPrice; + if (total == null) return; + items.add(_toItem(current, total)); + } + + for (var i = 0; i < lines.length && !stop; i++) { + final classified = _classify(lines[i], config); + switch (classified.kind) { + case _Kind.skip: + continue; + case _Kind.item: + flush(); + pending = _Pending( + name: classified.name!, + unitPrice: classified.amount, + quantity: classified.qty?.count ?? 1, + lineTotal: classified.qty == null + ? classified.amount + : classified.qty!.lineTotal, + ); + if (classified.qty != null) { + pending!.unitPrice = classified.qty!.unitPrice; + } + case _Kind.nameOnly: + flush(); + pending = _Pending(name: classified.name ?? classified.raw); + case _Kind.quantity: + final qty = classified.qty!; + if (pending != null) { + pending! + ..quantity = qty.count + ..unitPrice = qty.unitPrice + ..lineTotal = qty.lineTotal; + } + case _Kind.discount: + var amount = classified.amount; + if (amount == null && i + 1 < lines.length) { + amount = tryParsePrice(_stripTax(lines[i + 1], config)); + 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, + ); + } else { + totalDiscount += amount; + } + case _Kind.totalDiscount: + var amount = classified.amount; + if (amount == null && i + 1 < lines.length) { + amount = tryParsePrice(_stripTax(lines[i + 1], config)); + if (amount != null) i++; + } + if (amount != null) { + totalDiscount += amount > 0 ? -amount : amount; + } + case _Kind.grandTotal: + flush(); + if (classified.amount != null) { + receiptTotal = classified.amount; + stop = true; + } + case _Kind.priceOnly: + final amount = classified.amount!; + if (pending != null) { + if (amount < 0) { + pending!.discount += amount; + pending!.lineTotal = roundMoney( + (pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount, + ); + } else if (pending!.lineTotal == null && + pending!.unitPrice == null) { + pending!.lineTotal = amount; + flush(); + } + } + } + } + flush(); + + return _result( + items: items, + rawText: rawText, + receiptTotal: receiptTotal, + totalDiscount: totalDiscount, + lines: lines, + ); + } + + static ReceiptParseResult _parseColumns( + List lines, + ReceiptParseConfig config, + String rawText, + ) { + final names = <({String name, int? quantity, double? unitPrice})>[]; + final prices = []; + final discountSlots = []; + final discounts = []; + + for (final raw in lines) { + final line = _stripTax(raw, config); + if (isPriceOnly(line)) { + final price = tryParsePrice(line); + if (price == null) continue; + if (price < 0) { + discounts.add(price); + } else { + prices.add(price); + } + continue; + } + if (_isIgnorable(line, config)) continue; + if (config.matchesAny(line, [ + ...config.grandTotalKeywords, + ...config.subtotalKeywords, + ...config.taxInclusiveTotalKeywords, + ...config.totalDiscountKeywords, + ])) { + continue; + } + + final qty = _parseQty(line, config); + if (qty != null) { + if (names.isNotEmpty) { + final last = names.removeLast(); + names.add(( + name: last.name, + quantity: qty.count, + unitPrice: qty.unitPrice, + )); + } + continue; + } + + if (config.matchesAny(line, config.discountKeywords)) { + if (names.isNotEmpty) discountSlots.add(names.length - 1); + continue; + } + + names.add((name: line, quantity: null, unitPrice: null)); + } + + final count = names.length < prices.length ? names.length : prices.length; + final pairedNames = names.length > count + ? names.sublist(names.length - count) + : names; + final nameOffset = names.length - pairedNames.length; + final items = [ + for (var i = 0; i < count; i++) + LineItem( + id: _id(), + name: pairedNames[i].name, + price: prices[i], + quantity: pairedNames[i].quantity, + unitPrice: pairedNames[i].unitPrice, + ), + ]; + + for (var i = 0; i < discountSlots.length && i < discounts.length; i++) { + final index = discountSlots[i] - nameOffset; + if (index < 0 || index >= items.length) continue; + final item = items[index]; + final discount = discounts[i]; + items[index] = item.copyWith( + price: roundMoney(item.price + discount), + discount: roundMoney(item.discount + discount), + ); + } + + final receiptTotal = _readAmountAfterKeyword( + lines, + config, + config.grandTotalKeywords, + skipIf: (line) => config.matchesAny(line, [ + ...config.totalDiscountKeywords, + ...config.taxInclusiveTotalKeywords, + ...config.subtotalKeywords, + ]), + ); + var totalDiscount = discounts.fold(0, (sum, value) => sum + value); + totalDiscount += + _readAmountAfterKeyword(lines, config, config.totalDiscountKeywords) ?? + 0; + + return _result( + items: items, + rawText: rawText, + receiptTotal: receiptTotal, + totalDiscount: totalDiscount, + lines: lines, + ); + } + + static LineItem _toItem(_Pending pending, double total) { + return LineItem( + id: _id(), + name: pending.name, + price: roundMoney(total), + quantity: pending.quantity == 1 ? null : pending.quantity, + unitPrice: pending.quantity == 1 ? null : pending.unitPrice, + discount: pending.discount == 0 ? 0 : roundMoney(pending.discount), + ); + } + + static ReceiptParseResult _result({ + required List items, + required String rawText, + double? receiptTotal, + double totalDiscount = 0, + List? lines, + }) { + final config = receiptParseConfig; + final kept = items + .where((item) => item.name.trim().isNotEmpty && item.price > 0) + .toList(); + final sum = roundMoney( + kept.fold(0, (total, item) => total + item.price), + ); + final expectedItemCount = lines == null + ? null + : _readExpectedItemCountFromLines(lines, config); + var passed = + receiptTotal == null || + (sum - receiptTotal).abs() <= 0.01 || + (sum + totalDiscount - receiptTotal).abs() <= 0.01; + if (expectedItemCount != null && kept.length != expectedItemCount) { + passed = false; + } + return ReceiptParseResult( + items: kept, + store: detectStore(rawText), + receiptTotal: receiptTotal, + totalDiscount: totalDiscount, + validationPassed: passed, + expectedItemCount: expectedItemCount, + ); + } + + static int? _readExpectedItemCountFromLines( + List lines, + ReceiptParseConfig config, + ) { + final pattern = RegExp(r'(\d+)\s*$', caseSensitive: false); + for (final raw in lines) { + final line = _preprocess(raw); + if (!config.matchesAny(line, config.itemCountKeywords)) continue; + final match = pattern.firstMatch(line); + if (match != null) return int.tryParse(match.group(1)!); + } + return null; + } + + static String? detectStore(String rawText) { + final config = receiptParseConfig; + final header = rawText + .split('\n') + .map(_preprocess) + .where((line) => line.isNotEmpty) + .take(8) + .join('\n') + .toUpperCase(); + final stores = [...config.knownStores] + ..sort((a, b) => b.length.compareTo(a.length)); + for (final store in stores) { + if (RegExp('\\b${RegExp.escape(store)}\\b').hasMatch(header)) { + return _canonicalStore(store); + } + } + return null; + } + + static String _canonicalStore(String raw) { + const aliases = { + 'AH': 'Albert Heijn', + 'ALBERT HEIJN': 'Albert Heijn', + 'JUMBO': 'Jumbo', + 'LIDL': 'Lidl', + 'KRUIDVAT': 'Kruidvat', + 'ALDI': 'Aldi', + 'PLUS': 'Plus', + 'DIRK': 'Dirk', + 'COOP': 'Coop', + 'SPAR': 'SPAR', + 'VOMAR': 'Vomar', + 'EKOPLAZA': 'Ekoplaza', + 'HOOGVLIET': 'Hoogvliet', + 'ACTION': 'Action', + 'ETOS': 'Etos', + 'WORLD TOKO': 'World Toko', + }; + return aliases[raw.toUpperCase()] ?? raw; + } + + static double? _readAmountAfterKeyword( + List lines, + ReceiptParseConfig config, + List keywords, { + bool Function(String line)? skipIf, + }) { + for (var i = 0; i < lines.length; i++) { + final line = _stripTax(lines[i], config); + if (!config.matchesAny(line, keywords)) continue; + if (skipIf != null && skipIf(line)) continue; + final trailing = _trailingPrice(line, config); + if (trailing != null) return trailing; + if (i + 1 < lines.length) { + final next = tryParsePrice(_stripTax(lines[i + 1], config)); + if (next != null) return next; + } + } + return null; + } + + static _Line _classify(String raw, ReceiptParseConfig config) { + final line = _stripTax(raw, config); + if (_isIgnorable(line, config)) { + return _Line(kind: _Kind.skip, raw: line); + } + if (config.matchesAny(line, config.totalDiscountKeywords)) { + return _Line( + kind: _Kind.totalDiscount, + raw: line, + amount: _trailingPrice(line, config), + ); + } + if (config.matchesAny(line, config.taxInclusiveTotalKeywords) || + config.matchesAny(line, config.subtotalKeywords)) { + return _Line(kind: _Kind.skip, raw: line); + } + if (config.matchesAny(line, config.grandTotalKeywords)) { + return _Line( + kind: _Kind.grandTotal, + raw: line, + amount: _trailingPrice(line, config), + ); + } + + final qty = _parseQty(line, config); + if (qty != null) { + return _Line(kind: _Kind.quantity, raw: line, qty: qty); + } + + final isDiscount = + config.matchesAny(line, config.discountKeywords) || + config.negativeAmount.hasMatch(line); + if (isDiscount && + (config.matchesAny(line, config.discountKeywords) || + isPriceOnly(line))) { + return _Line( + kind: _Kind.discount, + raw: line, + name: line, + amount: _trailingPrice(line, config) ?? tryParsePrice(line), + ); + } + + if (isPriceOnly(line)) { + return _Line( + kind: _Kind.priceOnly, + raw: line, + amount: tryParsePrice(line), + ); + } + + final inline = config.quantityInline.firstMatch(line); + final trailing = _trailingPrice(line, config); + if (trailing != null) { + var name = line.replaceFirst(RegExp(r'\s+-?\d+[.,]\d{2}\s*$'), '').trim(); + _Qty? inlineQty; + if (inline != null) { + name = line.substring(0, inline.start).trim(); + final count = int.tryParse(inline.group(1)!); + final unit = tryParsePrice(inline.group(2)!); + if (count != null && unit != null) { + inlineQty = _Qty(count: count, unitPrice: unit, total: trailing); + } + } + if (name.isEmpty || _isIgnorable(name, config)) { + return _Line(kind: _Kind.skip, raw: line); + } + return _Line( + kind: _Kind.item, + raw: line, + name: name, + amount: trailing, + qty: inlineQty, + ); + } + + if (inline != null) { + final name = line.substring(0, inline.start).trim(); + final count = int.tryParse(inline.group(1)!); + final unit = tryParsePrice(inline.group(2)!); + if (name.isNotEmpty && count != null && unit != null) { + return _Line( + kind: _Kind.item, + raw: line, + name: name, + amount: roundMoney(count * unit), + qty: _Qty(count: count, unitPrice: unit), + ); + } + } + + return _Line(kind: _Kind.nameOnly, raw: line, name: line); + } + + static bool _isIgnorable(String line, ReceiptParseConfig config) { + if (config.separatorPattern.hasMatch(line)) return true; + if (_urlPattern.hasMatch(line)) return true; + if (_postalPattern.hasMatch(line)) return true; + if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) { + return true; + } + if (_streetPattern.hasMatch(line)) return true; + if (config.categoryRow.hasMatch(line)) return true; + if (config.isStandaloneKeywordLine(line, config.columnHeaderKeywords)) { + return true; + } + if (config.isStandaloneKeywordLine(line, config.itemCountKeywords)) { + return true; + } + if (config.matchesAny(line, config.taxKeywords)) return true; + if (config.matchesAny(line, config.paymentKeywords)) return true; + if (config.matchesAny(line, config.noiseKeywords)) return true; + return false; + } + + static double? _trailingPrice(String line, ReceiptParseConfig config) { + final match = RegExp(r'(-?\d+[.,]\d{2})\s*$').firstMatch(line); + if (match == null) return null; + return tryParsePrice(match.group(1)!); + } + + static _Qty? _parseQty(String line, ReceiptParseConfig config) { + final match = + _qtyWithTotal.firstMatch(line) ?? + config.quantityStandalone.firstMatch(line); + if (match == null) return null; + final count = int.tryParse(match.group(1)!); + final unit = tryParsePrice(match.group(2)!); + if (count == null || count < 1 || unit == null) return null; + double? total; + if (match.groupCount >= 3) { + final raw = match.group(3); + if (raw != null && RegExp(r'^-?\d+[.,]\d{2}$').hasMatch(raw)) { + total = tryParsePrice(raw); + } + } + return _Qty(count: count, unitPrice: unit, total: total); + } +} diff --git a/lib/widgets/receipt_highlight_overlay.dart b/lib/widgets/receipt_highlight_overlay.dart new file mode 100644 index 0000000..16d4b46 --- /dev/null +++ b/lib/widgets/receipt_highlight_overlay.dart @@ -0,0 +1,91 @@ +/// Overlays a highlight on the receipt photo for a parsed line item. +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../models/receipt_bounds.dart'; + +class ReceiptHighlightOverlay extends StatelessWidget { + const ReceiptHighlightOverlay({ + super.key, + required this.imagePath, + required this.bounds, + this.maxHeight = 280, + }); + + final String imagePath; + final ReceiptBounds bounds; + final double maxHeight; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + fit: StackFit.passthrough, + children: [ + Image.file( + File(imagePath), + fit: BoxFit.contain, + height: maxHeight, + width: constraints.maxWidth, + ), + Positioned.fill( + child: CustomPaint( + painter: _BoundsPainter( + bounds: bounds, + color: scheme.primary.withValues(alpha: 0.35), + stroke: scheme.primary, + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +class _BoundsPainter extends CustomPainter { + _BoundsPainter({ + required this.bounds, + required this.color, + required this.stroke, + }); + + final ReceiptBounds bounds; + final Color color; + final Color stroke; + + @override + void paint(Canvas canvas, Size size) { + if (bounds.isEmpty) return; + final rect = Rect.fromLTRB( + bounds.left * size.width, + bounds.top * size.height, + bounds.right * size.width, + bounds.bottom * size.height, + ); + canvas.drawRect(rect, Paint()..color = color); + canvas.drawRect( + rect, + Paint() + ..color = stroke + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + } + + @override + bool shouldRepaint(covariant _BoundsPainter oldDelegate) { + return oldDelegate.bounds != bounds || + oldDelegate.color != color || + oldDelegate.stroke != stroke; + } +} diff --git a/test/receipt_spatial_parser_test.dart b/test/receipt_spatial_parser_test.dart new file mode 100644 index 0000000..cb2b204 --- /dev/null +++ b/test/receipt_spatial_parser_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:groci/models/ocr_element.dart'; +import 'package:groci/services/receipt_parse_config.dart'; +import 'package:groci/services/receipt_parser.dart'; +import 'package:groci/services/receipt_row_builder.dart'; +import 'package:groci/services/receipt_spatial_parser.dart'; + +/// Synthetic two-column receipt layout (800×1200 image). +/// +/// Y increases downward; name column ~left 40–420, price column ~right 620–760. +const _imageWidth = 800.0; +const _imageHeight = 1200.0; +const _nameLeft = 40.0; +const _priceLeft = 620.0; +const _rowHeight = 24.0; +const _rowGap = 8.0; + +OcrElement _word(String text, double row, {bool price = false}) { + final y = row * (_rowHeight + _rowGap) + 100; + final left = price ? _priceLeft : _nameLeft; + final width = text.length * 10.0 + 20; + return OcrElement( + text: text, + left: left, + top: y, + right: left + width, + bottom: y + _rowHeight, + ); +} + +OcrDocument _document(List elements, {String? flattened}) { + return OcrDocument( + elements: elements, + imageWidth: _imageWidth, + imageHeight: _imageHeight, + flattenedText: flattened ?? elements.map((e) => e.text).join('\n'), + ); +} + +void main() { + setUpAll(() async { + await ReceiptParser.loadConfig(); + }); + + group('ReceiptRowBuilder', () { + test('clusters name and price on the same row by Y position', () { + final rows = ReceiptRowBuilder(receiptParseConfig).buildRows( + _document([ + _word('MILK', 0), + _word('1,50', 0, price: true), + ]), + ); + + expect(rows, hasLength(1)); + expect(rows.single.kind.name, 'item'); + expect(rows.single.nameText, 'MILK'); + expect(rows.single.price, 1.50); + }); + + test('classifies pure quantity row before item logic', () { + final rows = ReceiptRowBuilder(receiptParseConfig).buildRows( + _document([ + _word('2', 0), + _word('X', 0), + _word('2,99', 0, price: true), + ], flattened: '2 X 2,99'), + ); + + expect(rows, hasLength(1)); + expect(rows.single.kind.name, 'quantityModifier'); + expect(rows.single.quantity, 2); + expect(rows.single.unitPrice, 2.99); + }); + + test('treats row without price-column token as name-only', () { + final rows = ReceiptRowBuilder(receiptParseConfig).buildRows( + _document([_word('BROCCOLI', 0)]), + ); + + expect(rows.single.kind.name, 'nameOnly'); + expect(rows.single.nameText, 'BROCCOLI'); + }); + }); + + group('ReceiptSpatialParser lookahead', () { + test('merges Jumbo-style name row with following quantity modifier', () { + final result = ReceiptSpatialParser.parse( + _document([ + _word('AARDBEIEN', 0), + _word('2', 1), + _word('X', 1), + _word('3,99', 1, price: true), + _word('Totaal', 2), + _word('7,98', 2, price: true), + ]), + ); + + expect(result.items, hasLength(1)); + expect(result.items.single.name, 'AARDBEIEN'); + expect(result.items.single.quantity, 2); + expect(result.items.single.unitPrice, 3.99); + expect(result.items.single.price, 7.98); + expect(result.items.single.sourceBounds, isNotNull); + }); + + test('overrides inline price when quantity modifier follows (Kruidvat)', () { + final result = ReceiptSpatialParser.parse( + _document([ + _word('ZEEPTABLET', 0), + _word('NEUTRAL', 0), + _word('7,98', 0, price: true), + _word('2', 1), + _word('X', 1), + _word('3,99', 1, price: true), + ]), + ); + + expect(result.items, hasLength(1)); + expect(result.items.single.quantity, 2); + expect(result.items.single.unitPrice, 3.99); + expect(result.items.single.price, 7.98); + }); + + test('flags name-only row without following modifier for manual review', () { + final result = ReceiptSpatialParser.parse( + _document([ + _word('MILK', 0), + _word('BREAD', 1), + _word('2,00', 1, price: true), + ]), + ); + + expect(result.items, hasLength(2)); + expect(result.items[0].name, 'MILK'); + expect(result.items[0].needsManualReview, isTrue); + expect(result.items[1].name, 'BREAD'); + expect(result.items[1].price, 2.00); + }); + + test('parses two side-by-side items without cross-assigning prices', () { + final result = ReceiptSpatialParser.parse( + _document([ + _word('MILK', 0), + _word('1,50', 0, price: true), + _word('BREAD', 1), + _word('2,00', 1, price: true), + _word('Totaal', 2), + _word('3,50', 2, price: true), + ]), + ); + + expect(result.items.map((i) => i.name), ['MILK', 'BREAD']); + expect(result.items.map((i) => i.price), [1.50, 2.00]); + expect(result.validationPassed, isTrue); + }); + + test('fails validation when printed item count does not match', () { + final result = ReceiptSpatialParser.parse( + _document([ + _word('MILK', 0), + _word('1,50', 0, price: true), + _word('Aantal', 1), + _word('Artikel:', 1), + _word('3', 1, price: true), + ]), + ); + + expect(result.expectedItemCount, 3); + expect(result.validationPassed, isFalse); + }); + }); +}