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:
Abimael Martell
2026-02-18 18:03:25 -08:00
co-authored by Claude Opus 4.6
parent b6828fff8c
commit 0cf80025f6
4 changed files with 170 additions and 62 deletions
+51 -35
View File
@@ -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
View File
@@ -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(&region_height) {
return None;
}
Some(RectHintRegion { y_top, y_bottom })
}
/// Detect a single table from a cluster of spatially connected rects.
+1 -1
View File
@@ -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.