fix(tables): Prevent graph labels from merging into adjacent tables
When a PDF page has a graph directly above a table (e.g. Figure 2 above Table II in real-estate-pricing.pdf), the heuristic table detector would merge graph axis labels and legend items into the table, producing a corrupted result. Add rect hint regions: when a small cluster of cell-border rects (4-6) fails full grid validation (e.g. only row borders, no column dividers), extract their Y bounding box as a "hint region". The heuristic detector then runs separately on items inside vs outside hint regions, preventing unrelated content from being merged into tables. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b6828fff8c
commit
0cf80025f6
+51
-35
@@ -215,7 +215,7 @@ pub fn to_markdown_from_items_with_rects(
|
||||
let mut rect_claimed: HashSet<usize> = HashSet::new();
|
||||
|
||||
// Try rectangle-based table detection first
|
||||
let rect_tables = detect_tables_from_rects(&page_items, rects, page);
|
||||
let (rect_tables, hint_regions) = detect_tables_from_rects(&page_items, rects, page);
|
||||
for table in &rect_tables {
|
||||
for &idx in &table.item_indices {
|
||||
rect_claimed.insert(idx);
|
||||
@@ -231,43 +231,16 @@ pub fn to_markdown_from_items_with_rects(
|
||||
.push((table_y, table_md));
|
||||
}
|
||||
|
||||
// Run heuristic detection on unclaimed items only
|
||||
if rect_claimed.is_empty() {
|
||||
// No rect tables — run heuristic on all items
|
||||
let tables = detect_tables(&page_items, base_size, false);
|
||||
for table in tables {
|
||||
for &idx in &table.item_indices {
|
||||
if let Some(&(global_idx, _)) = group.get(idx) {
|
||||
table_items.insert(global_idx);
|
||||
}
|
||||
// Helper: run heuristic on a subset of items, remapping indices back to page-space
|
||||
let mut run_heuristic =
|
||||
|subset_items: &[TextItem], index_map: &[usize], min_items: usize| {
|
||||
if subset_items.len() < min_items {
|
||||
return;
|
||||
}
|
||||
let table_y = table.rows.first().copied().unwrap_or(0.0);
|
||||
let table_md = table_to_markdown(&table);
|
||||
page_tables
|
||||
.entry(page)
|
||||
.or_default()
|
||||
.push((table_y, table_md));
|
||||
}
|
||||
} else {
|
||||
// Rect tables found — run heuristic on unclaimed items
|
||||
let unclaimed_items: Vec<TextItem> = page_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| !rect_claimed.contains(idx))
|
||||
.map(|(_, item)| item.clone())
|
||||
.collect();
|
||||
if unclaimed_items.len() >= 6 {
|
||||
let tables = detect_tables(&unclaimed_items, base_size, false);
|
||||
let tables = detect_tables(subset_items, base_size, false);
|
||||
for table in tables {
|
||||
// Remap indices from unclaimed-space back to page-space
|
||||
let unclaimed_map: Vec<usize> = page_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| !rect_claimed.contains(idx))
|
||||
.map(|(idx, _)| idx)
|
||||
.collect();
|
||||
for &idx in &table.item_indices {
|
||||
if let Some(&page_idx) = unclaimed_map.get(idx) {
|
||||
if let Some(&page_idx) = index_map.get(idx) {
|
||||
if let Some(&(global_idx, _)) = group.get(page_idx) {
|
||||
table_items.insert(global_idx);
|
||||
}
|
||||
@@ -280,7 +253,50 @@ pub fn to_markdown_from_items_with_rects(
|
||||
.or_default()
|
||||
.push((table_y, table_md));
|
||||
}
|
||||
};
|
||||
|
||||
// Run heuristic detection on unclaimed items
|
||||
if rect_claimed.is_empty() && hint_regions.is_empty() {
|
||||
// No rect tables or hints — run heuristic on all items
|
||||
let identity_map: Vec<usize> = (0..page_items.len()).collect();
|
||||
run_heuristic(&page_items, &identity_map, 6);
|
||||
} else if rect_claimed.is_empty() && !hint_regions.is_empty() {
|
||||
// No rect tables but hint regions exist — run heuristic separately
|
||||
// on items inside each hint region and on items outside all hints.
|
||||
// This prevents graph labels from being merged into nearby tables.
|
||||
let padding = 15.0; // include header lines slightly above rects
|
||||
for hint in &hint_regions {
|
||||
let (inside_items, inside_map): (Vec<TextItem>, Vec<usize>) = page_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, item)| {
|
||||
item.y >= hint.y_bottom - padding && item.y <= hint.y_top + padding
|
||||
})
|
||||
.map(|(idx, item)| (item.clone(), idx))
|
||||
.unzip();
|
||||
run_heuristic(&inside_items, &inside_map, 6);
|
||||
// Mark hint-region items as claimed so they aren't re-processed
|
||||
for &page_idx in &inside_map {
|
||||
rect_claimed.insert(page_idx);
|
||||
}
|
||||
}
|
||||
// Run heuristic on remaining items outside all hint regions
|
||||
let (outside_items, outside_map): (Vec<TextItem>, Vec<usize>) = page_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| !rect_claimed.contains(idx))
|
||||
.map(|(idx, item)| (item.clone(), idx))
|
||||
.unzip();
|
||||
run_heuristic(&outside_items, &outside_map, 6);
|
||||
} else {
|
||||
// Rect tables found — run heuristic on unclaimed items
|
||||
let (unclaimed_items, unclaimed_map): (Vec<TextItem>, Vec<usize>) = page_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| !rect_claimed.contains(idx))
|
||||
.map(|(idx, item)| (item.clone(), idx))
|
||||
.unzip();
|
||||
run_heuristic(&unclaimed_items, &unclaimed_map, 6);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+104
-16
@@ -97,13 +97,36 @@ pub(crate) fn cluster_rects(
|
||||
result.into_iter().map(|(_, g)| g).collect()
|
||||
}
|
||||
|
||||
/// A bounding box hint from cell-border rects that failed full grid validation.
|
||||
///
|
||||
/// When a rect cluster contains cell-sized borders but they don't form a valid
|
||||
/// grid (e.g. only horizontal row borders with no vertical column dividers),
|
||||
/// the bounding box of those cell-sized rects can still be used to scope
|
||||
/// heuristic table detection, preventing unrelated items (graph labels, etc.)
|
||||
/// from being merged into the table.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RectHintRegion {
|
||||
/// Y coordinate of the top edge (highest value in PDF space)
|
||||
pub y_top: f32,
|
||||
/// Y coordinate of the bottom edge (lowest value in PDF space)
|
||||
pub y_bottom: f32,
|
||||
}
|
||||
|
||||
/// Detect tables from explicit rectangle (`re`) operators in the PDF.
|
||||
///
|
||||
/// Many PDFs draw cell borders using `re` (rectangle) operators. Table pages
|
||||
/// typically have 100-200+ rects while non-table pages have < 30. This function
|
||||
/// clusters spatially connected rectangles into groups, then identifies grids of
|
||||
/// cell-sized rectangles within each cluster and assigns text items to cells.
|
||||
pub fn detect_tables_from_rects(items: &[TextItem], rects: &[PdfRect], page: u32) -> Vec<Table> {
|
||||
///
|
||||
/// Also returns hint regions: bounding boxes of cell-sized rects from clusters
|
||||
/// that failed full grid validation. These can be used to scope heuristic
|
||||
/// detection and prevent unrelated items from being merged into tables.
|
||||
pub fn detect_tables_from_rects(
|
||||
items: &[TextItem],
|
||||
rects: &[PdfRect],
|
||||
page: u32,
|
||||
) -> (Vec<Table>, Vec<RectHintRegion>) {
|
||||
// Filter rects on this page; normalize negative widths/heights; skip tiny rects.
|
||||
let mut page_rects: Vec<(f32, f32, f32, f32)> = Vec::new(); // (x, y, w, h) normalized
|
||||
for r in rects {
|
||||
@@ -133,25 +156,90 @@ pub fn detect_tables_from_rects(items: &[TextItem], rects: &[PdfRect], page: u32
|
||||
rects.iter().filter(|r| r.page == page).count(),
|
||||
);
|
||||
|
||||
// Need a reasonable number of cell rects to form a table
|
||||
if page_rects.len() < 6 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Cluster spatially connected rects into groups
|
||||
let clusters = cluster_rects(&page_rects, 3.0, 6);
|
||||
debug!("page {}: {} clusters with >= 6 rects", page, clusters.len());
|
||||
|
||||
let mut tables = Vec::new();
|
||||
for cluster_indices in &clusters {
|
||||
let group_rects: Vec<(f32, f32, f32, f32)> =
|
||||
cluster_indices.iter().map(|&i| page_rects[i]).collect();
|
||||
if let Some(table) = detect_table_from_rect_group(items, &group_rects, page) {
|
||||
tables.push(table);
|
||||
let mut hint_regions = Vec::new();
|
||||
|
||||
// Full grid detection requires ≥ 6 rects
|
||||
if page_rects.len() >= 6 {
|
||||
let clusters = cluster_rects(&page_rects, 3.0, 6);
|
||||
debug!("page {}: {} clusters with >= 6 rects", page, clusters.len());
|
||||
|
||||
for cluster_indices in &clusters {
|
||||
let group_rects: Vec<(f32, f32, f32, f32)> =
|
||||
cluster_indices.iter().map(|&i| page_rects[i]).collect();
|
||||
if let Some(table) = detect_table_from_rect_group(items, &group_rects, page) {
|
||||
tables.push(table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tables
|
||||
// On rect-sparse pages (≤ 6 rects), a few cell-border rects may define the
|
||||
// table region even though they can't form a full grid (e.g. only horizontal
|
||||
// row borders, no column dividers). Extract a hint region so the heuristic
|
||||
// detector can be scoped to just that area, preventing nearby graph labels
|
||||
// or other content from being merged into the table.
|
||||
if tables.is_empty() && page_rects.len() >= 4 && page_rects.len() <= 6 {
|
||||
let clusters = cluster_rects(&page_rects, 3.0, 4);
|
||||
for cluster_indices in &clusters {
|
||||
let group_rects: Vec<(f32, f32, f32, f32)> =
|
||||
cluster_indices.iter().map(|&i| page_rects[i]).collect();
|
||||
if let Some(hint) = extract_hint_region(&group_rects) {
|
||||
debug!(
|
||||
"page {}: hint region y={:.1}..{:.1}",
|
||||
page, hint.y_bottom, hint.y_top
|
||||
);
|
||||
hint_regions.push(hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(tables, hint_regions)
|
||||
}
|
||||
|
||||
/// Extract a hint region from a rect cluster that failed grid validation.
|
||||
///
|
||||
/// Only produces hints from small clusters (≤ 8 rects) where a few cell-border
|
||||
/// rects define a table's row boundaries. Large clusters (form-style decorative
|
||||
/// rects) are not suitable for hint regions since they typically span the whole page.
|
||||
///
|
||||
/// Filters out oversized "bounding box" rects (height > 4× the median height),
|
||||
/// then computes the Y bounding box of the remaining cell-sized rects.
|
||||
fn extract_hint_region(group_rects: &[(f32, f32, f32, f32)]) -> Option<RectHintRegion> {
|
||||
// Only produce hints from small clusters — large clusters that fail grid
|
||||
// validation are likely form-style decorative rects, not table cell borders.
|
||||
if group_rects.len() < 2 || group_rects.len() > 8 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Compute median height to identify cell-sized rects
|
||||
let mut heights: Vec<f32> = group_rects.iter().map(|&(_, _, _, h)| h).collect();
|
||||
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let median_h = heights[heights.len() / 2];
|
||||
|
||||
// Keep only cell-sized rects (height ≤ 4× median)
|
||||
let cell_rects: Vec<&(f32, f32, f32, f32)> = group_rects
|
||||
.iter()
|
||||
.filter(|(_, _, _, h)| *h <= median_h * 4.0)
|
||||
.collect();
|
||||
|
||||
if cell_rects.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Compute Y bounding box of cell-sized rects
|
||||
let y_bottom = cell_rects.iter().map(|(_, y, _, _)| *y).reduce(f32::min)?;
|
||||
let y_top = cell_rects
|
||||
.iter()
|
||||
.map(|(_, y, _, h)| *y + *h)
|
||||
.reduce(f32::max)?;
|
||||
|
||||
// The region must have meaningful height but not span an unreasonable area
|
||||
let region_height = y_top - y_bottom;
|
||||
if !(10.0..=300.0).contains(®ion_height) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(RectHintRegion { y_top, y_bottom })
|
||||
}
|
||||
|
||||
/// Detect a single table from a cluster of spatially connected rects.
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ mod format;
|
||||
mod grid;
|
||||
|
||||
pub use detect_heuristic::detect_tables;
|
||||
pub use detect_rects::detect_tables_from_rects;
|
||||
pub use detect_rects::{detect_tables_from_rects, RectHintRegion};
|
||||
pub use format::table_to_markdown;
|
||||
|
||||
/// Detection mode controls thresholds for table validation.
|
||||
|
||||
Reference in New Issue
Block a user