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:
co-authored by
Claude Opus 4.6
parent
8eda530fed
commit
9a98fbc9ec
@@ -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 {
|
||||
|
||||
@@ -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)],
|
||||
|
||||
Reference in New Issue
Block a user