From ae42ba8193b305e5a9be06d37d72d638b3e8863b Mon Sep 17 00:00:00 2001 From: davmar Date: Wed, 2 Sep 2026 13:38:47 +0200 Subject: [PATCH] improve parsing 3 --- lib/models/ocr_element.dart | 23 +++ lib/review_screen.dart | 273 +++++++++++++++++-------- lib/services/receipt_ocr.dart | 19 +- lib/services/receipt_parse_config.dart | 4 +- lib/services/receipt_parser.dart | 48 ++++- lib/services/receipt_row_builder.dart | 22 +- lib/services/receipt_text_parser.dart | 11 + receipt-parsing-keywords.json | 4 +- test/receipt_parser_test.dart | 54 +++++ test/receipt_spatial_parser_test.dart | 95 +++++---- 10 files changed, 417 insertions(+), 136 deletions(-) diff --git a/lib/models/ocr_element.dart b/lib/models/ocr_element.dart index 9c7106d..0abaaaa 100644 --- a/lib/models/ocr_element.dart +++ b/lib/models/ocr_element.dart @@ -22,16 +22,39 @@ class OcrElement { double get width => (right - left).abs(); } +/// One ML Kit text line with its word-level elements. +class OcrLine { + const OcrLine({ + required this.text, + required this.elements, + required this.left, + required this.top, + required this.right, + required this.bottom, + }); + + final String text; + final List elements; + final double left; + final double top; + final double right; + final double bottom; + + double get centerY => (top + bottom) / 2; +} + /// All positioned elements from one ML Kit recognition pass. class OcrDocument { const OcrDocument({ required this.elements, + required this.lines, required this.imageWidth, required this.imageHeight, required this.flattenedText, }); final List elements; + final List lines; final double imageWidth; final double imageHeight; final String flattenedText; diff --git a/lib/review_screen.dart b/lib/review_screen.dart index 838bd24..546d2d1 100644 --- a/lib/review_screen.dart +++ b/lib/review_screen.dart @@ -55,6 +55,7 @@ class _ReviewScreenState extends State { List _tags = []; List _tagSuggestions = []; String? _highlightedItemId; + bool _showTripExtras = false; @override void initState() { @@ -137,18 +138,91 @@ class _ReviewScreenState extends State { setState(() => _shoppedAt = picked); } - Future _openReceipt() async { + Future _openReceiptPanel() async { final path = widget.imagePath; if (path == null || path.isEmpty) return; final file = File(path); if (!file.existsSync()) return; - await showDialog( + + final highlighted = _highlightedItemId == null + ? null + : _items.cast().firstWhere( + (item) => item?.id == _highlightedItemId, + orElse: () => null, + ); + + await showModalBottomSheet( context: context, + isScrollControlled: true, + showDragHandle: true, builder: (context) { - return Dialog( - insetPadding: const EdgeInsets.all(16), - child: InteractiveViewer( - child: Image.file(file, fit: BoxFit.contain), + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Receipt', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + if (highlighted?.sourceBounds != null) + ReceiptHighlightOverlay( + imagePath: path, + bounds: highlighted!.sourceBounds!, + maxHeight: 360, + ) + else + GestureDetector( + onTap: () async { + await showDialog( + context: context, + builder: (ctx) => Dialog( + insetPadding: const EdgeInsets.all(16), + child: InteractiveViewer( + child: Image.file(file, fit: BoxFit.contain), + ), + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file(file, fit: BoxFit.contain, height: 360), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Future _openOcrPanel() async { + if (widget.rawText.isEmpty) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Raw OCR text', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.55, + ), + child: SingleChildScrollView( + child: SelectableText(widget.rawText), + ), + ), + ], + ), ), ); }, @@ -202,13 +276,14 @@ class _ReviewScreenState extends State { if (_supermarket != null && !names.contains(_supermarket)) { names.add(_supermarket!); } - final receiptPath = widget.imagePath; - final highlighted = _highlightedItemId == null - ? null - : _items.cast().firstWhere( - (item) => item?.id == _highlightedItemId, - orElse: () => null, - ); + final flaggedCount = + _items.where((item) => item.needsManualReview).length; + final reviewMessage = flaggedCount > 0 + ? 'Some lines could not be parsed confidently — ' + '${flaggedCount == 1 ? '1 item is' : '$flaggedCount items are'} ' + 'marked below. Check them against the receipt before saving.' + : 'Item count or totals do not match the receipt. ' + 'Check the items below before saving.'; return Scaffold( appBar: AppBar( @@ -232,7 +307,7 @@ class _ReviewScreenState extends State { child: Padding( padding: const EdgeInsets.all(12), child: Text( - 'Item count or totals do not match the receipt, or some lines need manual review. Check highlighted items before saving.', + reviewMessage, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onErrorContainer, ), @@ -273,43 +348,57 @@ class _ReviewScreenState extends State { const SizedBox(height: 8), ListTile( contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.calendar_today_outlined), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + _showTripExtras + ? Icons.expand_less + : Icons.edit_note_outlined, + ), + tooltip: _showTripExtras + ? 'Hide trip name & tags' + : 'Trip name & tags', + onPressed: _saving + ? null + : () => setState( + () => _showTripExtras = !_showTripExtras, + ), + ), + const Icon(Icons.calendar_today_outlined), + ], + ), title: Text(formatDate(_shoppedAt)), subtitle: const Text('Shopping date'), onTap: _saving ? null : _pickDate, ), + if (_showTripExtras) ...[ + const SizedBox(height: 8), + TextField( + controller: _tripName, + enabled: !_saving, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Trip name', + hintText: 'Optional, e.g. Weekly shop', + ), + ), + const SizedBox(height: 12), + TagEditor( + tags: _tags, + suggestions: _tagSuggestions, + enabled: !_saving, + onChanged: (tags) => setState(() => _tags = tags), + ), + const SizedBox(height: 8), + ], ], ), ), Expanded( child: CustomScrollView( slivers: [ - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - sliver: SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - controller: _tripName, - enabled: !_saving, - textCapitalization: TextCapitalization.sentences, - decoration: const InputDecoration( - labelText: 'Trip name', - hintText: 'Optional, e.g. Weekly shop', - ), - ), - const SizedBox(height: 12), - TagEditor( - tags: _tags, - suggestions: _tagSuggestions, - enabled: !_saving, - onChanged: (tags) => setState(() => _tags = tags), - ), - ], - ), - ), - ), if (_items.isEmpty) SliverFillRemaining( hasScrollBody: false, @@ -329,6 +418,8 @@ class _ReviewScreenState extends State { separatorBuilder: (_, _) => const SizedBox(height: 4), itemBuilder: (context, index) { final item = _items[index]; + final flagged = item.needsManualReview; + final onReceipt = _highlightedItemId == item.id; return Dismissible( key: ValueKey(item.id), direction: DismissDirection.endToStart, @@ -350,13 +441,26 @@ class _ReviewScreenState extends State { }); }, child: ListTile( - selected: _highlightedItemId == item.id, - selectedTileColor: theme.colorScheme.primaryContainer - .withValues(alpha: 0.35), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: flagged + ? BorderSide( + color: theme.colorScheme.error + .withValues(alpha: 0.55), + ) + : BorderSide.none, + ), + tileColor: flagged + ? theme.colorScheme.errorContainer + .withValues(alpha: 0.35) + : onReceipt + ? theme.colorScheme.primaryContainer + .withValues(alpha: 0.35) + : null, leading: item.sourceBounds != null && _hasReceipt ? IconButton( icon: Icon( - _highlightedItemId == item.id + onReceipt ? Icons.visibility : Icons.visibility_outlined, size: 20, @@ -366,7 +470,7 @@ class _ReviewScreenState extends State { ? null : () => _toggleHighlight(item), ) - : item.needsManualReview + : flagged ? Icon( Icons.flag_outlined, color: theme.colorScheme.error, @@ -374,7 +478,7 @@ class _ReviewScreenState extends State { ) : null, title: Text(item.name), - subtitle: _itemSubtitle(item), + subtitle: _itemSubtitle(item, flagged: flagged), trailing: DiscountedPrice(item: item), onTap: _saving ? null : () => _edit(existing: item), ), @@ -385,45 +489,31 @@ class _ReviewScreenState extends State { ], ), ), - if (_hasReceipt) - ExpansionTile( - initiallyExpanded: highlighted?.sourceBounds != null, - title: const Text('See receipt'), - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: highlighted?.sourceBounds != null - ? ReceiptHighlightOverlay( - imagePath: receiptPath!, - bounds: highlighted!.sourceBounds!, - ) - : GestureDetector( - onTap: _openReceipt, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - File(receiptPath!), - fit: BoxFit.contain, - height: 220, - width: double.infinity, - ), - ), - ), - ), - ], - ), - if (widget.rawText.isNotEmpty) - ExpansionTile( - title: const Text('Raw OCR text'), - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 180), - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: SelectableText(widget.rawText), - ), - ), - ], + if (_hasReceipt || widget.rawText.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + child: Row( + children: [ + if (_hasReceipt) + Expanded( + child: OutlinedButton.icon( + onPressed: _saving ? null : _openReceiptPanel, + icon: const Icon(Icons.receipt_long_outlined, size: 18), + label: const Text('Receipt'), + ), + ), + if (_hasReceipt && widget.rawText.isNotEmpty) + const SizedBox(width: 8), + if (widget.rawText.isNotEmpty) + Expanded( + child: OutlinedButton.icon( + onPressed: _saving ? null : _openOcrPanel, + icon: const Icon(Icons.text_snippet_outlined, size: 18), + label: const Text('OCR text'), + ), + ), + ], + ), ), SafeArea( child: Padding( @@ -452,8 +542,13 @@ class _ReviewScreenState extends State { ); } - Widget? _itemSubtitle(LineItem item) { - if (item.quantity == null) return null; - return Text('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}'); + Widget? _itemSubtitle(LineItem item, {bool flagged = false}) { + final parts = []; + if (flagged) parts.add('Needs review'); + if (item.quantity != null) { + parts.add('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}'); + } + if (parts.isEmpty) return null; + return Text(parts.join(' · ')); } } diff --git a/lib/services/receipt_ocr.dart b/lib/services/receipt_ocr.dart index cb38b82..8ce83e0 100644 --- a/lib/services/receipt_ocr.dart +++ b/lib/services/receipt_ocr.dart @@ -16,15 +16,17 @@ class ReceiptOcr { required double imageHeight, }) { final elements = []; + final lines = []; var maxRight = 0.0; var maxBottom = 0.0; for (final block in recognized.blocks) { for (final line in block.lines) { + final lineElements = []; if (line.elements.isNotEmpty) { for (final element in line.elements) { final box = element.boundingBox; - elements.add( + lineElements.add( OcrElement( text: element.text, left: box.left, @@ -38,7 +40,7 @@ class ReceiptOcr { } } else { final box = line.boundingBox; - elements.add( + lineElements.add( OcrElement( text: line.text, left: box.left, @@ -50,6 +52,18 @@ class ReceiptOcr { if (box.right > maxRight) maxRight = box.right; if (box.bottom > maxBottom) maxBottom = box.bottom; } + elements.addAll(lineElements); + final box = line.boundingBox; + lines.add( + OcrLine( + text: line.text, + elements: lineElements, + left: box.left, + top: box.top, + right: box.right, + bottom: box.bottom, + ), + ); } } @@ -58,6 +72,7 @@ class ReceiptOcr { return OcrDocument( elements: elements, + lines: lines, imageWidth: width, imageHeight: height, flattenedText: recognized.text.trim(), diff --git a/lib/services/receipt_parse_config.dart b/lib/services/receipt_parse_config.dart index cd9bc6b..39084f0 100644 --- a/lib/services/receipt_parse_config.dart +++ b/lib/services/receipt_parse_config.dart @@ -25,7 +25,7 @@ class ReceiptParseConfig { required this.trailingCategoryLetter, required this.noiseKeywords, required this.itemCountKeywords, - this.rowClusterHeightFactor = 1.2, + this.rowClusterHeightFactor = 0.55, this.priceColumnWidthFraction = 0.25, }); @@ -106,7 +106,7 @@ class ReceiptParseConfig { rowClusterHeightFactor: _spatialDouble( json, 'row_cluster_height_factor', - fallback: 1.2, + fallback: 0.55, ), priceColumnWidthFraction: _spatialDouble( json, diff --git a/lib/services/receipt_parser.dart b/lib/services/receipt_parser.dart index b8e4830..3f437d2 100644 --- a/lib/services/receipt_parser.dart +++ b/lib/services/receipt_parser.dart @@ -25,7 +25,7 @@ class ReceiptParser { /// Primary path: positioned ML Kit elements → row clustering → lookahead. static ReceiptParseResult parseReceiptFromOcr(OcrDocument document) => - ReceiptSpatialParser.parse(document); + _parseDocument(document); static ReceiptParseResult parseRecognizedText( RecognizedText recognized, { @@ -37,6 +37,50 @@ class ReceiptParser { imageWidth: imageWidth, imageHeight: imageHeight, ); - return parseReceiptFromOcr(document); + return _parseDocument(document); + } + + static ReceiptParseResult _parseDocument(OcrDocument document) { + if (ReceiptTextParser.looksLikeColumnOcr(document.flattenedText)) { + return _withImageDimensions( + ReceiptTextParser.parseReceipt(document.flattenedText), + document, + ); + } + + final spatial = ReceiptSpatialParser.parse(document); + if (_shouldFallbackToText(spatial)) { + return _withImageDimensions( + ReceiptTextParser.parseReceipt(document.flattenedText), + document, + ); + } + return spatial; + } + + static bool _shouldFallbackToText(ReceiptParseResult spatial) { + if (spatial.items.isEmpty) return true; + final flagged = + spatial.items.where((item) => item.needsManualReview).length; + if (flagged > 0) return true; + if (spatial.items.any((item) => item.price <= 0)) return true; + if (spatial.receiptTotal != null && !spatial.validationPassed) return true; + return false; + } + + static ReceiptParseResult _withImageDimensions( + ReceiptParseResult result, + OcrDocument document, + ) { + return ReceiptParseResult( + items: result.items, + store: result.store, + receiptTotal: result.receiptTotal, + totalDiscount: result.totalDiscount, + validationPassed: result.validationPassed, + expectedItemCount: result.expectedItemCount, + ocrImageWidth: document.imageWidth, + ocrImageHeight: document.imageHeight, + ); } } diff --git a/lib/services/receipt_row_builder.dart b/lib/services/receipt_row_builder.dart index 21b9c7c..ff1bddf 100644 --- a/lib/services/receipt_row_builder.dart +++ b/lib/services/receipt_row_builder.dart @@ -28,13 +28,28 @@ class ReceiptRowBuilder { List 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); + // Prefer ML Kit line boundaries — element Y-clustering merges adjacent lines. + if (document.lines.isNotEmpty) { + return [ + for (final line in document.lines) + _classifyRow( + line.elements, + priceColumnStart: priceColumnStart, + imageWidth: document.imageWidth, + imageHeight: document.imageHeight, + rowText: line.text, + ), + ]; + } + + final rowClusterThreshold = _rowClusterThreshold(document.elements); + final clustered = _clusterByY(document.elements, rowClusterThreshold); + return [ for (final rowElements in clustered) _classifyRow( @@ -116,8 +131,9 @@ class ReceiptRowBuilder { required double priceColumnStart, required double imageWidth, required double imageHeight, + String? rowText, }) { - final text = rowElements.map((e) => e.text).join(' ').trim(); + final text = rowText ?? rowElements.map((e) => e.text).join(' ').trim(); final normalized = _preprocess(text); final bounds = _rowBounds(rowElements, imageWidth, imageHeight); diff --git a/lib/services/receipt_text_parser.dart b/lib/services/receipt_text_parser.dart index 5274744..1b9e14c 100644 --- a/lib/services/receipt_text_parser.dart +++ b/lib/services/receipt_text_parser.dart @@ -116,6 +116,17 @@ class ReceiptTextParser { return _parseSequential(lines, config, rawText); } + /// True when OCR dumped an entire name column before a price column (Jumbo-style). + static bool looksLikeColumnOcr(String rawText) { + final config = receiptParseConfig; + final lines = rawText + .split('\n') + .map(_preprocess) + .where((line) => line.isNotEmpty) + .toList(); + return _looksLikeColumnOcr(lines, config); + } + static String _preprocess(String line) => line.trim().replaceAll(RegExp(r'\s+'), ' '); diff --git a/receipt-parsing-keywords.json b/receipt-parsing-keywords.json index 4cf2646..0555a2a 100644 --- a/receipt-parsing-keywords.json +++ b/receipt-parsing-keywords.json @@ -185,8 +185,8 @@ }, "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, + "_comment": "Bounding-box pipeline settings. row_cluster_height_factor multiplies median element height when grouping elements into rows (only used when ML Kit lines are unavailable — keep below 0.6 so adjacent receipt lines stay separate). price_column_width_fraction is the right-hand slice of receipt content width treated as the price column.", + "row_cluster_height_factor": 0.55, "price_column_width_fraction": 0.25 } } diff --git a/test/receipt_parser_test.dart b/test/receipt_parser_test.dart index b036ef4..81d53b4 100644 --- a/test/receipt_parser_test.dart +++ b/test/receipt_parser_test.dart @@ -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 = ''' diff --git a/test/receipt_spatial_parser_test.dart b/test/receipt_spatial_parser_test.dart index cb2b204..3808887 100644 --- a/test/receipt_spatial_parser_test.dart +++ b/test/receipt_spatial_parser_test.dart @@ -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 elements, {String? flattened}) { +OcrLine _line(List 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> 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)], ]), );