Files
receipity/lib/services/receipt_spatial_parser.dart
2026-09-02 12:35:41 +02:00

275 lines
7.9 KiB
Dart

/// 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,
);
}
}