10using core::application::importing::transaction::internal::OcrLine;
11using core::application::importing::transaction::internal::TransactionMainRow;
14static const std::regex
15 g_amountRegex(R
"(^\(?-?\d{1,3}(?:[\.,]\d{3})*[\.,]\d{1,2}-?$)");
16static const std::regex g_amountFallbackRegex(R
"(^\(?-?\d+[\.,]\d{2}\)?$)");
17static constexpr std::string_view g_narrowNoBreakSpaceUtf8 =
"\xE2\x80\xAF";
18static constexpr std::string_view g_thinSpaceUtf8 =
"\xE2\x80\x89";
20bool tokenLooksLikeAmount(
const std::string &token)
noexcept {
22 return std::regex_match(token, g_amountRegex) ||
23 std::regex_match(token, g_amountFallbackRegex);
29void eraseAll(std::string &value,
const std::string_view token) {
31 while ((position = value.find(token, position)) != std::string::npos) {
32 value.erase(position, token.size());
39 int valutaX,
int bandPx)
noexcept {
40 std::vector<size_t> out;
43 for (
size_t i = 0; i < toks.size(); ++i) {
45 if (!tokenLooksLikeAmount(toks[i]))
50 if (valutaX >= 0 && bandPx > 0) {
51 if (i >= line.wordSpans.size())
53 const auto &sp = line.wordSpans[i];
54 const int cx = (sp.first + sp.second) / 2;
55 if (std::abs(cx - valutaX) > bandPx)
68 if ((c >=
'a' && c <=
'z') || (c >=
'0' && c <=
'9'))
69 o.push_back(
static_cast<char>(c));
75 const std::string &tokenLower)
noexcept {
77 if (toks.size() != line.wordSpans.size())
81 for (
size_t i = 0; i < toks.size(); ++i) {
84 const auto &sp = line.wordSpans[i];
85 return (sp.first + sp.second) / 2;
88 for (
size_t i = 0; i < toks.size(); ++i) {
90 if (n.find(want) == std::string::npos)
92 const auto &sp = line.wordSpans[i];
93 return (sp.first + sp.second) / 2;
101 std::initializer_list<const char *> phraseLower)
noexcept {
103 if (toks.size() != line.wordSpans.size())
107 for (
auto w : phraseLower)
109 if (want.empty() || toks.empty())
112 for (
size_t i = 0; i < toks.size(); ++i) {
114 int left = line.wordSpans[i].first;
115 int right = line.wordSpans[i].second;
119 (j - i) <
static_cast<size_t>(
parserConfig.maxPhraseTokens);
122 right = line.wordSpans[j].second;
123 if (acc.find(want) != std::string::npos) {
124 return (left + right) / 2;
133 int bandPx)
noexcept {
137 if (toks.size() != line.wordSpans.size() || toks.empty())
140 for (
size_t i = 0; i < toks.size(); ++i) {
141 const auto &sp = line.wordSpans[i];
142 const int cx = (sp.first + sp.second) / 2;
143 if (std::abs(cx - x) <= bandPx)
150 int valutaX)
noexcept {
163 int valutaX)
noexcept {
168 for (
size_t i = 0; i < toks.size() && i < line.wordSpans.size(); ++i) {
169 const auto &sp = line.wordSpans[i];
170 const int cx = (sp.first + sp.second) / 2;
171 if (cx < valutaX -
parserConfig.leftDescriptiveOffsetPx) {
172 for (
unsigned char c : toks[i])
177 for (
unsigned char c : toks[0])
185 int bandPx)
noexcept {
190 return !idxs.empty();
196 int valutaX)
noexcept {
203 if (!std::regex_search(line.text, md, std::regex(R
"((\d{2}\.\s*\d{2}))")))
205 const size_t datePos =
static_cast<size_t>(md.position(0));
207 bool foundAmountAfterDate =
false;
209 for (
auto i : amtIdxs) {
210 if (i >= toks.size())
212 const auto pos = line.text.find(toks[i]);
213 if (pos != std::string::npos && pos > datePos) {
214 foundAmountAfterDate =
true;
218 if (!foundAmountAfterDate)
220 const auto left = line.text.substr(0, datePos);
221 for (
unsigned char c : left) {
231 std::vector<std::string> *debugOut)
noexcept {
233 int band = (valutaX >= 0) ?
parserConfig.amountNearValutaBandPx : 0;
239 auto tryParse = [&](
const std::string &s) -> std::optional<double> {
244 debugOut->push_back(std::string(
"candidate.try\t") + s);
249 debugOut->push_back(std::string(
"candidate.skip.shortDate\t") +
256 std::string norm = s;
258 bool negative =
false;
259 if (!norm.empty() && norm.front() ==
'(' && norm.back() ==
')') {
261 norm = norm.substr(1, norm.size() - 2);
263 if (!norm.empty() && norm.front() ==
'-') {
265 norm = norm.substr(1);
267 if (!norm.empty() && norm.back() ==
'-') {
272 eraseAll(norm, g_narrowNoBreakSpaceUtf8);
273 eraseAll(norm, g_thinSpaceUtf8);
276 tmp.reserve(norm.size());
277 for (
unsigned char c : norm) {
278 if (c ==
'.' || c ==
' ')
280 tmp.push_back(
static_cast<char>(c));
282 std::replace(tmp.begin(), tmp.end(),
',',
'.');
286 double v = std::stod(tmp);
290 debugOut->push_back(std::string(
"candidate.ok.norm\t") + tmp +
291 std::string(
" -> ") + std::to_string(v));
300 debugOut->push_back(std::string(
"candidate.ok\t") + s +
301 std::string(
" -> ") + std::to_string(*v));
305 std::string nosp = s;
306 nosp.erase(std::remove(nosp.begin(), nosp.end(),
' '), nosp.end());
309 debugOut->push_back(std::string(
"candidate.try.nosp\t") + nosp);
314 debugOut->push_back(std::string(
"candidate.ok.nosp\t") + nosp +
315 std::string(
" -> ") + std::to_string(*v2));
325 std::string(
"helper.findAndParseAmountInLine\tline=") + line.text +
326 std::string(
" idxs=") + std::to_string(idxs.size()));
328 for (
auto i : idxs) {
329 if (i >= toks.size())
331 if (
auto r = tryParse(toks[i]))
334 if (
auto r = tryParse(toks[i - 1] + toks[i]))
336 if (
auto r2 = tryParse(toks[i - 1] +
" " + toks[i]))
339 for (
int len = 1; len <= 2; ++len) {
341 for (
int k = 0; k <= len; ++k) {
342 int idx =
static_cast<int>(i) + k;
343 if (idx >=
static_cast<int>(toks.size()))
346 joined.push_back(
' ');
349 if (!joined.empty()) {
350 if (
auto r = tryParse(joined))
354 if (i + 2 < toks.size()) {
355 std::string three = toks[i] + toks[i + 1] + toks[i + 2];
356 if (
auto r = tryParse(three))
361 debugOut->push_back(std::string(
"candidate.try.whole\t") + line.text);
365 debugOut->push_back(std::string(
"candidate.ok.whole\t") + line.text +
366 std::string(
" -> ") + std::to_string(*v));
374 const std::vector<core::ports::text_recognition::Word> &words) {
383 ws.reserve(words.size());
384 for (
const auto &w : words) {
385 bool anyNonSpace =
false;
386 for (
unsigned char c : w.text) {
387 if (!std::isspace(c)) {
394 const int cy = w.bbox.y + w.bbox.height / 2;
395 ws.push_back(W{&w, cy, w.bbox.y, w.bbox.y + w.bbox.height, w.bbox.x});
397 std::sort(ws.begin(), ws.end(), [](
const W &a,
const W &b) {
399 return a.top < b.top;
402 return a.left < b.left;
405 for (
const auto &ww : ws)
406 avgH += (ww.bottom - ww.top);
407 avgH = ws.empty() ? 10 : std::max(1, avgH /
static_cast<int>(ws.size()));
408 const int minOverlap = std::max(2, avgH / 3);
411 int minX = std::numeric_limits<int>::max();
412 int maxX = std::numeric_limits<int>::min();
413 int minY = std::numeric_limits<int>::max();
414 int maxY = std::numeric_limits<int>::min();
415 std::vector<const core::ports::text_recognition::Word *> words;
417 std::vector<LineAcc> acc;
418 acc.reserve(std::max<size_t>(8, words.size() / 8));
419 for (
const auto &ww : ws) {
420 const auto &w = *ww.w;
421 const int wMinX = w.bbox.x;
422 const int wMaxX = w.bbox.x + w.bbox.width;
423 const int wMinY = w.bbox.y;
424 const int wMaxY = w.bbox.y + w.bbox.height;
426 int bestOverlap = -1;
427 for (
int i =
static_cast<int>(acc.size()) - 1; i >= 0; --i) {
428 const auto &l = acc[
static_cast<size_t>(i)];
429 const int top = std::max(l.minY, wMinY);
430 const int bot = std::min(l.maxY, wMaxY);
431 const int ov = bot - top;
432 if (ov >= minOverlap && ov > bestOverlap) {
436 if (wMinY - l.maxY > avgH)
446 nl.words.push_back(&w);
447 acc.push_back(std::move(nl));
450 auto &l = acc[
static_cast<size_t>(bestIdx)];
451 l.cy = (l.cy + ww.cy) / 2;
452 l.minX = std::min(l.minX, wMinX);
453 l.maxX = std::max(l.maxX, wMaxX);
454 l.minY = std::min(l.minY, wMinY);
455 l.maxY = std::max(l.maxY, wMaxY);
456 l.words.push_back(&w);
458 std::vector<OcrLine> out;
459 out.reserve(acc.size());
460 for (
auto &la : acc) {
461 std::sort(la.words.begin(), la.words.end(),
464 if (a->bbox.x != b->bbox.x)
465 return a->bbox.x < b->bbox.x;
466 return a->bbox.y < b->bbox.y;
472 if (!la.words.empty()) {
473 ol.
minX = la.words.front()->bbox.x;
474 ol.
maxX = la.words.front()->bbox.x + la.words.front()->bbox.width;
480 for (
const auto *w : la.words) {
481 const int left = w->bbox.x;
482 const int right = w->bbox.x + w->bbox.width;
491 out.push_back(std::move(ol));
498 size_t scanN)
noexcept {
501 const size_t n = std::min(lines.size(), scanN);
502 for (
size_t i = 0; i < n; ++i) {
503 const auto &l = lines[i];
526std::vector<RawLineLite>
528 if (lines.size() <= 1)
530 std::vector<RawLineLite> merged;
531 merged.reserve(lines.size());
533 for (
size_t i = 1; i < lines.size(); ++i) {
534 const auto &next = lines[i];
535 if (next.minY - cur.
maxY <= maxGapPx) {
536 cur.
maxY = next.maxY;
538 next.wordSpans.end());
539 cur.
text +=
" " + next.text;
541 merged.push_back(std::move(cur));
545 merged.push_back(std::move(cur));
552 if (i0 >= i1 || i0 >= src.wordSpans.size())
554 i1 = std::min(i1, src.wordSpans.size());
557 out.
minX = src.wordSpans[i0].first;
558 out.
maxX = src.wordSpans[i0].second;
560 for (
size_t i = i0; i < i1; ++i) {
561 const auto &sp = src.wordSpans[i];
562 out.
minX = std::min(out.
minX, sp.first);
563 out.
maxX = std::max(out.
maxX, sp.second);
568 for (
size_t i = i0; i < i1 && i < toks.size(); ++i) {
579 int creditX)
noexcept {
582 if (toks.size() != src.wordSpans.size() || toks.empty()) {
591 auto cxAt = [&](
size_t i) ->
int {
592 const auto &sp = src.wordSpans[i];
593 return (sp.first + sp.second) / 2;
595 auto idxNearX = [&](
int x,
int skipIdx) ->
int {
597 int bestDist = std::numeric_limits<int>::max();
598 for (
size_t i = 0; i < toks.size(); ++i) {
599 if ((
int)i == skipIdx)
601 int d = std::abs(cxAt(i) - x);
609 auto idxNearXPreferNumeric = [&](
int x,
int skipIdx) ->
int {
611 int bestDist = std::numeric_limits<int>::max();
612 int bestPriority = -1;
613 for (
size_t i = 0; i < toks.size(); ++i) {
614 if ((
int)i == skipIdx)
616 int d = std::abs(cxAt(i) - x);
619 if (tokenLooksLikeAmount(toks[i]))
622 if (priority > bestPriority ||
623 (priority == bestPriority && d < bestDist)) {
624 bestPriority = priority;
632 int valutaIdx = (valutaX >= 0) ? idxNearX(valutaX, -1) : -1;
633 int debitIdx = (debitX >= 0) ? idxNearXPreferNumeric(debitX, -1) : -1;
634 int creditIdx = (creditX >= 0) ? idxNearXPreferNumeric(creditX, -1) : -1;
636 if (valutaIdx >= 0 && debitIdx < 0 && creditIdx < 0) {
638 for (
size_t i = 0; i < toks.size(); ++i) {
639 if (cxAt(i) <= valutaX - 120)
642 if (tokenLooksLikeAmount(toks[i])) {
651 else if (debitX >= 0)
658 if (valutaIdx >= 0) {
659 if (debitIdx == valutaIdx && debitX >= 0)
660 debitIdx = idxNearX(debitX, valutaIdx);
661 if (creditIdx == valutaIdx && creditX >= 0)
662 creditIdx = idxNearX(creditX, valutaIdx);
664 if (debitIdx >= 0 && creditIdx == debitIdx && creditX >= 0)
665 creditIdx = idxNearX(creditX, debitIdx);
667 int firstRight = std::numeric_limits<int>::max();
669 firstRight = std::min(firstRight, valutaIdx);
671 firstRight = std::min(firstRight, debitIdx);
673 firstRight = std::min(firstRight, creditIdx);
674 if (firstRight == std::numeric_limits<int>::max())
675 firstRight = (int)toks.size();
678 src, 0,
static_cast<size_t>(std::max(0, firstRight)));
679 if (valutaIdx >= 0) {
680 size_t v0 =
static_cast<size_t>(valutaIdx);
682 if (v1 < toks.size() &&
683 std::abs(cxAt(v1) - valutaX) <=
parserConfig.valutaNeighborExpandPx)
689 static_cast<size_t>(debitIdx + 1));
692 static_cast<size_t>(creditIdx + 1));
699 int valutaX,
int debitX,
int creditX)
noexcept {
Declares low-level OCR helper routines for statement parsing.
Declares private string helpers shared across core implementation files.
std::vector< RawLineLite > groupMergeLinesRaw(const std::vector< RawLineLite > &lines, int maxGapPx)
Merges raw lines using a gap-based heuristic.
std::string normalizeAlnumLower(const std::string &s) noexcept
Normalizes alphanumeric text to lower-case ASCII for matching.
std::vector< core::application::importing::transaction::internal::OcrLine > buildOcrLinesFromWords(const std::vector< core::ports::text_recognition::Word > &words)
Builds OCR lines from raw Tesseract word output.
std::optional< int > findPhraseCenterX(const core::application::importing::transaction::internal::OcrLine &line, std::initializer_list< const char * > phraseLower) noexcept
Locates the horizontal center of a multi-token phrase in an OCR line.
ColumnGuess inferColumnModelFromLines(const std::vector< core::application::importing::transaction::internal::OcrLine > &lines, size_t scanN=16) noexcept
Infers a column layout from a set of OCR lines.
ParserConfig parserConfig
Shared parser configuration instance used by import parsing helpers.
core::application::importing::transaction::internal::OcrLine toOcrLineFromRawWords(const RawLineLite &src, size_t i0, size_t i1) noexcept
Converts a range of raw words into one OCR line.
bool isLooseTransactionLine(const core::application::importing::transaction::internal::OcrLine &line, int valutaX) noexcept
Checks whether the line looks like a loosely formatted transaction row.
bool hasAmountLikeTokenInLine(const core::application::importing::transaction::internal::OcrLine &line, int valutaX) noexcept
Checks whether the line contains amount-like tokens near the valuta column.
std::vector< size_t > findAmountTokenIndices(const core::application::importing::transaction::internal::OcrLine &line, int valutaX=-1, int bandPx=0) noexcept
Finds the indices of amount-like tokens in an OCR line.
core::application::importing::transaction::internal::TransactionMainRow splitMainRowFromOcrLine(const core::application::importing::transaction::internal::OcrLine &src, int valutaX, int debitX, int creditX) noexcept
Splits an OCR line into the main transaction row representation.
bool hasAmountNearValuta(const core::application::importing::transaction::internal::OcrLine &line, int valutaX, int bandPx) noexcept
Checks whether an amount appears close to the valuta reference.
std::optional< int > findTokenCenterX(const core::application::importing::transaction::internal::OcrLine &line, const std::string &tokenLower) noexcept
Locates the horizontal center of a token inside an OCR line.
std::optional< double > findAndParseAmountInLine(const core::application::importing::transaction::internal::OcrLine &line, int valutaX=-1, std::vector< std::string > *debugOut=nullptr) noexcept
Finds and parses an amount token inside an OCR line.
bool hasLeftDescriptiveText(const core::application::importing::transaction::internal::OcrLine &line, int valutaX) noexcept
Checks whether the line contains descriptive text to the left of the valuta column.
bool hasShortDateToken(const std::string &text) noexcept
Checks whether the text contains a short date token.
bool hasTokenNearX(const core::application::importing::transaction::internal::OcrLine &line, int x, int bandPx) noexcept
Checks whether any token in the line is close to a horizontal position.
core::application::importing::transaction::internal::TransactionMainRow splitMainRowFromRaw(const RawLineLite &src, int valutaX, int debitX, int creditX) noexcept
Splits a raw line into the main transaction row representation.
std::optional< double > parseAmountString(const std::string &s)
std::vector< std::string > splitWhitespace(const std::string &s)
Splits a string on ASCII whitespace.
std::string lowerAscii(std::string s)
Converts ASCII letters in a string to lowercase.
std::string trim(std::string s)
Removes leading and trailing ASCII whitespace from a string.
Stores the inferred column positions for an OCR page.
Runtime configuration for parser heuristics and thresholds.
Lightweight raw-line representation used during grouping and splitting.
std::vector< std::pair< int, int > > wordSpans
Represents one OCR line with its bounding box and recognized text.
std::vector< std::pair< int, int > > wordSpans
Represents the parsed main row of a transaction block.