Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b040e83c0 | ||
|
|
ae42ba8193 | ||
|
|
98574af903 | ||
|
|
e98fb0e7f4 | ||
|
|
d532af6111 |
+23
-3
@@ -23,21 +23,41 @@ For each receipt:
|
||||
| `store` | Best-effort store from the header + `known_stores` |
|
||||
| `receiptTotal` | First grand-total amount after the items |
|
||||
| `totalDiscount` | Receipt-level `Totaal korting` (and similar) |
|
||||
| `validationPassed` | Item sum matches `receiptTotal` within €0.01 |
|
||||
| `validationPassed` | Item sum matches `receiptTotal` within €0.01, item count matches when printed, and no line needs manual review |
|
||||
| `items[].sourceBounds` | Normalized rect on the receipt photo for tap-to-highlight in Review |
|
||||
| `items[].needsManualReview` | Parser could not resolve price/qty confidently |
|
||||
| `expectedItemCount` | From `Aantal Artikel`-style lines when found |
|
||||
| `ocrImageWidth` / `ocrImageHeight` | Image size used to normalize bounds |
|
||||
|
||||
If validation fails, Review shows: *Parsed totals do not match the receipt.*
|
||||
If validation fails, Review shows a banner asking you to check items before saving.
|
||||
|
||||
---
|
||||
|
||||
## Pipeline
|
||||
|
||||
### Primary: spatial (ML Kit bounding boxes)
|
||||
|
||||
Used automatically after scanning a receipt photo.
|
||||
|
||||
1. **Extract elements** from ML Kit `RecognizedText` (blocks → lines → elements), keeping each element's pixel bounding box.
|
||||
2. **Cluster rows** by Y-center (threshold ≈ 1.2× median element height — see `spatial_parsing` in the JSON config).
|
||||
3. **Classify rows** with column logic: right ~25% of content width is the price column; pure `N X price` rows are quantity modifiers (checked first).
|
||||
4. **Build items** with a one-row lookahead buffer (Jumbo split layout + Kruidvat inline layout).
|
||||
5. **Validate** totals, printed item count, and manual-review flags.
|
||||
|
||||
Implementation: `ReceiptOcr`, `ReceiptRowBuilder`, `ReceiptSpatialParser` in `lib/services/`.
|
||||
|
||||
### Fallback: flat text
|
||||
|
||||
`ReceiptParser.parseReceipt(String)` for tests and when only flattened OCR text exists.
|
||||
|
||||
1. **Preprocess** each OCR line: trim, collapse spaces, drop empty lines.
|
||||
2. **Choose a layout**
|
||||
- **Sequential** (default): name, price, quantity and discount appear in
|
||||
reading order. Jumbo (printed), Lidl, Kruidvat, World Toko.
|
||||
- **Column OCR fallback:** ML Kit sometimes dumps every name, then every
|
||||
amount. Detected when there is a run of 4+ price-only lines *and* enough
|
||||
name-like lines. Used for the Jumbo sample dump in tests.
|
||||
name-like lines.
|
||||
3. **Classify** each line (first match wins) using the keyword JSON.
|
||||
4. **Build items** (pending name, then quantity/discount/price).
|
||||
5. **Validate** against the receipt total when one was found.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/// A parsed receipt row shown on the review screen.
|
||||
library;
|
||||
|
||||
import 'receipt_bounds.dart';
|
||||
|
||||
class LineItem {
|
||||
LineItem({
|
||||
required this.id,
|
||||
@@ -9,6 +11,8 @@ class LineItem {
|
||||
this.quantity,
|
||||
this.unitPrice,
|
||||
this.discount = 0,
|
||||
this.sourceBounds,
|
||||
this.needsManualReview = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -22,6 +26,12 @@ class LineItem {
|
||||
/// Negative amount when a discount was folded into [price].
|
||||
final double discount;
|
||||
|
||||
/// Region on the receipt photo (normalized 0–1) used when parsing this line.
|
||||
final ReceiptBounds? sourceBounds;
|
||||
|
||||
/// True when the parser could not confidently resolve price/qty for this line.
|
||||
final bool needsManualReview;
|
||||
|
||||
bool get hasDiscount => discount != 0;
|
||||
|
||||
/// List price before a folded discount. Equals [price] when there is none.
|
||||
@@ -37,7 +47,10 @@ class LineItem {
|
||||
int? quantity,
|
||||
double? unitPrice,
|
||||
double? discount,
|
||||
ReceiptBounds? sourceBounds,
|
||||
bool? needsManualReview,
|
||||
bool clearQuantity = false,
|
||||
bool clearSourceBounds = false,
|
||||
}) {
|
||||
return LineItem(
|
||||
id: id ?? this.id,
|
||||
@@ -46,6 +59,8 @@ class LineItem {
|
||||
quantity: clearQuantity ? null : (quantity ?? this.quantity),
|
||||
unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice),
|
||||
discount: discount ?? this.discount,
|
||||
sourceBounds: clearSourceBounds ? null : (sourceBounds ?? this.sourceBounds),
|
||||
needsManualReview: needsManualReview ?? this.needsManualReview,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -263,7 +263,11 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<ProductSort>(
|
||||
expandedInsets: EdgeInsets.zero,
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ProductSort.name,
|
||||
@@ -288,6 +292,7 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_nameIssues.isNotEmpty && !_editing)
|
||||
ProductNameConflictBanner(
|
||||
issues: _nameIssues,
|
||||
|
||||
+295
-62
@@ -8,11 +8,13 @@ import 'package:flutter/material.dart';
|
||||
import 'models/line_item.dart';
|
||||
import 'services/app_settings.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'services/receipt_capture.dart';
|
||||
import 'utils/dates.dart';
|
||||
import 'utils/money.dart';
|
||||
import 'utils/supermarkets.dart';
|
||||
import 'widgets/discounted_price.dart';
|
||||
import 'widgets/item_edit_sheet.dart';
|
||||
import 'widgets/receipt_highlight_overlay.dart';
|
||||
import 'widgets/store_tag.dart';
|
||||
import 'widgets/tag_editor.dart';
|
||||
|
||||
@@ -26,6 +28,8 @@ class ReviewScreen extends StatefulWidget {
|
||||
this.supermarketNames = kSupermarkets,
|
||||
this.needsReview = false,
|
||||
this.detectedStore,
|
||||
this.ocrImageWidth,
|
||||
this.ocrImageHeight,
|
||||
});
|
||||
|
||||
final List<LineItem> items;
|
||||
@@ -35,6 +39,8 @@ class ReviewScreen extends StatefulWidget {
|
||||
final List<String> supermarketNames;
|
||||
final bool needsReview;
|
||||
final String? detectedStore;
|
||||
final double? ocrImageWidth;
|
||||
final double? ocrImageHeight;
|
||||
|
||||
@override
|
||||
State<ReviewScreen> createState() => _ReviewScreenState();
|
||||
@@ -49,12 +55,21 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
final _tripName = TextEditingController();
|
||||
List<String> _tags = [];
|
||||
List<String> _tagSuggestions = [];
|
||||
String? _highlightedItemId;
|
||||
bool _showTripExtras = false;
|
||||
late String _rawText;
|
||||
String? _imagePath;
|
||||
late bool _needsReview;
|
||||
bool _addingReceipt = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items = List<LineItem>.from(widget.items);
|
||||
_shoppedAt = DateTime.now();
|
||||
_rawText = widget.rawText;
|
||||
_imagePath = widget.imagePath;
|
||||
_needsReview = widget.needsReview;
|
||||
final detected = detectSupermarket(
|
||||
widget.rawText,
|
||||
names: widget.supermarketNames,
|
||||
@@ -88,6 +103,57 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
return _supermarket;
|
||||
}
|
||||
|
||||
bool get _hasReceipt {
|
||||
final path = _imagePath;
|
||||
return path != null && path.isNotEmpty && File(path).existsSync();
|
||||
}
|
||||
|
||||
Future<void> _addReceipt() async {
|
||||
if (_saving || _addingReceipt) return;
|
||||
setState(() => _addingReceipt = true);
|
||||
try {
|
||||
final captured = await captureAndParseReceipt(context);
|
||||
if (captured == null || !mounted) return;
|
||||
|
||||
final keepExistingPhoto = _hasReceipt;
|
||||
final merged = [
|
||||
for (final item in captured.items)
|
||||
keepExistingPhoto
|
||||
? item.copyWith(clearSourceBounds: true)
|
||||
: item,
|
||||
];
|
||||
|
||||
setState(() {
|
||||
_items.addAll(merged);
|
||||
_needsReview = _needsReview || captured.needsReview;
|
||||
if (_rawText.isEmpty) {
|
||||
_rawText = captured.rawText;
|
||||
} else if (captured.rawText.isNotEmpty) {
|
||||
_rawText = '$_rawText\n\n---\n\n${captured.rawText}';
|
||||
}
|
||||
if (!keepExistingPhoto && captured.imagePath != null) {
|
||||
_imagePath = captured.imagePath;
|
||||
}
|
||||
if (_supermarket == null && captured.detectedStore != null) {
|
||||
_supermarket = captured.detectedStore;
|
||||
}
|
||||
_highlightedItemId = null;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
merged.length == 1
|
||||
? 'Added 1 item from receipt.'
|
||||
: 'Added ${merged.length} items from receipt.',
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _addingReceipt = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _edit({LineItem? existing}) async {
|
||||
final result = await showItemEditSheet(context, item: existing);
|
||||
if (result == null || !mounted) return;
|
||||
@@ -98,9 +164,20 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
} else {
|
||||
final index = _items.indexWhere((item) => item.id == existing.id);
|
||||
if (index >= 0) {
|
||||
_items[index] = result;
|
||||
_items[index] = result.copyWith(
|
||||
sourceBounds: existing.sourceBounds,
|
||||
needsManualReview: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
_highlightedItemId = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleHighlight(LineItem item) {
|
||||
if (item.sourceBounds == null || !_hasReceipt) return;
|
||||
setState(() {
|
||||
_highlightedItemId = _highlightedItemId == item.id ? null : item.id;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,19 +192,92 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
setState(() => _shoppedAt = picked);
|
||||
}
|
||||
|
||||
Future<void> _openReceipt() async {
|
||||
final path = widget.imagePath;
|
||||
Future<void> _openReceiptPanel() async {
|
||||
final path = _imagePath;
|
||||
if (path == null || path.isEmpty) return;
|
||||
final file = File(path);
|
||||
if (!file.existsSync()) return;
|
||||
|
||||
final highlighted = _highlightedItemId == null
|
||||
? null
|
||||
: _items.cast<LineItem?>().firstWhere(
|
||||
(item) => item?.id == _highlightedItemId,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Receipt', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
if (highlighted?.sourceBounds != null)
|
||||
ReceiptHighlightOverlay(
|
||||
imagePath: path,
|
||||
bounds: highlighted!.sourceBounds!,
|
||||
maxHeight: 360,
|
||||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return Dialog(
|
||||
builder: (ctx) => Dialog(
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
child: InteractiveViewer(
|
||||
child: Image.file(file, fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(file, fit: BoxFit.contain, height: 360),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openOcrPanel() async {
|
||||
if (_rawText.isEmpty) return;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Raw OCR text', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.55,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(_rawText),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -153,7 +303,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
supermarket: supermarket,
|
||||
shoppedAt: _shoppedAt,
|
||||
items: _items,
|
||||
receiptImagePath: widget.imagePath,
|
||||
receiptImagePath: _imagePath,
|
||||
name: _tripName.text,
|
||||
tags: _tags,
|
||||
);
|
||||
@@ -180,15 +330,19 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
if (_supermarket != null && !names.contains(_supermarket)) {
|
||||
names.add(_supermarket!);
|
||||
}
|
||||
final receiptPath = widget.imagePath;
|
||||
final hasReceipt = receiptPath != null &&
|
||||
receiptPath.isNotEmpty &&
|
||||
File(receiptPath).existsSync();
|
||||
final flaggedCount =
|
||||
_items.where((item) => item.needsManualReview).length;
|
||||
final reviewMessage = flaggedCount > 0
|
||||
? 'Some lines could not be parsed confidently — '
|
||||
'${flaggedCount == 1 ? '1 item is' : '$flaggedCount items are'} '
|
||||
'marked below. Check them against the receipt before saving.'
|
||||
: 'Item count or totals do not match the receipt. '
|
||||
'Check the items below before saving.';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.rawText.isEmpty && widget.imagePath == null
|
||||
_rawText.isEmpty && _imagePath == null
|
||||
? 'New trip'
|
||||
: 'Review items',
|
||||
),
|
||||
@@ -200,14 +354,14 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.needsReview) ...[
|
||||
if (_needsReview) ...[
|
||||
Material(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Parsed totals do not match the receipt. Check the items before saving.',
|
||||
reviewMessage,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
@@ -248,23 +402,33 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.calendar_today_outlined),
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_showTripExtras
|
||||
? Icons.expand_less
|
||||
: Icons.edit_note_outlined,
|
||||
),
|
||||
tooltip: _showTripExtras
|
||||
? 'Hide trip name & tags'
|
||||
: 'Trip name & tags',
|
||||
onPressed: _saving
|
||||
? null
|
||||
: () => setState(
|
||||
() => _showTripExtras = !_showTripExtras,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.calendar_today_outlined),
|
||||
],
|
||||
),
|
||||
title: Text(formatDate(_shoppedAt)),
|
||||
subtitle: const Text('Shopping date'),
|
||||
onTap: _saving ? null : _pickDate,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_showTripExtras) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _tripName,
|
||||
enabled: !_saving,
|
||||
@@ -281,10 +445,14 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
enabled: !_saving,
|
||||
onChanged: (tags) => setState(() => _tags = tags),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
if (_items.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
@@ -304,6 +472,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _items[index];
|
||||
final flagged = item.needsManualReview;
|
||||
final onReceipt = _highlightedItemId == item.id;
|
||||
return Dismissible(
|
||||
key: ValueKey(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
@@ -325,8 +495,44 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
});
|
||||
},
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: flagged
|
||||
? BorderSide(
|
||||
color: theme.colorScheme.error
|
||||
.withValues(alpha: 0.55),
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
tileColor: flagged
|
||||
? theme.colorScheme.errorContainer
|
||||
.withValues(alpha: 0.35)
|
||||
: onReceipt
|
||||
? theme.colorScheme.primaryContainer
|
||||
.withValues(alpha: 0.35)
|
||||
: null,
|
||||
leading: item.sourceBounds != null && _hasReceipt
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
onReceipt
|
||||
? Icons.visibility
|
||||
: Icons.visibility_outlined,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: 'Show on receipt',
|
||||
onPressed: _saving
|
||||
? null
|
||||
: () => _toggleHighlight(item),
|
||||
)
|
||||
: flagged
|
||||
? Icon(
|
||||
Icons.flag_outlined,
|
||||
color: theme.colorScheme.error,
|
||||
size: 20,
|
||||
)
|
||||
: null,
|
||||
title: Text(item.name),
|
||||
subtitle: _itemSubtitle(item),
|
||||
subtitle: _itemSubtitle(item, flagged: flagged),
|
||||
trailing: DiscountedPrice(item: item),
|
||||
onTap: _saving ? null : () => _edit(existing: item),
|
||||
),
|
||||
@@ -337,54 +543,76 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasReceipt)
|
||||
ExpansionTile(
|
||||
title: const Text('See receipt'),
|
||||
children: [
|
||||
if (_hasReceipt || _rawText.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: GestureDetector(
|
||||
onTap: _openReceipt,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(
|
||||
File(receiptPath),
|
||||
fit: BoxFit.contain,
|
||||
height: 220,
|
||||
width: double.infinity,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.rawText.isNotEmpty)
|
||||
ExpansionTile(
|
||||
title: const Text('Raw OCR text'),
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 180),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: SelectableText(widget.rawText),
|
||||
if (_hasReceipt)
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _saving || _addingReceipt
|
||||
? null
|
||||
: _openReceiptPanel,
|
||||
icon: const Icon(Icons.receipt_long_outlined, size: 18),
|
||||
label: const Text('Receipt'),
|
||||
),
|
||||
),
|
||||
if (_hasReceipt && _rawText.isNotEmpty)
|
||||
const SizedBox(width: 8),
|
||||
if (_rawText.isNotEmpty)
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _saving || _addingReceipt
|
||||
? null
|
||||
: _openOcrPanel,
|
||||
icon: const Icon(Icons.text_snippet_outlined, size: 18),
|
||||
label: const Text('OCR text'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _saving ? null : () => _edit(),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _saving || _addingReceipt
|
||||
? null
|
||||
: () => _edit(),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add item'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _saving || _addingReceipt
|
||||
? null
|
||||
: _addReceipt,
|
||||
icon: _addingReceipt
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.receipt_long_outlined),
|
||||
label: const Text('Add receipt'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _confirm,
|
||||
onPressed: _saving || _addingReceipt ? null : _confirm,
|
||||
child: Text(
|
||||
_saving ? 'Saving…' : 'Save trip · ${formatEuro(total)}',
|
||||
),
|
||||
@@ -398,8 +626,13 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _itemSubtitle(LineItem item) {
|
||||
if (item.quantity == null) return null;
|
||||
return Text('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}');
|
||||
Widget? _itemSubtitle(LineItem item, {bool flagged = false}) {
|
||||
final parts = <String>[];
|
||||
if (flagged) parts.add('Needs review');
|
||||
if (item.quantity != null) {
|
||||
parts.add('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}');
|
||||
}
|
||||
if (parts.isEmpty) return null;
|
||||
return Text(parts.join(' · '));
|
||||
}
|
||||
}
|
||||
|
||||
+34
-4
@@ -2,6 +2,7 @@
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -102,12 +103,22 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
});
|
||||
|
||||
final recognizer = TextRecognizer(script: TextRecognitionScript.latin);
|
||||
RecognizedText? recognized;
|
||||
double imageWidth = 0;
|
||||
double imageHeight = 0;
|
||||
try {
|
||||
final bytes = await File(imagePath).readAsBytes();
|
||||
final codec = await ui.instantiateImageCodec(bytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
imageWidth = frame.image.width.toDouble();
|
||||
imageHeight = frame.image.height.toDouble();
|
||||
frame.image.dispose();
|
||||
|
||||
final inputImage = InputImage.fromFilePath(imagePath);
|
||||
final recognized = await recognizer.processImage(inputImage);
|
||||
recognized = await recognizer.processImage(inputImage);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_extractedText = recognized.text.trim();
|
||||
_extractedText = recognized!.text.trim();
|
||||
if (_extractedText.isEmpty) {
|
||||
_error = 'No text found in this image.';
|
||||
}
|
||||
@@ -131,8 +142,14 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted && _extractedText.isNotEmpty) {
|
||||
await _openReview(_extractedText);
|
||||
if (mounted && _extractedText.isNotEmpty && recognized != null) {
|
||||
await _openReview(
|
||||
_extractedText,
|
||||
imagePath: imagePath,
|
||||
recognized: recognized,
|
||||
imageWidth: imageWidth,
|
||||
imageHeight: imageHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,12 +167,23 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
String rawText, {
|
||||
List<LineItem>? items,
|
||||
String? imagePath,
|
||||
RecognizedText? recognized,
|
||||
double? imageWidth,
|
||||
double? imageHeight,
|
||||
}) async {
|
||||
final parsedItems = items;
|
||||
ReceiptParseResult? parsed;
|
||||
if (parsedItems == null) {
|
||||
if (recognized != null && imageWidth != null && imageHeight != null) {
|
||||
parsed = ReceiptParser.parseRecognizedText(
|
||||
recognized,
|
||||
imageWidth: imageWidth,
|
||||
imageHeight: imageHeight,
|
||||
);
|
||||
} else {
|
||||
parsed = ReceiptParser.parseReceipt(rawText);
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
final names =
|
||||
SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ??
|
||||
@@ -170,6 +198,8 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
supermarketNames: names.toList(),
|
||||
needsReview: parsed != null && !parsed.validationPassed,
|
||||
detectedStore: parsed?.store,
|
||||
ocrImageWidth: parsed?.ocrImageWidth,
|
||||
ocrImageHeight: parsed?.ocrImageHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/// Shared pick → crop → OCR → parse flow for adding a receipt to a trip.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../crop_receipt_screen.dart';
|
||||
import '../models/line_item.dart';
|
||||
import 'receipt_parser.dart';
|
||||
|
||||
class CapturedReceiptParse {
|
||||
const CapturedReceiptParse({
|
||||
required this.items,
|
||||
required this.rawText,
|
||||
this.imagePath,
|
||||
this.needsReview = false,
|
||||
this.detectedStore,
|
||||
});
|
||||
|
||||
final List<LineItem> items;
|
||||
final String rawText;
|
||||
final String? imagePath;
|
||||
final bool needsReview;
|
||||
final String? detectedStore;
|
||||
}
|
||||
|
||||
/// Asks camera vs gallery, crops, runs OCR, and returns parsed items.
|
||||
///
|
||||
/// Returns null if the user cancels at any step.
|
||||
Future<CapturedReceiptParse?> captureAndParseReceipt(
|
||||
BuildContext context, {
|
||||
ImagePicker? picker,
|
||||
}) async {
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('Take photo'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('Pick from gallery'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (source == null || !context.mounted) return null;
|
||||
|
||||
final imagePicker = picker ?? ImagePicker();
|
||||
final file = await imagePicker.pickImage(source: source, imageQuality: 95);
|
||||
if (file == null || !context.mounted) return null;
|
||||
|
||||
final croppedPath = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => CropReceiptScreen(imagePath: file.path),
|
||||
),
|
||||
);
|
||||
if (croppedPath == null || !context.mounted) return null;
|
||||
|
||||
return _runOcrAndParse(context, croppedPath);
|
||||
}
|
||||
|
||||
Future<CapturedReceiptParse?> _runOcrAndParse(
|
||||
BuildContext context,
|
||||
String imagePath,
|
||||
) async {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) {
|
||||
return const PopScope(
|
||||
canPop: false,
|
||||
child: Center(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Reading receipt…'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final recognizer = TextRecognizer(script: TextRecognitionScript.latin);
|
||||
try {
|
||||
final bytes = await File(imagePath).readAsBytes();
|
||||
final codec = await ui.instantiateImageCodec(bytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
final imageWidth = frame.image.width.toDouble();
|
||||
final imageHeight = frame.image.height.toDouble();
|
||||
frame.image.dispose();
|
||||
|
||||
final recognized = await recognizer.processImage(
|
||||
InputImage.fromFilePath(imagePath),
|
||||
);
|
||||
final rawText = recognized.text.trim();
|
||||
if (rawText.isEmpty) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No text found in this image.')),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsed = ReceiptParser.parseRecognizedText(
|
||||
recognized,
|
||||
imageWidth: imageWidth,
|
||||
imageHeight: imageHeight,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
|
||||
if (parsed.items.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No products found on this receipt.')),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fresh ids so merged trips never collide with existing item keys.
|
||||
final stamp = DateTime.now().microsecondsSinceEpoch;
|
||||
final items = [
|
||||
for (var i = 0; i < parsed.items.length; i++)
|
||||
parsed.items[i].copyWith(id: 'merged-$stamp-$i'),
|
||||
];
|
||||
|
||||
return CapturedReceiptParse(
|
||||
items: items,
|
||||
rawText: rawText,
|
||||
imagePath: imagePath,
|
||||
needsReview: !parsed.validationPassed,
|
||||
detectedStore: parsed.store,
|
||||
);
|
||||
} on PlatformException catch (error) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Could not read text from this image.'
|
||||
'${error.message == null || error.message!.isEmpty ? '' : '\n${error.message}'}',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not read text from this image.\n$error')),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
await recognizer.close();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ class ReceiptParseConfig {
|
||||
required this.trailingCategoryLetter,
|
||||
required this.noiseKeywords,
|
||||
required this.itemCountKeywords,
|
||||
this.rowClusterHeightFactor = 0.55,
|
||||
this.priceColumnWidthFraction = 0.25,
|
||||
});
|
||||
|
||||
final List<String> knownStores;
|
||||
@@ -44,6 +46,8 @@ class ReceiptParseConfig {
|
||||
final RegExp trailingCategoryLetter;
|
||||
final List<String> noiseKeywords;
|
||||
final List<String> itemCountKeywords;
|
||||
final double rowClusterHeightFactor;
|
||||
final double priceColumnWidthFraction;
|
||||
|
||||
factory ReceiptParseConfig.fromJson(Map<String, dynamic> json) {
|
||||
List<String> list(String section, [String key = 'list']) {
|
||||
@@ -99,27 +103,48 @@ class ReceiptParseConfig {
|
||||
),
|
||||
noiseKeywords: list('noise_footer_keywords'),
|
||||
itemCountKeywords: list('line_item_count_keywords'),
|
||||
rowClusterHeightFactor: _spatialDouble(
|
||||
json,
|
||||
'row_cluster_height_factor',
|
||||
fallback: 0.55,
|
||||
),
|
||||
priceColumnWidthFraction: _spatialDouble(
|
||||
json,
|
||||
'price_column_width_fraction',
|
||||
fallback: 0.25,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static double _spatialDouble(
|
||||
Map<String, dynamic> json,
|
||||
String key, {
|
||||
required double fallback,
|
||||
}) {
|
||||
final spatial = json['spatial_parsing'];
|
||||
if (spatial is! Map) return fallback;
|
||||
final value = spatial[key];
|
||||
if (value is num && value > 0) return value.toDouble();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static const assetPath = 'receipt-parsing-keywords.json';
|
||||
|
||||
/// True if [line] matches a config keyword.
|
||||
///
|
||||
/// Short tokens (3 characters or fewer) use a word boundary so `PIN` does
|
||||
/// not match `SPINAZIE`. Longer tokens use a case-insensitive substring,
|
||||
/// and a letter-only compact form so OCR splits like `Betaal d:` still
|
||||
/// match `BETAALD`.
|
||||
/// Always uses word boundaries so `CHIP` does not match `CHIPS` and `PIN`
|
||||
/// does not match `SPINAZIE`. A letter-only compact form is a fallback for
|
||||
/// OCR splits like `Betaal d:` → `BETAALD` (only for keywords of length ≥ 5,
|
||||
/// so short tokens like `CHIP` cannot match inside longer product words).
|
||||
bool matchesKeyword(String line, String keyword) {
|
||||
final upper = line.toUpperCase();
|
||||
final key = keyword.toUpperCase();
|
||||
if (key.length <= 3) {
|
||||
return RegExp('\\b${RegExp.escape(key)}\\b').hasMatch(upper);
|
||||
if (RegExp('\\b${RegExp.escape(key)}\\b').hasMatch(upper)) {
|
||||
return true;
|
||||
}
|
||||
if (upper.contains(key)) return true;
|
||||
final compactLine = upper.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final compactKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
return compactKey.length >= 4 && compactLine.contains(compactKey);
|
||||
return compactKey.length >= 5 && compactLine.contains(compactKey);
|
||||
}
|
||||
|
||||
bool isStandaloneKeywordLine(String line, List<String> keywords) {
|
||||
|
||||
@@ -1,590 +1,86 @@
|
||||
/// Turns raw OCR text into [LineItem]s using receipt-parsing-keywords.json.
|
||||
/// Receipt parsing entry point: spatial OCR pipeline with text fallback.
|
||||
library;
|
||||
|
||||
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||
|
||||
import '../models/line_item.dart';
|
||||
import '../utils/money.dart';
|
||||
import '../models/ocr_element.dart';
|
||||
import 'receipt_ocr.dart';
|
||||
import 'receipt_parse_config.dart';
|
||||
|
||||
export 'receipt_parse_config.dart' show loadReceiptParseConfig;
|
||||
|
||||
/// Result of a full receipt parse, including totals used for review.
|
||||
class ReceiptParseResult {
|
||||
const ReceiptParseResult({
|
||||
required this.items,
|
||||
this.store,
|
||||
this.receiptTotal,
|
||||
this.totalDiscount = 0,
|
||||
this.validationPassed = true,
|
||||
});
|
||||
|
||||
final List<LineItem> items;
|
||||
final String? store;
|
||||
final double? receiptTotal;
|
||||
final double totalDiscount;
|
||||
final bool validationPassed;
|
||||
}
|
||||
|
||||
class _Qty {
|
||||
const _Qty({required this.count, required this.unitPrice, this.total});
|
||||
|
||||
final int count;
|
||||
final double unitPrice;
|
||||
final double? total;
|
||||
|
||||
double get lineTotal => total ?? roundMoney(count * unitPrice);
|
||||
}
|
||||
|
||||
class _Pending {
|
||||
_Pending({
|
||||
required this.name,
|
||||
this.unitPrice,
|
||||
this.quantity = 1,
|
||||
this.lineTotal,
|
||||
});
|
||||
|
||||
String name;
|
||||
double? unitPrice;
|
||||
int quantity;
|
||||
double? lineTotal;
|
||||
double discount = 0;
|
||||
}
|
||||
|
||||
enum _Kind {
|
||||
skip,
|
||||
item,
|
||||
nameOnly,
|
||||
quantity,
|
||||
discount,
|
||||
grandTotal,
|
||||
totalDiscount,
|
||||
priceOnly,
|
||||
}
|
||||
|
||||
class _Line {
|
||||
const _Line({
|
||||
required this.kind,
|
||||
required this.raw,
|
||||
this.name,
|
||||
this.amount,
|
||||
this.qty,
|
||||
});
|
||||
|
||||
final _Kind kind;
|
||||
final String raw;
|
||||
final String? name;
|
||||
final double? amount;
|
||||
final _Qty? qty;
|
||||
}
|
||||
|
||||
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b');
|
||||
final _phonePattern = RegExp(r'^\d{8,}$');
|
||||
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}[A-Za-z]?$");
|
||||
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
|
||||
|
||||
/// Jumbo-style OCR often adds a line total after `N X unit`.
|
||||
final _qtyWithTotal = RegExp(
|
||||
r'^\s*(\d+)\s*[xX×]\s*(-?\d+[,.]\d{2})(?:\s+(?:PER\s+STUK|PER\s+KG|ST\.?|STUKS?))?(?:\s+(-?\d+[,.]\d{2}))?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
export 'receipt_text_parser.dart' show ReceiptParseResult;
|
||||
import 'receipt_spatial_parser.dart';
|
||||
import 'receipt_text_parser.dart';
|
||||
|
||||
class ReceiptParser {
|
||||
ReceiptParser._();
|
||||
|
||||
static int _nextId = 0;
|
||||
|
||||
static String _id() => 'item-${++_nextId}';
|
||||
|
||||
static Future<void> loadConfig() => loadReceiptParseConfig();
|
||||
|
||||
static List<LineItem> parse(String rawText) => parseReceipt(rawText).items;
|
||||
|
||||
static ReceiptParseResult parseReceipt(String rawText) {
|
||||
final config = receiptParseConfig;
|
||||
final lines = rawText
|
||||
.split('\n')
|
||||
.map(_preprocess)
|
||||
.where((line) => line.isNotEmpty)
|
||||
.toList();
|
||||
/// Fallback when only flattened OCR text is available (tests, re-parse).
|
||||
static ReceiptParseResult parseReceipt(String rawText) =>
|
||||
ReceiptTextParser.parseReceipt(rawText);
|
||||
|
||||
if (_looksLikeColumnOcr(lines, config)) {
|
||||
return _parseColumns(lines, config, rawText);
|
||||
}
|
||||
return _parseSequential(lines, config, rawText);
|
||||
}
|
||||
/// Primary path: positioned ML Kit elements → row clustering → lookahead.
|
||||
static ReceiptParseResult parseReceiptFromOcr(OcrDocument document) =>
|
||||
_parseDocument(document);
|
||||
|
||||
static String _preprocess(String line) =>
|
||||
line.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
|
||||
static String _stripTax(String line, ReceiptParseConfig config) {
|
||||
return line.replaceFirst(config.trailingCategoryLetter, '').trim();
|
||||
}
|
||||
|
||||
static bool _looksLikeColumnOcr(
|
||||
List<String> lines,
|
||||
ReceiptParseConfig config,
|
||||
) {
|
||||
var run = 0;
|
||||
var maxRun = 0;
|
||||
var prices = 0;
|
||||
var names = 0;
|
||||
for (final line in lines) {
|
||||
final stripped = _stripTax(line, config);
|
||||
if (isPriceOnly(stripped)) {
|
||||
prices++;
|
||||
run++;
|
||||
if (run > maxRun) maxRun = run;
|
||||
} else {
|
||||
run = 0;
|
||||
if (_parseQty(stripped, config) == null &&
|
||||
!_isIgnorable(stripped, config)) {
|
||||
names++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxRun >= 4 && prices >= 4 && names >= 4;
|
||||
}
|
||||
|
||||
static ReceiptParseResult _parseSequential(
|
||||
List<String> lines,
|
||||
ReceiptParseConfig config,
|
||||
String rawText,
|
||||
) {
|
||||
final items = <LineItem>[];
|
||||
_Pending? pending;
|
||||
var receiptTotal = _readAmountAfterKeyword(
|
||||
lines,
|
||||
config,
|
||||
config.grandTotalKeywords,
|
||||
skipIf: (line) => config.matchesAny(line, [
|
||||
...config.totalDiscountKeywords,
|
||||
...config.taxInclusiveTotalKeywords,
|
||||
...config.subtotalKeywords,
|
||||
]),
|
||||
);
|
||||
var totalDiscount = 0.0;
|
||||
var stop = false;
|
||||
|
||||
void flush() {
|
||||
final current = pending;
|
||||
pending = null;
|
||||
if (current == null) return;
|
||||
final total = current.lineTotal ?? current.unitPrice;
|
||||
if (total == null) return;
|
||||
items.add(_toItem(current, total));
|
||||
}
|
||||
|
||||
for (var i = 0; i < lines.length && !stop; i++) {
|
||||
final classified = _classify(lines[i], config);
|
||||
switch (classified.kind) {
|
||||
case _Kind.skip:
|
||||
continue;
|
||||
case _Kind.item:
|
||||
flush();
|
||||
pending = _Pending(
|
||||
name: classified.name!,
|
||||
unitPrice: classified.amount,
|
||||
quantity: classified.qty?.count ?? 1,
|
||||
lineTotal: classified.qty == null
|
||||
? classified.amount
|
||||
: classified.qty!.lineTotal,
|
||||
);
|
||||
if (classified.qty != null) {
|
||||
pending!.unitPrice = classified.qty!.unitPrice;
|
||||
}
|
||||
case _Kind.nameOnly:
|
||||
flush();
|
||||
pending = _Pending(name: classified.name ?? classified.raw);
|
||||
case _Kind.quantity:
|
||||
final qty = classified.qty!;
|
||||
if (pending != null) {
|
||||
pending!
|
||||
..quantity = qty.count
|
||||
..unitPrice = qty.unitPrice
|
||||
..lineTotal = qty.lineTotal;
|
||||
}
|
||||
case _Kind.discount:
|
||||
var amount = classified.amount;
|
||||
if (amount == null && i + 1 < lines.length) {
|
||||
amount = tryParsePrice(_stripTax(lines[i + 1], config));
|
||||
if (amount != null) i++;
|
||||
}
|
||||
if (amount == null) continue;
|
||||
if (amount > 0) amount = -amount;
|
||||
if (pending != null) {
|
||||
pending!.discount += amount;
|
||||
pending!.lineTotal = roundMoney(
|
||||
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
|
||||
);
|
||||
} else {
|
||||
totalDiscount += amount;
|
||||
}
|
||||
case _Kind.totalDiscount:
|
||||
var amount = classified.amount;
|
||||
if (amount == null && i + 1 < lines.length) {
|
||||
amount = tryParsePrice(_stripTax(lines[i + 1], config));
|
||||
if (amount != null) i++;
|
||||
}
|
||||
if (amount != null) {
|
||||
totalDiscount += amount > 0 ? -amount : amount;
|
||||
}
|
||||
case _Kind.grandTotal:
|
||||
flush();
|
||||
if (classified.amount != null) {
|
||||
receiptTotal = classified.amount;
|
||||
stop = true;
|
||||
}
|
||||
case _Kind.priceOnly:
|
||||
final amount = classified.amount!;
|
||||
if (amount < 0 && pending != null) {
|
||||
pending!.discount += amount;
|
||||
pending!.lineTotal = roundMoney(
|
||||
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
flush();
|
||||
|
||||
return _result(
|
||||
items: items,
|
||||
rawText: rawText,
|
||||
receiptTotal: receiptTotal,
|
||||
totalDiscount: totalDiscount,
|
||||
);
|
||||
}
|
||||
|
||||
static ReceiptParseResult _parseColumns(
|
||||
List<String> lines,
|
||||
ReceiptParseConfig config,
|
||||
String rawText,
|
||||
) {
|
||||
final names = <({String name, int? quantity, double? unitPrice})>[];
|
||||
final prices = <double>[];
|
||||
final discountSlots = <int>[];
|
||||
final discounts = <double>[];
|
||||
|
||||
for (final raw in lines) {
|
||||
final line = _stripTax(raw, config);
|
||||
if (isPriceOnly(line)) {
|
||||
final price = tryParsePrice(line);
|
||||
if (price == null) continue;
|
||||
if (price < 0) {
|
||||
discounts.add(price);
|
||||
} else {
|
||||
prices.add(price);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (_isIgnorable(line, config)) continue;
|
||||
if (config.matchesAny(line, [
|
||||
...config.grandTotalKeywords,
|
||||
...config.subtotalKeywords,
|
||||
...config.taxInclusiveTotalKeywords,
|
||||
...config.totalDiscountKeywords,
|
||||
])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final qty = _parseQty(line, config);
|
||||
if (qty != null) {
|
||||
if (names.isNotEmpty) {
|
||||
final last = names.removeLast();
|
||||
names.add((
|
||||
name: last.name,
|
||||
quantity: qty.count,
|
||||
unitPrice: qty.unitPrice,
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.matchesAny(line, config.discountKeywords)) {
|
||||
if (names.isNotEmpty) discountSlots.add(names.length - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
names.add((name: line, quantity: null, unitPrice: null));
|
||||
}
|
||||
|
||||
final count = names.length < prices.length ? names.length : prices.length;
|
||||
final items = <LineItem>[
|
||||
for (var i = 0; i < count; i++)
|
||||
LineItem(
|
||||
id: _id(),
|
||||
name: names[i].name,
|
||||
price: prices[i],
|
||||
quantity: names[i].quantity,
|
||||
unitPrice: names[i].unitPrice,
|
||||
),
|
||||
];
|
||||
|
||||
for (var i = 0; i < discountSlots.length && i < discounts.length; i++) {
|
||||
final index = discountSlots[i];
|
||||
if (index < 0 || index >= items.length) continue;
|
||||
final item = items[index];
|
||||
final discount = discounts[i];
|
||||
items[index] = item.copyWith(
|
||||
price: roundMoney(item.price + discount),
|
||||
discount: roundMoney(item.discount + discount),
|
||||
);
|
||||
}
|
||||
|
||||
final receiptTotal = _readAmountAfterKeyword(
|
||||
lines,
|
||||
config,
|
||||
config.grandTotalKeywords,
|
||||
skipIf: (line) => config.matchesAny(line, [
|
||||
...config.totalDiscountKeywords,
|
||||
...config.taxInclusiveTotalKeywords,
|
||||
...config.subtotalKeywords,
|
||||
]),
|
||||
);
|
||||
var totalDiscount = discounts.fold<double>(0, (sum, value) => sum + value);
|
||||
totalDiscount +=
|
||||
_readAmountAfterKeyword(lines, config, config.totalDiscountKeywords) ??
|
||||
0;
|
||||
|
||||
return _result(
|
||||
items: items,
|
||||
rawText: rawText,
|
||||
receiptTotal: receiptTotal,
|
||||
totalDiscount: totalDiscount,
|
||||
);
|
||||
}
|
||||
|
||||
static LineItem _toItem(_Pending pending, double total) {
|
||||
return LineItem(
|
||||
id: _id(),
|
||||
name: pending.name,
|
||||
price: roundMoney(total),
|
||||
quantity: pending.quantity == 1 ? null : pending.quantity,
|
||||
unitPrice: pending.quantity == 1 ? null : pending.unitPrice,
|
||||
discount: pending.discount == 0 ? 0 : roundMoney(pending.discount),
|
||||
);
|
||||
}
|
||||
|
||||
static ReceiptParseResult _result({
|
||||
required List<LineItem> items,
|
||||
required String rawText,
|
||||
double? receiptTotal,
|
||||
double totalDiscount = 0,
|
||||
static ReceiptParseResult parseRecognizedText(
|
||||
RecognizedText recognized, {
|
||||
required double imageWidth,
|
||||
required double imageHeight,
|
||||
}) {
|
||||
final kept = items
|
||||
.where((item) => item.name.trim().isNotEmpty && item.price > 0)
|
||||
.toList();
|
||||
final sum = roundMoney(
|
||||
kept.fold<double>(0, (total, item) => total + item.price),
|
||||
final document = ReceiptOcr.fromRecognizedText(
|
||||
recognized,
|
||||
imageWidth: imageWidth,
|
||||
imageHeight: imageHeight,
|
||||
);
|
||||
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 ReceiptParseResult _parseDocument(OcrDocument document) {
|
||||
if (ReceiptTextParser.looksLikeColumnOcr(document.flattenedText)) {
|
||||
return _withImageDimensions(
|
||||
ReceiptTextParser.parseReceipt(document.flattenedText),
|
||||
document,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _detectStore(String rawText) {
|
||||
final config = receiptParseConfig;
|
||||
final header = rawText
|
||||
.split('\n')
|
||||
.map(_preprocess)
|
||||
.where((line) => line.isNotEmpty)
|
||||
.take(8)
|
||||
.join('\n')
|
||||
.toUpperCase();
|
||||
final stores = [...config.knownStores]
|
||||
..sort((a, b) => b.length.compareTo(a.length));
|
||||
for (final store in stores) {
|
||||
if (RegExp('\\b${RegExp.escape(store)}\\b').hasMatch(header)) {
|
||||
return _canonicalStore(store);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _canonicalStore(String raw) {
|
||||
const aliases = {
|
||||
'AH': 'Albert Heijn',
|
||||
'ALBERT HEIJN': 'Albert Heijn',
|
||||
'JUMBO': 'Jumbo',
|
||||
'LIDL': 'Lidl',
|
||||
'KRUIDVAT': 'Kruidvat',
|
||||
'ALDI': 'Aldi',
|
||||
'PLUS': 'Plus',
|
||||
'DIRK': 'Dirk',
|
||||
'COOP': 'Coop',
|
||||
'SPAR': 'SPAR',
|
||||
'VOMAR': 'Vomar',
|
||||
'EKOPLAZA': 'Ekoplaza',
|
||||
'HOOGVLIET': 'Hoogvliet',
|
||||
'ACTION': 'Action',
|
||||
'ETOS': 'Etos',
|
||||
'WORLD TOKO': 'World Toko',
|
||||
};
|
||||
return aliases[raw.toUpperCase()] ?? raw;
|
||||
}
|
||||
|
||||
static double? _readAmountAfterKeyword(
|
||||
List<String> lines,
|
||||
ReceiptParseConfig config,
|
||||
List<String> keywords, {
|
||||
bool Function(String line)? skipIf,
|
||||
}) {
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final line = _stripTax(lines[i], config);
|
||||
if (!config.matchesAny(line, keywords)) continue;
|
||||
if (skipIf != null && skipIf(line)) continue;
|
||||
final trailing = _trailingPrice(line, config);
|
||||
if (trailing != null) return trailing;
|
||||
if (i + 1 < lines.length) {
|
||||
final next = tryParsePrice(_stripTax(lines[i + 1], config));
|
||||
if (next != null) return next;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static _Line _classify(String raw, ReceiptParseConfig config) {
|
||||
final line = _stripTax(raw, config);
|
||||
if (_isIgnorable(line, config)) {
|
||||
return _Line(kind: _Kind.skip, raw: line);
|
||||
}
|
||||
if (config.matchesAny(line, config.totalDiscountKeywords)) {
|
||||
return _Line(
|
||||
kind: _Kind.totalDiscount,
|
||||
raw: line,
|
||||
amount: _trailingPrice(line, config),
|
||||
final spatial = ReceiptSpatialParser.parse(document);
|
||||
if (_shouldFallbackToText(spatial)) {
|
||||
return _withImageDimensions(
|
||||
ReceiptTextParser.parseReceipt(document.flattenedText),
|
||||
document,
|
||||
);
|
||||
}
|
||||
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),
|
||||
);
|
||||
return spatial;
|
||||
}
|
||||
|
||||
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;
|
||||
static bool _shouldFallbackToText(ReceiptParseResult spatial) {
|
||||
if (spatial.items.isEmpty) return true;
|
||||
final flagged =
|
||||
spatial.items.where((item) => item.needsManualReview).length;
|
||||
if (flagged > 0) return true;
|
||||
if (spatial.items.any((item) => item.price <= 0)) return true;
|
||||
if (spatial.receiptTotal != null && !spatial.validationPassed) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static double? _trailingPrice(String line, ReceiptParseConfig config) {
|
||||
final match = RegExp(r'(-?\d+[.,]\d{2})\s*$').firstMatch(line);
|
||||
if (match == null) return null;
|
||||
return tryParsePrice(match.group(1)!);
|
||||
}
|
||||
|
||||
static _Qty? _parseQty(String line, ReceiptParseConfig config) {
|
||||
final match =
|
||||
_qtyWithTotal.firstMatch(line) ??
|
||||
config.quantityStandalone.firstMatch(line);
|
||||
if (match == null) return null;
|
||||
final count = int.tryParse(match.group(1)!);
|
||||
final unit = tryParsePrice(match.group(2)!);
|
||||
if (count == null || count < 1 || unit == null) return null;
|
||||
double? total;
|
||||
if (match.groupCount >= 3) {
|
||||
final raw = match.group(3);
|
||||
if (raw != null && RegExp(r'^-?\d+[.,]\d{2}$').hasMatch(raw)) {
|
||||
total = tryParsePrice(raw);
|
||||
}
|
||||
}
|
||||
return _Qty(count: count, unitPrice: unit, total: total);
|
||||
static ReceiptParseResult _withImageDimensions(
|
||||
ReceiptParseResult result,
|
||||
OcrDocument document,
|
||||
) {
|
||||
return ReceiptParseResult(
|
||||
items: result.items,
|
||||
store: result.store,
|
||||
receiptTotal: result.receiptTotal,
|
||||
totalDiscount: result.totalDiscount,
|
||||
validationPassed: result.validationPassed,
|
||||
expectedItemCount: result.expectedItemCount,
|
||||
ocrImageWidth: document.imageWidth,
|
||||
ocrImageHeight: document.imageHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'models/line_item.dart';
|
||||
import 'models/shop.dart';
|
||||
import 'product_detail_screen.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'services/receipt_capture.dart';
|
||||
import 'utils/dates.dart';
|
||||
import 'utils/money.dart';
|
||||
import 'widgets/confirm_dialog.dart';
|
||||
@@ -37,6 +38,7 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
bool _editing = false;
|
||||
List<String> _tagSuggestions = [];
|
||||
int _loadToken = 0;
|
||||
bool _addingReceipt = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -150,6 +152,42 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addFromReceipt() async {
|
||||
final shop = _shop;
|
||||
if (shop == null || _addingReceipt) return;
|
||||
setState(() => _addingReceipt = true);
|
||||
try {
|
||||
final captured = await captureAndParseReceipt(context);
|
||||
if (captured == null || !mounted) return;
|
||||
|
||||
var added = 0;
|
||||
for (final item in captured.items) {
|
||||
await widget.repository.addShopItem(shopId: shop.id, item: item);
|
||||
added++;
|
||||
}
|
||||
if (!mounted) return;
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
added == 1
|
||||
? 'Added 1 item from receipt.'
|
||||
: 'Added $added items from receipt.',
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not add items from this receipt.')),
|
||||
);
|
||||
_load();
|
||||
} finally {
|
||||
if (mounted) setState(() => _addingReceipt = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editItem(ShopItem item) async {
|
||||
final result = await showItemEditSheet(
|
||||
context,
|
||||
@@ -299,11 +337,41 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: _editing
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: _addItem,
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: _addingReceipt ? null : _addFromReceipt,
|
||||
icon: _addingReceipt
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.receipt_long_outlined),
|
||||
label: const Text('Add receipt'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _addingReceipt ? null : _addItem,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add product'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
body: Builder(
|
||||
|
||||
@@ -170,6 +170,8 @@ class _ItemEditSheetState extends State<ItemEditSheet> {
|
||||
discount: widget.item?.price == roundMoney(resolvedTotal)
|
||||
? widget.item?.discount ?? 0
|
||||
: 0,
|
||||
sourceBounds: widget.item?.sourceBounds,
|
||||
needsManualReview: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/// Overlays a highlight on the receipt photo for a parsed line item.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/receipt_bounds.dart';
|
||||
|
||||
class ReceiptHighlightOverlay extends StatelessWidget {
|
||||
const ReceiptHighlightOverlay({
|
||||
super.key,
|
||||
required this.imagePath,
|
||||
required this.bounds,
|
||||
this.maxHeight = 280,
|
||||
});
|
||||
|
||||
final String imagePath;
|
||||
final ReceiptBounds bounds;
|
||||
final double maxHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
Image.file(
|
||||
File(imagePath),
|
||||
fit: BoxFit.contain,
|
||||
height: maxHeight,
|
||||
width: constraints.maxWidth,
|
||||
),
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: _BoundsPainter(
|
||||
bounds: bounds,
|
||||
color: scheme.primary.withValues(alpha: 0.35),
|
||||
stroke: scheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BoundsPainter extends CustomPainter {
|
||||
_BoundsPainter({
|
||||
required this.bounds,
|
||||
required this.color,
|
||||
required this.stroke,
|
||||
});
|
||||
|
||||
final ReceiptBounds bounds;
|
||||
final Color color;
|
||||
final Color stroke;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (bounds.isEmpty) return;
|
||||
final rect = Rect.fromLTRB(
|
||||
bounds.left * size.width,
|
||||
bounds.top * size.height,
|
||||
bounds.right * size.width,
|
||||
bounds.bottom * size.height,
|
||||
);
|
||||
canvas.drawRect(rect, Paint()..color = color);
|
||||
canvas.drawRect(
|
||||
rect,
|
||||
Paint()
|
||||
..color = stroke
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _BoundsPainter oldDelegate) {
|
||||
return oldDelegate.bounds != bounds ||
|
||||
oldDelegate.color != color ||
|
||||
oldDelegate.stroke != stroke;
|
||||
}
|
||||
}
|
||||
@@ -182,5 +182,11 @@
|
||||
"ART.",
|
||||
"ARTIKELEN"
|
||||
]
|
||||
},
|
||||
|
||||
"spatial_parsing": {
|
||||
"_comment": "Bounding-box pipeline settings. row_cluster_height_factor multiplies median element height when grouping elements into rows (only used when ML Kit lines are unavailable — keep below 0.6 so adjacent receipt lines stay separate). price_column_width_fraction is the right-hand slice of receipt content width treated as the price column.",
|
||||
"row_cluster_height_factor": 0.55,
|
||||
"price_column_width_fraction": 0.25
|
||||
}
|
||||
}
|
||||
|
||||
+92
-64
@@ -16,30 +16,53 @@ without assuming a single fixed format.
|
||||
|
||||
## 1. Input assumptions
|
||||
|
||||
- Input is raw OCR text, one line per detected text line, in top-to-bottom
|
||||
order as it appeared on the receipt.
|
||||
- Input is structured OCR from ML Kit: **blocks → lines → elements**, each
|
||||
element carrying its own pixel bounding box (`left`, `top`, `right`,
|
||||
`bottom`) on the receipt image. Do **not** rely on the flattened `.text`
|
||||
property for parsing — it discards the two-column layout.
|
||||
- Prices use European format: comma as decimal separator (e.g. `2,89`),
|
||||
period as thousands separator if present (rare on receipts this size).
|
||||
- OCR may introduce noise: misread characters, extra/missing spaces,
|
||||
occasional dropped or merged lines. The parser should be tolerant of
|
||||
minor spacing irregularities but does not need to do spell-correction.
|
||||
- Receipt photos vary in resolution and crop; all column thresholds must be
|
||||
computed **relative to the detected content width**, not as fixed pixels.
|
||||
|
||||
## 2. Preprocessing (run before classification)
|
||||
|
||||
1. Trim leading/trailing whitespace on every line; collapse multiple
|
||||
internal spaces to one.
|
||||
2. Normalize decimal separators: treat `,` as the decimal point in any
|
||||
number matching `\d+[,.]\d{2}`. Convert to a standard `.` internally
|
||||
for numeric storage, but remember the original format was comma-based
|
||||
(useful for locale-aware re-parsing if needed).
|
||||
3. Drop fully empty lines.
|
||||
4. Do NOT drop short lines outright (e.g. a lone `BROCCOLI` or a lone
|
||||
1. Extract every text **element** with its bounding box from ML Kit.
|
||||
2. **Rebuild rows** — do not trust ML Kit's line segmentation:
|
||||
- Sort elements by Y-center (top to bottom).
|
||||
- Cluster into rows where Y-centers fall within one line height of each
|
||||
other. Threshold = `median_element_height × row_cluster_height_factor`
|
||||
(default factor **1.2**, configurable in `spatial_parsing`).
|
||||
- Within each row, sort elements left-to-right by X position.
|
||||
3. Join element text within a row (single spaces) for keyword/regex checks.
|
||||
4. Trim leading/trailing whitespace; collapse multiple internal spaces to one.
|
||||
5. Normalize decimal separators: treat `,` as the decimal point in any number
|
||||
matching `\d+[,.]\d{2}`. Convert to `.` internally for storage.
|
||||
6. Do NOT drop short rows outright (e.g. a lone `BROCCOLI` or a lone
|
||||
`2 X 0,95`) — both are meaningful in different receipt layouts.
|
||||
|
||||
## 3. Core line classification
|
||||
## 3. Core row classification
|
||||
|
||||
Classify every line into one of these categories, in this priority order
|
||||
(check top-down, first match wins):
|
||||
Classify every **row** (not flat OCR line) into one of these categories.
|
||||
Within a row, use **X-position column logic** alongside regex:
|
||||
|
||||
- Compute content width = rightmost − leftmost element across the receipt.
|
||||
- **Price column** = rightmost `price_column_width_fraction` (default **25%**)
|
||||
of that width. A row has a price when a token in the price column matches
|
||||
`\d+[,.]\d{2}`. Everything left of the price column is the candidate name.
|
||||
- A row with no price-column match is **name-only**.
|
||||
|
||||
Check categories in this priority order (first match wins):
|
||||
|
||||
### 3.0 QUANTITY_MODIFIER (checked first — unconditional)
|
||||
|
||||
- If the **entire row** matches (not a substring):
|
||||
`^\d+\s*[xX]\s*\d+[,.]\d{2}\s*(PER\s+STUK|PER\s+KG|ST\.?|STUKS?)?$`
|
||||
- Classify as QUANTITY_MODIFIER **unconditionally**. A pure `N X price` row
|
||||
can never be a standalone product, even if a price-column token is present.
|
||||
|
||||
### 3.1 STORE_HEADER
|
||||
- The first 1–5 lines of the receipt, before the first recognizable
|
||||
@@ -72,13 +95,13 @@ Classify every line into one of these categories, in this priority order
|
||||
- This is the "complete" case: name + price on the same line, quantity
|
||||
of 1 implied unless a QUANTITY_MODIFIER line follows (section 3.4).
|
||||
|
||||
### 3.4 QUANTITY_MODIFIER (sub-line, belongs to previous or next ITEM)
|
||||
- A line matching the pattern: `<int> X <price>` optionally followed by
|
||||
a unit word (e.g. `PER STUK`), e.g.:
|
||||
- `2 X 2,79`
|
||||
- `2 X 3,99 PER STUK`
|
||||
- This line does NOT introduce a new product — it modifies the item on
|
||||
an adjacent line.
|
||||
### 3.4 QUANTITY_MODIFIER (sub-row, belongs to previous or next ITEM)
|
||||
|
||||
See **§3.0** — pure `N X price` rows are detected before any other type.
|
||||
The rules below describe how they attach to items:
|
||||
|
||||
- This row does NOT introduce a new product — it modifies the item on
|
||||
an adjacent row.
|
||||
- Two observed variants — the parser must detect which applies:
|
||||
- **Variant A (Jumbo-style):** product name appears on its own line
|
||||
with NO price. The next line is `N X unit_price`. The line total
|
||||
@@ -154,66 +177,71 @@ Classify every line into one of these categories, in this priority order
|
||||
|
||||
## 4. Item construction algorithm (pseudocode)
|
||||
|
||||
Uses a **one-row lookahead buffer** — do not emit an item until the next
|
||||
row is inspected (or the receipt ends).
|
||||
|
||||
```
|
||||
items = []
|
||||
pending_item = None # name found, price/qty not yet resolved
|
||||
|
||||
for line in classified_lines:
|
||||
if line.type == ITEM:
|
||||
if pending_item:
|
||||
items.append(pending_item) # close out previous item first
|
||||
pending_item = { name: line.name, unit_price: line.price,
|
||||
quantity: 1, line_total: line.price,
|
||||
discount: 0 }
|
||||
|
||||
elif line.type == QUANTITY_MODIFIER:
|
||||
if pending_item and pending_item.unit_price is None:
|
||||
# Variant A: name-only line was pending, this line completes it
|
||||
pending_item.quantity = line.qty
|
||||
pending_item.unit_price = line.price
|
||||
pending_item.line_total = line.qty * line.price
|
||||
elif pending_item:
|
||||
# Variant B: name+price already set, this line refines/
|
||||
# confirms it (overwrite quantity/unit price, recompute total)
|
||||
pending_item.quantity = line.qty
|
||||
pending_item.unit_price = line.price
|
||||
pending_item.line_total = line.qty * line.price
|
||||
else:
|
||||
# Orphan quantity line with no preceding item — flag for
|
||||
# manual review rather than silently dropping
|
||||
flag_for_review(line)
|
||||
|
||||
elif line.type == DISCOUNT:
|
||||
if pending_item:
|
||||
pending_item.discount += line.amount # amount is negative
|
||||
pending_item.line_total += line.amount
|
||||
else:
|
||||
receipt.total_discount += line.amount
|
||||
|
||||
elif line.type in (SUBTOTAL, TOTAL):
|
||||
for row in classified_rows:
|
||||
if row.type == QUANTITY_MODIFIER:
|
||||
if pending_item:
|
||||
pending_item.quantity = row.qty
|
||||
pending_item.unit_price = row.price
|
||||
pending_item.line_total = row.qty * row.price
|
||||
merge bounding boxes from row into pending_item
|
||||
items.append(pending_item)
|
||||
pending_item = None
|
||||
if is_grand_total(line):
|
||||
receipt.total = line.amount
|
||||
break # stop item parsing here
|
||||
continue # orphan modifier without pending → ignore
|
||||
|
||||
# catch any trailing pending item if receipt ended oddly
|
||||
if pending_item:
|
||||
items.append(pending_item)
|
||||
if row.type == NAME_ONLY:
|
||||
if pending_item:
|
||||
items.append(pending_item with needs_review if no price)
|
||||
pending_item = { name: row.name, unit_price: None, quantity: 1,
|
||||
line_total: None, discount: 0, bounds: row.bounds }
|
||||
peek next row:
|
||||
if next.type == QUANTITY_MODIFIER → defer emit (handled above)
|
||||
else → append pending_item flagged needs_review, clear pending
|
||||
continue
|
||||
|
||||
if row.type == ITEM:
|
||||
if pending_item:
|
||||
items.append(pending_item with needs_review if no price)
|
||||
pending_item = { name: row.name, unit_price: row.price,
|
||||
quantity: 1, line_total: row.price,
|
||||
discount: 0, bounds: row.bounds }
|
||||
peek next row:
|
||||
if next.type == QUANTITY_MODIFIER → defer emit (modifier overrides qty/unit)
|
||||
else → append pending_item, clear pending
|
||||
continue
|
||||
|
||||
if row.type == DISCOUNT:
|
||||
... (unchanged: attach to pending_item or receipt.total_discount)
|
||||
|
||||
if row.type in (SUBTOTAL, TOTAL):
|
||||
flush pending_item
|
||||
if is_grand_total(row):
|
||||
receipt.total = row.amount
|
||||
break
|
||||
|
||||
flush any trailing pending_item
|
||||
```
|
||||
|
||||
Each emitted item keeps **source bounding box(es)** (normalized 0–1 on the
|
||||
receipt image) for highlight overlays in the review UI.
|
||||
|
||||
## 5. Validation pass (do this after parsing, before showing results to user)
|
||||
|
||||
1. Sum `line_total` across all parsed items.
|
||||
2. Add `receipt.total_discount` if it wasn't already folded into
|
||||
individual item discounts.
|
||||
3. Compare the result to `receipt.total` (the grand total line).
|
||||
4. If they match within 0.01 → high confidence, no flags needed.
|
||||
5. If they don't match → mark the WHOLE receipt as "needs review" and
|
||||
surface it clearly in the review/edit screen (don't just flag
|
||||
individual lines — a mismatch means something in the whole parse is
|
||||
probably off, e.g. a missed line or a misclassified discount).
|
||||
4. If the receipt prints an item count (e.g. `Aantal Artikel: N`), compare
|
||||
`N` to the number of parsed items.
|
||||
5. If totals match within 0.01 **and** item count matches (when present)
|
||||
**and** no item is flagged `needs_manual_review` → high confidence.
|
||||
6. If any check fails → mark the **whole receipt** as "needs review".
|
||||
|
||||
## 6. Known store-specific quirks to encode as configurable rules
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:groci/models/ocr_element.dart';
|
||||
import 'package:groci/services/receipt_parser.dart';
|
||||
|
||||
void main() {
|
||||
@@ -15,6 +16,35 @@ CHANGE 1.50
|
||||
expect(items.map((item) => item.price), [1.50, 2.00]);
|
||||
});
|
||||
|
||||
test('parses name on one line and price on the next', () {
|
||||
final items = ReceiptParser.parse('''
|
||||
MILK
|
||||
1,50
|
||||
BREAD
|
||||
2,00
|
||||
Totaal 3,50
|
||||
''');
|
||||
|
||||
expect(items, hasLength(2));
|
||||
expect(items.map((item) => item.name), ['MILK', 'BREAD']);
|
||||
expect(items.map((item) => item.price), [1.50, 2.00]);
|
||||
});
|
||||
|
||||
test('pairs two name lines with two price lines from short column OCR', () {
|
||||
final items = ReceiptParser.parse('''
|
||||
Jumbo
|
||||
MILK
|
||||
BREAD
|
||||
1,50
|
||||
2,00
|
||||
Totaal 3,50
|
||||
''');
|
||||
|
||||
expect(items, hasLength(2));
|
||||
expect(items.map((item) => item.name), ['MILK', 'BREAD']);
|
||||
expect(items.map((item) => item.price), [1.50, 2.00]);
|
||||
});
|
||||
|
||||
test('uses the next line when quantity and total are split', () {
|
||||
final items = ReceiptParser.parse('''
|
||||
EGGS
|
||||
@@ -123,6 +153,72 @@ Bedr.Incl 2,09
|
||||
expect(items.last.price, 6.20);
|
||||
expect(items, hasLength(26));
|
||||
});
|
||||
|
||||
test('does not treat CHIPS as payment keyword CHIP', () {
|
||||
final items = ReceiptParser.parse('''
|
||||
AH Protein
|
||||
AH Chips
|
||||
1,49
|
||||
0.69
|
||||
''');
|
||||
|
||||
expect(items, hasLength(2));
|
||||
expect(items.map((item) => item.name), ['AH Protein', 'AH Chips']);
|
||||
expect(items.map((item) => item.price), [1.49, 0.69]);
|
||||
});
|
||||
|
||||
test('scan path uses column parser for Jumbo name-then-price OCR dump', () {
|
||||
const ocr = '''
|
||||
BIO VASTKOKEND AARD
|
||||
2 X 1,99
|
||||
AARDBEIEN
|
||||
2 X 3,99
|
||||
ACTIE AARDBEIEN
|
||||
BROCCOLII
|
||||
PREI
|
||||
CITROENEN
|
||||
KOMKOMMER
|
||||
2 X 0,95
|
||||
ACTIE KOMKOMMER
|
||||
BOL KNOFLOOK
|
||||
BROODSTICKS OLIJF
|
||||
4 X 1.25
|
||||
3,98
|
||||
7,98
|
||||
-2,00
|
||||
1,24
|
||||
0,69
|
||||
2,11
|
||||
1,90
|
||||
-0,21
|
||||
1,79
|
||||
5,00
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parseReceiptFromOcr(
|
||||
OcrDocument(
|
||||
elements: const [],
|
||||
lines: const [],
|
||||
imageWidth: 800,
|
||||
imageHeight: 1200,
|
||||
flattenedText: ocr,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.items, hasLength(8));
|
||||
expect(result.items[0].name, 'BIO VASTKOKEND AARD');
|
||||
expect(result.items[0].quantity, 2);
|
||||
expect(result.items[0].price, 3.98);
|
||||
expect(result.items[1].name, 'AARDBEIEN');
|
||||
expect(result.items[1].price, 5.98);
|
||||
expect(result.items[1].discount, -2.00);
|
||||
expect(result.items[5].name, 'KOMKOMMER');
|
||||
expect(result.items[5].price, 1.69);
|
||||
expect(result.items.last.name, 'BROODSTICKS OLIJF');
|
||||
expect(result.items.last.quantity, 4);
|
||||
expect(result.items.last.price, 5.00);
|
||||
expect(result.items.every((item) => !item.needsManualReview), isTrue);
|
||||
});
|
||||
}
|
||||
|
||||
const _jumboColumnOcr = '''
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user