feat(tables): Cluster rects with union-find for multi-table detection
PDFs like privacy notices have 6-7 distinct visual table sections per page, each with its own set of cell rects. Previously all rects were merged into a single giant sparse grid that failed validation. Now spatially connected rects are clustered via union-find before running grid detection independently on each cluster. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e7455a0a3c
commit
0f53de0387
+149
-10
@@ -26,11 +26,98 @@ pub struct Table {
|
|||||||
pub item_indices: Vec<usize>,
|
pub item_indices: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Disjoint-set (union-find) for clustering indices.
|
||||||
|
struct UnionFind {
|
||||||
|
parent: Vec<usize>,
|
||||||
|
rank: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnionFind {
|
||||||
|
fn new(n: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
parent: (0..n).collect(),
|
||||||
|
rank: vec![0; n],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find(&mut self, x: usize) -> usize {
|
||||||
|
if self.parent[x] != x {
|
||||||
|
self.parent[x] = self.find(self.parent[x]);
|
||||||
|
}
|
||||||
|
self.parent[x]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn union(&mut self, a: usize, b: usize) {
|
||||||
|
let ra = self.find(a);
|
||||||
|
let rb = self.find(b);
|
||||||
|
if ra == rb {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.rank[ra] < self.rank[rb] {
|
||||||
|
self.parent[ra] = rb;
|
||||||
|
} else if self.rank[ra] > self.rank[rb] {
|
||||||
|
self.parent[rb] = ra;
|
||||||
|
} else {
|
||||||
|
self.parent[rb] = ra;
|
||||||
|
self.rank[ra] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if two rects overlap after expanding each by `tol` on all sides.
|
||||||
|
fn rects_overlap(a: &(f32, f32, f32, f32), b: &(f32, f32, f32, f32), tol: f32) -> bool {
|
||||||
|
// a and b are (x, y, w, h) where (x,y) is bottom-left corner
|
||||||
|
let (ax, ay, aw, ah) = *a;
|
||||||
|
let (bx, by, bw, bh) = *b;
|
||||||
|
// Expand each rect by tol
|
||||||
|
let a_left = ax - tol;
|
||||||
|
let a_right = ax + aw + tol;
|
||||||
|
let a_bottom = ay - tol;
|
||||||
|
let a_top = ay + ah + tol;
|
||||||
|
let b_left = bx - tol;
|
||||||
|
let b_right = bx + bw + tol;
|
||||||
|
let b_bottom = by - tol;
|
||||||
|
let b_top = by + bh + tol;
|
||||||
|
// AABB overlap: NOT (separated)
|
||||||
|
!(a_right < b_left || b_right < a_left || a_top < b_bottom || b_top < a_bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cluster rects by spatial overlap using union-find.
|
||||||
|
/// Returns groups of rect indices; only groups with ≥ `min_size` rects are returned.
|
||||||
|
fn cluster_rects(
|
||||||
|
rects: &[(f32, f32, f32, f32)],
|
||||||
|
tolerance: f32,
|
||||||
|
min_size: usize,
|
||||||
|
) -> Vec<Vec<usize>> {
|
||||||
|
let n = rects.len();
|
||||||
|
let mut uf = UnionFind::new(n);
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
for j in (i + 1)..n {
|
||||||
|
if rects_overlap(&rects[i], &rects[j], tolerance) {
|
||||||
|
uf.union(i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group indices by root
|
||||||
|
let mut groups: std::collections::HashMap<usize, Vec<usize>> = std::collections::HashMap::new();
|
||||||
|
for i in 0..n {
|
||||||
|
groups.entry(uf.find(i)).or_default().push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
groups
|
||||||
|
.into_values()
|
||||||
|
.filter(|g| g.len() >= min_size)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Detect tables from explicit rectangle (`re`) operators in the PDF.
|
/// Detect tables from explicit rectangle (`re`) operators in the PDF.
|
||||||
///
|
///
|
||||||
/// Many PDFs draw cell borders using `re` (rectangle) operators. Table pages
|
/// Many PDFs draw cell borders using `re` (rectangle) operators. Table pages
|
||||||
/// typically have 100-200+ rects while non-table pages have < 30. This function
|
/// typically have 100-200+ rects while non-table pages have < 30. This function
|
||||||
/// identifies grids of cell-sized rectangles and assigns text items to cells.
|
/// 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> {
|
pub fn detect_tables_from_rects(items: &[TextItem], rects: &[PdfRect], page: u32) -> Vec<Table> {
|
||||||
// Filter rects on this page; normalize negative widths/heights; skip tiny rects.
|
// 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
|
let mut page_rects: Vec<(f32, f32, f32, f32)> = Vec::new(); // (x, y, w, h) normalized
|
||||||
@@ -59,10 +146,34 @@ pub fn detect_tables_from_rects(items: &[TextItem], rects: &[PdfRect], page: u32
|
|||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cluster spatially connected rects into groups
|
||||||
|
let clusters = cluster_rects(&page_rects, 3.0, 6);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tables
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect a single table from a cluster of spatially connected rects.
|
||||||
|
///
|
||||||
|
/// Contains the grid-detection logic: snap edges, fill-ratio check,
|
||||||
|
/// assign items to grid, content density validation.
|
||||||
|
fn detect_table_from_rect_group(
|
||||||
|
items: &[TextItem],
|
||||||
|
group_rects: &[(f32, f32, f32, f32)],
|
||||||
|
page: u32,
|
||||||
|
) -> Option<Table> {
|
||||||
// Extract unique X and Y edges from all rects
|
// Extract unique X and Y edges from all rects
|
||||||
let mut x_edges: Vec<f32> = Vec::new();
|
let mut x_edges: Vec<f32> = Vec::new();
|
||||||
let mut y_edges: Vec<f32> = Vec::new();
|
let mut y_edges: Vec<f32> = Vec::new();
|
||||||
for &(x, y, w, h) in &page_rects {
|
for &(x, y, w, h) in group_rects {
|
||||||
x_edges.push(x);
|
x_edges.push(x);
|
||||||
x_edges.push(x + w);
|
x_edges.push(x + w);
|
||||||
y_edges.push(y);
|
y_edges.push(y);
|
||||||
@@ -74,7 +185,7 @@ pub fn detect_tables_from_rects(items: &[TextItem], rects: &[PdfRect], page: u32
|
|||||||
|
|
||||||
if x_edges.len() < 3 || y_edges.len() < 4 {
|
if x_edges.len() < 3 || y_edges.len() < 4 {
|
||||||
// Need at least 2 columns (3 edges) and 3 rows (4 edges)
|
// Need at least 2 columns (3 edges) and 3 rows (4 edges)
|
||||||
return vec![];
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort column edges left-to-right, row edges top-to-bottom (highest Y first for PDF)
|
// Sort column edges left-to-right, row edges top-to-bottom (highest Y first for PDF)
|
||||||
@@ -87,7 +198,13 @@ pub fn detect_tables_from_rects(items: &[TextItem], rects: &[PdfRect], page: u32
|
|||||||
let num_rows = row_edges.len() - 1;
|
let num_rows = row_edges.len() - 1;
|
||||||
|
|
||||||
if num_cols < 2 || num_rows < 2 {
|
if num_cols < 2 || num_rows < 2 {
|
||||||
return vec![];
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject grids that are too large — real tables rarely exceed 12 columns.
|
||||||
|
// Form-style PDFs with scattered field boxes produce huge sparse grids.
|
||||||
|
if num_cols > 12 {
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify that cell-sized rects actually fill the grid
|
// Verify that cell-sized rects actually fill the grid
|
||||||
@@ -100,7 +217,7 @@ pub fn detect_tables_from_rects(items: &[TextItem], rects: &[PdfRect], page: u32
|
|||||||
let x_left = col_edges[col];
|
let x_left = col_edges[col];
|
||||||
let x_right = col_edges[col + 1];
|
let x_right = col_edges[col + 1];
|
||||||
// Check if any rect approximately covers this cell
|
// Check if any rect approximately covers this cell
|
||||||
let cell_covered = page_rects.iter().any(|&(rx, ry, rw, rh)| {
|
let cell_covered = group_rects.iter().any(|&(rx, ry, rw, rh)| {
|
||||||
let tol = 3.0;
|
let tol = 3.0;
|
||||||
rx <= x_left + tol
|
rx <= x_left + tol
|
||||||
&& (rx + rw) >= x_right - tol
|
&& (rx + rw) >= x_right - tol
|
||||||
@@ -118,7 +235,7 @@ pub fn detect_tables_from_rects(items: &[TextItem], rects: &[PdfRect], page: u32
|
|||||||
|
|
||||||
// Require at least 30% of cells to be backed by rects
|
// Require at least 30% of cells to be backed by rects
|
||||||
if fill_ratio < 0.3 {
|
if fill_ratio < 0.3 {
|
||||||
return vec![];
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build table: assign text items to cells
|
// Build table: assign text items to cells
|
||||||
@@ -134,7 +251,7 @@ pub fn detect_tables_from_rects(items: &[TextItem], rects: &[PdfRect], page: u32
|
|||||||
|
|
||||||
// Skip if no text was assigned
|
// Skip if no text was assigned
|
||||||
if item_indices.is_empty() {
|
if item_indices.is_empty() {
|
||||||
return vec![];
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip tables with only 1 row of content (header-only)
|
// Skip tables with only 1 row of content (header-only)
|
||||||
@@ -143,15 +260,37 @@ pub fn detect_tables_from_rects(items: &[TextItem], rects: &[PdfRect], page: u32
|
|||||||
.filter(|row| row.iter().any(|c| !c.trim().is_empty()))
|
.filter(|row| row.iter().any(|c| !c.trim().is_empty()))
|
||||||
.count();
|
.count();
|
||||||
if non_empty_rows < 2 {
|
if non_empty_rows < 2 {
|
||||||
return vec![];
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
vec![Table {
|
// Content density check: reject tables where most cells are empty.
|
||||||
|
// Real tables have content in most cells; form layouts produce sparse grids.
|
||||||
|
let non_empty_cells = cells
|
||||||
|
.iter()
|
||||||
|
.flat_map(|row| row.iter())
|
||||||
|
.filter(|c| !c.trim().is_empty())
|
||||||
|
.count();
|
||||||
|
let content_ratio = non_empty_cells as f32 / total_cells;
|
||||||
|
if content_ratio < 0.25 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject tables with any completely empty column — indicates a bad grid.
|
||||||
|
for col in 0..num_cols {
|
||||||
|
let col_has_content = cells
|
||||||
|
.iter()
|
||||||
|
.any(|row| row.get(col).is_some_and(|c| !c.trim().is_empty()));
|
||||||
|
if !col_has_content {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Table {
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
cells,
|
cells,
|
||||||
item_indices,
|
item_indices,
|
||||||
}]
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deduplicate nearby edge values within a tolerance, returning sorted unique edges.
|
/// Deduplicate nearby edge values within a tolerance, returning sorted unique edges.
|
||||||
|
|||||||
Reference in New Issue
Block a user