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
+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.