Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions src/extractor/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,13 @@ pub(crate) fn detect_columns(
// than the peaks on either side.
// Only attempt this for dense pages (>=100 items) — sparse pages with shallow
// histogram dips are likely not multi-column.
// Skip on pages with detected tables — table column gaps look like gutters
// in the histogram but the table pipeline already handles reading order.
if valleys.is_empty() && page_items.len() >= 100 && !page_has_table {
// This runs even on pages with a detected table: two-column prose and a table
// routinely coexist on one page (e.g. a fund factsheet whose left/right columns
// sit above a returns table), and gating the fallback on `page_has_table`
// collapses those pages to single-column, Y-interleaved reading order. The
// `columns_have_prose` check below is the real table defense — table column
// gaps produce many-items-per-line splits that it rejects.
if valleys.is_empty() && page_items.len() >= 100 {
let rel_valleys = find_relative_valleys(
&histogram,
num_bins,
Expand Down Expand Up @@ -168,9 +172,13 @@ pub(crate) fn detect_columns(
}
}
}
// Try XY-cut fallback before giving up
if let Some(columns) = try_xy_cut_split(&page_items, x_min, x_max, page) {
return columns;
// Try XY-cut fallback before giving up. Unlike the relative-valley path,
// this has no prose guard, so a table's widest column gap could be read
// as a page gutter — keep it off when the page has a detected table.
if !page_has_table {
if let Some(columns) = try_xy_cut_split(&page_items, x_min, x_max, page) {
return columns;
}
}
return vec![ColumnRegion { x_min, x_max }];
}
Expand Down Expand Up @@ -1895,6 +1903,27 @@ mod tests {
);
}

#[test]
fn relative_valley_detects_columns_even_with_table_on_page() {
// Regression: two-column justified prose and a table routinely coexist
// on one page (e.g. a fund factsheet whose left/right text columns sit
// above a returns table). Gating the relative-valley fallback on
// `page_has_table` collapsed such pages to a single column, producing
// Y-interleaved reading order. The `columns_have_prose` guard — not the
// page-level table flag — is what keeps genuine tables from being split.
let mut items = Vec::new();
items.extend(fill_zone_justified(1, 40.0, 290.0, 7.0, 750.0, 50.0));
items.extend(fill_zone_justified(1, 300.0, 550.0, 7.0, 750.0, 50.0));

let cols = detect_columns(&items, 1, /* page_has_table */ true);
assert_eq!(
cols.len(),
2,
"Two-column prose must still split when the page also has a table, got {}",
cols.len()
);
}

#[test]
fn relative_valley_rejects_single_column_margin() {
// Single column of text — the right margin drop-off should NOT be
Expand Down
76 changes: 75 additions & 1 deletion src/tables/detect_heuristic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,6 @@ fn detect_table_in_region(items: &[(usize, &TextItem)], mode: TableDetectionMode
columns.len(),
item_indices.len()
);

Some(Table::new(columns, rows, cells, item_indices))
}

Expand Down Expand Up @@ -1360,6 +1359,36 @@ fn is_paragraph_content(cells: &[Vec<String>]) -> bool {
return true;
}

// Justified paragraph text chopped across many columns. A near-full row
// (fills most columns) whose cells are mostly mid-sentence fragments —
// starting lowercase, including multi-word cells — is a wrapped prose line,
// not a table row. Two or more such rows means paragraph text. Real table
// rows are numeric or capitalized labels and seldom fill every column with
// lowercase word-runs, so this stays clear of legitimate wide text tables
// (category lists, program descriptions), whose cells are short labels.
let prose_rows = cells
.iter()
.filter(|row| {
let row_filled: Vec<&str> = row
.iter()
.map(|c| c.trim())
.filter(|c| !c.is_empty())
.collect();
if row_filled.len() < 4 || row_filled.len() < num_cols.saturating_sub(2) {
return false;
}
let lowercase_start = row_filled
.iter()
.filter(|c| c.chars().next().is_some_and(char::is_lowercase))
.count();
let has_multiword = row_filled.iter().any(|c| c.split_whitespace().count() >= 3);
lowercase_start * 5 >= row_filled.len() * 3 && has_multiword

@cubic-dev-ai cubic-dev-ai Bot Aug 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new prose-row gate in validation 8 can reject legitimate wide text tables and flatten them to plain text. has_table_like_content (validation 7) deliberately bypasses the numeric-content check for any 3+ column table so that genuine text-only tables (category lists, program descriptions, glossaries) are kept. But this new prose_rows >= 2 check then rejects any wide table where ≥2 rows are near-full, ≥60% lowercase-starting, and contain at least one 3+-word cell. A real multi-column text table whose rows are fully populated with lowercase multi-word cells therefore passes validation 7 and is then dropped as 'prose' — a structural regression for valid tables. The existing unit tests only cover the 8-column disclaimer (should reject) and a numeric stats table (should keep); neither exercises a legitimate full-width lowercase text table, so this false-positive path is untested. Consider gating the new heuristic on the table lacking genuine data cells (numbers/dates/units) so text rows beside real data are preserved, and/or requiring multi-word cells across a majority of the row rather than a single cell.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tables/detect_heuristic.rs, line 1385:

<comment>The new prose-row gate in validation 8 can reject legitimate wide text tables and flatten them to plain text. `has_table_like_content` (validation 7) deliberately bypasses the numeric-content check for any 3+ column table so that genuine text-only tables (category lists, program descriptions, glossaries) are kept. But this new `prose_rows >= 2` check then rejects any wide table where ≥2 rows are near-full, ≥60% lowercase-starting, and contain at least one 3+-word cell. A real multi-column text table whose rows are fully populated with lowercase multi-word cells therefore passes validation 7 and is then dropped as 'prose' — a structural regression for valid tables. The existing unit tests only cover the 8-column disclaimer (should reject) and a numeric stats table (should keep); neither exercises a legitimate full-width lowercase text table, so this false-positive path is untested. Consider gating the new heuristic on the table lacking genuine data cells (numbers/dates/units) so text rows beside real data are preserved, and/or requiring multi-word cells across a majority of the row rather than a single cell.</comment>

<file context>
@@ -1360,6 +1359,36 @@ fn is_paragraph_content(cells: &[Vec<String>]) -> bool {
+                .filter(|c| c.chars().next().is_some_and(char::is_lowercase))
+                .count();
+            let has_multiword = row_filled.iter().any(|c| c.split_whitespace().count() >= 3);
+            lowercase_start * 5 >= row_filled.len() * 3 && has_multiword
+        })
+        .count();
</file context>
Suggested change
lowercase_start * 5 >= row_filled.len() * 3 && has_multiword
// Only treat as prose if the row carries no genuine table data,
// so real tables with numbers/dates/units aren't flattened.
let has_data_cell = row_filled.iter().any(|c| looks_like_table_data(c));
if has_data_cell {
return false;
}
lowercase_start * 5 >= row_filled.len() * 3 && has_multiword
Fix with cubic

})
.count();
if prose_rows >= 2 {
return true;
}

false
}

Expand Down Expand Up @@ -2167,4 +2196,49 @@ mod tests {
];
assert!(!is_page_number_toc(&cells));
}

fn row(cells: &[&str]) -> Vec<String> {
cells.iter().map(|c| c.to_string()).collect()
}

#[test]
fn justified_prose_split_across_columns_is_paragraph() {
// A disclaimer paragraph chopped into 8 near-full rows of lowercase
// mid-sentence fragments must be recognized as prose, not a table.
let cells = vec![
row(&[
"minősül",
"ajánlattételnek vagy",
"befektetési",
"tanácsadásnak.",
"A befektetés részletes feltételeit",
"",
"",
"az Alap",
]),
row(&[
"Tájékoztatója",
"tartalmazza,",
"mely a mindenkor",
"érvényes",
"kondíciós",
"listákkal",
"együtt megtalálható",
"a",
]),
];
assert!(is_paragraph_content(&cells));
}

#[test]
fn numeric_stats_table_is_not_paragraph() {
// A real table with numeric cells must NOT be flagged as prose — its
// rows are short numeric values, not lowercase word-runs.
let cells = vec![
row(&["", "6 hó", "1 év", "3 év"]),
row(&["Az Alap szórása", "3,76%", "4,62%", "3,77%"]),
row(&["Az Alap Sharpe mutatója", "-0,02", "0,64", "1,05"]),
];
assert!(!is_paragraph_content(&cells));
}
}
Binary file added tests/fixtures/marketprog-prestige-two-column.pdf
Binary file not shown.
11 changes: 11 additions & 0 deletions tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,17 @@ fn test_snapshot_2013_app2() {
assert_snapshot("2013-app2");
}

// Two-column fund factsheet with a returns table on the same page. Guards
// against the regression where a detected table suppressed column detection,
// collapsing the left (fund data) and right (market commentary) columns into
// a single Y-interleaved stream. The snapshot reads the left column contiguously
// (ÁLTALÁNOS INFORMÁCIÓK → BEFEKTETÉSI POLITIKA) before the right column
// (PIACI ÖSSZEFOGLALÓ) rather than shredding one into the other.
#[test]
fn test_snapshot_marketprog_prestige_two_column() {
assert_snapshot("marketprog-prestige-two-column");
}

// ============================================================================
// Pages Needing OCR Tests
// ============================================================================
Expand Down
34 changes: 34 additions & 0 deletions tests/snapshots/marketprog-prestige-two-column.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
aegon **HAVI HÍRLEVÉL - 2026. ÁPRILIS (KÉSZÍTÉS IDŐPONTJA: 2026.04.30)**

# Prestige Abszolút Hozamú Származtatott Részalap EUR sorozat

**ÁLTALÁNOS INFORMÁCIÓK** Alapkezelő: Marketprog Asset Management Zrt. Letétkezelő: Unicredit Bank Zrt. Vezető forgalmazó: Marketprog Asset Management Zrt. Benchmark összetétele: 100% Constans referencia index (EUR) + 2,2% ISIN kód: HU0000714563 Indulás: 2020.06.16 Devizanem: EUR A teljes alap nettó eszközértéke: 3 472 617 177 HUF EUR sorozat nettó eszközértéke: 1 484 997 EUR Egy jegyre jutó nettó eszközérték: 0,008876 EUR **BEFEKTETÉSI POLITIKA:** A Prestige Abszolút Hozamú Származtatott Részalap (továbbiakban: Részalap) elsődleges célja, hogy a globálisan legszélesebb körben elérhető eszközosztályokba történő befektetés útján az előre meghatározott kockázati profil figyelembevételével az adott sorozatra meghatározott Referenciamutatónál magasabb hozamot érjen el többlet kockázatok vállalásán keresztül. A Részalap úgy alakítja ki portfólióját, hogy befektetéseinek aggregált volatilitása 10%-ot célozzon meg. A befektetések összetétele rendszeres időközönként felülvizsgálatra kerül. A cél volatilitástól való eltéres esetén a Részalap meglévő eszközeit súlyozza át, vagy új eszközöket vesz fel portfóliójába a cél elérése érdekeben. Az Alapkezelő, a portfolió összetétele és az eszközök közötti súlyok kialakítása során nagyrészt pénzügyi modellek igénybevételére, kisebb részben saját piaci megítéléseire támaszkodik. A potenciális befektetési terület globális és a lehető legszélesebb: hazai és nemzetközi részvények, kötvények, ETF-ek; bankbetétek és egyéb pénzpiaci instrumentumok; származtatott ügyletek. A Részalap emellett a globális devizapiac eszközeibe is befektet spekulatív vagy fedezési céllal. A Részalap a jogszabályban meghatározott limitek és szorzószámok figyelembevételével tőkeáttételes, hosszú (vételi) és fedezetlen rövid (eladási) pozíciókat is felvehet, de a teljes nettósított kockázati kitettsége nem haladhatja meg a 78/2014. (III. 14.) Korm. rendelet szerinti korrekció nélkül számított értéken figyelembe vett nettó eszközérték nyolcszorosát. A tőkeáttétellel való kereskedés az adott ügyletbe fektetett összegnél is nagyobb mértékű veszteséggel járhat. A Prestige Abszolút Hozamú Származtatott Részalap referenciamutatójának értéke "HUF" sorozat esetén 550 bázispont, "EUR" sorozatának 200 bázispont. A Részalapot kockázatvállaló ügyfeleinknek ajánljuk, hosszú távú pénzügyi célok megvalósításához, azok számára, akik akár hosszabb távon is tolerálni képesek a mérsékelt árfolyamveszteségeket. A Részalap adott esetben nem megfelelő azon befektetők számára, akik 3 éven belül ki akarják venni a Részalapból a pénzüket. Az Alapkezelőnek az alap kezelése során nem célja a fenntarthatósági tényezők előmozdítása. **FORGALMAZÓK** Erste Befektetési Zrt., Marketprog Asset Management Zrt., Raiffeisen Bank Zrt., Takarékbank **AZ ALAP ÁLTAL ELÉRT NETTÓ HOZAM:**

Időtáv Befektetési jegy Benchmark indulástól-2,01 % 1,17 % 2025 1,54 % 2,02 % 2024 4,50 % 2,55 % 2023 1,22 % 2,01 % 2022 4,07 % 0,33 % 2021-11,94 %-0,54 % **AZ ALAP TELJESÍTMÉNYEAZ ELMÚLT 12 HÓNAPBAN** **EGY JEGYRE JUTÓ NETTÓ ESZKÖZÉRTÉK, 2025.05.01 - 2026.04.30**

**PIACI ÖSSZEFOGLALÓ:** Az alap HUF sorozatának árfolyama áprilisban 2,8%-kal emelkedett, EUR sorozata 3,6%-kal. A részvénypiac kivételesen jó eredménnyel zárta a hónapot. Az S&P500 és Nasdaq is 10% feletti hozamot tudhat magáénak, amellyel új történelmi csúcson zárt. Kissé lemaradva ugyan, de az európai részvények is elindultak az újabb csúcsok irányába. Úgy tűnik a részvénypiac lerázta magáról a közel-keleti konfliktus gazdasági hatásait; hiába a magasan ragadt olajárak és inflációs félelmek, e kockázatokat egyelőre kiárazta a piac. A ralit a negyedéves jelentési szezon jó eredményei támogatták, miközben a befektetők a legújabb generációs MI fejlesztések hatékonyságnövelő képességéhez kapcsolódó várakozásai vásárlási hullámot indítottak. A kötvénypiac már kevésbé tudhat maga mögött sikeres hónapot. Itt többnyire érződik az inflációs várakozások beépülése és a kamatcsökkentések kiárazódása, az április enyhe hozamemelkedést hozott a hosszú amerikai kötvénypiacon. Egészen más képet fest a magyar piac. Az ápr 12-ei országgyűlési választások eredménye átrendeződést okozhat a gazdaságpolitikai intézkedések és nemzetközi megítélés tekintetében is. A politikai fordulat pozitív hatásait azonnal árazni kezdte a piac, amely a magyar bluechip részvények felülteljesítéséhez vezetett, a kötvényvásárlások ereje az összes időtávot elérte, a forint pedig 10% körüli erősödést tudhat magáénak az olajválság kirobbanásának mélypontjai óta. A részvények felülteljesítése volt az alap emelkedésének motorja, ahol elkezdtük az átsúlyozást az európai piacokról az USA felé. Magyar kötvénykitettségünk is eredményes volt, miközben deviza long pozícióinkat tovább csökkentettük. Az EUR sorozatban a devizakitettséget lefedeztük. **AZ ALAP ESZKÖZÖSSZETÉTELE2026.04.30**

## Eszköz típusa Részarány

Kollektívértékpapírok 39,83 % Vállalati és hitelintézeti kötvények 32,46 % Államkötvények 14,08 % Számlapénz 11,97 % Nyitott derivatívpozíciók értéke 0,93 % Követelés 0,92 % Kötelezettség-0,11 % Összesen 100,00 % Származtatott ügyletek 92,00 % Nettó korrekciós tőkeáttétel 91,37 %

## A(z) 5 legnagyobb pozíció:

Erste Ingatlan Befektetési Alap HUF Hold GALAXIS Absz.H. Alapok Alapja HUF sor Accorde ABACUS Abszolút Hozamú Alap C sorozat Accorde ABACUS Abszolút Hozamú Alap A sorozat ERSTE Jelzáloglevél 6,5% HUF 10/29/31 (Erste Jelzálog Bank Zrt.)

## 10%-nál magasabb részarányt képviselő eszközök

Nincs ilyen eszköz a portfólióban **Lejárat szerinti megoszlás:**

## ----- Prestige Abszolút Hozamú Származtatott Részalap EUR sorozat

## ----- Benchmark

A múltbeli hozamoknem jelentenekgaranciát az alap jövőbeli teljesítményére nézve. Jelen hirdetésnem minősül ajánlattételnek vagy befektetési tanácsadásnak. A befektetés részletes feltételeit az Alap Tájékoztatója tartalmazza, mely a mindenkor érvényes kondíciós listákkal együtt megtalálható a forgalmazási helyeken. A befektetési alap forgalmazásával (vétel, tartás, eladás) kapcsolatos költségek az <u>alap kezelési szabályzatában ésa forgalmazási helyeken megismerhetők.</u>

@cubic-dev-ai cubic-dev-ai Bot Aug 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The updated snapshot still blesses malformed prose with missing spaces, so the regression test will accept unreadable output for this paragraph. The extractor's spacing should be corrected and the snapshot regenerated rather than encoding these concatenations as the expected result.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/snapshots/marketprog-prestige-two-column.md, line 27:

<comment>The updated snapshot still blesses malformed prose with missing spaces, so the regression test will accept unreadable output for this paragraph. The extractor's spacing should be corrected and the snapshot regenerated rather than encoding these concatenations as the expected result.</comment>

<file context>
@@ -24,13 +24,7 @@ Nincs ilyen eszköz a portfólióban **Lejárat szerinti megoszlás:**
-|Tájékoztatója forgalmazási helyeken. A befektetési alap forgalmazásával (vétel, tartás, eladás) kapcsolatos költségek az alap kezelési szabályzatában ésa forgalmazási helyeken megismerhetők.|tartalmazza,|mely a mindenkor|érvényes|kondíciós|listákkal|együtt megtalálható|a|
-
-Tájékoztatója tartalmazza, mely a mindenkor érvényes kondíciós listákkal együtt megtalálható a
+A múltbeli hozamoknem jelentenekgaranciát az alap jövőbeli teljesítményére nézve. Jelen hirdetésnem minősül ajánlattételnek vagy befektetési tanácsadásnak. A befektetés részletes feltételeit az Alap Tájékoztatója tartalmazza, mely a mindenkor érvényes kondíciós listákkal együtt megtalálható a forgalmazási helyeken. A befektetési alap forgalmazásával (vétel, tartás, eladás) kapcsolatos költségek az <u>alap kezelési szabályzatában ésa forgalmazási helyeken megismerhetők.</u>
 
 **KOCKÁZATI MUTATÓK AZ ELMÚLT 12 HÓNAPRA:** Az alap heti hozamokból számolt évesített szórása: 4,77 % A benchmark heti hozamokból számolt évesített szórása: 0,04 % **BEFEKTETÉSI HORIZONT:** A javasolt minimális befektetési idő:
</file context>
Fix with cubic


**KOCKÁZATI MUTATÓK AZ ELMÚLT 12 HÓNAPRA:** Az alap heti hozamokból számolt évesített szórása: 4,77 % A benchmark heti hozamokból számolt évesített szórása: 0,04 % **BEFEKTETÉSI HORIZONT:** A javasolt minimális befektetési idő:

3 hó 6 hó 1 év 2 év 3 év 4 év 5 év Kockázati besorolás a heti hozamok elmúlt időszaki szórása alapján:

nagyon nagyon alacsony mérsékelt közepes jelentős magas alacsony magas