FOSSredder 0.5.0
Loading...
Searching...
No Matches
StatementParseHelpers.cpp
Go to the documentation of this file.
1
5
7
13
14#include <algorithm>
15#include <cmath>
16#include <limits>
17#include <regex>
18#include <utility>
19
21using core::application::importing::statement::DefaultStatementParser;
22using core::application::importing::transaction::DefaultTransactionParser;
23using core::application::importing::internal::ColumnGuess;
24using core::application::importing::internal::RawLineLite;
25using core::application::importing::transaction::internal::OcrLine;
26using core::application::importing::transaction::internal::TransactionBlock;
27using core::application::importing::transaction::internal::TransactionMainRow;
28namespace helpers = core::application::importing::internal;
29namespace heuristics = core::application::importing::internal;
30
31namespace {
32
33template <typename Action>
34bool tryParserAction(Action&& action)
35{
36 try {
37 std::forward<Action>(action)();
38 return true;
39 } catch (...) {
40 return false;
41 }
42}
43
44std::string fullBlockMetadata(const TransactionBlock& block)
45{
46 std::vector<std::string> parts;
47 auto append = [&](const std::string& text) {
48 const auto value = core::utils::trim(core::utils::collapseWhitespace(text));
49 if (value.empty()) return;
50 if (std::find(parts.begin(), parts.end(), value) != parts.end()) return;
51 parts.push_back(value);
52 };
53
54 append(block.main.left.line.text);
55 for (const auto& line : block.detailLines) {
56 append(line.text);
57 }
58
59 std::ostringstream out;
60 for (std::size_t i = 0; i < parts.size(); ++i) {
61 if (i > 0) out << '\n';
62 out << parts[i];
63 }
64 return out.str();
65}
66
67RawLineLite toRawLineLite(const OcrLine& line)
68{
69 return RawLineLite{line.minX, line.maxX, line.minY, line.maxY, line.wordSpans, line.text};
70}
71
72OcrLine rawLiteToOcrLine(const RawLineLite& line)
73{
74 OcrLine out;
75 out.minX = line.minX;
76 out.maxX = line.maxX;
77 out.minY = line.minY;
78 out.maxY = line.maxY;
79 out.wordSpans = line.wordSpans;
80 out.text = line.text;
81 return out;
82}
83
84RawLineLite mergeRawLines(const OcrLine& first, const OcrLine& second)
85{
86 RawLineLite merged = toRawLineLite(first);
87 merged.minX = std::min(first.minX, second.minX);
88 merged.maxX = std::max(first.maxX, second.maxX);
89 merged.minY = std::min(first.minY, second.minY);
90 merged.maxY = std::max(first.maxY, second.maxY);
91 merged.wordSpans.insert(merged.wordSpans.end(), second.wordSpans.begin(), second.wordSpans.end());
92 merged.text += std::string(" ") + second.text;
93 return merged;
94}
95
96TransactionMainRow splitMergedMainRow(const OcrLine& first,
97 const OcrLine& second,
98 const ColumnGuess& cols) noexcept
99{
100 return helpers::splitMainRowFromRaw(mergeRawLines(first, second), cols.valutaX, cols.debitX, cols.creditX);
101}
102
103bool hasVerticalStartEvidence(const OcrLine& descriptiveLine,
104 const OcrLine& amountLine,
105 int valutaX,
106 int bandPx) noexcept
107{
108 return helpers::hasLeftDescriptiveText(descriptiveLine, valutaX)
109 && !helpers::hasAmountLikeTokenInLine(descriptiveLine, valutaX)
110 && helpers::hasAmountNearValuta(amountLine, valutaX, bandPx);
111}
112
113const std::regex& combinedMainRowRegex()
114{
115 static const std::regex pattern(R"((\d{2}\.\s*\d{2})\s+\d{1,3}(?:[\.,]\d{3})*[\.,]\d{1,2}-?\b)", std::regex::ECMAScript);
116 return pattern;
117}
118
119void appendDebugValue(std::vector<std::string>& debugOut, const char* prefix, const std::string& value)
120{
121 if (value.empty()) return;
122 debugOut.push_back(std::string(prefix) + value);
123}
124
125bool looksLikeMetadataIdentifier(const std::string& value) noexcept
126{
127 try {
128 static const std::regex ibanPattern(R"(^[A-Z]{2}\d{2}[A-Z0-9]{10,32}$)", std::regex::icase);
129 static const std::regex bicPattern(R"(^[A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?$)", std::regex::icase);
130 static const std::regex referencePattern(R"((end-to-end|kundenreferenz|verwendungszweck|vertragskont|iban|bic|dauerauftrag|mandat|referenz|zweck))", std::regex::icase);
131 return std::regex_match(value, ibanPattern)
132 || std::regex_match(value, bicPattern)
133 || std::regex_search(value, referencePattern);
134 } catch (...) {
135 return false;
136 }
137}
138
139bool looksLikeStandaloneMetadataLine(const std::string& value) noexcept
140{
141 try {
142 const auto trimmed = core::utils::trim(value);
143 if (trimmed.empty()) return false;
144 if (looksLikeMetadataIdentifier(trimmed)) return true;
145
146 static const std::regex dateOnlyPattern(R"(^\d{1,2}[\./-]\d{1,2}(?:[\./-]\d{2,4})?$)", std::regex::icase);
147 static const std::regex labelPattern(R"((end-to-end|kundenreferenz|verwendungszweck|vertragskont|iban|bic|dauerauftrag|mandat|referenz|zweck))", std::regex::icase);
148 return std::regex_match(trimmed, dateOnlyPattern) || std::regex_search(trimmed, labelPattern);
149 } catch (...) {
150 return false;
151 }
152}
153
154bool isCurrencyToken(const std::string& token) noexcept
155{
156 const auto normalized = helpers::normalizeAlnumLower(token);
157 return normalized == "eur"
158 || normalized == "usd"
159 || normalized == "chf"
160 || normalized == "gbp";
161}
162
163bool looksLikePureAmountColumnTail(const OcrLine& line) noexcept
164{
165 try {
166 const auto tokens = core::utils::splitWhitespace(line.text);
167 if (tokens.empty()) return false;
168
169 bool sawAmount = false;
170 for (const auto& token : tokens) {
171 if (!helpers::findAmountTokenIndices(OcrLine{0, 0, 0, 0, {}, token}, -1, 0).empty()) {
172 sawAmount = true;
173 continue;
174 }
175 if (isCurrencyToken(token)) continue;
176
177 for (const unsigned char ch : token) {
178 if (std::isalpha(ch)) return false;
179 }
180 return false;
181 }
182
183 return sawAmount;
184 } catch (...) {
185 return false;
186 }
187}
188
189}
190
192 OcrLine out;
193 out.minX = line.minX;
194 out.maxX = line.maxX;
195 out.minY = line.minY;
196 out.maxY = line.maxY;
197 out.wordSpans = line.wordSpans;
198 out.text = line.text;
199 return out;
200}
201
202bool isLikelyTransactionMainRowGeom(const RawLine& line, const ColumnModel& cols) {
203 if (!cols.hasValuta() || (!cols.hasDebit() && !cols.hasCredit())) return false;
204 const auto tokens = core::utils::splitWhitespace(line.text);
205 if (tokens.size() != line.wordSpans.size() || tokens.size() < 3) return false;
206
207 const int band = helpers::parserConfig.tokenNearBandForMainRow;
208 const bool hasValuta = helpers::hasTokenNearX(rawToOcrLine(line), cols.valutaX, band);
209 bool hasDebit = cols.hasDebit() ? helpers::hasTokenNearX(rawToOcrLine(line), cols.debitX, band) : false;
210 bool hasCredit = cols.hasCredit() ? helpers::hasTokenNearX(rawToOcrLine(line), cols.creditX, band) : false;
211
212 if (!hasDebit && !hasCredit) {
213 tryParserAction([&] {
214 auto amountIndices = helpers::findAmountTokenIndices(rawToOcrLine(line), cols.valutaX, helpers::parserConfig.amountNearValutaBandPx);
215 if (!amountIndices.empty()) hasDebit = true;
216 });
217 }
218
219 bool hasLeft = false;
220 for (size_t i = 0; i < tokens.size(); ++i) {
221 const auto& span = line.wordSpans[i];
222 const int centerX = (span.first + span.second) / 2;
223 if (centerX < cols.valutaX - helpers::parserConfig.leftDescriptiveOffsetPx) {
224 hasLeft = true;
225 break;
226 }
227 }
228
229 return hasLeft && hasValuta && (hasDebit || hasCredit);
230}
231
232bool isLikelyTransactionHeaderLine(const OcrLine& line, const ColumnModel& cols) {
233 return cols.hasValuta() && helpers::hasTokenNearX(line, cols.valutaX, 100);
234}
235
236std::pair<int, bool> detectHeaderRegion(const std::vector<OcrLine>& lines, size_t scanLines)
237{
238 int lastBlockY = -1;
239 size_t idx = 0;
240 int noiseCount = 0;
241 for (const auto& line : lines) {
242 ++idx;
243 if (idx > scanLines) break;
244 const auto& text = line.text;
245 if (text.empty()) continue;
246
247 bool isStrongHeaderLike = false;
248 bool isNoise = false;
249 try {
250 if (heuristics::isTransactionsSectionHeader(text)) isStrongHeaderLike = true;
251 if (helpers::normalizeAlnumLower(text).find("valuta") != std::string::npos) isStrongHeaderLike = true;
252 if (heuristics::isDebitCreditHeaderLine(text)) isStrongHeaderLike = true;
253 if (helpers::findFirstFullDate(text).has_value()) isStrongHeaderLike = true;
254 if (heuristics::isHeaderNoiseLine(text)) isNoise = true;
255 } catch (...) {}
256
257 if (isStrongHeaderLike) {
258 lastBlockY = std::max(lastBlockY, line.maxY);
259 } else if (isNoise) {
260 ++noiseCount;
261 if (noiseCount >= 2) lastBlockY = std::max(lastBlockY, line.maxY);
262 } else if (lastBlockY >= 0) {
263 break;
264 }
265 }
266
267 return {lastBlockY, lastBlockY >= 0};
268}
269
270std::optional<std::string> findDefaultBookingDate(const std::vector<OcrLine>& lines, size_t scanLines) noexcept
271{
272 try {
273 static const std::regex reDate(R"((\d{2}\.\d{2}\.\d{4}))");
274 const size_t n = std::min(lines.size(), scanLines);
275 for (size_t i = 0; i < n; ++i) {
276 const auto& text = lines[i].text;
277 if (text.empty()) continue;
278 if (heuristics::isPostTransactionFootnote(text)) continue;
279
280 std::smatch match;
281 const auto combinedPrev = (i > 0) ? (lines[i - 1].text + std::string(" ") + text) : text;
282 const auto combinedNext = (i + 1 < lines.size()) ? (text + std::string(" ") + lines[i + 1].text) : text;
283 if (std::regex_search(text, match, reDate)
284 || std::regex_search(combinedPrev, match, reDate)
285 || std::regex_search(combinedNext, match, reDate)) {
286 return match.str(1);
287 }
288 }
289 } catch (...) {}
290 return std::nullopt;
291}
292
293std::optional<std::string> findBookingDateInHeader(const std::string& line) noexcept
294{
295 try {
296 const auto trimmed = core::utils::trim(line);
297 const auto normalized = helpers::normalizeAlnumLower(trimmed);
298 if (normalized.find("buchungsdat") == std::string::npos && normalized.find("buchungsdatum") == std::string::npos) return std::nullopt;
299
300 if (auto date = helpers::findFirstFullDate(trimmed)) return date;
301
302 std::string compact = trimmed;
303 compact.erase(std::remove_if(compact.begin(), compact.end(), [](char c) {
304 return !(std::isdigit(static_cast<unsigned char>(c)) || c == '.' || std::isspace(static_cast<unsigned char>(c)));
305 }), compact.end());
306 return helpers::findFirstFullDate(compact);
307 } catch (...) {}
308 return std::nullopt;
309}
310
311bool detectEarlyEmptyPage(const std::vector<OcrLine>& lines, std::string& outDebug) noexcept
312{
313 try {
314 const size_t checkLines = std::min(lines.size(), static_cast<size_t>(helpers::parserConfig.headerScanLines));
315 int footLike = 0;
316 int amountLikeCount = 0;
317 const size_t start = lines.size() > checkLines ? lines.size() - checkLines : 0;
318 for (size_t i = start; i < lines.size(); ++i) {
319 const auto& text = lines[i].text;
320 if (text.empty()) continue;
321 if (heuristics::isPostTransactionFootnote(text)) ++footLike;
322
323 const auto tokens = core::utils::splitWhitespace(text);
324 for (const auto& token : tokens) {
325 if (helpers::findAmountTokenIndices(OcrLine{0, 0, 0, 0, {}, token}, -1, 0).empty()) continue;
326 ++amountLikeCount;
327 break;
328 }
329 }
330
331 std::ostringstream summary;
332 summary << "page.earlyEmptyCheckBottom\tfootLike=" << footLike << "\tamts=" << amountLikeCount;
333 outDebug = summary.str();
334 return footLike >= static_cast<int>(checkLines * 2 / 3) && amountLikeCount == 0;
335 } catch (...) {}
336 return false;
337}
338
339std::vector<helpers::RawLineLite> selectiveGroupMergeLinesRaw(const std::vector<helpers::RawLineLite>& lines,
340 int maxGapPx,
341 const helpers::ColumnGuess& seedCols)
342{
343 if (lines.size() <= 1) return lines;
344
345 std::vector<helpers::RawLineLite> merged;
346 merged.reserve(lines.size());
347 auto current = lines.front();
348 for (size_t i = 1; i < lines.size(); ++i) {
349 const auto& next = lines[i];
350 if (next.minY - current.maxY <= maxGapPx) {
351 bool evidence = false;
352 try {
353 const auto currentLine = rawLiteToOcrLine(current);
354 const auto nextLine = rawLiteToOcrLine(next);
355 const bool currentStandaloneMetadata = looksLikeStandaloneMetadataLine(currentLine.text);
356 const bool nextStandaloneMetadata = looksLikeStandaloneMetadataLine(nextLine.text);
357 const bool currentHasAmount = helpers::hasAmountLikeTokenInLine(currentLine, seedCols.valutaX);
358 const bool nextHasAmount = helpers::hasAmountLikeTokenInLine(nextLine, seedCols.valutaX);
359 const bool currentNearValuta = helpers::hasTokenNearX(currentLine, seedCols.valutaX, helpers::parserConfig.tokenNearMergeBandPx);
360 const bool nextNearValuta = helpers::hasTokenNearX(nextLine, seedCols.valutaX, helpers::parserConfig.tokenNearMergeBandPx);
361
362 if (!currentStandaloneMetadata && !nextStandaloneMetadata) {
363 if (currentHasAmount && nextHasAmount) evidence = true;
364 if (!evidence && helpers::hasLeftDescriptiveText(currentLine, seedCols.valutaX) && nextNearValuta && nextHasAmount) evidence = true;
365 }
366 if (!evidence && !currentStandaloneMetadata && !nextStandaloneMetadata && helpers::isLooseTransactionLine(currentLine, seedCols.valutaX) && nextNearValuta && nextHasAmount) evidence = true;
367 if (!evidence && !currentStandaloneMetadata && !nextStandaloneMetadata && helpers::isLooseTransactionLine(nextLine, seedCols.valutaX) && currentNearValuta && currentHasAmount) evidence = true;
368 } catch (...) {}
369
370 if (evidence) {
371 current.maxX = std::max(current.maxX, next.maxX);
372 current.maxY = next.maxY;
373 current.wordSpans.insert(current.wordSpans.end(), next.wordSpans.begin(), next.wordSpans.end());
374 current.text += " " + next.text;
375 continue;
376 }
377 }
378
379 merged.push_back(std::move(current));
380 current = next;
381 }
382
383 merged.push_back(std::move(current));
384 return merged;
385}
386
387HeaderAnalysis analyzeHeaderWindow(const std::vector<OcrLine>& ocrLines,
388 const std::vector<RawLine>& lines,
389 const ColumnModel& seedCols,
391{
392 HeaderAnalysis result;
393 static constexpr size_t headerScanLines = 12;
394
395 auto [preHeaderBottomY, headerFound] = detectHeaderRegion(ocrLines, headerScanLines);
396 result.preHeaderBottomY = preHeaderBottomY;
397 result.headerFound = headerFound;
398 result.headerBottomY = preHeaderBottomY;
399
400 out.debugLines.push_back(std::string("header.prebottomY\t") + std::to_string(preHeaderBottomY));
401 out.debugLines.push_back(std::string("header.found\t") + (headerFound ? "1" : "0"));
402
403 try {
404 const size_t debugCount = std::min(ocrLines.size(), headerScanLines);
405 for (size_t headerIndex = 0; headerIndex < debugCount; ++headerIndex) {
406 const auto& line = ocrLines[headerIndex];
407 const auto& text = line.text;
408 try {
409 const bool isTxSection = heuristics::isTransactionsSectionHeader(text);
410 const bool isDebitCredit = heuristics::isDebitCreditHeaderLine(text);
411 const bool isNoise = heuristics::isHeaderNoiseLine(text);
412 const bool hasFullDate = helpers::findFirstFullDate(text).has_value();
413 bool hasAmount = helpers::hasAmountLikeTokenInLine(line, seedCols.valutaX);
414 bool hasValutaTokenNear = false;
415 tryParserAction([&] {
416 if (seedCols.valutaX >= 0) hasValutaTokenNear = helpers::hasTokenNearX(line, seedCols.valutaX, helpers::parserConfig.tokenNearBandForMainRow);
417 });
418 std::ostringstream summary;
419 summary << "header.candidate\tline=" << headerIndex << "\ttext=" << text << "\ttxSection=" << (isTxSection ? "1" : "0") << "\tdebitcredit=" << (isDebitCredit ? "1" : "0") << "\tnoise=" << (isNoise ? "1" : "0") << "\tfullDate=" << (hasFullDate ? "1" : "0") << "\thasAmt=" << (hasAmount ? "1" : "0") << "\tvalutaNear=" << (hasValutaTokenNear ? "1" : "0");
420 out.debugLines.push_back(summary.str());
421 } catch (...) { }
422 }
423 } catch (...) { }
424
425 try {
426 int earliestAmountY = std::numeric_limits<int>::max();
427 bool foundAmountLine = false;
428 for (const auto& line : lines) {
429 try {
430 OcrLine ocrLine = rawToOcrLine(line);
431 const bool hasAmount = helpers::hasAmountLikeTokenInLine(ocrLine, seedCols.valutaX);
432 const bool dateLeft = helpers::hasShortDateToken(ocrLine.text) && helpers::hasLeftDescriptiveText(ocrLine, seedCols.valutaX);
433 if (!hasAmount && !dateLeft) continue;
434 earliestAmountY = std::min(earliestAmountY, line.maxY);
435 foundAmountLine = true;
436 } catch (...) { }
437 }
438 if (foundAmountLine && earliestAmountY != std::numeric_limits<int>::max()) {
439 result.headerBottomY = std::min(result.headerBottomY, earliestAmountY - 2);
440 out.debugLines.push_back(std::string("header.adjustedForEarliestAmount\t") + std::to_string(result.headerBottomY));
441 }
442 } catch (...) { }
443
444 try {
445 for (const auto& line : lines) result.pageMaxY = std::max(result.pageMaxY, line.maxY);
446 } catch (...) { result.pageMaxY = -1; }
447
448 return result;
449}
450
451static bool looksLikeRescuedMainLine(const OcrLine& line, const ColumnModel& cols)
452{
453 bool looksMain = false;
454 try { if (helpers::hasAmountLikeTokenInLine(line, cols.valutaX)) looksMain = true; } catch (...) { }
455 try { if (!looksMain && helpers::isLooseTransactionLine(line, cols.valutaX)) looksMain = true; } catch (...) { }
456 try { if (!looksMain && helpers::hasShortDateToken(line.text) && helpers::hasLeftDescriptiveText(line, cols.valutaX)) looksMain = true; } catch (...) { }
457 return looksMain;
458}
459
460void rescueOrphanBlocks(std::vector<TransactionBlock>& blocks,
461 const std::vector<OcrLine>& orphanLines,
462 const ColumnModel& cols,
463 const std::string& currentBookingDate,
465{
466 try {
467 for (const auto& orphanLine : orphanLines) {
468 try {
469 if (!looksLikeRescuedMainLine(orphanLine, cols)) continue;
470 TransactionBlock block;
471 block.bookingDateGroup = currentBookingDate;
472 helpers::ColumnGuess guess{ cols.valutaX, cols.debitX, cols.creditX };
473 block.main = handleMainRow(orphanLine, guess, false, out.debugLines);
474 blocks.push_back(std::move(block));
475 out.debugLines.push_back(std::string("tx.start.rescued\ttext=") + orphanLine.text);
476 } catch (...) { }
477 }
478 } catch (...) { }
479}
480
481std::optional<std::pair<TransactionMainRow, int>> tryVerticalStart(const std::vector<OcrLine>& lines,
482 size_t li,
483 const helpers::ColumnGuess& cols) noexcept
484{
485 try {
486 if (li >= lines.size()) return std::nullopt;
487
488 const int band = std::max(helpers::parserConfig.amountNearValutaBandPx, helpers::parserConfig.tokenNearMergeBandPx);
489 const auto& current = lines[li];
490
491 if (li > 0) {
492 const auto& previous = lines[li - 1];
493 if (hasVerticalStartEvidence(previous, current, cols.valutaX, band)) {
494 return std::make_optional(std::make_pair(splitMergedMainRow(previous, current, cols), 0));
495 }
496 if (helpers::hasShortDateToken(previous.text) && helpers::hasAmountNearValuta(current, cols.valutaX, band)) {
497 return std::make_optional(std::make_pair(splitMergedMainRow(previous, current, cols), 0));
498 }
499 }
500
501 if (li + 1 < lines.size()) {
502 const auto& next = lines[li + 1];
503 if (hasVerticalStartEvidence(current, next, cols.valutaX, band)) {
504 return std::make_optional(std::make_pair(splitMergedMainRow(current, next, cols), 1));
505 }
506 if (helpers::hasShortDateToken(current.text) && helpers::hasAmountNearValuta(next, cols.valutaX, band)) {
507 return std::make_optional(std::make_pair(splitMergedMainRow(current, next, cols), 1));
508 }
509 }
510 } catch (...) {}
511 return std::nullopt;
512}
513
514std::optional<std::pair<TransactionMainRow, int>> tryCombinedStart(const std::vector<OcrLine>& lines,
515 size_t li,
516 const helpers::ColumnGuess& cols) noexcept
517{
518 try {
519 if (li >= lines.size()) return std::nullopt;
520
521 const auto& current = lines[li];
522 const auto combinedPrev = (li > 0) ? (lines[li - 1].text + std::string(" ") + current.text) : current.text;
523 const auto combinedNext = (li + 1 < lines.size()) ? (current.text + std::string(" ") + lines[li + 1].text) : current.text;
524
525 if (!combinedPrev.empty() && std::regex_search(combinedPrev, combinedMainRowRegex())) {
526 return std::make_optional(std::make_pair(helpers::splitMainRowFromOcrLine(current, cols.valutaX, cols.debitX, cols.creditX), 0));
527 }
528 if (!combinedNext.empty() && std::regex_search(combinedNext, combinedMainRowRegex())) {
529 return std::make_optional(std::make_pair(helpers::splitMainRowFromOcrLine(current, cols.valutaX, cols.debitX, cols.creditX), 1));
530 }
531 } catch (...) {}
532 return std::nullopt;
533}
534
536 const OcrLine& line,
537 const helpers::ColumnGuess& cols,
538 std::vector<std::string>* debugOut) noexcept
539{
540 try {
541 if (cols.valutaX >= 0 && helpers::hasLeftDescriptiveText(line, cols.valutaX)) {
542 cur.detailLines.push_back(line);
543 if (debugOut) debugOut->push_back(std::string("detail.append.helper.whole.left\ttext=") + line.text);
544 return;
545 }
546
547 if (cols.valutaX >= 0) {
548 const auto tokens = core::utils::splitWhitespace(line.text);
549 if (tokens.size() == line.wordSpans.size()) {
550 size_t cut = tokens.size();
551 for (size_t i = 0; i < tokens.size(); ++i) {
552 const auto& span = line.wordSpans[i];
553 const int centerX = (span.first + span.second) / 2;
554 if (centerX >= cols.valutaX) {
555 cut = i;
556 break;
557 }
558 }
559
560 const auto rawLine = toRawLineLite(line);
561 const auto leftPart = helpers::toOcrLineFromRawWords(rawLine, 0, cut);
562 const auto rightPart = helpers::toOcrLineFromRawWords(rawLine, cut, tokens.size());
563 const auto fullText = core::utils::trim(line.text);
564 const auto leftText = core::utils::trim(leftPart.text);
565 const auto rightText = core::utils::trim(rightPart.text);
566
567 bool keepWholeLine = leftText.empty() || looksLikeMetadataIdentifier(fullText);
568 if (!keepWholeLine && !rightText.empty()) {
569 keepWholeLine = !looksLikePureAmountColumnTail(rightPart);
570 }
571
572 if (keepWholeLine) {
573 cur.detailLines.push_back(line);
574 if (debugOut) debugOut->push_back(std::string("detail.append.helper.whole\ttext=") + line.text + std::string("\tcut=") + std::to_string(cut));
575 return;
576 }
577
578 if (!leftText.empty()) cur.detailLines.push_back(leftPart);
579 if (debugOut) debugOut->push_back(std::string("detail.append.helper\ttext=") + line.text + std::string("\tcut=") + std::to_string(cut));
580 return;
581 }
582 }
583
584 cur.detailLines.push_back(line);
585 if (debugOut) debugOut->push_back(std::string("detail.append.helper.whole\t") + line.text);
586 } catch (...) {}
587}
588
590 const helpers::ColumnGuess& cols,
591 bool isGeom,
592 std::vector<std::string>& debugOut) noexcept
593{
594 try {
595 auto mainRow = helpers::splitMainRowFromOcrLine(line, cols.valutaX, cols.debitX, cols.creditX);
596 std::ostringstream selection;
597 selection << "tx.main.split\tline?\tvalutaX=" << cols.valutaX << "\tdebitX=" << cols.debitX << "\tcreditX=" << cols.creditX;
598 debugOut.push_back(selection.str());
599 if (isGeom) debugOut.push_back(std::string("tx.main.geom\t") + line.text);
600 debugOut.push_back(std::string("tx.main\t") + line.text);
601 appendDebugValue(debugOut, "tx.main.left\t", mainRow.left.line.text);
602 appendDebugValue(debugOut, "tx.main.valuta\t", mainRow.valuta.line.text);
603 appendDebugValue(debugOut, "tx.main.debit\t", mainRow.debit.line.text);
604 appendDebugValue(debugOut, "tx.main.credit\t", mainRow.credit.line.text);
605 return mainRow;
606 } catch (...) {}
607 return TransactionMainRow{};
608}
609
610void attachOrphansToBlocks(std::vector<TransactionBlock>& blocks,
611 const std::vector<OcrLine>& orphans,
612 int maxGapPx,
613 int valutaX,
614 std::vector<std::string>* debugOut) noexcept
615{
616 try {
617 for (const auto& orphan : orphans) {
618 int bestBlock = -1;
619 int bestGap = std::numeric_limits<int>::max();
620 for (size_t blockIndex = 0; blockIndex < blocks.size(); ++blockIndex) {
621 const auto& block = blocks[blockIndex];
622 if (block.main.left.empty()) continue;
623 const int mainBottom = block.main.left.line.maxY;
624 if (orphan.minY < mainBottom) continue;
625 const int gap = orphan.minY - mainBottom;
626 if (gap <= maxGapPx && gap < bestGap) {
627 bestGap = gap;
628 bestBlock = static_cast<int>(blockIndex);
629 }
630 }
631
632 if (bestBlock < 0) {
633 if (debugOut) debugOut->push_back(std::string("orphan.unmatched\ttext=") + orphan.text);
634 continue;
635 }
636
637 const bool crossesValuta = valutaX >= 0 && orphan.maxX >= valutaX + 40;
638 const bool hasAmountLike = crossesValuta && helpers::hasAmountLikeTokenInLine(orphan, valutaX);
639 if (!crossesValuta || !hasAmountLike) {
640 appendDetailLine(blocks[static_cast<size_t>(bestBlock)], orphan, {valutaX, -1, -1}, debugOut);
641 if (debugOut) debugOut->push_back(std::string("orphan.attach\tblock=") + std::to_string(bestBlock) + std::string("\tgap=") + std::to_string(bestGap) + std::string("\ttext=") + orphan.text);
642 } else if (debugOut) {
643 debugOut->push_back(std::string("orphan.drop\treason=amount-column-overlap\ttext=") + orphan.text);
644 }
645 }
646 } catch (...) {}
647}
648
649void appendPageSummary(const std::vector<RawLine>& lines,
650 int headerBottomY,
651 const std::vector<TransactionBlock>& blocks,
652 int txStartLooseCount,
654{
655 try {
656 std::ostringstream summary;
657 summary << "page.summary\tocr.lines=" << lines.size() << "\theader.prebottomY=" << headerBottomY << "\tblocks=" << blocks.size() << "\ttxStartLoose=" << txStartLooseCount;
658 out.debugLines.push_back(summary.str());
659 } catch (...) { }
660}
661
662static void appendBlockDebug(const TransactionBlock& block,
663 const ColumnModel& cols,
665{
666 try {
667 out.debugLines.push_back(std::string("block.debug\tbookingDateGroup=") + block.bookingDateGroup);
668 out.debugLines.push_back(std::string("block.main.left.text\t") + block.main.left.line.text);
669 out.debugLines.push_back(std::string("block.main.valuta.empty\t") + (block.main.valuta.empty() ? "1" : "0"));
670 out.debugLines.push_back(std::string("block.main.valuta.text\t") + (block.main.valuta.empty() ? std::string("(none)") : block.main.valuta.line.text));
671 out.debugLines.push_back(std::string("block.main.debit.empty\t") + (block.main.debit.empty() ? "1" : "0"));
672 out.debugLines.push_back(std::string("block.main.debit.text\t") + (block.main.debit.empty() ? std::string("(none)") : block.main.debit.line.text));
673 out.debugLines.push_back(std::string("block.main.credit.empty\t") + (block.main.credit.empty() ? "1" : "0"));
674 out.debugLines.push_back(std::string("block.main.credit.text\t") + (block.main.credit.empty() ? std::string("(none)") : block.main.credit.line.text));
675 out.debugLines.push_back(std::string("cols.state\tvalutaX=") + std::to_string(cols.valutaX) + std::string("\tdebitX=") + std::to_string(cols.debitX) + std::string("\tcreditX=") + std::to_string(cols.creditX));
676 } catch (...) { }
677}
678
680 const TransactionBlock& block,
681 const ColumnModel& cols,
684{
685 try {
686 if (ocr.tables.empty() || ocr.tables[0].cells.empty()) return;
687
688 const auto& table = ocr.tables[0];
689 auto overlapsLine = [&](const core::ports::text_recognition::Cell& cell) -> bool {
690 const int cellTop = cell.bbox.y;
691 const int cellBottom = cell.bbox.y + cell.bbox.height;
692 return !(cellTop > block.main.left.line.maxY || cellBottom < block.main.left.line.minY);
693 };
694
695 bool usedCell = false;
696 if (cols.hasCredit() && cols.creditCol >= 0) {
697 for (const auto& cell : table.cells) {
698 if (cell.col != cols.creditCol || !overlapsLine(cell)) continue;
699 out.debugLines.push_back(std::string("cell.amount_used\t") + cell.text);
701 tx.amount = *value;
702 usedCell = true;
703 out.debugLines.push_back(std::string("cell.override->") + std::to_string(tx.amount));
704 }
705 break;
706 }
707 }
708
709 if (!usedCell && cols.hasDebit() && cols.debitCol >= 0) {
710 for (const auto& cell : table.cells) {
711 if (cell.col != cols.debitCol || !overlapsLine(cell)) continue;
712 out.debugLines.push_back(std::string("cell.amount_used\t") + cell.text);
714 tx.amount = -std::abs(*value);
715 out.debugLines.push_back(std::string("cell.override->") + std::to_string(tx.amount));
716 }
717 break;
718 }
719 }
720 } catch (...) { }
721}
722
724 const TransactionBlock& block,
725 int txIndex,
726 const std::shared_ptr<core::ports::document_image_processing::IDocumentImageProcessor>& documentImageProcessor,
727 const std::string& pageCropImagePath,
728 const std::vector<uint8_t>& pageCropImageBytes,
730{
731 try {
732 if (!documentImageProcessor || (pageCropImagePath.empty() && pageCropImageBytes.empty())) return;
733
734 int minX = std::numeric_limits<int>::max();
735 int maxX = std::numeric_limits<int>::min();
736 int minY = std::numeric_limits<int>::max();
737 int maxY = std::numeric_limits<int>::min();
738
739 auto accBounds = [&](const OcrLine& line) {
740 minX = std::min(minX, line.minX);
741 maxX = std::max(maxX, line.maxX);
742 minY = std::min(minY, line.minY);
743 maxY = std::max(maxY, line.maxY);
744 };
745
746 if (!block.main.left.empty()) accBounds(block.main.left.line);
747 for (const auto& line : block.detailLines) accBounds(line);
748 if (minX == std::numeric_limits<int>::max()) return;
749
751 if (!pageCropImagePath.empty()) request.imagePath = std::filesystem::path(pageCropImagePath);
752 request.imageBytes = pageCropImageBytes;
753 request.uniqIdPrefix = std::string(core::utils::makeTransientId());
754 request.filePrefix = std::string("document_image_processing_proof_tx") + std::to_string(txIndex);
756 request.jpegQuality = 92;
757 request.bbox.x = 0;
758 request.bbox.y = std::max(0, minY - 20);
759 request.bbox.width = 1 << 30;
760 request.bbox.height = std::max(1, (maxY - minY) + 24);
761
762 try {
763 out.debugLines.push_back(std::string("crop.request\t") + request.imagePath.string() + std::string("\tfilePrefix=") + request.filePrefix);
764 const auto response = documentImageProcessor->crop(request);
765 out.debugLines.push_back(std::string("crop.result.count\t") + std::to_string(response.croppedImagePaths.size()));
766 if (!response.croppedImageBytes.empty() && !response.croppedImageBytes.front().empty()) {
767 tx.proofImageData = response.croppedImageBytes.front();
768 }
769 } catch (const std::exception& ex) {
770 out.debugLines.push_back(std::string("crop.exception\t") + ex.what());
771 } catch (...) {
772 out.debugLines.push_back(std::string("crop.exception\tunknown"));
773 }
774 } catch (...) { }
775}
776
777void appendTransactionsFromBlocks(const std::vector<TransactionBlock>& blocks,
778 const ColumnModel& cols,
780 const std::shared_ptr<core::ports::document_image_processing::IDocumentImageProcessor>& documentImageProcessor,
781 const std::string& pageCropImagePath,
782 const std::vector<uint8_t>& pageCropImageBytes,
783 int& txIndex,
785{
786 for (const auto& block : blocks) {
787 if (block.main.left.empty()) continue;
788
789 appendBlockDebug(block, cols, out);
790
791 std::vector<std::string> txDebug;
792 const auto parsed = DefaultTransactionParser::parseTransaction(block, &txDebug);
793 for (const auto& line : txDebug) out.debugLines.push_back(std::string("txdbg\t") + line);
794
796 tx.name = parsed.name;
797 tx.bookingDate = parsed.bookingDate;
798 tx.valuta = parsed.valuta;
799 tx.amount = parsed.amount;
800 const auto blockMetadata = fullBlockMetadata(block);
801 tx.metadata = !blockMetadata.empty() ? blockMetadata : parsed.metadata;
802
803 applyCellAmountOverride(tx, block, cols, ocr, out);
804 attachProofCrop(tx, block, txIndex, documentImageProcessor, pageCropImagePath, pageCropImageBytes, out);
805
806 ++txIndex;
807 out.transactions.push_back(std::move(tx));
808 }
809}
810
811}
Declares helpers for parsing imported transaction amount strings.
Declares the default transaction parser used by the import pipeline.
Declares low-level OCR helper routines for statement parsing.
Declares helpers for the statement parsing workflow.
Declares private string helpers shared across core implementation files.
Declares private unique identifier helpers for transient import/job artifacts.
std::pair< int, bool > detectHeaderRegion(const std::vector< core::application::importing::transaction::internal::OcrLine > &lines, size_t scanLines)
Detects the most likely header region in the supplied OCR lines.
static void attachProofCrop(core::application::importing::draft::TransactionDraft &tx, const TransactionBlock &block, int txIndex, const std::shared_ptr< core::ports::document_image_processing::IDocumentImageProcessor > &documentImageProcessor, const std::string &pageCropImagePath, const std::vector< uint8_t > &pageCropImageBytes, DefaultStatementParser::ParseResult &out)
void appendPageSummary(const std::vector< core::application::importing::statement::internal::RawLine > &lines, int headerBottomY, const std::vector< core::application::importing::transaction::internal::TransactionBlock > &blocks, int txStartLooseCount, core::application::importing::statement::DefaultStatementParser::ParseResult &out)
Appends a page-level summary to the parser result.
bool isLikelyTransactionMainRowGeom(const core::application::importing::statement::internal::RawLine &line, const core::application::importing::statement::internal::ColumnModel &cols)
Checks whether a raw line likely contains a main transaction row.
HeaderAnalysis analyzeHeaderWindow(const std::vector< core::application::importing::transaction::internal::OcrLine > &ocrLines, const std::vector< core::application::importing::statement::internal::RawLine > &lines, const core::application::importing::statement::internal::ColumnModel &seedCols, core::application::importing::statement::DefaultStatementParser::ParseResult &out)
Analyzes the page header window and updates parser output state.
core::application::importing::transaction::internal::OcrLine rawToOcrLine(const core::application::importing::statement::internal::RawLine &line)
Converts a raw OCR line into a parser line model.
static bool looksLikeRescuedMainLine(const OcrLine &line, const ColumnModel &cols)
void attachOrphansToBlocks(std::vector< core::application::importing::transaction::internal::TransactionBlock > &blocks, const std::vector< core::application::importing::transaction::internal::OcrLine > &orphans, int maxGapPx, int valutaX, std::vector< std::string > *debugOut=nullptr) noexcept
Attaches orphan OCR lines to existing transaction blocks.
static void appendBlockDebug(const TransactionBlock &block, const ColumnModel &cols, DefaultStatementParser::ParseResult &out)
std::optional< std::string > findBookingDateInHeader(const std::string &line) noexcept
Extracts a booking date from a header line.
void rescueOrphanBlocks(std::vector< core::application::importing::transaction::internal::TransactionBlock > &blocks, const std::vector< core::application::importing::transaction::internal::OcrLine > &orphanLines, const core::application::importing::statement::internal::ColumnModel &cols, const std::string &currentBookingDate, core::application::importing::statement::DefaultStatementParser::ParseResult &out)
Reprocesses orphan transaction blocks against the current booking date and column model.
void appendDetailLine(core::application::importing::transaction::internal::TransactionBlock &cur, const core::application::importing::transaction::internal::OcrLine &line, const core::application::importing::internal::ColumnGuess &cols, std::vector< std::string > *debugOut=nullptr) noexcept
Appends a detail line to the current transaction block.
std::optional< std::pair< core::application::importing::transaction::internal::TransactionMainRow, int > > tryVerticalStart(const std::vector< core::application::importing::transaction::internal::OcrLine > &lines, size_t li, const core::application::importing::internal::ColumnGuess &cols) noexcept
Attempts to start a transaction block from a vertically aligned line.
static void applyCellAmountOverride(core::application::importing::draft::TransactionDraft &tx, const TransactionBlock &block, const ColumnModel &cols, const core::ports::text_recognition::ExtractResult &ocr, DefaultStatementParser::ParseResult &out)
std::optional< std::string > findDefaultBookingDate(const std::vector< core::application::importing::transaction::internal::OcrLine > &lines, size_t scanLines) noexcept
Finds a default booking date near the top of the parsed page.
bool detectEarlyEmptyPage(const std::vector< core::application::importing::transaction::internal::OcrLine > &lines, std::string &outDebug) noexcept
Detects whether a page is effectively empty before parsing continues.
std::vector< core::application::importing::internal::RawLineLite > selectiveGroupMergeLinesRaw(const std::vector< core::application::importing::internal::RawLineLite > &lines, int maxGapPx, const core::application::importing::internal::ColumnGuess &seedCols)
Merges nearby raw lines using the parser's selective grouping heuristic.
bool isLikelyTransactionHeaderLine(const core::application::importing::transaction::internal::OcrLine &line, const core::application::importing::statement::internal::ColumnModel &cols)
Checks whether an OCR line likely represents a transaction header row.
core::application::importing::transaction::internal::TransactionMainRow handleMainRow(const core::application::importing::transaction::internal::OcrLine &line, const core::application::importing::internal::ColumnGuess &cols, bool isGeom, std::vector< std::string > &debugOut) noexcept
Converts one OCR line into a transaction main row.
void appendTransactionsFromBlocks(const std::vector< core::application::importing::transaction::internal::TransactionBlock > &blocks, const core::application::importing::statement::internal::ColumnModel &cols, const core::ports::text_recognition::ExtractResult &ocr, const std::shared_ptr< core::ports::document_image_processing::IDocumentImageProcessor > &documentImageProcessor, const std::string &pageCropImagePath, const std::vector< uint8_t > &pageCropImageBytes, int &txIndex, core::application::importing::statement::DefaultStatementParser::ParseResult &out)
Converts parsed transaction blocks into drafts and extraction artifacts.
std::optional< std::pair< core::application::importing::transaction::internal::TransactionMainRow, int > > tryCombinedStart(const std::vector< core::application::importing::transaction::internal::OcrLine > &lines, size_t li, const core::application::importing::internal::ColumnGuess &cols) noexcept
Attempts to start a transaction block from a combined text pattern.
std::optional< double > parseAmountString(const std::string &s)
std::vector< std::string > tokens(const std::string &text)
Splits normalized text into tokens.
std::string makeTransientId()
Generates a transient unique identifier for import and job artifacts.
std::vector< std::string > splitWhitespace(const std::string &s)
Splits a string on ASCII whitespace.
std::string collapseWhitespace(std::string s)
Collapses consecutive whitespace to single spaces and trims the result.
std::string trim(std::string s)
Removes leading and trailing ASCII whitespace from a string.
Stores the inferred column positions for an OCR page.
Lightweight raw-line representation used during grouping and splitting.
Represents the inferred transaction column layout for a page.
std::vector< core::application::importing::draft::TransactionDraft > transactions
static DefaultTransactionParser parseTransaction(const TransactionBlock &block, std::vector< std::string > *debugOut=nullptr)
Parses a transaction block into a structured transaction model.
Holds the current header-detection state for one statement page.
Represents a merged raw OCR line before it is turned into parser lines.
Represents one OCR line with its bounding box and recognized text.
Groups the main row and detail rows of one parsed transaction block.
enum core::ports::document_image_processing::CropRequest::OutputFormat outputFormat