/// 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; } }