diff --git a/src/tables/detect_rects.rs b/src/tables/detect_rects.rs index 9cb0fea..38de71c 100644 --- a/src/tables/detect_rects.rs +++ b/src/tables/detect_rects.rs @@ -386,6 +386,8 @@ pub fn detect_tables_from_rects( tables.push(table); } else if let Some(table) = detect_row_stripe_table(items, &group_rects, page) { tables.push(table); + } else if let Some(table) = detect_stacked_box_table(items, &group_rects, page) { + tables.push(table); } else if let Some((left, right)) = split_wide_cluster(&group_rects, 15.0, 6) { // Cluster was too wide — retry each half independently debug!( @@ -631,6 +633,238 @@ pub fn detect_tables_from_rects( /// overlap or are close (gap < 50pt). This handles calendar-style layouts where a /// month zone's decorative rects split into 2-3 adjacent clusters with small X gaps. /// Runs iteratively until no more merges occur. +/// Detect a single-column table drawn as a vertical stack of boxes, each +/// holding one short line of text (framework/step lists on slide-style +/// pages). The normal grid path rejects these — one column means only two +/// x-edges — so the rows would otherwise flow into surrounding prose as a +/// run-on paragraph. +fn detect_stacked_box_table( + items: &[TextItem], + group_rects: &[(f32, f32, f32, f32)], + page: u32, +) -> Option { + // Candidate row boxes: single-text-line height, substantial width. + let cands: Vec<(f32, f32, f32, f32)> = group_rects + .iter() + .copied() + .filter(|&(_, _, w, h)| w >= 100.0 && (8.0..=80.0).contains(&h)) + .collect(); + // The row boxes form the largest family of same-width, x-aligned rects + // (backgrounds and decor have their own geometry and stay out). + let mut boxes: Vec<(f32, f32, f32, f32)> = Vec::new(); + for &anchor in &cands { + let family: Vec<(f32, f32, f32, f32)> = cands + .iter() + .copied() + .filter(|&(x, _, w, h)| { + (x - anchor.0).abs() <= 12.0 + && (w - anchor.2).abs() <= anchor.2 * 0.15 + && (h - anchor.3).abs() <= anchor.3 * 0.3 + }) + .collect(); + if family.len() > boxes.len() { + boxes = family; + } + } + if boxes.len() < 3 { + return None; + } + // Boxes flanked at the same y-level — by other rects or by text outside + // the family's x-range — are one column of a wider structure. Leave + // those to the grid/cell-rect paths instead of collapsing to one column. + let flanked = boxes + .iter() + .filter(|&&(bx, by, bw, bh)| { + let rect_sibling = group_rects.iter().any(|&(ox, oy, ow, oh)| { + let y_overlap = (by + bh).min(oy + oh) - by.max(oy); + oh >= 8.0 + && y_overlap > bh * 0.5 + && (ox + ow <= bx + 2.0 || ox >= bx + bw - 2.0) + && ow >= 30.0 + }); + let text_sibling = items.iter().any(|it| { + let cx = it.x + it.width / 2.0; + it.page == page + && it.y >= by - 2.0 + && it.y <= by + bh + 2.0 + && (cx < bx - 5.0 || cx > bx + bw + 5.0) + && it.width >= 10.0 + }); + rect_sibling || text_sibling + }) + .count(); + if flanked * 3 >= boxes.len() { + debug!( + " stacked-box rejected: {}/{} boxes flanked by rects or text", + flanked, + boxes.len() + ); + return None; + } + boxes.sort_by(|a, b| b.1.total_cmp(&a.1)); // top to bottom (descending y) + + // Merge duplicates (border + fill pairs draw the same box twice), then + // require a clean vertical stack: no overlaps beyond a small tolerance. + boxes.dedup_by(|a, b| (a.1 - b.1).abs() <= 3.0 && (a.3 - b.3).abs() <= 6.0); + if boxes.len() < 3 { + return None; + } + for w in boxes.windows(2) { + let (upper, lower) = (w[0], w[1]); + let upper_bottom = upper.1; + let lower_top = lower.1 + lower.3; + if lower_top > upper_bottom + 4.0 { + return None; // vertical overlap — not a stack + } + if upper_bottom - lower_top > upper.3.max(lower.3) { + return None; // gap larger than a row — unrelated boxes + } + } + + // Assign items to boxes; every box needs text and cells must stay short + // (prose paragraphs inside stacked frames are page decor, not a table). + let mut cells: Vec> = Vec::with_capacity(boxes.len()); + let mut item_indices: Vec = Vec::new(); + let mut multi_run_boxes = 0usize; + for &(bx, by, bw, bh) in &boxes { + let mut in_box: Vec<(usize, &TextItem)> = items + .iter() + .enumerate() + .filter(|(_, it)| { + it.page == page + && it.y >= by - 2.0 + && it.y <= by + bh + 2.0 + && it.x + it.width / 2.0 >= bx + && it.x + it.width / 2.0 <= bx + bw + }) + .collect(); + if in_box.is_empty() { + return None; + } + in_box.sort_by(|a, b| { + b.1.y + .partial_cmp(&a.1.y) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + a.1.x + .partial_cmp(&b.1.x) + .unwrap_or(std::cmp::Ordering::Equal) + }) + }); + // Count horizontally separated text runs inside the box. A single + // list row flows as one run; two-plus runs across most boxes means + // multi-column content (striped prose or a real grid) that must not + // collapse into a one-column table. + let mut runs = 1usize; + for pair in in_box.windows(2) { + let (prev, item) = (pair[0].1, pair[1].1); + if (prev.y - item.y).abs() <= 2.0 && item.x - (prev.x + prev.width) > 15.0 { + runs += 1; + } + } + if runs >= 2 { + multi_run_boxes += 1; + } + let text = in_box + .iter() + .map(|(_, it)| it.text.trim()) + .filter(|t| !t.is_empty()) + .collect::>() + .join(" "); + if text.is_empty() || text.chars().count() > 120 { + return None; + } + item_indices.extend(in_box.iter().map(|(i, _)| *i)); + cells.push(vec![text]); + } + if multi_run_boxes * 2 >= boxes.len() { + debug!( + " stacked-box rejected: {}/{} boxes hold multiple text runs", + multi_run_boxes, + boxes.len() + ); + return None; + } + + // Reject prose behind per-line stripe rects: sentence fragments flowing + // across rows read as long, function-word-dense cells, while genuine + // list-table rows are short labels/titles. + const PROSE_WORDS: &[&str] = &[ + "a", "an", "the", "of", "to", "is", "was", "are", "were", "be", "been", "in", "on", "at", + "with", "for", "by", "as", "and", "or", "but", "this", "that", "these", "those", "from", + "into", "has", "have", "had", "not", "it", "its", "their", "such", "shall", "which", + ]; + let total_chars: usize = cells.iter().map(|r| r[0].chars().count()).sum(); + let mean_chars = total_chars / cells.len().max(1); + let prose_cells = cells + .iter() + .filter(|r| { + r[0].to_ascii_lowercase() + .split(|c: char| !c.is_ascii_alphabetic() && c != '\'') + .any(|w| PROSE_WORDS.contains(&w)) + }) + .count(); + if mean_chars > 60 && prose_cells * 5 >= cells.len() * 2 { + debug!( + " stacked-box rejected: prose rows (mean {} chars, prose words {}/{})", + mean_chars, + prose_cells, + cells.len() + ); + return None; + } + // Sentences wrapping across stripe rects: a row ending with a comma, or + // a row without terminal punctuation followed by a row starting + // lowercase, is mid-sentence flow — not list rows. Genuine label/title + // rows produce none of these, so even a small share is disqualifying. + let continuations = cells + .windows(2) + .filter(|pair| { + let prev = pair[0][0].trim_end(); + let next = pair[1][0].trim_start(); + let prev_open = !prev.ends_with(['.', ':', ';', '!', '?', ')', '"', '%']); + let next_lower = next.chars().next().is_some_and(|c| c.is_lowercase()); + prev.ends_with(',') || (prev_open && next_lower) + }) + .count(); + if cells.len() >= 2 && (continuations >= 2 || continuations * 4 >= cells.len() - 1) { + debug!( + " stacked-box rejected: {}/{} row pairs continue a sentence", + continuations, + cells.len() - 1 + ); + return None; + } + // Numbered/lettered list items behind decorative stripes stay lists: + // "1) content..." / "(ii) content..." / "a. content...". + let list_marker = |t: &str| { + let t = t.trim_start().strip_prefix('(').unwrap_or(t.trim_start()); + let marker_len = t.chars().take_while(|c| c.is_ascii_alphanumeric()).count(); + (1..=3).contains(&marker_len) + && t.chars() + .nth(marker_len) + .is_some_and(|c| c == ')' || c == '.') + }; + let list_rows = cells.iter().filter(|r| list_marker(&r[0])).count(); + if list_rows * 2 >= cells.len() { + debug!( + " stacked-box rejected: {}/{} rows are numbered list items", + list_rows, + cells.len() + ); + return None; + } + + debug!( + "page {}: stacked-box table: {} single-column rows", + page, + cells.len() + ); + let columns = vec![boxes[0].0 + boxes[0].2 / 2.0]; + let rows: Vec = boxes.iter().map(|b| b.1 + b.3 / 2.0).collect(); + Some(Table::new(columns, rows, cells, item_indices)) +} + fn merge_overlapping_hints(mut hints: Vec) -> Vec { if hints.len() <= 1 { return hints; @@ -2458,6 +2692,92 @@ mod tests { } } + // --- detect_stacked_box_table --- + + /// N stacked boxes at x=100, w=300, h=22, top-to-bottom from y=600. + fn stacked_boxes(n: usize) -> Vec<(f32, f32, f32, f32)> { + (0..n) + .map(|i| (100.0, 600.0 - i as f32 * 22.0, 300.0, 22.0)) + .collect() + } + + #[test] + fn stacked_box_list_becomes_single_column_table() { + let rects = stacked_boxes(5); + let items: Vec = (0..5) + .map(|i| make_item("#1: Recycling Basics", 120.0, 605.0 - i as f32 * 22.0, 10.0)) + .collect(); + let table = detect_stacked_box_table(&items, &rects, 1).expect("stacked-box table"); + assert_eq!(table.cells.len(), 5); + assert_eq!(table.cells[0].len(), 1); + } + + #[test] + fn stacked_box_rejects_wrapped_sentences() { + // Line stripes behind flowing prose: rows continue mid-sentence. + let rects = stacked_boxes(4); + let texts = [ + "the provisions of this section apply to", + "companies subject to tax under those", + "sections, except that the copy of the", + "annual statement must be retained.", + ]; + let items: Vec = texts + .iter() + .enumerate() + .map(|(i, t)| make_item(t, 120.0, 605.0 - i as f32 * 22.0, 10.0)) + .collect(); + assert!(detect_stacked_box_table(&items, &rects, 1).is_none()); + } + + #[test] + fn stacked_box_rejects_flanking_text() { + // A ruled label column with plain-text data columns beside it is one + // column of a wider table, not a single-column list. + let rects = stacked_boxes(4); + let mut items = Vec::new(); + for i in 0..4 { + let y = 605.0 - i as f32 * 22.0; + items.push(make_item("Section 1.382", 120.0, y, 10.0)); + items.push(make_item("removed text", 450.0, y, 10.0)); // beside the box + } + assert!(detect_stacked_box_table(&items, &rects, 1).is_none()); + } + + #[test] + fn stacked_box_rejects_two_column_content() { + // Boxes holding two separated runs are striped multi-column content. + let rects = stacked_boxes(4); + let mut items = Vec::new(); + for i in 0..4 { + let y = 605.0 - i as f32 * 22.0; + let mut left = make_item("left words", 110.0, y, 10.0); + left.width = 60.0; + let mut right = make_item("right words", 250.0, y, 10.0); + right.width = 60.0; + items.push(left); + items.push(right); + } + assert!(detect_stacked_box_table(&items, &rects, 1).is_none()); + } + + #[test] + fn stacked_box_rejects_mixed_height_stripes() { + // Mixed 13/27pt stripes (redline markup) — height uniformity splits + // the family and the gap check rejects the remainder. + let mut rects = Vec::new(); + let mut y = 600.0; + for i in 0..8 { + let h = if i % 3 == 0 { 27.0 } else { 13.5 }; + y -= h; + rects.push((100.0, y, 300.0, h)); + } + let items: Vec = (0..8) + .map(|i| make_item("PART 602 OMB CONTROL", 120.0, 590.0 - i as f32 * 18.0, 10.0)) + .collect(); + assert!(detect_stacked_box_table(&items, &rects, 1).is_none()); + } + // --- has_dominant_prose_cell --- fn cells_of(rows: &[&[&str]]) -> Vec> {