improve parsing 2
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user