improve parsing
This commit is contained in:
+23
-3
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-18
@@ -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,24 +385,30 @@ 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
|
||||||
onTap: _openReceipt,
|
? ReceiptHighlightOverlay(
|
||||||
child: ClipRRect(
|
imagePath: receiptPath!,
|
||||||
borderRadius: BorderRadius.circular(12),
|
bounds: highlighted!.sourceBounds!,
|
||||||
child: Image.file(
|
)
|
||||||
File(receiptPath),
|
: GestureDetector(
|
||||||
fit: BoxFit.contain,
|
onTap: _openReceipt,
|
||||||
height: 220,
|
child: ClipRRect(
|
||||||
width: double.infinity,
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
child: Image.file(
|
||||||
),
|
File(receiptPath!),
|
||||||
),
|
fit: BoxFit.contain,
|
||||||
|
height: 220,
|
||||||
|
width: double.infinity,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
+35
-5
@@ -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,11 +167,22 @@ 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) {
|
||||||
parsed = ReceiptParser.parseReceipt(rawText);
|
if (recognized != null && imageWidth != null && imageHeight != null) {
|
||||||
|
parsed = ReceiptParser.parseRecognizedText(
|
||||||
|
recognized,
|
||||||
|
imageWidth: imageWidth,
|
||||||
|
imageHeight: imageHeight,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parsed = ReceiptParser.parseReceipt(rawText);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final names =
|
final names =
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user