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