improve parsing

This commit is contained in:
davmar
2026-09-02 12:35:35 +02:00
parent d532af6111
commit e98fb0e7f4
9 changed files with 294 additions and 680 deletions
+23 -3
View File
@@ -23,21 +23,41 @@ For each receipt:
| `store` | Best-effort store from the header + `known_stores` | | `store` | Best-effort store from the header + `known_stores` |
| `receiptTotal` | First grand-total amount after the items | | `receiptTotal` | First grand-total amount after the items |
| `totalDiscount` | Receipt-level `Totaal korting` (and similar) | | `totalDiscount` | Receipt-level `Totaal korting` (and similar) |
| `validationPassed` | Item sum matches `receiptTotal` within €0.01 | | `validationPassed` | Item sum matches `receiptTotal` within €0.01, item count matches when printed, and no line needs manual review |
| `items[].sourceBounds` | Normalized rect on the receipt photo for tap-to-highlight in Review |
| `items[].needsManualReview` | Parser could not resolve price/qty confidently |
| `expectedItemCount` | From `Aantal Artikel`-style lines when found |
| `ocrImageWidth` / `ocrImageHeight` | Image size used to normalize bounds |
If validation fails, Review shows: *Parsed totals do not match the receipt.* If validation fails, Review shows a banner asking you to check items before saving.
--- ---
## Pipeline ## Pipeline
### Primary: spatial (ML Kit bounding boxes)
Used automatically after scanning a receipt photo.
1. **Extract elements** from ML Kit `RecognizedText` (blocks → lines → elements), keeping each element's pixel bounding box.
2. **Cluster rows** by Y-center (threshold ≈ 1.2× median element height — see `spatial_parsing` in the JSON config).
3. **Classify rows** with column logic: right ~25% of content width is the price column; pure `N X price` rows are quantity modifiers (checked first).
4. **Build items** with a one-row lookahead buffer (Jumbo split layout + Kruidvat inline layout).
5. **Validate** totals, printed item count, and manual-review flags.
Implementation: `ReceiptOcr`, `ReceiptRowBuilder`, `ReceiptSpatialParser` in `lib/services/`.
### Fallback: flat text
`ReceiptParser.parseReceipt(String)` for tests and when only flattened OCR text exists.
1. **Preprocess** each OCR line: trim, collapse spaces, drop empty lines. 1. **Preprocess** each OCR line: trim, collapse spaces, drop empty lines.
2. **Choose a layout** 2. **Choose a layout**
- **Sequential** (default): name, price, quantity and discount appear in - **Sequential** (default): name, price, quantity and discount appear in
reading order. Jumbo (printed), Lidl, Kruidvat, World Toko. reading order. Jumbo (printed), Lidl, Kruidvat, World Toko.
- **Column OCR fallback:** ML Kit sometimes dumps every name, then every - **Column OCR fallback:** ML Kit sometimes dumps every name, then every
amount. Detected when there is a run of 4+ price-only lines *and* enough amount. Detected when there is a run of 4+ price-only lines *and* enough
name-like lines. Used for the Jumbo sample dump in tests. name-like lines.
3. **Classify** each line (first match wins) using the keyword JSON. 3. **Classify** each line (first match wins) using the keyword JSON.
4. **Build items** (pending name, then quantity/discount/price). 4. **Build items** (pending name, then quantity/discount/price).
5. **Validate** against the receipt total when one was found. 5. **Validate** against the receipt total when one was found.
+15
View File
@@ -1,6 +1,8 @@
/// A parsed receipt row shown on the review screen. /// A parsed receipt row shown on the review screen.
library; library;
import 'receipt_bounds.dart';
class LineItem { class LineItem {
LineItem({ LineItem({
required this.id, required this.id,
@@ -9,6 +11,8 @@ class LineItem {
this.quantity, this.quantity,
this.unitPrice, this.unitPrice,
this.discount = 0, this.discount = 0,
this.sourceBounds,
this.needsManualReview = false,
}); });
final String id; final String id;
@@ -22,6 +26,12 @@ class LineItem {
/// Negative amount when a discount was folded into [price]. /// Negative amount when a discount was folded into [price].
final double discount; final double discount;
/// Region on the receipt photo (normalized 0–1) used when parsing this line.
final ReceiptBounds? sourceBounds;
/// True when the parser could not confidently resolve price/qty for this line.
final bool needsManualReview;
bool get hasDiscount => discount != 0; bool get hasDiscount => discount != 0;
/// List price before a folded discount. Equals [price] when there is none. /// List price before a folded discount. Equals [price] when there is none.
@@ -37,7 +47,10 @@ class LineItem {
int? quantity, int? quantity,
double? unitPrice, double? unitPrice,
double? discount, double? discount,
ReceiptBounds? sourceBounds,
bool? needsManualReview,
bool clearQuantity = false, bool clearQuantity = false,
bool clearSourceBounds = false,
}) { }) {
return LineItem( return LineItem(
id: id ?? this.id, id: id ?? this.id,
@@ -46,6 +59,8 @@ class LineItem {
quantity: clearQuantity ? null : (quantity ?? this.quantity), quantity: clearQuantity ? null : (quantity ?? this.quantity),
unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice), unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice),
discount: discount ?? this.discount, discount: discount ?? this.discount,
sourceBounds: clearSourceBounds ? null : (sourceBounds ?? this.sourceBounds),
needsManualReview: needsManualReview ?? this.needsManualReview,
); );
} }
} }
+62 -8
View File
@@ -13,6 +13,7 @@ import 'utils/money.dart';
import 'utils/supermarkets.dart'; import 'utils/supermarkets.dart';
import 'widgets/discounted_price.dart'; import 'widgets/discounted_price.dart';
import 'widgets/item_edit_sheet.dart'; import 'widgets/item_edit_sheet.dart';
import 'widgets/receipt_highlight_overlay.dart';
import 'widgets/store_tag.dart'; import 'widgets/store_tag.dart';
import 'widgets/tag_editor.dart'; import 'widgets/tag_editor.dart';
@@ -26,6 +27,8 @@ class ReviewScreen extends StatefulWidget {
this.supermarketNames = kSupermarkets, this.supermarketNames = kSupermarkets,
this.needsReview = false, this.needsReview = false,
this.detectedStore, this.detectedStore,
this.ocrImageWidth,
this.ocrImageHeight,
}); });
final List<LineItem> items; final List<LineItem> items;
@@ -35,6 +38,8 @@ class ReviewScreen extends StatefulWidget {
final List<String> supermarketNames; final List<String> supermarketNames;
final bool needsReview; final bool needsReview;
final String? detectedStore; final String? detectedStore;
final double? ocrImageWidth;
final double? ocrImageHeight;
@override @override
State<ReviewScreen> createState() => _ReviewScreenState(); State<ReviewScreen> createState() => _ReviewScreenState();
@@ -49,6 +54,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
final _tripName = TextEditingController(); final _tripName = TextEditingController();
List<String> _tags = []; List<String> _tags = [];
List<String> _tagSuggestions = []; List<String> _tagSuggestions = [];
String? _highlightedItemId;
@override @override
void initState() { void initState() {
@@ -88,6 +94,11 @@ class _ReviewScreenState extends State<ReviewScreen> {
return _supermarket; return _supermarket;
} }
bool get _hasReceipt {
final path = widget.imagePath;
return path != null && path.isNotEmpty && File(path).existsSync();
}
Future<void> _edit({LineItem? existing}) async { Future<void> _edit({LineItem? existing}) async {
final result = await showItemEditSheet(context, item: existing); final result = await showItemEditSheet(context, item: existing);
if (result == null || !mounted) return; if (result == null || !mounted) return;
@@ -98,9 +109,20 @@ class _ReviewScreenState extends State<ReviewScreen> {
} else { } else {
final index = _items.indexWhere((item) => item.id == existing.id); final index = _items.indexWhere((item) => item.id == existing.id);
if (index >= 0) { if (index >= 0) {
_items[index] = result; _items[index] = result.copyWith(
sourceBounds: existing.sourceBounds,
needsManualReview: false,
);
} }
} }
_highlightedItemId = null;
});
}
void _toggleHighlight(LineItem item) {
if (item.sourceBounds == null || !_hasReceipt) return;
setState(() {
_highlightedItemId = _highlightedItemId == item.id ? null : item.id;
}); });
} }
@@ -181,9 +203,12 @@ class _ReviewScreenState extends State<ReviewScreen> {
names.add(_supermarket!); names.add(_supermarket!);
} }
final receiptPath = widget.imagePath; final receiptPath = widget.imagePath;
final hasReceipt = receiptPath != null && final highlighted = _highlightedItemId == null
receiptPath.isNotEmpty && ? null
File(receiptPath).existsSync(); : _items.cast<LineItem?>().firstWhere(
(item) => item?.id == _highlightedItemId,
orElse: () => null,
);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -207,7 +232,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Text( child: Text(
'Parsed totals do not match the receipt. Check the items before saving.', 'Item count or totals do not match the receipt, or some lines need manual review. Check highlighted items before saving.',
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer, color: theme.colorScheme.onErrorContainer,
), ),
@@ -325,6 +350,29 @@ class _ReviewScreenState extends State<ReviewScreen> {
}); });
}, },
child: ListTile( child: ListTile(
selected: _highlightedItemId == item.id,
selectedTileColor: theme.colorScheme.primaryContainer
.withValues(alpha: 0.35),
leading: item.sourceBounds != null && _hasReceipt
? IconButton(
icon: Icon(
_highlightedItemId == item.id
? Icons.visibility
: Icons.visibility_outlined,
size: 20,
),
tooltip: 'Show on receipt',
onPressed: _saving
? null
: () => _toggleHighlight(item),
)
: item.needsManualReview
? Icon(
Icons.flag_outlined,
color: theme.colorScheme.error,
size: 20,
)
: null,
title: Text(item.name), title: Text(item.name),
subtitle: _itemSubtitle(item), subtitle: _itemSubtitle(item),
trailing: DiscountedPrice(item: item), trailing: DiscountedPrice(item: item),
@@ -337,18 +385,24 @@ class _ReviewScreenState extends State<ReviewScreen> {
], ],
), ),
), ),
if (hasReceipt) if (_hasReceipt)
ExpansionTile( ExpansionTile(
initiallyExpanded: highlighted?.sourceBounds != null,
title: const Text('See receipt'), title: const Text('See receipt'),
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: GestureDetector( child: highlighted?.sourceBounds != null
? ReceiptHighlightOverlay(
imagePath: receiptPath!,
bounds: highlighted!.sourceBounds!,
)
: GestureDetector(
onTap: _openReceipt, onTap: _openReceipt,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Image.file( child: Image.file(
File(receiptPath), File(receiptPath!),
fit: BoxFit.contain, fit: BoxFit.contain,
height: 220, height: 220,
width: double.infinity, width: double.infinity,
+34 -4
View File
@@ -2,6 +2,7 @@
library; library;
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -102,12 +103,22 @@ class _ScanScreenState extends State<ScanScreen> {
}); });
final recognizer = TextRecognizer(script: TextRecognitionScript.latin); final recognizer = TextRecognizer(script: TextRecognitionScript.latin);
RecognizedText? recognized;
double imageWidth = 0;
double imageHeight = 0;
try { try {
final bytes = await File(imagePath).readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
imageWidth = frame.image.width.toDouble();
imageHeight = frame.image.height.toDouble();
frame.image.dispose();
final inputImage = InputImage.fromFilePath(imagePath); final inputImage = InputImage.fromFilePath(imagePath);
final recognized = await recognizer.processImage(inputImage); recognized = await recognizer.processImage(inputImage);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_extractedText = recognized.text.trim(); _extractedText = recognized!.text.trim();
if (_extractedText.isEmpty) { if (_extractedText.isEmpty) {
_error = 'No text found in this image.'; _error = 'No text found in this image.';
} }
@@ -131,8 +142,14 @@ class _ScanScreenState extends State<ScanScreen> {
} }
} }
if (mounted && _extractedText.isNotEmpty) { if (mounted && _extractedText.isNotEmpty && recognized != null) {
await _openReview(_extractedText); await _openReview(
_extractedText,
imagePath: imagePath,
recognized: recognized,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
} }
} }
@@ -150,12 +167,23 @@ class _ScanScreenState extends State<ScanScreen> {
String rawText, { String rawText, {
List<LineItem>? items, List<LineItem>? items,
String? imagePath, String? imagePath,
RecognizedText? recognized,
double? imageWidth,
double? imageHeight,
}) async { }) async {
final parsedItems = items; final parsedItems = items;
ReceiptParseResult? parsed; ReceiptParseResult? parsed;
if (parsedItems == null) { if (parsedItems == null) {
if (recognized != null && imageWidth != null && imageHeight != null) {
parsed = ReceiptParser.parseRecognizedText(
recognized,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
} else {
parsed = ReceiptParser.parseReceipt(rawText); parsed = ReceiptParser.parseReceipt(rawText);
} }
}
if (!mounted) return; if (!mounted) return;
final names = final names =
SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ?? SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ??
@@ -170,6 +198,8 @@ class _ScanScreenState extends State<ScanScreen> {
supermarketNames: names.toList(), supermarketNames: names.toList(),
needsReview: parsed != null && !parsed.validationPassed, needsReview: parsed != null && !parsed.validationPassed,
detectedStore: parsed?.store, detectedStore: parsed?.store,
ocrImageWidth: parsed?.ocrImageWidth,
ocrImageHeight: parsed?.ocrImageHeight,
), ),
), ),
); );
+26
View File
@@ -25,6 +25,8 @@ class ReceiptParseConfig {
required this.trailingCategoryLetter, required this.trailingCategoryLetter,
required this.noiseKeywords, required this.noiseKeywords,
required this.itemCountKeywords, required this.itemCountKeywords,
this.rowClusterHeightFactor = 1.2,
this.priceColumnWidthFraction = 0.25,
}); });
final List<String> knownStores; final List<String> knownStores;
@@ -44,6 +46,8 @@ class ReceiptParseConfig {
final RegExp trailingCategoryLetter; final RegExp trailingCategoryLetter;
final List<String> noiseKeywords; final List<String> noiseKeywords;
final List<String> itemCountKeywords; final List<String> itemCountKeywords;
final double rowClusterHeightFactor;
final double priceColumnWidthFraction;
factory ReceiptParseConfig.fromJson(Map<String, dynamic> json) { factory ReceiptParseConfig.fromJson(Map<String, dynamic> json) {
List<String> list(String section, [String key = 'list']) { List<String> list(String section, [String key = 'list']) {
@@ -99,9 +103,31 @@ class ReceiptParseConfig {
), ),
noiseKeywords: list('noise_footer_keywords'), noiseKeywords: list('noise_footer_keywords'),
itemCountKeywords: list('line_item_count_keywords'), itemCountKeywords: list('line_item_count_keywords'),
rowClusterHeightFactor: _spatialDouble(
json,
'row_cluster_height_factor',
fallback: 1.2,
),
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'; static const assetPath = 'receipt-parsing-keywords.json';
/// True if [line] matches a config keyword. /// True if [line] matches a config keyword.
+23 -590
View File
@@ -1,609 +1,42 @@
/// Turns raw OCR text into [LineItem]s using receipt-parsing-keywords.json. /// Receipt parsing entry point: spatial OCR pipeline with text fallback.
library; library;
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import '../models/line_item.dart'; import '../models/line_item.dart';
import '../utils/money.dart'; import '../models/ocr_element.dart';
import 'receipt_ocr.dart';
import 'receipt_parse_config.dart'; import 'receipt_parse_config.dart';
export 'receipt_parse_config.dart' show loadReceiptParseConfig; export 'receipt_parse_config.dart' show loadReceiptParseConfig;
export 'receipt_text_parser.dart' show ReceiptParseResult;
/// Result of a full receipt parse, including totals used for review. import 'receipt_spatial_parser.dart';
class ReceiptParseResult { import 'receipt_text_parser.dart';
const ReceiptParseResult({
required this.items,
this.store,
this.receiptTotal,
this.totalDiscount = 0,
this.validationPassed = true,
});
final List<LineItem> items;
final String? store;
final double? receiptTotal;
final double totalDiscount;
final bool validationPassed;
}
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 ReceiptParser { class ReceiptParser {
ReceiptParser._(); ReceiptParser._();
static int _nextId = 0;
static String _id() => 'item-${++_nextId}';
static Future<void> loadConfig() => loadReceiptParseConfig(); static Future<void> loadConfig() => loadReceiptParseConfig();
static List<LineItem> parse(String rawText) => parseReceipt(rawText).items; static List<LineItem> parse(String rawText) => parseReceipt(rawText).items;
static ReceiptParseResult parseReceipt(String rawText) { /// Fallback when only flattened OCR text is available (tests, re-parse).
final config = receiptParseConfig; static ReceiptParseResult parseReceipt(String rawText) =>
final lines = rawText ReceiptTextParser.parseReceipt(rawText);
.split('\n')
.map(_preprocess)
.where((line) => line.isNotEmpty)
.toList();
if (_looksLikeColumnOcr(lines, config)) { /// Primary path: positioned ML Kit elements → row clustering → lookahead.
return _parseColumns(lines, config, rawText); static ReceiptParseResult parseReceiptFromOcr(OcrDocument document) =>
} ReceiptSpatialParser.parse(document);
return _parseSequential(lines, config, rawText);
}
static String _preprocess(String line) => static ReceiptParseResult parseRecognizedText(
line.trim().replaceAll(RegExp(r'\s+'), ' '); RecognizedText recognized, {
required double imageWidth,
static String _stripTax(String line, ReceiptParseConfig config) { required double imageHeight,
return line.replaceFirst(config.trailingCategoryLetter, '').trim();
}
static bool _looksLikeColumnOcr(
List<String> 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<String> lines,
ReceiptParseConfig config,
String rawText,
) {
final items = <LineItem>[];
_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,
);
}
static ReceiptParseResult _parseColumns(
List<String> lines,
ReceiptParseConfig config,
String rawText,
) {
final names = <({String name, int? quantity, double? unitPrice})>[];
final prices = <double>[];
final discountSlots = <int>[];
final discounts = <double>[];
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 = <LineItem>[
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<double>(0, (sum, value) => sum + value);
totalDiscount +=
_readAmountAfterKeyword(lines, config, config.totalDiscountKeywords) ??
0;
return _result(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
);
}
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<LineItem> items,
required String rawText,
double? receiptTotal,
double totalDiscount = 0,
}) { }) {
final kept = items final document = ReceiptOcr.fromRecognizedText(
.where((item) => item.name.trim().isNotEmpty && item.price > 0) recognized,
.toList(); imageWidth: imageWidth,
final sum = roundMoney( imageHeight: imageHeight,
kept.fold<double>(0, (total, item) => total + item.price),
); );
final passed = return parseReceiptFromOcr(document);
receiptTotal == null ||
(sum - receiptTotal).abs() <= 0.01 ||
(sum + totalDiscount - receiptTotal).abs() <= 0.01;
return ReceiptParseResult(
items: kept,
store: _detectStore(rawText),
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
validationPassed: passed,
);
}
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<String> lines,
ReceiptParseConfig config,
List<String> 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);
} }
} }
+2
View File
@@ -170,6 +170,8 @@ class _ItemEditSheetState extends State<ItemEditSheet> {
discount: widget.item?.price == roundMoney(resolvedTotal) discount: widget.item?.price == roundMoney(resolvedTotal)
? widget.item?.discount ?? 0 ? widget.item?.discount ?? 0
: 0, : 0,
sourceBounds: widget.item?.sourceBounds,
needsManualReview: false,
), ),
); );
} }
+6
View File
@@ -182,5 +182,11 @@
"ART.", "ART.",
"ARTIKELEN" "ARTIKELEN"
] ]
},
"spatial_parsing": {
"_comment": "Bounding-box pipeline settings. row_cluster_height_factor multiplies median element height when grouping rows. price_column_width_fraction is the right-hand slice of receipt content width treated as the price column.",
"row_cluster_height_factor": 1.2,
"price_column_width_fraction": 0.25
} }
} }
+92 -64
View File
@@ -16,30 +16,53 @@ without assuming a single fixed format.
## 1. Input assumptions ## 1. Input assumptions
- Input is raw OCR text, one line per detected text line, in top-to-bottom - Input is structured OCR from ML Kit: **blocks → lines → elements**, each
order as it appeared on the receipt. element carrying its own pixel bounding box (`left`, `top`, `right`,
`bottom`) on the receipt image. Do **not** rely on the flattened `.text`
property for parsing — it discards the two-column layout.
- Prices use European format: comma as decimal separator (e.g. `2,89`), - Prices use European format: comma as decimal separator (e.g. `2,89`),
period as thousands separator if present (rare on receipts this size). period as thousands separator if present (rare on receipts this size).
- OCR may introduce noise: misread characters, extra/missing spaces, - OCR may introduce noise: misread characters, extra/missing spaces,
occasional dropped or merged lines. The parser should be tolerant of occasional dropped or merged lines. The parser should be tolerant of
minor spacing irregularities but does not need to do spell-correction. minor spacing irregularities but does not need to do spell-correction.
- Receipt photos vary in resolution and crop; all column thresholds must be
computed **relative to the detected content width**, not as fixed pixels.
## 2. Preprocessing (run before classification) ## 2. Preprocessing (run before classification)
1. Trim leading/trailing whitespace on every line; collapse multiple 1. Extract every text **element** with its bounding box from ML Kit.
internal spaces to one. 2. **Rebuild rows** — do not trust ML Kit's line segmentation:
2. Normalize decimal separators: treat `,` as the decimal point in any - Sort elements by Y-center (top to bottom).
number matching `\d+[,.]\d{2}`. Convert to a standard `.` internally - Cluster into rows where Y-centers fall within one line height of each
for numeric storage, but remember the original format was comma-based other. Threshold = `median_element_height × row_cluster_height_factor`
(useful for locale-aware re-parsing if needed). (default factor **1.2**, configurable in `spatial_parsing`).
3. Drop fully empty lines. - Within each row, sort elements left-to-right by X position.
4. Do NOT drop short lines outright (e.g. a lone `BROCCOLI` or a lone 3. Join element text within a row (single spaces) for keyword/regex checks.
4. Trim leading/trailing whitespace; collapse multiple internal spaces to one.
5. Normalize decimal separators: treat `,` as the decimal point in any number
matching `\d+[,.]\d{2}`. Convert to `.` internally for storage.
6. Do NOT drop short rows outright (e.g. a lone `BROCCOLI` or a lone
`2 X 0,95`) — both are meaningful in different receipt layouts. `2 X 0,95`) — both are meaningful in different receipt layouts.
## 3. Core line classification ## 3. Core row classification
Classify every line into one of these categories, in this priority order Classify every **row** (not flat OCR line) into one of these categories.
(check top-down, first match wins): Within a row, use **X-position column logic** alongside regex:
- Compute content width = rightmost − leftmost element across the receipt.
- **Price column** = rightmost `price_column_width_fraction` (default **25%**)
of that width. A row has a price when a token in the price column matches
`\d+[,.]\d{2}`. Everything left of the price column is the candidate name.
- A row with no price-column match is **name-only**.
Check categories in this priority order (first match wins):
### 3.0 QUANTITY_MODIFIER (checked first — unconditional)
- If the **entire row** matches (not a substring):
`^\d+\s*[xX]\s*\d+[,.]\d{2}\s*(PER\s+STUK|PER\s+KG|ST\.?|STUKS?)?$`
- Classify as QUANTITY_MODIFIER **unconditionally**. A pure `N X price` row
can never be a standalone product, even if a price-column token is present.
### 3.1 STORE_HEADER ### 3.1 STORE_HEADER
- The first 1–5 lines of the receipt, before the first recognizable - The first 1–5 lines of the receipt, before the first recognizable
@@ -72,13 +95,13 @@ Classify every line into one of these categories, in this priority order
- This is the "complete" case: name + price on the same line, quantity - This is the "complete" case: name + price on the same line, quantity
of 1 implied unless a QUANTITY_MODIFIER line follows (section 3.4). of 1 implied unless a QUANTITY_MODIFIER line follows (section 3.4).
### 3.4 QUANTITY_MODIFIER (sub-line, belongs to previous or next ITEM) ### 3.4 QUANTITY_MODIFIER (sub-row, belongs to previous or next ITEM)
- A line matching the pattern: `<int> X <price>` optionally followed by
a unit word (e.g. `PER STUK`), e.g.: See **§3.0** — pure `N X price` rows are detected before any other type.
- `2 X 2,79` The rules below describe how they attach to items:
- `2 X 3,99 PER STUK`
- This line does NOT introduce a new product — it modifies the item on - This row does NOT introduce a new product — it modifies the item on
an adjacent line. an adjacent row.
- Two observed variants — the parser must detect which applies: - Two observed variants — the parser must detect which applies:
- **Variant A (Jumbo-style):** product name appears on its own line - **Variant A (Jumbo-style):** product name appears on its own line
with NO price. The next line is `N X unit_price`. The line total with NO price. The next line is `N X unit_price`. The line total
@@ -154,66 +177,71 @@ Classify every line into one of these categories, in this priority order
## 4. Item construction algorithm (pseudocode) ## 4. Item construction algorithm (pseudocode)
Uses a **one-row lookahead buffer** — do not emit an item until the next
row is inspected (or the receipt ends).
``` ```
items = [] items = []
pending_item = None # name found, price/qty not yet resolved pending_item = None # name found, price/qty not yet resolved
for line in classified_lines: for row in classified_rows:
if line.type == ITEM: if row.type == QUANTITY_MODIFIER:
if pending_item:
items.append(pending_item) # close out previous item first
pending_item = { name: line.name, unit_price: line.price,
quantity: 1, line_total: line.price,
discount: 0 }
elif line.type == QUANTITY_MODIFIER:
if pending_item and pending_item.unit_price is None:
# Variant A: name-only line was pending, this line completes it
pending_item.quantity = line.qty
pending_item.unit_price = line.price
pending_item.line_total = line.qty * line.price
elif pending_item:
# Variant B: name+price already set, this line refines/
# confirms it (overwrite quantity/unit price, recompute total)
pending_item.quantity = line.qty
pending_item.unit_price = line.price
pending_item.line_total = line.qty * line.price
else:
# Orphan quantity line with no preceding item — flag for
# manual review rather than silently dropping
flag_for_review(line)
elif line.type == DISCOUNT:
if pending_item:
pending_item.discount += line.amount # amount is negative
pending_item.line_total += line.amount
else:
receipt.total_discount += line.amount
elif line.type in (SUBTOTAL, TOTAL):
if pending_item: if pending_item:
pending_item.quantity = row.qty
pending_item.unit_price = row.price
pending_item.line_total = row.qty * row.price
merge bounding boxes from row into pending_item
items.append(pending_item) items.append(pending_item)
pending_item = None pending_item = None
if is_grand_total(line): continue # orphan modifier without pending → ignore
receipt.total = line.amount
break # stop item parsing here
# catch any trailing pending item if receipt ended oddly if row.type == NAME_ONLY:
if pending_item: if pending_item:
items.append(pending_item) items.append(pending_item with needs_review if no price)
pending_item = { name: row.name, unit_price: None, quantity: 1,
line_total: None, discount: 0, bounds: row.bounds }
peek next row:
if next.type == QUANTITY_MODIFIER → defer emit (handled above)
else → append pending_item flagged needs_review, clear pending
continue
if row.type == ITEM:
if pending_item:
items.append(pending_item with needs_review if no price)
pending_item = { name: row.name, unit_price: row.price,
quantity: 1, line_total: row.price,
discount: 0, bounds: row.bounds }
peek next row:
if next.type == QUANTITY_MODIFIER → defer emit (modifier overrides qty/unit)
else → append pending_item, clear pending
continue
if row.type == DISCOUNT:
... (unchanged: attach to pending_item or receipt.total_discount)
if row.type in (SUBTOTAL, TOTAL):
flush pending_item
if is_grand_total(row):
receipt.total = row.amount
break
flush any trailing pending_item
``` ```
Each emitted item keeps **source bounding box(es)** (normalized 0–1 on the
receipt image) for highlight overlays in the review UI.
## 5. Validation pass (do this after parsing, before showing results to user) ## 5. Validation pass (do this after parsing, before showing results to user)
1. Sum `line_total` across all parsed items. 1. Sum `line_total` across all parsed items.
2. Add `receipt.total_discount` if it wasn't already folded into 2. Add `receipt.total_discount` if it wasn't already folded into
individual item discounts. individual item discounts.
3. Compare the result to `receipt.total` (the grand total line). 3. Compare the result to `receipt.total` (the grand total line).
4. If they match within 0.01 → high confidence, no flags needed. 4. If the receipt prints an item count (e.g. `Aantal Artikel: N`), compare
5. If they don't match → mark the WHOLE receipt as "needs review" and `N` to the number of parsed items.
surface it clearly in the review/edit screen (don't just flag 5. If totals match within 0.01 **and** item count matches (when present)
individual lines — a mismatch means something in the whole parse is **and** no item is flagged `needs_manual_review` → high confidence.
probably off, e.g. a missed line or a misclassified discount). 6. If any check fails → mark the **whole receipt** as "needs review".
## 6. Known store-specific quirks to encode as configurable rules ## 6. Known store-specific quirks to encode as configurable rules