Files
receipity/lib/services/receipt_parse_config.dart
T
2026-09-03 15:09:17 +02:00

205 lines
6.6 KiB
Dart

/// 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<String> knownStores;
final List<String> columnHeaderKeywords;
final RegExp separatorPattern;
final RegExp quantityStandalone;
final RegExp quantityInline;
final List<String> discountKeywords;
final RegExp negativeAmount;
final List<String> totalDiscountKeywords;
final List<String> subtotalKeywords;
final List<String> grandTotalKeywords;
final List<String> taxInclusiveTotalKeywords;
final List<String> paymentKeywords;
final List<String> taxKeywords;
final RegExp categoryRow;
final RegExp trailingCategoryLetter;
final List<String> noiseKeywords;
final List<String> itemCountKeywords;
final double rowClusterHeightFactor;
final double priceColumnWidthFraction;
factory ReceiptParseConfig.fromJson(Map<String, dynamic> json) {
List<String> 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<String, dynamic> 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<String> 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<String> 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<String, dynamic>,
);
return _loadedConfig!;
}
throw StateError(
'Receipt keyword config was not loaded. Call ReceiptParser.loadConfig().',
);
}
void setReceiptParseConfig(ReceiptParseConfig config) {
_loadedConfig = config;
}
Future<void> loadReceiptParseConfig() async {
try {
final raw = await rootBundle.loadString(ReceiptParseConfig.assetPath);
_loadedConfig = ReceiptParseConfig.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} catch (_) {
final file = File(ReceiptParseConfig.assetPath);
if (!file.existsSync()) rethrow;
_loadedConfig = ReceiptParseConfig.fromJson(
jsonDecode(file.readAsStringSync()) as Map<String, dynamic>,
);
}
}