fix(tables): Remove multi-column guard, add content-based validation

Replace the blanket multi-column page guard (which blocked ALL table
detection on two-column pages) with content-based validation that
rejects paragraph text falsely detected as tables:

- Detect word-break hyphens at cell boundaries (paragraph signal)
- Reject high empty-cell ratio with many rows (sparse grid = text)
- Detect letter-spaced text (wide character spacing, not data)
- Reject long sentence fragments (avg cell >40 chars + many >60)
- Recover body-font header rows for small-font tables
- Add X-range filtering to body-font table region detection

olmOCR-bench: table_tests 16.4%→18.7% (+24), overall 31.6%→32.2%

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-16 22:56:36 -08:00
co-authored by Claude Opus 4.6
parent 8eda530fed
commit 9a98fbc9ec
2 changed files with 98 additions and 16 deletions
-16
View File
@@ -190,22 +190,6 @@ pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) ->
let group = page_groups.get(&page).unwrap();
let page_items: Vec<TextItem> = group.iter().map(|(_, item)| (*item).clone()).collect();
// Skip table detection on pages with clear multi-column text layout.
// Column detection in group_into_lines handles these correctly, and
// table detection would incorrectly claim columnar text as table rows.
// Only skip when all detected columns are wide (>120pt),
// indicating true text columns rather than narrow table columns.
let columns = crate::extractor::detect_columns(&page_items, page);
if columns.len() >= 2 {
let min_col_width = columns
.iter()
.map(|c| c.x_max - c.x_min)
.fold(f32::INFINITY, f32::min);
if min_col_width > 120.0 {
continue;
}
}
let tables = detect_tables(&page_items, base_size, false);
for table in tables {
+98
View File
@@ -578,6 +578,13 @@ fn detect_table_in_region(items: &[(usize, &TextItem)], mode: TableDetectionMode
return None;
}
// Validation 9: Reject paragraph-like content falsely detected as tables.
// Real table cells are short and self-contained. Paragraph text split into
// "cells" produces long sentence fragments.
if is_paragraph_content(&cells) {
return None;
}
Some(Table {
columns,
rows,
@@ -824,6 +831,97 @@ fn is_table_of_contents(cells: &[Vec<String>]) -> bool {
dot_ratio > 0.15 || (dot_ratio > 0.05 && page_num_ratio > 0.15)
}
/// Check if detected "table" cells are actually paragraph text fragments.
///
/// Multi-column paragraph text falsely detected as tables produces:
/// - Many empty cells (text doesn't span all columns)
/// - Cells ending with hyphens (word breaks across "columns")
/// - Long sentence fragments or single-word fragments
fn is_paragraph_content(cells: &[Vec<String>]) -> bool {
if cells.is_empty() {
return false;
}
let num_cols = cells[0].len();
let total_cells = cells.len() * num_cols;
if total_cells == 0 {
return false;
}
let filled: Vec<&str> = cells
.iter()
.flat_map(|r| r.iter())
.map(|c| c.trim())
.filter(|c| !c.is_empty())
.collect();
let total_filled = filled.len();
if total_filled < 4 {
return false;
}
let empty_ratio = 1.0 - (total_filled as f32 / total_cells as f32);
// Cells ending with a hyphen suggest word breaks across columns.
// Real table cells almost never end with hyphens (except range indicators).
let hyphen_breaks = filled
.iter()
.filter(|c| {
c.ends_with('-') && c.len() > 1 && {
let mut chars = c.chars().rev();
chars.next(); // skip the '-'
chars.next().is_some_and(|ch| ch.is_alphabetic())
}
})
.count();
let hyphen_ratio = hyphen_breaks as f32 / total_filled as f32;
// Word-break hyphens are a strong paragraph signal
if hyphen_ratio > 0.03 {
return true;
}
// High empty ratio with many rows suggests paragraph text spread across a grid
if empty_ratio > 0.55 && cells.len() > 10 {
return true;
}
// Letter-spaced text (spaces between every character) is never real table data.
// This happens when PDF uses wide character spacing for emphasis/formatting.
// Require at least 9 chars (e.g., "a b c d e") to avoid matching short codes.
let letter_spaced = filled
.iter()
.filter(|c| {
let chars: Vec<char> = c.chars().collect();
chars.len() >= 9
&& chars.windows(4).all(|w| {
(w[0].is_alphabetic() && w[1] == ' ' && w[2].is_alphabetic() && w[3] == ' ')
|| (w[0] == ' '
&& w[1].is_alphabetic()
&& w[2] == ' '
&& w[3].is_alphabetic())
})
})
.count();
if letter_spaced > 0 && letter_spaced as f32 / total_filled as f32 > 0.08 {
return true;
}
// Long sentence fragments
let long_cells = filled.iter().filter(|c| c.len() > 60).count();
let long_ratio = long_cells as f32 / total_filled as f32;
let avg_len = filled.iter().map(|c| c.len()).sum::<usize>() as f32 / total_filled as f32;
if avg_len > 40.0 && long_ratio > 0.2 {
return true;
}
if long_ratio > 0.3 {
return true;
}
false
}
/// Check what fraction of items align to detected columns
fn check_column_alignment(
items: &[(usize, &TextItem)],