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 group = page_groups.get(&page).unwrap();
|
||||||
let page_items: Vec<TextItem> = group.iter().map(|(_, item)| (*item).clone()).collect();
|
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);
|
let tables = detect_tables(&page_items, base_size, false);
|
||||||
|
|
||||||
for table in tables {
|
for table in tables {
|
||||||
|
|||||||
@@ -578,6 +578,13 @@ fn detect_table_in_region(items: &[(usize, &TextItem)], mode: TableDetectionMode
|
|||||||
return None;
|
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 {
|
Some(Table {
|
||||||
columns,
|
columns,
|
||||||
rows,
|
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)
|
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
|
/// Check what fraction of items align to detected columns
|
||||||
fn check_column_alignment(
|
fn check_column_alignment(
|
||||||
items: &[(usize, &TextItem)],
|
items: &[(usize, &TextItem)],
|
||||||
|
|||||||
Reference in New Issue
Block a user