use crate::types::TextItem;
use log::debug;
use super::cell_text::join_cell_items;
use super::financial::try_split_financial_item;
use super::grid::{
find_column_boundaries, find_column_index, find_row_boundaries, find_row_index,
recover_header_row,
};
use super::{Table, TableDetectionMode};
#[cfg(test)]
pub(crate) fn merge_adjacent_items(items: &[TextItem]) -> (Vec<TextItem>, Vec<Vec<usize>>) {
merge_adjacent_items_preserving(items, &std::collections::HashSet::new())
}
fn merge_adjacent_items_preserving(
items: &[TextItem],
preserved_indices: &std::collections::HashSet<usize>,
) -> (Vec<TextItem>, Vec<Vec<usize>>) {
if items.is_empty() {
return (vec![], vec![]);
}
let y_tolerance = 5.0;
let mut line_groups: Vec<(f32, Vec<(usize, &TextItem)>)> = Vec::new();
for (idx, item) in items.iter().enumerate() {
let found = line_groups
.iter_mut()
.find(|(y, _)| (item.y - *y).abs() < y_tolerance);
if let Some((_, group)) = found {
group.push((idx, item));
} else {
line_groups.push((item.y, vec![(idx, item)]));
}
}
for (_, group) in &mut line_groups {
group.sort_by(|a, b| {
a.1.x
.partial_cmp(&b.1.x)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
line_groups.sort_by(|a, b| b.0.total_cmp(&a.0));
let mut merged_items = Vec::new();
let mut index_map: Vec<Vec<usize>> = Vec::new();
for (_, group) in &line_groups {
let mut i = 0;
while i < group.len() {
let (first_idx, first_item) = group[i];
let mut text = first_item.text.clone();
let mut end_x = first_item.x + first_item.width;
let mut box_right = first_item.x + first_item.width;
let mut indices = vec![first_idx];
let x_gap_max = first_item.font_size * 0.5;
let mut j = i + 1;
while j < group.len() {
let (next_idx, next_item) = group[j];
if (next_item.font_size - first_item.font_size).abs() > first_item.font_size * 0.20
{
break;
}
if !first_item.is_upright() || !next_item.is_upright() {
break;
}
if first_item.advance_known != next_item.advance_known {
break;
}
let decoration_changes = indices.iter().any(|index| {
let merged_item = &items[*index];
next_item.is_underline != merged_item.is_underline
|| next_item.is_strikeout != merged_item.is_strikeout
});
if decoration_changes
&& (indices
.iter()
.any(|index| preserved_indices.contains(index))
|| preserved_indices.contains(&next_idx))
{
break;
}
let gap = next_item.x - end_x;
if gap > x_gap_max {
break;
}
if gap < -first_item.font_size * 0.5 {
break;
}
if gap > first_item.font_size * 0.08 {
text.push(' ');
}
text.push_str(&next_item.text);
end_x = next_item.x + next_item.width;
box_right = box_right.max(next_item.x + next_item.width);
indices.push(next_idx);
j += 1;
}
merged_items.push(TextItem {
text,
x: first_item.x,
y: first_item.y,
width: if first_item.advance_known {
end_x - first_item.x
} else {
box_right - first_item.x
},
height: first_item.height,
font: first_item.font.clone(),
font_tag: first_item.font_tag.clone(),
legacy_symbol_rewrite: indices
.iter()
.any(|&index| items[index].legacy_symbol_rewrite),
font_size: first_item.font_size,
page: first_item.page,
is_bold: first_item.is_bold,
is_italic: first_item.is_italic,
is_underline: first_item.is_underline,
is_strikeout: first_item.is_strikeout,
rotation: first_item.rotation,
advance_known: first_item.advance_known,
item_type: first_item.item_type.clone(),
mcid: first_item.mcid,
baseline_shift: if indices
.iter()
.all(|index| items[*index].baseline_shift == first_item.baseline_shift)
{
first_item.baseline_shift
} else {
0.0
},
});
index_map.push(indices);
i = j;
}
}
(merged_items, index_map)
}
fn expand_consolidated_items(items: &[TextItem]) -> (Vec<TextItem>, Vec<usize>) {
let mut expanded = Vec::with_capacity(items.len());
let mut index_map = Vec::with_capacity(items.len());
for (orig_idx, item) in items.iter().enumerate() {
if let Some(sub_items) = try_split_financial_item(item) {
for sub in sub_items {
expanded.push(sub);
index_map.push(orig_idx);
}
} else {
expanded.push(item.clone());
index_map.push(orig_idx);
}
}
(expanded, index_map)
}
#[derive(Clone)]
struct RedlineEditRegion {
x_ranges: Vec<(f32, f32)>,
y_min: f32,
y_max: f32,
spans_page_width: bool,
}
#[derive(Clone, Copy)]
struct UnderlinedTableColumn {
x_min: f32,
x_max: f32,
}
pub(crate) fn content_width(items: &[TextItem]) -> f32 {
let x_min = items
.iter()
.map(|item| item.x)
.fold(f32::INFINITY, f32::min);
let x_max = items
.iter()
.map(|item| item.x + item.width)
.fold(f32::NEG_INFINITY, f32::max);
(x_max - x_min).max(1.0)
}
fn redline_edit_regions(items: &[TextItem], page_width: f32) -> Vec<RedlineEditRegion> {
const ROW_DEDUP_TOLERANCE: f32 = 8.0;
const MAX_CLUSTER_GAP: f32 = 64.0;
const Y_PADDING: f32 = 36.0;
const X_PADDING: f32 = 12.0;
const PAGE_WIDTH_RATIO: f32 = 0.35;
const MAX_HORIZONTAL_GAP_RATIO: f32 = 0.20;
let mut strikeouts: Vec<&TextItem> = items.iter().filter(|item| item.is_strikeout).collect();
strikeouts.sort_by(|a, b| a.y.total_cmp(&b.y));
let mut rows: Vec<(f32, Vec<(f32, f32)>)> = Vec::new();
for item in strikeouts {
if let Some((_, x_ranges)) = rows
.last_mut()
.filter(|(y, _)| (item.y - *y).abs() <= ROW_DEDUP_TOLERANCE)
{
x_ranges.push((item.x, item.x + item.width));
} else {
rows.push((item.y, vec![(item.x, item.x + item.width)]));
}
}
let mut regions = Vec::new();
let mut start = 0;
while start < rows.len() {
let mut end = start + 1;
while end < rows.len() && rows[end].0 - rows[end - 1].0 <= MAX_CLUSTER_GAP {
end += 1;
}
if end - start >= 2 {
let mut x_ranges: Vec<(f32, f32)> = rows[start..end]
.iter()
.flat_map(|(_, x_ranges)| x_ranges.iter().copied())
.collect();
x_ranges.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut merged_ranges: Vec<(f32, f32)> = Vec::new();
for (x_min, x_max) in x_ranges {
if let Some((_, merged_max)) = merged_ranges.last_mut().filter(|(_, merged_max)| {
x_min - *merged_max <= page_width * MAX_HORIZONTAL_GAP_RATIO
}) {
*merged_max = merged_max.max(x_max);
} else {
merged_ranges.push((x_min, x_max));
}
}
let covered_width: f32 = merged_ranges
.iter()
.map(|(x_min, x_max)| x_max - x_min)
.sum();
for (x_min, x_max) in &mut merged_ranges {
*x_min -= X_PADDING;
*x_max += X_PADDING;
}
regions.push(RedlineEditRegion {
x_ranges: merged_ranges,
y_min: rows[start].0 - Y_PADDING,
y_max: rows[end - 1].0 + Y_PADDING,
spans_page_width: covered_width >= page_width * PAGE_WIDTH_RATIO,
});
}
start = end;
}
for index in 1..regions.len() {
let (previous, current) = regions.split_at_mut(index);
let previous = &mut previous[index - 1];
let current = &mut current[0];
if previous.y_max >= current.y_min {
let boundary = (previous.y_max + current.y_min) / 2.0;
previous.y_max = boundary;
current.y_min = boundary;
}
}
regions
}
fn overlaps_redline_x(item: &TextItem, region: &RedlineEditRegion) -> bool {
let range_index = region
.x_ranges
.partition_point(|(_, x_max)| *x_max < item.x);
region
.x_ranges
.get(range_index)
.is_some_and(|(x_min, _)| item.x + item.width >= *x_min)
}
fn has_distinct_rows(
items: &[&TextItem],
underlined_only: bool,
required: usize,
tolerance: f32,
) -> bool {
debug_assert!(required <= 3);
let mut rows = [0.0; 3];
let mut row_count = 0;
for item in items {
if underlined_only && !item.is_underline {
continue;
}
if rows[..row_count]
.iter()
.all(|row| (item.y - row).abs() > tolerance)
{
rows[row_count] = item.y;
row_count += 1;
if row_count == required {
return true;
}
}
}
false
}
fn underlined_table_columns(
items: &[TextItem],
redline_regions: &[RedlineEditRegion],
) -> Vec<Vec<UnderlinedTableColumn>> {
const X_ALIGNMENT_TOLERANCE: f32 = 4.0;
const ROW_DEDUP_TOLERANCE: f32 = 8.0;
let mut live_items: Vec<&TextItem> = items.iter().filter(|item| !item.is_strikeout).collect();
live_items.sort_by(|a, b| a.x.total_cmp(&b.x));
let mut candidates_by_region: Vec<Vec<&TextItem>> = vec![Vec::new(); redline_regions.len()];
for item in live_items {
if let Some(region_index) = redline_region_at_y(redline_regions, item.y) {
if overlaps_redline_x(item, &redline_regions[region_index]) {
candidates_by_region[region_index].push(item);
}
}
}
let mut columns_by_region = Vec::with_capacity(redline_regions.len());
for (region, candidates) in redline_regions.iter().zip(candidates_by_region) {
let mut columns: Vec<UnderlinedTableColumn> = Vec::new();
let mut start = 0;
while start < candidates.len() {
let mut end = start + 1;
while end < candidates.len()
&& candidates[end].x - candidates[start].x <= X_ALIGNMENT_TOLERANCE
{
end += 1;
}
let aligned_items = &candidates[start..end];
let enough_live_rows = has_distinct_rows(aligned_items, false, 3, ROW_DEDUP_TOLERANCE);
let required_underlined_rows = if region.spans_page_width { 2 } else { 1 };
let enough_underlined_rows = has_distinct_rows(
aligned_items,
true,
required_underlined_rows,
ROW_DEDUP_TOLERANCE,
);
if enough_live_rows && enough_underlined_rows {
let x_min = candidates[start].x - X_ALIGNMENT_TOLERANCE;
let x_max = candidates[end - 1].x + X_ALIGNMENT_TOLERANCE;
if let Some(column) = columns.last_mut().filter(|column| column.x_max >= x_min) {
column.x_max = column.x_max.max(x_max);
} else {
columns.push(UnderlinedTableColumn { x_min, x_max });
}
}
start = end;
}
columns_by_region.push(columns);
}
columns_by_region
}
fn redline_region_at_y(redline_regions: &[RedlineEditRegion], y: f32) -> Option<usize> {
let region_index = redline_regions.partition_point(|region| region.y_max < y);
redline_regions
.get(region_index)
.filter(|region| y >= region.y_min)
.map(|_| region_index)
}
fn is_heuristic_table_evidence(
item: &TextItem,
redline_regions: &[RedlineEditRegion],
underlined_table_columns: &[Vec<UnderlinedTableColumn>],
) -> bool {
if item.is_strikeout {
return false;
}
let Some(region_index) = redline_region_at_y(redline_regions, item.y) else {
return true;
};
let region = &redline_regions[region_index];
let columns = &underlined_table_columns[region_index];
if region.spans_page_width && columns.is_empty() {
return false;
}
let overlaps_x = overlaps_redline_x(item, region);
!overlaps_x || is_revised_table_cell(item, columns)
}
fn is_revised_table_cell(
item: &TextItem,
underlined_table_columns: &[UnderlinedTableColumn],
) -> bool {
let column_index = underlined_table_columns.partition_point(|column| column.x_max < item.x);
underlined_table_columns
.get(column_index)
.is_some_and(|column| item.x >= column.x_min)
}
fn revised_table_cell_indices(
items: &[TextItem],
redline_regions: &[RedlineEditRegion],
underlined_table_columns: &[Vec<UnderlinedTableColumn>],
) -> std::collections::HashSet<usize> {
items
.iter()
.enumerate()
.filter_map(|(item_index, item)| {
let region_index = redline_region_at_y(redline_regions, item.y)?;
let region = &redline_regions[region_index];
(item.is_underline
&& overlaps_redline_x(item, region)
&& is_revised_table_cell(item, &underlined_table_columns[region_index]))
.then_some(item_index)
})
.collect()
}
struct ScriptBodyIndex<'a> {
by_y: Vec<(f32, &'a TextItem)>,
max_window: f32,
}
impl<'a> ScriptBodyIndex<'a> {
fn new(items: &'a [TextItem]) -> Self {
let mut by_y: Vec<(f32, &TextItem)> = items
.iter()
.filter(|i| i.font_size >= 6.0 * 1.2)
.map(|i| (i.y, i))
.collect();
by_y.sort_by(|a, b| a.0.total_cmp(&b.0));
let max_window = by_y
.iter()
.map(|(_, i)| i.font_size * 0.8)
.fold(0.0f32, f32::max);
Self { by_y, max_window }
}
fn is_script_attachment(&self, small: &TextItem, min_anchor_size: f32) -> bool {
let attach_gap = small.font_size.max(4.0) * 0.6;
let lo = self
.by_y
.partition_point(|(y, _)| *y < small.y - self.max_window);
self.by_y[lo..]
.iter()
.take_while(|(y, _)| *y <= small.y + self.max_window)
.any(|(_, body)| {
let dy = (small.y - body.y).abs();
body.font_size >= small.font_size * 1.2
&& body.font_size >= min_anchor_size
&& dy > body.font_size * 0.05
&& dy <= body.font_size * 0.8
&& {
let gap_after_body = small.x - (body.x + body.width);
let gap_before_body = body.x - (small.x + small.width);
(-attach_gap..=attach_gap).contains(&gap_after_body)
|| (-attach_gap..=attach_gap).contains(&gap_before_body)
}
})
}
}
pub fn detect_tables(items: &[TextItem], base_font_size: f32, skip_body_font: bool) -> Vec<Table> {
detect_tables_with_page_width(items, base_font_size, skip_body_font, content_width(items))
}
pub(crate) fn detect_tables_with_page_width(
items: &[TextItem],
base_font_size: f32,
skip_body_font: bool,
page_width: f32,
) -> Vec<Table> {
if items.len() < 6 {
return vec![];
}
let redline_regions = redline_edit_regions(items, page_width);
let underlined_table_columns = underlined_table_columns(items, &redline_regions);
let source_evidence: Vec<bool> = items
.iter()
.map(|item| is_heuristic_table_evidence(item, &redline_regions, &underlined_table_columns))
.collect();
let revised_table_cells =
revised_table_cell_indices(items, &redline_regions, &underlined_table_columns);
let (merged_items, merge_map) = merge_adjacent_items_preserving(items, &revised_table_cells);
let (expanded_items, expand_map) = expand_consolidated_items(&merged_items);
let expanded_evidence: Vec<bool> = expand_map
.iter()
.map(|&merged_index| {
merge_map[merged_index]
.iter()
.all(|&source_index| source_evidence[source_index])
})
.collect();
let items = &expanded_items[..];
let mut tables = Vec::new();
let mut claimed_indices = std::collections::HashSet::new();
let table_font_threshold = base_font_size * 0.90;
let script_index = ScriptBodyIndex::new(items);
let script_flags: Vec<bool> = items
.iter()
.map(|item| script_index.is_script_attachment(item, 0.0))
.collect();
let body_script_flags: Vec<bool> = items
.iter()
.map(|item| script_index.is_script_attachment(item, base_font_size * 1.15))
.collect();
let table_candidates: Vec<(usize, &TextItem)> = items
.iter()
.enumerate()
.filter(|(index, item)| {
expanded_evidence[*index]
&& item.font_size <= table_font_threshold
&& item.font_size >= 6.0
})
.collect();
if table_candidates.len() >= 6 {
let region_evidence: Vec<(usize, &TextItem)> = table_candidates
.iter()
.filter(|(idx, _)| !script_flags[*idx])
.cloned()
.collect();
let regions = find_table_regions(®ion_evidence);
for (y_min, y_max) in regions {
let region_items: Vec<(usize, &TextItem)> = table_candidates
.iter()
.filter(|(_, item)| item.y >= y_min && item.y <= y_max)
.cloned()
.collect();
if region_items.len() < 6 {
continue;
}
if let Some(mut table) =
detect_table_in_region(®ion_items, TableDetectionMode::SmallFont, &|i| {
script_flags[i]
})
{
recover_header_row(&mut table, items, table_font_threshold);
try_add_label_column(
&mut table,
&table_candidates,
&claimed_indices,
y_min,
y_max,
);
for &idx in &table.item_indices {
claimed_indices.insert(idx);
}
tables.push(table);
}
}
}
if !skip_body_font {
let body_font_low = base_font_size * 0.85;
let body_font_high = base_font_size * 1.05;
let body_candidates: Vec<(usize, &TextItem)> = items
.iter()
.enumerate()
.filter(|(idx, item)| {
!claimed_indices.contains(idx)
&& expanded_evidence[*idx]
&& item.font_size >= body_font_low
&& item.font_size <= body_font_high
&& item.font_size >= 6.0
})
.collect();
log::debug!(
"body-font pass: {} candidates (base={:.1}, range={:.1}..{:.1})",
body_candidates.len(),
base_font_size,
body_font_low,
body_font_high,
);
if body_candidates.len() >= 6 {
let region_evidence: Vec<(usize, &TextItem)> = body_candidates
.iter()
.filter(|(idx, _)| !body_script_flags[*idx])
.cloned()
.collect();
let regions = find_table_regions_strict(®ion_evidence);
log::debug!("body-font: {} strict regions found", regions.len());
for (y_min, y_max, _x_min, _x_max) in ®ions {
let region_items: Vec<(usize, &TextItem)> = body_candidates
.iter()
.filter(|(_, item)| item.y >= *y_min && item.y <= *y_max)
.cloned()
.collect();
log::debug!(
" region y={:.0}..{:.0}: {} items of {} candidates",
y_min,
y_max,
region_items.len(),
body_candidates.len()
);
if region_items.len() < 6 {
continue;
}
if let Some(table) =
detect_table_in_region(®ion_items, TableDetectionMode::BodyFont, &|i| {
body_script_flags[i]
})
{
tables.push(table);
}
}
}
}
for table in &mut tables {
let original_indices: std::collections::HashSet<usize> = table
.item_indices
.iter()
.flat_map(|&exp_idx| {
let merged_idx = expand_map[exp_idx];
merge_map[merged_idx].iter().copied()
})
.collect();
table.item_indices = original_indices.into_iter().collect();
table.item_indices.sort_unstable();
log::debug!(
" heuristic table: {}x{}, {} item indices",
table.rows.len(),
table.columns.len(),
table.item_indices.len()
);
}
tables
}
fn find_table_regions(items: &[(usize, &TextItem)]) -> Vec<(f32, f32)> {
if items.is_empty() {
return vec![];
}
let mut y_positions: Vec<f32> = items.iter().map(|(_, i)| i.y).collect();
y_positions.sort_by(|a, b| a.total_cmp(b));
let mut regions = Vec::new();
let gap_threshold = 30.0;
let mut region_start = y_positions[0];
let mut region_end = y_positions[0];
let mut region_count = 1;
for &y in &y_positions[1..] {
if y - region_end > gap_threshold {
if region_count >= 4 {
regions.push((region_start - 5.0, region_end + 5.0));
}
region_start = y;
region_end = y;
region_count = 1;
} else {
region_end = y;
region_count += 1;
}
}
if region_count >= 4 {
regions.push((region_start - 5.0, region_end + 5.0));
}
regions
}
fn find_table_regions_strict(items: &[(usize, &TextItem)]) -> Vec<(f32, f32, f32, f32)> {
if items.is_empty() {
return vec![];
}
let mut row_groups: Vec<(f32, Vec<f32>)> = Vec::new();
for (_, item) in items {
let mut found = false;
for (center, x_positions) in row_groups.iter_mut() {
if (item.y - *center).abs() < 8.0 {
x_positions.push(item.x);
found = true;
break;
}
}
if !found {
row_groups.push((item.y, vec![item.x]));
}
}
let mut qualifying_rows: Vec<(f32, Vec<f32>)> = Vec::new(); for (y, x_positions) in &row_groups {
let mut sorted_xs = x_positions.clone();
sorted_xs.sort_by(|a, b| a.total_cmp(b));
if sorted_xs.is_empty() {
continue;
}
let mut cluster_starts: Vec<f32> = vec![sorted_xs[0]];
let mut last_x = sorted_xs[0];
for &x in &sorted_xs[1..] {
if x - last_x > 20.0 {
cluster_starts.push(x);
last_x = x;
}
}
if cluster_starts.len() >= 2 {
qualifying_rows.push((*y, cluster_starts));
}
}
log::debug!(
"find_table_regions_strict: {} row groups, {} qualifying (2+ X-clusters)",
row_groups.len(),
qualifying_rows.len()
);
if qualifying_rows.len() < 3 {
return vec![];
}
qualifying_rows.sort_by(|a, b| a.0.total_cmp(&b.0));
let max_gap = if qualifying_rows.len() >= 3 {
let mut gaps: Vec<f32> = qualifying_rows
.windows(2)
.map(|w| (w[1].0 - w[0].0).abs())
.collect();
gaps.sort_by(|a, b| a.total_cmp(b));
let median_gap = gaps[gaps.len() / 2];
(median_gap * 3.0).max(25.0)
} else {
25.0
};
let mut candidate_regions: Vec<Vec<&(f32, Vec<f32>)>> = Vec::new();
let mut current_region: Vec<&(f32, Vec<f32>)> = vec![&qualifying_rows[0]];
for row in qualifying_rows.iter().skip(1) {
let prev_y = current_region.last().unwrap().0;
if row.0 - prev_y > max_gap {
if current_region.len() >= 3 {
candidate_regions.push(current_region);
}
current_region = vec![row];
} else {
current_region.push(row);
}
}
if current_region.len() >= 3 {
candidate_regions.push(current_region);
}
let mut regions = Vec::new();
for region_rows in &candidate_regions {
let num_rows = region_rows.len();
let mut total_score = 0.0f32;
let mut pair_count = 0u32;
let tolerance = 10.0f32;
for i in 0..num_rows {
for j in (i + 1)..num_rows {
let centers_a = ®ion_rows[i].1;
let centers_b = ®ion_rows[j].1;
let matches_a = centers_a
.iter()
.filter(|&&a| centers_b.iter().any(|&b| (a - b).abs() < tolerance))
.count();
let matches_b = centers_b
.iter()
.filter(|&&b| centers_a.iter().any(|&a| (a - b).abs() < tolerance))
.count();
let max_len = centers_a.len().max(centers_b.len());
if max_len > 0 {
total_score += (matches_a + matches_b) as f32 / (2 * max_len) as f32;
pair_count += 1;
}
}
}
let avg_score = if pair_count > 0 {
total_score / pair_count as f32
} else {
0.0
};
log::debug!(
" candidate region: {} rows, avg alignment score={:.2}",
num_rows,
avg_score
);
if avg_score >= 0.5 {
let y_min = region_rows.first().unwrap().0;
let y_max = region_rows.last().unwrap().0;
let x_min = region_rows
.iter()
.flat_map(|(_, clusters)| clusters.iter())
.cloned()
.fold(f32::INFINITY, f32::min);
let x_max = region_rows
.iter()
.flat_map(|(_, clusters)| clusters.iter())
.cloned()
.fold(f32::NEG_INFINITY, f32::max);
regions.push((y_min - 5.0, y_max + 5.0, x_min - 15.0, x_max + 50.0));
}
}
regions
}
fn detect_table_in_region(
items: &[(usize, &TextItem)],
mode: TableDetectionMode,
is_script: &dyn Fn(usize) -> bool,
) -> Option<Table> {
let geometry_items: Vec<(usize, &TextItem)> = items
.iter()
.filter(|(idx, _)| !is_script(*idx))
.cloned()
.collect();
if geometry_items.is_empty() {
return None;
}
let columns = find_column_boundaries(&geometry_items, mode);
let min_cols = 2;
if columns.len() < min_cols || columns.len() > 25 {
log::debug!(
" detect_table_in_region: rejected {} cols (need {}..25)",
columns.len(),
min_cols
);
return None;
}
let rows = find_row_boundaries(&geometry_items);
let min_rows = 2;
if rows.len() < min_rows {
log::debug!(
" detect_table_in_region: rejected {} rows (need {}+)",
rows.len(),
min_rows
);
return None;
}
log::debug!(
" detect_table_in_region: {} cols, {} rows, {} items",
columns.len(),
rows.len(),
items.len()
);
let col_alignment = check_column_alignment(items, &columns, mode);
let min_alignment = match mode {
TableDetectionMode::SmallFont => 0.5,
TableDetectionMode::BodyFont => 0.7,
};
if col_alignment < min_alignment {
log::debug!(
" detect_table_in_region: rejected alignment {:.2} < {:.2} ({} cols, {} rows)",
col_alignment,
min_alignment,
columns.len(),
rows.len()
);
return None;
}
let mut cell_items: Vec<Vec<Vec<&TextItem>>> =
vec![vec![Vec::new(); columns.len()]; rows.len()];
let mut item_indices = Vec::new();
for (idx, item) in items {
let col = find_column_index(&columns, item.x);
let row = find_row_index(&rows, item.line_y());
if super::crosses_other_rows(item, &rows, row) {
continue;
}
if let (Some(col), Some(row)) = (col, row) {
cell_items[row][col].push(item);
item_indices.push(*idx);
}
}
let (first_table_row, excluded_items) = find_first_table_row(&cell_items, &rows, items);
let item_indices: Vec<usize> = item_indices
.into_iter()
.filter(|idx| !excluded_items.contains(idx))
.collect();
let (rows, mut cell_items) = if first_table_row > 0 {
let new_rows = rows[first_table_row..].to_vec();
let new_cell_items = cell_items[first_table_row..].to_vec();
(new_rows, new_cell_items)
} else {
(rows, cell_items)
};
let mut cells: Vec<Vec<String>> = Vec::with_capacity(rows.len());
for row_items in &mut cell_items {
let mut row_cells = Vec::with_capacity(columns.len());
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| a.x.total_cmp(&b.x));
}
let text = join_cell_items(col_items);
row_cells.push(text);
}
cells.push(row_cells);
}
if matches!(mode, TableDetectionMode::SmallFont) {
let nonempty_cells: Vec<&String> =
cells.iter().flatten().filter(|c| !c.is_empty()).collect();
if rows.len() <= 2
&& !nonempty_cells.is_empty()
&& nonempty_cells
.iter()
.all(|c| c.len() <= 2 && c.chars().all(|ch| ch.is_ascii_digit()))
{
log::debug!(
" validation 0 fail: tiny all-numeric fragment ({} cells)",
nonempty_cells.len()
);
return None;
}
}
let rows_with_first_col = cells.iter().filter(|row| !row[0].is_empty()).count();
let is_narrow_toc = columns.len() <= 5 && is_table_of_contents(&cells);
if rows_with_first_col < rows.len() / 4 && !is_narrow_toc {
log::debug!(
" validation 1 fail: {}/{} rows have first col",
rows_with_first_col,
rows.len()
);
return None;
}
let rows_with_multi_cols = cells
.iter()
.filter(|row| row.iter().filter(|c| !c.is_empty()).count() >= 2)
.count();
let multi_col_threshold = match mode {
TableDetectionMode::SmallFont => (rows.len() / 3).max(1), TableDetectionMode::BodyFont => (rows.len() / 2).max(1), };
if rows_with_multi_cols < multi_col_threshold {
log::debug!(
" validation 2 fail: {}/{} rows multi-col (need {})",
rows_with_multi_cols,
rows.len(),
multi_col_threshold
);
return None;
}
let max_rows = match mode {
TableDetectionMode::SmallFont => 200,
TableDetectionMode::BodyFont => 200,
};
if rows.len() > max_rows {
return None;
}
let total_filled: usize = cells
.iter()
.map(|row| row.iter().filter(|c| !c.is_empty()).count())
.sum();
let avg_cells_per_row = total_filled as f32 / rows.len() as f32;
let min_avg_cells = 1.5;
if avg_cells_per_row < min_avg_cells {
log::debug!(
" validation 4 fail: avg_cells={:.1} < {:.1}",
avg_cells_per_row,
min_avg_cells
);
return None;
}
if is_key_value_layout(&cells) {
log::debug!(" validation 5 fail: key-value layout");
return None;
}
if !has_consistent_columns(&cells) {
log::debug!(" validation 6 fail: inconsistent columns");
return None;
}
if !has_table_like_content(&cells, mode) {
log::debug!(" validation 7 fail: no table-like content");
return None;
}
if is_paragraph_content(&cells) && !is_narrow_toc {
log::debug!(" validation 9 fail: paragraph content");
return None;
}
if is_inline_leader_index(&cells) {
log::debug!(" validation 9 fail: inline-leader index");
return None;
}
debug!(
"table detected: {} rows x {} cols, {} items",
rows.len(),
columns.len(),
item_indices.len()
);
Some(Table::new(columns, rows, cells, item_indices))
}
fn is_key_value_layout(cells: &[Vec<String>]) -> bool {
if cells.is_empty() {
return false;
}
let num_cols = cells[0].len();
let mut label_like_first_col = 0;
let mut rows_with_two_or_less = 0;
for row in cells {
let filled_count = row.iter().filter(|c| !c.is_empty()).count();
if filled_count <= 2 {
rows_with_two_or_less += 1;
}
let first = row.first().map(|s| s.trim()).unwrap_or("");
if first.ends_with(':')
|| (first.len() > 3
&& first
.chars()
.all(|c| c.is_uppercase() || c.is_whitespace() || c == '(' || c == ')'))
{
label_like_first_col += 1;
}
}
let pct_two_or_less = rows_with_two_or_less as f32 / cells.len() as f32;
let pct_label_like = label_like_first_col as f32 / cells.len() as f32;
pct_two_or_less > 0.7 && pct_label_like > 0.5 && num_cols <= 6
}
fn has_consistent_columns(cells: &[Vec<String>]) -> bool {
if cells.len() < 3 {
return true; }
let filled_counts: Vec<usize> = cells
.iter()
.map(|row| row.iter().filter(|c| !c.is_empty()).count())
.collect();
let mut count_freq: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
for &count in &filled_counts {
*count_freq.entry(count).or_insert(0) += 1;
}
let most_common_count = count_freq
.iter()
.max_by(|(count_a, freq_a), (count_b, freq_b)| {
freq_a.cmp(freq_b).then_with(|| count_a.cmp(count_b))
})
.map(|(count, _)| *count)
.unwrap_or(0);
let num_cols = cells[0].len();
let tolerance = if num_cols > 15 { num_cols / 4 } else { 2 };
let consistent_rows = filled_counts
.iter()
.filter(|&&c| {
c >= most_common_count.saturating_sub(tolerance) && c <= most_common_count + tolerance
})
.count();
let min_ratio = if num_cols > 15 { 0.25 } else { 0.40 };
consistent_rows as f32 / cells.len() as f32 > min_ratio
}
fn has_table_like_content(cells: &[Vec<String>], mode: TableDetectionMode) -> bool {
let mut data_like_cells = 0;
let mut total_cells = 0;
for row in cells.iter().skip(1) {
for cell in row {
let trimmed = cell.trim();
if !trimmed.is_empty() {
total_cells += 1;
if looks_like_table_data(trimmed) {
data_like_cells += 1;
}
}
}
}
if total_cells == 0 {
return false;
}
let pct_data = data_like_cells as f32 / total_cells as f32;
let num_cols = cells.first().map(|r| r.len()).unwrap_or(0);
let min_pct = match mode {
TableDetectionMode::SmallFont => 0.2,
TableDetectionMode::BodyFont => 0.3,
};
if pct_data > min_pct || num_cols >= 3 {
return true;
}
if num_cols == 2 && matches!(mode, TableDetectionMode::BodyFont) {
let non_empty: Vec<usize> = cells
.iter()
.skip(1)
.flat_map(|row| row.iter())
.filter(|c| !c.trim().is_empty())
.map(|c| c.trim().len())
.collect();
if !non_empty.is_empty() {
let avg_len = non_empty.iter().sum::<usize>() / non_empty.len();
return avg_len <= 25;
}
}
false
}
fn looks_like_table_data(s: &str) -> bool {
let s = s.trim();
if s.is_empty() {
return false;
}
if looks_like_number(s) {
return true;
}
if s.len() <= 10
&& s.chars().filter(|c| c.is_ascii_digit()).count() >= 4
&& (s.contains('/') || s.contains('-'))
&& s.chars()
.all(|c| c.is_ascii_digit() || c == '/' || c == '-')
{
return true;
}
if s.len() <= 10
&& s.chars().all(|c| c.is_alphanumeric())
&& s.chars().any(|c| c.is_ascii_digit())
{
return true;
}
let has_number = s.chars().any(|c| c.is_ascii_digit());
let has_unit = s.contains('°')
|| s.contains('V')
|| s.contains('A')
|| s.contains("Hz")
|| s.contains("mA")
|| s.contains("µ")
|| s.contains("pin")
|| s.contains("MHz")
|| s.contains("kHz");
if has_number && has_unit {
return true;
}
if s.contains('(') && s.contains(')') && s.chars().any(|c| c.is_ascii_digit()) {
return true;
}
if (s.contains("°C") || s.contains("°F")) && s.contains("to") {
return true;
}
false
}
fn looks_like_number(s: &str) -> bool {
let s = s.trim();
if s.is_empty() {
return false;
}
s.chars()
.all(|c| c.is_ascii_digit() || c == '.' || c == ',' || c == '-' || c == '+')
&& s.chars().any(|c| c.is_ascii_digit())
}
pub fn is_table_of_contents(cells: &[Vec<String>]) -> bool {
is_dot_leader_toc(cells) || is_tabular_toc(cells) || is_page_number_toc(cells)
}
fn page_number_value(token: &str) -> Option<u32> {
let t = token.trim();
if t.is_empty() {
return None;
}
if t.chars().all(|c| c.is_ascii_digit()) && t.len() <= 4 {
return t.parse().ok();
}
super::canonical_roman_value(t)
}
pub(super) fn is_page_number_toc(cells: &[Vec<String>]) -> bool {
let num_cols = cells.first().map(|r| r.len()).unwrap_or(0);
if !(2..=3).contains(&num_cols) || cells.len() < 2 {
return false;
}
let page_col = num_cols - 1;
let has_leader_dots = cells.iter().any(|row| {
let page_cell_leader = row.get(page_col).is_some_and(|c| {
let t = c.trim();
t.starts_with("..") || t.starts_with('\u{2026}')
});
let dots_only_cell = row.iter().any(|c| {
let t = c.trim();
t.len() >= 2 && t.chars().all(|ch| ch == '.' || ch == '\u{2026}')
});
page_cell_leader || dots_only_cell
});
fn clean_page_cell(cell: &str, has_leader_dots: bool) -> &str {
if has_leader_dots {
cell.trim_start_matches(['.', '\u{2026}', ' '])
} else {
cell.trim()
}
}
if cells.len() < 5 {
let last_col = num_cols - 1;
let vals: Vec<u32> = cells
.iter()
.filter_map(|row| {
let cell = row.get(last_col).map(String::as_str).unwrap_or("");
page_number_value(clean_page_cell(cell, has_leader_dots))
})
.collect();
let titles: Vec<&str> = cells
.iter()
.filter_map(|row| row.first())
.map(|c| c.trim())
.filter(|c| !c.is_empty())
.collect();
let titles_ok = titles.len() >= 2
&& titles.iter().all(|c| {
c.split_whitespace().count() >= 2 && c.chars().any(|ch| ch.is_alphabetic())
});
let section_numbered = |title: &str| {
let mut words = title.split_whitespace();
let first = words.next().unwrap_or("");
let leading_ordinal = first.len() <= 4
&& first.ends_with(['.', ')'])
&& !first[..first.len() - 1].is_empty()
&& first[..first.len() - 1].chars().all(|c| c.is_ascii_digit());
let dotted_section = title.split_whitespace().any(|tok| {
let tok = tok.trim_end_matches([':', '.']);
let groups: Vec<&str> = tok.split('.').collect();
groups.len() >= 2
&& groups.iter().all(|g| {
!g.is_empty() && g.len() <= 3 && g.chars().all(|c| c.is_ascii_digit())
})
});
leading_ordinal || dotted_section
};
if !has_leader_dots && !titles.iter().all(|t| section_numbered(t)) {
return false;
}
return vals.len() == cells.len() && titles_ok && vals.windows(2).all(|w| w[1] > w[0]);
}
let last = num_cols - 1;
let first_last = cells[0].get(last).map(|s| s.trim()).unwrap_or("");
if page_number_value(clean_page_cell(first_last, has_leader_dots)).is_none() {
return false;
}
let mut filled = 0u32;
let mut page_vals: Vec<u32> = Vec::new();
for row in cells {
let cell = row.get(last).map(|s| s.trim()).unwrap_or("");
if cell.is_empty() {
continue;
}
filled += 1;
if let Some(v) = page_number_value(clean_page_cell(cell, has_leader_dots)) {
page_vals.push(v);
}
}
if filled < 4 || (page_vals.len() as f32) < 0.7 * filled as f32 {
return false;
}
let text_first = cells
.iter()
.filter(|row| {
row.first()
.is_some_and(|c| c.chars().any(|ch| ch.is_alphabetic()))
})
.count();
if (text_first as f32) < 0.6 * cells.len() as f32 {
return false;
}
if page_vals.len() < 2 {
return false;
}
let non_decreasing = page_vals.windows(2).filter(|w| w[1] >= w[0]).count();
if (non_decreasing as f32) < 0.7 * (page_vals.len() - 1) as f32 {
return false;
}
let min = *page_vals.iter().min().unwrap();
let max = *page_vals.iter().max().unwrap();
let span = max.saturating_sub(min);
if span > page_vals.len() as u32 {
return true;
}
let dense_consecutive = (span as usize) + 1 == page_vals.len() && {
let mut sorted = page_vals.clone();
sorted.sort_unstable();
sorted.dedup();
sorted.len() == page_vals.len()
};
if !dense_consecutive {
return true;
}
let (total_words, titled_rows) = cells
.iter()
.filter_map(|row| row.first())
.filter(|c| c.chars().any(|ch| ch.is_alphabetic()))
.fold((0usize, 0usize), |(w, n), c| {
(
w + c
.split_whitespace()
.filter(|t| t.chars().any(|ch| ch.is_alphabetic()))
.count(),
n + 1,
)
});
titled_rows > 0 && (total_words as f32) / titled_rows as f32 >= 1.8
}
pub(super) fn is_dot_leader_toc(cells: &[Vec<String>]) -> bool {
has_structural_dot_leader(cells) || is_inline_leader_index(cells)
}
fn has_structural_dot_leader(cells: &[Vec<String>]) -> bool {
if cells.is_empty() {
return false;
}
let structural_rows = cells.iter().filter(|row| row_has_dot_leader(row)).count();
structural_rows as f32 / cells.len() as f32 >= 0.3
}
pub(super) fn is_inline_leader_index(cells: &[Vec<String>]) -> bool {
let mut inline_cells = 0;
let mut total_nonempty = 0;
for row in cells {
for cell in row {
let trimmed = cell.trim();
if trimmed.is_empty() {
continue;
}
total_nonempty += 1;
if cell_is_inline_leader(trimmed) {
inline_cells += 1;
}
}
}
total_nonempty >= 4 && inline_cells as f32 / total_nonempty as f32 >= 0.25
}
fn row_has_dot_leader(row: &[String]) -> bool {
let has_page_number = row.iter().any(|c| row_cell_is_page_number(c));
for (ci, cell) in row.iter().enumerate() {
let trimmed = cell.trim();
let dot_count = trimmed.chars().filter(|&c| c == '.').count();
let is_mostly_dots = dot_count >= 3
&& dot_count > trimmed.len() / 2
&& trimmed.chars().all(|c| c == '.' || c.is_whitespace());
if is_mostly_dots {
let has_label_left = row[..ci].iter().any(|c| {
let t = c.trim();
!t.is_empty() && t.chars().any(|ch| ch.is_alphabetic())
});
if has_label_left && has_page_number {
return true;
}
continue;
}
if has_page_number && cell_has_trailing_leader(trimmed) {
return true;
}
}
false
}
fn cell_has_trailing_leader(cell: &str) -> bool {
let trimmed = cell.trim_end();
if !trimmed.ends_with('.') {
return false;
}
let without_dots = trimmed.trim_end_matches('.');
let dot_run = trimmed.len() - without_dots.len();
if dot_run < 3 {
return false;
}
without_dots.ends_with(' ') && without_dots.trim().chars().any(|c| c.is_alphabetic())
}
fn row_cell_is_page_number(cell: &str) -> bool {
let t = cell.trim();
if t.is_empty() {
return false;
}
if looks_like_section_page_id(t) {
return true;
}
let parts: Vec<&str> = t.split(", ").collect();
parts
.iter()
.all(|p| !p.is_empty() && p.len() <= 4 && p.chars().all(|c| c.is_ascii_digit()))
}
fn cell_is_inline_leader(cell: &str) -> bool {
let cell = cell.trim();
let idx = match cell.match_indices("...").next() {
Some((i, _)) => i,
None => return false,
};
let before = &cell[..idx];
let after_dots = &cell[idx + 3..];
let after = after_dots.trim_start_matches('.');
let before_ok = before.is_empty() || before.ends_with(' ');
let after_ok = after.starts_with(' ') || after.is_empty();
if !before_ok || !after_ok {
return false;
}
let after_trim = after.trim();
if after_trim.is_empty() {
return false;
}
let tail_numeric = after_trim
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, ',' | ' ' | '.' | '-' | '$'))
&& after_trim.chars().any(|c| c.is_ascii_digit());
if !tail_numeric {
return false;
}
before.chars().any(|c| c.is_alphabetic()) || before.trim().is_empty()
}
pub(super) fn is_tabular_toc(cells: &[Vec<String>]) -> bool {
if cells.is_empty() {
return false;
}
let num_cols = cells[0].len();
if num_cols < 2 || cells.len() < 4 {
return false;
}
let section_rows = cells
.iter()
.filter(|row| {
row.iter()
.find(|c| !c.trim().is_empty())
.is_some_and(|c| starts_with_section_number(c.trim()))
})
.count();
let last_col = num_cols - 1;
let (last_filled, last_page_num) = cells.iter().fold((0u32, 0u32), |(f, n), row| {
let cell = row.get(last_col).map(|s| s.trim()).unwrap_or("");
if cell.is_empty() {
return (f, n);
}
let is_page_nums = cell
.split_whitespace()
.all(|tok| !tok.is_empty() && tok.chars().all(|c| c.is_ascii_digit()));
(f + 1, n + if is_page_nums { 1 } else { 0 })
});
let section_ratio = section_rows as f32 / cells.len() as f32;
let page_num_last_ratio = if last_filled > 0 {
last_page_num as f32 / last_filled as f32
} else {
0.0
};
section_ratio >= 0.6 && last_filled >= 3 && page_num_last_ratio >= 0.7
}
fn looks_like_section_page_id(s: &str) -> bool {
let ok = s
.chars()
.all(|c| c.is_ascii_digit() || c.is_ascii_uppercase() || c == '-');
ok && s.chars().any(|c| c.is_ascii_digit())
}
fn starts_with_section_number(s: &str) -> bool {
let Some(first) = s.split_whitespace().next() else {
return false;
};
let first = first.trim_end_matches('.');
let parts: Vec<&str> = first.split('.').collect();
if parts.len() < 2 || parts.len() > 6 {
return false;
}
parts
.iter()
.all(|p| !p.is_empty() && p.len() <= 3 && p.chars().all(|c| c.is_ascii_digit()))
}
fn is_paragraph_content(cells: &[Vec<String>]) -> bool {
if cells.is_empty() {
return false;
}
let num_cols = cells[0].len();
let total_cells = cells.len() * num_cols;
if total_cells == 0 {
return false;
}
let filled: Vec<&str> = cells
.iter()
.flat_map(|r| r.iter())
.map(|c| c.trim())
.filter(|c| !c.is_empty())
.collect();
let total_filled = filled.len();
if total_filled < 4 {
return false;
}
let empty_ratio = 1.0 - (total_filled as f32 / total_cells as f32);
let hyphen_breaks = filled
.iter()
.filter(|c| {
c.ends_with('-') && c.len() > 1 && {
let mut chars = c.chars().rev();
chars.next(); chars.next().is_some_and(|ch| ch.is_alphabetic())
}
})
.count();
let hyphen_ratio = hyphen_breaks as f32 / total_filled as f32;
if hyphen_ratio > 0.03 {
return true;
}
if empty_ratio > 0.55 && cells.len() > 10 {
return true;
}
let letter_spaced = filled
.iter()
.filter(|c| {
let chars: Vec<char> = c.chars().collect();
chars.len() >= 9
&& chars.windows(4).all(|w| {
(w[0].is_alphabetic() && w[1] == ' ' && w[2].is_alphabetic() && w[3] == ' ')
|| (w[0] == ' '
&& w[1].is_alphabetic()
&& w[2] == ' '
&& w[3].is_alphabetic())
})
})
.count();
if letter_spaced > 0 && letter_spaced as f32 / total_filled as f32 > 0.08 {
return true;
}
let long_cells = filled.iter().filter(|c| c.len() > 60).count();
let long_ratio = long_cells as f32 / total_filled as f32;
let avg_len = filled.iter().map(|c| c.len()).sum::<usize>() as f32 / total_filled as f32;
if avg_len > 40.0 && long_ratio > 0.2 {
return true;
}
if long_ratio > 0.3 {
return true;
}
false
}
fn check_column_alignment(
items: &[(usize, &TextItem)],
columns: &[f32],
mode: TableDetectionMode,
) -> f32 {
let tolerance = match mode {
TableDetectionMode::SmallFont => 40.0,
TableDetectionMode::BodyFont => 30.0,
};
let aligned = items
.iter()
.filter(|(_, item)| columns.iter().any(|&col| (item.x - col).abs() < tolerance))
.count();
aligned as f32 / items.len() as f32
}
pub(crate) fn find_first_table_row(
cell_items: &[Vec<Vec<&TextItem>>],
rows: &[f32],
original_items: &[(usize, &TextItem)],
) -> (usize, std::collections::HashSet<usize>) {
let mut excluded_items = std::collections::HashSet::new();
let cells: Vec<Vec<String>> = cell_items
.iter()
.map(|row| row.iter().map(|col| join_cell_items(col)).collect())
.collect();
if cells.is_empty() {
return (0, excluded_items);
}
let total_cols = cells[0].len();
let mut first_table_row = 0;
for (row_idx, row) in cells.iter().enumerate() {
let filled_cells: Vec<&String> = row.iter().filter(|c| !c.trim().is_empty()).collect();
let filled_count = filled_cells.len();
let fill_ratio = filled_count as f32 / total_cols as f32;
let form_cell_count = filled_cells
.iter()
.filter(|c| {
let text = c.trim();
(text.ends_with(':') && text.len() > 1)
|| (text.contains(": ") && !looks_like_number(text))
})
.count();
let has_form_patterns =
form_cell_count > 0 && (form_cell_count * 2 >= filled_count || fill_ratio < 0.3);
let numeric_count = filled_cells
.iter()
.filter(|c| looks_like_number(c.trim()))
.count();
let has_data = numeric_count >= 2;
if has_form_patterns {
continue;
}
if filled_count >= 2 && !has_data {
let mut text_counts: std::collections::HashMap<&str, usize> =
std::collections::HashMap::new();
for cell in &filled_cells {
*text_counts.entry(cell.trim()).or_insert(0) += 1;
}
let has_duplicates = text_counts.values().any(|&count| count >= 2);
if has_duplicates {
let has_better_below = cells.iter().skip(row_idx + 1).take(3).any(|r| {
let next_filled = r.iter().filter(|c| !c.trim().is_empty()).count();
let next_fill = next_filled as f32 / total_cols as f32;
let next_numeric = r.iter().filter(|c| looks_like_number(c.trim())).count();
next_fill >= 0.4 || next_numeric >= 2
});
if has_better_below {
continue;
}
}
}
if has_data {
first_table_row = row_idx;
break;
}
if fill_ratio >= 0.4 {
first_table_row = row_idx;
break;
}
if fill_ratio < 0.3 {
continue;
}
if row_idx + 1 < cells.len() {
let next_row = &cells[row_idx + 1];
let next_filled = next_row.iter().filter(|c| !c.trim().is_empty()).count();
let next_fill_ratio = next_filled as f32 / total_cols as f32;
let next_has_form = next_row.iter().any(|c| {
let text = c.trim();
(text.ends_with(':') && text.len() > 1)
|| (text.contains(": ") && !looks_like_number(text))
});
if (next_fill_ratio >= 0.4
|| next_row
.iter()
.filter(|c| looks_like_number(c.trim()))
.count()
>= 2)
&& !next_has_form
{
first_table_row = row_idx;
break;
}
}
}
if first_table_row > 0 {
let y_tolerance = 15.0;
for (idx, item) in original_items {
for row_y in rows.iter().take(first_table_row) {
if (item.y - *row_y).abs() < y_tolerance {
excluded_items.insert(*idx);
break;
}
}
}
}
(first_table_row, excluded_items)
}
fn try_add_label_column(
table: &mut Table,
all_candidates: &[(usize, &TextItem)],
claimed_indices: &std::collections::HashSet<usize>,
y_min: f32,
y_max: f32,
) {
if table.columns.len() < 2 || table.columns.len() > 3 || table.rows.len() < 5 {
return;
}
let numeric_cells = table
.cells
.iter()
.flat_map(|row| row.iter())
.filter(|cell| {
let text = cell.trim();
if text.is_empty() {
return false;
}
let data_chars = text
.chars()
.filter(|c| c.is_ascii_digit() || ",.-+%€$£¥()".contains(*c))
.count();
let total_chars = text.chars().count();
total_chars > 0 && data_chars as f32 / total_chars as f32 >= 0.6
})
.count();
let total_non_empty = table
.cells
.iter()
.flat_map(|row| row.iter())
.filter(|c| !c.trim().is_empty())
.count();
if total_non_empty == 0 || (numeric_cells as f32 / total_non_empty as f32) < 0.7 {
return;
}
let table_x_min = table.columns.first().copied().unwrap_or(f32::MAX);
let y_tol = 5.0;
let mut label_items_per_row: Vec<Vec<(usize, &TextItem)>> = Vec::new();
let mut found_count = 0;
for &row_y in &table.rows {
let mut row_labels: Vec<(usize, &TextItem)> = all_candidates
.iter()
.filter(|(idx, item)| {
!claimed_indices.contains(idx)
&& !table.item_indices.contains(idx)
&& (item.y - row_y).abs() < y_tol
&& item.x < table_x_min - 10.0
&& item.y >= y_min
&& item.y <= y_max
})
.map(|(idx, item)| (*idx, *item))
.collect();
row_labels.sort_by(|a, b| {
a.1.x
.partial_cmp(&b.1.x)
.unwrap_or(std::cmp::Ordering::Equal)
});
if !row_labels.is_empty() {
found_count += 1;
}
label_items_per_row.push(row_labels);
}
if found_count < table.rows.len() * 2 / 5 {
return;
}
debug!(
"recovering label column: {}/{} rows have labels to the left",
found_count,
table.rows.len()
);
let label_col_x = label_items_per_row
.iter()
.flat_map(|items| items.iter().map(|(_, i)| i.x))
.fold(f32::INFINITY, f32::min);
table.columns.insert(0, label_col_x);
for (row_idx, row_labels) in label_items_per_row.iter().enumerate() {
let mut label_text = String::new();
let mut last = None;
for (_, item) in row_labels {
super::cell_text::push_cell_item(&mut label_text, &mut last, item, &item.text);
}
table.cells[row_idx].insert(0, label_text);
for (idx, _) in row_labels {
table.item_indices.push(*idx);
}
}
}
#[cfg(test)]
mod tests {
fn make_item(text: &str, x: f32, y: f32, font_size: f32, width: f32) -> TextItem {
TextItem {
text: text.to_string(),
x,
y,
width,
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,
}
}
#[test]
fn script_attachment_detects_subscript_after_body_text() {
let body = make_item("log", 100.0, 500.0, 10.0, 15.0);
let sub = make_item("10", 115.5, 497.0, 7.0, 7.0);
let items = vec![body, sub.clone()];
assert!(ScriptBodyIndex::new(&items).is_script_attachment(&sub, 0.0));
}
#[test]
fn script_attachment_detects_superscript_footnote_marker() {
let body = make_item("Hartley", 200.0, 500.0, 10.0, 35.0);
let sup = make_item("2", 235.8, 504.0, 6.6, 3.5);
let items = vec![body, sup.clone()];
assert!(ScriptBodyIndex::new(&items).is_script_attachment(&sup, 0.0));
}
#[test]
fn script_attachment_ignores_small_cell_far_from_body_text() {
let body = make_item("Revenue", 100.0, 500.0, 10.0, 40.0);
let cell = make_item("1,234", 180.0, 500.0, 7.0, 20.0);
let items = vec![body, cell.clone()];
assert!(!ScriptBodyIndex::new(&items).is_script_attachment(&cell, 0.0));
}
#[test]
fn body_pass_anchor_spares_cells_beside_slightly_larger_labels() {
let label = make_item("Revenue", 100.0, 500.0, 10.5, 40.0);
let cell = make_item("1,234", 141.0, 496.5, 8.5, 22.0);
let items = vec![label, cell.clone()];
let index = ScriptBodyIndex::new(&items);
let base = 10.0;
assert!(
index.is_script_attachment(&cell, 0.0),
"small-font pass anchor should still see this as an attachment"
);
assert!(
!index.is_script_attachment(&cell, base * 1.15),
"body pass must not treat a cell beside a slightly larger label \
as a script — that removes real cells from the geometry"
);
let heading = make_item("Section", 100.0, 500.0, 20.0, 60.0);
let sup = make_item("3", 161.0, 508.0, 10.0, 5.0);
let h_items = vec![heading, sup.clone()];
assert!(
ScriptBodyIndex::new(&h_items).is_script_attachment(&sup, base * 1.15),
"script hanging off a heading must still be excluded in the body pass"
);
}
#[test]
fn script_attachment_ignores_same_baseline_neighbor_cell() {
let label = make_item("Total", 100.0, 500.0, 10.0, 25.0);
let cell = make_item("42", 127.0, 500.0, 7.5, 9.0);
let items = vec![label, cell.clone()];
assert!(!ScriptBodyIndex::new(&items).is_script_attachment(&cell, 0.0));
}
#[test]
fn script_attachment_ignores_neighbor_on_different_line() {
let body = make_item("Header", 100.0, 500.0, 10.0, 30.0);
let cell = make_item("42", 131.0, 486.0, 7.0, 10.0);
let items = vec![body, cell.clone()];
assert!(!ScriptBodyIndex::new(&items).is_script_attachment(&cell, 0.0));
}
fn shannon_page1_small_items() -> Vec<TextItem> {
vec![
make_item("2", 267.4, 133.9, 7.4, 3.7),
make_item("10", 306.2, 133.9, 7.4, 7.4),
make_item("10", 342.7, 133.9, 7.4, 7.4),
make_item("10", 325.0, 118.9, 7.4, 7.4),
make_item("Bell System Technical Journal,", 295.7, 101.9, 8.0, 95.0),
make_item(
"April 1924, p. 324; Certain Topics in",
396.7,
101.9,
8.0,
130.0,
),
make_item("v. 47, April 1928, p. 617.", 250.9, 92.5, 8.0, 90.0),
make_item("Bell System Technical Journal,", 264.2, 82.6, 8.0, 95.0),
make_item("July 1928, p. 535.", 364.3, 82.6, 8.0, 65.0),
]
}
#[test]
fn equation_scripts_do_not_form_phantom_table() {
let bare = shannon_page1_small_items();
assert!(
!detect_tables(&bare, 10.0, false).is_empty(),
"test layout must form a phantom table when the filter cannot fire"
);
let mut items = shannon_page1_small_items();
items.push(make_item("log", 253.0, 137.0, 10.0, 13.5));
items.push(make_item("log", 291.5, 137.0, 10.0, 13.5));
items.push(make_item("log", 328.0, 137.0, 10.0, 13.5));
items.push(make_item("log", 310.3, 122.0, 10.0, 13.5));
let tables = detect_tables(&items, 10.0, false);
assert!(
tables.is_empty(),
"equation scripts + footnotes must not become a table: {tables:?}"
);
}
use super::*;
use crate::types::ItemType;
fn body_item(text: &str, x: f32, y: f32, strikeout: bool) -> TextItem {
TextItem {
text: text.to_string(),
x,
y,
width: 90.0,
height: 12.0,
font: "F1".to_string(),
font_tag: String::new(),
legacy_symbol_rewrite: false,
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: strikeout,
rotation: 0.0,
advance_known: true,
item_type: ItemType::Text,
mcid: None,
baseline_shift: 0.0,
}
}
#[test]
fn merge_adjacent_items_preserves_redline_boundaries() {
let old = body_item("old value", 220.0, 700.0, true);
let mut replacement = body_item("new value", 312.0, 700.0, false);
replacement.is_underline = true;
let preserved_indices = std::collections::HashSet::from([1]);
let (merged, index_map) =
merge_adjacent_items_preserving(&[old, replacement], &preserved_indices);
assert_eq!(merged.len(), 2);
assert_eq!(index_map, vec![vec![0], vec![1]]);
assert!(merged[0].is_strikeout);
assert!(merged[1].is_underline);
}
#[test]
fn merge_adjacent_items_keeps_boundary_after_preserved_fragment() {
let mut prefix = body_item("prefix", 20.0, 700.0, false);
prefix.is_underline = true;
let mut replacement = body_item("replacement", 111.0, 700.0, false);
replacement.is_underline = true;
let deleted = body_item("deleted", 202.0, 700.0, true);
let preserved_indices = std::collections::HashSet::from([1]);
let (merged, index_map) =
merge_adjacent_items_preserving(&[prefix, replacement, deleted], &preserved_indices);
assert_eq!(merged.len(), 2);
assert_eq!(index_map, vec![vec![0, 1], vec![2]]);
assert!(merged[0].is_underline);
assert!(merged[1].is_strikeout);
}
#[test]
fn merge_adjacent_items_keeps_preserved_fragment_out_of_mixed_run() {
let mut prefix = body_item("prefix", 20.0, 700.0, false);
prefix.is_underline = true;
let deleted = body_item("deleted", 111.0, 700.0, true);
let mut replacement = body_item("replacement", 202.0, 700.0, false);
replacement.is_underline = true;
let preserved_indices = std::collections::HashSet::from([2]);
let (merged, index_map) =
merge_adjacent_items_preserving(&[prefix, deleted, replacement], &preserved_indices);
assert_eq!(merged.len(), 2);
assert_eq!(index_map, vec![vec![0, 1], vec![2]]);
assert!(merged[0].is_underline);
assert!(merged[1].is_underline);
}
#[test]
fn body_font_redline_deletions_do_not_create_a_table() {
let mut items = Vec::new();
for row in 0..8 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("live paragraph text", 50.0, y, false));
items.push(body_item("deleted wording", 220.0, y, true));
}
assert!(
detect_tables(&items, 12.0, false).is_empty(),
"aligned strikeout overlays are source edits, not table columns"
);
}
#[test]
fn underlined_body_font_table_without_deletions_is_still_detected() {
let mut items = Vec::new();
for row in 0..8 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("row label", 50.0, y, false));
let mut value = body_item("row value", 220.0, y, false);
value.is_underline = true;
items.push(value);
}
assert!(
!detect_tables(&items, 12.0, false).is_empty(),
"underline-only tables must keep their heuristic evidence"
);
}
#[test]
fn body_font_table_with_one_deletion_is_still_detected() {
let mut items = Vec::new();
for row in 0..8 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("row label", 50.0, y, false));
items.push(body_item("row value", 220.0, y, row == 0));
}
assert!(
!detect_tables(&items, 12.0, false).is_empty(),
"one revised cell must not suppress an otherwise complete table"
);
}
#[test]
fn unrelated_strikeout_does_not_remove_underlined_table_evidence() {
let mut items = Vec::new();
for row in 0..8 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("row label", 50.0, y, false));
let mut value = body_item("row value", 220.0, y, false);
value.is_underline = true;
items.push(value);
}
items.push(body_item("deleted prose", 50.0, 300.0, true));
assert!(
!detect_tables(&items, 12.0, false).is_empty(),
"a distant deletion must not suppress underlined table columns"
);
}
#[test]
fn nearby_redline_rows_do_not_remove_plain_table_evidence() {
let mut items = Vec::new();
for row in 0..8 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("row label", 50.0, y, false));
items.push(body_item("row value", 220.0, y, false));
}
for y in [700.0, 652.0, 604.0] {
items.push(body_item("deleted prose", 400.0, y, true));
}
assert!(
!detect_tables(&items, 12.0, false).is_empty(),
"redline rows must suppress decorations, not nearby live table cells"
);
}
#[test]
fn separate_strikeout_columns_do_not_suppress_content_between_them() {
let mut items = Vec::new();
for row in 0..8 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("row label", 160.0, y, false));
items.push(body_item("row value", 280.0, y, false));
}
for (row, y) in [700.0, 684.0, 668.0, 652.0].into_iter().enumerate() {
let x = if row % 2 == 0 { 50.0 } else { 420.0 };
let mut deletion = body_item("old", x, y, true);
deletion.width = 30.0;
items.push(deletion);
}
let regions = redline_edit_regions(&items, content_width(&items));
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].x_ranges.len(), 2);
assert!(!regions[0].spans_page_width);
assert!(
!detect_tables(&items, 12.0, false).is_empty(),
"separate edit columns must not create a suppression bridge across the page"
);
}
#[test]
fn wide_redline_rows_do_not_turn_fragmented_prose_into_a_table() {
let mut items = Vec::new();
for row in 0..8 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("line number", 50.0, y, false));
items.push(body_item("live prose fragment", 160.0, y, false));
let strikeout_x = if row % 2 == 0 { 280.0 } else { 430.0 };
items.push(body_item("deleted prose", strikeout_x, y, true));
}
assert!(
detect_tables(&items, 12.0, false).is_empty(),
"full-width redline prose must not retain table-shaped fragments"
);
}
#[test]
fn wide_redline_prose_ignores_underlines_outside_edit_span() {
let mut items = Vec::new();
for row in 0..8 {
let y = 700.0 - row as f32 * 16.0;
let mut line_number = body_item("line number", 50.0, y, false);
line_number.is_underline = row < 2;
items.push(line_number);
items.push(body_item("live prose fragment", 160.0, y, false));
let strikeout_x = if row % 2 == 0 { 280.0 } else { 430.0 };
items.push(body_item("deleted prose", strikeout_x, y, true));
}
assert!(redline_edit_regions(&items, content_width(&items))[0].spans_page_width);
assert!(
detect_tables(&items, 12.0, false).is_empty(),
"underlines outside the edit span must not disable the wide-prose veto"
);
}
#[test]
fn nearby_redline_rows_do_not_remove_separate_underlined_table_evidence() {
let mut items = Vec::new();
for row in 0..8 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("row label", 50.0, y, false));
let mut value = body_item("row value", 220.0, y, false);
value.is_underline = true;
items.push(value);
}
for y in [700.0, 652.0, 604.0] {
items.push(body_item("deleted prose", 400.0, y, true));
}
assert!(
!detect_tables(&items, 12.0, false).is_empty(),
"redline rows must not suppress a horizontally separate underlined table"
);
}
#[test]
fn multiple_revised_rows_do_not_remove_underlined_table_column() {
let mut items = Vec::new();
for row in 0..8 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("row label", 50.0, y, false));
let replacement_x = 220.0 + (row % 2) as f32 * 2.0;
let mut value = body_item("new value", replacement_x, y, false);
value.is_underline = true;
items.push(value);
}
for y in [700.0, 652.0, 604.0] {
items.push(body_item("old value", 220.0, y, true));
}
assert!(
!detect_tables(&items, 12.0, false).is_empty(),
"aligned replacement cells must preserve a partially revised table"
);
}
#[test]
fn single_replacement_uses_aligned_live_table_rows() {
let mut items = Vec::new();
for row in 0..8 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("row label", 50.0, y, false));
let mut value = body_item("row value", 220.0, y, false);
value.is_underline = row == 0;
items.push(value);
}
for y in [700.0, 652.0, 604.0] {
items.push(body_item("old value", 220.0, y, true));
}
assert!(
!detect_tables(&items, 12.0, false).is_empty(),
"one replacement cell must retain its aligned live table column"
);
}
#[test]
fn wide_revised_table_keeps_repeated_replacement_evidence() {
let mut items = Vec::new();
for row in 0..8 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("row label", 50.0, y, false));
let mut value = body_item("row value", 220.0, y, false);
value.is_underline = row < 2;
items.push(value);
let strikeout_x = if row % 2 == 0 { 220.0 } else { 400.0 };
items.push(body_item("old value", strikeout_x, y, true));
}
assert!(redline_edit_regions(&items, content_width(&items))[0].spans_page_width);
assert!(
!detect_tables(&items, 12.0, false).is_empty(),
"wide edits must keep a table column with repeated replacements"
);
}
#[test]
fn revised_financial_columns_survive_item_expansion() {
let mut items = Vec::new();
for row in 0..4 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("row label", 50.0, y, false));
items.push(body_item("old value", 180.0, y, true));
let mut values = body_item("$ 100 $ 200 $ 300", 180.0, y, false);
values.width = 300.0;
values.is_underline = true;
items.push(values);
}
let tables = detect_tables(&items, 12.0, false);
assert!(
tables.iter().any(|table| table.columns.len() >= 4),
"all expanded financial columns must inherit their source evidence"
);
}
#[test]
fn narrow_layout_band_uses_full_page_width_for_redline_scope() {
let mut items = Vec::new();
for row in 0..8 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("row label", 190.0, y, false));
items.push(body_item("old value", 300.0, y, true));
let mut replacement = body_item("new value", 300.0, y, false);
replacement.is_underline = true;
items.push(replacement);
}
assert!(redline_edit_regions(&items, content_width(&items))[0].spans_page_width);
assert!(!redline_edit_regions(&items, 500.0)[0].spans_page_width);
assert!(
!detect_tables_with_page_width(&items, 12.0, false, 500.0).is_empty(),
"a narrow band must use full-page context to preserve revised table cells"
);
}
#[test]
fn adjacent_revised_fragments_preserve_live_table_cells() {
let mut items = Vec::new();
for row in 0..4 {
let y = 700.0 - row as f32 * 16.0;
items.push(body_item("row label", 50.0, y, false));
items.push(body_item("old value", 220.0, y, true));
let mut replacement = body_item("new value", 312.0, y, false);
replacement.is_underline = true;
items.push(replacement);
}
assert!(
!detect_tables(&items, 12.0, false).is_empty(),
"adjacent old/new fragments must retain the live revised cells"
);
}
#[test]
fn small_toc_fragments_are_classified() {
let rows = |v: &[(&str, &str)]| -> Vec<Vec<String>> {
v.iter()
.map(|(a, b)| vec![a.to_string(), b.to_string()])
.collect()
};
assert!(is_table_of_contents(&rows(&[
("Section 4.1: Examining Relationships", "29"),
("Section 4.2: Correlation Assumptions", "31"),
("Section 4.3: Chapter Four Self-Test", "33"),
])));
assert!(is_table_of_contents(&rows(&[
("Section 6.3 Repeated Measures ANOVA", "54"),
("Section 6.4: Chapter Six Self-Test", "62"),
])));
assert!(is_table_of_contents(&rows(&[
("4. A Jewel in the Austrian Crown.", "..19"),
("5. Meeting the Relatives..", "..37"),
("", "..41"),
("7. To the Bottom of the World......", ".53"),
])));
assert!(!is_table_of_contents(&rows(&[
("Total", "54"),
("Margin", "62"),
])));
assert!(!is_table_of_contents(&rows(&[
("Net income before adjustments", "54"),
("Gross margin excluding one-offs", "42"),
])));
}
#[test]
fn is_table_of_contents_rejects_toc() {
let cells = vec![
vec![
"Chapter 1".to_string(),
"....................".to_string(),
"1".to_string(),
],
vec![
"Chapter 2".to_string(),
"....................".to_string(),
"15".to_string(),
],
vec![
"Chapter 3".to_string(),
"....................".to_string(),
"42".to_string(),
],
vec![
"Appendix".to_string(),
"....................".to_string(),
"100".to_string(),
],
];
assert!(is_table_of_contents(&cells));
}
#[test]
fn is_table_of_contents_allows_data_table_with_dot_leaders() {
let cells = vec![
vec![
"1973..........".to_string(),
"0.80".to_string(),
"1.08".to_string(),
"1.05".to_string(),
"0.02".to_string(),
"-0.28".to_string(),
"-0.33".to_string(),
"5.16".to_string(),
],
vec![
"1974..........".to_string(),
"73".to_string(),
"56".to_string(),
"49".to_string(),
"08".to_string(),
"17".to_string(),
"17".to_string(),
"-.28".to_string(),
],
vec![
"1975..........".to_string(),
"86".to_string(),
"-.05".to_string(),
"-.14".to_string(),
"09".to_string(),
"91".to_string(),
"85".to_string(),
"1.03".to_string(),
],
vec![
"1976..........".to_string(),
"-1.05".to_string(),
"36".to_string(),
"34".to_string(),
"02".to_string(),
"-1.41".to_string(),
"-1.31".to_string(),
"4.01".to_string(),
],
];
assert!(
!is_table_of_contents(&cells),
"data table with dot-leader labels should not be rejected as TOC"
);
}
#[test]
fn is_table_of_contents_accepts_hierarchical_indented_toc() {
let cells = vec![
vec!["Abstract".to_string(), String::new(), "3".to_string()],
vec![
"1 Introduction".to_string(),
String::new(),
"10".to_string(),
],
vec![
String::new(),
"1.1 Model training".to_string(),
"11".to_string(),
],
vec![
String::new(),
"1.1.1 Training data".to_string(),
"11".to_string(),
],
vec![
String::new(),
"1.1.2 Crowd workers".to_string(),
"12".to_string(),
],
vec![
String::new(),
"1.2 Release decision".to_string(),
"13".to_string(),
],
vec![
"2 RSP evaluations".to_string(),
String::new(),
"16".to_string(),
],
vec![
String::new(),
"2.1 RSP risk assessment".to_string(),
"16".to_string(),
],
vec![String::new(), "2.1.1 Context".to_string(), "16".to_string()],
vec![
String::new(),
"2.2 CB evaluations".to_string(),
"20".to_string(),
],
];
assert!(
is_table_of_contents(&cells),
"hierarchical TOC with sparse col 0 should still be detected"
);
}
#[test]
fn is_table_of_contents_rejects_dotless_toc() {
let cells = vec![
vec![
"4.3 Case studies and targeted evaluations".to_string(),
String::new(),
"86".to_string(),
],
vec![
"4.3.1 Destructive or reckless actions".to_string(),
"4.3.1.1 Synthetic-backend evaluation".to_string(),
"86 86".to_string(),
],
vec![
"4.3.2 Adherence to constitution".to_string(),
"4.3.2.1 Overview".to_string(),
"89 89".to_string(),
],
vec![
"4.3.3 Honesty and hallucinations".to_string(),
"4.3.3.1 Factual hallucinations".to_string(),
"93 94".to_string(),
],
vec![
"4.4 Capability evaluations".to_string(),
String::new(),
"101".to_string(),
],
];
assert!(
is_table_of_contents(&cells),
"dot-less TOC with section numbers + page numbers should be rejected"
);
}
#[test]
fn dot_leader_toc_accepts_short_inline_leaders() {
let cells = vec![
vec!["Child tax credit ... 235".to_string(), String::new()],
vec!["Church employee ... 252".to_string(), String::new()],
vec!["Citizens outside the U.S ... 6".to_string(), String::new()],
vec![
"Claim for refund ... 18, 36, 107".to_string(),
String::new(),
],
vec!["Clergy ... 7, 52".to_string(), String::new()],
];
assert!(is_dot_leader_toc(&cells));
}
#[test]
fn dot_leader_toc_allows_ellipsis_data_table() {
let cells = vec![
vec![
"0x00".to_string(),
"QTR0".to_string(),
"Transmit RAM".to_string(),
],
vec!["0x01".to_string(), "QTR1".to_string(), String::new()],
vec![
"...".to_string(),
"...".to_string(),
"16 bits wide".to_string(),
],
vec!["0x0F".to_string(), "QTR15".to_string(), String::new()],
vec![
"0x10".to_string(),
"QRR0".to_string(),
"Receive RAM".to_string(),
],
vec!["0x11".to_string(), "QRR1".to_string(), String::new()],
vec![
"...".to_string(),
"...".to_string(),
"16 bits wide".to_string(),
],
vec!["0x1F".to_string(), "QRR15".to_string(), String::new()],
];
assert!(
!is_dot_leader_toc(&cells),
"ellipsis markers in a data table should not match TOC detection"
);
}
#[test]
fn dot_leader_toc_rejects_year_row_data_table() {
let cells = vec![
vec![
"1973 ... ".to_string(),
"4. 0".to_string(),
"1. 8".to_string(),
"0. 4".to_string(),
"3. 2".to_string(),
" ... ".to_string(),
],
vec![
"1974 ... ".to_string(),
"–1. 9".to_string(),
"–1. 6".to_string(),
"–5. 6".to_string(),
"2. 4".to_string(),
" ... ".to_string(),
],
vec![
"1975 ... ".to_string(),
"2. 6".to_string(),
"5. 1".to_string(),
"6. 1".to_string(),
"4. 1".to_string(),
" ... ".to_string(),
],
vec![
"1976 ... ".to_string(),
"4. 3".to_string(),
"5. 4".to_string(),
"6. 4".to_string(),
"4. 5".to_string(),
" ... ".to_string(),
],
];
assert!(
!is_dot_leader_toc(&cells),
"year-indexed data tables with decimal cells must not match TOC detection"
);
}
#[test]
fn dot_leader_toc_rejects_monthly_data_table() {
let cells = vec![
vec![
"2023: Jan ... ".to_string(),
"265,962".to_string(),
"165,871".to_string(),
"160,152".to_string(),
"62. 4".to_string(),
],
vec![
"Feb ... ".to_string(),
"266,112".to_string(),
"166,263".to_string(),
"160,301".to_string(),
"62. 5".to_string(),
],
vec![
"Mar ... ".to_string(),
"266,272".to_string(),
"166,690".to_string(),
"160,824".to_string(),
"62. 6".to_string(),
],
vec![
"Apr ... ".to_string(),
"266,443".to_string(),
"166,678".to_string(),
"160,962".to_string(),
"62. 6".to_string(),
],
];
assert!(
!is_dot_leader_toc(&cells),
"monthly labor-force rows with thousands-separated data must not match TOC detection"
);
}
#[test]
fn tabular_toc_requires_section_numbers_and_pages() {
let cells = vec![
vec![
"4.3 Case studies".to_string(),
String::new(),
"86".to_string(),
],
vec![
"4.3.1 Destructive actions".to_string(),
String::new(),
"86".to_string(),
],
vec![
"4.3.2 Adherence".to_string(),
String::new(),
"89".to_string(),
],
vec!["4.3.3 Honesty".to_string(), String::new(), "93".to_string()],
];
assert!(is_tabular_toc(&cells));
assert!(!is_dot_leader_toc(&cells));
}
#[test]
fn starts_with_section_number_matches_dotted() {
assert!(starts_with_section_number("1.2"));
assert!(starts_with_section_number("4.3.1"));
assert!(starts_with_section_number("4.3.1.2"));
assert!(starts_with_section_number("4.3 Case studies"));
assert!(starts_with_section_number("2.2.5.1 Expert red teaming"));
}
#[test]
fn starts_with_section_number_rejects_non_sections() {
assert!(!starts_with_section_number("Chapter 1"));
assert!(!starts_with_section_number("1973"));
assert!(!starts_with_section_number("1.5M"));
assert!(!starts_with_section_number("10.0%"));
assert!(!starts_with_section_number(""));
assert!(!starts_with_section_number("Hello world"));
}
#[test]
fn page_number_value_rejects_roman_lookalike_words() {
assert!(page_number_value("civil").is_none());
assert!(page_number_value("mix").is_none());
assert!(page_number_value("ill").is_none());
assert!(page_number_value("lil").is_none());
assert_eq!(page_number_value("vii"), Some(7));
assert_eq!(page_number_value("ix"), Some(9));
assert_eq!(page_number_value("xii"), Some(12));
assert_eq!(page_number_value("42"), Some(42));
}
#[test]
fn page_number_toc_matches_consecutive_pages_with_titles() {
let cells: Vec<Vec<String>> = vec![
vec!["Introduction to the Study".into(), "1".into()],
vec!["Materials and Methods".into(), "2".into()],
vec!["Results and Discussion".into(), "3".into()],
vec!["Summary of Findings".into(), "4".into()],
vec!["References and Notes".into(), "5".into()],
];
assert!(is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_rejects_dense_ordinal_column() {
let cells: Vec<Vec<String>> = vec![
vec!["Alice".into(), "1".into()],
vec!["Bob".into(), "2".into()],
vec!["Carol".into(), "3".into()],
vec!["Dave".into(), "4".into()],
vec!["Erin".into(), "5".into()],
vec!["Frank".into(), "6".into()],
];
assert!(!is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_rejects_blank_header_cell() {
let cells = vec![
vec!["Category".into(), "".into()],
vec!["Alpha".into(), "3".into()],
vec!["Beta".into(), "9".into()],
vec!["Gamma".into(), "14".into()],
vec!["Delta".into(), "20".into()],
];
assert!(!is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_matches_title_based_contents() {
let cells = vec![
vec!["About the Publisher".into(), "vii".into()],
vec!["About This Project".into(), "ix".into()],
vec!["Acknowledgments".into(), "xi".into()],
vec!["Experiment #1: Hydrostatic Pressure".into(), "3".into()],
vec!["Experiment #2: Bernoulli's Theorem".into(), "13".into()],
vec![
"Experiment #3: Energy Loss in Pipe Fittings".into(),
"24".into(),
],
];
assert!(is_page_number_toc(&cells));
assert!(is_table_of_contents(&cells));
}
#[test]
fn page_number_toc_rejects_numeric_data_table() {
let cells = vec![
vec!["101".into(), "45".into()],
vec!["102".into(), "12".into()],
vec!["103".into(), "88".into()],
vec!["104".into(), "7".into()],
vec!["105".into(), "63".into()],
];
assert!(!is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_rejects_non_monotonic_pages() {
let cells: Vec<Vec<String>> = vec![
vec!["Apples".into(), "42".into()],
vec!["Oranges".into(), "7".into()],
vec!["Pears".into(), "91".into()],
vec!["Plums".into(), "3".into()],
vec!["Grapes".into(), "60".into()],
];
assert!(cells.len() >= 5 && page_number_value(cells[0][1].trim()).is_some());
assert!(!is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_rejects_header_row_data_table() {
let cells = vec![
vec![
"Mineral or colloid type".into(),
"CEC of pure colloid".into(),
],
vec!["kaolinite".into(), "10".into()],
vec!["illite".into(), "30".into()],
vec!["montmorillonite".into(), "100".into()],
vec!["vermiculite".into(), "150".into()],
];
assert!(!is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_rejects_wide_data_grid() {
let cells = vec![
vec![
"REGIONS".into(),
"2007".into(),
"2010".into(),
"2016".into(),
],
vec![
"National Capital Region".into(),
"9".into(),
"8".into(),
"5".into(),
],
vec!["Cordillera".into(), "1".into(), "2".into(), "1".into()],
vec!["Ilocos Region".into(), "1".into(), "5".into(), "4".into()],
vec!["Cagayan Valley".into(), "1".into(), "3".into(), "5".into()],
];
assert!(!is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_needs_page_number_last_column() {
let cells = vec![
vec!["Section A".into(), "see appendix".into()],
vec!["Section B".into(), "see notes".into()],
vec!["Section C".into(), "later".into()],
vec!["Section D".into(), "TBD".into()],
];
assert!(!is_page_number_toc(&cells));
}
}