5 Commits
Author SHA1 Message Date
davmar 8b040e83c0 multiple receipts in a trip 2026-09-03 15:09:17 +02:00
davmar ae42ba8193 improve parsing 3 2026-09-02 13:38:47 +02:00
davmar 98574af903 improve parsing 2 2026-09-02 12:35:41 +02:00
davmar e98fb0e7f4 improve parsing 2026-09-02 12:35:35 +02:00
davmar d532af6111 fix low count of items scanned 2026-09-01 17:52:52 +02:00
22 changed files with 2770 additions and 749 deletions
+23 -3
View File
@@ -23,21 +23,41 @@ For each receipt:
| `store` | Best-effort store from the header + `known_stores` |
| `receiptTotal` | First grand-total amount after the items |
| `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
### 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.
2. **Choose a layout**
- **Sequential** (default): name, price, quantity and discount appear in
reading order. Jumbo (printed), Lidl, Kruidvat, World Toko.
- **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
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.
4. **Build items** (pending name, then quantity/discount/price).
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.
library;
import 'receipt_bounds.dart';
class LineItem {
LineItem({
required this.id,
@@ -9,6 +11,8 @@ class LineItem {
this.quantity,
this.unitPrice,
this.discount = 0,
this.sourceBounds,
this.needsManualReview = false,
});
final String id;
@@ -22,6 +26,12 @@ class LineItem {
/// Negative amount when a discount was folded into [price].
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;
/// List price before a folded discount. Equals [price] when there is none.
@@ -37,7 +47,10 @@ class LineItem {
int? quantity,
double? unitPrice,
double? discount,
ReceiptBounds? sourceBounds,
bool? needsManualReview,
bool clearQuantity = false,
bool clearSourceBounds = false,
}) {
return LineItem(
id: id ?? this.id,
@@ -46,6 +59,8 @@ class LineItem {
quantity: clearQuantity ? null : (quantity ?? this.quantity),
unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice),
discount: discount ?? this.discount,
sourceBounds: clearSourceBounds ? null : (sourceBounds ?? this.sourceBounds),
needsManualReview: needsManualReview ?? this.needsManualReview,
);
}
}
+61
View File
@@ -0,0 +1,61 @@
/// One OCR text fragment with pixel bounding box on the receipt image.
library;
class OcrElement {
const OcrElement({
required this.text,
required this.left,
required this.top,
required this.right,
required this.bottom,
});
final String text;
final double left;
final double top;
final double right;
final double bottom;
double get centerX => (left + right) / 2;
double get centerY => (top + bottom) / 2;
double get height => (bottom - top).abs();
double get width => (right - left).abs();
}
/// One ML Kit text line with its word-level elements.
class OcrLine {
const OcrLine({
required this.text,
required this.elements,
required this.left,
required this.top,
required this.right,
required this.bottom,
});
final String text;
final List<OcrElement> elements;
final double left;
final double top;
final double right;
final double bottom;
double get centerY => (top + bottom) / 2;
}
/// All positioned elements from one ML Kit recognition pass.
class OcrDocument {
const OcrDocument({
required this.elements,
required this.lines,
required this.imageWidth,
required this.imageHeight,
required this.flattenedText,
});
final List<OcrElement> elements;
final List<OcrLine> lines;
final double imageWidth;
final double imageHeight;
final String flattenedText;
}
+57
View File
@@ -0,0 +1,57 @@
/// Normalized rectangle (0–1) on the receipt photo for review highlights.
library;
class ReceiptBounds {
const ReceiptBounds({
required this.left,
required this.top,
required this.right,
required this.bottom,
});
final double left;
final double top;
final double right;
final double bottom;
bool get isEmpty => right <= left || bottom <= top;
ReceiptBounds union(ReceiptBounds other) {
if (isEmpty) return other;
if (other.isEmpty) return this;
return ReceiptBounds(
left: left < other.left ? left : other.left,
top: top < other.top ? top : other.top,
right: right > other.right ? right : other.right,
bottom: bottom > other.bottom ? bottom : other.bottom,
);
}
static ReceiptBounds? merge(Iterable<ReceiptBounds> bounds) {
ReceiptBounds? merged;
for (final box in bounds) {
if (box.isEmpty) continue;
merged = merged == null ? box : merged.union(box);
}
return merged;
}
static ReceiptBounds fromPixelRect({
required double left,
required double top,
required double right,
required double bottom,
required double imageWidth,
required double imageHeight,
}) {
if (imageWidth <= 0 || imageHeight <= 0) {
return const ReceiptBounds(left: 0, top: 0, right: 0, bottom: 0);
}
return ReceiptBounds(
left: left / imageWidth,
top: top / imageHeight,
right: right / imageWidth,
bottom: bottom / imageHeight,
);
}
}
+39
View File
@@ -0,0 +1,39 @@
/// One visually reconstructed receipt row from clustered OCR elements.
library;
import 'ocr_element.dart';
import 'receipt_bounds.dart';
enum ReceiptRowKind {
skip,
quantityModifier,
nameOnly,
item,
discount,
grandTotal,
totalDiscount,
}
class ReceiptRow {
const ReceiptRow({
required this.kind,
required this.text,
required this.nameText,
this.price,
this.quantity,
this.unitPrice,
this.lineTotal,
this.elements = const [],
this.bounds,
});
final ReceiptRowKind kind;
final String text;
final String nameText;
final double? price;
final int? quantity;
final double? unitPrice;
final double? lineTotal;
final List<OcrElement> elements;
final ReceiptBounds? bounds;
}
+28 -23
View File
@@ -263,29 +263,34 @@ class _ProductsScreenState extends State<ProductsScreen> {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: SegmentedButton<ProductSort>(
segments: const [
ButtonSegment(
value: ProductSort.name,
label: Text('A–Z'),
),
ButtonSegment(
value: ProductSort.preferred,
label: Text('Frequent'),
),
ButtonSegment(
value: ProductSort.favourite,
icon: Icon(Icons.star),
label: Text('Star'),
),
],
selected: {_sort},
onSelectionChanged: (selected) {
setState(() {
_sort = selected.first;
});
_load();
},
child: SizedBox(
width: double.infinity,
child: SegmentedButton<ProductSort>(
expandedInsets: EdgeInsets.zero,
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: ProductSort.name,
label: Text('A–Z'),
),
ButtonSegment(
value: ProductSort.preferred,
label: Text('Frequent'),
),
ButtonSegment(
value: ProductSort.favourite,
icon: Icon(Icons.star),
label: Text('Star'),
),
],
selected: {_sort},
onSelectionChanged: (selected) {
setState(() {
_sort = selected.first;
});
_load();
},
),
),
),
if (_nameIssues.isNotEmpty && !_editing)
+316 -83
View File
@@ -8,11 +8,13 @@ import 'package:flutter/material.dart';
import 'models/line_item.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'services/receipt_capture.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'utils/supermarkets.dart';
import 'widgets/discounted_price.dart';
import 'widgets/item_edit_sheet.dart';
import 'widgets/receipt_highlight_overlay.dart';
import 'widgets/store_tag.dart';
import 'widgets/tag_editor.dart';
@@ -26,6 +28,8 @@ class ReviewScreen extends StatefulWidget {
this.supermarketNames = kSupermarkets,
this.needsReview = false,
this.detectedStore,
this.ocrImageWidth,
this.ocrImageHeight,
});
final List<LineItem> items;
@@ -35,6 +39,8 @@ class ReviewScreen extends StatefulWidget {
final List<String> supermarketNames;
final bool needsReview;
final String? detectedStore;
final double? ocrImageWidth;
final double? ocrImageHeight;
@override
State<ReviewScreen> createState() => _ReviewScreenState();
@@ -49,12 +55,21 @@ class _ReviewScreenState extends State<ReviewScreen> {
final _tripName = TextEditingController();
List<String> _tags = [];
List<String> _tagSuggestions = [];
String? _highlightedItemId;
bool _showTripExtras = false;
late String _rawText;
String? _imagePath;
late bool _needsReview;
bool _addingReceipt = false;
@override
void initState() {
super.initState();
_items = List<LineItem>.from(widget.items);
_shoppedAt = DateTime.now();
_rawText = widget.rawText;
_imagePath = widget.imagePath;
_needsReview = widget.needsReview;
final detected = detectSupermarket(
widget.rawText,
names: widget.supermarketNames,
@@ -88,6 +103,57 @@ class _ReviewScreenState extends State<ReviewScreen> {
return _supermarket;
}
bool get _hasReceipt {
final path = _imagePath;
return path != null && path.isNotEmpty && File(path).existsSync();
}
Future<void> _addReceipt() async {
if (_saving || _addingReceipt) return;
setState(() => _addingReceipt = true);
try {
final captured = await captureAndParseReceipt(context);
if (captured == null || !mounted) return;
final keepExistingPhoto = _hasReceipt;
final merged = [
for (final item in captured.items)
keepExistingPhoto
? item.copyWith(clearSourceBounds: true)
: item,
];
setState(() {
_items.addAll(merged);
_needsReview = _needsReview || captured.needsReview;
if (_rawText.isEmpty) {
_rawText = captured.rawText;
} else if (captured.rawText.isNotEmpty) {
_rawText = '$_rawText\n\n---\n\n${captured.rawText}';
}
if (!keepExistingPhoto && captured.imagePath != null) {
_imagePath = captured.imagePath;
}
if (_supermarket == null && captured.detectedStore != null) {
_supermarket = captured.detectedStore;
}
_highlightedItemId = null;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
merged.length == 1
? 'Added 1 item from receipt.'
: 'Added ${merged.length} items from receipt.',
),
),
);
} finally {
if (mounted) setState(() => _addingReceipt = false);
}
}
Future<void> _edit({LineItem? existing}) async {
final result = await showItemEditSheet(context, item: existing);
if (result == null || !mounted) return;
@@ -98,9 +164,20 @@ class _ReviewScreenState extends State<ReviewScreen> {
} else {
final index = _items.indexWhere((item) => item.id == existing.id);
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;
});
}
@@ -115,18 +192,91 @@ class _ReviewScreenState extends State<ReviewScreen> {
setState(() => _shoppedAt = picked);
}
Future<void> _openReceipt() async {
final path = widget.imagePath;
Future<void> _openReceiptPanel() async {
final path = _imagePath;
if (path == null || path.isEmpty) return;
final file = File(path);
if (!file.existsSync()) return;
await showDialog<void>(
final highlighted = _highlightedItemId == null
? null
: _items.cast<LineItem?>().firstWhere(
(item) => item?.id == _highlightedItemId,
orElse: () => null,
);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(file, fit: BoxFit.contain),
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Receipt', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
if (highlighted?.sourceBounds != null)
ReceiptHighlightOverlay(
imagePath: path,
bounds: highlighted!.sourceBounds!,
maxHeight: 360,
)
else
GestureDetector(
onTap: () async {
await showDialog<void>(
context: context,
builder: (ctx) => Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(file, fit: BoxFit.contain),
),
),
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(file, fit: BoxFit.contain, height: 360),
),
),
],
),
),
);
},
);
}
Future<void> _openOcrPanel() async {
if (_rawText.isEmpty) return;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Raw OCR text', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.55,
),
child: SingleChildScrollView(
child: SelectableText(_rawText),
),
),
],
),
),
);
},
@@ -153,7 +303,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
supermarket: supermarket,
shoppedAt: _shoppedAt,
items: _items,
receiptImagePath: widget.imagePath,
receiptImagePath: _imagePath,
name: _tripName.text,
tags: _tags,
);
@@ -180,15 +330,19 @@ class _ReviewScreenState extends State<ReviewScreen> {
if (_supermarket != null && !names.contains(_supermarket)) {
names.add(_supermarket!);
}
final receiptPath = widget.imagePath;
final hasReceipt = receiptPath != null &&
receiptPath.isNotEmpty &&
File(receiptPath).existsSync();
final flaggedCount =
_items.where((item) => item.needsManualReview).length;
final reviewMessage = flaggedCount > 0
? 'Some lines could not be parsed confidently — '
'${flaggedCount == 1 ? '1 item is' : '$flaggedCount items are'} '
'marked below. Check them against the receipt before saving.'
: 'Item count or totals do not match the receipt. '
'Check the items below before saving.';
return Scaffold(
appBar: AppBar(
title: Text(
widget.rawText.isEmpty && widget.imagePath == null
_rawText.isEmpty && _imagePath == null
? 'New trip'
: 'Review items',
),
@@ -200,14 +354,14 @@ class _ReviewScreenState extends State<ReviewScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.needsReview) ...[
if (_needsReview) ...[
Material(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Parsed totals do not match the receipt. Check the items before saving.',
reviewMessage,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
@@ -248,43 +402,57 @@ class _ReviewScreenState extends State<ReviewScreen> {
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_today_outlined),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
_showTripExtras
? Icons.expand_less
: Icons.edit_note_outlined,
),
tooltip: _showTripExtras
? 'Hide trip name & tags'
: 'Trip name & tags',
onPressed: _saving
? null
: () => setState(
() => _showTripExtras = !_showTripExtras,
),
),
const Icon(Icons.calendar_today_outlined),
],
),
title: Text(formatDate(_shoppedAt)),
subtitle: const Text('Shopping date'),
onTap: _saving ? null : _pickDate,
),
if (_showTripExtras) ...[
const SizedBox(height: 8),
TextField(
controller: _tripName,
enabled: !_saving,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Trip name',
hintText: 'Optional, e.g. Weekly shop',
),
),
const SizedBox(height: 12),
TagEditor(
tags: _tags,
suggestions: _tagSuggestions,
enabled: !_saving,
onChanged: (tags) => setState(() => _tags = tags),
),
const SizedBox(height: 8),
],
],
),
),
Expanded(
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _tripName,
enabled: !_saving,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Trip name',
hintText: 'Optional, e.g. Weekly shop',
),
),
const SizedBox(height: 12),
TagEditor(
tags: _tags,
suggestions: _tagSuggestions,
enabled: !_saving,
onChanged: (tags) => setState(() => _tags = tags),
),
],
),
),
),
if (_items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
@@ -304,6 +472,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final item = _items[index];
final flagged = item.needsManualReview;
final onReceipt = _highlightedItemId == item.id;
return Dismissible(
key: ValueKey(item.id),
direction: DismissDirection.endToStart,
@@ -325,8 +495,44 @@ class _ReviewScreenState extends State<ReviewScreen> {
});
},
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: flagged
? BorderSide(
color: theme.colorScheme.error
.withValues(alpha: 0.55),
)
: BorderSide.none,
),
tileColor: flagged
? theme.colorScheme.errorContainer
.withValues(alpha: 0.35)
: onReceipt
? theme.colorScheme.primaryContainer
.withValues(alpha: 0.35)
: null,
leading: item.sourceBounds != null && _hasReceipt
? IconButton(
icon: Icon(
onReceipt
? Icons.visibility
: Icons.visibility_outlined,
size: 20,
),
tooltip: 'Show on receipt',
onPressed: _saving
? null
: () => _toggleHighlight(item),
)
: flagged
? Icon(
Icons.flag_outlined,
color: theme.colorScheme.error,
size: 20,
)
: null,
title: Text(item.name),
subtitle: _itemSubtitle(item),
subtitle: _itemSubtitle(item, flagged: flagged),
trailing: DiscountedPrice(item: item),
onTap: _saving ? null : () => _edit(existing: item),
),
@@ -337,39 +543,35 @@ class _ReviewScreenState extends State<ReviewScreen> {
],
),
),
if (hasReceipt)
ExpansionTile(
title: const Text('See receipt'),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: GestureDetector(
onTap: _openReceipt,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(receiptPath),
fit: BoxFit.contain,
height: 220,
width: double.infinity,
if (_hasReceipt || _rawText.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: Row(
children: [
if (_hasReceipt)
Expanded(
child: OutlinedButton.icon(
onPressed: _saving || _addingReceipt
? null
: _openReceiptPanel,
icon: const Icon(Icons.receipt_long_outlined, size: 18),
label: const Text('Receipt'),
),
),
),
),
],
),
if (widget.rawText.isNotEmpty)
ExpansionTile(
title: const Text('Raw OCR text'),
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 180),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SelectableText(widget.rawText),
),
),
],
if (_hasReceipt && _rawText.isNotEmpty)
const SizedBox(width: 8),
if (_rawText.isNotEmpty)
Expanded(
child: OutlinedButton.icon(
onPressed: _saving || _addingReceipt
? null
: _openOcrPanel,
icon: const Icon(Icons.text_snippet_outlined, size: 18),
label: const Text('OCR text'),
),
),
],
),
),
SafeArea(
child: Padding(
@@ -377,14 +579,40 @@ class _ReviewScreenState extends State<ReviewScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
OutlinedButton.icon(
onPressed: _saving ? null : () => _edit(),
icon: const Icon(Icons.add),
label: const Text('Add item'),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _saving || _addingReceipt
? null
: () => _edit(),
icon: const Icon(Icons.add),
label: const Text('Add item'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _saving || _addingReceipt
? null
: _addReceipt,
icon: _addingReceipt
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.receipt_long_outlined),
label: const Text('Add receipt'),
),
),
],
),
const SizedBox(height: 8),
FilledButton(
onPressed: _saving ? null : _confirm,
onPressed: _saving || _addingReceipt ? null : _confirm,
child: Text(
_saving ? 'Saving…' : 'Save trip · ${formatEuro(total)}',
),
@@ -398,8 +626,13 @@ class _ReviewScreenState extends State<ReviewScreen> {
);
}
Widget? _itemSubtitle(LineItem item) {
if (item.quantity == null) return null;
return Text('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}');
Widget? _itemSubtitle(LineItem item, {bool flagged = false}) {
final parts = <String>[];
if (flagged) parts.add('Needs review');
if (item.quantity != null) {
parts.add('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}');
}
if (parts.isEmpty) return null;
return Text(parts.join(' · '));
}
}
+35 -5
View File
@@ -2,6 +2,7 @@
library;
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -102,12 +103,22 @@ class _ScanScreenState extends State<ScanScreen> {
});
final recognizer = TextRecognizer(script: TextRecognitionScript.latin);
RecognizedText? recognized;
double imageWidth = 0;
double imageHeight = 0;
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 recognized = await recognizer.processImage(inputImage);
recognized = await recognizer.processImage(inputImage);
if (!mounted) return;
setState(() {
_extractedText = recognized.text.trim();
_extractedText = recognized!.text.trim();
if (_extractedText.isEmpty) {
_error = 'No text found in this image.';
}
@@ -131,8 +142,14 @@ class _ScanScreenState extends State<ScanScreen> {
}
}
if (mounted && _extractedText.isNotEmpty) {
await _openReview(_extractedText);
if (mounted && _extractedText.isNotEmpty && recognized != null) {
await _openReview(
_extractedText,
imagePath: imagePath,
recognized: recognized,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
}
}
@@ -150,11 +167,22 @@ class _ScanScreenState extends State<ScanScreen> {
String rawText, {
List<LineItem>? items,
String? imagePath,
RecognizedText? recognized,
double? imageWidth,
double? imageHeight,
}) async {
final parsedItems = items;
ReceiptParseResult? parsed;
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;
final names =
@@ -170,6 +198,8 @@ class _ScanScreenState extends State<ScanScreen> {
supermarketNames: names.toList(),
needsReview: parsed != null && !parsed.validationPassed,
detectedStore: parsed?.store,
ocrImageWidth: parsed?.ocrImageWidth,
ocrImageHeight: parsed?.ocrImageHeight,
),
),
);
+188
View File
@@ -0,0 +1,188 @@
/// Shared pick → crop → OCR → parse flow for adding a receipt to a trip.
library;
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import 'package:image_picker/image_picker.dart';
import '../crop_receipt_screen.dart';
import '../models/line_item.dart';
import 'receipt_parser.dart';
class CapturedReceiptParse {
const CapturedReceiptParse({
required this.items,
required this.rawText,
this.imagePath,
this.needsReview = false,
this.detectedStore,
});
final List<LineItem> items;
final String rawText;
final String? imagePath;
final bool needsReview;
final String? detectedStore;
}
/// Asks camera vs gallery, crops, runs OCR, and returns parsed items.
///
/// Returns null if the user cancels at any step.
Future<CapturedReceiptParse?> captureAndParseReceipt(
BuildContext context, {
ImagePicker? picker,
}) async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Take photo'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Pick from gallery'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
);
},
);
if (source == null || !context.mounted) return null;
final imagePicker = picker ?? ImagePicker();
final file = await imagePicker.pickImage(source: source, imageQuality: 95);
if (file == null || !context.mounted) return null;
final croppedPath = await Navigator.of(context).push<String>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => CropReceiptScreen(imagePath: file.path),
),
);
if (croppedPath == null || !context.mounted) return null;
return _runOcrAndParse(context, croppedPath);
}
Future<CapturedReceiptParse?> _runOcrAndParse(
BuildContext context,
String imagePath,
) async {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) {
return const PopScope(
canPop: false,
child: Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Reading receipt…'),
],
),
),
),
),
);
},
);
final recognizer = TextRecognizer(script: TextRecognitionScript.latin);
try {
final bytes = await File(imagePath).readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
final imageWidth = frame.image.width.toDouble();
final imageHeight = frame.image.height.toDouble();
frame.image.dispose();
final recognized = await recognizer.processImage(
InputImage.fromFilePath(imagePath),
);
final rawText = recognized.text.trim();
if (rawText.isEmpty) {
if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No text found in this image.')),
);
}
return null;
}
final parsed = ReceiptParser.parseRecognizedText(
recognized,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
if (parsed.items.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No products found on this receipt.')),
);
}
return null;
}
// Fresh ids so merged trips never collide with existing item keys.
final stamp = DateTime.now().microsecondsSinceEpoch;
final items = [
for (var i = 0; i < parsed.items.length; i++)
parsed.items[i].copyWith(id: 'merged-$stamp-$i'),
];
return CapturedReceiptParse(
items: items,
rawText: rawText,
imagePath: imagePath,
needsReview: !parsed.validationPassed,
detectedStore: parsed.store,
);
} on PlatformException catch (error) {
if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Could not read text from this image.'
'${error.message == null || error.message!.isEmpty ? '' : '\n${error.message}'}',
),
),
);
}
return null;
} catch (error) {
if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not read text from this image.\n$error')),
);
}
return null;
} finally {
await recognizer.close();
}
}
+81
View File
@@ -0,0 +1,81 @@
/// Extract positioned OCR elements from ML Kit structured output.
library;
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import '../models/ocr_element.dart';
class ReceiptOcr {
ReceiptOcr._();
/// Reads blocks → lines → elements. Falls back to line-level boxes when a
/// line has no element children.
static OcrDocument fromRecognizedText(
RecognizedText recognized, {
required double imageWidth,
required double imageHeight,
}) {
final elements = <OcrElement>[];
final lines = <OcrLine>[];
var maxRight = 0.0;
var maxBottom = 0.0;
for (final block in recognized.blocks) {
for (final line in block.lines) {
final lineElements = <OcrElement>[];
if (line.elements.isNotEmpty) {
for (final element in line.elements) {
final box = element.boundingBox;
lineElements.add(
OcrElement(
text: element.text,
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
),
);
if (box.right > maxRight) maxRight = box.right;
if (box.bottom > maxBottom) maxBottom = box.bottom;
}
} else {
final box = line.boundingBox;
lineElements.add(
OcrElement(
text: line.text,
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
),
);
if (box.right > maxRight) maxRight = box.right;
if (box.bottom > maxBottom) maxBottom = box.bottom;
}
elements.addAll(lineElements);
final box = line.boundingBox;
lines.add(
OcrLine(
text: line.text,
elements: lineElements,
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
),
);
}
}
final width = imageWidth > 0 ? imageWidth : maxRight;
final height = imageHeight > 0 ? imageHeight : maxBottom;
return OcrDocument(
elements: elements,
lines: lines,
imageWidth: width,
imageHeight: height,
flattenedText: recognized.text.trim(),
);
}
}
+33 -8
View File
@@ -25,6 +25,8 @@ class ReceiptParseConfig {
required this.trailingCategoryLetter,
required this.noiseKeywords,
required this.itemCountKeywords,
this.rowClusterHeightFactor = 0.55,
this.priceColumnWidthFraction = 0.25,
});
final List<String> knownStores;
@@ -44,6 +46,8 @@ class ReceiptParseConfig {
final RegExp trailingCategoryLetter;
final List<String> noiseKeywords;
final List<String> itemCountKeywords;
final double rowClusterHeightFactor;
final double priceColumnWidthFraction;
factory ReceiptParseConfig.fromJson(Map<String, dynamic> json) {
List<String> list(String section, [String key = 'list']) {
@@ -99,27 +103,48 @@ class ReceiptParseConfig {
),
noiseKeywords: list('noise_footer_keywords'),
itemCountKeywords: list('line_item_count_keywords'),
rowClusterHeightFactor: _spatialDouble(
json,
'row_cluster_height_factor',
fallback: 0.55,
),
priceColumnWidthFraction: _spatialDouble(
json,
'price_column_width_fraction',
fallback: 0.25,
),
);
}
static double _spatialDouble(
Map<String, dynamic> json,
String key, {
required double fallback,
}) {
final spatial = json['spatial_parsing'];
if (spatial is! Map) return fallback;
final value = spatial[key];
if (value is num && value > 0) return value.toDouble();
return fallback;
}
static const assetPath = 'receipt-parsing-keywords.json';
/// True if [line] matches a config keyword.
///
/// Short tokens (3 characters or fewer) use a word boundary so `PIN` does
/// not match `SPINAZIE`. Longer tokens use a case-insensitive substring,
/// and a letter-only compact form so OCR splits like `Betaal d:` still
/// match `BETAALD`.
/// Always uses word boundaries so `CHIP` does not match `CHIPS` and `PIN`
/// does not match `SPINAZIE`. A letter-only compact form is a fallback for
/// OCR splits like `Betaal d:` → `BETAALD` (only for keywords of length ≥ 5,
/// so short tokens like `CHIP` cannot match inside longer product words).
bool matchesKeyword(String line, String keyword) {
final upper = line.toUpperCase();
final key = keyword.toUpperCase();
if (key.length <= 3) {
return RegExp('\\b${RegExp.escape(key)}\\b').hasMatch(upper);
if (RegExp('\\b${RegExp.escape(key)}\\b').hasMatch(upper)) {
return true;
}
if (upper.contains(key)) return true;
final compactLine = upper.replaceAll(RegExp(r'[^A-Z0-9]'), '');
final compactKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
return compactKey.length >= 4 && compactLine.contains(compactKey);
return compactKey.length >= 5 && compactLine.contains(compactKey);
}
bool isStandaloneKeywordLine(String line, List<String> keywords) {
+55 -559
View File
@@ -1,590 +1,86 @@
/// Turns raw OCR text into [LineItem]s using receipt-parsing-keywords.json.
/// Receipt parsing entry point: spatial OCR pipeline with text fallback.
library;
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import '../models/line_item.dart';
import '../utils/money.dart';
import '../models/ocr_element.dart';
import 'receipt_ocr.dart';
import 'receipt_parse_config.dart';
export 'receipt_parse_config.dart' show loadReceiptParseConfig;
/// Result of a full receipt parse, including totals used for review.
class ReceiptParseResult {
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,
);
export 'receipt_text_parser.dart' show ReceiptParseResult;
import 'receipt_spatial_parser.dart';
import 'receipt_text_parser.dart';
class ReceiptParser {
ReceiptParser._();
static int _nextId = 0;
static String _id() => 'item-${++_nextId}';
static Future<void> loadConfig() => loadReceiptParseConfig();
static List<LineItem> parse(String rawText) => parseReceipt(rawText).items;
static ReceiptParseResult parseReceipt(String rawText) {
final config = receiptParseConfig;
final lines = rawText
.split('\n')
.map(_preprocess)
.where((line) => line.isNotEmpty)
.toList();
/// Fallback when only flattened OCR text is available (tests, re-parse).
static ReceiptParseResult parseReceipt(String rawText) =>
ReceiptTextParser.parseReceipt(rawText);
if (_looksLikeColumnOcr(lines, config)) {
return _parseColumns(lines, config, rawText);
}
return _parseSequential(lines, config, rawText);
}
/// Primary path: positioned ML Kit elements → row clustering → lookahead.
static ReceiptParseResult parseReceiptFromOcr(OcrDocument document) =>
_parseDocument(document);
static String _preprocess(String line) =>
line.trim().replaceAll(RegExp(r'\s+'), ' ');
static String _stripTax(String line, ReceiptParseConfig config) {
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)) {
names++;
}
}
}
return maxRun >= 4 && prices >= 4 && names >= 4;
}
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 (amount < 0 && pending != null) {
pending!.discount += amount;
pending!.lineTotal = roundMoney(
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
);
}
}
}
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 items = <LineItem>[
for (var i = 0; i < count; i++)
LineItem(
id: _id(),
name: names[i].name,
price: prices[i],
quantity: names[i].quantity,
unitPrice: names[i].unitPrice,
),
];
for (var i = 0; i < discountSlots.length && i < discounts.length; i++) {
final index = discountSlots[i];
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,
static ReceiptParseResult parseRecognizedText(
RecognizedText recognized, {
required double imageWidth,
required double imageHeight,
}) {
final kept = items
.where((item) => item.name.trim().isNotEmpty && item.price > 0)
.toList();
final sum = roundMoney(
kept.fold<double>(0, (total, item) => total + item.price),
);
final passed =
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,
final document = ReceiptOcr.fromRecognizedText(
recognized,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
return _parseDocument(document);
}
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),
static ReceiptParseResult _parseDocument(OcrDocument document) {
if (ReceiptTextParser.looksLikeColumnOcr(document.flattenedText)) {
return _withImageDimensions(
ReceiptTextParser.parseReceipt(document.flattenedText),
document,
);
}
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),
final spatial = ReceiptSpatialParser.parse(document);
if (_shouldFallbackToText(spatial)) {
return _withImageDimensions(
ReceiptTextParser.parseReceipt(document.flattenedText),
document,
);
}
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);
return spatial;
}
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;
static bool _shouldFallbackToText(ReceiptParseResult spatial) {
if (spatial.items.isEmpty) return true;
final flagged =
spatial.items.where((item) => item.needsManualReview).length;
if (flagged > 0) return true;
if (spatial.items.any((item) => item.price <= 0)) return true;
if (spatial.receiptTotal != null && !spatial.validationPassed) 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);
static ReceiptParseResult _withImageDimensions(
ReceiptParseResult result,
OcrDocument document,
) {
return ReceiptParseResult(
items: result.items,
store: result.store,
receiptTotal: result.receiptTotal,
totalDiscount: result.totalDiscount,
validationPassed: result.validationPassed,
expectedItemCount: result.expectedItemCount,
ocrImageWidth: document.imageWidth,
ocrImageHeight: document.imageHeight,
);
}
}
+362
View File
@@ -0,0 +1,362 @@
/// Cluster OCR elements into rows and classify using column positions.
library;
import '../models/ocr_element.dart';
import '../models/receipt_bounds.dart';
import '../models/receipt_row.dart';
import '../utils/money.dart';
import 'receipt_parse_config.dart';
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b');
final _phonePattern = RegExp(r'^\d{8,}$');
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}[A-Za-z]?$");
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
/// Pure quantity row — checked before any other row classification.
final _pureQuantityRow = RegExp(
r'^\s*(\d+)\s*[xX×]\s*(\d+[,.]\d{2})\s*(PER\s+STUK|PER\s+KG|ST\.?|STUKS?)?\s*$',
caseSensitive: false,
);
final _priceToken = RegExp(r'^-?\d+[,.]\d{2}$');
class ReceiptRowBuilder {
ReceiptRowBuilder(this.config);
final ReceiptParseConfig config;
List<ReceiptRow> buildRows(OcrDocument document) {
if (document.elements.isEmpty) return const [];
final contentWidth = _contentWidth(document.elements);
final contentLeft = _contentLeft(document.elements);
final priceColumnStart =
contentLeft + contentWidth * (1 - config.priceColumnWidthFraction);
// Prefer ML Kit line boundaries — element Y-clustering merges adjacent lines.
if (document.lines.isNotEmpty) {
return [
for (final line in document.lines)
_classifyRow(
line.elements,
priceColumnStart: priceColumnStart,
imageWidth: document.imageWidth,
imageHeight: document.imageHeight,
rowText: line.text,
),
];
}
final rowClusterThreshold = _rowClusterThreshold(document.elements);
final clustered = _clusterByY(document.elements, rowClusterThreshold);
return [
for (final rowElements in clustered)
_classifyRow(
rowElements,
priceColumnStart: priceColumnStart,
imageWidth: document.imageWidth,
imageHeight: document.imageHeight,
),
];
}
double _rowClusterThreshold(List<OcrElement> elements) {
final heights = [
for (final element in elements)
if (element.height > 0) element.height,
]..sort();
if (heights.isEmpty) return 12;
final median = heights[heights.length ~/ 2];
return median * config.rowClusterHeightFactor;
}
List<List<OcrElement>> _clusterByY(
List<OcrElement> elements,
double threshold,
) {
final sorted = [...elements]..sort((a, b) {
final dy = a.centerY.compareTo(b.centerY);
if (dy != 0) return dy;
return a.left.compareTo(b.left);
});
final rows = <List<OcrElement>>[];
List<OcrElement>? current;
double? anchorY;
for (final element in sorted) {
if (current == null) {
current = [element];
anchorY = element.centerY;
continue;
}
if ((element.centerY - anchorY!).abs() <= threshold) {
current.add(element);
} else {
current.sort((a, b) => a.left.compareTo(b.left));
rows.add(current);
current = [element];
anchorY = element.centerY;
}
}
if (current != null) {
current.sort((a, b) => a.left.compareTo(b.left));
rows.add(current);
}
return rows;
}
double _contentWidth(List<OcrElement> elements) {
var minLeft = double.infinity;
var maxRight = 0.0;
for (final element in elements) {
if (element.left < minLeft) minLeft = element.left;
if (element.right > maxRight) maxRight = element.right;
}
if (minLeft == double.infinity) return 1;
return (maxRight - minLeft).clamp(1, double.infinity);
}
double _contentLeft(List<OcrElement> elements) {
var minLeft = double.infinity;
for (final element in elements) {
if (element.left < minLeft) minLeft = element.left;
}
return minLeft == double.infinity ? 0 : minLeft;
}
ReceiptRow _classifyRow(
List<OcrElement> rowElements, {
required double priceColumnStart,
required double imageWidth,
required double imageHeight,
String? rowText,
}) {
final text = rowText ?? rowElements.map((e) => e.text).join(' ').trim();
final normalized = _preprocess(text);
final bounds = _rowBounds(rowElements, imageWidth, imageHeight);
// 4. Quantity modifier — entire row, checked first.
final pureQty = _pureQuantityRow.firstMatch(normalized);
if (pureQty != null) {
final count = int.tryParse(pureQty.group(1)!);
final unit = tryParsePrice(pureQty.group(2)!);
return ReceiptRow(
kind: ReceiptRowKind.quantityModifier,
text: normalized,
nameText: '',
quantity: count,
unitPrice: unit,
lineTotal: count != null && unit != null
? roundMoney(count * unit)
: null,
elements: rowElements,
bounds: bounds,
);
}
if (_isIgnorable(normalized)) {
return ReceiptRow(
kind: ReceiptRowKind.skip,
text: normalized,
nameText: '',
elements: rowElements,
bounds: bounds,
);
}
if (config.matchesAny(normalized, config.totalDiscountKeywords)) {
return ReceiptRow(
kind: ReceiptRowKind.totalDiscount,
text: normalized,
nameText: normalized,
price: _rowPrice(rowElements, priceColumnStart) ??
_trailingPrice(normalized),
elements: rowElements,
bounds: bounds,
);
}
if (config.matchesAny(normalized, config.taxInclusiveTotalKeywords) ||
config.matchesAny(normalized, config.subtotalKeywords)) {
return ReceiptRow(
kind: ReceiptRowKind.skip,
text: normalized,
nameText: '',
elements: rowElements,
bounds: bounds,
);
}
if (config.matchesAny(normalized, config.grandTotalKeywords)) {
return ReceiptRow(
kind: ReceiptRowKind.grandTotal,
text: normalized,
nameText: normalized,
price: _rowPrice(rowElements, priceColumnStart) ??
_trailingPrice(normalized),
elements: rowElements,
bounds: bounds,
);
}
final isDiscount =
config.matchesAny(normalized, config.discountKeywords) ||
config.negativeAmount.hasMatch(normalized);
if (isDiscount) {
return ReceiptRow(
kind: ReceiptRowKind.discount,
text: normalized,
nameText: normalized,
price: _rowPrice(rowElements, priceColumnStart) ??
_trailingPrice(normalized) ??
tryParsePrice(normalized),
elements: rowElements,
bounds: bounds,
);
}
final price = _rowPrice(rowElements, priceColumnStart);
final inlineQty = config.quantityInline.firstMatch(normalized);
var nameText = _nameFromElements(rowElements, priceColumnStart);
if (price != null) {
if (inlineQty != null) {
nameText = normalized.substring(0, inlineQty.start).trim();
final count = int.tryParse(inlineQty.group(1)!);
final unit = tryParsePrice(inlineQty.group(2)!);
if (nameText.isNotEmpty && count != null && unit != null) {
return ReceiptRow(
kind: ReceiptRowKind.item,
text: normalized,
nameText: nameText,
price: price,
quantity: count,
unitPrice: unit,
lineTotal: price,
elements: rowElements,
bounds: bounds,
);
}
}
if (nameText.isEmpty || _isIgnorable(nameText)) {
return ReceiptRow(
kind: ReceiptRowKind.skip,
text: normalized,
nameText: nameText,
elements: rowElements,
bounds: bounds,
);
}
return ReceiptRow(
kind: ReceiptRowKind.item,
text: normalized,
nameText: nameText,
price: price,
lineTotal: price,
elements: rowElements,
bounds: bounds,
);
}
if (nameText.isEmpty) nameText = normalized;
return ReceiptRow(
kind: ReceiptRowKind.nameOnly,
text: normalized,
nameText: nameText,
elements: rowElements,
bounds: bounds,
);
}
double? _rowPrice(List<OcrElement> elements, double priceColumnStart) {
double? best;
var bestX = -1.0;
for (final element in elements) {
if (element.centerX < priceColumnStart) continue;
final token = element.text.trim().replaceAll(
config.trailingCategoryLetter,
'',
);
if (!_priceToken.hasMatch(token)) continue;
final value = tryParsePrice(token);
if (value == null) continue;
if (element.centerX >= bestX) {
bestX = element.centerX;
best = value;
}
}
return best;
}
String _nameFromElements(List<OcrElement> elements, double priceColumnStart) {
final parts = <String>[];
for (final element in elements) {
if (element.centerX >= priceColumnStart &&
_priceToken.hasMatch(element.text.trim())) {
continue;
}
final cleaned = element.text
.replaceFirst(config.trailingCategoryLetter, '')
.trim();
if (cleaned.isNotEmpty) parts.add(cleaned);
}
return _preprocess(parts.join(' '));
}
ReceiptBounds? _rowBounds(
List<OcrElement> elements,
double imageWidth,
double imageHeight,
) {
if (elements.isEmpty || imageWidth <= 0 || imageHeight <= 0) return null;
var left = double.infinity;
var top = double.infinity;
var right = 0.0;
var bottom = 0.0;
for (final element in elements) {
if (element.left < left) left = element.left;
if (element.top < top) top = element.top;
if (element.right > right) right = element.right;
if (element.bottom > bottom) bottom = element.bottom;
}
return ReceiptBounds.fromPixelRect(
left: left,
top: top,
right: right,
bottom: bottom,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
}
String _preprocess(String line) =>
line.trim().replaceAll(RegExp(r'\s+'), ' ');
double? _trailingPrice(String line) {
final stripped = line.replaceFirst(config.trailingCategoryLetter, '').trim();
final match = RegExp(r'(-?\d+[.,]\d{2})\s*$').firstMatch(stripped);
if (match == null) return null;
return tryParsePrice(match.group(1)!);
}
bool _isIgnorable(String line) {
if (config.separatorPattern.hasMatch(line)) return true;
if (_urlPattern.hasMatch(line)) return true;
if (_postalPattern.hasMatch(line)) return true;
if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) return true;
if (_streetPattern.hasMatch(line)) return true;
if (config.categoryRow.hasMatch(line)) return true;
if (config.isStandaloneKeywordLine(line, config.columnHeaderKeywords)) {
return true;
}
if (config.isStandaloneKeywordLine(line, config.itemCountKeywords)) {
return true;
}
if (config.matchesAny(line, config.taxKeywords)) return true;
if (config.matchesAny(line, config.paymentKeywords)) return true;
if (config.matchesAny(line, config.noiseKeywords)) return true;
return false;
}
}
+274
View File
@@ -0,0 +1,274 @@
/// Spatial receipt parser: row clustering, column logic, and lookahead buffer.
library;
import '../models/line_item.dart';
import '../models/ocr_element.dart';
import '../models/receipt_bounds.dart';
import '../models/receipt_row.dart';
import '../utils/money.dart';
import 'receipt_parse_config.dart';
import 'receipt_row_builder.dart';
import 'receipt_text_parser.dart';
class _PendingItem {
_PendingItem({
required this.name,
this.unitPrice,
this.lineTotal,
this.quantity = 1,
this.bounds,
});
String name;
double? unitPrice;
double? lineTotal;
int quantity;
double discount = 0;
ReceiptBounds? bounds;
void mergeBounds(ReceiptBounds? other) {
if (other == null) return;
bounds = bounds == null ? other : bounds!.union(other);
}
}
class ReceiptSpatialParser {
ReceiptSpatialParser._();
static int _nextId = 0;
static String _id() => 'item-${++_nextId}';
static ReceiptParseResult parse(OcrDocument document) {
_nextId = 0;
final config = receiptParseConfig;
final rows = ReceiptRowBuilder(config).buildRows(document);
return _parseRows(
rows,
rawText: document.flattenedText,
imageWidth: document.imageWidth,
imageHeight: document.imageHeight,
);
}
static ReceiptParseResult _parseRows(
List<ReceiptRow> rows, {
required String rawText,
required double imageWidth,
required double imageHeight,
}) {
final config = receiptParseConfig;
final items = <LineItem>[];
_PendingItem? pending;
var receiptTotal = _readTotalFromRows(rows, config);
var totalDiscount = 0.0;
var stop = false;
void emitPending({required bool needsReview}) {
final current = pending;
pending = null;
if (current == null) return;
final total = current.lineTotal ?? current.unitPrice;
if (total == null) {
items.add(
LineItem(
id: _id(),
name: current.name,
price: 0,
sourceBounds: current.bounds,
needsManualReview: true,
),
);
return;
}
items.add(
LineItem(
id: _id(),
name: current.name,
price: roundMoney(total),
quantity: current.quantity == 1 ? null : current.quantity,
unitPrice: current.quantity == 1 ? null : current.unitPrice,
discount: current.discount == 0 ? 0 : roundMoney(current.discount),
sourceBounds: current.bounds,
needsManualReview: needsReview,
),
);
}
for (var i = 0; i < rows.length && !stop; i++) {
final row = rows[i];
switch (row.kind) {
case ReceiptRowKind.skip:
continue;
case ReceiptRowKind.quantityModifier:
if (pending != null &&
row.quantity != null &&
row.unitPrice != null) {
pending!
..quantity = row.quantity!
..unitPrice = row.unitPrice
..lineTotal = row.lineTotal ?? roundMoney(row.quantity! * row.unitPrice!);
pending!.mergeBounds(row.bounds);
emitPending(needsReview: false);
}
continue;
case ReceiptRowKind.nameOnly:
emitPending(needsReview: pending?.lineTotal == null);
pending = _PendingItem(name: row.nameText, bounds: row.bounds);
final next = i + 1 < rows.length ? rows[i + 1] : null;
if (next?.kind == ReceiptRowKind.quantityModifier) {
continue;
}
emitPending(needsReview: true);
continue;
case ReceiptRowKind.item:
emitPending(needsReview: pending?.lineTotal == null);
pending = _PendingItem(
name: row.nameText,
unitPrice: row.unitPrice ?? row.price,
lineTotal: row.lineTotal ?? row.price,
quantity: row.quantity ?? 1,
bounds: row.bounds,
);
if (row.quantity != null) {
pending!.unitPrice = row.unitPrice;
}
final nextItem = i + 1 < rows.length ? rows[i + 1] : null;
if (nextItem?.kind == ReceiptRowKind.quantityModifier) {
continue;
}
emitPending(needsReview: false);
continue;
case ReceiptRowKind.discount:
var amount = row.price;
if (amount == null && i + 1 < rows.length) {
amount = rows[i + 1].price;
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,
);
pending!.mergeBounds(row.bounds);
} else {
totalDiscount += amount;
}
continue;
case ReceiptRowKind.totalDiscount:
var amount = row.price;
if (amount == null && i + 1 < rows.length) {
amount = rows[i + 1].price;
if (amount != null) i++;
}
if (amount != null) {
totalDiscount += amount > 0 ? -amount : amount;
}
continue;
case ReceiptRowKind.grandTotal:
emitPending(needsReview: pending?.lineTotal == null);
if (row.price != null) {
receiptTotal = row.price;
stop = true;
}
continue;
}
}
emitPending(needsReview: pending?.lineTotal == null);
final expectedItemCount = _readExpectedItemCount(rows, config);
return _finalize(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
expectedItemCount: expectedItemCount,
imageWidth: imageWidth,
imageHeight: imageHeight,
);
}
static double? _readTotalFromRows(
List<ReceiptRow> rows,
ReceiptParseConfig config,
) {
for (final row in rows) {
if (row.kind != ReceiptRowKind.grandTotal) continue;
if (config.matchesAny(row.text, [
...config.totalDiscountKeywords,
...config.taxInclusiveTotalKeywords,
...config.subtotalKeywords,
])) {
continue;
}
return row.price;
}
return null;
}
static int? _readExpectedItemCount(
List<ReceiptRow> rows,
ReceiptParseConfig config,
) {
final pattern = RegExp(
r'(\d+)\s*$',
caseSensitive: false,
);
for (final row in rows) {
if (!config.matchesAny(row.text, config.itemCountKeywords)) continue;
final match = pattern.firstMatch(row.text);
if (match != null) {
return int.tryParse(match.group(1)!);
}
}
return null;
}
static ReceiptParseResult _finalize({
required List<LineItem> items,
required String rawText,
double? receiptTotal,
double totalDiscount = 0,
int? expectedItemCount,
required double imageWidth,
required double imageHeight,
}) {
final kept = items
.where(
(item) =>
item.name.trim().isNotEmpty &&
(item.price > 0 || item.needsManualReview),
)
.toList();
final sum = roundMoney(
kept
.where((item) => item.price > 0)
.fold<double>(0, (total, item) => total + item.price),
);
var passed = true;
if (receiptTotal != null) {
passed =
(sum - receiptTotal).abs() <= 0.01 ||
(sum + totalDiscount - receiptTotal).abs() <= 0.01;
}
if (expectedItemCount != null && kept.length != expectedItemCount) {
passed = false;
}
if (kept.any((item) => item.needsManualReview)) {
passed = false;
}
return ReceiptParseResult(
items: kept,
store: ReceiptTextParser.detectStore(rawText),
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
validationPassed: passed,
expectedItemCount: expectedItemCount,
ocrImageWidth: imageWidth,
ocrImageHeight: imageHeight,
);
}
}
+649
View File
@@ -0,0 +1,649 @@
/// Flat text-line receipt parser (fallback when spatial OCR is unavailable).
library;
import '../models/line_item.dart';
import '../utils/money.dart';
import 'receipt_parse_config.dart';
/// Result of a full receipt parse, including totals used for review.
class ReceiptParseResult {
const ReceiptParseResult({
required this.items,
this.store,
this.receiptTotal,
this.totalDiscount = 0,
this.validationPassed = true,
this.expectedItemCount,
this.ocrImageWidth,
this.ocrImageHeight,
});
final List<LineItem> items;
final String? store;
final double? receiptTotal;
final double totalDiscount;
final bool validationPassed;
final int? expectedItemCount;
final double? ocrImageWidth;
final double? ocrImageHeight;
}
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 ReceiptTextParser {
ReceiptTextParser._();
static int _nextId = 0;
static String _id() => 'item-${++_nextId}';
static Future<void> loadConfig() => loadReceiptParseConfig();
static List<LineItem> parse(String rawText) => parseReceipt(rawText).items;
static ReceiptParseResult parseReceipt(String rawText) {
final config = receiptParseConfig;
final lines = rawText
.split('\n')
.map(_preprocess)
.where((line) => line.isNotEmpty)
.toList();
if (_looksLikeColumnOcr(lines, config)) {
return _parseColumns(lines, config, rawText);
}
return _parseSequential(lines, config, rawText);
}
/// True when OCR dumped an entire name column before a price column (Jumbo-style).
static bool looksLikeColumnOcr(String rawText) {
final config = receiptParseConfig;
final lines = rawText
.split('\n')
.map(_preprocess)
.where((line) => line.isNotEmpty)
.toList();
return _looksLikeColumnOcr(lines, config);
}
static String _preprocess(String line) =>
line.trim().replaceAll(RegExp(r'\s+'), ' ');
static String _stripTax(String line, ReceiptParseConfig config) {
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,
lines: lines,
);
}
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,
lines: lines,
);
}
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,
List<String>? lines,
}) {
final config = receiptParseConfig;
final kept = items
.where((item) => item.name.trim().isNotEmpty && item.price > 0)
.toList();
final sum = roundMoney(
kept.fold<double>(0, (total, item) => total + item.price),
);
final expectedItemCount = lines == null
? null
: _readExpectedItemCountFromLines(lines, config);
var passed =
receiptTotal == null ||
(sum - receiptTotal).abs() <= 0.01 ||
(sum + totalDiscount - receiptTotal).abs() <= 0.01;
if (expectedItemCount != null && kept.length != expectedItemCount) {
passed = false;
}
return ReceiptParseResult(
items: kept,
store: detectStore(rawText),
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
validationPassed: passed,
expectedItemCount: expectedItemCount,
);
}
static int? _readExpectedItemCountFromLines(
List<String> lines,
ReceiptParseConfig config,
) {
final pattern = RegExp(r'(\d+)\s*$', caseSensitive: false);
for (final raw in lines) {
final line = _preprocess(raw);
if (!config.matchesAny(line, config.itemCountKeywords)) continue;
final match = pattern.firstMatch(line);
if (match != null) return int.tryParse(match.group(1)!);
}
return null;
}
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);
}
}
+72 -4
View File
@@ -9,6 +9,7 @@ import 'models/line_item.dart';
import 'models/shop.dart';
import 'product_detail_screen.dart';
import 'services/product_repository.dart';
import 'services/receipt_capture.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'widgets/confirm_dialog.dart';
@@ -37,6 +38,7 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
bool _editing = false;
List<String> _tagSuggestions = [];
int _loadToken = 0;
bool _addingReceipt = false;
@override
void initState() {
@@ -150,6 +152,42 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
}
}
Future<void> _addFromReceipt() async {
final shop = _shop;
if (shop == null || _addingReceipt) return;
setState(() => _addingReceipt = true);
try {
final captured = await captureAndParseReceipt(context);
if (captured == null || !mounted) return;
var added = 0;
for (final item in captured.items) {
await widget.repository.addShopItem(shopId: shop.id, item: item);
added++;
}
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
added == 1
? 'Added 1 item from receipt.'
: 'Added $added items from receipt.',
),
),
);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not add items from this receipt.')),
);
_load();
} finally {
if (mounted) setState(() => _addingReceipt = false);
}
}
Future<void> _editItem(ShopItem item) async {
final result = await showItemEditSheet(
context,
@@ -299,11 +337,41 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
),
],
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: _editing
? FloatingActionButton.extended(
onPressed: _addItem,
icon: const Icon(Icons.add),
label: const Text('Add product'),
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: FilledButton.tonalIcon(
onPressed: _addingReceipt ? null : _addFromReceipt,
icon: _addingReceipt
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.receipt_long_outlined),
label: const Text('Add receipt'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: _addingReceipt ? null : _addItem,
icon: const Icon(Icons.add),
label: const Text('Add product'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
),
),
],
),
)
: null,
body: Builder(
+2
View File
@@ -170,6 +170,8 @@ class _ItemEditSheetState extends State<ItemEditSheet> {
discount: widget.item?.price == roundMoney(resolvedTotal)
? widget.item?.discount ?? 0
: 0,
sourceBounds: widget.item?.sourceBounds,
needsManualReview: false,
),
);
}
@@ -0,0 +1,91 @@
/// Overlays a highlight on the receipt photo for a parsed line item.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import '../models/receipt_bounds.dart';
class ReceiptHighlightOverlay extends StatelessWidget {
const ReceiptHighlightOverlay({
super.key,
required this.imagePath,
required this.bounds,
this.maxHeight = 280,
});
final String imagePath;
final ReceiptBounds bounds;
final double maxHeight;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
fit: StackFit.passthrough,
children: [
Image.file(
File(imagePath),
fit: BoxFit.contain,
height: maxHeight,
width: constraints.maxWidth,
),
Positioned.fill(
child: CustomPaint(
painter: _BoundsPainter(
bounds: bounds,
color: scheme.primary.withValues(alpha: 0.35),
stroke: scheme.primary,
),
),
),
],
);
},
),
);
}
}
class _BoundsPainter extends CustomPainter {
_BoundsPainter({
required this.bounds,
required this.color,
required this.stroke,
});
final ReceiptBounds bounds;
final Color color;
final Color stroke;
@override
void paint(Canvas canvas, Size size) {
if (bounds.isEmpty) return;
final rect = Rect.fromLTRB(
bounds.left * size.width,
bounds.top * size.height,
bounds.right * size.width,
bounds.bottom * size.height,
);
canvas.drawRect(rect, Paint()..color = color);
canvas.drawRect(
rect,
Paint()
..color = stroke
..style = PaintingStyle.stroke
..strokeWidth = 2,
);
}
@override
bool shouldRepaint(covariant _BoundsPainter oldDelegate) {
return oldDelegate.bounds != bounds ||
oldDelegate.color != color ||
oldDelegate.stroke != stroke;
}
}
+6
View File
@@ -182,5 +182,11 @@
"ART.",
"ARTIKELEN"
]
},
"spatial_parsing": {
"_comment": "Bounding-box pipeline settings. row_cluster_height_factor multiplies median element height when grouping elements into rows (only used when ML Kit lines are unavailable — keep below 0.6 so adjacent receipt lines stay separate). price_column_width_fraction is the right-hand slice of receipt content width treated as the price column.",
"row_cluster_height_factor": 0.55,
"price_column_width_fraction": 0.25
}
}
+92 -64
View File
@@ -16,30 +16,53 @@ without assuming a single fixed format.
## 1. Input assumptions
- Input is raw OCR text, one line per detected text line, in top-to-bottom
order as it appeared on the receipt.
- Input is structured OCR from ML Kit: **blocks → lines → elements**, each
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`),
period as thousands separator if present (rare on receipts this size).
- OCR may introduce noise: misread characters, extra/missing spaces,
occasional dropped or merged lines. The parser should be tolerant of
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)
1. Trim leading/trailing whitespace on every line; collapse multiple
internal spaces to one.
2. Normalize decimal separators: treat `,` as the decimal point in any
number matching `\d+[,.]\d{2}`. Convert to a standard `.` internally
for numeric storage, but remember the original format was comma-based
(useful for locale-aware re-parsing if needed).
3. Drop fully empty lines.
4. Do NOT drop short lines outright (e.g. a lone `BROCCOLI` or a lone
1. Extract every text **element** with its bounding box from ML Kit.
2. **Rebuild rows** — do not trust ML Kit's line segmentation:
- Sort elements by Y-center (top to bottom).
- Cluster into rows where Y-centers fall within one line height of each
other. Threshold = `median_element_height × row_cluster_height_factor`
(default factor **1.2**, configurable in `spatial_parsing`).
- Within each row, sort elements left-to-right by X position.
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.
## 3. Core line classification
## 3. Core row classification
Classify every line into one of these categories, in this priority order
(check top-down, first match wins):
Classify every **row** (not flat OCR line) into one of these categories.
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
- 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
of 1 implied unless a QUANTITY_MODIFIER line follows (section 3.4).
### 3.4 QUANTITY_MODIFIER (sub-line, 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.:
- `2 X 2,79`
- `2 X 3,99 PER STUK`
- This line does NOT introduce a new product — it modifies the item on
an adjacent line.
### 3.4 QUANTITY_MODIFIER (sub-row, belongs to previous or next ITEM)
See **§3.0** — pure `N X price` rows are detected before any other type.
The rules below describe how they attach to items:
- This row does NOT introduce a new product — it modifies the item on
an adjacent row.
- Two observed variants — the parser must detect which applies:
- **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
@@ -154,66 +177,71 @@ Classify every line into one of these categories, in this priority order
## 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 = []
pending_item = None # name found, price/qty not yet resolved
for line in classified_lines:
if line.type == ITEM:
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):
for row in classified_rows:
if row.type == QUANTITY_MODIFIER:
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)
pending_item = None
if is_grand_total(line):
receipt.total = line.amount
break # stop item parsing here
continue # orphan modifier without pending → ignore
# catch any trailing pending item if receipt ended oddly
if pending_item:
items.append(pending_item)
if row.type == NAME_ONLY:
if 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)
1. Sum `line_total` across all parsed items.
2. Add `receipt.total_discount` if it wasn't already folded into
individual item discounts.
3. Compare the result to `receipt.total` (the grand total line).
4. If they match within 0.01 → high confidence, no flags needed.
5. If they don't match → mark the WHOLE receipt as "needs review" and
surface it clearly in the review/edit screen (don't just flag
individual lines — a mismatch means something in the whole parse is
probably off, e.g. a missed line or a misclassified discount).
4. If the receipt prints an item count (e.g. `Aantal Artikel: N`), compare
`N` to the number of parsed items.
5. If totals match within 0.01 **and** item count matches (when present)
**and** no item is flagged `needs_manual_review` → high confidence.
6. If any check fails → mark the **whole receipt** as "needs review".
## 6. Known store-specific quirks to encode as configurable rules
+96
View File
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/ocr_element.dart';
import 'package:groci/services/receipt_parser.dart';
void main() {
@@ -15,6 +16,35 @@ CHANGE 1.50
expect(items.map((item) => item.price), [1.50, 2.00]);
});
test('parses name on one line and price on the next', () {
final items = ReceiptParser.parse('''
MILK
1,50
BREAD
2,00
Totaal 3,50
''');
expect(items, hasLength(2));
expect(items.map((item) => item.name), ['MILK', 'BREAD']);
expect(items.map((item) => item.price), [1.50, 2.00]);
});
test('pairs two name lines with two price lines from short column OCR', () {
final items = ReceiptParser.parse('''
Jumbo
MILK
BREAD
1,50
2,00
Totaal 3,50
''');
expect(items, hasLength(2));
expect(items.map((item) => item.name), ['MILK', 'BREAD']);
expect(items.map((item) => item.price), [1.50, 2.00]);
});
test('uses the next line when quantity and total are split', () {
final items = ReceiptParser.parse('''
EGGS
@@ -123,6 +153,72 @@ Bedr.Incl 2,09
expect(items.last.price, 6.20);
expect(items, hasLength(26));
});
test('does not treat CHIPS as payment keyword CHIP', () {
final items = ReceiptParser.parse('''
AH Protein
AH Chips
1,49
0.69
''');
expect(items, hasLength(2));
expect(items.map((item) => item.name), ['AH Protein', 'AH Chips']);
expect(items.map((item) => item.price), [1.49, 0.69]);
});
test('scan path uses column parser for Jumbo name-then-price OCR dump', () {
const ocr = '''
BIO VASTKOKEND AARD
2 X 1,99
AARDBEIEN
2 X 3,99
ACTIE AARDBEIEN
BROCCOLII
PREI
CITROENEN
KOMKOMMER
2 X 0,95
ACTIE KOMKOMMER
BOL KNOFLOOK
BROODSTICKS OLIJF
4 X 1.25
3,98
7,98
-2,00
1,24
0,69
2,11
1,90
-0,21
1,79
5,00
''';
final result = ReceiptParser.parseReceiptFromOcr(
OcrDocument(
elements: const [],
lines: const [],
imageWidth: 800,
imageHeight: 1200,
flattenedText: ocr,
),
);
expect(result.items, hasLength(8));
expect(result.items[0].name, 'BIO VASTKOKEND AARD');
expect(result.items[0].quantity, 2);
expect(result.items[0].price, 3.98);
expect(result.items[1].name, 'AARDBEIEN');
expect(result.items[1].price, 5.98);
expect(result.items[1].discount, -2.00);
expect(result.items[5].name, 'KOMKOMMER');
expect(result.items[5].price, 1.69);
expect(result.items.last.name, 'BROODSTICKS OLIJF');
expect(result.items.last.quantity, 4);
expect(result.items.last.price, 5.00);
expect(result.items.every((item) => !item.needsManualReview), isTrue);
});
}
const _jumboColumnOcr = '''
+195
View File
@@ -0,0 +1,195 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:groci/models/ocr_element.dart';
import 'package:groci/services/receipt_parse_config.dart';
import 'package:groci/services/receipt_parser.dart';
import 'package:groci/services/receipt_row_builder.dart';
import 'package:groci/services/receipt_spatial_parser.dart';
/// Synthetic two-column receipt layout (800×1200 image).
const _imageWidth = 800.0;
const _imageHeight = 1200.0;
const _nameLeft = 40.0;
const _priceLeft = 620.0;
const _rowHeight = 24.0;
const _rowGap = 8.0;
OcrElement _word(String text, double row, {bool price = false}) {
final y = row * (_rowHeight + _rowGap) + 100;
final left = price ? _priceLeft : _nameLeft;
final width = text.length * 10.0 + 20;
return OcrElement(
text: text,
left: left,
top: y,
right: left + width,
bottom: y + _rowHeight,
);
}
OcrLine _line(List<OcrElement> elements) {
var left = double.infinity;
var top = double.infinity;
var right = 0.0;
var bottom = 0.0;
for (final e in elements) {
if (e.left < left) left = e.left;
if (e.top < top) top = e.top;
if (e.right > right) right = e.right;
if (e.bottom > bottom) bottom = e.bottom;
}
return OcrLine(
text: elements.map((e) => e.text).join(' '),
elements: elements,
left: left,
top: top,
right: right,
bottom: bottom,
);
}
OcrDocument _document(
List<List<OcrElement>> rows, {
String? flattened,
}) {
final elements = [for (final row in rows) ...row];
final lines = [for (final row in rows) _line(row)];
return OcrDocument(
elements: elements,
lines: lines,
imageWidth: _imageWidth,
imageHeight: _imageHeight,
flattenedText: flattened ?? lines.map((l) => l.text).join('\n'),
);
}
void main() {
setUpAll(() async {
await ReceiptParser.loadConfig();
});
group('ReceiptRowBuilder', () {
test('clusters name and price on the same row by Y position', () {
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
_document([
[_word('MILK', 0), _word('1,50', 0, price: true)],
]),
);
expect(rows, hasLength(1));
expect(rows.single.kind.name, 'item');
expect(rows.single.nameText, 'MILK');
expect(rows.single.price, 1.50);
});
test('classifies pure quantity row before item logic', () {
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
_document([
[_word('2', 1), _word('X', 1), _word('2,99', 1, price: true)],
], flattened: '2 X 2,99'),
);
expect(rows, hasLength(1));
expect(rows.single.kind.name, 'quantityModifier');
expect(rows.single.quantity, 2);
expect(rows.single.unitPrice, 2.99);
});
test('treats row without price-column token as name-only', () {
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
_document([
[_word('BROCCOLI', 0)],
]),
);
expect(rows.single.kind.name, 'nameOnly');
expect(rows.single.nameText, 'BROCCOLI');
});
test('does not merge adjacent ML Kit lines into one row', () {
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
_document([
[_word('BIO', 0), _word('VASTKOKEND', 0), _word('AARD', 0)],
[_word('2', 1), _word('X', 1), _word('1,99', 1, price: true)],
]),
);
expect(rows, hasLength(2));
expect(rows[0].nameText, contains('BIO'));
expect(rows[1].kind.name, 'quantityModifier');
});
});
group('ReceiptSpatialParser lookahead', () {
test('merges Jumbo-style name row with following quantity modifier', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('AARDBEIEN', 0)],
[_word('2', 1), _word('X', 1), _word('3,99', 1, price: true)],
[_word('Totaal', 2), _word('7,98', 2, price: true)],
]),
);
expect(result.items, hasLength(1));
expect(result.items.single.name, 'AARDBEIEN');
expect(result.items.single.quantity, 2);
expect(result.items.single.unitPrice, 3.99);
expect(result.items.single.price, 7.98);
expect(result.items.single.sourceBounds, isNotNull);
});
test('overrides inline price when quantity modifier follows (Kruidvat)', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('ZEEPTABLET', 0), _word('NEUTRAL', 0), _word('7,98', 0, price: true)],
[_word('2', 1), _word('X', 1), _word('3,99', 1, price: true)],
]),
);
expect(result.items, hasLength(1));
expect(result.items.single.quantity, 2);
expect(result.items.single.unitPrice, 3.99);
expect(result.items.single.price, 7.98);
});
test('flags name-only row without following modifier for manual review', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('MILK', 0)],
[_word('BREAD', 1), _word('2,00', 1, price: true)],
]),
);
expect(result.items, hasLength(2));
expect(result.items[0].name, 'MILK');
expect(result.items[0].needsManualReview, isTrue);
expect(result.items[1].name, 'BREAD');
expect(result.items[1].price, 2.00);
});
test('parses two side-by-side items without cross-assigning prices', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('MILK', 0), _word('1,50', 0, price: true)],
[_word('BREAD', 1), _word('2,00', 1, price: true)],
[_word('Totaal', 2), _word('3,50', 2, price: true)],
]),
);
expect(result.items.map((i) => i.name), ['MILK', 'BREAD']);
expect(result.items.map((i) => i.price), [1.50, 2.00]);
expect(result.validationPassed, isTrue);
});
test('fails validation when printed item count does not match', () {
final result = ReceiptSpatialParser.parse(
_document([
[_word('MILK', 0), _word('1,50', 0, price: true)],
[_word('Aantal Artikel: 3', 1)],
]),
);
expect(result.expectedItemCount, 3);
expect(result.validationPassed, isFalse);
});
});
}