Compare commits
3
Commits
cdf46bb12a
...
98574af903
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,38 @@
|
||||
/// 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();
|
||||
}
|
||||
|
||||
/// All positioned elements from one ML Kit recognition pass.
|
||||
class OcrDocument {
|
||||
const OcrDocument({
|
||||
required this.elements,
|
||||
required this.imageWidth,
|
||||
required this.imageHeight,
|
||||
required this.flattenedText,
|
||||
});
|
||||
|
||||
final List<OcrElement> elements;
|
||||
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;
|
||||
}
|
||||
+62
-8
@@ -13,6 +13,7 @@ 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 +27,8 @@ class ReviewScreen extends StatefulWidget {
|
||||
this.supermarketNames = kSupermarkets,
|
||||
this.needsReview = false,
|
||||
this.detectedStore,
|
||||
this.ocrImageWidth,
|
||||
this.ocrImageHeight,
|
||||
});
|
||||
|
||||
final List<LineItem> items;
|
||||
@@ -35,6 +38,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,6 +54,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
final _tripName = TextEditingController();
|
||||
List<String> _tags = [];
|
||||
List<String> _tagSuggestions = [];
|
||||
String? _highlightedItemId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -88,6 +94,11 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
return _supermarket;
|
||||
}
|
||||
|
||||
bool get _hasReceipt {
|
||||
final path = widget.imagePath;
|
||||
return path != null && path.isNotEmpty && File(path).existsSync();
|
||||
}
|
||||
|
||||
Future<void> _edit({LineItem? existing}) async {
|
||||
final result = await showItemEditSheet(context, item: existing);
|
||||
if (result == null || !mounted) return;
|
||||
@@ -98,9 +109,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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -181,9 +203,12 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
names.add(_supermarket!);
|
||||
}
|
||||
final receiptPath = widget.imagePath;
|
||||
final hasReceipt = receiptPath != null &&
|
||||
receiptPath.isNotEmpty &&
|
||||
File(receiptPath).existsSync();
|
||||
final highlighted = _highlightedItemId == null
|
||||
? null
|
||||
: _items.cast<LineItem?>().firstWhere(
|
||||
(item) => item?.id == _highlightedItemId,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -207,7 +232,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Parsed totals do not match the receipt. Check the items before saving.',
|
||||
'Item count or totals do not match the receipt, or some lines need manual review. Check highlighted items before saving.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
@@ -325,6 +350,29 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
});
|
||||
},
|
||||
child: ListTile(
|
||||
selected: _highlightedItemId == item.id,
|
||||
selectedTileColor: theme.colorScheme.primaryContainer
|
||||
.withValues(alpha: 0.35),
|
||||
leading: item.sourceBounds != null && _hasReceipt
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
_highlightedItemId == item.id
|
||||
? Icons.visibility
|
||||
: Icons.visibility_outlined,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: 'Show on receipt',
|
||||
onPressed: _saving
|
||||
? null
|
||||
: () => _toggleHighlight(item),
|
||||
)
|
||||
: item.needsManualReview
|
||||
? Icon(
|
||||
Icons.flag_outlined,
|
||||
color: theme.colorScheme.error,
|
||||
size: 20,
|
||||
)
|
||||
: null,
|
||||
title: Text(item.name),
|
||||
subtitle: _itemSubtitle(item),
|
||||
trailing: DiscountedPrice(item: item),
|
||||
@@ -337,18 +385,24 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasReceipt)
|
||||
if (_hasReceipt)
|
||||
ExpansionTile(
|
||||
initiallyExpanded: highlighted?.sourceBounds != null,
|
||||
title: const Text('See receipt'),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: GestureDetector(
|
||||
child: highlighted?.sourceBounds != null
|
||||
? ReceiptHighlightOverlay(
|
||||
imagePath: receiptPath!,
|
||||
bounds: highlighted!.sourceBounds!,
|
||||
)
|
||||
: GestureDetector(
|
||||
onTap: _openReceipt,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(
|
||||
File(receiptPath),
|
||||
File(receiptPath!),
|
||||
fit: BoxFit.contain,
|
||||
height: 220,
|
||||
width: double.infinity,
|
||||
|
||||
+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,66 @@
|
||||
/// 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>[];
|
||||
var maxRight = 0.0;
|
||||
var maxBottom = 0.0;
|
||||
|
||||
for (final block in recognized.blocks) {
|
||||
for (final line in block.lines) {
|
||||
if (line.elements.isNotEmpty) {
|
||||
for (final element in line.elements) {
|
||||
final box = element.boundingBox;
|
||||
elements.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;
|
||||
elements.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final width = imageWidth > 0 ? imageWidth : maxRight;
|
||||
final height = imageHeight > 0 ? imageHeight : maxBottom;
|
||||
|
||||
return OcrDocument(
|
||||
elements: elements,
|
||||
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 = 1.2,
|
||||
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,9 +103,31 @@ class ReceiptParseConfig {
|
||||
),
|
||||
noiseKeywords: list('noise_footer_keywords'),
|
||||
itemCountKeywords: list('line_item_count_keywords'),
|
||||
rowClusterHeightFactor: _spatialDouble(
|
||||
json,
|
||||
'row_cluster_height_factor',
|
||||
fallback: 1.2,
|
||||
),
|
||||
priceColumnWidthFraction: _spatialDouble(
|
||||
json,
|
||||
'price_column_width_fraction',
|
||||
fallback: 0.25,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static double _spatialDouble(
|
||||
Map<String, dynamic> json,
|
||||
String key, {
|
||||
required double fallback,
|
||||
}) {
|
||||
final spatial = json['spatial_parsing'];
|
||||
if (spatial is! Map) return fallback;
|
||||
final value = spatial[key];
|
||||
if (value is num && value > 0) return value.toDouble();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static const assetPath = 'receipt-parsing-keywords.json';
|
||||
|
||||
/// True if [line] matches a config keyword.
|
||||
|
||||
@@ -1,590 +1,42 @@
|
||||
/// Turns raw OCR text into [LineItem]s using receipt-parsing-keywords.json.
|
||||
/// Receipt parsing entry point: spatial OCR pipeline with text fallback.
|
||||
library;
|
||||
|
||||
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) =>
|
||||
ReceiptSpatialParser.parse(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,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
return parseReceiptFromOcr(document);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/// 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 rowClusterThreshold = _rowClusterThreshold(document.elements);
|
||||
final clustered = _clusterByY(document.elements, rowClusterThreshold);
|
||||
final contentWidth = _contentWidth(document.elements);
|
||||
final contentLeft = _contentLeft(document.elements);
|
||||
final priceColumnStart =
|
||||
contentLeft + contentWidth * (1 - config.priceColumnWidthFraction);
|
||||
|
||||
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,
|
||||
}) {
|
||||
final text = 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,638 @@
|
||||
/// 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 rows. price_column_width_fraction is the right-hand slice of receipt content width treated as the price column.",
|
||||
"row_cluster_height_factor": 1.2,
|
||||
"price_column_width_fraction": 0.25
|
||||
}
|
||||
}
|
||||
|
||||
+92
-64
@@ -16,30 +16,53 @@ without assuming a single fixed format.
|
||||
|
||||
## 1. Input assumptions
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -15,6 +15,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
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
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).
|
||||
///
|
||||
/// Y increases downward; name column ~left 40–420, price column ~right 620–760.
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
OcrDocument _document(List<OcrElement> elements, {String? flattened}) {
|
||||
return OcrDocument(
|
||||
elements: elements,
|
||||
imageWidth: _imageWidth,
|
||||
imageHeight: _imageHeight,
|
||||
flattenedText: flattened ?? elements.map((e) => e.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', 0),
|
||||
_word('X', 0),
|
||||
_word('2,99', 0, 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');
|
||||
});
|
||||
});
|
||||
|
||||
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', 1),
|
||||
_word('Artikel:', 1),
|
||||
_word('3', 1, price: true),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result.expectedItemCount, 3);
|
||||
expect(result.validationPassed, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user