/// Keyword and regex config loaded from receipt-parsing-keywords.json. library; import 'dart:convert'; import 'dart:io'; import 'package:flutter/services.dart'; class ReceiptParseConfig { ReceiptParseConfig({ required this.knownStores, required this.columnHeaderKeywords, required this.separatorPattern, required this.quantityStandalone, required this.quantityInline, required this.discountKeywords, required this.negativeAmount, required this.totalDiscountKeywords, required this.subtotalKeywords, required this.grandTotalKeywords, required this.taxInclusiveTotalKeywords, required this.paymentKeywords, required this.taxKeywords, required this.categoryRow, required this.trailingCategoryLetter, required this.noiseKeywords, required this.itemCountKeywords, this.rowClusterHeightFactor = 0.55, this.priceColumnWidthFraction = 0.25, }); final List knownStores; final List columnHeaderKeywords; final RegExp separatorPattern; final RegExp quantityStandalone; final RegExp quantityInline; final List discountKeywords; final RegExp negativeAmount; final List totalDiscountKeywords; final List subtotalKeywords; final List grandTotalKeywords; final List taxInclusiveTotalKeywords; final List paymentKeywords; final List taxKeywords; final RegExp categoryRow; final RegExp trailingCategoryLetter; final List noiseKeywords; final List itemCountKeywords; final double rowClusterHeightFactor; final double priceColumnWidthFraction; factory ReceiptParseConfig.fromJson(Map json) { List list(String section, [String key = 'list']) { final block = json[section]; if (block is! Map) return const []; final value = block[key]; if (value is! List) return const []; return [ for (final item in value) if (item is String) item, ]; } RegExp rx(String section, String key, {bool caseInsensitive = true}) { final block = json[section]; final value = block is Map ? block[key] : null; final source = value is String ? value : '^\$'; return RegExp(source, caseSensitive: !caseInsensitive); } return ReceiptParseConfig( knownStores: list('known_stores'), columnHeaderKeywords: list('column_header_keywords'), separatorPattern: rx('column_header_keywords', 'separator_pattern'), quantityStandalone: rx( 'quantity_modifier_pattern', 'regex', caseInsensitive: true, ), quantityInline: rx( 'quantity_modifier_pattern', 'inline_regex', caseInsensitive: true, ), discountKeywords: list('discount_keywords'), negativeAmount: rx('discount_keywords', 'negative_amount_pattern'), totalDiscountKeywords: list( 'discount_keywords', 'receipt_level_total_discount_keywords', ), subtotalKeywords: list('total_keywords', 'subtotal_keywords'), grandTotalKeywords: list('total_keywords', 'grand_total_keywords'), taxInclusiveTotalKeywords: list( 'total_keywords', 'tax_inclusive_total_keywords', ), paymentKeywords: list('payment_keywords'), taxKeywords: list('tax_breakdown_keywords'), categoryRow: rx('tax_breakdown_keywords', 'category_row_pattern'), trailingCategoryLetter: rx( 'tax_breakdown_keywords', 'trailing_category_letter_pattern', ), noiseKeywords: list('noise_footer_keywords'), itemCountKeywords: list('line_item_count_keywords'), rowClusterHeightFactor: _spatialDouble( json, 'row_cluster_height_factor', fallback: 0.55, ), priceColumnWidthFraction: _spatialDouble( json, 'price_column_width_fraction', fallback: 0.25, ), ); } static double _spatialDouble( Map json, String key, { required double fallback, }) { final spatial = json['spatial_parsing']; if (spatial is! Map) return fallback; final value = spatial[key]; if (value is num && value > 0) return value.toDouble(); return fallback; } static const assetPath = 'receipt-parsing-keywords.json'; /// True if [line] matches a config keyword. /// /// Always uses word boundaries so `CHIP` does not match `CHIPS` and `PIN` /// does not match `SPINAZIE`. A letter-only compact form is a fallback for /// OCR splits like `Betaal d:` → `BETAALD` (only for keywords of length ≥ 5, /// so short tokens like `CHIP` cannot match inside longer product words). bool matchesKeyword(String line, String keyword) { final upper = line.toUpperCase(); final key = keyword.toUpperCase(); if (RegExp('\\b${RegExp.escape(key)}\\b').hasMatch(upper)) { return true; } final compactLine = upper.replaceAll(RegExp(r'[^A-Z0-9]'), ''); final compactKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), ''); return compactKey.length >= 5 && compactLine.contains(compactKey); } bool isStandaloneKeywordLine(String line, List keywords) { final compact = line.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); for (final keyword in keywords) { final key = keyword.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); if (key.isEmpty) continue; if (compact == key) return true; if (compact.startsWith(key) && compact.length <= key.length + 3) { return true; } } return false; } bool matchesAny(String line, List keywords) { for (final keyword in keywords) { if (matchesKeyword(line, keyword)) return true; } return false; } } ReceiptParseConfig? _loadedConfig; ReceiptParseConfig get receiptParseConfig { if (_loadedConfig != null) return _loadedConfig!; final file = File(ReceiptParseConfig.assetPath); if (file.existsSync()) { _loadedConfig = ReceiptParseConfig.fromJson( jsonDecode(file.readAsStringSync()) as Map, ); return _loadedConfig!; } throw StateError( 'Receipt keyword config was not loaded. Call ReceiptParser.loadConfig().', ); } void setReceiptParseConfig(ReceiptParseConfig config) { _loadedConfig = config; } Future loadReceiptParseConfig() async { try { final raw = await rootBundle.loadString(ReceiptParseConfig.assetPath); _loadedConfig = ReceiptParseConfig.fromJson( jsonDecode(raw) as Map, ); } catch (_) { final file = File(ReceiptParseConfig.assetPath); if (!file.existsSync()) rethrow; _loadedConfig = ReceiptParseConfig.fromJson( jsonDecode(file.readAsStringSync()) as Map, ); } }