Files
pdf-inspector/src/tables/detect_rects.rs
T
Abimael MartellandCursor 1719d24871 fix(tables): bound disjoint-rect clustering so overlap tests stay subquadratic (#381)
* fix(tables): bound disjoint-rect clustering so overlap tests stay subquadratic

MAX_CLUSTER_RECTS only helped when a component actually merged. Pairwise-disjoint drawing rects never hit that cap, so the all-pairs loop stayed O(n²). Sweep by left edge and cap AABB tests at 1e6.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(tables): cap clustering overlap tests per rect, not globally

A page-wide AABB budget could be spent on a dense stack of disjoint drawings and never reach an independent table at a later X. Limit each rect to 256 later candidates so other X-ranges still cluster.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(tables): cluster overlapping rects with a spatial grid

A per-rect cap in X-sort order could skip a same-X neighbor after 256 junk candidates. Hash rects into 64-pt cells and pair only inside each cell so independent regions still cluster and disjoint drawings stay subquadratic.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(tables): cluster oversized rects via a bounded fallback

A span cap of 64 grid cells could omit the far end of a huge rect. Those rects now compare against every other rect (up to 32 oversized). Grid buckets are visited in sorted key order so union-find is deterministic.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(tables): visit every oversized rect under a per-rect overlap budget

Dropping .take(32) on the oversized-span list so later page-wide rules still
union the cells they overlap. AABB tests stay capped per oversized rect.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(tables): query overlapping grid cells for oversized cluster rects

Index-order scans starved later overlaps once a per-rect check cap filled
with disjoint drawings. Oversized spans now probe the cells they cover,
with Y-banded oversized-to-oversized unions so stacked page-wide rules
stay linear.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(tables): range-query cluster grid cells for oversized rects

Scan only occupied rows in the oversized rect's Y range, then X-partition
those keys, so unrelated drawings are not visited. Band oversized-to-oversized
unions on the short axis instead of a per-rect huge-Y fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(tables): union crossing oversized cluster rects across orientation bands

Wide and tall page-spanning rules are indexed on different axes, so a
crossing pair never shared a bucket. Query the tall X-index from each wide
or dual-oversized rect, and insert dual-oversized spans into every coarse
Y cell they cover.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(tables): skip quadratic wide-by-tall clustering when the product is huge

Cross-orientation union is only needed for a handful of page-spanning rules.
When |wide|×|tall| exceeds the per-cell pair cap, skip that pass so mixed
oversized drawings cannot go quadratic. Pair counts in a range query no
longer reset per band.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(tables): count unique oversized candidates when querying X/Y bands

A tall rule occupying several X cells was charged once per cell against the
pair budget, which could skip a later overlapping partner. Deduplicate `j`
per query so the cap applies to distinct rects.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 13:34:03 -07:00

5436 lines
198 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Rectangle-based table detection using union-find clustering.
use std::collections::{BTreeMap, HashMap, HashSet};
use log::debug;
use crate::types::{PdfRect, TextItem};
use super::Table;
const DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS: usize = 8;
const COMPETING_TABLE_MIN_ROWS: usize = 8;
/// Disjoint-set (union-find) with component sizes for clustering indices.
struct UnionFind {
parent: Vec<usize>,
rank: Vec<usize>,
size: Vec<usize>,
}
impl UnionFind {
fn new(n: usize) -> Self {
Self {
parent: (0..n).collect(),
rank: vec![0; n],
size: vec![1; 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;
}
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.
pub(crate) 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)
}
fn grid_coord(value: f32, cell: f32) -> i32 {
(value / cell).floor().clamp(-1_000_000.0, 1_000_000.0) as i32
}
/// Inclusive grid range. `None` if the rect covers more cells than we will
/// materialize — those rects are clustered via a bounded fallback.
fn grid_span(lo: f32, hi: f32, cell: f32) -> Option<std::ops::RangeInclusive<i32>> {
let a = grid_coord(lo.min(hi), cell);
let b = grid_coord(lo.max(hi), cell);
let span = b.saturating_sub(a);
if span > 64 {
return None;
}
Some(a..=b)
}
fn union_bucket_pairs(
uf: &mut UnionFind,
rects: &[(f32, f32, f32, f32)],
bucket: &[usize],
tolerance: f32,
) {
let m = bucket.len();
let mut pairs = 0usize;
'cell: for a in 0..m {
let i = bucket[a];
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
continue;
}
for &j in &bucket[a + 1..] {
if pairs >= MAX_CLUSTER_PAIRS_PER_CELL {
break 'cell;
}
if uf.component_size(j) >= MAX_CLUSTER_RECTS {
continue;
}
pairs += 1;
if rects_overlap(&rects[i], &rects[j], tolerance) {
uf.union(i, j);
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
break;
}
}
}
}
}
fn union_rect_against_bands(
uf: &mut UnionFind,
rects: &[(f32, f32, f32, f32)],
i: usize,
bands: &BTreeMap<i32, Vec<usize>>,
lo: i32,
hi: i32,
tolerance: f32,
) {
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
return;
}
let mut pairs = 0usize;
let mut seen = HashSet::new();
for (_, bucket) in bands.range(lo..=hi) {
for &j in bucket {
if !seen.insert(j) {
continue;
}
if pairs >= MAX_CLUSTER_PAIRS_PER_CELL {
return;
}
if i == j || uf.component_size(j) >= MAX_CLUSTER_RECTS {
continue;
}
pairs += 1;
if rects_overlap(&rects[i], &rects[j], tolerance) {
uf.union(i, j);
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
return;
}
}
}
}
}
/// 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.
const MAX_CLUSTER_RECTS: usize = 2000;
/// Pairwise-disjoint rects never merge, so a component-size cap does not
/// stop an all-pairs loop. Rects are hashed into this many points of grid
/// and compared only against others in the same cell.
const CLUSTER_GRID_CELL: f32 = 64.0;
/// All-pairs AABB tests allowed inside one grid cell. A real table cell is
/// tens of points wide, so a 64-pt cell holds a handful of neighbors — not
/// thousands of stacked drawings.
const MAX_CLUSTER_PAIRS_PER_CELL: usize = 16_384;
/// Cluster rects by spatial overlap using union-find.
/// Returns groups of rect indices; only groups with ≥ `min_size` rects are returned.
///
/// Overlap tests run inside a uniform grid so far-apart rects are never
/// compared, and each cell is pair-capped so a dense stack cannot go
/// quadratic or starve an independent table in another cell.
pub(crate) 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);
let cell = CLUSTER_GRID_CELL.max(tolerance * 4.0);
let mut grid: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
let mut large: Vec<usize> = Vec::new();
for (idx, &(x, y, w, h)) in rects.iter().enumerate() {
match (
grid_span(x - tolerance, x + w + tolerance, cell),
grid_span(y - tolerance, y + h + tolerance, cell),
) {
(Some(xs), Some(ys)) => {
for gx in xs {
for gy in ys.clone() {
grid.entry((gx, gy)).or_default().push(idx);
}
}
}
_ => large.push(idx),
}
}
let mut keys: Vec<_> = grid.keys().copied().collect();
keys.sort_unstable();
let mut keys_by_y: BTreeMap<i32, Vec<i32>> = BTreeMap::new();
for &key in &keys {
union_bucket_pairs(&mut uf, rects, &grid[&key], tolerance);
keys_by_y.entry(key.1).or_default().push(key.0);
}
// Oversized spans skip insert. Range-query occupied cells they cover so
// later X-ranges are not starved and we do not scan unrelated rows.
for &i in &large {
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
continue;
}
let (x, y, w, h) = rects[i];
let x_lo = grid_coord(x - tolerance, cell);
let x_hi = grid_coord(x + w + tolerance, cell);
let y_lo = grid_coord(y - tolerance, cell);
let y_hi = grid_coord(y + h + tolerance, cell);
for (&gy, gxs) in keys_by_y.range(y_lo..=y_hi) {
let start = gxs.partition_point(|&gx| gx < x_lo);
for &gx in &gxs[start..] {
if gx > x_hi {
break;
}
let bucket = &grid[&(gx, gy)];
let mut pairs = 0usize;
for &j in bucket {
if pairs >= MAX_CLUSTER_PAIRS_PER_CELL {
break;
}
if uf.component_size(j) >= MAX_CLUSTER_RECTS {
continue;
}
pairs += 1;
if rects_overlap(&rects[i], &rects[j], tolerance) {
uf.union(i, j);
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
break;
}
}
}
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
break;
}
}
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
break;
}
}
}
// Oversized-vs-oversized: band on the short axis so stacked or side-by-side
// page-spanning rules stay linear. Wide vs tall pairs are matched by
// querying the tall X-index; dual-oversized rects occupy every coarse-Y
// cell they span.
let mut large_x: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
let mut large_y: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
let mut large_coarse_y: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
let mut wide: Vec<usize> = Vec::new();
let mut dual: Vec<usize> = Vec::new();
for &i in &large {
let (x, y, w, h) = rects[i];
let xs = grid_span(x - tolerance, x + w + tolerance, cell);
let ys = grid_span(y - tolerance, y + h + tolerance, cell);
match (xs, ys) {
(Some(xs), _) => {
for gx in xs {
large_x.entry(gx).or_default().push(i);
}
}
(_, Some(ys)) => {
wide.push(i);
for gy in ys {
large_y.entry(gy).or_default().push(i);
}
}
_ => {
dual.push(i);
let coarse = cell * 64.0;
match grid_span(y - tolerance, y + h + tolerance, coarse) {
Some(ys) => {
for gy in ys {
large_coarse_y.entry(gy).or_default().push(i);
}
}
None => {
large_coarse_y.entry(i32::MIN).or_default().push(i);
}
}
}
}
}
for bands in [&large_x, &large_y, &large_coarse_y] {
for bucket in bands.values() {
union_bucket_pairs(&mut uf, rects, bucket, tolerance);
}
}
// Cross-orientation is |wide|×|tall| if every wide rule spans the page.
// Skip that pass when the product cannot be a table (a few rules).
let tall_n = large
.len()
.saturating_sub(wide.len())
.saturating_sub(dual.len());
let cross_n =
(wide.len() + dual.len()).saturating_mul(tall_n) + dual.len().saturating_mul(wide.len());
if cross_n > 0 && cross_n <= MAX_CLUSTER_PAIRS_PER_CELL {
for &i in wide.iter().chain(&dual) {
let (x, _, w, _) = rects[i];
let x_lo = grid_coord(x - tolerance, cell);
let x_hi = grid_coord(x + w + tolerance, cell);
union_rect_against_bands(&mut uf, rects, i, &large_x, x_lo, x_hi, tolerance);
}
for &i in &dual {
let (_, y, _, h) = rects[i];
let y_lo = grid_coord(y - tolerance, cell);
let y_hi = grid_coord(y + h + tolerance, cell);
union_rect_against_bands(&mut uf, rects, i, &large_y, y_lo, y_hi, tolerance);
}
}
// Group indices by root
let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
for i in 0..n {
groups.entry(uf.find(i)).or_default().push(i);
}
// Sort by root index for deterministic output order
let mut result: Vec<(usize, Vec<usize>)> = groups
.into_iter()
.filter(|(_, g)| g.len() >= min_size)
.collect();
result.sort_by_key(|(root, _)| *root);
result.into_iter().map(|(_, g)| g).collect()
}
/// Split a rect cluster at the widest X-gap when detection fails.
/// Returns sub-groups only if a gap >= `min_gap` exists and both sides have >= `min_group_size` rects.
#[allow(clippy::type_complexity)]
fn split_wide_cluster(
rects: &[(f32, f32, f32, f32)],
min_gap: f32,
min_group_size: usize,
) -> Option<(Vec<(f32, f32, f32, f32)>, Vec<(f32, f32, f32, f32)>)> {
if rects.len() < min_group_size * 2 {
return None;
}
// Build sorted list of X-intervals (x_left, x_right) from each rect
let mut intervals: Vec<(f32, f32)> = rects.iter().map(|&(x, _, w, _)| (x, x + w)).collect();
intervals.sort_by(|a, b| a.0.total_cmp(&b.0));
// Merge overlapping intervals to find contiguous X-bands
let mut merged: Vec<(f32, f32)> = Vec::new();
for (start, end) in &intervals {
if let Some(last) = merged.last_mut() {
if *start <= last.1 + 1.0 {
last.1 = last.1.max(*end);
continue;
}
}
merged.push((*start, *end));
}
if merged.len() < 2 {
return None;
}
// Find the widest gap between consecutive merged intervals
let mut best_gap = 0.0_f32;
let mut best_split_x = 0.0_f32;
for i in 1..merged.len() {
let gap = merged[i].0 - merged[i - 1].1;
if gap > best_gap {
best_gap = gap;
best_split_x = (merged[i - 1].1 + merged[i].0) / 2.0;
}
}
if best_gap < min_gap {
return None;
}
let left: Vec<_> = rects
.iter()
.filter(|&&(x, _, w, _)| x + w / 2.0 < best_split_x)
.copied()
.collect();
let right: Vec<_> = rects
.iter()
.filter(|&&(x, _, w, _)| x + w / 2.0 >= best_split_x)
.copied()
.collect();
if left.len() >= min_group_size && right.len() >= min_group_size {
Some((left, right))
} else {
None
}
}
/// 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,
/// X coordinate of the left edge
pub x_left: f32,
/// X coordinate of the right edge
pub x_right: f32,
/// Raw rects from the cluster (x, y, w, h) for rect-guided table building
pub cluster_rects: Vec<(f32, f32, f32, 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.
///
/// 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.
/// Bounding boxes of chart-bar clusters on the page. Text inside these
/// regions (axis labels, data values, legends) belongs to a figure and must
/// not be gridded into a table by any detection strategy.
pub fn detect_chart_regions(
items: &[TextItem],
rects: &[PdfRect],
page: u32,
) -> Vec<(f32, f32, f32, f32)> {
// Match detect_tables_from_rects: image placeholders are not text and
// would defeat the bar-content check.
let items_owned: Vec<TextItem> = items
.iter()
.filter(|i| crate::extractor::is_text_layout_item(i))
.cloned()
.collect();
let items = items_owned.as_slice();
let page_rects: Vec<(f32, f32, f32, f32)> = rects
.iter()
.filter(|r| r.page == page)
.map(|r| {
let (x, w) = if r.width < 0.0 {
(r.x + r.width, -r.width)
} else {
(r.x, r.width)
};
let (y, h) = if r.height < 0.0 {
(r.y + r.height, -r.height)
} else {
(r.y, r.height)
};
(x, y, w, h)
})
// Origin-anchored page backgrounds/clipping paths are never chart
// geometry, and letting one bridge into a bar cluster would inflate
// the region to the whole page.
.filter(|&(x, y, w, h)| w >= 5.0 && h >= 5.0 && !(x < 5.0 && y < 5.0))
.collect();
if page_rects.len() < 6 {
return Vec::new();
}
let mut regions = Vec::new();
for cluster in &cluster_rects(&page_rects, 3.0, 6) {
let group: Vec<(f32, f32, f32, f32)> = cluster.iter().map(|&i| page_rects[i]).collect();
if is_chart_bar_cluster(items, &group, page) {
let bbox = group.iter().fold(
(
f32::INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
f32::NEG_INFINITY,
),
|(x0, y0, x1, y1), &(x, y, w, h)| {
(x0.min(x), y0.min(y), x1.max(x + w), y1.max(y + h))
},
);
regions.push(bbox);
}
}
regions
}
fn detect_direct_rect_table(
items: &[TextItem],
rects: &[(f32, f32, f32, f32)],
page: u32,
) -> Option<Table> {
detect_table_from_rect_group(items, rects, page)
.or_else(|| detect_row_stripe_table(items, rects, page))
.or_else(|| detect_stacked_box_table(items, rects, page))
}
pub fn detect_tables_from_rects(
items: &[TextItem],
rects: &[PdfRect],
page: u32,
) -> (Vec<Table>, Vec<RectHintRegion>) {
// Strip Image placeholders before column/row clustering — an image's bbox
// would otherwise show up as a spurious column edge. See `is_text_layout_item`.
let items_owned: Vec<TextItem> = items
.iter()
.filter(|i| crate::extractor::is_text_layout_item(i))
.cloned()
.collect();
let items = items_owned.as_slice();
// 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 {
if r.page != page {
continue;
}
let (mut x, mut y, mut w, mut h) = (r.x, r.y, r.width, r.height);
if w < 0.0 {
x += w;
w = -w;
}
if h < 0.0 {
y += h;
h = -h;
}
// Skip tiny rects (borders, dots, decorations)
if w < 5.0 || h < 5.0 {
continue;
}
page_rects.push((x, y, w, h));
}
// Remove rects that are much wider than typical cell rects — these are
// page-spanning clipping paths or row-spanning background fills that
// would add spurious X-edges and corrupt the grid. We use the median
// WIDTH (not area) because row-stripe tables have ALL rects at the same
// full width, so their median width equals the full table width and none
// get filtered. Cell-grid tables have narrow cell rects, so full-width
// background fills stand out clearly.
if page_rects.len() >= 6 {
let mut widths: Vec<f32> = page_rects.iter().map(|&(_, _, w, _)| w).collect();
widths.sort_by(|a, b| a.total_cmp(b));
let median_width = widths[widths.len() / 2];
let width_threshold = median_width * 10.0;
let before = page_rects.len();
page_rects.retain(|&(_, _, w, _)| w <= width_threshold);
if page_rects.len() < before {
debug!(
"page {}: removed {} oversized rects (median_w={:.0}, threshold={:.0})",
page,
before - page_rects.len(),
median_width,
width_threshold,
);
}
// Deduplicate sub-rects: when a rect is fully contained within a
// slightly larger rect (same column, interior Y range), the smaller
// one is a cell-internal decoration (e.g. content-area shading
// inside the full cell background). Keeping both creates spurious
// Y-edges that split visual rows into thin sub-rows.
//
// 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. Origin-anchored page-background rects
// also disqualify as containers — they normally exceed the 4× ratio,
// but when the sub-rect is itself a tall table-frame the ratio can
// fall under the gate, and dropping the frame collapses cluster
// adjacency between adjacent column-cell groups.
//
// 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)| {
let container_is_page_bg = bx < 5.0 && by < 5.0;
// 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
&& !container_is_page_bg
&& 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(),
);
}
}
}
debug!(
"page {}: {} rects after size filter (from {} raw)",
page,
page_rects.len(),
rects.iter().filter(|r| r.page == page).count(),
);
let mut tables = Vec::new();
let mut hint_regions = Vec::new();
let mut failed_clusters: Vec<Vec<(f32, f32, f32, f32)>> = Vec::new();
// Full grid detection requires ≥ 6 rects
if page_rects.len() >= 6 {
// Identify origin-anchored page-background rects (clipping paths or
// page fills) that would bridge separate table regions if included in
// clustering. Exclude them from adjacency but add them back to each
// cluster they overlap, so grid detection still has their edges.
let is_page_bg = {
let mut heights: Vec<f32> = page_rects.iter().map(|&(_, _, _, h)| h).collect();
heights.sort_by(|a, b| a.total_cmp(b));
let median_height = heights[heights.len() / 2];
let height_threshold = median_height * 20.0;
let flags: Vec<bool> = page_rects
.iter()
.map(|&(x, y, _, h)| x < 5.0 && y < 5.0 && h > height_threshold)
.collect();
if flags.iter().any(|&b| b) {
debug!(
"page {}: {} origin-anchored page-bg rects excluded from clustering",
page,
flags.iter().filter(|&&b| b).count(),
);
}
flags
};
// Build filtered rect list for clustering (excluding page backgrounds)
let non_bg_indices: Vec<usize> =
(0..page_rects.len()).filter(|&i| !is_page_bg[i]).collect();
let non_bg_rects: Vec<(f32, f32, f32, f32)> =
non_bg_indices.iter().map(|&i| page_rects[i]).collect();
let raw_clusters = cluster_rects(&non_bg_rects, 3.0, 6);
// Map cluster indices back to page_rects indices
let clusters: Vec<Vec<usize>> = raw_clusters
.iter()
.map(|cluster| cluster.iter().map(|&i| non_bg_indices[i]).collect())
.collect();
debug!("page {}: {} clusters with >= 6 rects", page, clusters.len());
let mut merge_excluded_cluster_ids: Vec<usize> = Vec::new();
for (cluster_id, cluster_indices) in clusters.iter().enumerate() {
let group_rects: Vec<(f32, f32, f32, f32)> =
cluster_indices.iter().map(|&i| page_rects[i]).collect();
// Chart bars are neither table cells nor a hint region — gridding
// a chart's axis labels scrambles the page. Skip the cluster
// entirely so it can't reach any detector, the merged fallback,
// or the hint fallback.
if is_chart_bar_cluster(items, &group_rects, page) {
// Repeated page fills can dominate the geometry and make a
// real shaded-cell table look like a chart. Remove those fills,
// re-cluster the remaining geometry, and evaluate valid table
// candidates as a competing hypothesis before the chart
// rejection wins.
let normalized = without_dominant_page_backgrounds(&group_rects);
let normalized_table = (normalized.len() < group_rects.len())
.then(|| {
cluster_rects(&normalized, 3.0, 6)
.iter()
.filter_map(|indices| {
let candidate: Vec<(f32, f32, f32, f32)> =
indices.iter().map(|&i| normalized[i]).collect();
if is_chart_bar_cluster(items, &candidate, page) {
None
} else {
detect_table_from_rect_group(items, &candidate, page)
.or_else(|| {
detect_row_stripe_table_from_cell_rects(
items, &candidate, page,
)
})
// Small chart panels can still form
// plausible grids from their labels.
// Require sustained row evidence; the
// motivating table has 17 rows.
.filter(|table| {
table.rows.len() >= COMPETING_TABLE_MIN_ROWS
})
}
})
.max_by_key(|table| table.rows.len() * table.columns.len())
})
.flatten();
if let Some(table) = normalized_table {
debug!(
"page {}: chart-like cluster normalized from {} to {} rects; accepted {}x{} table hypothesis",
page,
group_rects.len(),
normalized.len(),
table.rows.len(),
table.columns.len()
);
// The accepted hypothesis is based on normalized
// geometry. Keep the original chart-like cluster out of
// the merged fallback: reintroducing its repeated page
// fills can manufacture a wider candidate that replaces
// this valid narrow table below.
merge_excluded_cluster_ids.push(cluster_id);
tables.push(table);
continue;
} else {
debug!(
"page {}: skipping chart-bar cluster ({} rects)",
page,
group_rects.len()
);
merge_excluded_cluster_ids.push(cluster_id);
continue;
}
}
if let Some(table) = detect_direct_rect_table(items, &group_rects, page) {
tables.push(table);
} else if let Some((left, right)) = split_wide_cluster(&group_rects, 15.0, 6) {
// Cluster was too wide — retry each half independently
debug!(
"page {}: splitting cluster of {} rects into {} + {} at x-gap",
page,
group_rects.len(),
left.len(),
right.len()
);
let mut split_found = false;
for sub in [&left, &right] {
if let Some(table) = detect_table_from_rect_group(items, sub, page) {
tables.push(table);
split_found = true;
} else if let Some(table) = detect_row_stripe_table(items, sub, page) {
tables.push(table);
split_found = true;
}
}
if !split_found {
failed_clusters.push(group_rects);
}
} else {
failed_clusters.push(group_rects);
}
}
// Merged-cluster fallback: when per-cluster attempts produce no tables
// or only narrow false-positives (≤3 columns from individual column
// clusters), merge all cluster rects and try row-stripe strategy with
// text-based column detection.
let only_narrow = !tables.is_empty() && tables.iter().all(|t| t.columns.len() <= 3);
if tables.is_empty() || only_narrow {
// Chart clusters stay out of the merge as well.
let table_clusters: Vec<&Vec<usize>> = clusters
.iter()
.enumerate()
.filter(|(id, _)| !merge_excluded_cluster_ids.contains(id))
.map(|(_, c)| c)
.collect();
let total_clustered: usize = table_clusters.iter().map(|c| c.len()).sum();
if table_clusters.len() >= 3 && total_clustered >= 50 {
debug!(
"page {}: trying merged-cluster fallback ({} clusters, {} rects{})",
page,
table_clusters.len(),
total_clustered,
if only_narrow {
", replacing narrow tables"
} else {
""
}
);
let all_cluster_rects: Vec<(f32, f32, f32, f32)> = table_clusters
.iter()
.flat_map(|idxs| idxs.iter().map(|&i| page_rects[i]))
.collect();
if let Some(table) = detect_merged_cluster_table(items, &all_cluster_rects, page) {
if only_narrow {
tables.clear();
}
tables.push(table);
}
}
}
// Cell-rect fallback: when per-cluster attempts all fail, try using
// rect Y-edges for rows + text X-positions for columns on each failed
// cluster. Handles tables with cell-background rects that don't form
// a clean grid (variable column widths, decoration fills).
if tables.is_empty() {
debug!(
"page {}: cell-rect fallback: {} failed clusters",
page,
failed_clusters.len()
);
for fc_rects in &failed_clusters {
if fc_rects.len() >= 6 {
if let Some(table) =
detect_row_stripe_table_from_cell_rects(items, fc_rects, page)
{
tables.push(table);
}
}
}
}
// Row-stripe fallback: when clustering produces no large clusters
// (row stripes don't overlap so each is its own cluster of 1),
// try all page rects directly as a row-stripe table.
// Require ≥15 rects and ≥10 result rows to avoid decorative fill false positives.
if tables.is_empty() && clusters.is_empty() && page_rects.len() >= 15 {
if let Some(table) = detect_row_stripe_table(items, &page_rects, page) {
if table.rows.len() >= 10 {
debug!(
"page {}: row-stripe fallback succeeded ({} rects, {} rows)",
page,
page_rects.len(),
table.rows.len()
);
tables.push(table);
} else {
debug!(
"page {}: row-stripe fallback rejected: only {} rows",
page,
table.rows.len()
);
}
}
}
}
// NOTE: 3-5 box stacks never reach detect_stacked_box_table — the main
// loop requires >=6-rect clusters (and a >=6-rect page). This is a
// deliberate precision gate: routing smaller clusters through the
// detector was tried and regressed four pdf-evals documents (striped
// bullet lists, wrapped regulation text, stats-table columns) while
// improving nothing — with so few boxes the anti-prose guards have too
// little signal to discriminate. See stacked_box_three_rows_below_
// cluster_minimum for the pinned behavior.
if tables.is_empty() {
// When no tables detected but clusters exist, generate XY hint regions
// from cluster bounding boxes to scope heuristic table detection.
// This handles both large decorative-rect clusters (calendars, forms)
// and small cell-border clusters on rect-sparse pages.
let mut has_failed_cluster_hints = false;
if page_rects.len() >= 6 {
let clusters = cluster_rects(&page_rects, 3.0, 6);
// Generate hints from large clusters (≥30 rects, decorative/calendar style)
for cluster_indices in &clusters {
let group_rects: Vec<(f32, f32, f32, f32)> =
cluster_indices.iter().map(|&i| page_rects[i]).collect();
if group_rects.len() < 30 {
continue;
}
let x_left = group_rects.iter().map(|r| r.0).reduce(f32::min).unwrap();
let x_right = group_rects
.iter()
.map(|r| r.0 + r.2)
.reduce(f32::max)
.unwrap();
let y_bottom = group_rects.iter().map(|r| r.1).reduce(f32::min).unwrap();
let y_top = group_rects
.iter()
.map(|r| r.1 + r.3)
.reduce(f32::max)
.unwrap();
let w = x_right - x_left;
let h = y_top - y_bottom;
if (30.0..=400.0).contains(&w) && (10.0..=400.0).contains(&h) {
debug!(
"page {}: hint candidate from {} rects: x={:.1}..{:.1} y={:.1}..{:.1} ({:.0}×{:.0})",
page, group_rects.len(), x_left, x_right, y_bottom, y_top, w, h
);
hint_regions.push(RectHintRegion {
y_top,
y_bottom,
x_left,
x_right,
cluster_rects: group_rects.clone(),
});
}
}
// Generate hints from failed clusters (≥6 rects that had valid bounding
// boxes but insufficient grid structure — e.g. outer border or header
// divider with 2x2 edges). These tell us WHERE a table is even though
// the rects don't define column structure.
for fc_rects in &failed_clusters {
if fc_rects.len() < 6 {
continue;
}
let x_left = fc_rects.iter().map(|r| r.0).reduce(f32::min).unwrap();
let x_right = fc_rects.iter().map(|r| r.0 + r.2).reduce(f32::max).unwrap();
let y_bottom = fc_rects.iter().map(|r| r.1).reduce(f32::min).unwrap();
let y_top = fc_rects.iter().map(|r| r.1 + r.3).reduce(f32::max).unwrap();
let h = y_top - y_bottom;
// Require reasonable height and text items inside the region
let padding = 15.0;
let items_inside = items
.iter()
.filter(|item| {
item.y >= y_bottom - padding
&& item.y <= y_top + padding
&& item.x >= x_left - padding
&& item.x <= x_right + padding
})
.count();
let w = x_right - x_left;
// Require reasonable dimensions: height ≥100pt (≈5+ rows),
// height ≤600pt (not full page).
// Width check: ≤500pt normally, but allow wider for large
// clusters (≥30 rects) that are clearly structured.
let max_w = if fc_rects.len() >= 30 { 800.0 } else { 500.0 };
if (100.0..=600.0).contains(&h) && w <= max_w && items_inside >= 6 {
debug!(
"page {}: failed-cluster hint from {} rects ({} items): x={:.1}..{:.1} y={:.1}..{:.1} ({:.0}×{:.0})",
page, fc_rects.len(), items_inside, x_left, x_right, y_bottom, y_top,
x_right - x_left, h
);
hint_regions.push(RectHintRegion {
y_top,
y_bottom,
x_left,
x_right,
cluster_rects: fc_rects.clone(),
});
has_failed_cluster_hints = true;
}
}
// Deduplicate overlapping hints
hint_regions = merge_overlapping_hints(hint_regions);
// Require multiple hint regions to confirm a multi-zone layout
// (calendars, forms). A single hint is likely a decorative cluster
// that would interfere with full-page heuristic detection.
// Exception: failed-cluster hints represent real table boundaries
// confirmed by rect presence, so a single one is meaningful.
if hint_regions.len() < 2 && !has_failed_cluster_hints {
hint_regions.clear();
}
if !hint_regions.is_empty() {
debug!(
"page {}: {} XY hint regions from failed clusters",
page,
hint_regions.len()
);
}
}
// 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.
if hint_regions.is_empty() && page_rects.len() >= 4 && page_rects.len() <= 6 {
let small_clusters = cluster_rects(&page_rects, 3.0, 4);
for cluster_indices in &small_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} x={:.1}..{:.1}",
page, hint.y_bottom, hint.y_top, hint.x_left, hint.x_right
);
hint_regions.push(hint);
}
}
}
}
(tables, hint_regions)
}
/// Merge nearby hint regions that share a Y band.
///
/// Two hints merge when they have substantial Y overlap (>50%) AND their X ranges
/// overlap or are close (gap < 50pt). This handles calendar-style layouts where a
/// month zone's decorative rects split into 2-3 adjacent clusters with small X gaps.
/// Runs iteratively until no more merges occur.
/// Detect a single-column table drawn as a vertical stack of boxes, each
/// holding one short line of text (framework/step lists on slide-style
/// pages). The normal grid path rejects these — one column means only two
/// x-edges — so the rows would otherwise flow into surrounding prose as a
/// run-on paragraph.
fn detect_stacked_box_table(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
) -> Option<Table> {
// Candidate row boxes: single-text-line height, substantial width.
let cands: Vec<(f32, f32, f32, f32)> = group_rects
.iter()
.copied()
.filter(|&(_, _, w, h)| w >= 100.0 && (8.0..=80.0).contains(&h))
.collect();
// The row boxes form the largest family of same-width, x-aligned rects
// (backgrounds and decor have their own geometry and stay out).
let mut boxes: Vec<(f32, f32, f32, f32)> = Vec::new();
for &anchor in &cands {
let family: Vec<(f32, f32, f32, f32)> = cands
.iter()
.copied()
.filter(|&(x, _, w, h)| {
(x - anchor.0).abs() <= 12.0
&& (w - anchor.2).abs() <= anchor.2 * 0.15
&& (h - anchor.3).abs() <= anchor.3 * 0.3
})
.collect();
if family.len() > boxes.len() {
boxes = family;
}
}
if boxes.len() < 3 {
return None;
}
// Boxes flanked at the same y-level — by other rects or by text outside
// the family's x-range — are one column of a wider structure. Leave
// those to the grid/cell-rect paths instead of collapsing to one column.
let flanked = boxes
.iter()
.filter(|&&(bx, by, bw, bh)| {
let rect_sibling = group_rects.iter().any(|&(ox, oy, ow, oh)| {
let y_overlap = (by + bh).min(oy + oh) - by.max(oy);
oh >= 8.0
&& y_overlap > bh * 0.5
&& (ox + ow <= bx + 2.0 || ox >= bx + bw - 2.0)
&& ow >= 30.0
});
let text_sibling = items.iter().any(|it| {
let cx = it.x + it.width / 2.0;
it.page == page
&& it.y >= by - 2.0
&& it.y <= by + bh + 2.0
&& (cx < bx - 5.0 || cx > bx + bw + 5.0)
&& it.width >= 10.0
});
rect_sibling || text_sibling
})
.count();
if flanked * 3 >= boxes.len() {
debug!(
" stacked-box rejected: {}/{} boxes flanked by rects or text",
flanked,
boxes.len()
);
return None;
}
boxes.sort_by(|a, b| b.1.total_cmp(&a.1)); // top to bottom (descending y)
// Merge duplicates (border + fill pairs draw the same box twice), then
// require a clean vertical stack: no overlaps beyond a small tolerance.
boxes.dedup_by(|a, b| (a.1 - b.1).abs() <= 3.0 && (a.3 - b.3).abs() <= 6.0);
if boxes.len() < 3 {
return None;
}
for w in boxes.windows(2) {
let (upper, lower) = (w[0], w[1]);
let upper_bottom = upper.1;
let lower_top = lower.1 + lower.3;
if lower_top > upper_bottom + 4.0 {
return None; // vertical overlap — not a stack
}
if upper_bottom - lower_top > upper.3.max(lower.3) {
return None; // gap larger than a row — unrelated boxes
}
}
// Assign items to boxes; every box needs text and cells must stay short
// (prose paragraphs inside stacked frames are page decor, not a table).
let mut cells: Vec<Vec<String>> = Vec::with_capacity(boxes.len());
let mut item_indices: Vec<usize> = Vec::new();
let mut multi_run_boxes = 0usize;
for &(bx, by, bw, bh) in &boxes {
let mut in_box: Vec<(usize, &TextItem)> = items
.iter()
.enumerate()
.filter(|(_, it)| {
it.page == page
&& it.y >= by - 2.0
&& it.y <= by + bh + 2.0
&& it.x + it.width / 2.0 >= bx
&& it.x + it.width / 2.0 <= bx + bw
})
.collect();
if in_box.is_empty() {
return None;
}
in_box.sort_by(|a, b| {
b.1.y
.partial_cmp(&a.1.y)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
a.1.x
.partial_cmp(&b.1.x)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
// Count horizontally separated text runs inside the box. A single
// list row flows as one run; two-plus runs across most boxes means
// multi-column content (striped prose or a real grid) that must not
// collapse into a one-column table. Same-baseline only: boxed
// display/diagram rows legitimately scatter segments at mixed
// baselines, and those must stay one row.
let mut runs = 1usize;
for pair in in_box.windows(2) {
let (prev, item) = (pair[0].1, pair[1].1);
if (prev.y - item.y).abs() <= 2.0 && item.x - (prev.x + prev.width) > 15.0 {
runs += 1;
}
}
if runs >= 2 {
multi_run_boxes += 1;
}
let text = in_box
.iter()
.map(|(_, it)| it.text.trim())
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join(" ");
if text.is_empty() || text.chars().count() > 120 {
return None;
}
item_indices.extend(in_box.iter().map(|(i, _)| *i));
cells.push(vec![text]);
}
if multi_run_boxes * 2 >= boxes.len() {
debug!(
" stacked-box rejected: {}/{} boxes hold multiple text runs",
multi_run_boxes,
boxes.len()
);
return None;
}
// Reject prose behind per-line stripe rects: sentence fragments flowing
// across rows read as long, function-word-dense cells, while genuine
// list-table rows are short labels/titles.
const PROSE_WORDS: &[&str] = &[
"a", "an", "the", "of", "to", "is", "was", "are", "were", "be", "been", "in", "on", "at",
"with", "for", "by", "as", "and", "or", "but", "this", "that", "these", "those", "from",
"into", "has", "have", "had", "not", "it", "its", "their", "such", "shall", "which",
];
let total_chars: usize = cells.iter().map(|r| r[0].chars().count()).sum();
let mean_chars = total_chars / cells.len().max(1);
let prose_cells = cells
.iter()
.filter(|r| {
r[0].to_ascii_lowercase()
.split(|c: char| !c.is_ascii_alphabetic() && c != '\'')
.any(|w| PROSE_WORDS.contains(&w))
})
.count();
if mean_chars > 60 && prose_cells * 5 >= cells.len() * 2 {
debug!(
" stacked-box rejected: prose rows (mean {} chars, prose words {}/{})",
mean_chars,
prose_cells,
cells.len()
);
return None;
}
// Sentences wrapping across stripe rects: a row ending with a comma, or
// a row without terminal punctuation followed by a row starting
// lowercase, is mid-sentence flow — not list rows. Genuine label/title
// rows produce none of these, so even a small share is disqualifying.
let continuations = cells
.windows(2)
.filter(|pair| {
let prev = pair[0][0].trim_end();
let next = pair[1][0].trim_start();
let prev_open = !prev.ends_with(['.', ':', ';', '!', '?', ')', '"', '%']);
let next_lower = next.chars().next().is_some_and(|c| c.is_lowercase());
prev.ends_with(',') || (prev_open && next_lower)
})
.count();
if cells.len() >= 2 && (continuations >= 2 || continuations * 4 >= cells.len() - 1) {
debug!(
" stacked-box rejected: {}/{} row pairs continue a sentence",
continuations,
cells.len() - 1
);
return None;
}
// Numbered/lettered list items behind decorative stripes stay lists:
// "1) content..." / "(ii) content..." / "a. content...".
let list_marker = |t: &str| {
let t = t.trim_start().strip_prefix('(').unwrap_or(t.trim_start());
let marker_len = t.chars().take_while(|c| c.is_ascii_alphanumeric()).count();
(1..=3).contains(&marker_len)
&& t.chars()
.nth(marker_len)
.is_some_and(|c| c == ')' || c == '.')
};
let list_rows = cells.iter().filter(|r| list_marker(&r[0])).count();
if list_rows * 2 >= cells.len() {
debug!(
" stacked-box rejected: {}/{} rows are numbered list items",
list_rows,
cells.len()
);
return None;
}
debug!(
"page {}: stacked-box table: {} single-column rows",
page,
cells.len()
);
let columns = vec![boxes[0].0 + boxes[0].2 / 2.0];
let rows: Vec<f32> = boxes.iter().map(|b| b.1 + b.3 / 2.0).collect();
Some(Table::new(columns, rows, cells, item_indices))
}
fn merge_overlapping_hints(mut hints: Vec<RectHintRegion>) -> Vec<RectHintRegion> {
if hints.len() <= 1 {
return hints;
}
loop {
hints.sort_by(|a, b| a.x_left.total_cmp(&b.x_left));
let mut merged: Vec<RectHintRegion> = Vec::new();
let mut any_merged = false;
for hint in &hints {
let mut did_merge = false;
for existing in merged.iter_mut() {
// Check Y overlap (>50% of smaller span)
let y_overlap =
existing.y_top.min(hint.y_top) - existing.y_bottom.max(hint.y_bottom);
let y_min_span =
(existing.y_top - existing.y_bottom).min(hint.y_top - hint.y_bottom);
if y_overlap <= y_min_span * 0.5 {
continue;
}
// Check X: overlapping or adjacent (gap < 50pt)
let x_gap = existing.x_left.max(hint.x_left) - existing.x_right.min(hint.x_right);
if x_gap < 50.0 {
// Don't merge if result would exceed max hint width (400pt)
let merged_left = existing.x_left.min(hint.x_left);
let merged_right = existing.x_right.max(hint.x_right);
if merged_right - merged_left > 400.0 {
continue;
}
existing.x_left = merged_left;
existing.x_right = merged_right;
existing.y_bottom = existing.y_bottom.min(hint.y_bottom);
existing.y_top = existing.y_top.max(hint.y_top);
existing
.cluster_rects
.extend_from_slice(&hint.cluster_rects);
did_merge = true;
any_merged = true;
break;
}
}
if !did_merge {
merged.push(hint.clone());
}
}
hints = merged;
if !any_merged {
break;
}
}
hints
}
/// 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.total_cmp(b));
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 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)?;
let x_left = cell_rects.iter().map(|(x, _, _, _)| *x).reduce(f32::min)?;
let x_right = cell_rects
.iter()
.map(|(x, _, w, _)| *x + *w)
.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,
x_left,
x_right,
cluster_rects: Vec::new(),
})
}
/// 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.
pub(crate) fn detect_table_from_rect_group(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
) -> Option<Table> {
// First, try normal detection with all rects.
let no_skip: Vec<bool> = vec![false; group_rects.len()];
match try_build_grid(items, group_rects, page, &no_skip, false) {
GridResult::Ok(table) => return Some(table),
GridResult::FewNonEmptyRows => {
// propagate_merged_cells likely collapsed text into row 0
// due to a full-page background rect — retry below.
}
GridResult::Failed => return None,
}
// Check if the group contains page-origin background rects (starting
// near (0,0), spanning nearly the full group). If so, retry with those
// rects excluded from X-edge extraction and propagate_merged_cells.
// This handles PDFs where a full-page background fill adds spurious
// margin columns and collapses all rows.
let origin_tol = 5.0;
let group_x_min = group_rects
.iter()
.map(|r| r.0)
.fold(f32::INFINITY, f32::min);
let group_x_max = group_rects
.iter()
.map(|r| r.0 + r.2)
.fold(f32::NEG_INFINITY, f32::max);
let group_y_min = group_rects
.iter()
.map(|r| r.1)
.fold(f32::INFINITY, f32::min);
let group_y_max = group_rects
.iter()
.map(|r| r.1 + r.3)
.fold(f32::NEG_INFINITY, f32::max);
let group_w = group_x_max - group_x_min;
let group_h = group_y_max - group_y_min;
let is_page_bg: Vec<bool> = group_rects
.iter()
.map(|&(x, y, w, h)| {
x < origin_tol && y < origin_tol && w >= group_w * 0.95 && h >= group_h * 0.9
})
.collect();
// Only retry for groups with enough Y-edges to form a large grid.
// Full-page backgrounds are problematic for dense tables (many rows)
// but not for small grids where the retry would accept false positives.
let y_edge_count = {
let mut ys: Vec<f32> = Vec::new();
for &(_, y, _, h) in group_rects {
ys.push(y);
ys.push(y + h);
}
snap_edges(&ys, 6.0).len()
};
if is_page_bg.iter().any(|&b| b) && y_edge_count >= 12 {
debug!(" retrying without page-background rects");
if let GridResult::Ok(table) = try_build_grid(items, group_rects, page, &is_page_bg, true) {
return Some(table);
}
}
None
}
/// Result from `try_build_grid` — distinguishes "few non-empty rows"
/// (fixable by excluding page-background rects) from other failures.
enum GridResult {
Ok(Table),
/// Grid was structurally valid but too few rows had content —
/// likely caused by `propagate_merged_cells` collapsing text.
FewNonEmptyRows,
/// Grid failed for structural reasons (bad dimensions, low fill, etc.)
Failed,
}
/// Core grid-building logic. `skip_rects[i]` marks rects to exclude from
/// X-edge extraction and propagate_merged_cells (but they're still used for
/// fill-ratio checking). When `strict` is true, apply higher thresholds
/// for non-empty rows and content density to avoid false positives.
fn try_build_grid(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
skip_rects: &[bool],
strict: bool,
) -> GridResult {
// Extract unique X and Y edges from all rects.
// Skip X edges from marked rects (page backgrounds add page-boundary
// edges that create empty margin columns).
let mut x_edges: Vec<f32> = Vec::new();
let mut y_edges: Vec<f32> = Vec::new();
for (i, &(x, y, w, h)) in group_rects.iter().enumerate() {
if !skip_rects[i] {
x_edges.push(x);
x_edges.push(x + w);
}
y_edges.push(y);
y_edges.push(y + h);
}
let x_edges = snap_edges(&x_edges, 6.0);
let y_edges = snap_edges(&y_edges, 6.0);
debug!(
" edges: {} x, {} y — grid {}x{}",
x_edges.len(),
y_edges.len(),
y_edges.len().saturating_sub(1),
x_edges.len().saturating_sub(1),
);
if x_edges.len() < 3 || y_edges.len() < 4 {
debug!(
" rejected: {} x-edges, {} y-edges (need >=3, >=4)",
x_edges.len(),
y_edges.len()
);
return GridResult::Failed;
}
// Sort column edges left-to-right, row edges top-to-bottom (highest Y first for PDF)
let mut col_edges = x_edges;
col_edges.sort_by(|a, b| a.total_cmp(b));
let mut row_edges = y_edges;
row_edges.sort_by(|a, b| b.total_cmp(a));
let num_cols = col_edges.len() - 1;
let num_rows = row_edges.len() - 1;
if num_cols < 2 || num_rows < 2 {
return GridResult::Failed;
}
// Reject grids that are too large — form-style PDFs with scattered field
// boxes produce huge sparse grids. Statistical lookup tables (e.g. MWU,
// chi-square) can legitimately have 20+ columns, so allow up to 25.
if num_cols > 25 {
debug!(" rejected: {} columns > 25", num_cols);
return GridResult::Failed;
}
// Verify that cell-sized rects actually fill the grid
// Count how many grid cells have a matching rect
let mut filled_cells = 0u32;
for row in 0..num_rows {
let y_top = row_edges[row];
let y_bot = row_edges[row + 1];
for col in 0..num_cols {
let x_left = col_edges[col];
let x_right = col_edges[col + 1];
// Check if any rect approximately covers this cell
let cell_covered = group_rects.iter().any(|&(rx, ry, rw, rh)| {
let tol = 6.0;
rx <= x_left + tol
&& (rx + rw) >= x_right - tol
&& ry <= y_top + tol
&& (ry + rh) >= y_bot - tol
});
if cell_covered {
filled_cells += 1;
}
}
}
let total_cells = (num_cols * num_rows) as f32;
let fill_ratio = filled_cells as f32 / total_cells;
debug!(
" grid: {}x{} = {} cells, {} filled, ratio={:.2}",
num_rows, num_cols, total_cells as u32, filled_cells, fill_ratio
);
// Require at least 30% of cells to be backed by rects
if fill_ratio < 0.3 {
debug!(" rejected: fill ratio {:.2} < 0.30", fill_ratio);
return GridResult::Failed;
}
// Build table: assign text items to cells
let (mut cells, item_indices) = assign_items_to_grid(items, &col_edges, &row_edges, page);
// Consolidate vertically-merged cells: rects spanning multiple grid rows
// should have their text collected into the first sub-row.
// Skip for wide tables (>10 columns) where spanning rects are typically
// background fills rather than true merged cells (e.g. statistical lookup
// tables with row-grouping shading).
if num_cols <= 10 {
propagate_merged_cells(&mut cells, &col_edges, &row_edges, group_rects, skip_rects);
}
// Compute column centers and row centers for the Table struct
let columns: Vec<f32> = (0..num_cols)
.map(|c| (col_edges[c] + col_edges[c + 1]) / 2.0)
.collect();
let rows: Vec<f32> = (0..num_rows)
.map(|r| (row_edges[r] + row_edges[r + 1]) / 2.0)
.collect();
// Skip if no text was assigned
if item_indices.is_empty() {
debug!(" rejected: no text items assigned to grid");
return GridResult::Failed;
}
// Skip tables with too few rows of content.
// In strict mode (retry without page backgrounds), require at least 50%
// of rows to have content to avoid false positives.
let non_empty_rows = cells
.iter()
.filter(|row| row.iter().any(|c| !c.trim().is_empty()))
.count();
let min_rows = if strict { num_rows / 2 } else { 2 };
if non_empty_rows < min_rows {
debug!(
" rejected: only {} non-empty rows (need {})",
non_empty_rows, min_rows
);
return GridResult::FewNonEmptyRows;
}
// Content density check: reject tables where most cells are empty.
// In strict mode, require 40% instead of 25%.
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;
let min_content = if strict { 0.40 } else { 0.25 };
if content_ratio < min_content {
debug!(
" rejected: content ratio {:.2} < {:.2} ({} non-empty / {} total)",
content_ratio, min_content, non_empty_cells, total_cells as u32
);
return GridResult::Failed;
}
// In strict mode, reject tables where any single cell has very long text —
// this indicates a paragraph was incorrectly captured in the grid.
if strict {
let max_cell_len = cells
.iter()
.flat_map(|row| row.iter())
.map(|c| c.len())
.max()
.unwrap_or(0);
if max_cell_len > 200 {
debug!(
" rejected: max cell length {} > 200 (likely paragraph text)",
max_cell_len
);
return GridResult::Failed;
}
}
// Trim empty outer columns (rect edges beyond text), reject if any
// interior column is empty — that indicates a bad grid.
let first_non_empty = (0..num_cols).find(|&col| {
cells
.iter()
.any(|row| row.get(col).is_some_and(|c| !c.trim().is_empty()))
});
let last_non_empty = (0..num_cols).rev().find(|&col| {
cells
.iter()
.any(|row| row.get(col).is_some_and(|c| !c.trim().is_empty()))
});
let (first_col, last_col) = match (first_non_empty, last_non_empty) {
(Some(f), Some(l)) if l > f => (f, l),
_ => {
debug!(" rejected: no content columns");
return GridResult::Failed;
}
};
// Check interior columns
for col in first_col..=last_col {
let col_has_content = cells
.iter()
.any(|row| row.get(col).is_some_and(|c| !c.trim().is_empty()));
if !col_has_content {
debug!(" rejected: interior column {} is completely empty", col);
return GridResult::Failed;
}
}
// Trim outer empty columns
let (columns, cells) = if first_col > 0 || last_col < num_cols - 1 {
let trimmed_cols: Vec<f32> = columns[first_col..=last_col].to_vec();
let trimmed_cells: Vec<Vec<String>> = cells
.iter()
.map(|row| row[first_col..=last_col].to_vec())
.collect();
debug!(
" trimmed {} empty outer columns ({}..={})",
(num_cols - 1 - last_col + first_col),
first_col,
last_col
);
(trimmed_cols, trimmed_cells)
} else {
(columns, cells)
};
GridResult::Ok(Table::new(columns, rows, cells, item_indices))
}
/// Deduplicate nearby edge values within a tolerance, returning sorted unique edges.
pub(crate) fn snap_edges(values: &[f32], tolerance: f32) -> Vec<f32> {
let mut sorted: Vec<f32> = values.to_vec();
sorted.sort_by(|a, b| a.total_cmp(b));
let mut snapped: Vec<f32> = Vec::new();
for &v in &sorted {
if let Some(last) = snapped.last() {
if (v - *last).abs() <= tolerance {
continue; // Skip — too close to previous edge
}
}
snapped.push(v);
}
snapped
}
/// Assign text items to grid cells defined by column/row edges.
///
/// Returns `(cells, item_indices)` where `cells[row][col]` is the cell text
/// and `item_indices` lists the original item indices that were consumed.
pub(crate) fn assign_items_to_grid(
items: &[TextItem],
col_edges: &[f32],
row_edges: &[f32],
page: u32,
) -> (Vec<Vec<String>>, Vec<usize>) {
let num_cols = col_edges.len() - 1;
let num_rows = row_edges.len() - 1;
// Collect items per cell for proper sorting before joining
let mut cell_items: Vec<Vec<Vec<(usize, &TextItem)>>> =
vec![vec![Vec::new(); num_cols]; num_rows];
let mut indices = Vec::new();
for (idx, item) in items.iter().enumerate() {
if item.page != page {
continue;
}
// Use item center for assignment
let cx = item.x + item.width / 2.0;
let cy = item.y;
// Find column: cx must be between col_edges[c] and col_edges[c+1]
let col = (0..num_cols).find(|&c| cx >= col_edges[c] - 2.0 && cx <= col_edges[c + 1] + 2.0);
// Find row: cy must be between row_edges[r+1] (bottom) and row_edges[r] (top)
let row = (0..num_rows).find(|&r| cy >= row_edges[r + 1] - 2.0 && cy <= row_edges[r] + 2.0);
if let (Some(c), Some(r)) = (col, row) {
cell_items[r][c].push((idx, item));
indices.push(idx);
}
}
// Build cell strings: sort items within each cell by Y descending then X ascending
let mut cells: Vec<Vec<String>> = Vec::with_capacity(num_rows);
for row_items in &mut cell_items {
let mut row_cells = Vec::with_capacity(num_cols);
for col_items in row_items.iter_mut() {
col_items.sort_by(|a, b| {
b.1.y
.partial_cmp(&a.1.y)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
a.1.x
.partial_cmp(&b.1.x)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
let text = col_items
.iter()
.map(|(_, item)| item.text.trim())
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join(" ");
let text = remove_inner_delimiter_spaces(&text);
row_cells.push(text);
}
cells.push(row_cells);
}
(cells, indices)
}
fn remove_inner_delimiter_spaces(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut result = String::with_capacity(text.len());
for (i, &ch) in chars.iter().enumerate() {
if ch == ' ' {
let after_open =
result.ends_with('(') || result.ends_with('[') || result.ends_with('{');
let before_close = chars
.get(i + 1)
.is_some_and(|next| matches!(next, ')' | ']' | '}'));
if after_open || before_close {
continue;
}
}
result.push(ch);
}
result
}
/// Consolidate text in vertically-merged cells.
///
/// When a single rect spans multiple grid rows (e.g. a "Classification" label
/// covering several price sub-rows), text ends up in only one sub-row while the
/// others have an empty cell. This function detects such spans and moves all
/// text into the first sub-row, clearing the rest so that downstream
/// continuation-merge in `clean_table_cells` collapses sub-rows correctly.
fn propagate_merged_cells(
cells: &mut [Vec<String>],
col_edges: &[f32],
row_edges: &[f32],
group_rects: &[(f32, f32, f32, f32)],
skip_rects: &[bool],
) {
let num_cols = col_edges.len() - 1;
let num_rows = row_edges.len() - 1;
let tol = 6.0;
for col in 0..num_cols {
for (rect_idx, rect) in group_rects.iter().enumerate() {
let (rx, ry, rw, rh) = *rect;
// Skip rects flagged as page backgrounds — they span all rows
// and would collapse all text into the first row.
if skip_rects[rect_idx] {
continue;
}
// Rect must cover this column
if rx > col_edges[col] + tol || (rx + rw) < col_edges[col + 1] - tol {
continue;
}
// Find first and last grid rows that the rect spans.
//
// Require a rect to actually overlap the row by more than `tol`
// to count as a span. A "rect bottom ≤ row top + tol AND rect
// top ≥ row bottom tol" check gives false positives at shared
// row boundaries — a rect whose top equals row N's bottom lies
// entirely below the row but still passes the tolerance-slack
// check, cascading body text from unrelated rows into one
// merged cell.
let spans = |r: usize| {
let row_top = row_edges[r];
let row_bot = row_edges[r + 1];
let overlap = (row_top.min(ry + rh) - row_bot.max(ry)).max(0.0);
overlap > tol
};
let first_row = (0..num_rows).find(|&r| spans(r));
let last_row = (0..num_rows).rfind(|&r| spans(r));
let (first, last) = match (first_row, last_row) {
(Some(f), Some(l)) if l > f => (f, l),
_ => continue, // Single row or no match — skip
};
// Collect all text from sub-rows within the merged range
let mut combined = String::new();
for row in cells.iter().take(last + 1).skip(first) {
let text = row[col].trim();
if !text.is_empty() {
if !combined.is_empty() {
combined.push(' ');
}
combined.push_str(text);
}
}
// Place combined text in the first sub-row, clear the rest
cells[first][col] = combined;
for row in cells.iter_mut().take(last + 1).skip(first + 1) {
row[col] = String::new();
}
}
}
}
/// Check if rects form a row-stripe pattern (full-width horizontal bands).
///
/// Row-stripe shading uses rects that all share similar X position and width,
/// spanning the full table width. This produces only ~2 unique X-edges, which
/// makes normal grid detection fail (1-column grid).
fn is_row_stripe_pattern(rects: &[(f32, f32, f32, f32)]) -> bool {
if rects.len() < 3 {
return false;
}
let mut widths: Vec<f32> = rects.iter().map(|&(_, _, w, _)| w).collect();
widths.sort_by(|a, b| a.total_cmp(b));
let median_width = widths[widths.len() / 2];
// Must be page-spanning (>200pt)
if median_width <= 200.0 {
return false;
}
// >75% of rects should have width within 10% of median
let within_tolerance = rects
.iter()
.filter(|&&(_, _, w, _)| (w - median_width).abs() <= median_width * 0.10)
.count();
within_tolerance as f32 / rects.len() as f32 > 0.75
}
/// Detect a table from row-stripe rects by using rect Y-edges for rows
/// and text X-position clustering for columns.
fn detect_row_stripe_table(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
) -> Option<Table> {
if !is_row_stripe_pattern(group_rects) {
return None;
}
debug!(
" trying row-stripe detection ({} rects)",
group_rects.len()
);
// Extract Y-edges from rects
let mut y_edges: Vec<f32> = Vec::new();
for &(_, y, _, h) in group_rects {
y_edges.push(y);
y_edges.push(y + h);
}
let y_edges = snap_edges(&y_edges, 6.0);
if y_edges.len() < 4 {
debug!(" row-stripe rejected: only {} y-edges", y_edges.len());
return None;
}
// Sort row edges top-to-bottom (highest Y first for PDF)
let mut row_edges = y_edges;
row_edges.sort_by(|a, b| b.total_cmp(a));
// Compute the bounding box of the stripe region for filtering items
let y_top = row_edges[0];
let y_bottom = *row_edges.last().unwrap();
let x_left = group_rects
.iter()
.map(|&(x, _, _, _)| x)
.reduce(f32::min)
.unwrap();
let x_right = group_rects
.iter()
.map(|&(x, _, w, _)| x + w)
.reduce(f32::max)
.unwrap();
// Gather page items within the stripe region
let page_items: Vec<(usize, &TextItem)> = items
.iter()
.enumerate()
.filter(|(_, item)| {
item.page == page
&& item.y >= y_bottom - 2.0
&& item.y <= y_top + 2.0
&& item.x >= x_left - 5.0
&& item.x + item.width <= x_right + 5.0
})
.collect();
if page_items.is_empty() {
return None;
}
// Derive column boundaries from text X-position clustering.
// Use a lower threshold than find_column_boundaries (which clamps at 25pt min)
// since we already know this is a table from the rects and narrow columns
// (e.g. row-number + date at 21pt gap) should stay separate.
let columns = cluster_x_positions(&page_items, 15.0);
if columns.len() < 2 {
debug!(
" row-stripe rejected: only {} columns from text clustering",
columns.len()
);
return None;
}
// Convert column centers to column edges (midpoints between adjacent, plus outer edges)
let mut col_edges: Vec<f32> = Vec::with_capacity(columns.len() + 1);
// Left edge: minimum item X minus small padding
let min_x = page_items
.iter()
.map(|(_, i)| i.x)
.reduce(f32::min)
.unwrap();
col_edges.push(min_x - 5.0);
// Midpoints between adjacent column centers
for pair in columns.windows(2) {
col_edges.push((pair[0] + pair[1]) / 2.0);
}
// Right edge: maximum item right edge plus small padding
let max_x_right = page_items
.iter()
.map(|(_, i)| i.x + i.width)
.reduce(f32::max)
.unwrap();
col_edges.push(max_x_right + 5.0);
let num_cols = col_edges.len() - 1;
let num_rows = row_edges.len() - 1;
debug!(
" row-stripe grid: {}x{} ({} col edges, {} row edges)",
num_rows,
num_cols,
col_edges.len(),
row_edges.len()
);
// Assign items to grid
let (cells, item_indices) = assign_items_to_grid(items, &col_edges, &row_edges, page);
if item_indices.is_empty() {
debug!(" row-stripe rejected: no items assigned");
return None;
}
// Validate: >=2 non-empty rows
let non_empty_rows = cells
.iter()
.filter(|row| row.iter().any(|c| !c.trim().is_empty()))
.count();
if non_empty_rows < 2 {
debug!(
" row-stripe rejected: only {} non-empty rows",
non_empty_rows
);
return None;
}
// Content density: >=25%
let total_cells = (num_cols * num_rows) as f32;
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.40 {
debug!(
" row-stripe rejected: content ratio {:.2} < 0.40",
content_ratio
);
return None;
}
// Reject if any cell has excessive text — layout background rects (sidebar,
// header, section bands) produce "cells" that contain paragraphs of body text.
// Real alternating-row-stripe data tables have short cell content.
let max_cell_len = cells
.iter()
.flat_map(|row| row.iter())
.map(|c| c.len())
.max()
.unwrap_or(0);
// Allow longer cells for multi-column tables (descriptions in one column
// are common). Narrow grids with giant cells are usually layout
// backgrounds — but only when the row count is also small. A 4+-row
// key/value table with one descriptive column reads as a real table
// on every other gate, so don't reject it on cell length alone.
let max_allowed = if num_cols >= 3 { 2000 } else { 500 };
if max_cell_len > max_allowed && non_empty_rows < 4 {
debug!(
" row-stripe rejected: max cell length {} > {} (layout background, {} rows)",
max_cell_len, max_allowed, non_empty_rows
);
return None;
}
// Trim empty outer columns, reject if interior columns are empty
let first_col = (0..num_cols).find(|&col| {
cells
.iter()
.any(|row| row.get(col).is_some_and(|c| !c.trim().is_empty()))
});
let last_col = (0..num_cols).rev().find(|&col| {
cells
.iter()
.any(|row| row.get(col).is_some_and(|c| !c.trim().is_empty()))
});
let (first_col, last_col) = match (first_col, last_col) {
(Some(f), Some(l)) if l > f => (f, l),
_ => return None,
};
for col in first_col..=last_col {
let col_has_content = cells
.iter()
.any(|row| row.get(col).is_some_and(|c| !c.trim().is_empty()));
if !col_has_content {
debug!(" row-stripe rejected: interior column {} is empty", col);
return None;
}
}
let (col_edges, cells) = if first_col > 0 || last_col < num_cols - 1 {
let new_edges: Vec<f32> = col_edges[first_col..=last_col + 1].to_vec();
let new_cells: Vec<Vec<String>> = cells
.iter()
.map(|row| row[first_col..=last_col].to_vec())
.collect();
(new_edges, new_cells)
} else {
(col_edges, cells)
};
let num_cols = col_edges.len() - 1;
if row_stripe_is_sparse_prose_outline(&cells) {
debug!(" row-stripe rejected: sparse outline/prose continuation shape");
return None;
}
if has_dominant_prose_cell(&cells) {
debug!(" row-stripe rejected: dominant prose cell (chart/figure region over body text)");
return None;
}
let column_centers: Vec<f32> = (0..num_cols)
.map(|c| (col_edges[c] + col_edges[c + 1]) / 2.0)
.collect();
let row_centers: Vec<f32> = (0..num_rows)
.map(|r| (row_edges[r] + row_edges[r + 1]) / 2.0)
.collect();
debug!(
" row-stripe table accepted: {}x{}, {:.0}% density",
num_rows,
num_cols,
content_ratio * 100.0
);
Some(Table::new(column_centers, row_centers, cells, item_indices))
}
/// Detect a grid that swallowed body text instead of tabular data.
///
/// Charts (bar graphs, axis gridlines) emit fields of drawing rects that can
/// pass the row-stripe shape test; the resulting "table" then captures the
/// page's prose. The signature: one cell holds an entire paragraph — ≥60 words
/// AND at least a third of all words in the table.
///
/// There is deliberately no row-count exemption. A small table whose single
/// long cell dominates its word count is indistinguishable by content from a
/// phantom grid over body text, and across the regression corpora every such
/// grid observed has been swallowed prose, never a real note table. The costs
/// are also asymmetric: rejecting a real table degrades it to readable prose,
/// while accepting a phantom scrambles the page into Y-interleaved cells.
/// Larger legitimate tables are safe because the one-third-of-total threshold
/// scales with table size.
fn has_dominant_prose_cell(cells: &[Vec<String>]) -> bool {
let mut total_words = 0usize;
let mut max_cell_words = 0usize;
for row in cells {
for cell in row {
let words = cell.split_whitespace().count();
total_words += words;
max_cell_words = max_cell_words.max(words);
}
}
max_cell_words >= 60 && max_cell_words * 3 >= total_words
}
fn row_stripe_is_sparse_prose_outline(cells: &[Vec<String>]) -> bool {
let Some(num_cols) = cells.first().map(|row| row.len()) else {
return false;
};
if num_cols != 2 || cells.len() < 4 {
return false;
}
let non_empty_rows = cells
.iter()
.filter(|row| row.iter().any(|cell| !cell.trim().is_empty()))
.count();
if non_empty_rows < 4 {
return false;
}
let mut col_counts = [0usize; 2];
for row in cells {
for (idx, cell) in row.iter().enumerate() {
if !cell.trim().is_empty() {
col_counts[idx] += 1;
}
}
}
let (sparse_col, dense_col) = if col_counts[0] <= col_counts[1] {
(0usize, 1usize)
} else {
(1usize, 0usize)
};
let sparse_count = col_counts[sparse_col];
let dense_count = col_counts[dense_col];
if sparse_count * 2 >= non_empty_rows || dense_count * 3 < non_empty_rows * 2 {
return false;
}
let blank_sparse_dense_rows = cells
.iter()
.filter(|row| row[sparse_col].trim().is_empty() && !row[dense_col].trim().is_empty())
.count();
if blank_sparse_dense_rows * 2 < non_empty_rows {
return false;
}
let long_dense_cells = cells
.iter()
.filter(|row| row[dense_col].split_whitespace().count() >= 6)
.count();
long_dense_cells * 2 >= dense_count
}
/// Remove repeated page-scale fills from a chart-like cluster so the actual
/// cell/bar geometry can be evaluated independently. A small number of
/// coincident origin frames may be meaningful table structure, so repetition
/// only becomes normalization evidence when it dominates the cluster.
fn without_dominant_page_backgrounds(rects: &[(f32, f32, f32, f32)]) -> Vec<(f32, f32, f32, f32)> {
let x_max = rects
.iter()
.map(|&(x, _, width, _)| x + width)
.fold(0.0_f32, f32::max);
let y_max = rects
.iter()
.map(|&(_, y, _, height)| y + height)
.fold(0.0_f32, f32::max);
let is_page_scale = |&(x, y, width, height): &(f32, f32, f32, f32)| {
x < 5.0 && y < 5.0 && width >= x_max * 0.9 && height >= y_max * 0.9
};
if rects.iter().filter(|rect| is_page_scale(rect)).count()
< DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS
{
return rects.to_vec();
}
rects
.iter()
.filter(|rect| !is_page_scale(rect))
.copied()
.collect()
}
/// Repeated rows of touching cell rectangles are stronger table evidence
/// than the bar-length variation used by the chart detector.
///
/// Ruled tables with wrapped labels naturally have variable row heights, and
/// numeric-heavy cells can otherwise resemble horizontal or vertical bars.
/// Require several rows to repeat a shared edge schema before overriding the
/// chart hypothesis so sparse plots and independent bars remain unaffected.
fn is_repeated_cell_grid(group_rects: &[(f32, f32, f32, f32)]) -> bool {
type RowGroup = (f32, f32, Vec<(f32, f32)>);
const ROW_EDGE_TOLERANCE: f32 = 3.0;
const MIN_GRID_ROWS: usize = 4;
const MIN_CELLS_PER_ROW: usize = 3;
if group_rects.len() < MIN_GRID_ROWS * MIN_CELLS_PER_ROW {
return false;
}
let mut row_groups: Vec<RowGroup> = Vec::new();
for &(x, y, width, height) in group_rects {
if width < 5.0 || height < 5.0 {
continue;
}
let top = y + height;
if let Some((_, _, cells)) = row_groups.iter_mut().find(|(bottom, row_top, _)| {
(y - *bottom).abs() <= ROW_EDGE_TOLERANCE
&& (top - *row_top).abs() <= ROW_EDGE_TOLERANCE
}) {
cells.push((x, x + width));
} else {
row_groups.push((y, top, vec![(x, x + width)]));
}
}
let mut row_schemas = Vec::new();
for (_, _, mut cells) in row_groups {
if cells.len() < MIN_CELLS_PER_ROW {
continue;
}
let mut widths: Vec<f32> = cells.iter().map(|&(left, right)| right - left).collect();
widths.sort_by(f32::total_cmp);
let median_width = widths[widths.len() / 2];
cells.retain(|&(left, right)| right - left <= median_width * 2.5);
cells.sort_by(|left, right| {
left.0
.total_cmp(&right.0)
.then_with(|| left.1.total_cmp(&right.1))
});
cells.dedup_by(|left, right| {
(left.0 - right.0).abs() <= ROW_EDGE_TOLERANCE
&& (left.1 - right.1).abs() <= ROW_EDGE_TOLERANCE
});
if cells.len() < MIN_CELLS_PER_ROW
|| cells
.windows(2)
.any(|pair| pair[1].0 > pair[0].1 + ROW_EDGE_TOLERANCE)
{
continue;
}
let edges: Vec<f32> = cells
.iter()
.flat_map(|&(left, right)| [left, right])
.collect();
let schema = snap_edges(&edges, ROW_EDGE_TOLERANCE);
if schema.len() > MIN_CELLS_PER_ROW {
row_schemas.push(schema);
}
}
if row_schemas.len() < MIN_GRID_ROWS {
return false;
}
let reference = row_schemas
.iter()
.max_by_key(|schema| schema.len())
.expect("grid rows are non-empty");
row_schemas
.iter()
.filter(|schema| {
let comparable_edges = reference.len().min(schema.len());
let matched_edges = schema
.iter()
.filter(|edge| {
reference
.iter()
.any(|reference_edge| (*edge - *reference_edge).abs() <= ROW_EDGE_TOLERANCE)
})
.count();
matched_edges > MIN_CELLS_PER_ROW && matched_edges * 4 >= comparable_edges * 3
})
.count()
>= MIN_GRID_ROWS
}
fn repeated_cell_grid_overrides_bar_hypothesis(group_rects: &[(f32, f32, f32, f32)]) -> bool {
is_repeated_cell_grid(group_rects)
&& without_dominant_page_backgrounds(group_rects).len() == group_rects.len()
}
/// Detect horizontal segmented stacks from aligned rows of touching rects.
///
/// Category rows must have visible gutters and data-varying internal segment
/// boundaries, unlike the stable boundaries of a ruled table.
struct SegmentedBarGeometry {
bounds: (f32, f32, f32, f32),
row_bands: Vec<(f32, f32)>,
}
fn segmented_stacked_bar_geometry(
group_rects: &[(f32, f32, f32, f32)],
) -> Option<SegmentedBarGeometry> {
type BarRow = (f32, f32, Vec<(f32, f32)>);
const EDGE_TOLERANCE: f32 = 3.0;
const MIN_ROWS: usize = 4;
const MIN_SEGMENTS: usize = 3;
let mut rows: Vec<BarRow> = Vec::new();
for &(x, y, width, height) in group_rects {
if width < 5.0 || height < 5.0 {
continue;
}
let top = y + height;
if let Some((_, _, segments)) = rows.iter_mut().find(|(bottom, row_top, _)| {
(y - *bottom).abs() <= EDGE_TOLERANCE && (top - *row_top).abs() <= EDGE_TOLERANCE
}) {
segments.push((x, x + width));
} else {
rows.push((y, top, vec![(x, x + width)]));
}
}
rows.retain_mut(|(_, _, segments)| {
segments.sort_by(|left, right| left.0.total_cmp(&right.0));
segments.len() >= MIN_SEGMENTS
&& segments
.windows(2)
.all(|pair| (pair[1].0 - pair[0].1).abs() <= EDGE_TOLERANCE)
});
if rows.len() < MIN_ROWS {
return None;
}
rows.sort_by(|left, right| left.0.total_cmp(&right.0));
// Table rows normally share borders. Horizontal stacked bars instead
// leave a visible gutter between category rows.
if rows.windows(2).any(|pair| {
let shorter_height = (pair[0].1 - pair[0].0).min(pair[1].1 - pair[1].0);
pair[1].0 - pair[0].1 < (shorter_height * 0.25).max(2.0)
}) {
return None;
}
// At least two rows must move an internal segment boundary. Stable
// boundaries across every row are stronger evidence for a ruled table.
let reference_edges: Vec<f32> = rows[0]
.2
.iter()
.take(rows[0].2.len() - 1)
.map(|segment| segment.1)
.collect();
let drifting_rows = rows
.iter()
.skip(1)
.filter(|(_, _, segments)| {
let edges: Vec<f32> = segments
.iter()
.take(segments.len() - 1)
.map(|segment| segment.1)
.collect();
edges.len() == reference_edges.len()
&& edges
.iter()
.zip(&reference_edges)
.any(|(edge, reference)| (edge - reference).abs() > EDGE_TOLERANCE)
})
.count();
if drifting_rows < 2 {
return None;
}
let left = rows
.iter()
.flat_map(|row| &row.2)
.map(|segment| segment.0)
.reduce(f32::min)?;
let right = rows
.iter()
.flat_map(|row| &row.2)
.map(|segment| segment.1)
.reduce(f32::max)?;
let bottom = rows.iter().map(|row| row.0).reduce(f32::min)?;
let top = rows.iter().map(|row| row.1).reduce(f32::max)?;
let row_bands = rows.iter().map(|row| (row.0, row.1)).collect();
Some(SegmentedBarGeometry {
bounds: (left, bottom, right, top),
row_bands,
})
}
/// Category labels beside multiple bar rows are independent chart evidence:
/// numeric table text stays inside its cells, regardless of whether the table
/// has an outer border or extra padding.
fn has_external_segmented_bar_labels(
items: &[TextItem],
page: u32,
geometry: &SegmentedBarGeometry,
) -> bool {
const LABEL_EDGE_TOLERANCE: f32 = 3.0;
const LABEL_CLAIM_PAD: f32 = 20.0;
let (content_left, _, content_right, _) = geometry.bounds;
let labeled_rows = geometry
.row_bands
.iter()
.filter(|&&(row_bottom, row_top)| {
items.iter().any(|item| {
if item.page != page || item.text.trim().is_empty() {
return false;
}
let item_left = item.x.min(item.x + item.width);
let item_right = item.x.max(item.x + item.width);
let item_center_x = (item_left + item_right) / 2.0;
let item_center_y = item.y + item.height / 2.0;
let beside_stack = (item_center_x <= content_left + LABEL_EDGE_TOLERANCE
&& item_center_x >= content_left - LABEL_CLAIM_PAD
&& item_left < content_left)
|| (item_center_x >= content_right - LABEL_EDGE_TOLERANCE
&& item_center_x <= content_right + LABEL_CLAIM_PAD
&& item_right > content_right);
beside_stack
&& item_center_y >= row_bottom - LABEL_EDGE_TOLERANCE
&& item_center_y <= row_top + LABEL_EDGE_TOLERANCE
})
})
.count();
labeled_rows >= 2 && labeled_rows * 2 >= geometry.row_bands.len()
}
/// Recognize filled vertical or horizontal bars whose geometry and labels are
/// data-driven rather than uniform table cells.
fn has_chart_bar_signature(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
) -> bool {
let numeric_or_empty = |(rx, ry, rw, rh): (f32, f32, f32, f32)| {
let inside: Vec<&TextItem> = items
.iter()
.filter(|it| {
let cx = it.x + it.width / 2.0;
it.page == page && cx >= rx && cx <= rx + rw && it.y >= ry && it.y <= ry + rh
})
.collect();
// Any number of numeric data labels is chart-like; a single run of
// word text inside means a table cell.
inside.iter().all(|it| {
let t = it.text.trim();
let data = t
.chars()
.filter(|c| c.is_ascii_digit() || ",.%-".contains(*c))
.count();
t.is_empty() || data * 2 >= t.chars().count()
})
};
// Bars: the dominant equal-width family, arranged in >=2 spaced columns
// (inter-column gap >= half a bar width — table cell rects touch), with
// data-driven height variation (checkbox/cell grids are uniform).
// Mirrored predicate catches horizontal bar charts.
let bar_family = |pos: fn(&(f32, f32, f32, f32)) -> f32,
breadth: fn(&(f32, f32, f32, f32)) -> f32,
length: fn(&(f32, f32, f32, f32)) -> f32,
along: fn(&(f32, f32, f32, f32)) -> f32| {
group_rects.iter().any(|anchor| {
let bw = breadth(anchor);
if bw <= 0.0 {
return false;
}
let family: Vec<&(f32, f32, f32, f32)> = group_rects
.iter()
.filter(|r| {
(breadth(r) - bw).abs() <= (bw * 0.1).max(2.0)
&& length(r) > 0.0
&& length(r) < bw * 20.0
})
.collect();
if family.len() < 4 {
return false;
}
// Distinct positions along the axis (bar columns).
let mut positions: Vec<f32> = Vec::new();
for r in &family {
let p = pos(r);
if !positions.iter().any(|&q| (q - p).abs() <= 2.0) {
positions.push(p);
}
}
if positions.len() < 2 {
return false;
}
positions.sort_by(|a, b| a.total_cmp(b));
let min_gap = positions
.windows(2)
.map(|w| w[1] - w[0] - bw)
.fold(f32::INFINITY, f32::min);
if min_gap < bw * 0.5 {
return false;
}
// Data-driven variation along the bar direction.
let len_min = family
.iter()
.map(|r| length(r))
.fold(f32::INFINITY, f32::min);
let len_max = family
.iter()
.map(|r| length(r))
.fold(f32::NEG_INFINITY, f32::max);
if len_max < len_min * 1.3 {
return false;
}
// Grid rows disguise as bars: a table's cell rects have same-y,
// same-height partners in other columns (uniform row heights).
// Chart segments start where the previous datum ended, so their
// extents rarely pair up across positions.
let matched = family
.iter()
.filter(|r| {
family.iter().any(|s| {
(pos(s) - pos(r)).abs() > 2.0
&& (along(s) - along(r)).abs() <= 3.0
&& (length(s) - length(r)).abs() <= 3.0
})
})
.count();
if matched * 5 >= family.len() * 3 {
return false;
}
family.iter().filter(|r| numeric_or_empty(***r)).count() * 3 >= family.len() * 2
})
};
// vertical bars: position/breadth = x/width, length = height, along = y
bar_family(|r| r.0, |r| r.2, |r| r.3, |r| r.1)
// horizontal bars: position/breadth = y/height, length = width, along = x
|| bar_family(|r| r.1, |r| r.3, |r| r.2, |r| r.0)
}
fn is_chart_bar_cluster(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
) -> bool {
let has_bar_signature = has_chart_bar_signature(items, group_rects, page);
// A segmented horizontal chart can share most of its edges across rows.
// Row-aligned category labels outside the stack distinguish it from a
// numeric table without depending on whether either shape has a frame.
if has_bar_signature {
if let Some(geometry) = segmented_stacked_bar_geometry(group_rects) {
if has_external_segmented_bar_labels(items, page, &geometry) {
return true;
}
}
}
if repeated_cell_grid_overrides_bar_hypothesis(group_rects) {
return false;
}
has_bar_signature
}
fn detect_row_stripe_table_from_cell_rects(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
) -> Option<Table> {
if group_rects.len() < 6 {
return None;
}
// Extract Y-edges from rects
let mut y_edges: Vec<f32> = Vec::new();
for &(_, y, _, h) in group_rects {
y_edges.push(y);
y_edges.push(y + h);
}
let y_edges = snap_edges(&y_edges, 6.0);
// If rect Y-edges are insufficient for row structure, use the rect
// bounding box to scope items and derive rows from text Y-positions.
let row_edges = if y_edges.len() >= 4 {
let mut edges = y_edges;
edges.sort_by(|a, b| b.total_cmp(a));
edges
} else {
// Fall back: gather items in the rect region and cluster by Y
let y_min = y_edges.first().copied().unwrap_or(0.0);
let y_max = y_edges.last().copied().unwrap_or(0.0);
let x_min = group_rects
.iter()
.map(|r| r.0)
.reduce(f32::min)
.unwrap_or(0.0);
let x_max = group_rects
.iter()
.map(|r| r.0 + r.2)
.reduce(f32::max)
.unwrap_or(0.0);
let region_items: Vec<&TextItem> = items
.iter()
.filter(|i| {
i.page == page
&& i.y >= y_min - 5.0
&& i.y <= y_max + 5.0
&& i.x >= x_min - 5.0
&& i.x <= x_max + 5.0
})
.collect();
if region_items.len() < 4 {
return None;
}
// Cluster Y positions using median font height as threshold
let median_h = {
let mut hs: Vec<f32> = region_items.iter().map(|i| i.height).collect();
hs.sort_by(|a, b| a.total_cmp(b));
hs[hs.len() / 2]
};
let mut ys: Vec<f32> = region_items.iter().map(|i| i.y).collect();
ys.sort_by(|a, b| b.total_cmp(a));
let mut edges = Vec::new();
let threshold = median_h * 0.8;
let mut cluster_start = ys[0];
let mut cluster_sum = ys[0];
let mut cluster_count = 1.0f32;
for &y in &ys[1..] {
if (cluster_sum / cluster_count - y).abs() > threshold {
let center = cluster_sum / cluster_count;
edges.push(center + median_h * 0.5);
edges.push(center - median_h * 0.5);
cluster_start = y;
cluster_sum = y;
cluster_count = 1.0;
} else {
cluster_sum += y;
cluster_count += 1.0;
}
}
let center = cluster_sum / cluster_count;
edges.push(center + median_h * 0.5);
edges.push(center - median_h * 0.5);
let _ = cluster_start; // suppress unused warning
edges = snap_edges(&edges, 3.0);
edges.sort_by(|a, b| b.total_cmp(a));
if edges.len() < 4 {
return None;
}
edges
};
// Compute bounding box from non-full-page rects
let median_h = {
let mut heights: Vec<f32> = group_rects.iter().map(|&(_, _, _, h)| h).collect();
heights.sort_by(|a, b| a.total_cmp(b));
heights[heights.len() / 2]
};
let content_rects: Vec<_> = group_rects
.iter()
.filter(|&&(_, _, _, h)| h < median_h * 10.0)
.collect();
if content_rects.is_empty() {
return None;
}
let x_left = content_rects
.iter()
.map(|&&(x, _, _, _)| x)
.reduce(f32::min)?;
let x_right = content_rects
.iter()
.map(|&&(x, _, w, _)| x + w)
.reduce(f32::max)?;
let y_top = row_edges[0];
let y_bottom = *row_edges.last()?;
// Gather items within the rect region
let page_items: Vec<(usize, &TextItem)> = items
.iter()
.enumerate()
.filter(|(_, item)| {
item.page == page
&& item.y >= y_bottom - 2.0
&& item.y <= y_top + 2.0
&& item.x >= x_left - 5.0
&& item.x + item.width <= x_right + 5.0
})
.collect();
if page_items.is_empty() {
return None;
}
// Derive columns from text X-position clustering, but prefer rect
// X-edges when they already provide a tighter scaffold. Some PDFs draw
// only the row-index cells in the body plus a full header row; that is
// not dense enough for `try_build_grid`, but the header rects still define
// the real columns. Text starts inside wide cells can otherwise split the
// table into spurious sub-columns.
let columns = cluster_x_positions(&page_items, 15.0);
let text_col_edges = if columns.len() >= 2 {
let mut edges: Vec<f32> = Vec::with_capacity(columns.len() + 1);
let min_x = page_items.iter().map(|(_, i)| i.x).reduce(f32::min)?;
edges.push(min_x - 5.0);
for pair in columns.windows(2) {
edges.push((pair[0] + pair[1]) / 2.0);
}
let max_x_right = page_items
.iter()
.map(|(_, i)| i.x + i.width)
.reduce(f32::max)?;
edges.push(max_x_right + 5.0);
Some(edges)
} else {
None
};
let rect_col_edges = {
let mut x_vals = Vec::with_capacity(content_rects.len() * 2);
for &&(x, _, w, _) in &content_rects {
x_vals.push(x);
x_vals.push(x + w);
}
let mut edges = snap_edges(&x_vals, 6.0);
edges.sort_by(|a, b| a.total_cmp(b));
if (3..=26).contains(&edges.len()) {
Some(edges)
} else {
None
}
};
// For wired-grid tables whose header text is centered/right-aligned but
// whose data is left-aligned, cluster_x_positions can drop the header-only
// x-cluster in its singleton-filter pass and merge adjacent data clusters
// when the gap is below threshold, losing a column. Rect borders are
// ground truth in that case — but only when each rect column actually
// holds text. Decorative or background rects (prose laid out in a frame,
// cell-fill rects with extra borders) can produce more rect-derived
// columns than the text supports; preferring rects there would split a
// logical column into spurious sub-columns.
let rect_cols_match_text = match (&rect_col_edges, &text_col_edges) {
(Some(rect_edges), _) if rect_edges.len() >= 4 => {
let num_rect_cols = rect_edges.len() - 1;
let mut col_item_counts = vec![0usize; num_rect_cols];
for (_, item) in &page_items {
let cx = item.x + item.width / 2.0;
for c in 0..num_rect_cols {
if cx >= rect_edges[c] - 2.0 && cx <= rect_edges[c + 1] + 2.0 {
col_item_counts[c] += 1;
break;
}
}
}
// Require every rect column to hold multiple text items. A rect
// column with no (or only one) item is decorative or the rect grid
// is detecting a spurious column the data does not need; in those
// cases the old text-cluster preference is the safer fallback.
col_item_counts.iter().all(|&n| n >= 2)
}
_ => false,
};
let (col_edges, columns_from_text) = match (rect_col_edges, text_col_edges) {
(Some(rect_edges), text_edges_opt) if rect_cols_match_text => {
debug!(
" cell-rect using {} rect-derived columns (text clusters: {}; rect cols well-distributed)",
rect_edges.len() - 1,
text_edges_opt
.as_ref()
.map(|e| (e.len() - 1) as i32)
.unwrap_or(-1)
);
(rect_edges, false)
}
(Some(rect_edges), Some(text_edges)) if rect_edges.len() <= text_edges.len() => {
debug!(
" cell-rect using {} rect-derived columns over {} text clusters",
rect_edges.len() - 1,
text_edges.len() - 1
);
(rect_edges, false)
}
(_, Some(text_edges)) => (text_edges, true),
(Some(rect_edges), None) => (rect_edges, false),
(None, None) => {
debug!(
" cell-rect rejected: only {} columns from text clustering",
columns.len()
);
return None;
}
};
if col_edges.len() < 3 {
return None;
}
let num_cols = col_edges.len() - 1;
let num_rows = row_edges.len() - 1;
debug!(
" cell-rect table: {}x{} from {} rects, {} items",
num_rows,
num_cols,
group_rects.len(),
page_items.len()
);
let (mut cells, item_indices) = assign_items_to_grid(items, &col_edges, &row_edges, page);
if item_indices.is_empty() {
return None;
}
let mut row_edges = row_edges;
let (collapsed_cells, collapsed_row_edges, collapsed_rows) =
collapse_multiline_description_rows(cells, row_edges, &col_edges);
let has_wrapped_description_rows = collapsed_rows > 0;
cells = collapsed_cells;
row_edges = collapsed_row_edges;
if collapsed_rows > 0 {
debug!(
" cell-rect collapsed {} wrapped description rows",
collapsed_rows
);
}
// Validate: >=2 non-empty rows, >=25% density
let non_empty_rows = cells
.iter()
.filter(|row| row.iter().any(|c| !c.trim().is_empty()))
.count();
if non_empty_rows < 2 {
debug!(
" cell-rect rejected: only {} non-empty rows",
non_empty_rows
);
return None;
}
let num_rows = cells.len();
let total_cells = (num_cols * num_rows) as f32;
let non_empty_cells = cells
.iter()
.flat_map(|row| row.iter())
.filter(|c| !c.trim().is_empty())
.count();
let density = if total_cells > 0.0 {
non_empty_cells as f32 / total_cells
} else {
0.0
};
if density < 0.25 {
debug!(
" cell-rect rejected: density {:.0}% < 25%",
density * 100.0
);
return None;
}
// Reject tables with paragraph-length cells — typically layout
// backgrounds (sidebars, banners) where a single big rectangle
// contains a wall of prose. Spare multi-row key/value tables where
// the value column is a multi-bullet description: those pass every
// other gate and shouldn't get killed on cell length alone.
let max_cell_len = cells
.iter()
.flat_map(|row| row.iter())
.map(|c| c.len())
.max()
.unwrap_or(0);
if max_cell_len > 500 && non_empty_rows < 4 {
debug!(
" cell-rect rejected: max cell length {} > 500 ({} rows, layout background)",
max_cell_len, non_empty_rows
);
return None;
}
// Reject wildly disproportionate grids (e.g. 68x6 from decorative rects)
if num_rows > 20 && num_cols < 4 {
debug!(
" cell-rect rejected: disproportionate grid {}x{}",
num_rows, num_cols
);
return None;
}
// Reject "tables" that are actually prose in a framed region.
// Columns here come from text X-position clustering; when prose wraps
// inside a bounding-box rect (e.g. chat-transcript figures, two-column
// legal-text blocks in forms) the word-boundary gaps cluster into
// spurious columns, and the resulting cells hold sentence fragments
// riddled with common English function words.
//
// Apply at any column count >= 2. The 2-col case is the bite — a
// paragraph wrapped into 2 justified columns produces the same
// surface signal as a real "label / value" table in the
// well-distributed-cols check (both cols populated), so we need a
// content-based signal to tell them apart.
//
// Layered checks combine after the 20%-of-cells prose-word
// trigger fires:
// (a) Long-cell content: prose-in-a-frame averages ~70-100 chars
// per non-empty cell (sentence fragments); real data tables
// are typically <30 chars, occasionally up to ~55 for
// descriptive 4-col tables. The 65-char threshold cleanly
// separates them on observed fixtures (accessory_building
// prose=74 chars, upstage data=53, greencomp=20). This
// overrides the well-distributed relaxation — long cells
// are the strongest prose signal even when both cols are
// populated.
// (b) Two-column text-only scaffold: when both columns were inferred
// from text starts rather than rect edges, prose fragments can look
// perfectly balanced. Require rect evidence for this relaxed shape.
// (c) Well-distributed columns: ≥75% of cols hold ≥2 non-empty
// cells. Catches the prose-paragraph-as-many-cols shape
// while admitting real "label / value / description /
// benefit"-style tables.
if num_cols >= 2 {
const PROSE_WORDS: &[&str] = &[
"a", "an", "the", "of", "to", "is", "was", "are", "were", "be", "been", "in", "on",
"at", "with", "for", "by", "as", "and", "or", "but", "this", "that", "these", "those",
"from", "into", "has", "have", "had", "not", "don't", "doesn't", "it's", "its", "it",
"i", "me", "my", "we", "our", "us", "you", "your", "they", "them", "their", "he",
"she", "his", "her",
];
let mut prose_cells = 0usize;
let mut counted = 0usize;
let mut total_chars = 0usize;
for row in &cells {
for cell in row {
let t = cell.trim();
if t.is_empty() {
continue;
}
counted += 1;
total_chars += t.chars().count();
let lower = t.to_ascii_lowercase();
let has_prose_word = lower
.split(|c: char| !c.is_ascii_alphabetic() && c != '\'')
.any(|w| PROSE_WORDS.contains(&w));
if has_prose_word {
prose_cells += 1;
}
}
}
if counted > 0 && prose_cells * 5 >= counted {
// (a) Long-cell content: overrides the well-distributed
// relaxation. The 2-col prose-in-a-frame case populates
// both cols (passes well-distributed) but every cell
// holds a sentence fragment, so mean cell length is the
// discriminator.
const PROSE_MEAN_CHAR_THRESHOLD: usize = 65;
let mean_chars = total_chars / counted;
if mean_chars > PROSE_MEAN_CHAR_THRESHOLD && !has_wrapped_description_rows {
debug!(
" cell-rect rejected: prose-in-frame, mean non-empty cell {} chars > {} (prose words {}/{})",
mean_chars, PROSE_MEAN_CHAR_THRESHOLD, prose_cells, counted
);
return None;
} else if mean_chars > PROSE_MEAN_CHAR_THRESHOLD {
debug!(
" cell-rect prose check relaxed: wrapped description rows, mean {} chars (prose words {}/{})",
mean_chars, prose_cells, counted
);
}
// (b) Two text-derived columns are not enough vector evidence once
// the content looks prose-like. Real 2-col rect tables still pass
// when the column scaffold comes from drawn cell geometry.
if columns_from_text && num_cols == 2 {
debug!(
" cell-rect rejected: prose-in-frame with text-derived 2-col scaffold (mean {} chars, prose words {}/{})",
mean_chars, prose_cells, counted
);
return None;
}
// (c) Well-distributed columns.
let filled_cols = (0..num_cols)
.filter(|&c| {
cells
.iter()
.filter(|row| {
!row.get(c)
.map(String::as_str)
.unwrap_or("")
.trim()
.is_empty()
})
.count()
>= 2
})
.count();
let well_distributed = filled_cols * 4 >= num_cols * 3;
if !well_distributed {
debug!(
" cell-rect rejected: {}/{} cells contain prose function words — likely prose ({}/{} cols filled, mean {} chars)",
prose_cells, counted, filled_cols, num_cols, mean_chars
);
return None;
}
debug!(
" cell-rect prose check relaxed: {}/{} cols filled, mean {} chars — table-with-description-col",
filled_cols, num_cols, mean_chars
);
}
}
let column_centers: Vec<f32> = (0..num_cols)
.map(|c| (col_edges[c] + col_edges[c + 1]) / 2.0)
.collect();
let row_centers: Vec<f32> = (0..num_rows)
.map(|r| (row_edges[r] + row_edges[r + 1]) / 2.0)
.collect();
debug!(
" cell-rect table accepted: {}x{}, {:.0}% density",
num_rows,
num_cols,
non_empty_cells as f32 / total_cells * 100.0
);
Some(Table::new(column_centers, row_centers, cells, item_indices))
}
/// Merge wrapped description-line bands back into their visual data rows.
///
/// Some Word/PDF exports draw enough rectangle geometry to prove a table exists
/// but expose Y bands per wrapped text line instead of per cell row. In the
/// common mapping-table shape, a narrow row-label column precedes one wide
/// description column, and wrapped continuation bands have content only in that
/// wide column. Merge only that high-confidence shape so framed prose still
/// falls through the existing prose guards.
fn collapse_multiline_description_rows(
cells: Vec<Vec<String>>,
row_edges: Vec<f32>,
col_edges: &[f32],
) -> (Vec<Vec<String>>, Vec<f32>, usize) {
let num_rows = cells.len();
let num_cols = col_edges.len().saturating_sub(1);
if num_rows < 3 || num_cols < 3 || row_edges.len() != num_rows + 1 {
return (cells, row_edges, 0);
}
let table_width = col_edges[num_cols] - col_edges[0];
if table_width <= 0.0 {
return (cells, row_edges, 0);
}
let Some((description_col, description_width)) = (0..num_cols)
.map(|c| (c, col_edges[c + 1] - col_edges[c]))
.max_by(|a, b| a.1.total_cmp(&b.1))
else {
return (cells, row_edges, 0);
};
// Require a preceding row-label column. Without it (e.g. a prose frame
// split into text-start columns), "one populated wide column" is not enough
// evidence to find visual row starts safely.
if description_col == 0 || description_width < table_width * 0.35 {
return (cells, row_edges, 0);
}
let row_has_left_label = |row: &[String]| {
row.iter()
.take(description_col)
.any(|cell| !cell.trim().is_empty())
};
let labeled_rows = cells.iter().filter(|row| row_has_left_label(row)).count();
if labeled_rows < 2 {
return (cells, row_edges, 0);
}
let mut merged_rows = 0usize;
let mut wrapped_description_rows = 0usize;
let mut new_cells: Vec<Vec<String>> = Vec::with_capacity(num_rows);
let mut new_edges = Vec::with_capacity(row_edges.len());
new_edges.push(row_edges[0]);
for (row_idx, row) in cells.into_iter().enumerate() {
let desc_text = row
.get(description_col)
.map(String::as_str)
.unwrap_or("")
.trim();
let left_label = row_has_left_label(&row);
let non_desc_non_empty = row
.iter()
.enumerate()
.filter(|(col, cell)| *col != description_col && !cell.trim().is_empty())
.count();
// Wrapped continuation bands contain only description-column text.
// The preceding label/marker column is empty because the visual row's
// label cell spans the whole wrapped block.
let is_description_continuation = row_idx > 0
&& !desc_text.is_empty()
&& !left_label
&& non_desc_non_empty == 0
&& !new_cells.is_empty();
// Header cells are often split as "Controls" / "Version" in the first
// column while the other header labels sit on the first band.
let only_first_col = row
.iter()
.enumerate()
.all(|(col, cell)| col == 0 || cell.trim().is_empty());
let is_header_continuation = row_idx > 0
&& only_first_col
&& row
.first()
.is_some_and(|cell| !cell.trim().is_empty() && cell.chars().count() <= 24)
&& !new_cells.is_empty()
&& new_cells
.last()
.is_some_and(|prev| prev.iter().filter(|c| !c.trim().is_empty()).count() >= 2);
if is_description_continuation || is_header_continuation {
if let Some(prev) = new_cells.last_mut() {
for (col, cell) in row.iter().enumerate() {
let text = cell.trim();
if text.is_empty() {
continue;
}
if !prev[col].trim().is_empty() {
prev[col].push(' ');
}
prev[col].push_str(text);
}
}
merged_rows += 1;
if is_description_continuation {
wrapped_description_rows += 1;
}
} else {
if !new_cells.is_empty() {
new_edges.push(row_edges[row_idx]);
}
new_cells.push(row);
}
}
new_edges.push(*row_edges.last().unwrap());
if merged_rows == 0 || new_cells.len() < 2 || new_edges.len() != new_cells.len() + 1 {
return (new_cells, row_edges, 0);
}
(new_cells, new_edges, wrapped_description_rows)
}
/// Detect a table by merging all cluster rects into one group.
///
/// This handles clip-path PDFs where each column's cell rects form a separate
/// cluster (no spatial overlap between columns). Uses rect Y-edges for rows
/// and text X-position clustering for columns, similar to `detect_row_stripe_table`
/// but without the width-uniformity check.
fn detect_merged_cluster_table(
items: &[TextItem],
all_rects: &[(f32, f32, f32, f32)],
page: u32,
) -> Option<Table> {
// Extract Y-edges from all rects
let mut y_vals: Vec<f32> = Vec::new();
for &(_, y, _, h) in all_rects {
y_vals.push(y);
y_vals.push(y + h);
}
let y_edges = snap_edges(&y_vals, 6.0);
if y_edges.len() < 4 {
debug!(" merged-cluster rejected: only {} y-edges", y_edges.len());
return None;
}
let mut row_edges = y_edges;
row_edges.sort_by(|a, b| b.total_cmp(a));
// Bounding box of all rects
let y_top = row_edges[0];
let y_bottom = *row_edges.last().unwrap();
let x_left = all_rects
.iter()
.map(|&(x, _, _, _)| x)
.reduce(f32::min)
.unwrap();
let x_right = all_rects
.iter()
.map(|&(x, _, w, _)| x + w)
.reduce(f32::max)
.unwrap();
// Gather page items within the bounding box
let page_items: Vec<(usize, &TextItem)> = items
.iter()
.enumerate()
.filter(|(_, item)| {
item.page == page
&& item.y >= y_bottom - 2.0
&& item.y <= y_top + 2.0
&& item.x >= x_left - 5.0
&& item.x + item.width <= x_right + 5.0
})
.collect();
if page_items.is_empty() {
return None;
}
// Derive columns from text X-position clustering
let columns = cluster_x_positions(&page_items, 15.0);
if columns.len() < 2 {
debug!(
" merged-cluster rejected: only {} columns from text clustering",
columns.len()
);
return None;
}
// Convert column centers to edges
let mut col_edges: Vec<f32> = Vec::with_capacity(columns.len() + 1);
let min_x = page_items
.iter()
.map(|(_, i)| i.x)
.reduce(f32::min)
.unwrap();
col_edges.push(min_x - 5.0);
for pair in columns.windows(2) {
col_edges.push((pair[0] + pair[1]) / 2.0);
}
let max_x_right = page_items
.iter()
.map(|(_, i)| i.x + i.width)
.reduce(f32::max)
.unwrap();
col_edges.push(max_x_right + 5.0);
let num_cols = col_edges.len() - 1;
let num_rows = row_edges.len() - 1;
debug!(
" merged-cluster grid: {}x{} ({} col edges, {} row edges)",
num_rows,
num_cols,
col_edges.len(),
row_edges.len()
);
// Assign items to grid
let (cells, item_indices) = assign_items_to_grid(items, &col_edges, &row_edges, page);
if item_indices.is_empty() {
debug!(" merged-cluster rejected: no items assigned");
return None;
}
// Validate: >=2 non-empty rows
let non_empty_rows = cells
.iter()
.filter(|row| row.iter().any(|c| !c.trim().is_empty()))
.count();
if non_empty_rows < 2 {
debug!(
" merged-cluster rejected: only {} non-empty rows",
non_empty_rows
);
return None;
}
// Content density: >=40%
let total_cells = (num_cols * num_rows) as f32;
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.40 {
debug!(
" merged-cluster rejected: content ratio {:.2} < 0.40",
content_ratio
);
return None;
}
// Reject if any cell has excessive text — layout background rects
// produce "cells" containing paragraphs, not short data-table values.
// Multi-row key/value tables can legitimately have one column of
// long descriptive text, so only reject narrow-row layouts here.
let max_cell_len = cells
.iter()
.flat_map(|row| row.iter())
.map(|c| c.len())
.max()
.unwrap_or(0);
if max_cell_len > 500 && non_empty_rows < 4 {
debug!(
" merged-cluster rejected: max cell length {} > 500 ({} rows, layout background)",
max_cell_len, non_empty_rows
);
return None;
}
if has_dominant_prose_cell(&cells) {
debug!(
" merged-cluster rejected: dominant prose cell (chart/figure region over body text)"
);
return None;
}
// No empty columns
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 {
debug!(" merged-cluster rejected: column {} is empty", col);
return None;
}
}
let column_centers: Vec<f32> = (0..num_cols)
.map(|c| (col_edges[c] + col_edges[c + 1]) / 2.0)
.collect();
let row_centers: Vec<f32> = (0..num_rows)
.map(|r| (row_edges[r] + row_edges[r + 1]) / 2.0)
.collect();
debug!(
" merged-cluster table accepted: {}x{}, {:.0}% density",
num_rows,
num_cols,
content_ratio * 100.0
);
Some(Table::new(column_centers, row_centers, cells, item_indices))
}
/// Cluster text item X positions into column centers with a given minimum threshold.
///
/// Similar to `find_column_boundaries` in grid.rs but with a lower minimum threshold
/// suitable for rect-backed tables where we already know tabular structure exists
/// (no need for anti-paragraph safeguards).
fn cluster_x_positions(items: &[(usize, &TextItem)], min_threshold: f32) -> Vec<f32> {
// Column edges come from where text STARTS. An item whose left edge hugs
// the previous item's right edge on the same line is a continuation run
// (style boundary, script change, underline split) — feeding its x-start
// in here fabricates a phantom column mid-cell.
let mut sorted: Vec<&TextItem> = items.iter().map(|&(_, i)| i).collect();
sorted.sort_by(|a, b| a.y.total_cmp(&b.y).then(a.x.total_cmp(&b.x)));
let mut x_positions: Vec<f32> = Vec::with_capacity(sorted.len());
for (idx, item) in sorted.iter().enumerate() {
let is_continuation = idx > 0 && {
let prev = sorted[idx - 1];
// Style/underline splits leave runs that TOUCH (gap ~0); real
// cell boundaries in even the tightest tables keep a visible
// gap. 2pt separates the two without eating dense-table columns.
// The negative side is bounded too: text overhanging from an
// adjacent cell overlaps by far more than italic kerning ever
// does, and must still start its own column.
let gap = item.x - (prev.x + prev.width);
(prev.y - item.y).abs() <= 2.0 && gap < 2.0 && gap > -4.0 && item.x >= prev.x
};
if !is_continuation {
x_positions.push(item.x);
}
}
x_positions.sort_by(|a, b| a.total_cmp(b));
if x_positions.is_empty() {
return vec![];
}
let x_range = x_positions.last().unwrap() - x_positions.first().unwrap();
let avg_gap = if x_positions.len() > 1 {
x_range / (x_positions.len() - 1) as f32
} else {
60.0
};
let cluster_threshold = avg_gap.clamp(min_threshold, 50.0);
let mut columns = Vec::new();
let mut cluster_items: Vec<f32> = vec![x_positions[0]];
for &x in &x_positions[1..] {
let cluster_center = cluster_items.iter().sum::<f32>() / cluster_items.len() as f32;
if x - cluster_center > cluster_threshold {
columns.push(cluster_center);
cluster_items = vec![x];
} else {
cluster_items.push(x);
}
}
if !cluster_items.is_empty() {
columns.push(cluster_items.iter().sum::<f32>() / cluster_items.len() as f32);
}
// Filter: each column needs multiple items
let min_items_per_col = (items.len() / columns.len().max(1) / 4).max(2);
columns
.into_iter()
.filter(|&col_x| {
items
.iter()
.filter(|(_, i)| (i.x - col_x).abs() < cluster_threshold)
.count()
>= min_items_per_col
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ItemType;
fn make_item(text: &str, x: f32, y: f32, font_size: f32) -> TextItem {
TextItem {
text: text.to_string(),
x,
y,
width: text.len() as f32 * font_size * 0.5,
height: font_size,
font: "TestFont".to_string(),
font_size,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
}
}
// --- is_chart_bar_cluster / detect_chart_regions ---
/// Stacked bar chart: frame + 3 columns of equal-width segments with
/// data-driven heights, holding numeric labels.
fn chart_rects() -> Vec<PdfRect> {
let mut rects = vec![PdfRect {
x: 126.0,
y: 548.0,
width: 396.0,
height: 216.0,
page: 1,
}];
let bars = [
(208.0, 618.0, 59.0),
(208.0, 661.0, 39.0),
(208.0, 696.0, 37.0),
(313.0, 618.0, 67.0),
(313.0, 670.0, 49.0),
(313.0, 691.0, 42.0),
(419.0, 618.0, 73.0),
(419.0, 684.0, 37.0),
(419.0, 708.0, 25.0),
];
for (x, y, h) in bars {
rects.push(PdfRect {
x,
y,
width: 46.0,
height: h,
page: 1,
});
}
rects
}
#[test]
fn chart_bars_produce_region_not_table() {
let items: Vec<TextItem> = [
("38", 228.0, 638.0),
("30", 228.0, 676.0),
("46", 333.0, 643.0),
("17", 333.0, 679.0),
("57", 438.0, 650.0),
("20", 438.0, 694.0),
]
.iter()
.map(|&(t, x, y)| make_item(t, x, y, 9.0))
.collect();
let rects = chart_rects();
let regions = detect_chart_regions(&items, &rects, 1);
assert_eq!(regions.len(), 1, "expected one chart region");
let (tables, hints) = detect_tables_from_rects(&items, &rects, 1);
assert!(tables.is_empty(), "chart bars must not become a table");
assert!(hints.is_empty(), "chart bars must not become a hint region");
}
#[test]
fn dominant_page_backgrounds_are_normalized_only_after_repetition() {
let page_fill = (0.0, 0.0, 600.0, 800.0);
let cell = (100.0, 500.0, 120.0, 20.0);
let mut dominant = vec![page_fill; DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS];
dominant.push(cell);
assert_eq!(without_dominant_page_backgrounds(&dominant), vec![cell]);
let mut incidental = vec![page_fill; DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS - 1];
incidental.push(cell);
assert_eq!(without_dominant_page_backgrounds(&incidental), incidental);
}
#[test]
fn uniform_cell_grid_is_not_a_chart() {
// Touching, uniform-height cell rects (a real table) must not match:
// no inter-column gap and no bar-length variation.
let mut rects = Vec::new();
for row in 0..4 {
for col in 0..3 {
rects.push(PdfRect {
x: 100.0 + col as f32 * 80.0,
y: 600.0 - row as f32 * 20.0,
width: 80.0,
height: 20.0,
page: 1,
});
}
}
let items: Vec<TextItem> = (0..4)
.flat_map(|r| {
(0..3).map(move |c| (100.0 + c as f32 * 80.0 + 10.0, 605.0 - r as f32 * 20.0))
})
.map(|(x, y)| make_item("42", x, y, 9.0))
.collect();
assert!(detect_chart_regions(&items, &rects, 1).is_empty());
}
#[test]
fn variable_height_ruled_grid_overrides_bar_hypothesis() {
let edge_sets = [
[80.0, 140.0, 200.0, 260.0, 320.0, 380.0, 440.0, 500.0, 560.0],
[80.0, 140.0, 210.0, 260.0, 320.0, 380.0, 450.0, 500.0, 560.0],
];
let heights = [20.0, 34.0, 26.0, 42.0, 20.0, 34.0];
let edge_variants = [0, 0, 0, 0, 1, 1];
let mut rects = Vec::new();
let mut y = 650.0;
for (row, height) in heights.into_iter().enumerate() {
let edges = edge_sets[edge_variants[row]];
rects.extend(
edges
.windows(2)
.map(|edge| (edge[0], y, edge[1] - edge[0], height)),
);
y -= height;
}
assert!(is_repeated_cell_grid(&rects));
assert!(has_chart_bar_signature(&[], &rects, 1));
assert!(repeated_cell_grid_overrides_bar_hypothesis(&rects));
assert!(segmented_stacked_bar_geometry(&rects).is_none());
assert!(!is_chart_bar_cluster(&[], &rects, 1));
let mut with_page_fills =
vec![(0.0, 0.0, 600.0, 800.0); DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS];
with_page_fills.extend(rects);
assert!(!repeated_cell_grid_overrides_bar_hypothesis(
&with_page_fills
));
}
#[test]
fn touching_segments_with_spaced_rows_remain_a_chart() {
let row_edges = [
[100.0, 140.0, 180.0, 220.0, 260.0],
[100.0, 140.0, 180.0, 228.0, 260.0],
[100.0, 140.0, 180.0, 214.0, 260.0],
[100.0, 140.0, 180.0, 232.0, 260.0],
];
let mut raw_rects = vec![(90.0, 530.0, 190.0, 100.0)];
for (row, edges) in row_edges.into_iter().enumerate() {
let y = 540.0 + row as f32 * 20.0;
raw_rects.extend(
edges
.windows(2)
.map(|edge| (edge[0], y, edge[1] - edge[0], 12.0)),
);
}
let items: Vec<TextItem> = (0..4)
.map(|row| make_item("Category", 62.0, 541.0 + row as f32 * 20.0, 9.0))
.collect();
assert!(is_repeated_cell_grid(&raw_rects));
assert!(has_chart_bar_signature(&items, &raw_rects, 1));
let geometry = segmented_stacked_bar_geometry(&raw_rects).expect("segmented stack");
assert!(has_external_segmented_bar_labels(&items, 1, &geometry));
assert!(is_chart_bar_cluster(&items, &raw_rects, 1));
let numeric_items: Vec<TextItem> = (0..4)
.map(|row| make_item("2024", 80.0, 541.0 + row as f32 * 18.0, 9.0))
.collect();
assert!(has_external_segmented_bar_labels(
&numeric_items,
1,
&geometry
));
assert!(is_chart_bar_cluster(&numeric_items, &raw_rects, 1));
let edge_adjacent_items: Vec<TextItem> = (0..4)
.map(|row| make_item("2024", 92.0, 541.0 + row as f32 * 18.0, 9.0))
.collect();
assert!(has_external_segmented_bar_labels(
&edge_adjacent_items,
1,
&geometry
));
assert!(is_chart_bar_cluster(&edge_adjacent_items, &raw_rects, 1));
let far_items: Vec<TextItem> = (0..4)
.map(|row| make_item("Category", 20.0, 541.0 + row as f32 * 18.0, 9.0))
.collect();
assert!(!has_external_segmented_bar_labels(&far_items, 1, &geometry));
assert!(!is_chart_bar_cluster(&far_items, &raw_rects, 1));
let rects: Vec<PdfRect> = raw_rects
.into_iter()
.map(|(x, y, width, height)| PdfRect {
x,
y,
width,
height,
page: 1,
})
.collect();
assert_eq!(detect_chart_regions(&items, &rects, 1).len(), 1);
let (tables, hints) = detect_tables_from_rects(&items, &rects, 1);
assert!(tables.is_empty());
assert!(hints.is_empty());
}
#[test]
fn padded_numeric_grid_frame_remains_a_table() {
let row_edges = [
[100.0, 140.0, 180.0, 220.0, 260.0],
[100.0, 140.0, 180.0, 228.0, 260.0],
[100.0, 140.0, 180.0, 214.0, 260.0],
[100.0, 140.0, 180.0, 232.0, 260.0],
];
let mut raw_rects = vec![(96.0, 536.0, 168.0, 80.0)];
let mut items = Vec::new();
for (row, edges) in row_edges.into_iter().enumerate() {
let y = 540.0 + row as f32 * 20.0;
for edge in edges.windows(2) {
raw_rects.push((edge[0], y, edge[1] - edge[0], 12.0));
items.push(make_item("42", edge[0] + 8.0, y + 1.0, 9.0));
}
}
assert!(is_repeated_cell_grid(&raw_rects));
assert!(has_chart_bar_signature(&items, &raw_rects, 1));
let geometry = segmented_stacked_bar_geometry(&raw_rects).expect("segmented rows");
assert!(!has_external_segmented_bar_labels(&items, 1, &geometry));
assert!(!is_chart_bar_cluster(&items, &raw_rects, 1));
let flush_items: Vec<TextItem> = (0..4)
.map(|row| make_item("1", 100.0, 541.0 + row as f32 * 20.0, 9.0))
.collect();
assert!(!has_external_segmented_bar_labels(
&flush_items,
1,
&geometry
));
assert!(!is_chart_bar_cluster(&flush_items, &raw_rects, 1));
let rects: Vec<PdfRect> = raw_rects
.into_iter()
.map(|(x, y, width, height)| PdfRect {
x,
y,
width,
height,
page: 1,
})
.collect();
assert!(detect_chart_regions(&items, &rects, 1).is_empty());
assert!(!detect_tables_from_rects(&items, &rects, 1).0.is_empty());
}
#[test]
fn frameless_segmented_chart_with_category_labels_remains_a_chart() {
let row_edges = [
[100.0, 140.0, 180.0, 220.0, 260.0],
[100.0, 140.0, 180.0, 228.0, 260.0],
[100.0, 140.0, 180.0, 214.0, 260.0],
[100.0, 140.0, 180.0, 232.0, 260.0],
];
let mut raw_rects = Vec::new();
let mut items = Vec::new();
for (row, edges) in row_edges.into_iter().enumerate() {
let y = 540.0 + row as f32 * 18.0;
raw_rects.extend(
edges
.windows(2)
.map(|edge| (edge[0], y, edge[1] - edge[0], 12.0)),
);
items.push(make_item("Category", 62.0, y + 1.0, 9.0));
}
let geometry = segmented_stacked_bar_geometry(&raw_rects).expect("segmented stack");
assert!(has_external_segmented_bar_labels(&items, 1, &geometry));
assert!(is_chart_bar_cluster(&items, &raw_rects, 1));
let rects: Vec<PdfRect> = raw_rects
.into_iter()
.map(|(x, y, width, height)| PdfRect {
x,
y,
width,
height,
page: 1,
})
.collect();
assert_eq!(detect_chart_regions(&items, &rects, 1).len(), 1);
}
// --- detect_stacked_box_table ---
/// N stacked boxes at x=100, w=300, h=22, top-to-bottom from y=600.
fn stacked_boxes(n: usize) -> Vec<(f32, f32, f32, f32)> {
(0..n)
.map(|i| (100.0, 600.0 - i as f32 * 22.0, 300.0, 22.0))
.collect()
}
#[test]
fn stacked_box_list_becomes_single_column_table() {
let rects = stacked_boxes(5);
let items: Vec<TextItem> = (0..5)
.map(|i| make_item("#1: Recycling Basics", 120.0, 605.0 - i as f32 * 22.0, 10.0))
.collect();
let table = detect_stacked_box_table(&items, &rects, 1).expect("stacked-box table");
assert_eq!(table.cells.len(), 5);
assert_eq!(table.cells[0].len(), 1);
}
#[test]
fn stacked_box_rejects_wrapped_sentences() {
// Line stripes behind flowing prose: rows continue mid-sentence.
let rects = stacked_boxes(4);
let texts = [
"the provisions of this section apply to",
"companies subject to tax under those",
"sections, except that the copy of the",
"annual statement must be retained.",
];
let items: Vec<TextItem> = texts
.iter()
.enumerate()
.map(|(i, t)| make_item(t, 120.0, 605.0 - i as f32 * 22.0, 10.0))
.collect();
assert!(detect_stacked_box_table(&items, &rects, 1).is_none());
}
#[test]
fn stacked_box_rejects_flanking_text() {
// A ruled label column with plain-text data columns beside it is one
// column of a wider table, not a single-column list.
let rects = stacked_boxes(4);
let mut items = Vec::new();
for i in 0..4 {
let y = 605.0 - i as f32 * 22.0;
items.push(make_item("Section 1.382", 120.0, y, 10.0));
items.push(make_item("removed text", 450.0, y, 10.0)); // beside the box
}
assert!(detect_stacked_box_table(&items, &rects, 1).is_none());
}
#[test]
fn stacked_box_rejects_two_column_content() {
// Boxes holding two separated runs are striped multi-column content.
let rects = stacked_boxes(4);
let mut items = Vec::new();
for i in 0..4 {
let y = 605.0 - i as f32 * 22.0;
let mut left = make_item("left words", 110.0, y, 10.0);
left.width = 60.0;
let mut right = make_item("right words", 250.0, y, 10.0);
right.width = 60.0;
items.push(left);
items.push(right);
}
assert!(detect_stacked_box_table(&items, &rects, 1).is_none());
}
#[test]
fn stacked_box_rejects_mixed_height_stripes() {
// Mixed 13/27pt stripes (redline markup) — height uniformity splits
// the family and the gap check rejects the remainder.
let mut rects = Vec::new();
let mut y = 600.0;
for i in 0..8 {
let h = if i % 3 == 0 { 27.0 } else { 13.5 };
y -= h;
rects.push((100.0, y, 300.0, h));
}
let items: Vec<TextItem> = (0..8)
.map(|i| make_item("PART 602 OMB CONTROL", 120.0, 590.0 - i as f32 * 18.0, 10.0))
.collect();
assert!(detect_stacked_box_table(&items, &rects, 1).is_none());
}
// --- has_dominant_prose_cell ---
fn cells_of(rows: &[&[&str]]) -> Vec<Vec<String>> {
rows.iter()
.map(|r| r.iter().map(|c| c.to_string()).collect())
.collect()
}
#[test]
fn dominant_prose_cell_rejects_swallowed_paragraph() {
// Two cells hold paragraphs (the shape every observed phantom grid
// has: swallowed body text spans multiple cells), rest are chart labels
let para = ["word"; 70].join(" ");
let para2 = ["word"; 35].join(" ");
let cells = cells_of(&[
&[para.as_str(), "81", "76"],
&[para2.as_str(), "56", "9"],
&["2019", "2020", ""],
]);
assert!(has_dominant_prose_cell(&cells));
}
#[test]
fn dominant_prose_cell_rejects_small_table_dominated_by_one_cell() {
// Boundary case, documented as INTENDED: a small grid whose single
// long cell dominates the word count is rejected even at 4+ rows.
// By content alone this shape is indistinguishable from a phantom
// grid over body text, and every observed instance in the regression
// corpora was swallowed prose (chart/figure regions), not a real
// note table. Rejection degrades gracefully — the text is still
// extracted as prose — while accepting a phantom scrambles reading
// order.
let note = ["word"; 70].join(" ");
let cells = cells_of(&[
&["Purpose", note.as_str()],
&["Owner", "Facilities team"],
&["Date", "2024-06-01"],
&["Status", "Active"],
]);
assert!(has_dominant_prose_cell(&cells));
}
#[test]
fn dominant_prose_cell_allows_description_column() {
// Long-ish description cells, but text is spread across the table
let desc = ["word"; 25].join(" ");
let cells = cells_of(&[
&["Item A", desc.as_str(), "100"],
&["Item B", desc.as_str(), "200"],
&["Item C", desc.as_str(), "300"],
&["Item D", desc.as_str(), "400"],
]);
assert!(!has_dominant_prose_cell(&cells));
}
#[test]
fn dominant_prose_cell_allows_short_tables() {
let cells = cells_of(&[&["Name", "Value"], &["Total", "42"]]);
assert!(!has_dominant_prose_cell(&cells));
}
#[test]
fn dominant_prose_cell_allows_data_table_with_long_note() {
// A real 4+ row table with one verbose remark cell: the note is ≥60
// words but the table's other content carries more than 2× its word
// count, so concentration stays below the 1/3 threshold. The
// denominator scales with table size — this is what keeps large
// legitimate tables safe where a bare length cap would not.
let note = ["word"; 60].join(" ");
let row_text = ["data"; 12].join(" ");
let mut rows: Vec<Vec<String>> = (0..11)
.map(|i| {
vec![
format!("Item {i}"),
row_text.clone(),
format!("{}", i * 100),
]
})
.collect();
rows.push(vec!["Note".into(), note, String::new()]);
assert!(!has_dominant_prose_cell(&rows));
}
// --- rects_overlap ---
#[test]
fn test_rects_overlap_overlapping() {
let a = (0.0, 0.0, 10.0, 10.0);
let b = (5.0, 5.0, 10.0, 10.0);
assert!(rects_overlap(&a, &b, 0.0));
}
#[test]
fn test_rects_overlap_touching() {
let a = (0.0, 0.0, 10.0, 10.0);
let b = (10.0, 0.0, 10.0, 10.0);
// Touching at edge — with 0 tolerance, the right edge of a == left edge of b
assert!(rects_overlap(&a, &b, 0.0));
}
#[test]
fn test_rects_overlap_separated() {
let a = (0.0, 0.0, 10.0, 10.0);
let b = (20.0, 20.0, 10.0, 10.0);
assert!(!rects_overlap(&a, &b, 0.0));
}
#[test]
fn test_rects_overlap_contained() {
let a = (0.0, 0.0, 20.0, 20.0);
let b = (5.0, 5.0, 5.0, 5.0);
assert!(rects_overlap(&a, &b, 0.0));
}
#[test]
fn test_rects_overlap_identical() {
let a = (10.0, 10.0, 50.0, 50.0);
assert!(rects_overlap(&a, &a, 0.0));
}
#[test]
fn test_rects_overlap_tolerance_expansion() {
let a = (0.0, 0.0, 10.0, 10.0);
let b = (15.0, 0.0, 10.0, 10.0);
// Gap of 5 — with tol=0 they don't overlap
assert!(!rects_overlap(&a, &b, 0.0));
// With tol=3, each expands by 3 → they overlap
assert!(rects_overlap(&a, &b, 3.0));
}
// --- cluster_rects ---
#[test]
fn test_cluster_rects_empty() {
let rects: Vec<(f32, f32, f32, f32)> = vec![];
assert!(cluster_rects(&rects, 3.0, 1).is_empty());
}
#[test]
fn test_cluster_rects_single_rect() {
let rects = vec![(0.0, 0.0, 10.0, 10.0)];
// min_size=1 → should return the single rect
let groups = cluster_rects(&rects, 3.0, 1);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0], vec![0]);
}
#[test]
fn test_cluster_rects_all_disconnected() {
let rects = vec![
(0.0, 0.0, 10.0, 10.0),
(100.0, 100.0, 10.0, 10.0),
(200.0, 200.0, 10.0, 10.0),
];
// All separated, min_size=2 → no groups
let groups = cluster_rects(&rects, 0.0, 2);
assert!(groups.is_empty());
}
#[test]
fn test_cluster_rects_chain_overlap() {
// A overlaps B, B overlaps C → all in one group
let rects = vec![
(0.0, 0.0, 10.0, 10.0),
(8.0, 0.0, 10.0, 10.0),
(16.0, 0.0, 10.0, 10.0),
];
let groups = cluster_rects(&rects, 0.0, 1);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 3);
}
#[test]
fn test_cluster_rects_all_connected() {
let rects = vec![
(0.0, 0.0, 20.0, 20.0),
(5.0, 5.0, 20.0, 20.0),
(10.0, 10.0, 20.0, 20.0),
];
let groups = cluster_rects(&rects, 0.0, 1);
assert_eq!(groups.len(), 1);
}
#[test]
fn test_cluster_rects_min_size_filter() {
// Two separate pairs + one lone rect
let rects = vec![
(0.0, 0.0, 10.0, 10.0),
(5.0, 0.0, 10.0, 10.0),
(100.0, 100.0, 10.0, 10.0),
];
// min_size=2 → only the overlapping pair returned
let groups = cluster_rects(&rects, 0.0, 2);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 2);
}
#[test]
fn test_cluster_rects_overlapping_grid_still_clusters() {
// Neighboring cells overlap; the grid must still union the whole table.
let mut rects = Vec::new();
for row in 0..4 {
for col in 0..4 {
rects.push((col as f32 * 9.0, row as f32 * 9.0, 10.0, 10.0));
}
}
let groups = cluster_rects(&rects, 0.0, 1);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 16);
}
#[test]
fn test_cluster_rects_many_disjoint_stays_subquadratic() {
// Pairwise-disjoint rects never merge, so a component-size cap does
// not stop all-pairs overlap tests. Spread in X so they land in
// different grid cells; 8k is enough that n² tests would dominate.
let n = 8_000usize;
let rects: Vec<(f32, f32, f32, f32)> =
(0..n).map(|i| (i as f32 * 20.0, 0.0, 10.0, 10.0)).collect();
let groups = cluster_rects(&rects, 0.0, 2);
assert!(groups.is_empty());
}
#[test]
fn test_cluster_rects_stacked_disjoint_does_not_starve_later_table() {
// Same X, spread in Y: a spatial grid must still union an overlapping
// pair in another region of the page.
let n = 8_000usize;
let mut rects: Vec<(f32, f32, f32, f32)> =
(0..n).map(|i| (0.0, i as f32 * 20.0, 10.0, 10.0)).collect();
rects.push((500.0, 0.0, 10.0, 10.0));
rects.push((508.0, 0.0, 10.0, 10.0));
let groups = cluster_rects(&rects, 0.0, 2);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 2);
}
#[test]
fn test_cluster_rects_oversized_span_still_unions() {
// Wider than 64 grid cells; must still union the small overlapping rect.
let rects = vec![(0.0, 0.0, 5000.0, 10.0), (4900.0, 0.0, 10.0, 10.0)];
let groups = cluster_rects(&rects, 0.0, 1);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 2);
}
#[test]
fn test_cluster_rects_many_oversized_spans_all_get_a_pass() {
// More than 32 huge rects: the last one must still union its overlap.
let mut rects: Vec<(f32, f32, f32, f32)> = (0..40)
.map(|i| (0.0, i as f32 * 20.0, 5000.0, 10.0))
.collect();
rects.push((4900.0, 39.0 * 20.0, 10.0, 10.0));
let groups = cluster_rects(&rects, 0.0, 2);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 2);
}
#[test]
fn test_cluster_rects_oversized_not_starved_by_earlier_disjoint() {
// 9k earlier disjoint drawings would exhaust an index-order cap of
// 8,192 before the overlapping cell is visited.
let mut rects: Vec<(f32, f32, f32, f32)> = (0..9_000)
.map(|i| (10_000.0, i as f32 * 20.0, 10.0, 10.0))
.collect();
let wide = rects.len();
rects.push((0.0, 0.0, 5000.0, 10.0));
let target = rects.len();
rects.push((4900.0, 0.0, 10.0, 10.0));
let groups = cluster_rects(&rects, 0.0, 2);
assert!(
groups
.iter()
.any(|g| g.contains(&wide) && g.contains(&target)),
"wide rule and far-end cell must share a cluster"
);
}
#[test]
fn test_cluster_rects_wide_and_tall_oversized_union() {
let rects = vec![(0.0, 0.0, 5000.0, 10.0), (0.0, 0.0, 10.0, 5000.0)];
let groups = cluster_rects(&rects, 0.0, 2);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 2);
}
#[test]
fn test_cluster_rects_dual_oversized_spans_coarse_y() {
let rects = vec![(0.0, 0.0, 5000.0, 5000.0), (0.0, 4500.0, 5000.0, 5000.0)];
let groups = cluster_rects(&rects, 0.0, 2);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 2);
}
#[test]
fn test_cluster_rects_many_wide_and_tall_stays_subquadratic() {
let mut rects = Vec::with_capacity(4_000);
for i in 0..2_000 {
rects.push((0.0, i as f32 * 20.0, 5000.0, 10.0));
rects.push((i as f32 * 20.0, 0.0, 10.0, 5000.0));
}
let _groups = cluster_rects(&rects, 0.0, 2);
}
// --- snap_edges ---
#[test]
fn test_snap_edges_empty() {
assert!(snap_edges(&[], 6.0).is_empty());
}
#[test]
fn test_snap_edges_single_value() {
assert_eq!(snap_edges(&[42.0], 6.0), vec![42.0]);
}
#[test]
fn test_snap_edges_within_tolerance_deduped() {
let edges = snap_edges(&[10.0, 12.0, 14.0, 30.0], 6.0);
// 10, 12, 14 are all within 6 of the first → deduplicated
assert_eq!(edges.len(), 2);
assert!((edges[0] - 10.0).abs() < 0.01);
assert!((edges[1] - 30.0).abs() < 0.01);
}
#[test]
fn test_snap_edges_outside_tolerance_kept() {
let edges = snap_edges(&[10.0, 20.0, 30.0], 5.0);
assert_eq!(edges.len(), 3);
}
#[test]
fn test_snap_edges_unsorted_input() {
let edges = snap_edges(&[30.0, 10.0, 20.0], 5.0);
// Should be sorted
assert_eq!(edges, vec![10.0, 20.0, 30.0]);
}
// --- assign_items_to_grid ---
#[test]
fn test_assign_items_basic() {
let items = vec![
make_item("A", 15.0, 85.0, 10.0),
make_item("B", 55.0, 85.0, 10.0),
make_item("C", 15.0, 55.0, 10.0),
make_item("D", 55.0, 55.0, 10.0),
];
// 2x2 grid: cols at [10, 50, 90], rows at [90, 70, 50] (top-to-bottom)
let col_edges = vec![10.0, 50.0, 90.0];
let row_edges = vec![90.0, 70.0, 40.0];
let (cells, indices) = assign_items_to_grid(&items, &col_edges, &row_edges, 1);
assert_eq!(cells.len(), 2);
assert_eq!(cells[0][0], "A");
assert_eq!(cells[0][1], "B");
assert_eq!(cells[1][0], "C");
assert_eq!(cells[1][1], "D");
assert_eq!(indices.len(), 4);
}
#[test]
fn test_assign_items_outside_grid() {
let items = vec![make_item("Outside", 500.0, 500.0, 10.0)];
let col_edges = vec![10.0, 50.0, 90.0];
let row_edges = vec![90.0, 70.0, 50.0];
let (_, indices) = assign_items_to_grid(&items, &col_edges, &row_edges, 1);
assert!(indices.is_empty());
}
#[test]
fn test_assign_items_wrong_page_filtered() {
let mut item = make_item("A", 15.0, 85.0, 10.0);
item.page = 2;
let items = vec![item];
let col_edges = vec![10.0, 50.0, 90.0];
let row_edges = vec![90.0, 70.0, 50.0];
let (_, indices) = assign_items_to_grid(&items, &col_edges, &row_edges, 1);
assert!(indices.is_empty());
}
#[test]
fn test_assign_items_multiple_same_cell() {
let items = vec![
make_item("Hello", 15.0, 85.0, 10.0),
make_item("World", 20.0, 80.0, 10.0),
];
let col_edges = vec![10.0, 50.0];
let row_edges = vec![90.0, 70.0];
let (cells, indices) = assign_items_to_grid(&items, &col_edges, &row_edges, 1);
assert_eq!(indices.len(), 2);
assert!(cells[0][0].contains("Hello"));
assert!(cells[0][0].contains("World"));
}
#[test]
fn test_assign_items_parenthetical_no_inner_spaces() {
let items = vec![
make_item("The first sentence", 15.0, 85.0, 10.0),
make_item("(", 90.0, 85.0, 10.0),
make_item("twice", 95.0, 85.0, 10.0),
make_item(")", 120.0, 85.0, 10.0),
];
let col_edges = vec![10.0, 150.0];
let row_edges = vec![90.0, 70.0];
let (cells, indices) = assign_items_to_grid(&items, &col_edges, &row_edges, 1);
assert_eq!(indices.len(), 4);
assert_eq!(cells[0][0], "The first sentence (twice)");
}
#[test]
fn test_assign_items_boundary_tolerance() {
// Item right at edge with ±2pt tolerance
let items = vec![make_item("Edge", 9.0, 89.0, 10.0)];
let col_edges = vec![10.0, 50.0];
let row_edges = vec![90.0, 70.0];
let (_, indices) = assign_items_to_grid(&items, &col_edges, &row_edges, 1);
assert_eq!(indices.len(), 1);
}
#[test]
fn test_assign_items_empty_grid() {
let items = vec![make_item("A", 15.0, 85.0, 10.0)];
let col_edges = vec![10.0]; // Only 1 edge → 0 columns
let row_edges = vec![90.0]; // Only 1 edge → 0 rows
let (cells, indices) = assign_items_to_grid(&items, &col_edges, &row_edges, 1);
assert!(cells.is_empty());
assert!(indices.is_empty());
}
#[test]
fn test_assign_items_all_assigned() {
let items = vec![
make_item("A", 15.0, 85.0, 10.0),
make_item("B", 55.0, 85.0, 10.0),
];
let col_edges = vec![10.0, 50.0, 90.0];
let row_edges = vec![90.0, 70.0];
let (_, indices) = assign_items_to_grid(&items, &col_edges, &row_edges, 1);
assert_eq!(indices.len(), 2);
}
#[test]
fn test_assign_items_sorted_y_desc_x_asc() {
// Two items in same cell — should sort by Y desc, X asc
let items = vec![
make_item("Bottom", 15.0, 75.0, 10.0),
make_item("Top", 15.0, 85.0, 10.0),
];
let col_edges = vec![10.0, 50.0];
let row_edges = vec![90.0, 70.0];
let (cells, _) = assign_items_to_grid(&items, &col_edges, &row_edges, 1);
assert_eq!(cells[0][0], "Top Bottom");
}
// --- is_row_stripe_pattern ---
#[test]
fn test_is_row_stripe_pattern_too_few_rects() {
let rects = vec![(0.0, 0.0, 300.0, 20.0), (0.0, 25.0, 300.0, 20.0)];
assert!(!is_row_stripe_pattern(&rects));
}
#[test]
fn test_is_row_stripe_pattern_narrow_rects() {
let rects = vec![
(0.0, 0.0, 50.0, 20.0),
(0.0, 25.0, 50.0, 20.0),
(0.0, 50.0, 50.0, 20.0),
];
assert!(!is_row_stripe_pattern(&rects));
}
#[test]
fn test_is_row_stripe_pattern_uniform_wide() {
let rects = vec![
(10.0, 0.0, 500.0, 20.0),
(10.0, 25.0, 500.0, 20.0),
(10.0, 50.0, 500.0, 20.0),
(10.0, 75.0, 500.0, 20.0),
];
assert!(is_row_stripe_pattern(&rects));
}
#[test]
fn test_is_row_stripe_pattern_mixed_widths() {
let rects = vec![
(10.0, 0.0, 500.0, 20.0),
(10.0, 25.0, 100.0, 20.0), // Very different width
(10.0, 50.0, 500.0, 20.0),
(10.0, 75.0, 50.0, 20.0), // Very different width
];
assert!(!is_row_stripe_pattern(&rects));
}
#[test]
fn test_is_row_stripe_pattern_75_percent_boundary() {
// 3 of 4 (75%) within tolerance → should pass (> 0.75)
let rects = vec![
(10.0, 0.0, 500.0, 20.0),
(10.0, 25.0, 505.0, 20.0),
(10.0, 50.0, 495.0, 20.0),
(10.0, 75.0, 100.0, 20.0), // outlier
];
// 3/4 = 0.75 — NOT > 0.75, so false
assert!(!is_row_stripe_pattern(&rects));
}
#[test]
fn test_row_stripe_rejects_layout_background_long_cells() {
// Simulate a newsletter page with wide background rects (sidebar, header, body)
// that look like row stripes but contain paragraphs of body text.
let rects = vec![
(10.0, 700.0, 550.0, 50.0), // header band
(10.0, 640.0, 550.0, 50.0), // nav band
(10.0, 200.0, 550.0, 430.0), // body background
];
let items = vec![
make_item("General News", 20.0, 650.0, 10.0),
make_item("People News", 20.0, 710.0, 10.0),
// Simulate a long body text (>500 chars) in the main content area
make_item(&"A".repeat(600), 200.0, 650.0, 10.0),
];
let result = detect_row_stripe_table(&items, &rects, 1);
assert!(
result.is_none(),
"layout background rects should not be detected as a table"
);
}
#[test]
fn test_row_stripe_accepts_multi_row_key_value_long_cells() {
// Multi-row 2-column key/value table where one value cell holds
// a paragraph (>500 chars). The old `max_cell_len > 500` check
// rejected this shape as a "layout background"; with the
// multi-row guard, it should be accepted.
let mut rects = Vec::new();
let row_h = 25.0_f32;
let y_top = 700.0_f32;
for i in 0..8 {
let y = y_top - (i as f32) * row_h;
rects.push((40.0, y, 510.0, row_h));
}
let mut items = Vec::new();
for i in 0..8 {
let row_center_y = y_top - (i as f32) * row_h + row_h / 2.0;
// Left column: short label
items.push(make_item(&format!("Field {}", i), 45.0, row_center_y, 10.0));
// Right column: short value, except the last row which is a paragraph
let value = if i == 7 {
"X".repeat(800)
} else {
"value".to_string()
};
items.push(make_item(&value, 300.0, row_center_y, 10.0));
}
let result = detect_row_stripe_table(&items, &rects, 1);
assert!(
result.is_some(),
"multi-row key/value table with one long cell should be accepted"
);
let t = result.unwrap();
assert!(
t.cells.len() >= 4,
"expected ≥4 rows, got {}",
t.cells.len()
);
assert_eq!(t.cells[0].len(), 2, "expected 2 columns");
}
// --- propagate_merged_cells ---
#[test]
fn test_propagate_merged_cells_spanning_rect() {
// A rect spanning 2 rows in column 0
let col_edges = vec![0.0, 50.0, 100.0];
let row_edges = vec![100.0, 80.0, 60.0]; // 2 rows
let mut cells = vec![
vec!["Top".to_string(), "A".to_string()],
vec!["Bottom".to_string(), "B".to_string()],
];
// Rect spanning both rows in col 0
let group_rects = vec![(0.0, 60.0, 50.0, 40.0)];
let skip = vec![false];
propagate_merged_cells(&mut cells, &col_edges, &row_edges, &group_rects, &skip);
assert_eq!(cells[0][0], "Top Bottom");
assert!(cells[1][0].is_empty());
}
#[test]
fn test_propagate_merged_cells_single_row_rect_noop() {
// Use well-separated rows so the rect doesn't bleed into adjacent row
// via the 6pt tolerance in propagate_merged_cells.
let col_edges = vec![0.0, 50.0, 100.0];
let row_edges = vec![200.0, 100.0, 0.0];
let mut cells = vec![
vec!["A".to_string(), "B".to_string()],
vec!["C".to_string(), "D".to_string()],
];
// Rect clearly inside row 0 only (y=110..190, row 0 is 100..200)
// ry=110 > row_edges[1]+tol = 106, so it doesn't span into row 1
let group_rects = vec![(0.0, 110.0, 50.0, 80.0)];
let skip = vec![false];
let cells_before = cells.clone();
propagate_merged_cells(&mut cells, &col_edges, &row_edges, &group_rects, &skip);
assert_eq!(cells, cells_before);
}
#[test]
fn test_propagate_merged_cells_skip_rects_respected() {
let col_edges = vec![0.0, 50.0, 100.0];
let row_edges = vec![100.0, 80.0, 60.0];
let mut cells = vec![
vec!["A".to_string(), "B".to_string()],
vec!["C".to_string(), "D".to_string()],
];
let group_rects = vec![(0.0, 60.0, 50.0, 40.0)];
let skip = vec![true]; // Skip this rect
let cells_before = cells.clone();
propagate_merged_cells(&mut cells, &col_edges, &row_edges, &group_rects, &skip);
assert_eq!(cells, cells_before);
}
#[test]
fn test_propagate_merged_cells_text_in_multiple_sub_rows() {
let col_edges = vec![0.0, 50.0];
let row_edges = vec![100.0, 80.0, 60.0, 40.0]; // 3 rows
let mut cells = vec![
vec!["Line1".to_string()],
vec!["Line2".to_string()],
vec!["Line3".to_string()],
];
// Rect spanning all 3 rows
let group_rects = vec![(0.0, 40.0, 50.0, 60.0)];
let skip = vec![false];
propagate_merged_cells(&mut cells, &col_edges, &row_edges, &group_rects, &skip);
assert_eq!(cells[0][0], "Line1 Line2 Line3");
assert!(cells[1][0].is_empty());
assert!(cells[2][0].is_empty());
}
#[test]
fn test_propagate_merged_cells_full_width_spanning() {
let col_edges = vec![0.0, 50.0, 100.0];
let row_edges = vec![100.0, 80.0, 60.0];
let mut cells = vec![
vec!["A".to_string(), "X".to_string()],
vec!["B".to_string(), "Y".to_string()],
];
// Rect spanning both rows but only column 1
let group_rects = vec![(50.0, 60.0, 50.0, 40.0)];
let skip = vec![false];
propagate_merged_cells(&mut cells, &col_edges, &row_edges, &group_rects, &skip);
assert_eq!(cells[0][1], "X Y");
assert!(cells[1][1].is_empty());
// Column 0 should be unchanged
assert_eq!(cells[0][0], "A");
assert_eq!(cells[1][0], "B");
}
#[test]
fn test_propagate_merged_cells_rect_tangent_to_row_boundary() {
// Regression: a rect whose top exactly equals a row's bottom lies
// entirely outside that row, so it must not be considered to span
// it. With the old overlap-based predicate this cascaded into body
// text from unrelated rows being merged into a single header cell
// (mythos system card CB task-based evaluations table).
//
// Layout: two rows 0..80 and 80..160 (bottom → top in PDF coords),
// rect occupies only the lower row (y=0..80). Its top equals the
// upper row's bottom; it must not span the upper row.
let col_edges = vec![0.0, 50.0];
let row_edges = vec![160.0, 80.0, 0.0]; // top → bot
let mut cells = vec![vec!["Upper".to_string()], vec!["Lower".to_string()]];
let group_rects = vec![(0.0, 0.0, 50.0, 80.0)]; // rect at y=0..80
let skip = vec![false];
propagate_merged_cells(&mut cells, &col_edges, &row_edges, &group_rects, &skip);
assert_eq!(cells[0][0], "Upper", "upper row must not be merged");
assert_eq!(cells[1][0], "Lower", "lower row must not be touched");
}
#[test]
fn test_propagate_merged_cells_empty_cells_preserved() {
let col_edges = vec![0.0, 50.0];
let row_edges = vec![100.0, 80.0, 60.0];
let mut cells = vec![vec!["Text".to_string()], vec!["".to_string()]];
// Rect spanning both rows
let group_rects = vec![(0.0, 60.0, 50.0, 40.0)];
let skip = vec![false];
propagate_merged_cells(&mut cells, &col_edges, &row_edges, &group_rects, &skip);
// Only "Text" in first row (empty cell contributes nothing)
assert_eq!(cells[0][0], "Text");
assert!(cells[1][0].is_empty());
}
// --- detect_table_from_rect_group / try_build_grid ---
// Helper: create a 3-row × 2-col grid of rects with 10pt gaps between rows.
// Gaps prevent propagate_merged_cells from collapsing adjacent rows
// (shared-edge rects bleed via the 6pt tolerance).
// Y layout: row0 y=60..80, row1 y=30..50, row2 y=0..20
fn make_grid_rects() -> Vec<(f32, f32, f32, f32)> {
vec![
(10.0, 60.0, 40.0, 20.0), // row0, col0
(50.0, 60.0, 40.0, 20.0), // row0, col1
(10.0, 30.0, 40.0, 20.0), // row1, col0
(50.0, 30.0, 40.0, 20.0), // row1, col1
(10.0, 0.0, 40.0, 20.0), // row2, col0
(50.0, 0.0, 40.0, 20.0), // row2, col1
]
}
#[test]
fn test_try_build_grid_basic_valid() {
let items = vec![
make_item("H1", 15.0, 70.0, 10.0),
make_item("H2", 55.0, 70.0, 10.0),
make_item("D1", 15.0, 40.0, 10.0),
make_item("D2", 55.0, 40.0, 10.0),
make_item("E1", 15.0, 10.0, 10.0),
make_item("E2", 55.0, 10.0, 10.0),
];
let group_rects = make_grid_rects();
let skip = vec![false; 6];
match try_build_grid(&items, &group_rects, 1, &skip, false) {
GridResult::Ok(table) => {
assert!(table.columns.len() >= 2);
assert!(table.rows.len() >= 2);
}
other => panic!(
"Expected Ok, got {:?}",
match other {
GridResult::FewNonEmptyRows => "FewNonEmptyRows",
GridResult::Failed => "Failed",
GridResult::Ok(_) => unreachable!(),
}
),
}
}
#[test]
fn test_try_build_grid_too_few_edges() {
// Only 2 rects → not enough edges for a grid
let items = vec![make_item("A", 15.0, 85.0, 10.0)];
let group_rects = vec![(10.0, 70.0, 40.0, 20.0), (10.0, 50.0, 40.0, 20.0)];
let skip = vec![false; 2];
match try_build_grid(&items, &group_rects, 1, &skip, false) {
GridResult::Failed => {}
_ => panic!("Expected Failed"),
}
}
#[test]
fn test_try_build_grid_strict_rejects_long_text() {
let long_text = "a".repeat(250);
let mut long_item = make_item(&long_text, 15.0, 70.0, 10.0);
// Override width so the item center stays inside the grid cell
long_item.width = 20.0;
let items = vec![
long_item,
make_item("H2", 55.0, 70.0, 10.0),
make_item("D1", 15.0, 40.0, 10.0),
make_item("D2", 55.0, 40.0, 10.0),
make_item("E1", 15.0, 10.0, 10.0),
make_item("E2", 55.0, 10.0, 10.0),
];
let group_rects = make_grid_rects();
let skip = vec![false; 6];
match try_build_grid(&items, &group_rects, 1, &skip, true) {
GridResult::Failed => {}
_ => panic!("Expected Failed due to long text in strict mode"),
}
}
#[test]
fn test_try_build_grid_empty_column_rejected() {
// All items in column 0 only — column 1 is empty
let items = vec![
make_item("A", 15.0, 70.0, 10.0),
make_item("B", 15.0, 40.0, 10.0),
make_item("C", 15.0, 10.0, 10.0),
];
let group_rects = make_grid_rects();
let skip = vec![false; 6];
match try_build_grid(&items, &group_rects, 1, &skip, false) {
GridResult::Failed => {}
_ => panic!("Expected Failed due to empty column"),
}
}
#[test]
fn test_try_build_grid_no_items() {
let items: Vec<TextItem> = vec![];
let group_rects = make_grid_rects();
let skip = vec![false; 6];
match try_build_grid(&items, &group_rects, 1, &skip, false) {
GridResult::Failed => {}
_ => panic!("Expected Failed with no items"),
}
}
#[test]
fn test_detect_table_from_rect_group_valid() {
let items = vec![
make_item("H1", 15.0, 70.0, 10.0),
make_item("H2", 55.0, 70.0, 10.0),
make_item("D1", 15.0, 40.0, 10.0),
make_item("D2", 55.0, 40.0, 10.0),
make_item("E1", 15.0, 10.0, 10.0),
make_item("E2", 55.0, 10.0, 10.0),
];
let group_rects = make_grid_rects();
let result = detect_table_from_rect_group(&items, &group_rects, 1);
assert!(result.is_some());
}
// --- extract_hint_region ---
#[test]
fn test_extract_hint_region_valid_small_cluster() {
let rects = vec![
(10.0, 100.0, 200.0, 30.0),
(10.0, 140.0, 200.0, 30.0),
(10.0, 180.0, 200.0, 30.0),
];
let hint = extract_hint_region(&rects);
assert!(hint.is_some());
let hint = hint.unwrap();
assert!(hint.y_top > hint.y_bottom);
}
#[test]
fn test_extract_hint_region_too_few_rects() {
let rects = vec![(10.0, 100.0, 200.0, 30.0)];
assert!(extract_hint_region(&rects).is_none());
}
#[test]
fn test_extract_hint_region_too_many_rects() {
let rects: Vec<(f32, f32, f32, f32)> = (0..10)
.map(|i| (10.0, 100.0 + i as f32 * 30.0, 200.0, 25.0))
.collect();
assert!(extract_hint_region(&rects).is_none());
}
// --- split_wide_cluster ---
#[test]
fn split_at_wide_gap() {
// Left zone: x=10..50, Right zone: x=80..120 → gap of 30pt
let mut rects = Vec::new();
for i in 0..8 {
rects.push((10.0, i as f32 * 20.0, 40.0, 15.0)); // left
rects.push((80.0, i as f32 * 20.0, 40.0, 15.0)); // right
}
let result = split_wide_cluster(&rects, 15.0, 6);
assert!(result.is_some());
let (left, right) = result.unwrap();
assert!(left.iter().all(|&(x, _, _, _)| x < 60.0));
assert!(right.iter().all(|&(x, _, _, _)| x >= 60.0));
}
#[test]
fn no_split_narrow_gap() {
// Left zone: x=10..50, Right zone: x=55..95 → gap of only 5pt
let mut rects = Vec::new();
for i in 0..8 {
rects.push((10.0, i as f32 * 20.0, 40.0, 15.0));
rects.push((55.0, i as f32 * 20.0, 40.0, 15.0));
}
assert!(split_wide_cluster(&rects, 15.0, 6).is_none());
}
#[test]
fn no_split_small_subgroup() {
// Left zone: 2 rects, Right zone: 8 rects → left too small (< 6)
let mut rects = Vec::new();
for i in 0..2 {
rects.push((10.0, i as f32 * 20.0, 40.0, 15.0));
}
for i in 0..8 {
rects.push((80.0, i as f32 * 20.0, 40.0, 15.0));
}
// Also fails min total: 10 < 12 (min_group_size * 2 = 12)
assert!(split_wide_cluster(&rects, 15.0, 6).is_none());
}
#[test]
fn split_preserves_all_rects() {
let mut rects = Vec::new();
for i in 0..10 {
rects.push((10.0, i as f32 * 20.0, 40.0, 15.0));
rects.push((80.0, i as f32 * 20.0, 40.0, 15.0));
}
let (left, right) = split_wide_cluster(&rects, 15.0, 6).unwrap();
assert_eq!(left.len() + right.len(), rects.len());
}
#[test]
fn no_split_single_band() {
// All rects overlap in X → single merged interval, no gap
let rects: Vec<(f32, f32, f32, f32)> = (0..12)
.map(|i| (10.0 + i as f32 * 5.0, i as f32 * 20.0, 40.0, 15.0))
.collect();
assert!(split_wide_cluster(&rects, 15.0, 6).is_none());
}
// --- XY hint regions from failed clusters ---
#[test]
fn hint_from_failed_large_clusters() {
// Two separate clusters of 36 rects (6×6) each, placed side by side
// with a large gap so they form two distinct clusters.
// Requires ≥2 qualifying clusters to produce hints (multi-zone layout).
let mut page_rects: Vec<(f32, f32, f32, f32)> = Vec::new();
// Cluster 1: x=50..120, y=100..170
for row in 0..6 {
for col in 0..6 {
page_rects.push((
50.0 + col as f32 * 12.0,
100.0 + row as f32 * 12.0,
10.0,
10.0,
));
}
}
// Cluster 2: x=250..320, y=100..170 (130pt gap from cluster 1)
for row in 0..6 {
for col in 0..6 {
page_rects.push((
250.0 + col as f32 * 12.0,
100.0 + row as f32 * 12.0,
10.0,
10.0,
));
}
}
let items: Vec<TextItem> = vec![];
let rects: Vec<crate::types::PdfRect> = page_rects
.iter()
.map(|&(x, y, w, h)| crate::types::PdfRect {
x,
y,
width: w,
height: h,
page: 1,
})
.collect();
let (tables, hints) = detect_tables_from_rects(&items, &rects, 1);
assert!(tables.is_empty());
assert_eq!(hints.len(), 2);
// Cluster 1: x=50..120, y=100..170
assert!((hints[0].x_left - 50.0).abs() < 1.0);
assert!((hints[0].x_right - 120.0).abs() < 1.0);
assert!((hints[0].y_bottom - 100.0).abs() < 1.0);
assert!((hints[0].y_top - 170.0).abs() < 1.0);
// Cluster 2: x=250..320, y=100..170
assert!((hints[1].x_left - 250.0).abs() < 1.0);
assert!((hints[1].x_right - 320.0).abs() < 1.0);
}
#[test]
fn no_hint_single_large_cluster() {
// Single cluster of 36 rects — not enough (need ≥2 zones)
let mut page_rects: Vec<(f32, f32, f32, f32)> = Vec::new();
for row in 0..6 {
for col in 0..6 {
page_rects.push((
50.0 + col as f32 * 12.0,
100.0 + row as f32 * 12.0,
10.0,
10.0,
));
}
}
let items: Vec<TextItem> = vec![];
let rects: Vec<crate::types::PdfRect> = page_rects
.iter()
.map(|&(x, y, w, h)| crate::types::PdfRect {
x,
y,
width: w,
height: h,
page: 1,
})
.collect();
let (tables, hints) = detect_tables_from_rects(&items, &rects, 1);
assert!(tables.is_empty());
assert!(hints.is_empty());
}
#[test]
fn no_hint_too_few_rects() {
// 5 rects (< 10 threshold for large-cluster hints, also < 6 for clustering)
let rects: Vec<crate::types::PdfRect> = (0..5)
.map(|i| crate::types::PdfRect {
x: 50.0 + i as f32 * 30.0,
y: 100.0,
width: 20.0,
height: 20.0,
page: 1,
})
.collect();
let (tables, hints) = detect_tables_from_rects(&[], &rects, 1);
assert!(tables.is_empty());
// 5 rects: not enough for ≥6 clustering, and rect-sparse path needs 4-6
// but clusters of ≥4 won't form with disconnected rects (30pt gap > 3pt tol)
assert!(hints.is_empty());
}
#[test]
fn no_hint_page_spanning_width() {
// Rects spanning > 400pt width → no hint
let mut page_rects = Vec::new();
for i in 0..12 {
page_rects.push(crate::types::PdfRect {
x: i as f32 * 40.0,
y: 100.0,
width: 38.0,
height: 10.0,
page: 1,
});
}
let (tables, hints) = detect_tables_from_rects(&[], &page_rects, 1);
assert!(tables.is_empty());
assert!(hints.is_empty());
}
// --- merge_overlapping_hints ---
#[test]
fn merge_overlapping_hints_dedup() {
let hints = vec![
RectHintRegion {
x_left: 50.0,
x_right: 250.0,
y_bottom: 100.0,
y_top: 200.0,
cluster_rects: Vec::new(),
},
RectHintRegion {
x_left: 60.0,
x_right: 260.0,
y_bottom: 110.0,
y_top: 210.0,
cluster_rects: Vec::new(),
},
];
let merged = merge_overlapping_hints(hints);
assert_eq!(merged.len(), 1);
assert!((merged[0].x_left - 50.0).abs() < 0.01);
assert!((merged[0].x_right - 260.0).abs() < 0.01);
assert!((merged[0].y_bottom - 100.0).abs() < 0.01);
assert!((merged[0].y_top - 210.0).abs() < 0.01);
}
#[test]
fn merge_overlapping_hints_disjoint() {
let hints = vec![
RectHintRegion {
x_left: 50.0,
x_right: 200.0,
y_bottom: 100.0,
y_top: 200.0,
cluster_rects: Vec::new(),
},
RectHintRegion {
x_left: 350.0,
x_right: 500.0,
y_bottom: 100.0,
y_top: 200.0,
cluster_rects: Vec::new(),
},
];
let merged = merge_overlapping_hints(hints);
assert_eq!(merged.len(), 2);
}
#[test]
fn merge_hints_blocked_by_max_width() {
// Two hints in the same Y band with small X gap (8pt) but combined
// width > 400pt. Simulates left/right calendar month zones that
// should NOT merge.
let hints = vec![
RectHintRegion {
x_left: 20.0,
x_right: 340.0,
y_bottom: 100.0,
y_top: 170.0,
cluster_rects: Vec::new(),
},
RectHintRegion {
x_left: 348.0,
x_right: 668.0,
y_bottom: 100.0,
y_top: 170.0,
cluster_rects: Vec::new(),
},
];
let merged = merge_overlapping_hints(hints);
// Should remain separate: merged width would be 648pt > 400pt
assert_eq!(merged.len(), 2);
}
#[test]
fn merge_hints_adjacent_fragments() {
// Two fragments of the same zone with small gap, combined width < 400pt.
// Should merge.
let hints = vec![
RectHintRegion {
x_left: 20.0,
x_right: 266.0,
y_bottom: 100.0,
y_top: 170.0,
cluster_rects: Vec::new(),
},
RectHintRegion {
x_left: 276.0,
x_right: 340.0,
y_bottom: 100.0,
y_top: 170.0,
cluster_rects: Vec::new(),
},
];
let merged = merge_overlapping_hints(hints);
assert_eq!(merged.len(), 1);
assert!((merged[0].x_left - 20.0).abs() < 0.01);
assert!((merged[0].x_right - 340.0).abs() < 0.01);
}
#[test]
fn stacked_box_three_rows_below_cluster_minimum() {
// Pins a deliberate precision gate: a 3-box stack stays below the
// main loop's 6-rect cluster minimum and is NOT detected end-to-end.
// Routing smaller clusters through detect_stacked_box_table was
// tried and regressed four pdf-evals documents (striped bullet
// lists, wrapped regulation text, stats-table columns) with no
// corpus gains — too few boxes for the anti-prose guards to work.
// If this ever becomes worth revisiting, the guards need stronger
// signals first; flipping this assertion is the entry point.
let mut rects: Vec<PdfRect> = (0..3)
.map(|i| PdfRect {
x: 100.0,
y: 600.0 - i as f32 * 22.0,
width: 300.0,
height: 22.0,
page: 1,
})
.collect();
// Unrelated scattered rects push the page past the 6-rect page gate
// so the run reaches clustering, while the 3-box stack itself stays
// below the 6-rect cluster minimum.
for i in 0..4 {
rects.push(PdfRect {
x: 100.0 + i as f32 * 120.0,
y: 100.0,
width: 40.0,
height: 15.0,
page: 1,
});
}
let items: Vec<TextItem> = ["Step One: Plan", "Step Two: Build", "Step Three: Ship"]
.iter()
.enumerate()
.map(|(i, t)| make_item(t, 120.0, 605.0 - i as f32 * 22.0, 10.0))
.collect();
let (tables, _) = detect_tables_from_rects(&items, &rects, 1);
assert!(
tables.is_empty(),
"3-box stacks are intentionally below the detection floor"
);
}
#[test]
fn failed_cluster_generates_hint_with_items() {
// A cluster of rects forming an outer border (2 x-edges after snapping)
// that fails grid detection should produce a hint when items are inside.
// Use overlapping rects with the same left/right edges but varied heights
// so row-stripe detection also fails.
let page_rects: Vec<(f32, f32, f32, f32)> = vec![
(50.0, 100.0, 400.0, 200.0), // outer border
(52.0, 102.0, 396.0, 196.0), // inner border (within snap tolerance)
(51.0, 101.0, 398.0, 198.0), // another border variant
(50.0, 100.0, 400.0, 10.0), // top divider (thin)
(50.0, 290.0, 400.0, 10.0), // bottom divider (thin)
(50.0, 195.0, 400.0, 10.0), // middle divider
];
// Create text items inside the bounding box (≥6 items)
let mut items: Vec<TextItem> = Vec::new();
for row in 0..4 {
for col in 0..3 {
items.push(TextItem {
text: format!("cell{}_{}", row, col),
x: 60.0 + col as f32 * 120.0,
y: 120.0 + row as f32 * 40.0,
width: 50.0,
height: 10.0,
font: String::new(),
font_size: 10.0,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: crate::types::ItemType::Text,
mcid: None,
});
}
}
let rects: Vec<crate::types::PdfRect> = page_rects
.iter()
.map(|&(x, y, w, h)| crate::types::PdfRect {
x,
y,
width: w,
height: h,
page: 1,
})
.collect();
let (tables, hints) = detect_tables_from_rects(&items, &rects, 1);
// Grid detection should fail (2 x-edges after snapping: ~50 and ~450)
// If detection fails, we should get a failed-cluster hint
if tables.is_empty() {
assert_eq!(hints.len(), 1, "failed cluster should produce one hint");
assert!(!hints[0].cluster_rects.is_empty());
}
// If tables were detected, that's also acceptable
}
#[test]
fn text_derived_two_col_prose_is_not_cell_rect_table() {
let page = 1;
let mut rects = Vec::new();
for row in 0..8 {
rects.push(PdfRect {
x: 50.0,
y: 100.0 + row as f32 * 20.0,
width: 180.0,
height: 18.0,
page,
});
}
let mut items = Vec::new();
let left = [
"the annual plan was revised",
"and the team noted changes",
"this section explains limits",
"with additional notes below",
"the policy was reviewed",
"and results are summarized",
"this appendix describes scope",
"with examples for reference",
];
let right = [
"for each area in the review",
"as part of the assessment",
"that were applied in context",
"to support the conclusion",
"for use by the committee",
"as shown in the narrative",
"that remain under discussion",
"to clarify the method",
];
for row in 0..8 {
let y = 104.0 + row as f32 * 20.0;
let mut left_item = make_item(left[row], 60.0, y, 9.0);
left_item.width = 50.0;
items.push(left_item);
let mut right_item = make_item(right[row], 150.0, y, 9.0);
right_item.width = 50.0;
items.push(right_item);
}
let (tables, _hints) = detect_tables_from_rects(&items, &rects, page);
assert!(
tables.is_empty(),
"text-derived two-column prose must not be accepted as a rect table; got {:?}",
tables
.iter()
.map(|t| (t.rows.len(), t.columns.len()))
.collect::<Vec<_>>()
);
}
#[test]
fn multiline_indented_description_rows_collapse_to_visual_rows() {
let page = 1;
let col_edges = [0.0, 60.0, 420.0, 460.0, 500.0, 540.0];
let row_edges = [
340.0, 320.0, 300.0, 270.0, 250.0, 230.0, 200.0, 180.0, 160.0,
];
let mut rects = Vec::new();
for row in 0..row_edges.len() - 1 {
let y_top = row_edges[row];
let y_bot = row_edges[row + 1];
for col in 0..col_edges.len() - 1 {
rects.push((
col_edges[col],
y_bot,
col_edges[col + 1] - col_edges[col],
y_top - y_bot,
));
}
}
let mut items = vec![
make_item("Controls", 8.0, 330.0, 9.0),
make_item("Control", 70.0, 330.0, 9.0),
make_item("IG 1", 428.0, 330.0, 9.0),
make_item("IG 2", 468.0, 330.0, 9.0),
make_item("IG 3", 508.0, 330.0, 9.0),
make_item("Version", 8.0, 310.0, 9.0),
make_item("v8", 20.0, 285.0, 9.0),
make_item(
"4.5 Implement and Manage a Firewall on End-User Devices",
70.0,
285.0,
9.0,
),
make_item("*", 438.0, 285.0, 9.0),
make_item("*", 478.0, 285.0, 9.0),
make_item("*", 518.0, 285.0, 9.0),
make_item("v7", 20.0, 215.0, 9.0),
make_item(
"9.4 Apply Host-based Firewalls or Port-Filtering",
70.0,
215.0,
9.0,
),
make_item("*", 478.0, 215.0, 9.0),
make_item("*", 518.0, 215.0, 9.0),
];
items.push(make_item(
"Implement and manage a host-based firewall or port-filtering tool",
84.0,
260.0,
8.0,
));
items.push(make_item(
"on end-user devices with a default-deny rule",
84.0,
240.0,
8.0,
));
items.push(make_item(
"Apply host-based firewalls or port filtering tools on end systems",
84.0,
190.0,
8.0,
));
items.push(make_item(
"and deny unauthorized network communication",
84.0,
170.0,
8.0,
));
let table = detect_row_stripe_table_from_cell_rects(&items, &rects, page)
.expect("expected multiline description table");
assert_eq!(table.columns.len(), 5);
assert_eq!(
table.rows.len(),
3,
"wrapped lines should collapse to header plus two data rows"
);
assert_eq!(table.cells[0][0], "Controls Version");
assert!(table.cells[1][1].contains("host-based firewall"));
assert!(table.cells[1][1].contains("default-deny rule"));
assert!(table.cells[2][1].contains("deny unauthorized"));
}
/// Wire-bordered 4-column table whose header text is centered/right-aligned
/// inside each cell while the data is left-aligned: cluster_x_positions
/// merges adjacent columns (data Item→EAN gap is below threshold) and
/// drops the header-only x-clusters in the filter pass, leaving only 3
/// text-derived columns. Rect borders are 4 columns of ground truth.
/// Before the fix the cell-rect path preferred text edges when they were
/// the smaller set — losing a column. After the fix, 3+ rect columns
/// always win.
#[test]
fn wired_header_data_misaligned_keeps_all_columns_from_rects() {
let page = 1;
// 4 cols: Item | EAN | Nombre | Cant
let col_xs = [380.0_f32, 410.0, 470.0, 660.0, 700.0];
// Header + 9 data rows at 15pt tall each (y descending).
let row_ys: Vec<f32> = (0..=10).map(|r| 400.0 - 15.0 * r as f32).collect();
let mut rects: Vec<(f32, f32, f32, f32)> = Vec::new();
for r in 0..10 {
let y_top = row_ys[r];
let y_bot = row_ys[r + 1];
for c in 0..4 {
rects.push((col_xs[c], y_bot, col_xs[c + 1] - col_xs[c], y_top - y_bot));
}
}
let mut items: Vec<TextItem> = Vec::new();
// Header row (y ≈ 392.5): headers sit further to the right than data
// because they are centered/right-aligned in the cells.
items.push(make_item("Item", 389.0, 392.5, 9.0));
items.push(make_item("EAN", 432.0, 392.5, 9.0));
items.push(make_item("Nombre", 552.0, 392.5, 9.0));
items.push(make_item("Cant", 672.0, 392.5, 9.0));
let names = [
"Arnes Frontal",
"Arnes Motor",
"Arnes Piso",
"Arnes Techo",
"Arnes Puerta",
"Arnes Tablero",
"Arnes Trasero",
"Arnes Lateral",
"Arnes Sensor",
];
for r in 0..9 {
let y = 377.5 - 15.0 * r as f32;
items.push(make_item(&(r + 1).to_string(), 396.0, y, 9.0));
items.push(make_item("7701023403016", 410.0, y, 9.0));
items.push(make_item(names[r], 480.0, y, 9.0));
items.push(make_item("1", 680.0, y, 9.0));
}
let table = detect_row_stripe_table_from_cell_rects(&items, &rects, page)
.expect("wired 4-column table with header/data x-misalignment must detect");
assert_eq!(
table.columns.len(),
4,
"expected 4 columns from rect borders; cells: {:?}",
table.cells
);
for c in 0..4 {
let any_populated = table.cells.iter().any(|row| !row[c].trim().is_empty());
assert!(
any_populated,
"column {} empty across all rows; cells: {:?}",
c, table.cells
);
}
// Header row populated in all 4 cells.
let header = &table.cells[0];
assert_eq!(header[0].trim(), "Item");
assert_eq!(header[1].trim(), "EAN");
assert_eq!(header[2].trim(), "Nombre");
assert_eq!(header[3].trim(), "Cant");
// First data row: Item="1", EAN, name, count="1" — no Item↔EAN merge.
let data1 = &table.cells[1];
assert_eq!(data1[0].trim(), "1");
assert_eq!(data1[1].trim(), "7701023403016");
assert!(data1[2].trim().contains("Arnes"));
assert_eq!(data1[3].trim(), "1");
}
#[test]
fn failed_cluster_no_hint_without_items() {
// Rects with no text items inside → no failed-cluster hint generated.
// Use >6 rects to avoid the rect-sparse path (4-6 rects).
let page_rects: Vec<(f32, f32, f32, f32)> = vec![
(50.0, 100.0, 400.0, 200.0),
(52.0, 102.0, 396.0, 196.0),
(51.0, 101.0, 398.0, 198.0),
(50.0, 100.0, 400.0, 10.0),
(50.0, 290.0, 400.0, 10.0),
(50.0, 195.0, 400.0, 10.0),
(50.0, 150.0, 400.0, 10.0),
(50.0, 250.0, 400.0, 10.0),
];
let rects: Vec<crate::types::PdfRect> = page_rects
.iter()
.map(|&(x, y, w, h)| crate::types::PdfRect {
x,
y,
width: w,
height: h,
page: 1,
})
.collect();
let (tables, hints) = detect_tables_from_rects(&[], &rects, 1);
// No items → no table, no hint (items_inside check fails)
if tables.is_empty() {
assert!(hints.is_empty(), "no items inside → no hint");
}
}
#[test]
fn failed_cluster_no_hint_narrow_height() {
// Cluster with only 20pt height (header band) should not produce hint
// even with items inside (height < 100pt threshold)
let page_rects: Vec<(f32, f32, f32, f32)> = vec![
(50.0, 650.0, 50.0, 20.0),
(100.0, 650.0, 50.0, 20.0),
(150.0, 650.0, 50.0, 20.0),
(200.0, 650.0, 50.0, 20.0),
(250.0, 650.0, 50.0, 20.0),
(300.0, 650.0, 50.0, 20.0),
(350.0, 650.0, 50.0, 20.0),
(400.0, 650.0, 50.0, 20.0),
];
let mut items: Vec<TextItem> = Vec::new();
for col in 0..8 {
items.push(TextItem {
text: format!("hdr{}", col),
x: 55.0 + col as f32 * 50.0,
y: 655.0,
width: 40.0,
height: 10.0,
font: String::new(),
font_size: 10.0,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: crate::types::ItemType::Text,
mcid: None,
});
}
let rects: Vec<crate::types::PdfRect> = page_rects
.iter()
.map(|&(x, y, w, h)| crate::types::PdfRect {
x,
y,
width: w,
height: h,
page: 1,
})
.collect();
let (tables, hints) = detect_tables_from_rects(&items, &rects, 1);
assert!(tables.is_empty());
assert!(
hints.is_empty(),
"narrow header band (20pt) should not produce hint"
);
}
// --- page-bg clustering exclusion ---
#[test]
fn page_bg_rects_do_not_bridge_separate_clusters() {
// Simulate page 27 scenario: two groups of row stripes at different Y
// ranges, connected by full-page background rects at (0,0).
// Without exclusion, all rects cluster into one group.
// With exclusion, two separate clusters form.
let mut rects = Vec::new();
let page = 1;
// Group 1: 7 row stripes at Y=444..537 (Reference Group table)
for i in 0..7 {
let y = 444.0 + i as f32 * 15.5;
rects.push(PdfRect {
x: 44.0,
y,
width: 505.0,
height: 15.5,
page,
});
}
// Group 2: 4 row stripes at Y=176..238 (smaller table)
for i in 0..4 {
let y = 176.0 + i as f32 * 15.5;
rects.push(PdfRect {
x: 44.0,
y,
width: 505.0,
height: 15.5,
page,
});
}
// 3 full-page background rects at origin
for _ in 0..3 {
rects.push(PdfRect {
x: 0.0,
y: 0.0,
width: 594.0,
height: 774.0,
page,
});
}
// Items in group 1 region for row-stripe detection
let mut items = Vec::new();
for i in 0..7 {
let y = 449.0 + i as f32 * 15.5;
items.push(make_item("Company Name", 50.0, y, 9.0));
items.push(make_item("P", 320.0, y, 9.0));
items.push(make_item("P", 450.0, y, 9.0));
}
let (tables, _hints) = detect_tables_from_rects(&items, &rects, page);
// Should detect the group 1 table (7 row stripes) without being
// confused by group 2 stripes bridged via page-bg rects.
assert!(
!tables.is_empty(),
"should detect table from row stripes when page-bg rects are excluded from clustering"
);
// The table should have rows from group 1 only, not spanning to group 2
let table = &tables[0];
assert!(
table.rows.len() <= 8,
"table should have at most ~7 rows from group 1, got {}",
table.rows.len()
);
}
}