improve parsing 3
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:groci/models/ocr_element.dart';
|
||||
import 'package:groci/services/receipt_parser.dart';
|
||||
|
||||
void main() {
|
||||
@@ -152,6 +153,59 @@ Bedr.Incl 2,09
|
||||
expect(items.last.price, 6.20);
|
||||
expect(items, hasLength(26));
|
||||
});
|
||||
|
||||
test('scan path uses column parser for Jumbo name-then-price OCR dump', () {
|
||||
const ocr = '''
|
||||
BIO VASTKOKEND AARD
|
||||
2 X 1,99
|
||||
AARDBEIEN
|
||||
2 X 3,99
|
||||
ACTIE AARDBEIEN
|
||||
BROCCOLII
|
||||
PREI
|
||||
CITROENEN
|
||||
KOMKOMMER
|
||||
2 X 0,95
|
||||
ACTIE KOMKOMMER
|
||||
BOL KNOFLOOK
|
||||
BROODSTICKS OLIJF
|
||||
4 X 1.25
|
||||
3,98
|
||||
7,98
|
||||
-2,00
|
||||
1,24
|
||||
0,69
|
||||
2,11
|
||||
1,90
|
||||
-0,21
|
||||
1,79
|
||||
5,00
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parseReceiptFromOcr(
|
||||
OcrDocument(
|
||||
elements: const [],
|
||||
lines: const [],
|
||||
imageWidth: 800,
|
||||
imageHeight: 1200,
|
||||
flattenedText: ocr,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.items, hasLength(8));
|
||||
expect(result.items[0].name, 'BIO VASTKOKEND AARD');
|
||||
expect(result.items[0].quantity, 2);
|
||||
expect(result.items[0].price, 3.98);
|
||||
expect(result.items[1].name, 'AARDBEIEN');
|
||||
expect(result.items[1].price, 5.98);
|
||||
expect(result.items[1].discount, -2.00);
|
||||
expect(result.items[5].name, 'KOMKOMMER');
|
||||
expect(result.items[5].price, 1.69);
|
||||
expect(result.items.last.name, 'BROODSTICKS OLIJF');
|
||||
expect(result.items.last.quantity, 4);
|
||||
expect(result.items.last.price, 5.00);
|
||||
expect(result.items.every((item) => !item.needsManualReview), isTrue);
|
||||
});
|
||||
}
|
||||
|
||||
const _jumboColumnOcr = '''
|
||||
|
||||
@@ -6,8 +6,6 @@ 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;
|
||||
@@ -28,12 +26,39 @@ OcrElement _word(String text, double row, {bool price = false}) {
|
||||
);
|
||||
}
|
||||
|
||||
OcrDocument _document(List<OcrElement> elements, {String? flattened}) {
|
||||
OcrLine _line(List<OcrElement> elements) {
|
||||
var left = double.infinity;
|
||||
var top = double.infinity;
|
||||
var right = 0.0;
|
||||
var bottom = 0.0;
|
||||
for (final e in elements) {
|
||||
if (e.left < left) left = e.left;
|
||||
if (e.top < top) top = e.top;
|
||||
if (e.right > right) right = e.right;
|
||||
if (e.bottom > bottom) bottom = e.bottom;
|
||||
}
|
||||
return OcrLine(
|
||||
text: elements.map((e) => e.text).join(' '),
|
||||
elements: elements,
|
||||
left: left,
|
||||
top: top,
|
||||
right: right,
|
||||
bottom: bottom,
|
||||
);
|
||||
}
|
||||
|
||||
OcrDocument _document(
|
||||
List<List<OcrElement>> rows, {
|
||||
String? flattened,
|
||||
}) {
|
||||
final elements = [for (final row in rows) ...row];
|
||||
final lines = [for (final row in rows) _line(row)];
|
||||
return OcrDocument(
|
||||
elements: elements,
|
||||
lines: lines,
|
||||
imageWidth: _imageWidth,
|
||||
imageHeight: _imageHeight,
|
||||
flattenedText: flattened ?? elements.map((e) => e.text).join('\n'),
|
||||
flattenedText: flattened ?? lines.map((l) => l.text).join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,8 +71,7 @@ void main() {
|
||||
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),
|
||||
[_word('MILK', 0), _word('1,50', 0, price: true)],
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -60,9 +84,7 @@ void main() {
|
||||
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),
|
||||
[_word('2', 1), _word('X', 1), _word('2,99', 1, price: true)],
|
||||
], flattened: '2 X 2,99'),
|
||||
);
|
||||
|
||||
@@ -74,24 +96,36 @@ void main() {
|
||||
|
||||
test('treats row without price-column token as name-only', () {
|
||||
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
|
||||
_document([_word('BROCCOLI', 0)]),
|
||||
_document([
|
||||
[_word('BROCCOLI', 0)],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows.single.kind.name, 'nameOnly');
|
||||
expect(rows.single.nameText, 'BROCCOLI');
|
||||
});
|
||||
|
||||
test('does not merge adjacent ML Kit lines into one row', () {
|
||||
final rows = ReceiptRowBuilder(receiptParseConfig).buildRows(
|
||||
_document([
|
||||
[_word('BIO', 0), _word('VASTKOKEND', 0), _word('AARD', 0)],
|
||||
[_word('2', 1), _word('X', 1), _word('1,99', 1, price: true)],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows, hasLength(2));
|
||||
expect(rows[0].nameText, contains('BIO'));
|
||||
expect(rows[1].kind.name, 'quantityModifier');
|
||||
});
|
||||
});
|
||||
|
||||
group('ReceiptSpatialParser lookahead', () {
|
||||
test('merges Jumbo-style name row with following quantity modifier', () {
|
||||
final result = ReceiptSpatialParser.parse(
|
||||
_document([
|
||||
_word('AARDBEIEN', 0),
|
||||
_word('2', 1),
|
||||
_word('X', 1),
|
||||
_word('3,99', 1, price: true),
|
||||
_word('Totaal', 2),
|
||||
_word('7,98', 2, price: true),
|
||||
[_word('AARDBEIEN', 0)],
|
||||
[_word('2', 1), _word('X', 1), _word('3,99', 1, price: true)],
|
||||
[_word('Totaal', 2), _word('7,98', 2, price: true)],
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -106,12 +140,8 @@ void main() {
|
||||
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),
|
||||
[_word('ZEEPTABLET', 0), _word('NEUTRAL', 0), _word('7,98', 0, price: true)],
|
||||
[_word('2', 1), _word('X', 1), _word('3,99', 1, price: true)],
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -124,9 +154,8 @@ void main() {
|
||||
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),
|
||||
[_word('MILK', 0)],
|
||||
[_word('BREAD', 1), _word('2,00', 1, price: true)],
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -140,12 +169,9 @@ void main() {
|
||||
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),
|
||||
[_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)],
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -157,11 +183,8 @@ void main() {
|
||||
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),
|
||||
[_word('MILK', 0), _word('1,50', 0, price: true)],
|
||||
[_word('Aantal Artikel: 3', 1)],
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user