From da8c27f3c4f20d0d7936cd58e8c1f0ef58427793 Mon Sep 17 00:00:00 2001 From: Roman Yurchak Date: Thu, 19 Mar 2026 01:27:49 +0100 Subject: [PATCH] =?UTF-8?q?perf:=20cap=20cluster=5Frects=20component=20siz?= =?UTF-8?q?e=20to=20avoid=20O(n=C2=B2)=20on=20vector-heavy=20pages=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pages with tens of thousands of vector-drawing rects (e.g. architectural plans with 36k+ rects) caused cluster_rects and sub-rect deduplication to spend 10-50s in O(n²) loops. No real table has thousands of cell rects, so once a union-find component exceeds 2000 elements we skip further comparisons, and skip the O(n²) sub-rect dedup entirely for such pages. Benchmarks on 8 slow production PDFs show 10-40x speedups on vector-heavy files (12s→0.3s) with no regression on other file types. All 342 tests pass. --- src/tables/detect_rects.rs | 80 ++++++++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/src/tables/detect_rects.rs b/src/tables/detect_rects.rs index 29c1f63..d308c29 100644 --- a/src/tables/detect_rects.rs +++ b/src/tables/detect_rects.rs @@ -8,10 +8,11 @@ use crate::types::{PdfRect, TextItem}; use super::Table; -/// Disjoint-set (union-find) for clustering indices. +/// Disjoint-set (union-find) with component sizes for clustering indices. struct UnionFind { parent: Vec, rank: Vec, + size: Vec, } impl UnionFind { @@ -19,6 +20,7 @@ impl UnionFind { Self { parent: (0..n).collect(), rank: vec![0; n], + size: vec![1; n], } } @@ -35,15 +37,24 @@ impl UnionFind { if ra == rb { return; } + let new_size = self.size[ra] + self.size[rb]; if self.rank[ra] < self.rank[rb] { self.parent[ra] = rb; + self.size[rb] = new_size; } else if self.rank[ra] > self.rank[rb] { self.parent[rb] = ra; + self.size[ra] = new_size; } else { self.parent[rb] = ra; + self.size[ra] = new_size; self.rank[ra] += 1; } } + + fn component_size(&mut self, x: usize) -> usize { + let root = self.find(x); + self.size[root] + } } /// Check if two rects overlap after expanding each by `tol` on all sides. @@ -64,8 +75,19 @@ pub(crate) fn rects_overlap(a: &(f32, f32, f32, f32), b: &(f32, f32, f32, f32), !(a_right < b_left || b_right < a_left || a_top < b_bottom || b_top < a_bottom) } +/// Maximum component size for rect clustering. No real table has thousands +/// of cell rects — once a component exceeds this, it is a vector drawing or +/// page-spanning clipping path. We skip overlap checks for rects already in +/// an oversized component, keeping the original O(n²) loop but making it +/// effectively O(n) for pathological pages. +const MAX_CLUSTER_RECTS: usize = 2000; + /// Cluster rects by spatial overlap using union-find. /// Returns groups of rect indices; only groups with ≥ `min_size` rects are returned. +/// +/// Skips overlap checks for rects whose component has already exceeded +/// [`MAX_CLUSTER_RECTS`], so pages with tens of thousands of vector-drawing +/// rects complete in milliseconds instead of minutes. pub(crate) fn cluster_rects( rects: &[(f32, f32, f32, f32)], tolerance: f32, @@ -75,9 +97,20 @@ pub(crate) fn cluster_rects( let mut uf = UnionFind::new(n); for i in 0..n { + // If rect i is already in an oversized component, no point comparing + // it against further rects — the component won't be used for table + // detection anyway. + if uf.component_size(i) >= MAX_CLUSTER_RECTS { + continue; + } for j in (i + 1)..n { if rects_overlap(&rects[i], &rects[j], tolerance) { uf.union(i, j); + // Check if the merged component just exceeded the cap — + // if so, no need to test more pairs for rect i. + if uf.component_size(i) >= MAX_CLUSTER_RECTS { + break; + } } } } @@ -253,26 +286,31 @@ pub fn detect_tables_from_rects( // Only remove when the container is a similarly-sized cell (height // ratio < 4×), NOT when the container is a table-wide background // that dwarfs the sub-rect. - let before = page_rects.len(); - let snapshot = page_rects.clone(); - page_rects.retain(|&(ax, ay, aw, ah)| { - let tol = 2.0; - !snapshot.iter().any(|&(bx, by, bw, bh)| { - // b must strictly contain a (b is larger in area) - bw * bh > aw * ah * 1.2 - && bh < ah * 4.0 // container must be similarly sized, not a table background - && bx <= ax + tol - && (bx + bw) >= (ax + aw) - tol - && by <= ay + tol - && (by + bh) >= (ay + ah) - tol - }) - }); - if page_rects.len() < before { - debug!( - "page {}: removed {} contained sub-rects", - page, - before - page_rects.len(), - ); + // + // Skip this O(n²) dedup when there are too many rects — pages with + // thousands of vector-drawing rects won't benefit from cell dedup. + if page_rects.len() < MAX_CLUSTER_RECTS { + let before = page_rects.len(); + let snapshot = page_rects.clone(); + page_rects.retain(|&(ax, ay, aw, ah)| { + let tol = 2.0; + !snapshot.iter().any(|&(bx, by, bw, bh)| { + // b must strictly contain a (b is larger in area) + bw * bh > aw * ah * 1.2 + && bh < ah * 4.0 // container must be similarly sized, not a table background + && bx <= ax + tol + && (bx + bw) >= (ax + aw) - tol + && by <= ay + tol + && (by + bh) >= (ay + ah) - tol + }) + }); + if page_rects.len() < before { + debug!( + "page {}: removed {} contained sub-rects", + page, + before - page_rects.len(), + ); + } } }