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