/// Turns raw OCR text into [LineItem]s using line-end prices and a keyword filter. library; import '../models/line_item.dart'; import '../utils/money.dart'; /// Headers, payments, and totals — never treated as products. const Set kHeaderKeywords = { 'TOTAL', 'SUBTOTAL', 'TOTAAL', 'TAX', 'VAT', 'BTW', 'CASH', 'CARD', 'CHANGE', 'VISA', 'MASTERCARD', 'MAESTRO', 'PIN', 'DEBIT', 'CREDIT', 'OMSCHRIJVING', 'BEDRAG', 'AMOUNT', 'DESCRIPTION', 'THANK', 'BEDANKT', 'WISSELGELD', 'BETAALD', 'BETAAL', 'CONTANT', 'KASSA', 'RECEIPT', 'WWW', 'HTTP', }; /// Discount / promo rows. Paired with a price, then dropped. const Set kDropKeywords = { 'DISCOUNT', 'KORTING', 'ACTIE', }; final _separatorPattern = RegExp(r'^[=\-_.:\s]+$'); 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}$"); final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false); class _QtyLine { const _QtyLine({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 ReceiptParser { ReceiptParser._(); static int _nextId = 0; static String _id() => 'item-${++_nextId}'; /// Parses [rawText] into product rows. /// /// Prefers same-line `name … price` (with a following `2 x 1.50` line when /// the name has no price). If OCR split the receipt into a name column and a /// price column, falls back to pairing those columns. static List parse(String rawText) { final lines = rawText .split('\n') .map((line) => line.trim()) .where((line) => line.isNotEmpty) .toList(); final inline = _parseInline(lines); final columns = _parseColumns(lines); final chosen = columns.length > inline.length ? columns : inline; return chosen.where(_keepItem).toList(); } static List _parseInline(List lines) { final items = []; for (var i = 0; i < lines.length; i++) { final line = lines[i]; if (_shouldSkipLine(line) || isPriceOnly(line)) continue; final qty = _parseQty(line); if (qty != null) continue; final trailing = trailingPricePattern.firstMatch(line); if (trailing != null) { final name = trailing.group(1)!.trim(); final price = tryParsePrice(trailing.group(2)!); if (name.isEmpty || price == null || _shouldSkipLine(name)) continue; items.add(LineItem(id: _id(), name: name, price: price)); continue; } if (i + 1 >= lines.length) continue; final nextQty = _parseQty(lines[i + 1]); if (nextQty == null) continue; items.add( LineItem( id: _id(), name: line, price: nextQty.lineTotal, quantity: nextQty.count, unitPrice: nextQty.unitPrice, ), ); i++; } return items; } static List _parseColumns(List lines) { final names = <({String name, int? quantity, double? unitPrice})>[]; final prices = []; for (final line in lines) { if (isPriceOnly(line)) { final price = tryParsePrice(line); if (price != null) prices.add(price); continue; } if (_shouldSkipLine(line)) continue; final qty = _parseQty(line); if (qty != null) { if (names.isNotEmpty) { final last = names.removeLast(); names.add(( name: last.name, quantity: qty.count, unitPrice: qty.unitPrice, )); } continue; } names.add((name: line, quantity: null, unitPrice: null)); } final count = names.length < prices.length ? names.length : prices.length; return [ for (var i = 0; i < count; i++) LineItem( id: _id(), name: names[i].name, price: prices[i], quantity: names[i].quantity, unitPrice: names[i].unitPrice, ), ]; } static bool _keepItem(LineItem item) { if (item.price < 0) return false; if (item.name.trim().isEmpty) return false; return !_containsKeyword(item.name, kDropKeywords); } static bool _shouldSkipLine(String line) { if (_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 (_containsKeyword(line, kHeaderKeywords)) return true; return false; } static bool _containsKeyword(String line, Set keywords) { final upper = line.toUpperCase(); for (final keyword in keywords) { if (RegExp('\\b${RegExp.escape(keyword)}\\b').hasMatch(upper)) { return true; } } return false; } static _QtyLine? _parseQty(String line) { final match = quantityPattern.firstMatch(line.trim()); 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; final totalRaw = match.group(3); final total = totalRaw == null ? null : tryParsePrice(totalRaw); return _QtyLine(count: count, unitPrice: unit, total: total); } }