use std::collections::HashMap;
use log::debug;
use crate::types::{PdfRect, TextItem};
use super::Table;
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)
}
const MAX_CLUSTER_RECTS: usize = 2000;
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);
for i in 0..n {
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
continue;
}
for j in (i + 1)..n {
if rects_overlap(&rects[i], &rects[j], tolerance) {
uf.union(i, j);
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
break;
}
}
}
}
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_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));
}
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());
for cluster_indices in &clusters {
let group_rects: Vec<(f32, f32, f32, f32)> =
cluster_indices.iter().map(|&i| page_rects[i]).collect();
if let Some(table) = detect_table_from_rect_group(items, &group_rects, page) {
tables.push(table);
} else if let Some(table) = detect_row_stripe_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 total_clustered: usize = clusters.iter().map(|c| c.len()).sum();
if clusters.len() >= 3 && total_clustered >= 50 {
debug!(
"page {}: trying merged-cluster fallback ({} clusters, {} rects{})",
page,
clusters.len(),
total_clustered,
if only_narrow {
", replacing narrow tables"
} else {
""
}
);
let all_cluster_rects: Vec<(f32, f32, f32, f32)> = 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);
}
}
}
}
if tables.is_empty() && clusters.is_empty() && page_rects.len() >= 15 {
if let Some(table) = detect_row_stripe_table(items, &page_rects, page) {
if table.rows.len() >= 10 {
debug!(
"page {}: row-stripe fallback succeeded ({} rects, {} rows)",
page,
page_rects.len(),
table.rows.len()
);
tables.push(table);
} else {
debug!(
"page {}: row-stripe fallback rejected: only {} rows",
page,
table.rows.len()
);
}
}
}
}
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 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();
for (idx, item) in items.iter().enumerate() {
if item.page != page {
continue;
}
let cx = item.x + item.width / 2.0;
let cy = item.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 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() {
col_items.sort_by(|a, b| {
b.1.y
.partial_cmp(&a.1.y)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
a.1.x
.partial_cmp(&b.1.x)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
let text = col_items
.iter()
.map(|(_, item)| item.text.trim())
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join(" ");
let text = remove_inner_delimiter_spaces(&text);
row_cells.push(text);
}
cells.push(row_cells);
}
(cells, indices)
}
fn remove_inner_delimiter_spaces(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut result = String::with_capacity(text.len());
for (i, &ch) in chars.iter().enumerate() {
if ch == ' ' {
let after_open =
result.ends_with('(') || result.ends_with('[') || result.ends_with('{');
let before_close = chars
.get(i + 1)
.is_some_and(|next| matches!(next, ')' | ']' | '}'));
if after_open || before_close {
continue;
}
}
result.push(ch);
}
result
}
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;
}
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_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
}
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 {
use super::*;
use crate::types::ItemType;
fn make_item(text: &str, x: f32, y: f32, font_size: f32) -> TextItem {
TextItem {
text: text.to_string(),
x,
y,
width: text.len() as f32 * font_size * 0.5,
height: font_size,
font: "TestFont".to_string(),
font_size,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
}
}
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_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 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 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_size: 10.0,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: crate::types::ItemType::Text,
mcid: None,
});
}
}
let rects: Vec<crate::types::PdfRect> = page_rects
.iter()
.map(|&(x, y, w, h)| crate::types::PdfRect {
x,
y,
width: w,
height: h,
page: 1,
})
.collect();
let (tables, hints) = detect_tables_from_rects(&items, &rects, 1);
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_size: 10.0,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: crate::types::ItemType::Text,
mcid: None,
});
}
let rects: Vec<crate::types::PdfRect> = page_rects
.iter()
.map(|&(x, y, w, h)| crate::types::PdfRect {
x,
y,
width: w,
height: h,
page: 1,
})
.collect();
let (tables, hints) = detect_tables_from_rects(&items, &rects, 1);
assert!(tables.is_empty());
assert!(
hints.is_empty(),
"narrow header band (20pt) should not produce hint"
);
}
#[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()
);
}
}