improve parsing 2

This commit is contained in:
davmar
2026-09-02 12:35:41 +02:00
parent e98fb0e7f4
commit 98574af903
9 changed files with 1721 additions and 0 deletions
+346
View File
@@ -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;
}
}