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;
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]
}
}
pub(crate) fn rects_overlap(a: &(f32, f32, f32, f32), b: &(f32, f32, f32, f32), tol: f32) -> bool {
let (ax, ay, aw, ah) = *a;
let (bx, by, bw, bh) = *b;
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;
!(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
}
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;
}
}
}
}
}
const MAX_CLUSTER_RECTS: usize = 2000;
const CLUSTER_GRID_CELL: f32 = 64.0;
const MAX_CLUSTER_PAIRS_PER_CELL: usize = 16_384;
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);
}
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;
}
}
}
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);
}
}
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);
}
}
let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
for i in 0..n {
groups.entry(uf.find(i)).or_default().push(i);
}
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()
}
#[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;
}
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));
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;
}
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
}
}
#[derive(Debug, Clone)]
pub struct RectHintRegion {
pub y_top: f32,
pub y_bottom: f32,
pub x_left: f32,
pub x_right: f32,
pub cluster_rects: Vec<(f32, f32, f32, f32)>,
}
pub fn detect_chart_regions(
items: &[TextItem],
rects: &[PdfRect],
page: u32,
) -> Vec<(f32, f32, f32, f32)> {
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)
})
.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>) {
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 mut page_rects: Vec<(f32, f32, f32, f32)> = Vec::new(); 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;
}
if w < 5.0 || h < 5.0 {
continue;
}
page_rects.push((x, y, w, h));
}
let normalized_rects =
without_page_backgrounds(&page_rects, PageBackgroundRemoval::Overwhelming);
if normalized_rects.len() < page_rects.len() {
debug!(
"page {}: removed {} overwhelming page-background rects",
page,
page_rects.len() - normalized_rects.len()
);
page_rects = normalized_rects;
}
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,
);
}
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;
bw * bh > aw * ah * 1.2
&& bh < ah * 4.0 && !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();
if page_rects.len() >= 6 {
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
};
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);
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();
if is_chart_bar_cluster(items, &group_rects, page) {
let normalized =
without_page_backgrounds(&group_rects, PageBackgroundRemoval::Repeated);
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,
)
})
.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()
);
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) {
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);
}
}
let only_narrow = !tables.is_empty() && tables.iter().all(|t| t.columns.len() <= 3);
if tables.is_empty() || only_narrow {
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);
}
}
}
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);
}
}
}
}
let row_stripe_rects =
without_page_backgrounds(&page_rects, PageBackgroundRemoval::Repeated);
if tables.is_empty() && clusters.is_empty() && row_stripe_rects.len() >= 15 {
if let Some(table) = detect_row_stripe_table(items, &row_stripe_rects, page) {
if table.rows.len() >= 10 {
debug!(
"page {}: row-stripe fallback succeeded ({} rects, {} rows)",
page,
row_stripe_rects.len(),
table.rows.len()
);
tables.push(table);
} else {
debug!(
"page {}: row-stripe fallback rejected: only {} rows",
page,
table.rows.len()
);
}
}
}
}
if tables.is_empty() {
let mut has_failed_cluster_hints = false;
if page_rects.len() >= 6 {
let clusters = cluster_rects(&page_rects, 3.0, 6);
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(),
});
}
}
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;
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;
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;
}
}
hint_regions = merge_overlapping_hints(hint_regions);
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()
);
}
}
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)
}
fn detect_stacked_box_table(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
) -> Option<Table> {
let cands: Vec<(f32, f32, f32, f32)> = group_rects
.iter()
.copied()
.filter(|&(_, _, w, h)| w >= 100.0 && (8.0..=80.0).contains(&h))
.collect();
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;
}
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));
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; }
if upper_bottom - lower_top > upper.3.max(lower.3) {
return None; }
}
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.line_y()
.partial_cmp(&a.1.line_y())
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
a.1.x
.partial_cmp(&b.1.x)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
let mut runs = 1usize;
for pair in in_box.windows(2) {
let (prev, item) = (pair[0].1, pair[1].1);
if (prev.line_y() - item.line_y()).abs() <= 2.0 && item.x - (prev.x + prev.width) > 15.0
{
runs += 1;
}
}
if runs >= 2 {
multi_run_boxes += 1;
}
let mut text = String::new();
let mut last = None;
for (_, it) in &in_box {
let trimmed = it.text.trim();
if !trimmed.is_empty() {
super::cell_text::push_cell_item(&mut text, &mut last, it, trimmed);
}
}
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;
}
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;
}
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;
}
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() {
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;
}
let x_gap = existing.x_left.max(hint.x_left) - existing.x_right.min(hint.x_right);
if x_gap < 50.0 {
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
}
fn extract_hint_region(group_rects: &[(f32, f32, f32, f32)]) -> Option<RectHintRegion> {
if group_rects.len() < 2 || group_rects.len() > 8 {
return None;
}
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];
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;
}
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)?;
let region_height = y_top - y_bottom;
if !(10.0..=300.0).contains(®ion_height) {
return None;
}
Some(RectHintRegion {
y_top,
y_bottom,
x_left,
x_right,
cluster_rects: Vec::new(),
})
}
pub(crate) fn detect_table_from_rect_group(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
) -> Option<Table> {
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 => {
}
GridResult::Failed => return None,
}
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();
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
}
enum GridResult {
Ok(Table),
FewNonEmptyRows,
Failed,
}
fn try_build_grid(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
skip_rects: &[bool],
strict: bool,
) -> GridResult {
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;
}
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;
}
if num_cols > 25 {
debug!(" rejected: {} columns > 25", num_cols);
return GridResult::Failed;
}
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];
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
);
if fill_ratio < 0.3 {
debug!(" rejected: fill ratio {:.2} < 0.30", fill_ratio);
return GridResult::Failed;
}
let (mut cells, item_indices) = assign_items_to_grid(items, &col_edges, &row_edges, page);
if num_cols <= 10 {
propagate_merged_cells(&mut cells, &col_edges, &row_edges, group_rects, skip_rects);
}
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();
if item_indices.is_empty() {
debug!(" rejected: no text items assigned to grid");
return GridResult::Failed;
}
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;
}
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;
}
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;
}
}
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;
}
};
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;
}
}
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))
}
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; }
}
snapped.push(v);
}
snapped
}
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;
let mut cell_items: Vec<Vec<Vec<(usize, &TextItem)>>> =
vec![vec![Vec::new(); num_cols]; num_rows];
let mut indices = Vec::new();
let row_centers: Vec<f32> = (0..num_rows)
.map(|r| (row_edges[r] + row_edges[r + 1]) / 2.0)
.collect();
for (idx, item) in items.iter().enumerate() {
if item.page != page {
continue;
}
let cx = item.x + item.width / 2.0;
let cy = item.line_y();
let col = (0..num_cols).find(|&c| cx >= col_edges[c] - 2.0 && cx <= col_edges[c + 1] + 2.0);
let row = (0..num_rows).find(|&r| cy >= row_edges[r + 1] - 2.0 && cy <= row_edges[r] + 2.0);
if super::crosses_other_rows(item, &row_centers, row) {
continue;
}
if let (Some(c), Some(r)) = (col, row) {
cell_items[r][c].push((idx, item));
indices.push(idx);
}
}
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() {
let rtl = crate::text_utils::is_rtl_text(col_items.iter().map(|(_, i)| &i.text));
if rtl {
crate::text_utils::sort_rtl_cell_items(
col_items,
|(_, i)| i.x,
|(_, i)| i.line_y(),
|(_, i)| i.text.as_str(),
);
} else {
col_items.sort_by(|a, b| {
b.1.line_y()
.partial_cmp(&a.1.line_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 = if col_items.iter().any(|(_, item)| item.is_script()) {
let refs: Vec<&TextItem> = col_items.iter().map(|(_, item)| *item).collect();
super::cell_text::join_cell_items(&refs)
} else {
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
}
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;
if skip_rects[rect_idx] {
continue;
}
if rx > col_edges[col] + tol || (rx + rw) < col_edges[col + 1] - tol {
continue;
}
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, };
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);
}
}
cells[first][col] = combined;
for row in cells.iter_mut().take(last + 1).skip(first + 1) {
row[col] = String::new();
}
}
}
}
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];
if median_width <= 200.0 {
return false;
}
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
}
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()
);
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;
}
let mut row_edges = y_edges;
row_edges.sort_by(|a, b| b.total_cmp(a));
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();
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;
}
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;
}
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!(
" row-stripe grid: {}x{} ({} col edges, {} row edges)",
num_rows,
num_cols,
col_edges.len(),
row_edges.len()
);
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;
}
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;
}
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;
}
let max_cell_len = cells
.iter()
.flat_map(|row| row.iter())
.map(|c| c.len())
.max()
.unwrap_or(0);
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;
}
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;
}
if row_stripe_cells_are_prose(&cells) {
debug!(" row-stripe rejected: prose fragments behind stripes");
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))
}
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_cells_are_prose(cells: &[Vec<String>]) -> bool {
let num_cols = cells.iter().map(Vec::len).max().unwrap_or(0);
let mut counted = 0usize;
let mut total_chars = 0usize;
let mut sentence_cells = 0usize;
let mut sentence_columns = vec![false; num_cols];
for row in cells {
for (col, cell) in row.iter().enumerate() {
let t = cell.trim();
if t.is_empty() {
continue;
}
counted += 1;
total_chars += t.chars().count();
if t.split_whitespace().count() >= 10 && t.contains(['.', ',']) {
sentence_cells += 1;
sentence_columns[col] = true;
}
}
}
counted > 0
&& total_chars / counted > 90
&& sentence_cells * 2 >= counted
&& sentence_columns.iter().filter(|&&v| v).count() >= 2
}
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
}
#[derive(Clone, Copy)]
enum PageBackgroundRemoval {
Repeated,
Overwhelming,
}
fn without_page_backgrounds(
rects: &[(f32, f32, f32, f32)],
policy: PageBackgroundRemoval,
) -> 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
};
let page_scale_count = rects.iter().filter(is_page_scale).count();
let meets_policy = match policy {
PageBackgroundRemoval::Repeated => true,
PageBackgroundRemoval::Overwhelming => page_scale_count * 4 >= rects.len() * 3,
};
if page_scale_count < DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS || !meets_policy {
return rects.to_vec();
}
rects
.iter()
.filter(|rect| !is_page_scale(rect))
.copied()
.collect()
}
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_page_backgrounds(group_rects, PageBackgroundRemoval::Repeated).len()
== group_rects.len()
}
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));
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;
}
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,
})
}
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()
}
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();
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()
})
};
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;
}
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;
}
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;
}
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
})
};
bar_family(|r| r.0, |r| r.2, |r| r.3, |r| r.1)
|| 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);
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;
}
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);
let row_edges = if y_edges.len() >= 4 {
let mut edges = y_edges;
edges.sort_by(|a, b| b.total_cmp(a));
edges
} else {
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;
}
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; edges = snap_edges(&edges, 3.0);
edges.sort_by(|a, b| b.total_cmp(a));
if edges.len() < 4 {
return None;
}
edges
};
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()?;
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;
}
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
}
};
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;
}
}
}
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
);
}
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;
}
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;
}
if num_rows > 20 && num_cols < 4 {
debug!(
" cell-rect rejected: disproportionate grid {}x{}",
num_rows, num_cols
);
return None;
}
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 {
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
);
}
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;
}
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))
}
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);
};
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();
let is_description_continuation = row_idx > 0
&& !desc_text.is_empty()
&& !left_label
&& non_desc_non_empty == 0
&& !new_cells.is_empty();
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)
}
fn detect_merged_cluster_table(
items: &[TextItem],
all_rects: &[(f32, f32, f32, f32)],
page: u32,
) -> Option<Table> {
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));
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();
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;
}
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;
}
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()
);
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;
}
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;
}
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;
}
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;
}
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))
}
fn cluster_x_positions(items: &[(usize, &TextItem)], min_threshold: f32) -> Vec<f32> {
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];
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);
}
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 {
#[test]
fn row_stripe_prose_fragments_are_rejected() {
let prose = vec![
vec![
"The other potentially invasive fouler is the tropical American species in low abundances near the harbor entrance today."
.to_string(),
"Mytilopsis sallei and M. adamsi which has been recorded invasive in Singapore, Australia, Thailand among other regions of the coast."
.to_string(),
],
vec![
"Figure 3. Non-indigenous macrofoulers from Manila Bay with IAS, based on more intensive biofouling ecological monitoring efforts."
.to_string(),
"Newer estimates on the number of possible IAS in Manila Bay is likely more than 30 species, when research started on this topic."
.to_string(),
],
];
assert!(super::row_stripe_cells_are_prose(&prose));
let zebra = vec![
vec!["Revenue".to_string(), "$1,240".to_string()],
vec!["Cost of goods".to_string(), "$310".to_string()],
vec!["Net margin".to_string(), "24%".to_string()],
];
assert!(!super::row_stripe_cells_are_prose(&zebra));
}
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_tag: String::new(),
legacy_symbol_rewrite: false,
font_size,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
rotation: 0.0,
advance_known: true,
item_type: ItemType::Text,
mcid: None,
baseline_shift: 0.0,
}
}
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_page_backgrounds(&dominant, PageBackgroundRemoval::Repeated),
vec![cell]
);
let mut incidental = vec![page_fill; DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS - 1];
incidental.push(cell);
assert_eq!(
without_page_backgrounds(&incidental, PageBackgroundRemoval::Repeated),
incidental
);
}
#[test]
fn overwhelming_page_backgrounds_are_removed_before_grid_detection() {
let page_fill = PdfRect {
x: 0.0,
y: 0.0,
width: 600.0,
height: 800.0,
page: 1,
};
let mut rects = vec![page_fill; 12];
rects.extend([
PdfRect {
x: 70.0,
y: 320.0,
width: 240.0,
height: 260.0,
page: 1,
},
PdfRect {
x: 330.0,
y: 400.0,
width: 200.0,
height: 48.0,
page: 1,
},
PdfRect {
x: 115.0,
y: 70.0,
width: 150.0,
height: 12.0,
page: 1,
},
]);
let items = vec![
make_item("body prose", 70.0, 700.0, 10.0),
make_item("continued body prose", 300.0, 620.0, 10.0),
make_item("Figure 6", 340.0, 430.0, 9.0),
make_item("caption", 410.0, 410.0, 9.0),
make_item("footnote", 120.0, 74.0, 8.0),
];
let (tables, _) = detect_tables_from_rects(&items, &rects, 1);
assert!(
tables.is_empty(),
"duplicate page backgrounds must not manufacture a full-page table"
);
}
#[test]
fn minority_page_backgrounds_preserve_real_cell_grid() {
let page_fill = PdfRect {
x: 0.0,
y: 0.0,
width: 600.0,
height: 800.0,
page: 1,
};
let mut rects = vec![page_fill; DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS];
let mut items = Vec::new();
for row in 0..4 {
for col in 0..3 {
rects.push(PdfRect {
x: 100.0 + col as f32 * 100.0,
y: 600.0 - row as f32 * 24.0,
width: 100.0,
height: 24.0,
page: 1,
});
items.push(make_item(
"42",
110.0 + col as f32 * 100.0,
607.0 - row as f32 * 24.0,
9.0,
));
}
}
let (tables, _) = detect_tables_from_rects(&items, &rects, 1);
assert_eq!(tables.len(), 1, "a real repeated cell grid must survive");
assert_eq!(tables[0].rows.len(), 4);
assert_eq!(tables[0].columns.len(), 3);
}
#[test]
fn minority_page_backgrounds_do_not_expand_row_stripe_fallback() {
let page_fill = PdfRect {
x: 0.0,
y: 0.0,
width: 600.0,
height: 800.0,
page: 1,
};
let mut rects = vec![page_fill; DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS];
let mut items = Vec::new();
for row in 0..25 {
let y = 200.0 + row as f32 * 20.0;
rects.push(PdfRect {
x: 40.0,
y,
width: 510.0,
height: 16.0,
page: 1,
});
items.push(make_item(&format!("row {row}"), 60.0, y + 5.0, 9.0));
items.push(make_item(&format!("value {row}"), 320.0, y + 5.0, 9.0));
}
let (tables, _) = detect_tables_from_rects(&items, &rects, 1);
assert_eq!(tables.len(), 1, "the row-stripe table should survive");
assert_eq!(
tables[0].rows.len(),
25,
"page fills must not add full-page rows to the fallback grid"
);
assert_eq!(tables[0].columns.len(), 2);
}
#[test]
fn uniform_cell_grid_is_not_a_chart() {
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);
}
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() {
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() {
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)); }
assert!(detect_stacked_box_table(&items, &rects, 1).is_none());
}
#[test]
fn stacked_box_rejects_two_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() {
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());
}
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() {
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() {
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() {
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() {
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));
}
#[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);
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);
assert!(!rects_overlap(&a, &b, 0.0));
assert!(rects_overlap(&a, &b, 3.0));
}
#[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)];
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),
];
let groups = cluster_rects(&rects, 0.0, 2);
assert!(groups.is_empty());
}
#[test]
fn test_cluster_rects_chain_overlap() {
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() {
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),
];
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() {
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() {
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() {
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() {
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() {
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() {
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);
}
#[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);
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);
assert_eq!(edges, vec![10.0, 20.0, 30.0]);
}
#[test]
fn long_vertical_runs_never_fill_a_cell() {
let mut running_head = make_item("Diversity and Distributions, 1-15", 15.0, 45.0, 9.0);
running_head.rotation = 90.0;
running_head.width = 9.0;
running_head.height = 200.0;
let mut header = make_item("Total", 55.0, 75.0, 9.0);
header.rotation = 90.0;
header.width = 9.0;
header.height = 40.0;
let items = vec![running_head, header, make_item("A", 15.0, 85.0, 10.0)];
let col_edges = vec![10.0, 50.0, 90.0];
let row_edges = vec![120.0, 70.0, 55.0, 40.0];
let (cells, indices) = assign_items_to_grid(&items, &col_edges, &row_edges, 1);
assert_eq!(indices, vec![1, 2]);
assert_eq!(cells[0][0], "A");
assert_eq!(cells[0][1], "Total");
assert!(
cells.iter().flatten().all(|c| !c.contains("Diversity")),
"{cells:?}"
);
}
#[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),
];
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() {
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]; let row_edges = vec![90.0]; 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() {
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");
}
#[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), (10.0, 50.0, 500.0, 20.0),
(10.0, 75.0, 50.0, 20.0), ];
assert!(!is_row_stripe_pattern(&rects));
}
#[test]
fn test_is_row_stripe_pattern_75_percent_boundary() {
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), ];
assert!(!is_row_stripe_pattern(&rects));
}
#[test]
fn test_row_stripe_rejects_layout_background_long_cells() {
let rects = vec![
(10.0, 700.0, 550.0, 50.0), (10.0, 640.0, 550.0, 50.0), (10.0, 200.0, 550.0, 430.0), ];
let items = vec![
make_item("General News", 20.0, 650.0, 10.0),
make_item("People News", 20.0, 710.0, 10.0),
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() {
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;
items.push(make_item(&format!("Field {}", i), 45.0, row_center_y, 10.0));
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");
}
#[test]
fn test_propagate_merged_cells_spanning_rect() {
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!["Top".to_string(), "A".to_string()],
vec!["Bottom".to_string(), "B".to_string()],
];
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() {
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()],
];
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]; 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]; let mut cells = vec![
vec!["Line1".to_string()],
vec!["Line2".to_string()],
vec!["Line3".to_string()],
];
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()],
];
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());
assert_eq!(cells[0][0], "A");
assert_eq!(cells[1][0], "B");
}
#[test]
fn test_propagate_merged_cells_rect_tangent_to_row_boundary() {
let col_edges = vec![0.0, 50.0];
let row_edges = vec![160.0, 80.0, 0.0]; let mut cells = vec![vec!["Upper".to_string()], vec!["Lower".to_string()]];
let group_rects = vec![(0.0, 0.0, 50.0, 80.0)]; 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()]];
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], "Text");
assert!(cells[1][0].is_empty());
}
fn make_grid_rects() -> Vec<(f32, f32, f32, f32)> {
vec![
(10.0, 60.0, 40.0, 20.0), (50.0, 60.0, 40.0, 20.0), (10.0, 30.0, 40.0, 20.0), (50.0, 30.0, 40.0, 20.0), (10.0, 0.0, 40.0, 20.0), (50.0, 0.0, 40.0, 20.0), ]
}
#[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() {
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);
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() {
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());
}
#[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());
}
#[test]
fn split_at_wide_gap() {
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((80.0, i as f32 * 20.0, 40.0, 15.0)); }
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() {
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() {
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));
}
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() {
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());
}
#[test]
fn hint_from_failed_large_clusters() {
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,
));
}
}
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);
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);
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() {
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() {
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());
assert!(hints.is_empty());
}
#[test]
fn no_hint_page_spanning_width() {
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());
}
#[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() {
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);
assert_eq!(merged.len(), 2);
}
#[test]
fn merge_hints_adjacent_fragments() {
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() {
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();
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() {
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), ];
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_tag: String::new(),
legacy_symbol_rewrite: false,
font_size: 10.0,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
rotation: 0.0,
advance_known: true,
item_type: crate::types::ItemType::Text,
mcid: None,
baseline_shift: 0.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(&items, &rects, 1);
if tables.is_empty() {
assert_eq!(hints.len(), 1, "failed cluster should produce one hint");
assert!(!hints[0].cluster_rects.is_empty());
}
}
#[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"));
}
#[test]
fn wired_header_data_misaligned_keeps_all_columns_from_rects() {
let page = 1;
let col_xs = [380.0_f32, 410.0, 470.0, 660.0, 700.0];
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();
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
);
}
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");
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() {
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);
if tables.is_empty() {
assert!(hints.is_empty(), "no items inside → no hint");
}
}
#[test]
fn failed_cluster_no_hint_narrow_height() {
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_tag: String::new(),
legacy_symbol_rewrite: false,
font_size: 10.0,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
rotation: 0.0,
advance_known: true,
item_type: crate::types::ItemType::Text,
mcid: None,
baseline_shift: 0.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(&items, &rects, 1);
assert!(tables.is_empty());
assert!(
hints.is_empty(),
"narrow header band (20pt) should not produce hint"
);
}
#[test]
fn page_bg_rects_do_not_bridge_separate_clusters() {
let mut rects = Vec::new();
let page = 1;
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,
});
}
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,
});
}
for _ in 0..3 {
rects.push(PdfRect {
x: 0.0,
y: 0.0,
width: 594.0,
height: 774.0,
page,
});
}
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);
assert!(
!tables.is_empty(),
"should detect table from row stripes when page-bg rects are excluded from clustering"
);
let table = &tables[0];
assert!(
table.rows.len() <= 8,
"table should have at most ~7 rows from group 1, got {}",
table.rows.len()
);
}
}