1use crate::layout::text_block::TextSpan;
8use crate::structure::table_extractor::{Table, TableCell, TableRow, span_text_for_cell};
9use std::collections::HashMap;
10
11struct UnionFind {
13 parent: Vec<usize>,
14}
15
16impl UnionFind {
17 fn new(n: usize) -> Self {
18 Self {
19 parent: (0..n).collect(),
20 }
21 }
22
23 fn find(&mut self, i: usize) -> usize {
24 let mut curr = i;
25 while self.parent[curr] != curr {
26 self.parent[curr] = self.parent[self.parent[curr]];
27 curr = self.parent[curr];
28 }
29 curr
30 }
31
32 fn union(&mut self, i: usize, j: usize) {
33 let ri = self.find(i);
34 let rj = self.find(j);
35 if ri != rj {
36 self.parent[ri] = rj;
37 }
38 }
39
40 fn groups(&mut self) -> HashMap<usize, Vec<usize>> {
41 let mut result: HashMap<usize, Vec<usize>> = HashMap::new();
42 for i in 0..self.parent.len() {
43 let root = self.find(i);
44 result.entry(root).or_default().push(i);
45 }
46 result
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
52pub enum TableStrategy {
53 #[serde(rename = "lines")]
55 Lines,
56 #[serde(rename = "text")]
58 Text,
59 #[default]
61 #[serde(rename = "both")]
62 Both,
63}
64
65#[derive(Debug, Clone, PartialEq)]
67pub struct TableDetectionConfig {
68 pub enabled: bool,
70 pub horizontal_strategy: TableStrategy,
72 pub vertical_strategy: TableStrategy,
74 pub column_tolerance: f32,
76 pub row_tolerance: f32,
78 pub min_table_cells: usize,
80 pub min_table_columns: usize,
82 pub regular_row_ratio: f32,
84 pub max_table_columns: usize,
86 pub column_merge_threshold: f32,
89 pub v_split_gap: f32,
93 pub text_fallback: bool,
111}
112
113impl Default for TableDetectionConfig {
114 fn default() -> Self {
115 Self {
116 enabled: true,
117 horizontal_strategy: TableStrategy::Both,
118 vertical_strategy: TableStrategy::Both,
119 column_tolerance: 15.0,
120 row_tolerance: 2.8,
121 min_table_cells: 4,
122 min_table_columns: 2,
123 regular_row_ratio: 0.3,
124 max_table_columns: 15,
125 column_merge_threshold: 25.0,
126 v_split_gap: 20.0,
127 text_fallback: true,
128 }
129 }
130}
131
132impl TableDetectionConfig {
133 pub fn strict() -> Self {
135 Self {
136 enabled: true,
137 horizontal_strategy: TableStrategy::Lines,
138 vertical_strategy: TableStrategy::Lines,
139 column_tolerance: 2.0,
140 row_tolerance: 1.0,
141 min_table_cells: 6,
142 min_table_columns: 3,
143 regular_row_ratio: 0.8,
144 max_table_columns: 12,
145 column_merge_threshold: 10.0,
146 v_split_gap: 4.0,
147 text_fallback: true,
148 }
149 }
150
151 pub fn relaxed() -> Self {
153 Self {
154 enabled: true,
155 horizontal_strategy: TableStrategy::Text,
156 vertical_strategy: TableStrategy::Text,
157 column_tolerance: 10.0,
158 row_tolerance: 5.0,
159 min_table_cells: 4,
160 min_table_columns: 2,
161 regular_row_ratio: 0.3,
162 max_table_columns: 20,
163 column_merge_threshold: 30.0,
164 v_split_gap: 40.0,
165 text_fallback: true,
166 }
167 }
168}
169
170fn is_valid_table(table: &Table) -> bool {
184 if table.rows.is_empty() || table.col_count == 0 {
185 return false;
186 }
187
188 let total_cells = table.rows.len() * table.col_count;
189 let empty_cells = table
190 .rows
191 .iter()
192 .flat_map(|r| &r.cells)
193 .filter(|c| c.text.trim().is_empty())
194 .count();
195 let empty_ratio = empty_cells as f32 / total_cells.max(1) as f32;
196
197 if empty_ratio > 0.6 {
198 return false;
199 }
200
201 if table.col_count == 2 {
202 let has_continuation_row = table
203 .rows
204 .iter()
205 .any(|r| r.cells.len() == 2 && r.cells[0].text.trim().is_empty() && !r.cells[1].text.trim().is_empty());
206 if has_continuation_row {
207 return false;
208 }
209 }
210
211 true
212}
213
214fn passes_spatial_quality_gate(table: &Table) -> bool {
227 if table.col_count < 5 {
228 return true;
229 }
230 let non_empty: Vec<&str> = table
231 .rows
232 .iter()
233 .flat_map(|r| &r.cells)
234 .map(|c| c.text.trim())
235 .filter(|t| !t.is_empty())
236 .collect();
237 if non_empty.is_empty() {
238 return true;
239 }
240 let data_values = non_empty.iter().filter(|t| is_data_value(t)).count();
250 if data_values * 2 >= non_empty.len() {
251 return true;
252 }
253 let single_word_count = non_empty.iter().filter(|t| t.split_whitespace().count() <= 1).count();
254 let ratio = single_word_count as f32 / non_empty.len() as f32;
255 ratio <= 0.7
256}
257
258fn is_data_value(t: &str) -> bool {
263 !t.is_empty()
264 && t.chars().any(|c| c.is_ascii_digit())
265 && t.chars().all(|c| {
266 c.is_ascii_digit()
267 || matches!(
268 c,
269 '.' | ',' | '%' | '+' | '-' | '\u{2212}' | '$' | '\u{20AC}' | '\u{00A3}'
270 )
271 })
272}
273
274fn looks_like_prose_paragraph(table: &Table) -> bool {
297 const CASELESS_TERMINATORS: [char; 2] = ['।', '॥'];
298
299 for row in &table.rows {
300 let joined = row
301 .cells
302 .iter()
303 .map(|c| c.text.trim())
304 .filter(|t| !t.is_empty())
305 .collect::<Vec<_>>()
306 .join(" ");
307 let chars: Vec<char> = joined.chars().collect();
308 for i in 0..chars.len() {
309 if matches!(chars[i], '.' | '!' | '?')
317 && i >= 1
318 && (chars[i - 1].is_ascii_lowercase() || chars[i - 1].is_ascii_digit())
319 && i + 3 < chars.len()
320 && chars[i + 1] == ' '
321 && chars[i + 2].is_ascii_uppercase()
322 && chars[i + 3].is_ascii_lowercase()
323 {
324 return true;
325 }
326 if CASELESS_TERMINATORS.contains(&chars[i]) {
327 let Some(&prev) = chars[..i].iter().rev().find(|c| **c != ' ') else {
328 continue;
329 };
330 if !prev.is_alphanumeric() {
331 continue;
332 }
333 let mut after = chars[i + 1..].iter().copied().skip_while(|c| *c == ' ');
334 if after.next().is_some_and(|c| c.is_alphabetic()) {
335 return true;
336 }
337 }
338 }
339 }
340
341 for row in &table.rows {
356 let cells: Vec<&str> = row
357 .cells
358 .iter()
359 .map(|c| c.text.trim())
360 .filter(|t| !t.is_empty())
361 .collect();
362 if cells.len() < 2 {
363 continue;
364 }
365 let lower_starts = cells
366 .iter()
367 .filter(|c| c.chars().next().is_some_and(char::is_lowercase))
368 .count();
369 if lower_starts as f32 / cells.len() as f32 > 0.5 {
370 return true;
371 }
372 }
373
374 for row in &table.rows {
382 for cell in &row.cells {
383 let lines: Vec<&str> = cell.text.split('\n').map(str::trim).filter(|l| !l.is_empty()).collect();
384 if lines.len() < 3 {
385 continue;
386 }
387 let single_char_lines = lines.iter().filter(|l| l.chars().count() == 1).count();
388 if single_char_lines as f32 / lines.len() as f32 > 0.5 {
389 return true;
390 }
391 }
392 }
393
394 false
395}
396
397fn looks_like_cjk_prose(table: &Table) -> bool {
405 const MIN_CJK_RUN: usize = 12;
406 fn is_cjk(c: char) -> bool {
407 matches!(
408 c as u32,
409 0x3040..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF66..=0xFF9F | 0xAC00..=0xD7AF )
416 }
417 table.rows.iter().flat_map(|r| &r.cells).any(|c| {
418 let mut run = 0usize;
419 for ch in c.text.chars() {
420 if is_cjk(ch) {
421 run += 1;
422 if run >= MIN_CJK_RUN {
423 return true;
424 }
425 } else if !ch.is_whitespace() {
426 run = 0;
427 }
428 }
429 false
430 })
431}
432
433fn looks_like_bulleted_list(table: &Table) -> bool {
440 fn is_bullet_glyph(c: char) -> bool {
447 matches!(
448 c,
449 '\u{2022}'
450 | '\u{2023}'
451 | '\u{2043}'
452 | '\u{2219}'
453 | '\u{25AA}'
454 | '\u{25CF}'
455 | '\u{25E6}'
456 | '\u{00B7}'
457 | '\u{2024}'
458 | '\u{27A2}'
459 | '\u{27A4}'
460 | '\u{25BA}'
461 | '\u{25B6}'
462 | '\u{25CB}'
463 )
464 }
465 fn is_list_item(t: &str) -> bool {
466 let mut chars = t.chars();
467 match chars.next() {
468 Some(c) if is_bullet_glyph(c) => true,
469 Some('*') => chars.next().is_none(),
471 _ => false,
472 }
473 }
474 table
475 .rows
476 .iter()
477 .flat_map(|r| &r.cells)
478 .filter(|c| !c.text.trim().is_empty())
479 .any(|c| is_list_item(c.text.trim()))
480}
481
482fn detect_page_columns(spans: &[TextSpan]) -> Vec<(f32, f32)> {
492 if spans.is_empty() {
493 return Vec::new();
494 }
495
496 const MAX_EXTENT_FROM_MEDIAN: f32 = 5_000.0;
509
510 let mut x_centers: Vec<f32> = spans.iter().map(|s| s.bbox.x + s.bbox.width * 0.5).collect();
511 x_centers.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
512 let median_x = x_centers[x_centers.len() / 2];
513
514 let mut page_x_min = f32::MAX;
515 let mut page_x_max = f32::MIN;
516 for s in spans {
517 let center = s.bbox.x + s.bbox.width * 0.5;
518 if (center - median_x).abs() > MAX_EXTENT_FROM_MEDIAN {
519 continue;
520 }
521 let left = s.bbox.x;
522 let right = s.bbox.x + s.bbox.width;
523 if left < page_x_min {
524 page_x_min = left;
525 }
526 if right > page_x_max {
527 page_x_max = right;
528 }
529 }
530
531 if page_x_min >= page_x_max {
532 return vec![(
534 spans.iter().map(|s| s.bbox.x).fold(f32::MAX, f32::min),
535 spans.iter().map(|s| s.bbox.x + s.bbox.width).fold(f32::MIN, f32::max),
536 )];
537 }
538
539 let page_width = page_x_max - page_x_min;
540
541 if page_width > 10_000.0 {
544 tracing::warn!(
545 page_width,
546 "detect_page_columns: page width still exceeds safe limit after outlier filtering, \
547 falling back to single column"
548 );
549 return vec![(page_x_min, page_x_max)];
550 }
551
552 let bucket_size = 2.0_f32;
553 let n_buckets = ((page_width) / bucket_size).ceil() as usize + 1;
554 let mut histogram = vec![0u32; n_buckets];
555
556 for s in spans {
557 let center = s.bbox.x + s.bbox.width * 0.5;
558 if (center - median_x).abs() > MAX_EXTENT_FROM_MEDIAN {
559 continue;
560 }
561 let left = s.bbox.x;
562 let right = s.bbox.x + s.bbox.width;
563 let b_start = ((left - page_x_min) / bucket_size).floor() as usize;
564 let b_end = ((right - page_x_min) / bucket_size).ceil() as usize;
565 for b in b_start..b_end.min(n_buckets) {
566 histogram[b] += 1;
567 }
568 }
569
570 let min_gap_pt = 20.0_f32;
571 let min_gap_buckets = ((min_gap_pt / bucket_size).ceil() as usize).max(1);
572
573 struct Gap {
574 start_bucket: usize,
575 len_buckets: usize,
576 }
577
578 let mut gaps = Vec::new();
579 let mut gap_start: Option<usize> = None;
580
581 for (i, &count) in histogram.iter().enumerate() {
582 if count == 0 {
583 if gap_start.is_none() {
584 gap_start = Some(i);
585 }
586 } else if let Some(gs) = gap_start {
587 let gap_len = i - gs;
588 if gap_len >= min_gap_buckets {
589 gaps.push(Gap {
590 start_bucket: gs,
591 len_buckets: gap_len,
592 });
593 }
594 gap_start = None;
595 }
596 }
597
598 let min_paragraph_width = 80.0_f32;
605 let qualifying_indices: Vec<usize> = (0..gaps.len())
606 .filter(|&gi| {
607 let gap_left_x = page_x_min + gaps[gi].start_bucket as f32 * bucket_size;
608 let gap_right_x = page_x_min + (gaps[gi].start_bucket + gaps[gi].len_buckets) as f32 * bucket_size;
609
610 let left_bound = if gi > 0 {
611 page_x_min + (gaps[gi - 1].start_bucket + gaps[gi - 1].len_buckets) as f32 * bucket_size
612 } else {
613 page_x_min
614 };
615 let right_bound = if gi + 1 < gaps.len() {
616 page_x_min + gaps[gi + 1].start_bucket as f32 * bucket_size
617 } else {
618 page_x_max
619 };
620
621 let has_wide_left = spans.iter().any(|s| {
622 let center = s.bbox.x + s.bbox.width / 2.0;
623 center >= left_bound && center <= gap_left_x && s.bbox.width >= min_paragraph_width
624 });
625
626 let has_wide_right = spans.iter().any(|s| {
627 let center = s.bbox.x + s.bbox.width / 2.0;
628 center >= gap_right_x && center <= right_bound && s.bbox.width >= min_paragraph_width
629 });
630
631 has_wide_left || has_wide_right
632 })
633 .collect();
634 let qualifying_gaps: Vec<&Gap> = qualifying_indices.iter().map(|&i| &gaps[i]).collect();
635
636 if qualifying_gaps.is_empty() {
637 return vec![(page_x_min, page_x_max)];
638 }
639
640 let mut columns = Vec::new();
641
642 let first_occ = match histogram.iter().position(|&c| c > 0) {
643 Some(b) => b,
644 None => return Vec::new(),
645 };
646 let mut region_start = first_occ;
647
648 for gap in &qualifying_gaps {
649 if gap.start_bucket > region_start {
650 let x_min = page_x_min + region_start as f32 * bucket_size;
651 let x_max = page_x_min + gap.start_bucket as f32 * bucket_size;
652 columns.push((x_min, x_max));
653 }
654 region_start = gap.start_bucket + gap.len_buckets;
655 }
656
657 let last_occ = histogram.iter().rposition(|&c| c > 0).unwrap_or(n_buckets - 1);
658 if region_start <= last_occ {
659 let x_min = page_x_min + region_start as f32 * bucket_size;
660 let x_max = page_x_min + (last_occ + 1) as f32 * bucket_size;
661 columns.push((x_min, x_max));
662 }
663
664 columns
665}
666
667pub fn detect_tables_from_spans_column_aware(spans: &[TextSpan], config: &TableDetectionConfig) -> Vec<Table> {
674 if !config.enabled || spans.is_empty() {
675 return Vec::new();
676 }
677
678 let page_cols = detect_page_columns(spans);
679
680 if page_cols.len() <= 1 {
681 return detect_tables_from_spans(spans, config);
682 }
683
684 let mut all_tables = Vec::new();
685 for &(col_x_min, col_x_max) in &page_cols {
686 let col_spans: Vec<TextSpan> = spans
687 .iter()
688 .filter(|s| {
689 let span_center = s.bbox.x + s.bbox.width / 2.0;
690 span_center >= col_x_min && span_center <= col_x_max
691 })
692 .cloned()
693 .collect();
694 if col_spans.is_empty() {
695 continue;
696 }
697 let mut tables = detect_tables_from_spans(&col_spans, config);
698 all_tables.append(&mut tables);
699 }
700
701 all_tables
702}
703
704pub fn detect_tables_from_spans(spans: &[TextSpan], config: &TableDetectionConfig) -> Vec<Table> {
706 if !config.enabled || spans.is_empty() {
707 return Vec::new();
708 }
709
710 let mut columns = detect_columns(spans, config.column_tolerance, config.column_merge_threshold);
711
712 if columns.len() > config.max_table_columns {
729 let te_columns = detect_text_edge_columns(spans, config);
730 if te_columns.len() >= config.min_table_columns.max(2) && te_columns.len() < columns.len() {
731 columns = te_columns;
732 }
733 }
734
735 let numeric_spans = spans.iter().filter(|s| is_numeric_cell(s.text.trim())).count();
745 if numeric_spans >= 10 && columns.len() <= config.max_table_columns {
746 let te_columns = detect_text_edge_columns(spans, config);
747 if te_columns.len() > columns.len()
748 && te_columns.len() >= 5
749 && te_columns.len() <= config.max_table_columns
750 && is_regular_lattice(&te_columns)
751 {
752 let probe_rows = detect_rows(spans, config.row_tolerance);
758 if probe_rows.len() >= 2 {
759 let probe_grid = assign_spans_to_cells(spans, &te_columns, &probe_rows);
760 if validate_table_structure_internal(&probe_grid, config) {
761 let probe_table = grid_to_table(&probe_grid, spans, None);
762 if is_valid_table(&probe_table)
763 && passes_spatial_quality_gate(&probe_table)
764 && !looks_like_prose_paragraph(&probe_table)
765 {
766 columns = te_columns;
767 }
768 }
769 }
770 }
771 }
772
773 if columns.len() < config.min_table_columns.max(2) || columns.len() > config.max_table_columns {
774 return Vec::new();
775 }
776
777 let rows = detect_rows(spans, config.row_tolerance);
778 if rows.len() < 2 {
779 return Vec::new();
780 }
781
782 let orig_grid = assign_spans_to_cells(spans, &columns, &rows);
793 if !validate_table_structure_internal(&orig_grid, config) {
794 return Vec::new();
795 }
796 let orig_table = grid_to_table(&orig_grid, spans, None);
797 if !is_valid_table(&orig_table)
798 || !passes_spatial_quality_gate(&orig_table)
799 || looks_like_prose_paragraph(&orig_table)
800 || looks_like_bulleted_list(&orig_table)
801 || looks_like_cjk_prose(&orig_table)
802 {
803 return Vec::new();
804 }
805
806 if rows.len() >= 3 {
816 columns = filter_columns_by_row_coverage(&columns, &rows, spans);
817 if columns.len() < config.min_table_columns.max(2) {
818 return Vec::new();
819 }
820 }
821
822 let grid = assign_spans_to_cells(spans, &columns, &rows);
823 if !validate_table_structure_internal(&grid, config) {
824 return Vec::new();
825 }
826
827 let table = grid_to_table(&grid, spans, None);
828 if !is_valid_table(&table)
829 || !passes_spatial_quality_gate(&table)
830 || looks_like_prose_paragraph(&table)
831 || looks_like_bulleted_list(&table)
832 || looks_like_cjk_prose(&table)
833 {
834 return Vec::new();
835 }
836
837 let table = if BUCKET_BORDERLESS_TABLE_GRID_IN_ROTATED_FRAME {
853 reorient_table_to_rotated_frame(table)
854 } else {
855 table
856 };
857 vec![table]
858}
859
860const BUCKET_BORDERLESS_TABLE_GRID_IN_ROTATED_FRAME: bool = true;
867
868#[derive(Debug, Clone)]
869struct ColumnCluster {
870 x_center: f32,
871 x_min: f32,
872 x_max: f32,
873 span_indices: Vec<usize>,
874}
875
876#[derive(Debug, Clone)]
877struct RowCluster {
878 y_center: f32,
879 y_min: f32,
880 y_max: f32,
881 span_indices: Vec<usize>,
882}
883
884#[derive(Debug, Clone)]
885struct GridStructure {
886 columns: Vec<ColumnCluster>,
887 rows: Vec<RowCluster>,
888 cells: Vec<Vec<Vec<usize>>>,
889}
890
891impl GridStructure {
892 fn is_row_empty(&self, row_idx: usize) -> bool {
893 self.cells[row_idx].iter().all(|cell| cell.is_empty())
894 }
895
896 fn is_column_empty(&self, col_idx: usize) -> bool {
897 for row in &self.cells {
898 if !row[col_idx].is_empty() {
899 return false;
900 }
901 }
902 true
903 }
904
905 fn trim_empty_columns(&self) -> GridStructure {
906 let num_rows = self.cells.len();
907 let num_cols = self.columns.len();
908
909 let mut first_col = 0;
910 while first_col < num_cols && self.is_column_empty(first_col) {
911 first_col += 1;
912 }
913
914 let mut last_col = num_cols;
915 while last_col > first_col && self.is_column_empty(last_col - 1) {
916 last_col -= 1;
917 }
918
919 if first_col >= last_col {
920 return self.clone();
921 }
922
923 let mut active_cols = Vec::new();
924 for c in first_col..last_col {
925 let col_width = self.columns[c].x_max - self.columns[c].x_min;
926 if col_width < 2.0 && self.is_column_empty(c) {
927 continue;
928 }
929 active_cols.push(c);
930 }
931
932 if active_cols.is_empty() {
933 return self.clone();
934 }
935
936 let new_columns: Vec<ColumnCluster> = active_cols.iter().map(|&c| self.columns[c].clone()).collect();
937
938 let mut new_cells = Vec::with_capacity(num_rows);
939 for r in 0..num_rows {
940 let row_cells = active_cols.iter().map(|&c| self.cells[r][c].clone()).collect();
941 new_cells.push(row_cells);
942 }
943
944 GridStructure {
945 columns: new_columns,
946 rows: self.rows.clone(),
947 cells: new_cells,
948 }
949 }
950}
951
952#[derive(Debug, Clone)]
953struct CellMergeInfo {
954 colspan: u32,
955 rowspan: u32,
956 covered: bool,
957}
958
959fn filter_columns_by_row_coverage(
970 columns: &[ColumnCluster],
971 rows: &[RowCluster],
972 spans: &[TextSpan],
973) -> Vec<ColumnCluster> {
974 let num_rows = rows.len();
975 if num_rows < 3 {
976 return columns.to_vec();
977 }
978 let min_cov = (((num_rows as f32) * 0.6).ceil() as usize).max(2);
979
980 let span_row = |sidx: usize| -> Option<usize> {
981 let cy = spans[sidx].bbox.center().y;
982 rows.iter().position(|r| cy <= r.y_max && cy >= r.y_min)
983 };
984
985 let kept: Vec<ColumnCluster> = columns
986 .iter()
987 .filter(|col| {
988 let mut seen: Vec<usize> = col.span_indices.iter().filter_map(|&s| span_row(s)).collect();
989 seen.sort_unstable();
990 seen.dedup();
991 seen.len() >= min_cov
992 })
993 .cloned()
994 .collect();
995
996 if kept.len() >= 2 { kept } else { columns.to_vec() }
1000}
1001
1002fn detect_columns(spans: &[TextSpan], column_tolerance: f32, merge_threshold: f32) -> Vec<ColumnCluster> {
1003 let mut sorted_indices: Vec<usize> = (0..spans.len()).collect();
1005 sorted_indices.sort_by(|&a, &b| crate::utils::safe_float_cmp(spans[a].bbox.left(), spans[b].bbox.left()));
1006
1007 let mut columns: Vec<ColumnCluster> = Vec::new();
1008 for idx in sorted_indices {
1009 let x = spans[idx].bbox.left();
1010 let mut found = false;
1011 for col in &mut columns {
1012 if (x - col.x_center).abs() < column_tolerance {
1013 col.span_indices.push(idx);
1014 col.x_min = col.x_min.min(x);
1015 col.x_max = col.x_max.max(x);
1016 let n = col.span_indices.len() as f32;
1019 col.x_center = col.x_center * ((n - 1.0) / n) + x / n;
1020 found = true;
1021 break;
1022 }
1023 }
1024 if !found {
1025 columns.push(ColumnCluster {
1026 x_center: x,
1027 x_min: x,
1028 x_max: x,
1029 span_indices: vec![idx],
1030 });
1031 }
1032 }
1033
1034 columns.sort_by(|a, b| crate::utils::safe_float_cmp(a.x_center, b.x_center));
1035
1036 let effective_merge_threshold = if columns.len() >= 3 {
1049 let mut gaps: Vec<f32> = columns
1050 .windows(2)
1051 .map(|w| w[1].x_center - w[0].x_center)
1052 .filter(|g| *g > 0.0)
1053 .collect();
1054 if gaps.is_empty() {
1055 merge_threshold
1056 } else {
1057 gaps.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
1058 let median_gap = gaps[gaps.len() / 2];
1059 merge_threshold.min(median_gap * 0.6)
1060 }
1061 } else {
1062 merge_threshold
1063 };
1064
1065 let mut merged: Vec<ColumnCluster> = Vec::new();
1066 for col in columns {
1067 let should_merge = merged.last().is_some_and(|prev: &ColumnCluster| {
1068 (col.x_center - prev.x_center).abs() < effective_merge_threshold || col.x_min <= prev.x_max
1069 });
1070 if should_merge {
1071 let prev = merged.last_mut().unwrap();
1072 prev.x_min = prev.x_min.min(col.x_min);
1073 prev.x_max = prev.x_max.max(col.x_max);
1074 let total = prev.span_indices.len() as f32 + col.span_indices.len() as f32;
1075 prev.x_center = prev.x_center * (prev.span_indices.len() as f32 / total)
1076 + col.x_center * (col.span_indices.len() as f32 / total);
1077 prev.span_indices.extend(col.span_indices);
1078 } else {
1079 merged.push(col);
1080 }
1081 }
1082
1083 merged.sort_by(|a, b| crate::utils::safe_float_cmp(a.x_center, b.x_center));
1084 merged
1085}
1086
1087fn is_numeric_cell(t: &str) -> bool {
1102 if t.is_empty() || t.len() > 8 {
1103 return false;
1104 }
1105 let t = t.strip_suffix('%').unwrap_or(t);
1106 let t = t.strip_prefix(['+', '-', '\u{2212}']).unwrap_or(t);
1107 let mut seen_dot = false;
1108 let mut seen_digit = false;
1109 for c in t.chars() {
1110 match c {
1111 '0'..='9' => seen_digit = true,
1112 '.' if !seen_dot => seen_dot = true,
1113 _ => return false,
1114 }
1115 }
1116 seen_digit
1117}
1118
1119fn is_regular_lattice(cols: &[ColumnCluster]) -> bool {
1124 if cols.len() < 5 {
1125 return false;
1126 }
1127 let mut centers: Vec<f32> = cols.iter().map(|c| c.x_center).collect();
1128 centers.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
1129 let gaps: Vec<f32> = centers.windows(2).map(|w| w[1] - w[0]).collect();
1130 let mut sorted = gaps.clone();
1131 sorted.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
1132 let median = sorted[sorted.len() / 2];
1133 if median <= 0.0 {
1134 return false;
1135 }
1136 let on_pitch = gaps.iter().filter(|&&g| g >= median * 0.6 && g <= median * 1.6).count();
1137 on_pitch + 2 >= gaps.len()
1138}
1139
1140fn detect_text_edge_columns(spans: &[TextSpan], config: &TableDetectionConfig) -> Vec<ColumnCluster> {
1141 if spans.is_empty() {
1142 return Vec::new();
1143 }
1144
1145 let snap_tolerance = config.column_tolerance;
1146 let min_row_count: usize = 3;
1147
1148 let row_tol = config.row_tolerance;
1149
1150 let mut row_ids: Vec<usize> = Vec::with_capacity(spans.len());
1151 let mut row_centres: Vec<f32> = Vec::new();
1152 for span in spans {
1153 let y = span.bbox.center().y;
1154 let mut assigned = None;
1155 for (rid, rc) in row_centres.iter().enumerate() {
1156 if (y - rc).abs() < row_tol {
1157 assigned = Some(rid);
1158 break;
1159 }
1160 }
1161 match assigned {
1162 Some(rid) => row_ids.push(rid),
1163 None => {
1164 row_ids.push(row_centres.len());
1165 row_centres.push(y);
1166 }
1167 }
1168 }
1169
1170 let mut edge_obs: Vec<(f32, usize)> = Vec::with_capacity(spans.len() * 2);
1171 for (i, span) in spans.iter().enumerate() {
1172 edge_obs.push((span.bbox.left(), row_ids[i]));
1173 edge_obs.push((span.bbox.right(), row_ids[i]));
1174 }
1175 edge_obs.sort_by(|a, b| crate::utils::safe_float_cmp(a.0, b.0));
1177
1178 struct XCluster {
1179 x_center: f32,
1180 count: usize,
1181 rows: Vec<usize>,
1182 }
1183
1184 let mut x_clusters: Vec<XCluster> = Vec::new();
1185 for &(x, rid) in &edge_obs {
1186 let mut found = false;
1187 for cl in &mut x_clusters {
1188 if (x - cl.x_center).abs() < snap_tolerance {
1189 let n = cl.count as f32;
1190 cl.x_center = cl.x_center * (n / (n + 1.0)) + x / (n + 1.0);
1191 cl.count += 1;
1192 cl.rows.push(rid);
1193 found = true;
1194 break;
1195 }
1196 }
1197 if !found {
1198 x_clusters.push(XCluster {
1199 x_center: x,
1200 count: 1,
1201 rows: vec![rid],
1202 });
1203 }
1204 }
1205
1206 let mut edges: Vec<f32> = Vec::new();
1207 for cl in &mut x_clusters {
1208 cl.rows.sort_unstable();
1209 cl.rows.dedup();
1210 if cl.rows.len() >= min_row_count {
1211 edges.push(cl.x_center);
1212 }
1213 }
1214 edges.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
1215
1216 let mut deduped: Vec<f32> = Vec::new();
1218 for &e in &edges {
1219 if deduped.last().is_some_and(|prev| (e - prev).abs() < snap_tolerance) {
1220 let prev = deduped.last_mut().unwrap();
1221 *prev = (*prev + e) / 2.0;
1222 } else {
1223 deduped.push(e);
1224 }
1225 }
1226
1227 let mut columns: Vec<ColumnCluster> = deduped
1228 .iter()
1229 .map(|&x| ColumnCluster {
1230 x_center: x,
1231 x_min: x,
1232 x_max: x,
1233 span_indices: Vec::new(),
1234 })
1235 .collect();
1236
1237 if columns.is_empty() {
1238 return columns;
1239 }
1240
1241 for (idx, span) in spans.iter().enumerate() {
1242 let sx = span.bbox.left();
1243 let best = columns
1244 .iter()
1245 .enumerate()
1246 .min_by_key(|(_, c)| ((sx - c.x_center).abs() * 1000.0) as i32)
1247 .map(|(i, _)| i)
1248 .unwrap_or(0);
1249 columns[best].span_indices.push(idx);
1250 columns[best].x_min = columns[best].x_min.min(sx);
1251 columns[best].x_max = columns[best].x_max.max(sx);
1252 }
1253
1254 columns.retain(|c| !c.span_indices.is_empty());
1255
1256 columns.sort_by(|a, b| crate::utils::safe_float_cmp(a.x_center, b.x_center));
1257 columns
1258}
1259
1260fn detect_rows(spans: &[TextSpan], row_tolerance: f32) -> Vec<RowCluster> {
1261 let mut sorted_indices: Vec<usize> = (0..spans.len()).collect();
1263 sorted_indices.sort_by(|&a, &b| crate::utils::safe_float_cmp(spans[a].bbox.center().y, spans[b].bbox.center().y));
1264
1265 let mut rows: Vec<RowCluster> = Vec::new();
1266 for idx in sorted_indices {
1267 let y = spans[idx].bbox.center().y;
1268 let mut found = false;
1269 for row in &mut rows {
1270 if (y - row.y_center).abs() < row_tolerance {
1271 row.span_indices.push(idx);
1272 row.y_min = row.y_min.min(y);
1273 row.y_max = row.y_max.max(y);
1274 let n = row.span_indices.len() as f32;
1276 row.y_center = row.y_center * ((n - 1.0) / n) + y / n;
1277 found = true;
1278 break;
1279 }
1280 }
1281 if !found {
1282 rows.push(RowCluster {
1283 y_center: y,
1284 y_min: y,
1285 y_max: y,
1286 span_indices: vec![idx],
1287 });
1288 }
1289 }
1290 rows.sort_by(|a, b| crate::utils::safe_float_cmp(b.y_center, a.y_center));
1291 rows
1292}
1293
1294fn assign_spans_to_cells(spans: &[TextSpan], columns: &[ColumnCluster], rows: &[RowCluster]) -> GridStructure {
1295 let num_cols = columns.len();
1296 let num_rows = rows.len();
1297 let mut cells: Vec<Vec<Vec<usize>>> = vec![vec![Vec::new(); num_cols]; num_rows];
1298 for (idx, span) in spans.iter().enumerate() {
1299 let span_x = span.bbox.center().x;
1300 let span_y = span.bbox.center().y;
1301 let col_idx = columns
1302 .iter()
1303 .enumerate()
1304 .min_by_key(|(_, col)| ((span_x - col.x_center).abs() * 1000.0) as i32)
1305 .map(|(i, _)| i)
1306 .unwrap_or(0);
1307 let row_idx = rows
1308 .iter()
1309 .enumerate()
1310 .min_by_key(|(_, row)| ((span_y - row.y_center).abs() * 1000.0) as i32)
1311 .map(|(i, _)| i)
1312 .unwrap_or(0);
1313 cells[row_idx][col_idx].push(idx);
1314 }
1315 GridStructure {
1316 columns: columns.to_vec(),
1317 rows: rows.to_vec(),
1318 cells,
1319 }
1320}
1321
1322const MAX_MASK_COLUMNS: usize = 128;
1326
1327const MIN_SPLIT_GROUP_ROW_SHARE: f32 = 0.20;
1333
1334fn validate_table_structure_internal(grid: &GridStructure, config: &TableDetectionConfig) -> bool {
1335 let num_cols = grid.columns.len();
1336 let total_cells: usize = grid
1337 .cells
1338 .iter()
1339 .flat_map(|row| row.iter().take(num_cols))
1340 .map(|cell| if cell.is_empty() { 0 } else { 1 })
1341 .sum();
1342 if total_cells < config.min_table_cells {
1343 return false;
1344 }
1345 let cell_counts: Vec<usize> = grid
1346 .cells
1347 .iter()
1348 .map(|row| row.iter().take(num_cols).filter(|cell| !cell.is_empty()).count())
1349 .collect();
1350 if cell_counts.is_empty() {
1351 return false;
1352 }
1353 let most_common_count = *cell_counts
1354 .iter()
1355 .max_by_key(|&&count| cell_counts.iter().filter(|&&c| c == count).count())
1356 .unwrap_or(&0);
1357 if most_common_count == 0 {
1358 return false;
1359 }
1360 let regular_rows = cell_counts.iter().filter(|&&count| count == most_common_count).count();
1361 if (regular_rows as f32 / cell_counts.len() as f32) < config.regular_row_ratio {
1362 return false;
1363 }
1364
1365 if has_split_modal_column_groups(grid, most_common_count) {
1366 return false;
1367 }
1368
1369 true
1370}
1371
1372fn has_split_modal_column_groups(grid: &GridStructure, most_common_count: usize) -> bool {
1388 let num_cols = grid.columns.len();
1389
1390 if !(4..=MAX_MASK_COLUMNS).contains(&num_cols) || most_common_count < 2 {
1393 return false;
1394 }
1395
1396 let modal_masks: Vec<u128> = grid
1401 .cells
1402 .iter()
1403 .filter_map(|row| {
1404 let populated = row.iter().take(num_cols).filter(|cell| !cell.is_empty()).count();
1405
1406 if populated != most_common_count {
1407 return None;
1408 }
1409
1410 let mut mask = 0u128;
1411 for (idx, cell) in row.iter().take(num_cols).enumerate() {
1412 if !cell.is_empty() {
1413 mask |= 1u128 << idx;
1414 }
1415 }
1416
1417 if mask.count_ones() >= 2 { Some(mask) } else { None }
1418 })
1419 .collect();
1420
1421 if modal_masks.len() < 4 {
1422 return false;
1423 }
1424
1425 let min_component_rows = (((modal_masks.len() as f32) * MIN_SPLIT_GROUP_ROW_SHARE).ceil() as usize).max(2);
1429
1430 let mut adjacency: Vec<u128> = vec![0u128; num_cols];
1431 let mut active_columns: u128 = 0;
1432
1433 for &mask in &modal_masks {
1434 active_columns |= mask;
1435 let mut bits = mask;
1436 while bits != 0 {
1437 let bit = bits & bits.wrapping_neg();
1438 let col = bit.trailing_zeros() as usize;
1439 adjacency[col] |= mask;
1440 bits &= !bit;
1441 }
1442 }
1443
1444 let mut remaining = active_columns;
1445 let mut significant_components = 0usize;
1446
1447 while remaining != 0 {
1448 let seed_bit = remaining & remaining.wrapping_neg();
1449 let mut component: u128 = 0;
1450 let mut frontier: u128 = seed_bit;
1451
1452 while frontier != 0 {
1453 let bit = frontier & frontier.wrapping_neg();
1454 frontier &= !bit;
1455
1456 if component & bit != 0 {
1457 continue;
1458 }
1459
1460 component |= bit;
1461 let col = bit.trailing_zeros() as usize;
1462 frontier |= adjacency[col] & !component;
1463 }
1464
1465 remaining &= !component;
1466
1467 let component_cols = component.count_ones() as usize;
1468 let component_row_support = modal_masks.iter().filter(|&&mask| mask & component != 0).count();
1469
1470 if component_cols >= 2 && component_row_support >= min_component_rows {
1471 significant_components += 1;
1472 if significant_components >= 2 {
1473 return true;
1474 }
1475 }
1476 }
1477
1478 false
1479}
1480
1481#[derive(Debug, Clone)]
1483pub struct DetectedTable {
1484 pub span_indices: Vec<usize>,
1486}
1487
1488pub struct SpatialTableDetector {
1490 pub config: TableDetectionConfig,
1492}
1493
1494impl SpatialTableDetector {
1495 pub fn with_config(config: TableDetectionConfig) -> Self {
1497 Self { config }
1498 }
1499 pub fn detect_tables(&self, spans: &[TextSpan]) -> Vec<DetectedTable> {
1501 detect_tables_from_spans_column_aware(spans, &self.config)
1502 .into_iter()
1503 .flat_map(|_| None)
1504 .collect()
1505 }
1506 pub fn detect_tables_hybrid(&self, spans: &[TextSpan], lines: &[crate::elements::PathContent]) -> Vec<Table> {
1508 detect_tables_with_lines(spans, lines, &self.config)
1509 }
1510}
1511
1512fn cluster_values(values: &[f32], tolerance: f32) -> Vec<f32> {
1513 let mut clusters: Vec<f32> = Vec::new();
1514 let mut counts: Vec<u32> = Vec::new();
1515 for &v in values {
1516 if let Some(idx) = clusters.iter().position(|&c| (v - c).abs() < tolerance) {
1517 counts[idx] += 1;
1518 clusters[idx] += (v - clusters[idx]) / counts[idx] as f32;
1519 } else {
1520 clusters.push(v);
1521 counts.push(1);
1522 }
1523 }
1524 clusters
1525}
1526
1527struct LineCluster {
1528 lines: Vec<usize>,
1529 bbox: crate::geometry::Rect,
1530}
1531
1532impl LineCluster {
1533 fn new(line_idx: usize, bbox: crate::geometry::Rect) -> Self {
1534 Self {
1535 lines: vec![line_idx],
1536 bbox,
1537 }
1538 }
1539 fn add(&mut self, line_idx: usize, bbox: crate::geometry::Rect) {
1540 self.lines.push(line_idx);
1541 self.bbox = self.bbox.union(&bbox);
1542 }
1543}
1544
1545fn group_lines_into_clusters(
1546 lines: &[crate::elements::PathContent],
1547 config: &TableDetectionConfig,
1548) -> Vec<LineCluster> {
1549 if lines.is_empty() {
1550 return Vec::new();
1551 }
1552 let rendered: Vec<crate::geometry::Rect> = lines.iter().map(|p| p.rendered_bbox()).collect();
1558 let mut uf = UnionFind::new(lines.len());
1559 let mut valid_indices: Vec<usize> = lines
1560 .iter()
1561 .enumerate()
1562 .filter(|(_, path)| path.is_table_primitive())
1563 .map(|(i, _)| i)
1564 .collect();
1565
1566 valid_indices.sort_by(|&a, &b| crate::utils::safe_float_cmp(rendered[a].x, rendered[b].x));
1568
1569 const EXPANSION: f32 = 3.0;
1570 for i in 0..valid_indices.len() {
1571 let idx_a = valid_indices[i];
1572 let bbox_a = &rendered[idx_a];
1573 let expanded_a = crate::geometry::Rect::new(
1574 bbox_a.x - EXPANSION,
1575 bbox_a.y - EXPANSION,
1576 bbox_a.width + EXPANSION * 2.0,
1577 bbox_a.height + EXPANSION * 2.0,
1578 );
1579
1580 for j in (i + 1)..valid_indices.len() {
1581 let idx_b = valid_indices[j];
1582 let bbox_b = &rendered[idx_b];
1583
1584 if bbox_b.x > expanded_a.x + expanded_a.width {
1587 break;
1588 }
1589
1590 let expanded_b = crate::geometry::Rect::new(
1591 bbox_b.x - EXPANSION,
1592 bbox_b.y - EXPANSION,
1593 bbox_b.width + EXPANSION * 2.0,
1594 bbox_b.height + EXPANSION * 2.0,
1595 );
1596
1597 if expanded_a.intersects(&expanded_b) {
1598 uf.union(idx_a, idx_b);
1599 }
1600 }
1601 }
1602 let mut cluster_map: HashMap<usize, LineCluster> = HashMap::new();
1603 for i in valid_indices {
1604 let root = uf.find(i);
1605 let bbox = rendered[i];
1606 cluster_map
1607 .entry(root)
1608 .and_modify(|c| c.add(i, bbox))
1609 .or_insert_with(|| LineCluster::new(i, bbox));
1610 }
1611
1612 let mut raw_clusters: Vec<LineCluster> = cluster_map.into_values().collect();
1620 raw_clusters.sort_by_key(|c| c.lines.first().copied().unwrap_or(usize::MAX));
1621 let mut result: Vec<LineCluster> = Vec::with_capacity(raw_clusters.len());
1622 const LINE_AXIS_TOL: f32 = 2.0;
1623 let v_split_gap = config.v_split_gap;
1624
1625 for cluster in raw_clusters {
1626 let mut v_ranges: Vec<(usize, f32, f32)> = Vec::new();
1627 for &idx in &cluster.lines {
1628 let path = &lines[idx];
1629 if path.is_vertical_line(LINE_AXIS_TOL) && rendered[idx].height.abs() > 5.0 {
1630 let y_min = rendered[idx].y;
1631 let y_max = rendered[idx].y + rendered[idx].height;
1632 let (y_min, y_max) = if y_min <= y_max { (y_min, y_max) } else { (y_max, y_min) };
1633 v_ranges.push((idx, y_min, y_max));
1634 }
1635 }
1636
1637 if v_ranges.len() < 2 {
1638 result.push(cluster);
1639 continue;
1640 }
1641
1642 v_ranges.sort_by(|a, b| crate::utils::safe_float_cmp(a.1, b.1));
1643 let mut bands: Vec<(f32, f32)> = Vec::new();
1644 let mut band_start = v_ranges[0].1;
1645 let mut band_end = v_ranges[0].2;
1646 for &(_, y_min, y_max) in &v_ranges[1..] {
1647 if y_min > band_end + v_split_gap {
1648 bands.push((band_start, band_end));
1649 band_start = y_min;
1650 band_end = y_max;
1651 } else {
1652 band_end = band_end.max(y_max);
1653 }
1654 }
1655 bands.push((band_start, band_end));
1656
1657 if bands.len() < 2 {
1658 result.push(cluster);
1659 continue;
1660 }
1661
1662 let mut sub_clusters: Vec<Vec<usize>> = vec![Vec::new(); bands.len()];
1663 for &idx in &cluster.lines {
1664 let bbox = &rendered[idx];
1665 let line_y_mid = bbox.y + bbox.height * 0.5;
1666 let mut best_band = 0;
1667 let mut best_dist = f32::MAX;
1668 for (bi, &(b_min, b_max)) in bands.iter().enumerate() {
1669 let dist = if line_y_mid >= b_min && line_y_mid <= b_max {
1670 0.0
1671 } else {
1672 (line_y_mid - b_min).abs().min((line_y_mid - b_max).abs())
1673 };
1674 if dist < best_dist {
1675 best_dist = dist;
1676 best_band = bi;
1677 }
1678 }
1679 sub_clusters[best_band].push(idx);
1680 }
1681
1682 for sub in sub_clusters {
1683 if sub.is_empty() {
1684 continue;
1685 }
1686 let first_bbox = rendered[sub[0]];
1687 let mut lc = LineCluster::new(sub[0], first_bbox);
1688 for &idx in &sub[1..] {
1689 lc.add(idx, rendered[idx]);
1690 }
1691 result.push(lc);
1692 }
1693 }
1694
1695 result
1696}
1697
1698fn detect_header_row_above(spans: &[TextSpan], row_ys: &[f32], col_xs: &[f32]) -> Option<f32> {
1714 const WINDOW_ROWS: f32 = 1.5;
1717 const MIN_SPAN_FRAC: f32 = 0.5;
1720
1721 if row_ys.len() < 2 || col_xs.len() < 2 {
1722 return None;
1723 }
1724 let grid_top = row_ys[0];
1725 let col_lo = *col_xs.first().unwrap();
1726 let col_hi = *col_xs.last().unwrap();
1727 let col_extent = col_hi - col_lo;
1728 if col_extent <= 0.0 {
1729 return None;
1730 }
1731
1732 let mut gaps: Vec<f32> = row_ys.windows(2).map(|w| (w[0] - w[1]).abs()).collect();
1733 gaps.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
1734 let median_row_h = gaps[gaps.len() / 2];
1735 if median_row_h <= 0.0 {
1736 return None;
1737 }
1738 let window_top = grid_top + WINDOW_ROWS * median_row_h;
1739
1740 let mut cols_hit: Vec<usize> = Vec::new();
1741 let mut header_max_top = f32::NEG_INFINITY;
1742 let mut cx_min = f32::INFINITY;
1743 let mut cx_max = f32::NEG_INFINITY;
1744 let mut cy_min = f32::INFINITY;
1745 let mut cy_max = f32::NEG_INFINITY;
1746 for span in spans {
1747 let cy = span.bbox.center().y;
1748 if cy <= grid_top || cy > window_top {
1749 continue;
1750 }
1751 let cx = span.bbox.center().x;
1752 if cx < col_lo || cx > col_hi {
1753 continue;
1754 }
1755 let Some(ci) = (0..col_xs.len() - 1).find(|&c| cx >= col_xs[c] && cx <= col_xs[c + 1]) else {
1756 continue;
1757 };
1758 if !cols_hit.contains(&ci) {
1759 cols_hit.push(ci);
1760 }
1761 header_max_top = header_max_top.max(span.bbox.y + span.bbox.height);
1762 cx_min = cx_min.min(cx);
1763 cx_max = cx_max.max(cx);
1764 cy_min = cy_min.min(cy);
1765 cy_max = cy_max.max(cy);
1766 }
1767
1768 if cols_hit.len() < 2 {
1769 return None;
1770 }
1771 if (cx_max - cx_min) < MIN_SPAN_FRAC * col_extent {
1772 return None;
1773 }
1774 if (cy_max - cy_min) > median_row_h {
1775 return None;
1776 }
1777
1778 Some(header_max_top + 1.0)
1779}
1780
1781fn detect_tables_in_cluster(
1782 spans: &[TextSpan],
1783 all_lines: &[crate::elements::PathContent],
1784 cluster: &LineCluster,
1785 config: &TableDetectionConfig,
1786) -> Vec<Table> {
1787 const MIN_LINE_LENGTH: f32 = 5.0;
1788 const LINE_AXIS_TOL: f32 = 2.0;
1789 let mut h_ys: Vec<f32> = Vec::new();
1790 let mut v_xs: Vec<f32> = Vec::new();
1791 for &idx in &cluster.lines {
1792 let path = &all_lines[idx];
1793 let bbox = path.rendered_bbox();
1796 if path.is_horizontal_line(LINE_AXIS_TOL) && bbox.width > MIN_LINE_LENGTH {
1797 h_ys.push(bbox.center().y);
1798 }
1799 if path.is_vertical_line(LINE_AXIS_TOL) && bbox.height.abs() > MIN_LINE_LENGTH {
1800 v_xs.push(bbox.center().x);
1801 }
1802 }
1803 let mut row_ys = cluster_values(&h_ys, config.row_tolerance);
1804 let mut col_xs = cluster_values(&v_xs, config.column_tolerance);
1805 if row_ys.len() < 2 || col_xs.len() < 2 {
1806 return Vec::new();
1807 }
1808 row_ys.sort_by(|a, b| crate::utils::safe_float_cmp(*b, *a));
1809 col_xs.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
1810 let mut assign_bbox = cluster.bbox;
1816 let mut inserted_header_row = false;
1817 if let Some(header_top) = detect_header_row_above(spans, &row_ys, &col_xs) {
1818 row_ys.insert(0, header_top);
1819 inserted_header_row = true;
1820 let new_height = (header_top - assign_bbox.y).max(assign_bbox.height);
1821 assign_bbox = crate::geometry::Rect::new(assign_bbox.x, assign_bbox.y, assign_bbox.width, new_height);
1822 }
1823 let num_rows = row_ys.len() - 1;
1824 let num_cols = col_xs.len() - 1;
1825 if num_cols < config.min_table_columns || num_cols > config.max_table_columns {
1826 return Vec::new();
1827 }
1828 let mut cells: Vec<Vec<Vec<usize>>> = vec![vec![Vec::new(); num_cols]; num_rows];
1829 let mut assigned_any = false;
1830 for (orig_idx, span) in spans.iter().enumerate() {
1831 if !assign_bbox.intersects(&span.bbox) {
1832 continue;
1833 }
1834 let cx = span.bbox.center().x;
1835 let cy = span.bbox.center().y;
1836 let row_idx = (0..num_rows).find(|&r| cy <= row_ys[r] && cy >= row_ys[r + 1]);
1837 let col_idx = (0..num_cols).find(|&c| cx >= col_xs[c] && cx <= col_xs[c + 1]);
1838 if let (Some(r), Some(c)) = (row_idx, col_idx) {
1839 cells[r][c].push(orig_idx);
1840 assigned_any = true;
1841 }
1842 }
1843 if !assigned_any {
1844 return Vec::new();
1845 }
1846 let columns: Vec<ColumnCluster> = (0..num_cols)
1847 .map(|c| ColumnCluster {
1848 x_center: (col_xs[c] + col_xs[c + 1]) / 2.0,
1849 x_min: col_xs[c],
1850 x_max: col_xs[c + 1],
1851 span_indices: Vec::new(),
1852 })
1853 .collect();
1854 let all_rows: Vec<RowCluster> = (0..num_rows)
1855 .map(|r| RowCluster {
1856 y_center: (row_ys[r] + row_ys[r + 1]) / 2.0,
1857 y_min: row_ys[r + 1],
1858 y_max: row_ys[r],
1859 span_indices: Vec::new(),
1860 })
1861 .collect();
1862 let grid_full = GridStructure {
1863 columns: columns.clone(),
1864 rows: all_rows.clone(),
1865 cells: cells.clone(),
1866 };
1867 let mut tables = Vec::new();
1868 let mut current_start_row = 0;
1869 while current_start_row < num_rows {
1870 if grid_full.is_row_empty(current_start_row) {
1871 current_start_row += 1;
1872 continue;
1873 }
1874 let mut current_end_row = current_start_row;
1875 while current_end_row < num_rows {
1876 if grid_full.is_row_empty(current_end_row) {
1877 break;
1878 }
1879 current_end_row += 1;
1880 }
1881 if current_end_row > current_start_row {
1882 let sub_cells = cells[current_start_row..current_end_row].to_vec();
1883 let sub_rows = all_rows[current_start_row..current_end_row].to_vec();
1884 let mut grid = GridStructure {
1885 columns: columns.clone(),
1886 rows: sub_rows,
1887 cells: sub_cells,
1888 };
1889 grid = grid.trim_empty_columns();
1890 if validate_table_structure_internal(&grid, config) {
1891 let protected_header_rows = usize::from(inserted_header_row && current_start_row == 0);
1895 let mut table = grid_to_table(
1896 &grid,
1897 spans,
1898 Some(detect_merged_cells_visually(
1899 &grid,
1900 spans,
1901 cluster,
1902 all_lines,
1903 protected_header_rows,
1904 )),
1905 );
1906 let mut min_y = f32::INFINITY;
1907 let mut max_y = f32::NEG_INFINITY;
1908 for r in &grid.rows {
1909 min_y = min_y.min(r.y_min);
1910 max_y = max_y.max(r.y_max);
1911 }
1912 table.bbox = Some(crate::geometry::Rect::new(
1913 cluster.bbox.x,
1914 min_y,
1915 cluster.bbox.width,
1916 max_y - min_y,
1917 ));
1918 let mut header_rows_detected = 0;
1919 let table_width = cluster.bbox.width;
1920 for r in 0..table.rows.len().min(3) {
1921 let row_bottom = grid.rows[r].y_min;
1922 let has_separator = cluster.lines.iter().any(|&idx| {
1923 let path = &all_lines[idx];
1924 let rendered = path.rendered_bbox();
1925 path.is_horizontal_line(LINE_AXIS_TOL)
1926 && rendered.width > table_width * 0.8
1927 && (rendered.center().y - row_bottom).abs() < config.row_tolerance
1928 });
1929 if has_separator {
1930 header_rows_detected = r + 1;
1931 } else if r == 0 && table.rows[r].has_colspan() {
1932 header_rows_detected = 1;
1933 } else {
1934 break;
1935 }
1936 }
1937 if header_rows_detected > 0 {
1938 table.has_header = true;
1939 for r in 0..header_rows_detected {
1940 if r < table.rows.len() {
1941 table.rows[r].is_header = true;
1942 for cell in &mut table.rows[r].cells {
1943 cell.is_header = true;
1944 }
1945 }
1946 }
1947 }
1948 tables.push(table);
1949 }
1950 }
1951 current_start_row = current_end_row + 1;
1952 }
1953 tables
1954}
1955
1956fn detect_merged_cells_visually(
1957 grid: &GridStructure,
1958 spans: &[TextSpan],
1959 cluster: &LineCluster,
1960 all_lines: &[crate::elements::PathContent],
1961 protected_header_rows: usize,
1962) -> Vec<Vec<CellMergeInfo>> {
1963 let num_rows = grid.cells.len();
1964 let num_cols = grid.columns.len();
1965 const LINE_TOLERANCE: f32 = 2.0;
1966 let mut merge_info: Vec<Vec<CellMergeInfo>> = (0..num_rows)
1967 .map(|_| {
1968 (0..num_cols)
1969 .map(|_| CellMergeInfo {
1970 colspan: 1,
1971 rowspan: 1,
1972 covered: false,
1973 })
1974 .collect()
1975 })
1976 .collect();
1977 for r in 0..num_rows {
1978 if r < protected_header_rows {
1984 continue;
1985 }
1986 let mut c = 0;
1987 while c < num_cols {
1988 if merge_info[r][c].covered {
1989 c += 1;
1990 continue;
1991 }
1992 let mut colspan = 1;
1993 let mut cell_text_width: f32 = 0.0;
1994 for &idx in &grid.cells[r][c] {
1995 cell_text_width = cell_text_width.max(spans[idx].bbox.width);
1996 }
1997 let mut total_cell_width = grid.columns[c].x_max - grid.columns[c].x_min;
1998 for next_c in (c + 1)..num_cols {
1999 let separator_x = grid.columns[next_c].x_min;
2000 let y_min = grid.rows[r].y_min;
2001 let y_max = grid.rows[r].y_max;
2002 let has_separator = cluster.lines.iter().any(|&idx| {
2003 let path = &all_lines[idx];
2004 let rendered = path.rendered_bbox();
2008 path.is_vertical_line(LINE_TOLERANCE)
2009 && (rendered.center().x - separator_x).abs() < LINE_TOLERANCE
2010 && rendered.y < y_max
2011 && (rendered.y + rendered.height) > y_min
2012 });
2013 if !has_separator || (cell_text_width > total_cell_width + 2.0) {
2014 colspan += 1;
2015 total_cell_width += grid.columns[next_c].x_max - grid.columns[next_c].x_min;
2016 } else {
2017 break;
2018 }
2019 }
2020 if colspan > 1 {
2021 merge_info[r][c].colspan = colspan;
2022 for i in 1..colspan {
2023 merge_info[r][c + i as usize].covered = true;
2024 }
2025 }
2026 c += colspan as usize;
2027 }
2028 }
2029 for c in 0..num_cols {
2030 let mut r = 0;
2031 while r < num_rows {
2032 if merge_info[r][c].covered {
2033 r += 1;
2034 continue;
2035 }
2036 let mut rowspan = 1;
2037 let current_colspan = merge_info[r][c].colspan;
2038 for next_r in (r + 1)..num_rows {
2039 let separator_y = grid.rows[next_r].y_max;
2040 let x_min = grid.columns[c].x_min;
2041 let x_max = grid.columns[c + current_colspan as usize - 1].x_max;
2042 let has_separator = cluster.lines.iter().any(|&idx| {
2043 let path = &all_lines[idx];
2044 let rendered = path.rendered_bbox();
2049 path.is_horizontal_line(LINE_TOLERANCE)
2050 && (rendered.center().y - separator_y).abs() < LINE_TOLERANCE
2051 && rendered.x < x_max
2052 && (rendered.x + rendered.width) > x_min
2053 });
2054 if !has_separator {
2055 rowspan += 1;
2056 } else {
2057 break;
2058 }
2059 }
2060 if rowspan > 1 {
2061 merge_info[r][c].rowspan = rowspan;
2062 for i in 1..rowspan {
2063 merge_info[r + i as usize][c].covered = true;
2064 for j in 1..current_colspan {
2065 merge_info[r + i as usize][c + j as usize].covered = true;
2066 }
2067 }
2068 }
2069 r += rowspan as usize;
2070 }
2071 }
2072 merge_info
2073}
2074
2075const SNAP_TOL: f32 = 3.0;
2077const JOIN_TOL: f32 = 3.0;
2079const MIN_EDGE_LEN: f32 = 5.0;
2081const DOTTED_MIN_SEGMENTS: usize = 3;
2084const DOTTED_MIN_SPAN: f32 = 50.0;
2087const DOTTED_COORD_SNAP: f32 = 10.0; #[derive(Debug, Clone, Copy)]
2092struct Edge {
2093 coord: f32,
2095 start: f32,
2097 end: f32,
2099}
2100
2101#[derive(Debug, Clone, Copy, PartialEq)]
2103struct Intersection {
2104 x: f32,
2105 y: f32,
2106}
2107
2108#[derive(Debug, Clone, Copy)]
2110struct IntersectionCell {
2111 x1: f32,
2112 y1: f32,
2113 x2: f32,
2114 y2: f32,
2115}
2116
2117fn extract_edges(lines: &[crate::elements::PathContent]) -> (Vec<Edge>, Vec<Edge>) {
2119 const LINE_AXIS_TOL: f32 = 2.0;
2120 let mut h_edges: Vec<Edge> = Vec::new();
2121 let mut v_edges: Vec<Edge> = Vec::new();
2122
2123 let mut rectangles = Vec::new();
2124 for path in lines {
2125 if path.is_horizontal_line(LINE_AXIS_TOL) {
2126 let rendered = path.rendered_bbox();
2130 h_edges.push(Edge {
2131 coord: rendered.center().y,
2132 start: rendered.left(),
2133 end: rendered.right(),
2134 });
2135 } else if path.is_vertical_line(LINE_AXIS_TOL) {
2136 let rendered = path.rendered_bbox();
2137 v_edges.push(Edge {
2138 coord: rendered.center().x,
2139 start: rendered.top(),
2140 end: rendered.bottom(),
2141 });
2142 } else if path.is_rectangle() {
2143 rectangles.push(path);
2144 }
2145 }
2146
2147 let mut ruled_h = h_edges.clone();
2150 let mut ruled_v = v_edges.clone();
2151 snap_and_merge(&mut ruled_h);
2152 snap_and_merge(&mut ruled_v);
2153 for path in &rectangles {
2154 let bbox = &path.bbox;
2155 let (l, r, t, b) = (bbox.left(), bbox.right(), bbox.top(), bbox.bottom());
2156 let inset_background = path.has_fill()
2160 && !path.has_stroke()
2161 && rectangles.iter().any(|outer| {
2162 outer.has_fill()
2163 && !outer.has_stroke()
2164 && outer.fill_color == path.fill_color
2165 && outer.bbox.left() < l - SNAP_TOL
2166 && outer.bbox.right() > r + SNAP_TOL
2167 && (outer.bbox.top() - t).abs() <= SNAP_TOL
2168 && (outer.bbox.bottom() - b).abs() <= SNAP_TOL
2169 });
2170 let inside_ruled_band = inset_background
2171 && ruled_h.iter().any(|top| {
2172 (top.coord - t).abs() <= SNAP_TOL
2173 && top.start <= l + SNAP_TOL
2174 && top.end >= r - SNAP_TOL
2175 && ruled_h.iter().any(|bottom| {
2176 (bottom.coord - b).abs() <= SNAP_TOL
2177 && bottom.start <= l + SNAP_TOL
2178 && bottom.end >= r - SNAP_TOL
2179 && [true, false].into_iter().all(|left| {
2180 ruled_v.iter().any(|side| {
2181 side.start <= t + SNAP_TOL
2182 && side.end >= b - SNAP_TOL
2183 && if left {
2184 side.coord <= l + SNAP_TOL
2185 && side.coord >= top.start.max(bottom.start) - SNAP_TOL
2186 } else {
2187 side.coord >= r - SNAP_TOL
2188 && side.coord <= top.end.min(bottom.end) + SNAP_TOL
2189 }
2190 })
2191 })
2192 })
2193 });
2194 if inside_ruled_band {
2195 continue;
2196 }
2197 h_edges.push(Edge {
2198 coord: t,
2199 start: l,
2200 end: r,
2201 });
2202 h_edges.push(Edge {
2203 coord: b,
2204 start: l,
2205 end: r,
2206 });
2207 v_edges.push(Edge {
2208 coord: l,
2209 start: t,
2210 end: b,
2211 });
2212 v_edges.push(Edge {
2213 coord: r,
2214 start: t,
2215 end: b,
2216 });
2217 }
2218 (h_edges, v_edges)
2219}
2220
2221fn snap_and_merge(edges: &mut Vec<Edge>) {
2224 snap_edges(edges);
2225 join_collinear_edges(edges);
2226 reconstitute_dotted_lines(edges);
2227}
2228
2229fn snap_edges(edges: &mut [Edge]) {
2232 if edges.is_empty() {
2233 return;
2234 }
2235 edges.sort_by(|a, b| crate::utils::safe_float_cmp(a.coord, b.coord));
2236
2237 let mut i = 0;
2238 while i < edges.len() {
2239 let base_coord = edges[i].coord;
2240 let mut j = i + 1;
2241 while j < edges.len() && (edges[j].coord - base_coord).abs() <= SNAP_TOL {
2242 edges[j].coord = base_coord;
2243 j += 1;
2244 }
2245 i = j;
2246 }
2247}
2248
2249fn join_collinear_edges(edges: &mut Vec<Edge>) {
2252 if edges.is_empty() {
2253 return;
2254 }
2255 edges.sort_by(|a, b| {
2258 crate::utils::safe_float_cmp(a.coord, b.coord).then_with(|| crate::utils::safe_float_cmp(a.start, b.start))
2259 });
2260
2261 let mut merged: Vec<Edge> = Vec::new();
2262 for &edge in edges.iter() {
2263 let should_merge = merged.last().is_some_and(|prev: &Edge| {
2267 (prev.coord - edge.coord).abs() <= SNAP_TOL && edge.start <= prev.end + JOIN_TOL
2268 });
2269 if should_merge {
2270 let prev = merged.last_mut().unwrap();
2271 prev.end = prev.end.max(edge.end);
2272 } else {
2273 merged.push(edge);
2274 }
2275 }
2276
2277 *edges = merged;
2278}
2279
2280fn reconstitute_dotted_lines(edges: &mut Vec<Edge>) {
2285 let mut dotted_groups: HashMap<i32, Vec<Edge>> = HashMap::new();
2286 let mut long_edges: Vec<Edge> = Vec::new();
2287
2288 for &edge in edges.iter() {
2289 if (edge.end - edge.start) >= MIN_EDGE_LEN {
2290 long_edges.push(edge);
2291 } else {
2292 let key = (edge.coord * DOTTED_COORD_SNAP).round() as i32;
2293 dotted_groups.entry(key).or_default().push(edge);
2294 }
2295 }
2296
2297 let mut dotted_keys: Vec<i32> = dotted_groups.keys().copied().collect();
2303 dotted_keys.sort_unstable();
2304 for key in dotted_keys {
2305 let segments = &dotted_groups[&key];
2306 if segments.len() >= DOTTED_MIN_SEGMENTS {
2307 let min_start = segments
2308 .iter()
2309 .map(|e| e.start)
2310 .min_by(|a, b| crate::utils::safe_float_cmp(*a, *b))
2311 .unwrap();
2312 let max_end = segments
2313 .iter()
2314 .map(|e| e.end)
2315 .max_by(|a, b| crate::utils::safe_float_cmp(*a, *b))
2316 .unwrap();
2317 let total_span = max_end - min_start;
2318 if total_span >= DOTTED_MIN_SPAN {
2319 long_edges.push(Edge {
2322 coord: segments[0].coord,
2323 start: min_start,
2324 end: max_end,
2325 });
2326 }
2327 }
2328 }
2329
2330 *edges = long_edges;
2333}
2334
2335fn filter_edges_by_coverage(h_edges: &mut Vec<Edge>, v_edges: &mut Vec<Edge>) {
2347 let all_x_min = h_edges
2348 .iter()
2349 .map(|e| e.start)
2350 .chain(v_edges.iter().map(|e| e.coord))
2351 .fold(f32::INFINITY, f32::min);
2352 let all_x_max = h_edges
2353 .iter()
2354 .map(|e| e.end)
2355 .chain(v_edges.iter().map(|e| e.coord))
2356 .fold(f32::NEG_INFINITY, f32::max);
2357 let x_span = (all_x_max - all_x_min).max(1.0);
2358 let x_tol = x_span * 0.5;
2359
2360 h_edges.retain(|h| {
2361 v_edges
2362 .iter()
2363 .any(|v| v.coord >= h.start - x_tol && v.coord <= h.end + x_tol)
2364 });
2365
2366 v_edges.retain(|v| {
2367 h_edges
2368 .iter()
2369 .any(|h| v.coord >= h.start - x_tol && v.coord <= h.end + x_tol)
2370 });
2371}
2372
2373fn find_intersections(h_edges: &[Edge], v_edges: &[Edge]) -> Vec<Intersection> {
2375 let mut pts: Vec<Intersection> = Vec::new();
2376 for h in h_edges {
2377 for v in v_edges {
2378 if v.coord >= h.start - SNAP_TOL
2379 && v.coord <= h.end + SNAP_TOL
2380 && h.coord >= v.start - SNAP_TOL
2381 && h.coord <= v.end + SNAP_TOL
2382 {
2383 pts.push(Intersection { x: v.coord, y: h.coord });
2384 }
2385 }
2386 }
2387 pts.sort_by(|a, b| crate::utils::safe_float_cmp(a.x, b.x).then_with(|| crate::utils::safe_float_cmp(a.y, b.y)));
2388 pts.dedup_by(|a, b| (a.x - b.x).abs() <= SNAP_TOL && (a.y - b.y).abs() <= SNAP_TOL);
2389 pts
2390}
2391
2392const CELL_RULE_SPAN_TOL: f32 = 6.0;
2416
2417const MAX_HEADER_CELL_WIDENING_CANDIDATES: usize = 8;
2428
2429fn build_cells_from_intersections(pts: &[Intersection], h_edges: &[Edge], v_edges: &[Edge]) -> Vec<IntersectionCell> {
2433 use std::collections::BTreeSet;
2434
2435 let mut xs: Vec<f32> = pts.iter().map(|p| p.x).collect();
2436 let mut ys: Vec<f32> = pts.iter().map(|p| p.y).collect();
2437 xs.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
2438 xs.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
2439 ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
2440 ys.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
2441
2442 let x_idx = |xv: f32| -> Option<usize> { xs.iter().position(|&c| (c - xv).abs() <= SNAP_TOL) };
2443 let y_idx = |yv: f32| -> Option<usize> { ys.iter().position(|&c| (c - yv).abs() <= SNAP_TOL) };
2444
2445 let nx = xs.len();
2446 let ny = ys.len();
2447 let mut present: BTreeSet<usize> = BTreeSet::new();
2448 for p in pts {
2449 if let (Some(xi), Some(yi)) = (x_idx(p.x), y_idx(p.y)) {
2450 present.insert(yi * nx + xi);
2451 }
2452 }
2453
2454 let has = |xi: usize, yi: usize| -> bool { present.contains(&(yi * nx + xi)) };
2455
2456 let spans_band = |edge: &Edge, y_lo: f32, y_hi: f32| -> bool {
2470 edge.start <= y_lo + CELL_RULE_SPAN_TOL && edge.end >= y_hi - CELL_RULE_SPAN_TOL
2471 };
2472 let v_edge_spans = |x: f32, y_lo: f32, y_hi: f32| -> bool {
2473 v_edges
2474 .iter()
2475 .any(|edge| (edge.coord - x).abs() <= SNAP_TOL && spans_band(edge, y_lo, y_hi))
2476 };
2477
2478 let h_edge_across = |y: f32, x1: f32, x2: f32| -> Option<&Edge> {
2479 h_edges
2480 .iter()
2481 .find(|edge| (edge.coord - y).abs() <= SNAP_TOL && edge.start <= x1 + SNAP_TOL && edge.end >= x2 - SNAP_TOL)
2482 };
2483 let band_is_ruled = |x1: f32, x2: f32, y_lo: f32, y_hi: f32| -> bool {
2484 let (Some(top), Some(bottom)) = (h_edge_across(y_lo, x1, x2), h_edge_across(y_hi, x1, x2)) else {
2485 return false;
2486 };
2487 let (from, to) = (top.start.max(bottom.start), top.end.min(bottom.end));
2488 (v_edge_spans(from, y_lo, y_hi) && v_edge_spans(to, y_lo, y_hi))
2489 || v_edges
2490 .iter()
2491 .any(|edge| edge.coord > from + SNAP_TOL && edge.coord < to - SNAP_TOL && spans_band(edge, y_lo, y_hi))
2492 };
2493
2494 let mut cells = Vec::new();
2495 for yi in 0..ny {
2496 for xi in 0..nx {
2497 if !has(xi, yi) {
2498 continue;
2499 }
2500 let divider_ends_below = ((xi + 1)..nx).find(|&next| has(next, yi)).is_some_and(|next| {
2503 v_edges.iter().any(|edge| {
2504 (edge.coord - xs[next]).abs() <= SNAP_TOL
2505 && edge.start < ys[yi] - SNAP_TOL
2506 && (edge.end - ys[yi]).abs() <= CELL_RULE_SPAN_TOL
2507 })
2508 });
2509 for (candidate, nxi) in ((xi + 1)..nx).filter(|&nxi| has(nxi, yi)).enumerate() {
2510 if candidate > 0 && (!divider_ends_below || candidate > MAX_HEADER_CELL_WIDENING_CANDIDATES) {
2511 break;
2512 }
2513 let side_closes = |x: f32, nyi: usize| -> bool {
2514 v_edge_spans(x, ys[yi], ys[nyi]) || band_is_ruled(xs[xi], xs[nxi], ys[yi], ys[nyi])
2515 };
2516 let mut next_yi = None;
2520 for (candidate_y, nyi) in ((yi + 1)..ny)
2521 .filter(|&nyi| has(xi, nyi) && side_closes(xs[xi], nyi))
2522 .enumerate()
2523 {
2524 if has(nxi, nyi)
2525 && side_closes(xs[nxi], nyi)
2526 && (candidate_y == 0
2527 || (v_edge_spans(xs[xi], ys[yi], ys[nyi])
2528 && v_edge_spans(xs[nxi], ys[yi], ys[nyi])
2529 && h_edge_across(ys[yi], xs[xi], xs[nxi]).is_some()
2530 && h_edge_across(ys[nyi], xs[xi], xs[nxi]).is_some()))
2531 {
2532 next_yi = Some(nyi);
2533 break;
2534 }
2535 if h_edges.iter().any(|edge| {
2539 (edge.coord - ys[nyi]).abs() <= SNAP_TOL
2540 && edge.end > xs[xi] + SNAP_TOL
2541 && edge.start < xs[nxi] - SNAP_TOL
2542 }) {
2543 break;
2544 }
2545 }
2546
2547 if let Some(nyi) = next_yi
2548 && (candidate == 0
2549 || (v_edge_spans(xs[xi], ys[yi], ys[nyi])
2550 && v_edge_spans(xs[nxi], ys[yi], ys[nyi])
2551 && h_edge_across(ys[yi], xs[xi], xs[nxi]).is_some()
2552 && h_edge_across(ys[nyi], xs[xi], xs[nxi]).is_some()))
2553 {
2554 cells.push(IntersectionCell {
2555 x1: xs[xi],
2556 y1: ys[yi],
2557 x2: xs[nxi],
2558 y2: ys[nyi],
2559 });
2560 break;
2561 }
2562 }
2563 }
2564 }
2565 cells
2566}
2567
2568fn build_extended_grid_cells(h_edges: &[Edge], v_edges: &[Edge]) -> Vec<IntersectionCell> {
2575 let mut ys: Vec<f32> = h_edges.iter().map(|e| e.coord).collect();
2576 ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
2577 ys.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
2578
2579 let mut xs: Vec<f32> = v_edges.iter().map(|e| e.coord).collect();
2580 xs.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
2581 xs.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
2582
2583 if xs.len() < 2 || ys.len() < 2 {
2584 return Vec::new();
2585 }
2586
2587 let mut cells = Vec::new();
2588 for yi in 0..ys.len() - 1 {
2589 for xi in 0..xs.len() - 1 {
2590 cells.push(IntersectionCell {
2591 x1: xs[xi],
2592 y1: ys[yi],
2593 x2: xs[xi + 1],
2594 y2: ys[yi + 1],
2595 });
2596 }
2597 }
2598 cells
2599}
2600
2601fn group_cells_into_tables(cells: &[IntersectionCell]) -> Vec<Vec<usize>> {
2603 if cells.is_empty() {
2604 return Vec::new();
2605 }
2606 let n = cells.len();
2607 let mut uf = UnionFind::new(n);
2608
2609 let mut order: Vec<usize> = (0..n).collect();
2620 order.sort_by(|&a, &b| crate::utils::safe_float_cmp(cells[a].y1, cells[b].y1));
2621 for a in 0..n {
2622 let i = order[a];
2623 let ci = &cells[i];
2624 let y_limit = ci.y2 + SNAP_TOL;
2625 for &j in order.iter().skip(a + 1) {
2626 let cj = &cells[j];
2627 if cj.y1 > y_limit {
2628 break;
2629 }
2630 let shares_edge = (((ci.x2 - cj.x1).abs() <= SNAP_TOL || (ci.x1 - cj.x2).abs() <= SNAP_TOL)
2632 && (ci.y1 - cj.y1).abs() <= SNAP_TOL
2633 && (ci.y2 - cj.y2).abs() <= SNAP_TOL)
2634 || (((ci.y2 - cj.y1).abs() <= SNAP_TOL || (ci.y1 - cj.y2).abs() <= SNAP_TOL)
2636 && (ci.x1 - cj.x1).abs() <= SNAP_TOL
2637 && (ci.x2 - cj.x2).abs() <= SNAP_TOL);
2638 if shares_edge {
2639 uf.union(i, j);
2640 }
2641 }
2642 }
2643
2644 let mut groups: Vec<Vec<usize>> = uf.groups().into_values().collect();
2652 groups.sort_by_key(|g| g.first().copied().unwrap_or(usize::MAX));
2653 groups
2654}
2655
2656fn attach_spanning_headers(cells: &[IntersectionCell], groups: Vec<Vec<usize>>, min_cells: usize) -> Vec<Vec<usize>> {
2660 let mut uf = UnionFind::new(groups.len());
2661 for (body_index, body) in groups.iter().enumerate() {
2662 if body.len() < min_cells {
2663 continue;
2664 }
2665 let first_y = cells[body[0]].y1;
2668 if body.iter().all(|&i| (cells[i].y1 - first_y).abs() <= SNAP_TOL) {
2669 continue;
2670 }
2671 let left = body.iter().map(|&i| cells[i].x1).fold(f32::INFINITY, f32::min);
2672 let right = body.iter().map(|&i| cells[i].x2).fold(f32::NEG_INFINITY, f32::max);
2673 let top = body.iter().map(|&i| cells[i].y2).fold(f32::NEG_INFINITY, f32::max);
2674 for (header_index, header) in groups.iter().enumerate() {
2675 if body_index == header_index
2676 || !header.iter().all(|&i| {
2677 let c = cells[i];
2678 c.y1 >= top - SNAP_TOL && c.x1 >= left - SNAP_TOL && c.x2 <= right + SNAP_TOL
2679 })
2680 {
2681 continue;
2682 }
2683 let shares_edge = header.iter().any(|&h| {
2684 body.iter().any(|&b| {
2685 let (h, b) = (cells[h], cells[b]);
2686 (h.y1 - b.y2).abs() <= SNAP_TOL && h.x2.min(b.x2) - h.x1.max(b.x1) > SNAP_TOL
2687 })
2688 });
2689 if shares_edge {
2690 uf.union(body_index, header_index);
2691 }
2692 }
2693 }
2694 let mut joined: Vec<Vec<usize>> = uf
2695 .groups()
2696 .into_values()
2697 .map(|indices| {
2698 let mut members: Vec<_> = indices.into_iter().flat_map(|i| groups[i].iter().copied()).collect();
2699 members.sort_unstable();
2700 members
2701 })
2702 .collect();
2703 joined.sort_by_key(|g| g.first().copied().unwrap_or(usize::MAX));
2704 joined
2705}
2706
2707const MIN_ROW_SPLIT_EVIDENCE_COLUMNS: usize = 2;
2712
2713const MIN_EVIDENCED_CLUSTERS_FOR_SPLIT: usize = 2;
2723
2724fn split_rows_by_text_positions(
2765 table_rows: Vec<TableRow>,
2766 row_cell_span_indices: &[Vec<Vec<usize>>],
2767 spans: &[TextSpan],
2768 config: &TableDetectionConfig,
2769) -> Vec<TableRow> {
2770 let mut result: Vec<TableRow> = Vec::new();
2771
2772 for (row_idx, row) in table_rows.into_iter().enumerate() {
2773 let cell_indices = &row_cell_span_indices[row_idx];
2774
2775 let mut all_ys: Vec<f32> = Vec::new();
2776 for col_spans in cell_indices {
2777 for &idx in col_spans {
2778 if let Some(s) = spans.get(idx) {
2779 all_ys.push(s.bbox.center().y);
2780 }
2781 }
2782 }
2783
2784 if all_ys.len() <= 1 {
2785 result.push(row);
2786 continue;
2787 }
2788
2789 all_ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
2790 let mut y_clusters: Vec<f32> = Vec::new();
2791 for &y in &all_ys {
2792 let merged = y_clusters
2793 .last()
2794 .is_some_and(|&last| (y - last).abs() < config.row_tolerance);
2795 if merged {
2796 let last = y_clusters.last_mut().unwrap();
2797 *last = (*last + y) / 2.0;
2798 } else {
2799 y_clusters.push(y);
2800 }
2801 }
2802
2803 if y_clusters.len() <= 1 {
2804 result.push(row);
2805 continue;
2806 }
2807
2808 let num_cols = row.cells.len();
2809 let nearest_cluster_value = |sy: f32| -> f32 {
2810 *y_clusters
2811 .iter()
2812 .min_by_key(|&&cy| ((sy - cy).abs() * 1000.0) as i32)
2813 .unwrap_or(&sy)
2814 };
2815
2816 let mut cluster_columns: Vec<Vec<bool>> = vec![vec![false; num_cols]; y_clusters.len()];
2818 for (ci, col_spans) in cell_indices.iter().enumerate() {
2819 for &idx in col_spans {
2820 if let Some(s) = spans.get(idx) {
2821 let nearest = nearest_cluster_value(s.bbox.center().y);
2822 if let Some(cluster_idx) = y_clusters.iter().position(|&cy| (cy - nearest).abs() < 0.01) {
2823 cluster_columns[cluster_idx][ci] = true;
2824 }
2825 }
2826 }
2827 }
2828
2829 let required_evidence_columns = MIN_ROW_SPLIT_EVIDENCE_COLUMNS.min(num_cols.max(1));
2841 let is_evidenced: Vec<bool> = cluster_columns
2842 .iter()
2843 .map(|cols| cols.iter().filter(|&&present| present).count() >= required_evidence_columns)
2844 .collect();
2845 let evidenced_cluster_count = is_evidenced.iter().filter(|&&evidenced| evidenced).count();
2846
2847 if evidenced_cluster_count < MIN_EVIDENCED_CLUSTERS_FOR_SPLIT {
2848 result.push(row);
2849 continue;
2850 }
2851
2852 let mut fold_into: Vec<Option<usize>> = vec![None; y_clusters.len()];
2859 for i in 0..y_clusters.len().saturating_sub(1) {
2860 if is_evidenced[i] {
2861 continue;
2862 }
2863 let is_wrapped_continuation = cluster_columns[i]
2864 .iter()
2865 .zip(cluster_columns[i + 1].iter())
2866 .all(|(&here, &above)| !here || above);
2867 if is_wrapped_continuation {
2868 fold_into[i] = Some(i + 1);
2869 }
2870 }
2871
2872 let resolve_group = |mut idx: usize| -> usize {
2873 while let Some(next) = fold_into[idx] {
2874 idx = next;
2875 }
2876 idx
2877 };
2878
2879 let mut group_members: Vec<Vec<usize>> = vec![Vec::new(); y_clusters.len()];
2880 for i in 0..y_clusters.len() {
2881 group_members[resolve_group(i)].push(i);
2882 }
2883
2884 let mut group_order: Vec<usize> = (0..y_clusters.len())
2888 .filter(|&g| !group_members[g].is_empty())
2889 .collect();
2890 group_order.sort_by(|&a, &b| crate::utils::safe_float_cmp(y_clusters[b], y_clusters[a]));
2891
2892 for group_idx in group_order {
2893 let members = &group_members[group_idx];
2894 let mut new_row = TableRow::new(row.is_header);
2895 for ci in 0..num_cols {
2896 let matching_indices: Vec<usize> = cell_indices[ci]
2897 .iter()
2898 .copied()
2899 .filter(|&idx| {
2900 spans
2901 .get(idx)
2902 .map(|s| {
2903 let nearest = nearest_cluster_value(s.bbox.center().y);
2904 members.iter().any(|&m| (y_clusters[m] - nearest).abs() < 0.01)
2905 })
2906 .unwrap_or(false)
2907 })
2908 .collect();
2909
2910 let cell_text = extract_cell_text(&matching_indices, spans);
2911 let mcids: Vec<u32> = matching_indices
2912 .iter()
2913 .filter_map(|&idx| spans.get(idx).and_then(|s| s.mcid))
2914 .collect();
2915
2916 let cell_bbox = if matching_indices.is_empty() {
2917 row.cells[ci].bbox
2918 } else {
2919 let mut b = spans[matching_indices[0]].bbox;
2920 for &idx in &matching_indices[1..] {
2921 b = b.union(&spans[idx].bbox);
2922 }
2923 Some(b)
2924 };
2925
2926 let cell_spans = matching_indices
2927 .iter()
2928 .filter_map(|&idx| spans.get(idx).cloned())
2929 .collect::<Vec<_>>();
2930
2931 new_row.cells.push(TableCell {
2932 text: cell_text,
2933 spans: cell_spans,
2934 colspan: 1,
2935 rowspan: 1,
2936 mcids,
2937 bbox: cell_bbox,
2938 is_header: row.is_header,
2939 });
2940 }
2941 result.push(new_row);
2942 }
2943 }
2944
2945 result
2946}
2947
2948fn strip_form_numbering_artifacts(table_rows: &mut Vec<TableRow>) {
2960 table_rows.retain(|row| {
2965 let all_empty_or_digit = row.cells.iter().all(|c| {
2966 let t = c.text.trim();
2967 t.is_empty() || (t.len() == 1 && t.as_bytes().first().is_some_and(|b| b.is_ascii_digit() && *b != b'0'))
2968 });
2969 let has_digit = row.cells.iter().any(|c| {
2970 let t = c.text.trim();
2971 t.len() == 1 && t.as_bytes().first().is_some_and(|b| b.is_ascii_digit() && *b != b'0')
2972 });
2973 !(all_empty_or_digit && has_digit)
2974 });
2975
2976 for row in table_rows.iter_mut() {
2982 let mut stripped_any = false;
2983 for cell in &mut row.cells {
2984 let text = cell.text.trim();
2985 if text.len() < 3 {
2986 continue; }
2988 let bytes = text.as_bytes();
2989 if bytes[0].is_ascii_digit() && bytes[0] != b'0' && bytes[1] == b' ' {
2990 let rest = text[2..].trim_start();
2991 if !rest.is_empty() {
2992 let first = rest.as_bytes()[0];
2993 let looks_like_data = first == b'$'
2997 || first.is_ascii_digit()
2998 || (first.is_ascii_alphabetic()
2999 && (rest.contains('-') || rest.contains('/') || rest.contains(',')));
3000 if looks_like_data {
3001 cell.text = rest.to_string();
3002 stripped_any = true;
3003 }
3004 }
3005 }
3006 }
3007
3008 if stripped_any {
3012 for cell in &mut row.cells {
3013 let t = cell.text.trim();
3014 if t.len() == 1 && t.as_bytes()[0].is_ascii_digit() {
3015 cell.text.clear();
3016 }
3017 }
3018 }
3019 }
3020
3021 for row in table_rows.iter_mut() {
3024 for cell in &mut row.cells {
3025 let t = cell.text.trim();
3026 if !t.is_empty() && t.chars().all(|c| c == '-' || c == '_') {
3027 cell.text.clear();
3028 }
3029 }
3030 }
3031
3032 }
3036
3037fn detect_tables_from_intersections(
3056 spans: &[TextSpan],
3057 lines: &[crate::elements::PathContent],
3058 config: &TableDetectionConfig,
3059) -> Vec<Table> {
3060 let (groups, v_edges, cells_are_intersections) = build_grid_from_lines(lines, config);
3061
3062 let mut tables = Vec::new();
3063 for (group_cells, xs, ys, num_cols) in &groups {
3064 let Some((table_rows, row_cell_span_indices)) =
3065 assign_spans_to_intersection_grid(group_cells, xs, ys, *num_cols, spans, &v_edges, cells_are_intersections)
3066 else {
3067 continue;
3068 };
3069 let sub_tables = finalize_intersection_tables(table_rows, &row_cell_span_indices, spans, config, *num_cols);
3070 tables.extend(sub_tables);
3071 }
3072
3073 merge_vertically_adjacent_tables(&mut tables);
3074
3075 let (mut h_edges, mut v_edges) = extract_edges(lines);
3081 snap_and_merge(&mut h_edges);
3082 snap_edges(&mut v_edges);
3083 tables = split_tables_at_section_dividers(tables, &h_edges, &v_edges, config);
3084
3085 if BUCKET_TABLE_GRID_IN_ROTATED_FRAME {
3091 tables = tables.into_iter().map(reorient_table_to_rotated_frame).collect();
3092 }
3093
3094 tables
3095}
3096
3097fn build_grid_from_lines(
3104 lines: &[crate::elements::PathContent],
3105 config: &TableDetectionConfig,
3106) -> (Vec<(Vec<IntersectionCell>, Vec<f32>, Vec<f32>, usize)>, Vec<Edge>, bool) {
3107 let (mut h_edges, mut v_edges) = extract_edges(lines);
3108 snap_and_merge(&mut h_edges);
3109 snap_and_merge(&mut v_edges);
3110
3111 if h_edges.len() < 2 || v_edges.len() < 2 {
3112 return (Vec::new(), v_edges, false);
3113 }
3114
3115 let intersections = find_intersections(&h_edges, &v_edges);
3116
3117 if intersections.len() < 4 {
3122 filter_edges_by_coverage(&mut h_edges, &mut v_edges);
3123 if h_edges.len() < 2 || v_edges.len() < 2 {
3124 return (Vec::new(), v_edges, false);
3125 }
3126 }
3127
3128 let (cells, cells_are_intersections) = if intersections.len() >= 4 {
3134 let c = build_cells_from_intersections(&intersections, &h_edges, &v_edges);
3135 if c.is_empty() {
3136 (build_extended_grid_cells(&h_edges, &v_edges), false)
3138 } else {
3139 (c, true)
3140 }
3141 } else {
3142 (build_extended_grid_cells(&h_edges, &v_edges), false)
3146 };
3147 if cells.is_empty() {
3148 return (Vec::new(), v_edges, cells_are_intersections);
3149 }
3150
3151 let table_groups = attach_spanning_headers(&cells, group_cells_into_tables(&cells), config.min_table_cells);
3152 let mut result = Vec::new();
3153 for group in &table_groups {
3154 let group_cells: Vec<IntersectionCell> = group.iter().map(|&i| cells[i]).collect();
3155
3156 let mut xs: Vec<f32> = Vec::new();
3157 let mut ys: Vec<f32> = Vec::new();
3158 for c in &group_cells {
3159 xs.push(c.x1);
3160 xs.push(c.x2);
3161 ys.push(c.y1);
3162 ys.push(c.y2);
3163 }
3164 xs.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
3165 xs.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
3166 ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
3167 ys.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
3168
3169 let num_cols = if xs.len() >= 2 {
3170 xs.len() - 1
3171 } else {
3172 continue;
3173 };
3174 if ys.len() < 2 {
3175 continue;
3176 }
3177
3178 if num_cols < config.min_table_columns || num_cols > config.max_table_columns {
3179 continue;
3180 }
3181
3182 result.push((group_cells, xs, ys, num_cols));
3183 }
3184 (result, v_edges, cells_are_intersections)
3185}
3186
3187const BAND_RULE_SPAN_TOL: f32 = 1.0;
3205
3206fn band_column_groups(
3224 xs: &[f32],
3225 y_lo: f32,
3226 y_hi: f32,
3227 num_cols: usize,
3228 v_edges: &[Edge],
3229 cells_are_intersections: bool,
3230) -> Vec<(usize, usize)> {
3231 let boundary_is_drawn = |x: f32| -> bool {
3232 v_edges.iter().any(|edge| {
3233 (edge.coord - x).abs() <= SNAP_TOL
3234 && edge.start <= y_lo + BAND_RULE_SPAN_TOL
3235 && edge.end >= y_hi - BAND_RULE_SPAN_TOL
3236 })
3237 };
3238
3239 let mut groups = Vec::new();
3240 let mut start = 0usize;
3241 for c in 0..num_cols {
3242 if c + 1 == num_cols || !cells_are_intersections || boundary_is_drawn(xs[c + 1]) {
3243 groups.push((start, c));
3244 start = c + 1;
3245 }
3246 }
3247 groups
3248}
3249
3250fn assign_spans_to_intersection_grid(
3253 group_cells: &[IntersectionCell],
3254 xs: &[f32],
3255 ys: &[f32],
3256 num_cols: usize,
3257 spans: &[TextSpan],
3258 v_edges: &[Edge],
3259 cells_are_intersections: bool,
3260) -> Option<(Vec<TableRow>, Vec<Vec<Vec<usize>>>)> {
3261 let num_rows = if ys.len() >= 2 {
3262 ys.len() - 1
3263 } else {
3264 return None;
3265 };
3266
3267 let boundary_index = |value: f32, boundaries: &[f32]| {
3268 boundaries
3269 .iter()
3270 .position(|&boundary| (boundary - value).abs() <= SNAP_TOL)
3271 };
3272
3273 let mut grid_has_cell = vec![vec![false; num_cols]; num_rows];
3277 for cell in group_cells {
3278 if let (Some(left), Some(right), Some(bottom), Some(top)) = (
3279 boundary_index(cell.x1, xs),
3280 boundary_index(cell.x2, xs),
3281 boundary_index(cell.y1, ys),
3282 boundary_index(cell.y2, ys),
3283 ) {
3284 if bottom < top && left < right {
3290 for row in &mut grid_has_cell[bottom..top] {
3291 row[left..right].fill(true);
3292 }
3293 }
3294 }
3295 }
3296
3297 let mut grid_spans: Vec<Vec<Vec<usize>>> = vec![vec![Vec::new(); num_cols]; num_rows];
3302 for (idx, span) in spans.iter().enumerate() {
3303 let cx = span.bbox.center().x;
3304 let cy = span.bbox.center().y;
3305 let col_idx = grid_interval_for_point(cx, xs);
3306 let row_idx = grid_interval_for_point(cy, ys);
3307 if let (Some(ci), Some(ri)) = (col_idx, row_idx)
3308 && grid_has_cell[ri][ci]
3309 {
3310 grid_spans[ri][ci].push(idx);
3311 }
3312 }
3313
3314 let mut row_order: Vec<usize> = (0..num_rows).collect();
3317 row_order.sort_by(|&a, &b| crate::utils::safe_float_cmp(ys[b], ys[a]));
3318
3319 let mut table_rows = Vec::new();
3320 let mut row_cell_span_indices: Vec<Vec<Vec<usize>>> = Vec::new();
3322 for &ri in &row_order {
3323 let mut row = TableRow::new(false);
3324 let mut cell_indices_for_row: Vec<Vec<usize>> = Vec::new();
3325 let groups = band_column_groups(xs, ys[ri], ys[ri + 1], num_cols, v_edges, cells_are_intersections);
3326 for (first_col, last_col) in groups {
3327 let colspan = (last_col - first_col + 1) as u32;
3328 let group_bbox = crate::geometry::Rect::new(
3329 xs[first_col],
3330 ys[ri],
3331 xs[last_col + 1] - xs[first_col],
3332 ys[ri + 1] - ys[ri],
3333 );
3334 let group_has_cell = (first_col..=last_col).any(|ci| grid_has_cell[ri][ci]);
3335 if !group_has_cell {
3336 row.cells.push(TableCell {
3338 text: String::new(),
3339 spans: Vec::new(),
3340 colspan,
3341 rowspan: 1,
3342 mcids: Vec::new(),
3343 bbox: Some(group_bbox),
3344 is_header: false,
3345 });
3346 cell_indices_for_row.push(Vec::new());
3347 continue;
3348 }
3349
3350 let group_span_indices: Vec<usize> = (first_col..=last_col)
3354 .flat_map(|ci| grid_spans[ri][ci].iter().copied())
3355 .collect();
3356 let cell_text = extract_cell_text(&group_span_indices, spans);
3357 let mcids: Vec<u32> = group_span_indices
3358 .iter()
3359 .filter_map(|&idx| spans.get(idx).and_then(|s| s.mcid))
3360 .collect();
3361 let cell_spans = group_span_indices
3362 .iter()
3363 .filter_map(|&idx| spans.get(idx).cloned())
3364 .collect::<Vec<_>>();
3365
3366 row.cells.push(TableCell {
3367 text: cell_text,
3368 spans: cell_spans,
3369 colspan,
3370 rowspan: 1,
3371 mcids,
3372 bbox: Some(group_bbox),
3373 is_header: false,
3374 });
3375 cell_indices_for_row.push(group_span_indices);
3376 }
3377 table_rows.push(row);
3378 row_cell_span_indices.push(cell_indices_for_row);
3379 }
3380
3381 Some((table_rows, row_cell_span_indices))
3382}
3383
3384fn grid_interval_for_point(point: f32, boundaries: &[f32]) -> Option<usize> {
3390 let interval_count = boundaries.len().checked_sub(1)?;
3391 if interval_count == 0 || !point.is_finite() {
3392 return None;
3393 }
3394
3395 if point < boundaries[0] {
3396 return (boundaries[0] - point <= SNAP_TOL).then_some(0);
3397 }
3398 if point > boundaries[interval_count] {
3399 return (point - boundaries[interval_count] <= SNAP_TOL).then_some(interval_count - 1);
3400 }
3401
3402 (0..interval_count).find(|&index| {
3403 point >= boundaries[index]
3404 && (point < boundaries[index + 1] || (index + 1 == interval_count && point <= boundaries[index + 1]))
3405 })
3406}
3407
3408const BUCKET_TABLE_GRID_IN_ROTATED_FRAME: bool = true;
3415
3416fn dominant_table_rotation_quadrant(table: &Table) -> f32 {
3435 let mut counts: [usize; 4] = [0; 4];
3436 for row in &table.rows {
3437 for cell in &row.cells {
3438 for span in &cell.spans {
3439 let normalized = span.rotation_degrees.rem_euclid(360.0);
3440 let quadrant = ((normalized / 90.0).round() as usize) % 4;
3441 if (normalized - (quadrant as f32) * 90.0).abs() < 0.5 {
3442 counts[quadrant] += 1;
3443 } else {
3444 counts[0] += 1;
3445 }
3446 }
3447 }
3448 }
3449 let total: usize = counts.iter().sum();
3450 if total == 0 {
3451 return 0.0;
3452 }
3453 let Some((index, &count)) = counts
3454 .iter()
3455 .enumerate()
3456 .max_by_key(|&(index, &count)| (count, std::cmp::Reverse(index)))
3457 else {
3458 return 0.0;
3459 };
3460 if count * 2 > total { (index as f32) * 90.0 } else { 0.0 }
3461}
3462
3463fn reorient_table_to_rotated_frame(table: Table) -> Table {
3503 let rotation = dominant_table_rotation_quadrant(&table);
3507 if rotation == 0.0 {
3508 return table;
3509 }
3510 let Some((rows, col_count)) = rotate_table_rows(&table.rows, rotation) else {
3511 return table;
3516 };
3517 Table {
3518 rows,
3519 has_header: table.has_header,
3520 col_count,
3521 bbox: table.bbox,
3522 }
3523}
3524
3525fn rotate_table_rows(rows: &[TableRow], rotation_degrees: f32) -> Option<(Vec<TableRow>, usize)> {
3530 let num_orig_rows = rows.len();
3531 let num_orig_cols = rows.first().map_or(0, |row| row.cells.len());
3532 if rotation_degrees == 0.0 || num_orig_rows == 0 || num_orig_cols == 0 {
3533 return Some((rows.to_vec(), num_orig_cols));
3534 }
3535 if rows.iter().any(|row| row.cells.len() != num_orig_cols) {
3536 return None;
3537 }
3538
3539 let transpose = rotation_degrees == 90.0 || rotation_degrees == 270.0;
3544 let (new_num_rows, new_num_cols) = if transpose {
3545 (num_orig_cols, num_orig_rows)
3546 } else {
3547 (num_orig_rows, num_orig_cols)
3548 };
3549
3550 let mut new_rows = Vec::with_capacity(new_num_rows);
3551 for r_new in 0..new_num_rows {
3552 let mut cells = Vec::with_capacity(new_num_cols);
3553 for c_new in 0..new_num_cols {
3554 let (r_old, c_old) = if !transpose {
3555 (num_orig_rows - 1 - r_new, num_orig_cols - 1 - c_new)
3557 } else if rotation_degrees == 90.0 {
3558 (num_orig_rows - 1 - c_new, r_new)
3559 } else {
3560 (c_new, num_orig_cols - 1 - r_new)
3562 };
3563 cells.push(rows[r_old].cells[c_old].clone());
3564 }
3565 new_rows.push(TableRow {
3566 cells,
3567 is_header: false,
3568 });
3569 }
3570
3571 Some((new_rows, new_num_cols))
3572}
3573
3574fn finalize_intersection_tables(
3577 table_rows: Vec<TableRow>,
3578 row_cell_span_indices: &[Vec<Vec<usize>>],
3579 spans: &[TextSpan],
3580 config: &TableDetectionConfig,
3581 num_cols: usize,
3582) -> Vec<Table> {
3583 let mut table_rows = split_rows_by_text_positions(table_rows, row_cell_span_indices, spans, config);
3584
3585 strip_form_numbering_artifacts(&mut table_rows);
3586
3587 let mut tables = Vec::new();
3589 let mut sub_start = 0;
3590 while sub_start < table_rows.len() {
3591 let row_is_empty = |r: &TableRow| r.cells.iter().all(|c| c.text.is_empty());
3592 if row_is_empty(&table_rows[sub_start]) {
3593 sub_start += 1;
3594 continue;
3595 }
3596 let mut sub_end = sub_start + 1;
3597 while sub_end < table_rows.len() && !row_is_empty(&table_rows[sub_end]) {
3598 sub_end += 1;
3599 }
3600 let sub_rows: Vec<TableRow> = table_rows[sub_start..sub_end].to_vec();
3601 let filled: usize = sub_rows
3602 .iter()
3603 .flat_map(|r| r.cells.iter())
3604 .filter(|c| !c.text.is_empty())
3605 .count();
3606 if filled >= config.min_table_cells {
3607 let mut min_x = f32::INFINITY;
3608 let mut min_y = f32::INFINITY;
3609 let mut max_x = f32::NEG_INFINITY;
3610 let mut max_y = f32::NEG_INFINITY;
3611 for r in &sub_rows {
3612 for c in &r.cells {
3613 if let Some(b) = c.bbox {
3614 min_x = min_x.min(b.left());
3615 min_y = min_y.min(b.top());
3616 max_x = max_x.max(b.right());
3617 max_y = max_y.max(b.bottom());
3618 }
3619 }
3620 }
3621 let sub_bbox = if min_x.is_finite() {
3622 Some(crate::geometry::Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
3623 } else {
3624 None
3625 };
3626 tables.push(Table {
3627 rows: sub_rows,
3628 has_header: false,
3629 col_count: num_cols,
3630 bbox: sub_bbox,
3631 });
3632 }
3633 sub_start = sub_end;
3634 }
3635 tables
3636}
3637
3638const SECTION_DIVIDER_WIDTH_RATIO: f32 = 0.80;
3641
3642fn split_tables_at_section_dividers(
3646 tables: Vec<Table>,
3647 h_edges: &[Edge],
3648 v_edges: &[Edge],
3649 config: &TableDetectionConfig,
3650) -> Vec<Table> {
3651 let mut result = Vec::new();
3652 for table in tables {
3653 let parts = split_table_at_section_dividers(table, h_edges, v_edges, config);
3654 result.extend(parts);
3655 }
3656 result
3657}
3658
3659fn split_table_at_section_dividers(
3665 table: Table,
3666 h_edges: &[Edge],
3667 v_edges: &[Edge],
3668 config: &TableDetectionConfig,
3669) -> Vec<Table> {
3670 let Some(bbox) = table.bbox else {
3671 return vec![table];
3672 };
3673 if table.rows.len() < 2 {
3674 return vec![table];
3675 }
3676
3677 let table_width = bbox.right() - bbox.left();
3678 if table_width <= 0.0 {
3679 return vec![table];
3680 }
3681
3682 let top = bbox.top();
3687 let bottom = bbox.bottom();
3688 let margin = 2.0; let table_left = bbox.left();
3691 let table_right = bbox.right();
3692 let relevant_v_edges: Vec<&Edge> = v_edges
3693 .iter()
3694 .filter(|e| e.coord >= table_left - SNAP_TOL && e.coord <= table_right + SNAP_TOL)
3695 .collect();
3696
3697 let mut divider_ys: Vec<f32> = Vec::new();
3698 let mut internal_rule_ys: Vec<f32> = Vec::new();
3699 for edge in h_edges {
3700 let overlap_start = edge.start.max(table_left);
3701 let overlap_end = edge.end.min(table_right);
3702 let overlap = overlap_end - overlap_start;
3703 if overlap < table_width * SECTION_DIVIDER_WIDTH_RATIO {
3704 continue;
3705 }
3706 let y = edge.coord;
3707 if y <= top + margin || y >= bottom - margin {
3708 continue;
3709 }
3710 internal_rule_ys.push(y);
3711 let cross_margin = SNAP_TOL + 1.0;
3712 let crossings = relevant_v_edges
3713 .iter()
3714 .filter(|v| v.start < y - cross_margin && v.end > y + cross_margin)
3715 .count();
3716 if crossings <= 1 {
3719 divider_ys.push(y);
3720 }
3721 }
3722
3723 if divider_ys.is_empty() {
3724 return vec![table];
3725 }
3726
3727 divider_ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
3728 divider_ys.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
3729 internal_rule_ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
3730 internal_rule_ys.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
3731
3732 if internal_rule_ys.len() >= 2 && divider_ys.len() == internal_rule_ys.len() {
3739 return vec![table];
3740 }
3741
3742 let row_bounds: Vec<Option<(f32, f32)>> = table
3743 .rows
3744 .iter()
3745 .map(|row| {
3746 let mut rmin = f32::INFINITY;
3747 let mut rmax = f32::NEG_INFINITY;
3748 for c in &row.cells {
3749 if let Some(b) = c.bbox {
3750 rmin = rmin.min(b.top());
3751 rmax = rmax.max(b.bottom());
3752 }
3753 }
3754 if rmin.is_finite() { Some((rmin, rmax)) } else { None }
3755 })
3756 .collect();
3757
3758 let mut split_after: Vec<usize> = Vec::new();
3759 let tol = SNAP_TOL + 2.0; for &dy in ÷r_ys {
3761 let mut best_idx: Option<usize> = None;
3762 let mut best_dist = f32::INFINITY;
3763 for (i, bounds) in row_bounds.iter().enumerate() {
3764 if i >= table.rows.len().saturating_sub(1) {
3765 continue;
3766 }
3767 let Some((row_top, row_bot)) = bounds else {
3768 continue;
3769 };
3770 let dist_to_bot = (dy - row_bot).abs();
3771 let dist_to_top = (dy - row_top).abs();
3772 let min_dist = dist_to_bot.min(dist_to_top);
3773 if min_dist <= tol && min_dist < best_dist {
3774 if dist_to_bot <= dist_to_top {
3775 best_idx = Some(i);
3776 } else if i > 0 {
3777 best_idx = Some(i - 1);
3778 }
3779 best_dist = min_dist;
3780 }
3781 }
3782 if let Some(idx) = best_idx {
3783 split_after.push(idx);
3784 }
3785 }
3786 split_after.sort_unstable();
3787 split_after.dedup();
3788
3789 if split_after.is_empty() {
3790 return vec![table];
3791 }
3792
3793 let num_cols = table.col_count;
3794 let all_rows = table.rows;
3795 let mut sub_tables = Vec::new();
3796 let mut start = 0;
3797 for &split_idx in &split_after {
3798 let end = split_idx + 1;
3799 if end > start {
3800 sub_tables.push(&all_rows[start..end]);
3801 }
3802 start = end;
3803 }
3804 if start < all_rows.len() {
3805 sub_tables.push(&all_rows[start..]);
3806 }
3807
3808 let mut result = Vec::new();
3809 for sub_rows_slice in sub_tables {
3810 let sub_rows: Vec<TableRow> = sub_rows_slice.to_vec();
3811 let filled: usize = sub_rows
3812 .iter()
3813 .flat_map(|r| r.cells.iter())
3814 .filter(|c| !c.text.is_empty())
3815 .count();
3816 if filled < config.min_table_cells {
3817 continue;
3818 }
3819 let mut min_x = f32::INFINITY;
3820 let mut min_y = f32::INFINITY;
3821 let mut max_x = f32::NEG_INFINITY;
3822 let mut max_y = f32::NEG_INFINITY;
3823 for r in &sub_rows {
3824 for c in &r.cells {
3825 if let Some(b) = c.bbox {
3826 min_x = min_x.min(b.left());
3827 min_y = min_y.min(b.top());
3828 max_x = max_x.max(b.right());
3829 max_y = max_y.max(b.bottom());
3830 }
3831 }
3832 }
3833 let sub_bbox = if min_x.is_finite() {
3834 Some(crate::geometry::Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
3835 } else {
3836 None
3837 };
3838 result.push(Table {
3839 rows: sub_rows,
3840 has_header: false,
3841 col_count: num_cols,
3842 bbox: sub_bbox,
3843 });
3844 }
3845
3846 if result.is_empty() {
3847 return vec![Table {
3849 rows: all_rows,
3850 has_header: false,
3851 col_count: num_cols,
3852 bbox: Some(bbox),
3853 }];
3854 }
3855
3856 result
3857}
3858
3859const ADJACENT_TABLE_MERGE_GAP: f32 = 20.0;
3862
3863const MERGE_COL_DIFF_TOLERANCE: usize = 2;
3866
3867fn merge_vertically_adjacent_tables(tables: &mut Vec<Table>) {
3871 if tables.len() < 2 {
3872 return;
3873 }
3874
3875 tables.sort_by(|a, b| {
3877 let ay = a.bbox.map_or(f32::NEG_INFINITY, |bb| bb.top());
3878 let by = b.bbox.map_or(f32::NEG_INFINITY, |bb| bb.top());
3879 crate::utils::safe_float_cmp(ay, by)
3880 });
3881
3882 let mut merged: Vec<Table> = Vec::new();
3883 for table in tables.drain(..) {
3884 let should_merge = merged.last().is_some_and(|prev: &Table| {
3885 let col_diff = (prev.col_count as isize - table.col_count as isize).unsigned_abs();
3886 if col_diff > MERGE_COL_DIFF_TOLERANCE {
3887 return false;
3888 }
3889 match (prev.bbox, table.bbox) {
3890 (Some(pb), Some(tb)) => {
3891 let gap = (tb.top() - pb.bottom()).abs().min((pb.top() - tb.bottom()).abs());
3892 gap <= ADJACENT_TABLE_MERGE_GAP
3893 }
3894 _ => false,
3895 }
3896 });
3897
3898 if should_merge {
3899 let prev = merged.last_mut().unwrap();
3900 let target_cols = prev.col_count.max(table.col_count);
3901
3902 if prev.col_count < target_cols {
3903 let pad = target_cols - prev.col_count;
3904 for row in &mut prev.rows {
3905 for _ in 0..pad {
3906 row.cells.push(TableCell {
3907 text: String::new(),
3908 spans: Vec::new(),
3909 colspan: 1,
3910 rowspan: 1,
3911 mcids: Vec::new(),
3912 bbox: None,
3913 is_header: row.is_header,
3914 });
3915 }
3916 }
3917 }
3918
3919 let mut incoming_rows = table.rows;
3920 if table.col_count < target_cols {
3921 let pad = target_cols - table.col_count;
3922 for row in &mut incoming_rows {
3923 for _ in 0..pad {
3924 row.cells.push(TableCell {
3925 text: String::new(),
3926 spans: Vec::new(),
3927 colspan: 1,
3928 rowspan: 1,
3929 mcids: Vec::new(),
3930 bbox: None,
3931 is_header: row.is_header,
3932 });
3933 }
3934 }
3935 }
3936
3937 prev.rows.extend(incoming_rows);
3938 prev.col_count = target_cols;
3939 if let (Some(pb), Some(tb)) = (prev.bbox, table.bbox) {
3940 let min_x = pb.left().min(tb.left());
3941 let min_y = pb.top().min(tb.top());
3942 let max_x = pb.right().max(tb.right());
3943 let max_y = pb.bottom().max(tb.bottom());
3944 prev.bbox = Some(crate::geometry::Rect::new(min_x, min_y, max_x - min_x, max_y - min_y));
3945 }
3946 prev.has_header = prev.has_header || table.has_header;
3947 } else {
3948 merged.push(table);
3949 }
3950 }
3951
3952 *tables = merged;
3953}
3954
3955fn has_vertical_ruling_evidence(lines: &[crate::elements::PathContent], h_edges: &[Edge]) -> bool {
3972 const LINE_AXIS_TOL: f32 = 2.0;
3973 lines.iter().any(|path| {
3974 if path.is_horizontal_line(LINE_AXIS_TOL) {
3975 return false;
3976 }
3977 if path.is_vertical_line(LINE_AXIS_TOL) {
3978 let r = path.rendered_bbox();
3979 let mut crossed_ys: Vec<f32> = h_edges
3984 .iter()
3985 .filter(|h| r.y <= h.coord && (r.y + r.height) >= h.coord && (r.x + r.width) >= h.start && r.x <= h.end)
3986 .map(|h| h.coord)
3987 .collect();
3988 crossed_ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
3989 crossed_ys.dedup_by(|a, b| (*a - *b).abs() <= LINE_AXIS_TOL);
3990 return crossed_ys.len() >= 2;
3991 }
3992 path.is_rectangle()
3993 })
3994}
3995
3996fn x_coherent_rule_families<'a>(wide: &[&'a Edge]) -> Vec<Vec<&'a Edge>> {
4005 const X_COHERENCE: f32 = 0.85;
4012
4013 let mut uf = UnionFind::new(wide.len());
4014 for i in 0..wide.len() {
4015 for j in (i + 1)..wide.len() {
4016 let (a, b) = (wide[i], wide[j]);
4017 let overlap = a.end.min(b.end) - a.start.max(b.start);
4018 let union = a.end.max(b.end) - a.start.min(b.start);
4019 if union > 0.0 && overlap / union >= X_COHERENCE {
4020 uf.union(i, j);
4021 }
4022 }
4023 }
4024 let mut families: std::collections::BTreeMap<usize, Vec<&'a Edge>> = std::collections::BTreeMap::new();
4039 for (i, e) in wide.iter().enumerate() {
4040 families.entry(uf.find(i)).or_default().push(e);
4041 }
4042 let mut families: Vec<(usize, Vec<&'a Edge>)> = families.into_iter().collect();
4043 families.sort_by(|(root_a, a), (root_b, b)| {
4044 crate::utils::safe_float_cmp(a[0].coord, b[0].coord).then_with(|| root_a.cmp(root_b))
4045 });
4046 families.into_iter().map(|(_root, f)| f).collect()
4047}
4048
4049fn rule_band_spans(
4052 spans: &[TextSpan],
4053 y_top: f32,
4054 y_bot: f32,
4055 x_overlap_start: f32,
4056 x_overlap_end: f32,
4057) -> Option<Vec<TextSpan>> {
4058 let pad = 2.0;
4059 let mut region_spans: Vec<TextSpan> = Vec::new();
4060 let mut outside_width = 0.0f32;
4061 let mut inside_width = 0.0f32;
4062 for s in spans {
4063 let cy = s.bbox.center().y;
4064 if cy > y_top + pad || cy < y_bot - pad {
4065 continue;
4066 }
4067 let cx = s.bbox.center().x;
4068 if cx >= x_overlap_start - pad && cx <= x_overlap_end + pad {
4069 inside_width += s.bbox.width.max(0.0);
4070 region_spans.push(s.clone());
4071 } else {
4072 outside_width += s.bbox.width.max(0.0);
4073 }
4074 }
4075
4076 if region_spans.is_empty() {
4077 return None;
4078 }
4079
4080 if outside_width > (outside_width + inside_width) * 0.3 {
4092 return None;
4093 }
4094
4095 let word_count = region_spans.len();
4108 let mut single_any = 0usize;
4109 let mut single_alpha = 0usize;
4110 for rs in ®ion_spans {
4111 let mut chars = rs.text.trim().chars();
4112 if let (Some(c), None) = (chars.next(), chars.next()) {
4113 single_any += 1;
4114 if c.is_alphabetic() {
4115 single_alpha += 1;
4116 }
4117 }
4118 }
4119 if word_count > 0 && (single_alpha * 3 >= word_count || single_any * 2 >= word_count) {
4120 return None;
4121 }
4122
4123 Some(region_spans)
4124}
4125
4126fn detect_rule_run(spans: &[TextSpan], run: Option<(f32, f32, f32, f32)>, config: &TableDetectionConfig) -> Vec<Table> {
4127 run.and_then(|(top, bot, x0, x1)| rule_band_spans(spans, top, bot, x0, x1))
4128 .map(|region| detect_tables_from_spans(®ion, config))
4129 .unwrap_or_default()
4130}
4131
4132fn is_single_text_line(spans: &[TextSpan]) -> bool {
4133 let line_h = spans.iter().map(|s| s.bbox.height).fold(0.0f32, f32::max);
4134 let (lo, hi) = spans.iter().fold((f32::MAX, f32::MIN), |(lo, hi), s| {
4135 let cy = s.bbox.center().y;
4136 (lo.min(cy), hi.max(cy))
4137 });
4138 hi - lo <= line_h * 0.5
4139}
4140
4141fn detect_tables_from_horizontal_rules(
4146 spans: &[TextSpan],
4147 h_edges: &[Edge],
4148 config: &TableDetectionConfig,
4149) -> Vec<Table> {
4150 const MIN_RULE_WIDTH: f32 = 100.0;
4151 const Y_SNAP: f32 = 4.0;
4152
4153 let wide: Vec<&Edge> = h_edges.iter().filter(|e| (e.end - e.start) >= MIN_RULE_WIDTH).collect();
4154 if wide.len() < 2 {
4155 return Vec::new();
4156 }
4157
4158 let families = x_coherent_rule_families(&wide);
4159
4160 let mut tables = Vec::new();
4161
4162 for family in &families {
4163 let mut y_coords: Vec<f32> = Vec::new();
4164 for e in family {
4165 let merged = y_coords.iter_mut().find(|y| (e.coord - **y).abs() <= Y_SNAP);
4166 if merged.is_none() {
4167 y_coords.push(e.coord);
4168 }
4169 }
4170 y_coords.sort_by(|a, b| crate::utils::safe_float_cmp(*b, *a));
4171 if y_coords.len() < 2 {
4174 continue;
4175 }
4176
4177 let x_range_for_y = |target_y: f32| -> (f32, f32) {
4178 let mut min_x = f32::MAX;
4179 let mut max_x = f32::MIN;
4180 for e in family {
4181 if (e.coord - target_y).abs() <= Y_SNAP {
4182 if e.start < min_x {
4183 min_x = e.start;
4184 }
4185 if e.end > max_x {
4186 max_x = e.end;
4187 }
4188 }
4189 }
4190 (min_x, max_x)
4191 };
4192
4193 let mut run: Option<(f32, f32, f32, f32)> = None;
4197 for pair in y_coords.windows(2) {
4198 let y_top = pair[0];
4199 let y_bot = pair[1];
4200 let (x1_start, x1_end) = x_range_for_y(y_top);
4201 let (x2_start, x2_end) = x_range_for_y(y_bot);
4202 let x_start = x1_start.max(x2_start);
4203 let x_end = x1_end.min(x2_end);
4204 if x_end - x_start < MIN_RULE_WIDTH {
4205 tables.append(&mut detect_rule_run(spans, run.take(), config));
4206 continue;
4207 }
4208 let Some(band) = rule_band_spans(spans, y_top, y_bot, x_start, x_end) else {
4209 tables.append(&mut detect_rule_run(spans, run.take(), config));
4210 continue;
4211 };
4212 let joined = match run.take() {
4213 Some((top, _, x0, x1)) => (top, y_bot, x0.min(x_start), x1.max(x_end)),
4214 None => (y_top, y_bot, x_start, x_end),
4215 };
4216 if is_single_text_line(&band) {
4217 run = Some(joined);
4218 continue;
4219 }
4220 let mut detected = detect_rule_run(spans, Some(joined), config);
4221 if detected.is_empty() && joined.0 != y_top {
4222 tables.append(&mut detect_rule_run(
4223 spans,
4224 Some((joined.0, y_top, joined.2, joined.3)),
4225 config,
4226 ));
4227 detected = detect_tables_from_spans(&band, config);
4228 }
4229 tables.append(&mut detected);
4230 }
4231 tables.append(&mut detect_rule_run(spans, run, config));
4232 }
4233
4234 let mut keep: Vec<bool> = vec![true; tables.len()];
4241 for i in 0..tables.len() {
4242 for j in (i + 1)..tables.len() {
4243 if !keep[i] || !keep[j] {
4244 continue;
4245 }
4246 let (Some(a), Some(b)) = (tables[i].bbox, tables[j].bbox) else {
4247 continue;
4248 };
4249 let ov_w = (a.x + a.width).min(b.x + b.width) - a.x.max(b.x);
4250 let ov_h = (a.y + a.height).min(b.y + b.height) - a.y.max(b.y);
4251 if ov_w <= 0.0 || ov_h <= 0.0 {
4252 continue;
4253 }
4254 let ov_area = ov_w * ov_h;
4255 let min_area = (a.width * a.height).min(b.width * b.height);
4256 if min_area > 0.0 && ov_area / min_area > 0.5 {
4257 if a.width * a.height >= b.width * b.height {
4258 keep[i] = false;
4259 } else {
4260 keep[j] = false;
4261 }
4262 }
4263 }
4264 }
4265 let mut it = keep.iter();
4266 tables.retain(|_| *it.next().unwrap_or(&true));
4267
4268 tables
4269}
4270
4271fn tables_from_horizontal_rules(
4275 spans: &[TextSpan],
4276 lines: &[crate::elements::PathContent],
4277 config: &TableDetectionConfig,
4278) -> Vec<Table> {
4279 let (mut h_edges, _) = extract_edges(lines);
4280 if h_edges.is_empty() || has_vertical_ruling_evidence(lines, &h_edges) {
4281 return Vec::new();
4282 }
4283 snap_and_merge(&mut h_edges);
4284 let mut tables = detect_tables_from_horizontal_rules(spans, &h_edges, config);
4285 let row_h = median_fragment_row_height(&tables);
4298 let y_tol = (row_h * 1.5).max(3.0);
4299 tables = consolidate_adjacent_table_fragments_with_tol(tables, 2.0, y_tol);
4300 tables.retain(|t| t.rows.len() >= 3);
4307 tables
4308}
4309
4310fn tables_from_leftover_rules(
4313 spans: &[TextSpan],
4314 lines: &[crate::elements::PathContent],
4315 found: &[Table],
4316 config: &TableDetectionConfig,
4317) -> Vec<Table> {
4318 let claimed: Option<Vec<crate::geometry::Rect>> = found.iter().map(|t| t.bbox).collect();
4319 let Some(claimed) = claimed.filter(|c| !c.is_empty()) else {
4320 return Vec::new();
4321 };
4322 let free = |r: &crate::geometry::Rect| !claimed.iter().any(|c| c.intersects(r));
4323 let free_lines: Vec<_> = lines.iter().filter(|p| free(&p.rendered_bbox())).cloned().collect();
4324 tables_from_horizontal_rules(spans, &free_lines, config)
4325 .into_iter()
4326 .filter(|t| is_valid_table(t) && t.bbox.as_ref().is_some_and(&free))
4327 .collect()
4328}
4329
4330pub fn detect_tables_with_lines(
4332 spans: &[TextSpan],
4333 lines: &[crate::elements::PathContent],
4334 config: &TableDetectionConfig,
4335) -> Vec<Table> {
4336 if !config.enabled || spans.is_empty() {
4337 return Vec::new();
4338 }
4339 match (config.horizontal_strategy, config.vertical_strategy) {
4340 (TableStrategy::Text, TableStrategy::Text) => {
4341 return detect_tables_from_spans_column_aware(spans, config);
4342 }
4343 (TableStrategy::Lines, TableStrategy::Lines) => {
4344 let mut tables = detect_tables_from_intersections(spans, lines, config);
4345 if tables.is_empty() {
4346 for cluster in group_lines_into_clusters(lines, config) {
4347 tables.append(&mut detect_tables_in_cluster(spans, lines, &cluster, config));
4348 }
4349 }
4350 let mut tables: Vec<Table> = tables.into_iter().filter(is_valid_table).collect();
4351 let leftover = tables_from_leftover_rules(spans, lines, &tables, config);
4352 tables.extend(leftover);
4353 return tables;
4354 }
4355 _ => {}
4356 }
4357 let mut final_tables = detect_tables_from_intersections(spans, lines, config);
4360 if final_tables.is_empty() {
4361 let clusters = group_lines_into_clusters(lines, config);
4362 for cluster in clusters {
4363 final_tables.append(&mut detect_tables_in_cluster(spans, lines, &cluster, config));
4364 }
4365 }
4366 if final_tables.is_empty() {
4369 final_tables = tables_from_horizontal_rules(spans, lines, config);
4370 }
4371 final_tables.retain(is_valid_table);
4374
4375 let allow_text_fallback = config.text_fallback
4385 && config.horizontal_strategy != TableStrategy::Lines
4386 && config.vertical_strategy != TableStrategy::Lines;
4387
4388 if allow_text_fallback {
4389 let text_candidates = detect_tables_from_spans_column_aware(spans, config);
4390 for text_table in text_candidates {
4391 if !passes_spatial_quality_gate(&text_table) {
4392 continue;
4393 }
4394 if text_table.rows.len() < 3 {
4400 continue;
4401 }
4402 if let Some(text_bbox) = text_table.bbox {
4403 let overlaps = final_tables.iter().any(|t| {
4404 if let Some(line_bbox) = t.bbox {
4405 line_bbox.intersects(&text_bbox)
4406 || line_bbox.contains_rect(&text_bbox)
4407 || text_bbox.contains_rect(&line_bbox)
4408 } else {
4409 false
4410 }
4411 });
4412 if !overlaps {
4413 final_tables.push(text_table);
4414 }
4415 }
4416 }
4417 }
4418 final_tables
4419}
4420
4421fn grid_to_table(
4422 grid: &GridStructure,
4423 spans: &[TextSpan],
4424 visual_merge_info: Option<Vec<Vec<CellMergeInfo>>>,
4425) -> Table {
4426 let num_rows = grid.cells.len();
4427 let num_cols = grid.columns.len();
4428 let merge_info = visual_merge_info.unwrap_or_else(|| detect_merged_cells(grid, spans));
4429 let header_row_idx = detect_header_row(grid, spans);
4430 let mut table_rows = Vec::new();
4431 for (row_idx, row) in grid.cells.iter().enumerate() {
4432 let is_header = header_row_idx == Some(row_idx);
4433 let mut table_row = TableRow::new(is_header);
4434 for (col_idx, cell_span_indices) in row.iter().enumerate() {
4435 let mi = &merge_info[row_idx][col_idx];
4436 if mi.covered {
4437 continue;
4438 }
4439 let cell_text = extract_cell_text(cell_span_indices, spans);
4440 let mut cell_bbox = None;
4441 if !cell_span_indices.is_empty() {
4442 let mut b = spans[cell_span_indices[0]].bbox;
4443 for &idx in &cell_span_indices[1..] {
4444 b = b.union(&spans[idx].bbox);
4445 }
4446 cell_bbox = Some(b);
4447 }
4448 let mcids = cell_span_indices
4449 .iter()
4450 .filter_map(|&idx| spans.get(idx).and_then(|s| s.mcid))
4451 .collect::<Vec<_>>();
4452 let cell_spans = cell_span_indices
4453 .iter()
4454 .filter_map(|&idx| spans.get(idx).cloned())
4455 .collect::<Vec<_>>();
4456
4457 table_row.cells.push(TableCell {
4458 text: cell_text,
4459 spans: cell_spans,
4460 colspan: mi.colspan.min((num_cols - col_idx) as u32),
4461 rowspan: mi.rowspan.min((num_rows - row_idx) as u32),
4462 mcids,
4463 bbox: cell_bbox,
4464 is_header,
4465 });
4466 }
4467 table_rows.push(table_row);
4468 }
4469 let all_span_indices: Vec<usize> = grid
4470 .cells
4471 .iter()
4472 .flat_map(|row| row.iter().flat_map(|cell| cell.iter().copied()))
4473 .collect();
4474 let mut bbox = None;
4475 if !all_span_indices.is_empty() {
4476 let mut min_x = f32::INFINITY;
4477 let mut min_y = f32::INFINITY;
4478 let mut max_x = f32::NEG_INFINITY;
4479 let mut max_y = f32::NEG_INFINITY;
4480 for &idx in &all_span_indices {
4481 if let Some(s) = spans.get(idx) {
4482 min_x = min_x.min(s.bbox.x);
4483 min_y = min_y.min(s.bbox.y);
4484 max_x = max_x.max(s.bbox.x + s.bbox.width);
4485 max_y = max_y.max(s.bbox.y + s.bbox.height);
4486 }
4487 }
4488 bbox = Some(crate::geometry::Rect::new(min_x, min_y, max_x - min_x, max_y - min_y));
4489 }
4490 Table {
4491 rows: table_rows,
4492 has_header: header_row_idx.is_some(),
4493 col_count: num_cols,
4494 bbox,
4495 }
4496}
4497
4498fn extract_cell_text(cell_span_indices: &[usize], spans: &[TextSpan]) -> String {
4499 if cell_span_indices.is_empty() {
4500 return String::new();
4501 }
4502 let mut span_entries: Vec<(f32, &TextSpan, String)> = cell_span_indices
4510 .iter()
4511 .filter_map(|&idx| spans.get(idx).map(|s| (s.bbox.center().y, s, span_text_for_cell(s))))
4512 .collect();
4513 if span_entries.is_empty() {
4514 return String::new();
4515 }
4516 if span_entries.len() == 1 {
4517 return span_entries.remove(0).2.trim().to_string();
4521 }
4522 span_entries.sort_by(|a, b| crate::utils::safe_float_cmp(b.0, a.0));
4523
4524 let mut lines: Vec<Vec<(&TextSpan, String)>> = Vec::new();
4527 let mut current_line: Vec<(&TextSpan, String)> = vec![(span_entries[0].1, span_entries[0].2.clone())];
4528 let mut current_y = span_entries[0].0;
4529 for (y, span, text) in &span_entries[1..] {
4530 if (current_y - y).abs() <= 2.0 {
4531 current_line.push((span, text.clone()));
4532 } else {
4533 lines.push(current_line);
4534 current_line = vec![(span, text.clone())];
4535 current_y = *y;
4536 }
4537 }
4538 lines.push(current_line);
4539
4540 lines
4541 .iter()
4542 .map(|line| {
4543 let mut out = String::new();
4544 for (i, (span, text)) in line.iter().enumerate() {
4545 if i > 0 {
4546 let (prev_span, _) = line[i - 1];
4547 let separator = cell_span_separator(prev_span, span);
4548 out.push_str(separator);
4549 }
4550 out.push_str(text);
4551 }
4552 out.trim().to_string()
4558 })
4559 .collect::<Vec<_>>()
4560 .join("\n")
4561}
4562
4563fn cell_span_separator(prev: &TextSpan, current: &TextSpan) -> &'static str {
4569 if prev.text.ends_with(' ') || current.text.starts_with(' ') {
4570 return "";
4571 }
4572
4573 let prev_ends_in_digit = prev.text.trim_end().chars().next_back().is_some_and(|c| c.is_numeric());
4577 let mut current_chars = current.text.trim_start().chars();
4578 let starts_numeric_group = matches!(current_chars.next(), Some('.' | ',' | '\u{066B}' | '\u{066C}'))
4579 && current_chars.clone().next().is_some()
4580 && current_chars.all(|c| c.is_numeric());
4581 if prev_ends_in_digit && starts_numeric_group {
4582 return "";
4583 }
4584
4585 let prev_end_x = prev.bbox.x + prev.bbox.width;
4586 let gap = current.bbox.x - prev_end_x;
4587 let font_size = prev.font_size.max(current.font_size).max(1.0);
4588
4589 let space_threshold = font_size * 0.15;
4590 if gap <= space_threshold {
4591 return "";
4592 }
4593 let is_cjk = |c: char| {
4605 matches!(
4606 c as u32,
4607 0x3040..=0x309F
4608 | 0x30A0..=0x30FF
4609 | 0x4E00..=0x9FFF
4610 | 0xAC00..=0xD7AF
4611 | 0x3400..=0x4DBF
4612 | 0x20000..=0x2A6DF
4613 )
4614 };
4615 let is_fw_op = |c: char| {
4616 matches!(
4617 c as u32,
4618 0xFF0B | 0xFF0D | 0xFF1A | 0xFF1B
4619 | 0xFF1C..=0xFF1E
4620 | 0x2260 | 0x2248
4621 | 0x2264..=0x2265
4622 | 0x00B5 | 0x03BC
4623 | 0x00B1 | 0x00D7 | 0x00F7
4624 )
4625 };
4626 let prev_tail = prev.text.chars().next_back();
4627 let curr_head = current.text.chars().next();
4628 if let (Some(p), Some(c)) = (prev_tail, curr_head) {
4629 let p_cjk = is_cjk(p);
4630 let c_cjk = is_cjk(c);
4631 if (p_cjk || is_fw_op(p)) && (c_cjk || is_fw_op(c)) && (p_cjk || c_cjk) {
4632 return "";
4633 }
4634 }
4635
4636 " "
4637}
4638
4639pub fn consolidate_adjacent_table_fragments(tables: Vec<Table>) -> Vec<Table> {
4663 consolidate_adjacent_table_fragments_with_tol(tables, 2.0, 3.0)
4664}
4665
4666fn median_fragment_row_height(tables: &[Table]) -> f32 {
4671 let mut heights: Vec<f32> = tables
4672 .iter()
4673 .filter_map(|t| {
4674 let b = t.bbox?;
4675 let n = t.rows.len();
4676 if n == 0 || b.height <= 0.0 {
4677 None
4678 } else {
4679 Some(b.height / n as f32)
4680 }
4681 })
4682 .collect();
4683 if heights.is_empty() {
4684 return 0.0;
4685 }
4686 heights.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
4687 heights[heights.len() / 2]
4688}
4689
4690pub fn consolidate_adjacent_table_fragments_with_tol(
4701 tables: Vec<Table>,
4702 x_tolerance: f32,
4703 y_tolerance: f32,
4704) -> Vec<Table> {
4705 if tables.len() < 2 {
4706 return tables;
4707 }
4708
4709 let mut sorted = tables;
4711 sorted.sort_by(|a, b| {
4712 let a_top = a.bbox.map(|b| b.y + b.height).unwrap_or(f32::NEG_INFINITY);
4713 let b_top = b.bbox.map(|b| b.y + b.height).unwrap_or(f32::NEG_INFINITY);
4714 crate::utils::safe_float_cmp(b_top, a_top)
4715 });
4716
4717 let mut consolidated: Vec<Table> = Vec::with_capacity(sorted.len());
4718 for table in sorted {
4719 let merge_into_last = consolidated
4720 .last()
4721 .map(|last| can_merge_tables(last, &table, x_tolerance, y_tolerance))
4722 .unwrap_or(false);
4723 if merge_into_last {
4724 if let Some(last) = consolidated.last_mut() {
4727 merge_table_into(last, table);
4728 }
4729 } else {
4730 consolidated.push(table);
4731 }
4732 }
4733 consolidated
4734}
4735
4736fn can_merge_tables(upper: &Table, lower: &Table, x_tol: f32, y_tol: f32) -> bool {
4737 let (Some(u_bbox), Some(l_bbox)) = (upper.bbox, lower.bbox) else {
4738 return false;
4739 };
4740 if upper.col_count != lower.col_count || upper.col_count == 0 {
4741 return false;
4742 }
4743 if (u_bbox.x - l_bbox.x).abs() > x_tol {
4744 return false;
4745 }
4746 if (u_bbox.width - l_bbox.width).abs() > x_tol {
4747 return false;
4748 }
4749 let upper_bottom = u_bbox.y;
4759 let lower_top = l_bbox.y + l_bbox.height;
4760 let gap = upper_bottom - lower_top;
4761 if gap > y_tol {
4762 return false;
4763 }
4764 let min_height = u_bbox.height.min(l_bbox.height);
4765 if -gap > min_height * 0.5 {
4766 return false;
4767 }
4768 true
4769}
4770
4771fn merge_table_into(upper: &mut Table, lower: Table) {
4772 if let (Some(ub), Some(lb)) = (upper.bbox, lower.bbox) {
4773 let new_y = ub.y.min(lb.y);
4774 let new_top = (ub.y + ub.height).max(lb.y + lb.height);
4775 let new_x = ub.x.min(lb.x);
4776 let new_right = (ub.x + ub.width).max(lb.x + lb.width);
4777 upper.bbox = Some(crate::geometry::Rect {
4778 x: new_x,
4779 y: new_y,
4780 width: new_right - new_x,
4781 height: new_top - new_y,
4782 });
4783 }
4784 upper.rows.extend(lower.rows);
4785}
4786
4787fn detect_merged_cells(grid: &GridStructure, spans: &[TextSpan]) -> Vec<Vec<CellMergeInfo>> {
4788 let num_rows = grid.cells.len();
4789 let num_cols = grid.columns.len();
4790 let mut merge_info: Vec<Vec<CellMergeInfo>> = (0..num_rows)
4791 .map(|_| {
4792 (0..num_cols)
4793 .map(|_| CellMergeInfo {
4794 colspan: 1,
4795 rowspan: 1,
4796 covered: false,
4797 })
4798 .collect()
4799 })
4800 .collect();
4801 for row_idx in 0..num_rows {
4802 for col_idx in 0..num_cols {
4803 if grid.cells[row_idx][col_idx].is_empty() {
4804 continue;
4805 }
4806 let cell_right = grid.cells[row_idx][col_idx]
4807 .iter()
4808 .filter_map(|&idx| spans.get(idx).map(|s| s.bbox.right()))
4809 .fold(f32::NEG_INFINITY, f32::max);
4810 if cell_right == f32::NEG_INFINITY {
4811 continue;
4812 }
4813 let mut extra_cols = 0u32;
4814 for next_col in (col_idx + 1)..num_cols {
4815 if !grid.cells[row_idx][next_col].is_empty() {
4816 break;
4817 }
4818 if cell_right > grid.columns[next_col].x_center {
4819 extra_cols += 1;
4820 } else {
4821 break;
4822 }
4823 }
4824 if extra_cols > 0 {
4825 merge_info[row_idx][col_idx].colspan = 1 + extra_cols;
4826 for c in 1..=(extra_cols as usize) {
4827 merge_info[row_idx][col_idx + c].covered = true;
4828 }
4829 }
4830 }
4831 }
4832 for col_idx in 0..num_cols {
4833 for row_idx in 0..num_rows {
4834 if grid.cells[row_idx][col_idx].is_empty() || merge_info[row_idx][col_idx].covered {
4835 continue;
4836 }
4837 let cell_bottom = grid.cells[row_idx][col_idx]
4838 .iter()
4839 .filter_map(|&idx| spans.get(idx).map(|s| s.bbox.bottom()))
4840 .fold(f32::INFINITY, f32::min);
4841 if cell_bottom == f32::INFINITY {
4842 continue;
4843 }
4844 let mut extra_rows = 0u32;
4845 for next_row in (row_idx + 1)..num_rows {
4846 if !grid.cells[next_row][col_idx].is_empty() {
4847 break;
4848 }
4849 if cell_bottom < grid.rows[next_row].y_center {
4850 extra_rows += 1;
4851 } else {
4852 break;
4853 }
4854 }
4855 if extra_rows > 0 {
4856 merge_info[row_idx][col_idx].rowspan = 1 + extra_rows;
4857 for r in 1..=(extra_rows as usize) {
4858 merge_info[row_idx + r][col_idx].covered = true;
4859 }
4860 }
4861 }
4862 }
4863 merge_info
4864}
4865
4866fn detect_header_row(grid: &GridStructure, spans: &[TextSpan]) -> Option<usize> {
4867 if grid.cells.len() < 2 {
4868 return None;
4869 }
4870 let first_row_spans: Vec<&TextSpan> = grid.cells[0]
4871 .iter()
4872 .flat_map(|cell| cell.iter().filter_map(|&idx| spans.get(idx)))
4873 .collect();
4874 if first_row_spans.is_empty() {
4875 return None;
4876 }
4877 let data_row_spans: Vec<&TextSpan> = grid.cells[1..]
4878 .iter()
4879 .flat_map(|row| {
4880 row.iter()
4881 .flat_map(|cell| cell.iter().filter_map(|&idx| spans.get(idx)))
4882 })
4883 .collect();
4884 if data_row_spans.is_empty() {
4885 return None;
4886 }
4887 let first_row_bold_ratio =
4888 first_row_spans.iter().filter(|s| s.font_weight.is_bold()).count() as f32 / first_row_spans.len() as f32;
4889 let data_bold_ratio =
4890 data_row_spans.iter().filter(|s| s.font_weight.is_bold()).count() as f32 / data_row_spans.len() as f32;
4891 if first_row_bold_ratio > 0.5 && data_bold_ratio < 0.3 {
4892 return Some(0);
4893 }
4894 let first_row_avg_size: f32 =
4895 first_row_spans.iter().map(|s| s.font_size).sum::<f32>() / first_row_spans.len() as f32;
4896 let data_avg_size: f32 = data_row_spans.iter().map(|s| s.font_size).sum::<f32>() / data_row_spans.len() as f32;
4897 if first_row_avg_size > data_avg_size + 1.5 {
4898 return Some(0);
4899 }
4900 None
4901}
4902
4903#[cfg(test)]
4904mod tests {
4905 use super::*;
4906 use crate::geometry::Rect;
4907 use crate::layout::text_block::{Color, FontWeight};
4908
4909 #[test]
4910 fn row_spanning_cell_ignores_a_neighbours_one_sided_crossing() {
4911 let horizontal = vec![
4914 Edge {
4915 coord: 0.,
4916 start: 0.,
4917 end: 80.,
4918 },
4919 Edge {
4920 coord: 20.,
4921 start: 0.,
4922 end: 40.,
4923 },
4924 Edge {
4925 coord: 40.,
4926 start: 0.,
4927 end: 80.,
4928 },
4929 ];
4930 let vertical = vec![
4931 Edge {
4932 coord: 0.,
4933 start: 0.,
4934 end: 40.,
4935 },
4936 Edge {
4937 coord: 40.,
4938 start: 0.,
4939 end: 40.,
4940 },
4941 Edge {
4942 coord: 80.,
4943 start: 0.,
4944 end: 40.,
4945 },
4946 ];
4947 let points = find_intersections(&horizontal, &vertical);
4948 let cells = build_cells_from_intersections(&points, &horizontal, &vertical);
4949 assert!(
4950 cells
4951 .iter()
4952 .any(|c| c.x1 == 40. && c.x2 == 80. && c.y1 == 0. && c.y2 == 40.),
4953 "{cells:?}"
4954 );
4955 assert_eq!(cells.len(), 3);
4956 }
4957
4958 #[test]
4959 fn later_closing_corners_do_not_bridge_separate_boxes() {
4960 let horizontal = vec![
4961 Edge {
4962 coord: 0.,
4963 start: 0.,
4964 end: 20.,
4965 },
4966 Edge {
4967 coord: 10.,
4968 start: 0.,
4969 end: 20.,
4970 },
4971 Edge {
4972 coord: 40.,
4973 start: 0.,
4974 end: 20.,
4975 },
4976 Edge {
4977 coord: 0.,
4978 start: 40.,
4979 end: 60.,
4980 },
4981 Edge {
4982 coord: 40.,
4983 start: 40.,
4984 end: 60.,
4985 },
4986 ];
4987 let vertical: Vec<_> = [0., 20., 40., 60.]
4988 .into_iter()
4989 .map(|coord| Edge {
4990 coord,
4991 start: 0.,
4992 end: 40.,
4993 })
4994 .collect();
4995 let points = find_intersections(&horizontal, &vertical);
4996 let cells = build_cells_from_intersections(&points, &horizontal, &vertical);
4997 assert!(
4998 !cells
4999 .iter()
5000 .any(|cell| cell.x1 == 20. && cell.x2 == 40. && cell.y1 == 0.),
5001 "{cells:?}"
5002 );
5003 }
5004
5005 #[test]
5006 fn cell_search_does_not_skip_a_rule_entering_its_interior() {
5007 let horizontal = vec![
5008 Edge {
5009 coord: 0.,
5010 start: 0.,
5011 end: 80.,
5012 },
5013 Edge {
5014 coord: 20.,
5015 start: 0.,
5016 end: 30.,
5017 },
5018 Edge {
5019 coord: 40.,
5020 start: 0.,
5021 end: 80.,
5022 },
5023 ];
5024 let vertical = vec![
5025 Edge {
5026 coord: 0.,
5027 start: 0.,
5028 end: 40.,
5029 },
5030 Edge {
5031 coord: 80.,
5032 start: 0.,
5033 end: 40.,
5034 },
5035 ];
5036 let points = find_intersections(&horizontal, &vertical);
5037 let cells = build_cells_from_intersections(&points, &horizontal, &vertical);
5038 assert!(
5039 !cells
5040 .iter()
5041 .any(|cell| cell.x1 == 0. && cell.y1 == 0. && cell.x2 == 80. && cell.y2 == 40.),
5042 "{cells:?}"
5043 );
5044 }
5045
5046 #[test]
5047 fn farther_corners_without_a_divider_ending_below_are_not_header_cells() {
5048 let points = [
5049 Intersection { x: 0., y: 0. },
5050 Intersection { x: 40., y: 0. },
5051 Intersection { x: 80., y: 0. },
5052 Intersection { x: 0., y: 20. },
5053 Intersection { x: 80., y: 20. },
5054 ];
5055 let h = [
5056 Edge {
5057 coord: 0.,
5058 start: 0.,
5059 end: 80.,
5060 },
5061 Edge {
5062 coord: 20.,
5063 start: 0.,
5064 end: 80.,
5065 },
5066 ];
5067 let v = [
5068 Edge {
5069 coord: 0.,
5070 start: 0.,
5071 end: 20.,
5072 },
5073 Edge {
5074 coord: 40.,
5075 start: 0.,
5076 end: 10.,
5077 },
5078 Edge {
5079 coord: 80.,
5080 start: 0.,
5081 end: 20.,
5082 },
5083 ];
5084 assert!(build_cells_from_intersections(&points, &h, &v).is_empty());
5085 }
5086
5087 #[test]
5088 fn a_single_ruled_header_row_does_not_claim_the_unruled_body() {
5089 let mut cells: Vec<_> = (0..6)
5090 .map(|column| IntersectionCell {
5091 x1: column as f32 * 20.,
5092 y1: 0.,
5093 x2: (column + 1) as f32 * 20.,
5094 y2: 20.,
5095 })
5096 .collect();
5097 cells.push(IntersectionCell {
5098 x1: 0.,
5099 y1: 20.,
5100 x2: 120.,
5101 y2: 40.,
5102 });
5103 let groups = attach_spanning_headers(&cells, group_cells_into_tables(&cells), 6);
5104 assert_eq!(groups.len(), 2);
5105 }
5106
5107 #[test]
5108 fn sparse_header_boxes_do_not_form_a_new_table() {
5109 let cells = [
5110 IntersectionCell {
5111 x1: 0.,
5112 y1: 20.,
5113 x2: 80.,
5114 y2: 40.,
5115 },
5116 IntersectionCell {
5117 x1: 0.,
5118 y1: 0.,
5119 x2: 40.,
5120 y2: 20.,
5121 },
5122 IntersectionCell {
5123 x1: 40.,
5124 y1: 0.,
5125 x2: 80.,
5126 y2: 20.,
5127 },
5128 ];
5129 let groups = attach_spanning_headers(&cells, group_cells_into_tables(&cells), 6);
5130 assert_eq!(groups.len(), 2);
5131 }
5132
5133 #[test]
5134 fn inset_fills_do_not_add_columns_inside_a_ruled_band() {
5135 use crate::elements::PathContent;
5136 let mut fill = PathContent::rect(4., 0.5, 72., 19.);
5137 fill.stroke_color = None;
5138 fill.fill_color = Some(Color::black());
5139 let rules = vec![
5140 PathContent::line(0., 0., 80., 0.),
5141 PathContent::line(0., 20., 80., 20.),
5142 PathContent::line(0., 0., 0., 20.),
5143 PathContent::line(80., 0., 80., 20.),
5144 ];
5145 let mut outer = PathContent::rect(0., 0., 80., 20.);
5146 outer.stroke_color = None;
5147 outer.fill_color = Some(Color::black());
5148 let mut shaded = rules.clone();
5149 shaded.extend([outer, fill.clone()]);
5150 let (mut h, mut v) = extract_edges(&shaded);
5151 snap_and_merge(&mut h);
5152 snap_and_merge(&mut v);
5153 assert_eq!(h.len(), 2);
5154 assert_eq!(v.len(), 2);
5155 let mut single_fill = rules.clone();
5157 single_fill.push(fill.clone());
5158 let (h, v) = extract_edges(&single_fill);
5159 assert_eq!(h.len(), 4);
5160 assert_eq!(v.len(), 4);
5161 let (h, v) = extract_edges(&[rules[0].clone(), rules[1].clone(), fill.clone()]);
5163 assert_eq!(h.len(), 4);
5164 assert_eq!(v.len(), 2);
5165 fill.stroke_color = Some(Color::black());
5167 let mut boxed = rules;
5168 boxed.push(fill);
5169 let (h, v) = extract_edges(&boxed);
5170 assert_eq!(h.len(), 4);
5171 assert_eq!(v.len(), 4);
5172 }
5173
5174 #[test]
5175 fn spanning_header_stays_in_body_group() {
5176 let cells = [
5177 IntersectionCell {
5178 x1: 0.,
5179 y1: 40.,
5180 x2: 80.,
5181 y2: 60.,
5182 },
5183 IntersectionCell {
5184 x1: 0.,
5185 y1: 0.,
5186 x2: 40.,
5187 y2: 20.,
5188 },
5189 IntersectionCell {
5190 x1: 40.,
5191 y1: 0.,
5192 x2: 80.,
5193 y2: 20.,
5194 },
5195 IntersectionCell {
5196 x1: 0.,
5197 y1: 20.,
5198 x2: 40.,
5199 y2: 40.,
5200 },
5201 IntersectionCell {
5202 x1: 40.,
5203 y1: 20.,
5204 x2: 80.,
5205 y2: 40.,
5206 },
5207 ];
5208 let groups = attach_spanning_headers(&cells, group_cells_into_tables(&cells), 2);
5209 assert_eq!(groups.len(), 1, "{groups:?}");
5210 }
5211
5212 #[test]
5213 fn unrelated_cells_stay_separate_after_header_attachment() {
5214 let body: Vec<_> = (0..2)
5217 .flat_map(|row| {
5218 (0..3).map(move |column| IntersectionCell {
5219 x1: column as f32 * 40.,
5220 y1: row as f32 * 20.,
5221 x2: (column + 1) as f32 * 40.,
5222 y2: (row + 1) as f32 * 20.,
5223 })
5224 })
5225 .collect();
5226 for (case, candidate) in [
5227 (
5228 "gap",
5229 IntersectionCell {
5230 x1: 0.,
5231 y1: 50.,
5232 x2: 120.,
5233 y2: 70.,
5234 },
5235 ),
5236 (
5237 "corner",
5238 IntersectionCell {
5239 x1: 120.,
5240 y1: 40.,
5241 x2: 160.,
5242 y2: 60.,
5243 },
5244 ),
5245 ] {
5246 let mut cells = body.clone();
5247 cells.push(candidate);
5248 let groups = attach_spanning_headers(&cells, group_cells_into_tables(&cells), 6);
5249 assert_eq!(groups, vec![vec![0, 1, 2, 3, 4, 5], vec![6]], "{case}");
5250 }
5251 }
5252
5253 #[test]
5254 fn header_cell_search_skips_nonclosing_inner_corner() {
5255 let points = [
5256 Intersection { x: 0., y: 0. },
5257 Intersection { x: 40., y: 0. },
5258 Intersection { x: 80., y: 0. },
5259 Intersection { x: 0., y: 20. },
5260 Intersection { x: 80., y: 20. },
5261 ];
5262 let h = [
5263 Edge {
5264 coord: 0.,
5265 start: 0.,
5266 end: 80.,
5267 },
5268 Edge {
5269 coord: 20.,
5270 start: 0.,
5271 end: 80.,
5272 },
5273 ];
5274 let v = [
5275 Edge {
5276 coord: 0.,
5277 start: 0.,
5278 end: 20.,
5279 },
5280 Edge {
5281 coord: 40.,
5282 start: -20.,
5283 end: 0.,
5284 },
5285 Edge {
5286 coord: 80.,
5287 start: 0.,
5288 end: 20.,
5289 },
5290 ];
5291 let cells = build_cells_from_intersections(&points, &h, &v);
5292 assert!(
5293 cells
5294 .iter()
5295 .any(|c| c.x1 == 0. && c.x2 == 80. && c.y1 == 0. && c.y2 == 20.),
5296 "{cells:?}"
5297 );
5298 }
5299
5300 #[test]
5301 fn rowspan_preserves_label_once_and_rejects_outside_text() {
5302 let cells = [
5303 IntersectionCell {
5304 x1: 0.,
5305 y1: 0.,
5306 x2: 40.,
5307 y2: 40.,
5308 },
5309 IntersectionCell {
5310 x1: 40.,
5311 y1: 0.,
5312 x2: 80.,
5313 y2: 20.,
5314 },
5315 IntersectionCell {
5316 x1: 40.,
5317 y1: 20.,
5318 x2: 80.,
5319 y2: 40.,
5320 },
5321 ];
5322 let spans = [
5323 create_test_span("Label", 5., 28., 20., 6.),
5324 create_test_span("Outside", 5., 50., 20., 6.),
5325 ];
5326 let edges = [Edge {
5327 coord: 40.,
5328 start: 0.,
5329 end: 40.,
5330 }];
5331 let (rows, _) =
5332 assign_spans_to_intersection_grid(&cells, &[0., 40., 80.], &[0., 20., 40.], 2, &spans, &edges, true)
5333 .unwrap();
5334 let texts: Vec<_> = rows.iter().flat_map(|r| &r.cells).map(|c| c.text.as_str()).collect();
5335 assert_eq!(texts.iter().filter(|&&t| t == "Label").count(), 1, "{texts:?}");
5336 assert!(!texts.iter().any(|t| t.contains("Outside")));
5337 }
5338
5339 #[test]
5340 fn colspan_preserves_off_center_header_once() {
5341 let cells = [
5342 IntersectionCell {
5343 x1: 0.,
5344 y1: 20.,
5345 x2: 80.,
5346 y2: 40.,
5347 },
5348 IntersectionCell {
5349 x1: 0.,
5350 y1: 0.,
5351 x2: 40.,
5352 y2: 20.,
5353 },
5354 IntersectionCell {
5355 x1: 40.,
5356 y1: 0.,
5357 x2: 80.,
5358 y2: 20.,
5359 },
5360 ];
5361 let spans = [create_test_span("Heading", 50., 28., 20., 6.)];
5362 let edges = [Edge {
5363 coord: 40.,
5364 start: 0.,
5365 end: 20.,
5366 }];
5367 let (rows, _) =
5368 assign_spans_to_intersection_grid(&cells, &[0., 40., 80.], &[0., 20., 40.], 2, &spans, &edges, true)
5369 .unwrap();
5370 assert_eq!(
5371 rows.iter()
5372 .flat_map(|r| &r.cells)
5373 .filter(|c| c.text == "Heading")
5374 .count(),
5375 1
5376 );
5377 }
5378
5379 #[test]
5380 fn grid_has_cell_skips_cell_with_reversed_boundaries() {
5381 let cells = [IntersectionCell {
5384 x1: 0.,
5385 y1: 40.,
5386 x2: 40.,
5387 y2: 0.,
5388 }];
5389 let result = assign_spans_to_intersection_grid(&cells, &[0., 40., 80.], &[0., 20., 40.], 2, &[], &[], true);
5390 assert!(result.is_some(), "malformed cell geometry must not panic");
5391 }
5392
5393 #[test]
5394 fn test_is_numeric_cell() {
5395 for ok in ["0.69", "100", "-1.2", "52%", "0", "1.00", "\u{2212}3.5"] {
5396 assert!(is_numeric_cell(ok), "{ok:?} should be numeric");
5397 }
5398 for no in ["Ours", "GLUE", "v3", "1e9", "0.6.6", "", "12345678.9", "p<0.05"] {
5399 assert!(!is_numeric_cell(no), "{no:?} should NOT be numeric");
5400 }
5401 }
5402
5403 fn col_at(x: f32) -> ColumnCluster {
5404 ColumnCluster {
5405 x_center: x,
5406 x_min: x - 3.0,
5407 x_max: x + 3.0,
5408 span_indices: Vec::new(),
5409 }
5410 }
5411
5412 #[test]
5413 fn grid_interval_prefers_exact_side_near_internal_boundary() {
5414 let boundaries = [0.0, 40.0, 70.0, 100.0];
5415
5416 assert_eq!(grid_interval_for_point(68.5, &boundaries), Some(1));
5417 assert_eq!(grid_interval_for_point(71.0, &boundaries), Some(2));
5418 assert_eq!(grid_interval_for_point(70.0, &boundaries), Some(2));
5419 assert_eq!(grid_interval_for_point(-2.0, &boundaries), Some(0));
5420 assert_eq!(grid_interval_for_point(102.0, &boundaries), Some(2));
5421 assert_eq!(grid_interval_for_point(104.0, &boundaries), None);
5422 }
5423
5424 #[test]
5425 fn intersection_grid_keeps_superscripts_and_boundary_text_in_their_cells() {
5426 let group_cells = [
5427 IntersectionCell {
5428 x1: 0.0,
5429 y1: 0.0,
5430 x2: 40.0,
5431 y2: 20.0,
5432 },
5433 IntersectionCell {
5434 x1: 40.0,
5435 y1: 0.0,
5436 x2: 70.0,
5437 y2: 20.0,
5438 },
5439 IntersectionCell {
5440 x1: 70.0,
5441 y1: 0.0,
5442 x2: 100.0,
5443 y2: 20.0,
5444 },
5445 ];
5446 let spans = vec![
5447 create_test_span("273", 10.0, 5.0, 27.0, 10.0),
5448 create_test_span("1", 37.0, 5.0, 2.0, 6.0),
5449 create_test_span("83", 45.0, 5.0, 21.0, 10.0),
5450 create_test_span("2", 66.0, 5.0, 2.0, 6.0),
5451 create_test_span("Europe", 66.0, 5.0, 10.0, 10.0),
5452 ];
5453 let v_edges = [
5456 Edge {
5457 coord: 40.0,
5458 start: 0.0,
5459 end: 20.0,
5460 },
5461 Edge {
5462 coord: 70.0,
5463 start: 0.0,
5464 end: 20.0,
5465 },
5466 ];
5467
5468 let (rows, _) = assign_spans_to_intersection_grid(
5469 &group_cells,
5470 &[0.0, 40.0, 70.0, 100.0],
5471 &[0.0, 20.0],
5472 3,
5473 &spans,
5474 &v_edges,
5475 true,
5476 )
5477 .expect("synthetic grid should be valid");
5478
5479 assert_eq!(rows.len(), 1);
5480 assert_eq!(rows[0].cells[0].text, "2731");
5481 assert_eq!(rows[0].cells[1].text, "832");
5482 assert_eq!(rows[0].cells[2].text, "Europe");
5483 }
5484
5485 #[test]
5491 fn unruled_band_between_ruled_bands_is_not_cut_at_phantom_columns() {
5492 let group_cells = [
5493 IntersectionCell {
5495 x1: 44.8,
5496 y1: 525.9,
5497 x2: 289.2,
5498 y2: 585.9,
5499 },
5500 IntersectionCell {
5501 x1: 289.2,
5502 y1: 525.9,
5503 x2: 317.6,
5504 y2: 585.9,
5505 },
5506 IntersectionCell {
5507 x1: 317.6,
5508 y1: 525.9,
5509 x2: 551.5,
5510 y2: 585.9,
5511 },
5512 IntersectionCell {
5515 x1: 44.8,
5516 y1: 465.4,
5517 x2: 289.2,
5518 y2: 525.9,
5519 },
5520 IntersectionCell {
5521 x1: 289.2,
5522 y1: 465.4,
5523 x2: 317.6,
5524 y2: 525.9,
5525 },
5526 IntersectionCell {
5527 x1: 317.6,
5528 y1: 465.4,
5529 x2: 551.5,
5530 y2: 525.9,
5531 },
5532 ];
5533 let xs = [44.8, 289.2, 317.6, 551.5];
5534 let ys = [465.4, 525.9, 585.9];
5535
5536 let mut spans = vec![
5538 create_test_span("A", 140.0, 545.0, 20.0, 15.0),
5539 create_test_span("B", 295.0, 545.0, 15.0, 15.0),
5540 create_test_span("C", 400.0, 545.0, 20.0, 15.0),
5541 ];
5542 spans.extend([
5546 create_test_span(
5547 "8.2.7 Geen warmwater (alleen bij toepassing indirect",
5548 60.0,
5549 490.0,
5550 220.0,
5551 15.0,
5552 ),
5553 create_test_span("gest", 295.0, 490.0, 20.0, 15.0),
5554 create_test_span("ookte boiler)", 400.0, 490.0, 60.0, 15.0),
5555 ]);
5556
5557 let v_edges = [
5562 Edge {
5563 coord: 289.2,
5564 start: 525.9,
5565 end: 585.9,
5566 },
5567 Edge {
5568 coord: 317.6,
5569 start: 525.9,
5570 end: 585.9,
5571 },
5572 ];
5573
5574 let (rows, _) = assign_spans_to_intersection_grid(&group_cells, &xs, &ys, 3, &spans, &v_edges, true)
5575 .expect("synthetic grid should be valid");
5576
5577 assert_eq!(rows.len(), 2);
5578 let ruled_row = &rows[0];
5579 let unruled_row = &rows[1];
5580
5581 assert_eq!(ruled_row.cells.len(), 3, "ruled band must keep its three columns");
5583 assert_eq!(ruled_row.cells[0].text, "A");
5584 assert_eq!(ruled_row.cells[1].text, "B");
5585 assert_eq!(ruled_row.cells[2].text, "C");
5586
5587 let unruled_texts: Vec<&str> = unruled_row.cells.iter().map(|c| c.text.as_str()).collect();
5589 assert_eq!(
5590 unruled_texts.len(),
5591 1,
5592 "unruled band must not be split at phantom column positions, got {unruled_texts:?}"
5593 );
5594 assert!(
5595 unruled_texts[0].contains("gest"),
5596 "heading text should be reassembled as one run, got {unruled_texts:?}"
5597 );
5598 }
5599
5600 #[test]
5605 fn should_not_count_a_rule_that_stops_short_of_the_band_as_a_boundary() {
5606 let xs = [0.0, 40.0, 80.0];
5607 let (y_lo, y_hi) = (0.0, 20.0);
5608 let num_cols = 2;
5609
5610 let v_edges = [Edge {
5614 coord: 40.0,
5615 start: y_lo + 2.0,
5616 end: y_hi - 2.0,
5617 }];
5618
5619 let groups = band_column_groups(&xs, y_lo, y_hi, num_cols, &v_edges, true);
5620
5621 assert_eq!(
5622 groups,
5623 vec![(0, 1)],
5624 "a rule that stops short of the band must not split it into separate column groups, got {groups:?}"
5625 );
5626 }
5627
5628 #[test]
5629 fn test_is_regular_lattice() {
5630 let regular: Vec<ColumnCluster> = [113.0, 150.0, 170.0, 190.0, 210.0, 230.0]
5632 .iter()
5633 .map(|&x| col_at(x))
5634 .collect();
5635 assert!(is_regular_lattice(®ular));
5636
5637 let small: Vec<ColumnCluster> = [100.0, 200.0, 300.0].iter().map(|&x| col_at(x)).collect();
5638 assert!(!is_regular_lattice(&small));
5639
5640 let irregular: Vec<ColumnCluster> = [100.0, 140.0, 320.0, 330.0, 500.0, 505.0]
5642 .iter()
5643 .map(|&x| col_at(x))
5644 .collect();
5645 assert!(!is_regular_lattice(&irregular));
5646 }
5647
5648 #[test]
5649 fn test_is_data_value() {
5650 for v in ["5,012", "+2%", "240", "-1.5", "1,000.50", "67", "\u{2212}3"] {
5651 assert!(is_data_value(v), "{v:?} should be a data value");
5652 }
5653 for w in ["FY22", "Mercury", "Body", "", "YoY", "$", "+", "Q1"] {
5654 assert!(!is_data_value(w), "{w:?} should NOT be a data value");
5655 }
5656 }
5657
5658 #[test]
5659 fn test_quality_gate_admits_numeric_table_rejects_prose_split() {
5660 let row = |cells: &[&str]| TableRow {
5661 cells: cells.iter().map(|c| prose_cell(c)).collect(),
5662 is_header: false,
5663 };
5664 let mut numeric = Table::new();
5667 numeric.col_count = 8;
5668 for r in [
5669 ["Body", "FY22", "FY23", "FY24", "FY25", "YoY", "Plan", "Var"],
5670 [
5671 "Mercury Transits",
5672 "5,012",
5673 "5,210",
5674 "5,488",
5675 "5,612",
5676 "+2%",
5677 "5,600",
5678 "+12",
5679 ],
5680 [
5681 "Venus Phases",
5682 "1,840",
5683 "1,902",
5684 "1,975",
5685 "2,041",
5686 "+3%",
5687 "2,030",
5688 "+11",
5689 ],
5690 ] {
5691 numeric.rows.push(row(&r));
5692 }
5693 assert!(
5694 passes_spatial_quality_gate(&numeric),
5695 "dense numeric table must pass the spatial quality gate"
5696 );
5697
5698 let mut prose = Table::new();
5700 prose.col_count = 6;
5701 for _ in 0..3 {
5702 prose.rows.push(row(&["the", "quick", "brown", "fox", "jumps", "over"]));
5703 }
5704 assert!(
5705 !passes_spatial_quality_gate(&prose),
5706 "word-dominated single-word split must still be rejected"
5707 );
5708 }
5709
5710 fn prose_cell(text: &str) -> TableCell {
5711 TableCell {
5712 text: text.to_string(),
5713 spans: Vec::new(),
5714 colspan: 1,
5715 rowspan: 1,
5716 mcids: Vec::new(),
5717 bbox: None,
5718 is_header: false,
5719 }
5720 }
5721
5722 #[test]
5723 fn test_looks_like_cjk_prose() {
5724 let row = |cells: &[&str]| TableRow {
5725 cells: cells.iter().map(|c| prose_cell(c)).collect(),
5726 is_header: false,
5727 };
5728 let mut prose = Table::new();
5730 prose.col_count = 2;
5731 prose.rows.push(row(&[
5732 "\u{30CD}\u{30B3}",
5733 "\u{72ED}\u{7FA9}\u{306B}\u{306F}\u{98DF}\u{8089}\u{76EE}\u{30CD}\u{30B3}\u{79D1}\u{30CD}\u{30B3}\u{5C5E}",
5734 ]));
5735 assert!(looks_like_cjk_prose(&prose));
5736
5737 let mut table = Table::new();
5739 table.col_count = 2;
5740 table.rows.push(row(&["\u{58F2}\u{4E0A}", "1,234"]));
5741 table.rows.push(row(&["\u{5229}\u{76CA}", "567"]));
5742 assert!(!looks_like_cjk_prose(&table));
5743
5744 let mut latin = Table::new();
5746 latin.col_count = 2;
5747 latin.rows.push(row(&["Region", "North"]));
5748 assert!(!looks_like_cjk_prose(&latin));
5749 }
5750
5751 #[test]
5752 fn test_looks_like_bulleted_list_rejects_bullet_cells() {
5753 let row = |cells: &[&str]| TableRow {
5754 cells: cells.iter().map(|c| prose_cell(c)).collect(),
5755 is_header: false,
5756 };
5757 let mut list = Table::new();
5760 list.col_count = 2;
5761 list.rows.push(row(&["\u{2022}", "Ship the API."]));
5762 list.rows.push(row(&["\u{2022}", "Write the docs."]));
5763 assert!(looks_like_bulleted_list(&list));
5764
5765 let mut table = Table::new();
5767 table.col_count = 2;
5768 table.rows.push(row(&["Region", "Q1"]));
5769 table.rows.push(row(&["North", "120"]));
5770 assert!(!looks_like_bulleted_list(&table));
5771
5772 let mut dash = Table::new();
5775 dash.col_count = 2;
5776 dash.rows.push(row(&["Delta", "-12"]));
5777 assert!(!looks_like_bulleted_list(&dash));
5778 }
5779
5780 #[test]
5784 fn test_looks_like_prose_paragraph_detects_sentence_crossing_row() {
5785 let mut t = Table::new();
5786 t.col_count = 4;
5787 t.rows.push(TableRow {
5788 cells: vec![
5789 prose_cell("Total SKU count grew 15%"),
5790 prose_cell("quarter-over-quarter to"),
5791 prose_cell("23,500."),
5792 prose_cell("Stockout rate improved by 200 basis"),
5793 ],
5794 is_header: false,
5795 });
5796 assert!(looks_like_prose_paragraph(&t));
5797 }
5798
5799 #[test]
5804 fn test_looks_like_prose_paragraph_detects_bengali_danda_crossing_row() {
5805 let mut t = Table::new();
5806 t.col_count = 4;
5807 t.rows.push(TableRow {
5808 cells: vec![
5809 prose_cell("বিড়াল একটি গার্হস্থ্য প্রজাতি"),
5810 prose_cell("স্তন্যপায়ী প্রাণী"),
5811 prose_cell("। এটি"),
5812 prose_cell("ফেলিডা পরিবারের একমাত্র গৃহপালিত প্রজাতি"),
5813 ],
5814 is_header: false,
5815 });
5816 assert!(looks_like_prose_paragraph(&t));
5817 }
5818
5819 #[test]
5822 fn test_looks_like_prose_paragraph_keeps_real_table() {
5823 let mut t = Table::new();
5824 t.col_count = 4;
5825 for cells in [
5826 ["Zone", "Pallets stored", "11,100", "-2.5%"],
5827 ["A", "Utilization", "87%", "-3pp"],
5828 ["B", "Damage rate", "0.3%", "-0.2pp"],
5829 ] {
5830 t.rows.push(TableRow {
5831 cells: cells.iter().map(|c| prose_cell(c)).collect(),
5832 is_header: false,
5833 });
5834 }
5835 assert!(!looks_like_prose_paragraph(&t));
5836 }
5837
5838 #[test]
5839 fn test_line_clustering_multiple_tables() {
5840 let lines = vec![
5841 make_rect_path(10.0, 100.0, 50.0, 20.0),
5842 make_rect_path(10.0, 50.0, 50.0, 20.0), ];
5844 let config = TableDetectionConfig::default();
5845 let clusters = group_lines_into_clusters(&lines, &config);
5846 assert_eq!(
5847 clusters.len(),
5848 2,
5849 "Should find 2 separate table regions with optimized clustering"
5850 );
5851 }
5852
5853 #[test]
5854 fn test_line_clustering_horizontal_separation() {
5855 let lines = vec![
5856 make_rect_path(10.0, 100.0, 50.0, 20.0), make_rect_path(80.0, 100.0, 50.0, 20.0), ];
5859 let config = TableDetectionConfig::default();
5860 let clusters = group_lines_into_clusters(&lines, &config);
5861 assert_eq!(
5862 clusters.len(),
5863 2,
5864 "Should find 2 separate table regions even if nearby horizontally"
5865 );
5866 }
5867
5868 #[test]
5878 fn test_gh1656_page_frame_unions_clusters_until_caller_filters_it() {
5879 let media_box = (0.0, 0.0, 595.28, 842.0);
5880 let page_frame = make_rect_path(0.0, 0.0, 595.28, 842.0);
5881
5882 let table_lines = vec![
5885 make_v_line(45.36, 472.70, 136.80),
5886 make_v_line(321.84, 472.70, 136.80),
5887 make_h_line(45.36, 609.50, 276.48),
5888 make_h_line(45.36, 472.70, 276.48),
5889 ];
5890 let footer_rule = make_h_line(0.0, 48.30, 595.28);
5894 let figure_border = make_rect_path(400.0, 700.0, 100.0, 80.0);
5897
5898 let mut all = vec![page_frame];
5899 all.extend(table_lines.clone());
5900 all.push(footer_rule.clone());
5901 all.push(figure_border.clone());
5902
5903 let config = TableDetectionConfig::default();
5904 let clusters_with_frame = group_lines_into_clusters(&all, &config);
5905 assert_eq!(
5906 clusters_with_frame.len(),
5907 1,
5908 "page-sized background rect must union every primitive (documents today's defect)"
5909 );
5910
5911 let filtered: Vec<_> = all
5912 .into_iter()
5913 .filter(|p| !p.is_page_frame_rectangle(media_box))
5914 .collect();
5915 assert_eq!(
5916 filtered.len(),
5917 6,
5918 "the filter must remove exactly the page frame and nothing else"
5919 );
5920
5921 let clusters_without_frame = group_lines_into_clusters(&filtered, &config);
5922 assert_eq!(
5923 clusters_without_frame.len(),
5924 3,
5925 "table, footer rule, and figure border must stay separate once the frame is filtered"
5926 );
5927 }
5928
5929 fn gh1656_segmented_rule_table() -> (Vec<TextSpan>, Vec<crate::elements::PathContent>) {
5934 const BAR: f32 = 0.48;
5935 const PITCH: f32 = 11.40;
5936 const Y0: f32 = 472.46;
5937 const ROWS: usize = 12;
5938 let col_xs = [45.12_f32, 151.50, 321.60];
5939 let mut paths = Vec::new();
5940 for r in 0..=ROWS {
5941 let y = Y0 + r as f32 * PITCH;
5942 for pair in col_xs.windows(2) {
5943 paths.push(make_rect_path(pair[0] + BAR, y, pair[1] - pair[0] - BAR, BAR));
5944 }
5945 for &x in &col_xs {
5946 paths.push(make_rect_path(x, y, BAR, BAR));
5947 }
5948 if r < ROWS {
5949 for &x in &col_xs {
5950 paths.push(make_rect_path(x, y + BAR, BAR, PITCH - BAR));
5951 }
5952 }
5953 }
5954 let mut spans = Vec::new();
5955 for r in 0..ROWS {
5956 let y = Y0 + (ROWS - 1 - r) as f32 * PITCH + 2.0;
5957 spans.push(create_test_span(&format!("{}", r + 1), 50.0, y, 10.0, 8.0));
5958 spans.push(create_test_span(&format!("{}", 130 + r * 15), 160.0, y, 20.0, 8.0));
5959 }
5960 (spans, paths)
5961 }
5962
5963 #[test]
5970 fn gh1656_per_cell_segmented_rules_keep_every_row_of_the_table() {
5971 let (spans, paths) = gh1656_segmented_rule_table();
5972 let table_paths: Vec<_> = paths.into_iter().filter(|p| p.is_table_primitive()).collect();
5973 let config = TableDetectionConfig {
5974 horizontal_strategy: TableStrategy::Lines,
5975 vertical_strategy: TableStrategy::Lines,
5976 ..Default::default()
5977 };
5978
5979 let (groups, _, _) = build_grid_from_lines(&table_paths, &config);
5980 assert_eq!(groups.len(), 1, "fixture assumption: one cell group");
5981 assert_eq!(groups[0].0.len(), 24, "fixture assumption: the grid itself is complete");
5982 assert_eq!(groups[0].2.len(), 13, "fixture assumption: 12 row bands");
5983
5984 let tables = detect_tables_with_lines(&spans, &table_paths, &config);
5985 assert_eq!(tables.len(), 1, "one table, not one-row fragments: {tables:?}");
5986 let table = &tables[0];
5987 assert_eq!(table.col_count, 2);
5988 assert_eq!(
5989 table.rows.len(),
5990 12,
5991 "every row band must survive, got {:?}",
5992 table
5993 .rows
5994 .iter()
5995 .map(|r| r.cells.iter().map(|c| c.text.as_str()).collect::<Vec<_>>())
5996 .collect::<Vec<_>>()
5997 );
5998 assert_eq!(
5999 table.rows[0].cells.iter().map(|c| c.text.as_str()).collect::<Vec<_>>(),
6000 ["1", "130"]
6001 );
6002 assert_eq!(
6003 table.rows[11].cells.iter().map(|c| c.text.as_str()).collect::<Vec<_>>(),
6004 ["12", "295"]
6005 );
6006 }
6007
6008 fn create_test_span(text: &str, x: f32, y: f32, width: f32, height: f32) -> TextSpan {
6009 TextSpan {
6010 provenance: None,
6011 text_rise: 0.0,
6012 artifact_type: None,
6013 text: text.to_string(),
6014 bbox: Rect::new(x, y, width, height),
6015 font_name: "TestFont".to_string(),
6016 font_size: 12.0,
6017 font_weight: FontWeight::Normal,
6018 is_italic: false,
6019 is_monospace: false,
6020 color: Color::black(),
6021 mcid: None,
6022 mcid_scope: None,
6023 sequence: 0,
6024 split_boundary_before: false,
6025 offset_semantic: false,
6026 char_spacing: 0.0,
6027 word_spacing: 0.0,
6028 horizontal_scaling: 1.0,
6029 primary_detected: false,
6030 char_widths: vec![],
6031 char_x_offsets: Vec::new(),
6032 heading_level: None,
6033 rotation_degrees: 0.0,
6034 wmode: 0,
6035 rtl_draw_logical: false,
6036 mirrored: false,
6037 page_rotation_applied: 0,
6038 }
6039 }
6040 fn make_h_line(x: f32, y: f32, width: f32) -> crate::elements::PathContent {
6041 crate::elements::PathContent::line(x, y, x + width, y)
6042 }
6043 fn make_v_line(x: f32, y: f32, height: f32) -> crate::elements::PathContent {
6044 crate::elements::PathContent::line(x, y, x, y + height)
6045 }
6046 fn make_line_path(x1: f32, y1: f32, x2: f32, y2: f32) -> crate::elements::PathContent {
6047 crate::elements::PathContent::line(x1, y1, x2, y2)
6048 }
6049 fn make_rect_path(x: f32, y: f32, w: f32, h: f32) -> crate::elements::PathContent {
6050 crate::elements::PathContent::rect(x, y, w, h)
6051 }
6052
6053 #[test]
6062 fn test_issue6_agenda_words_not_split_into_columns() {
6063 let spans = vec![
6065 create_test_span("Time", 72.0, 638.6, 24.4, 12.0),
6066 create_test_span("Activity", 200.0, 638.6, 34.8, 12.0),
6067 create_test_span("Team", 420.0, 638.6, 28.1, 12.0),
6068 create_test_span("06:00 - 07:00", 72.0, 610.6, 61.1, 12.0),
6069 create_test_span("Receiving", 200.0, 610.6, 43.9, 12.0),
6070 create_test_span("Dock", 249.9, 610.6, 22.8, 12.0),
6071 create_test_span("Inspection", 278.7, 610.6, 45.6, 12.0),
6072 create_test_span("Inbound Team", 420.0, 610.6, 65.7, 12.0),
6073 create_test_span("07:00 - 09:00", 72.0, 582.6, 61.1, 12.0),
6074 create_test_span("Bulk", 200.0, 582.6, 19.5, 12.0),
6075 create_test_span("Putaway", 225.4, 582.6, 38.3, 12.0),
6076 create_test_span("Slotting", 282.5, 582.6, 33.4, 12.0),
6077 create_test_span("Warehouse Ops", 420.0, 582.6, 73.5, 12.0),
6078 create_test_span("09:00 - 11:00", 72.0, 554.6, 61.1, 12.0),
6079 create_test_span("Pick", 200.0, 554.6, 18.9, 12.0),
6080 create_test_span("Wave", 230.0, 554.6, 24.0, 12.0),
6081 create_test_span("Processing", 262.0, 554.6, 48.0, 12.0),
6082 create_test_span("Fulfillment", 420.0, 554.6, 55.0, 12.0),
6083 ];
6084 let config = TableDetectionConfig::default();
6085 let tables = detect_tables_from_spans(&spans, &config);
6086 if let Some(t) = tables.first() {
6090 let ncols = t.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
6091 assert!(
6092 ncols <= 4,
6093 "agenda must not fragment Activity words into columns; got {} cols",
6094 ncols
6095 );
6096 }
6097 }
6098
6099 #[test]
6100 fn test_lines_strategy_no_lines_returns_empty() {
6101 let spans = vec![
6102 create_test_span("A", 10.0, 100.0, 10.0, 10.0),
6103 create_test_span("B", 50.0, 100.0, 10.0, 10.0),
6104 create_test_span("C", 10.0, 80.0, 10.0, 10.0),
6105 create_test_span("D", 50.0, 80.0, 10.0, 10.0),
6106 ];
6107 let config = TableDetectionConfig {
6108 horizontal_strategy: TableStrategy::Lines,
6109 vertical_strategy: TableStrategy::Lines,
6110 ..TableDetectionConfig::default()
6111 };
6112 assert!(detect_tables_with_lines(&spans, &[], &config).is_empty());
6113 }
6114
6115 #[test]
6116 fn test_horizontal_lines_only_strategy_no_false_positives() {
6117 let spans = vec![
6118 create_test_span("A", 10.0, 100.0, 10.0, 10.0),
6119 create_test_span("B", 50.0, 100.0, 10.0, 10.0),
6120 create_test_span("C", 10.0, 80.0, 10.0, 10.0),
6121 create_test_span("D", 50.0, 80.0, 10.0, 10.0),
6122 ];
6123 let config = TableDetectionConfig {
6124 horizontal_strategy: TableStrategy::Lines,
6125 vertical_strategy: TableStrategy::Both,
6126 ..TableDetectionConfig::default()
6127 };
6128 assert!(detect_tables_with_lines(&spans, &[], &config).is_empty());
6129 }
6130
6131 #[test]
6146 fn test_text_fallback_detects_lineless_grid() {
6147 let spans = vec![
6148 create_test_span("Pos", 10.0, 200.0, 25.0, 10.0),
6149 create_test_span("Boat", 50.0, 200.0, 25.0, 10.0),
6150 create_test_span("Pts", 90.0, 200.0, 20.0, 10.0),
6151 create_test_span("1", 10.0, 180.0, 25.0, 10.0),
6152 create_test_span("Alpha", 50.0, 180.0, 25.0, 10.0),
6153 create_test_span("14", 90.0, 180.0, 20.0, 10.0),
6154 create_test_span("2", 10.0, 160.0, 25.0, 10.0),
6155 create_test_span("Beta", 50.0, 160.0, 25.0, 10.0),
6156 create_test_span("17", 90.0, 160.0, 20.0, 10.0),
6157 create_test_span("3", 10.0, 140.0, 25.0, 10.0),
6158 create_test_span("Gamma", 50.0, 140.0, 25.0, 10.0),
6159 create_test_span("21", 90.0, 140.0, 20.0, 10.0),
6160 ];
6161
6162 let config = TableDetectionConfig {
6168 text_fallback: true,
6169 ..TableDetectionConfig::default()
6170 };
6171 let tables = detect_tables_with_lines(&spans, &[], &config);
6172 assert_eq!(
6173 tables.len(),
6174 1,
6175 "Text-only fallback in detect_tables_with_lines should detect the grid (got {:?} tables)",
6176 tables.len()
6177 );
6178 let t = &tables[0];
6179 assert_eq!(t.col_count, 3, "Should detect 3 columns");
6180 assert_eq!(t.rows.len(), 4, "Should detect 4 rows");
6181 }
6182
6183 #[test]
6189 fn test_text_fallback_disabled_lines_strategy_returns_empty() {
6190 let spans = vec![
6191 create_test_span("Pos", 10.0, 200.0, 25.0, 10.0),
6192 create_test_span("Boat", 50.0, 200.0, 25.0, 10.0),
6193 create_test_span("Pts", 90.0, 200.0, 20.0, 10.0),
6194 create_test_span("1", 10.0, 180.0, 25.0, 10.0),
6195 create_test_span("Alpha", 50.0, 180.0, 25.0, 10.0),
6196 create_test_span("14", 90.0, 180.0, 20.0, 10.0),
6197 ];
6198 let config = TableDetectionConfig::strict(); let tables = detect_tables_with_lines(&spans, &[], &config);
6202 assert!(
6203 tables.is_empty(),
6204 "Lines-only strategy with no ruling lines should return no tables"
6205 );
6206 }
6207
6208 #[test]
6209 fn test_table_splitting_on_empty_row() {
6210 let spans = vec![
6211 create_test_span("T1-11", 20.0, 115.0, 10.0, 10.0),
6212 create_test_span("T1-12", 40.0, 115.0, 10.0, 10.0),
6213 create_test_span("T1-21", 20.0, 95.0, 10.0, 10.0),
6214 create_test_span("T1-22", 40.0, 95.0, 10.0, 10.0),
6215 create_test_span("T2-11", 20.0, 35.0, 10.0, 10.0),
6216 create_test_span("T2-12", 40.0, 35.0, 10.0, 10.0),
6217 create_test_span("T2-21", 20.0, 15.0, 10.0, 10.0),
6218 create_test_span("T2-22", 40.0, 15.0, 10.0, 10.0),
6219 ];
6220 let lines = vec![
6221 make_h_line(10.0, 130.0, 50.0),
6222 make_h_line(10.0, 110.0, 50.0),
6223 make_h_line(10.0, 90.0, 50.0),
6224 make_v_line(10.0, 90.0, 40.0),
6225 make_v_line(30.0, 90.0, 40.0),
6226 make_v_line(60.0, 90.0, 40.0),
6227 make_h_line(10.0, 50.0, 50.0),
6228 make_h_line(10.0, 30.0, 50.0),
6229 make_h_line(10.0, 10.0, 50.0),
6230 make_v_line(10.0, 10.0, 40.0),
6231 make_v_line(30.0, 10.0, 40.0),
6232 make_v_line(60.0, 10.0, 40.0),
6233 make_v_line(10.0, 50.0, 40.0),
6234 ];
6235 let config = TableDetectionConfig {
6236 horizontal_strategy: TableStrategy::Both,
6237 vertical_strategy: TableStrategy::Both,
6238 ..TableDetectionConfig::default()
6239 };
6240 assert_eq!(detect_tables_with_lines(&spans, &lines, &config).len(), 2);
6241 }
6242
6243 #[test]
6244 fn test_detect_columns_invoice_4_columns() {
6245 let spans = vec![
6246 create_test_span("01/01", 50.0, 100.0, 50.0, 10.0),
6247 create_test_span("Widget", 130.0, 100.0, 220.0, 10.0),
6248 create_test_span("$100", 500.0, 100.0, 50.0, 10.0),
6249 create_test_span("$0", 600.0, 100.0, 50.0, 10.0),
6250 create_test_span("02/15", 50.0, 80.0, 50.0, 10.0),
6251 create_test_span("Service fee", 130.0, 80.0, 220.0, 10.0),
6252 create_test_span("$250", 500.0, 80.0, 50.0, 10.0),
6253 create_test_span("$50", 600.0, 80.0, 50.0, 10.0),
6254 create_test_span("03/20", 50.0, 60.0, 50.0, 10.0),
6255 create_test_span("Consulting", 130.0, 60.0, 220.0, 10.0),
6256 create_test_span("$500", 500.0, 60.0, 50.0, 10.0),
6257 create_test_span("$100", 600.0, 60.0, 50.0, 10.0),
6258 ];
6259 let config = TableDetectionConfig::default();
6260 let columns = detect_columns(&spans, config.column_tolerance, config.column_merge_threshold);
6261 assert_eq!(
6262 columns.len(),
6263 4,
6264 "Invoice with 4 distinct column groups should produce exactly 4 columns, got {}",
6265 columns.len()
6266 );
6267 }
6268
6269 #[test]
6270 fn test_detect_columns_merges_nearby_clusters() {
6271 let spans = vec![
6272 create_test_span("A", 50.0, 100.0, 30.0, 10.0),
6273 create_test_span("B", 130.0, 100.0, 30.0, 10.0),
6274 create_test_span("C", 50.0, 80.0, 30.0, 10.0),
6275 create_test_span("D", 135.0, 80.0, 30.0, 10.0),
6276 create_test_span("E", 50.0, 60.0, 30.0, 10.0),
6277 create_test_span("F", 140.0, 60.0, 30.0, 10.0),
6278 ];
6279 let config = TableDetectionConfig::default();
6280 let columns = detect_columns(&spans, config.column_tolerance, config.column_merge_threshold);
6281 assert_eq!(
6282 columns.len(),
6283 2,
6284 "Spans at x=130/135/140 should merge into 1 column, plus x=50 = 2 total, got {}",
6285 columns.len()
6286 );
6287 }
6288
6289 #[test]
6290 fn test_detect_columns_dense_pitch_stays_distinct() {
6291 let mut spans = Vec::new();
6300 for row in 0..3 {
6301 let y = 100.0 - row as f32 * 20.0;
6302 for col in 0..6 {
6303 let x = 20.0 + col as f32 * 20.0;
6304 spans.push(create_test_span("9", x, y, 8.0, 10.0));
6305 }
6306 }
6307 let config = TableDetectionConfig::default();
6308 let columns = detect_columns(&spans, config.column_tolerance, config.column_merge_threshold);
6309 assert_eq!(
6310 columns.len(),
6311 6,
6312 "a 20pt-pitch dense table must keep all 6 columns distinct \
6313 despite the 25pt fixed merge_threshold, got {}",
6314 columns.len()
6315 );
6316 }
6317
6318 #[test]
6319 fn test_detect_columns_order_independent() {
6320 let spans_ordered = vec![
6321 create_test_span("A", 50.0, 100.0, 30.0, 10.0),
6322 create_test_span("B", 200.0, 100.0, 30.0, 10.0),
6323 create_test_span("C", 400.0, 100.0, 30.0, 10.0),
6324 create_test_span("D", 50.0, 80.0, 30.0, 10.0),
6325 create_test_span("E", 200.0, 80.0, 30.0, 10.0),
6326 create_test_span("F", 400.0, 80.0, 30.0, 10.0),
6327 ];
6328 let spans_reversed = vec![
6329 create_test_span("F", 400.0, 80.0, 30.0, 10.0),
6330 create_test_span("E", 200.0, 80.0, 30.0, 10.0),
6331 create_test_span("D", 50.0, 80.0, 30.0, 10.0),
6332 create_test_span("C", 400.0, 100.0, 30.0, 10.0),
6333 create_test_span("B", 200.0, 100.0, 30.0, 10.0),
6334 create_test_span("A", 50.0, 100.0, 30.0, 10.0),
6335 ];
6336 let config = TableDetectionConfig::default();
6337 let cols_ordered = detect_columns(&spans_ordered, config.column_tolerance, config.column_merge_threshold);
6338 let cols_reversed = detect_columns(&spans_reversed, config.column_tolerance, config.column_merge_threshold);
6339 assert_eq!(
6340 cols_ordered.len(),
6341 cols_reversed.len(),
6342 "Column count should be independent of span order"
6343 );
6344 let centers_ordered: Vec<f32> = cols_ordered.iter().map(|c| (c.x_center * 10.0).round()).collect();
6345 let centers_reversed: Vec<f32> = cols_reversed.iter().map(|c| (c.x_center * 10.0).round()).collect();
6346 assert_eq!(
6347 centers_ordered, centers_reversed,
6348 "Column centers should match regardless of input order"
6349 );
6350 }
6351
6352 #[test]
6353 fn test_detect_header_row_returns_none_when_no_heuristic_matches() {
6354 let spans = vec![
6356 create_test_span("A", 10.0, 100.0, 30.0, 10.0),
6357 create_test_span("B", 50.0, 100.0, 30.0, 10.0),
6358 create_test_span("C", 10.0, 80.0, 30.0, 10.0),
6359 create_test_span("D", 50.0, 80.0, 30.0, 10.0),
6360 ];
6361 let columns = detect_columns(&spans, 15.0, 25.0);
6362 let rows = detect_rows(&spans, 2.8);
6363 let grid = assign_spans_to_cells(&spans, &columns, &rows);
6364 let header = detect_header_row(&grid, &spans);
6365 assert_eq!(header, None, "Should return None when no heuristic matches");
6366 }
6367
6368 #[test]
6369 fn test_hierarchical_header_with_visual_heuristic() {
6370 let spans = vec![
6371 create_test_span("H1", 10.0, 115.0, 35.0, 10.0),
6372 create_test_span("H2", 55.0, 115.0, 35.0, 10.0),
6373 create_test_span("Col 1", 10.0, 95.0, 35.0, 10.0),
6374 create_test_span("Col 2", 55.0, 95.0, 35.0, 10.0),
6375 create_test_span("Data 1", 10.0, 75.0, 35.0, 10.0),
6376 create_test_span("Data 2", 55.0, 75.0, 35.0, 10.0),
6377 ];
6378 let lines = vec![
6379 make_line_path(10.0, 130.0, 90.0, 130.0),
6380 make_line_path(10.0, 110.0, 90.0, 110.0),
6381 make_line_path(10.0, 90.0, 90.0, 90.0),
6382 make_v_line(10.0, 70.0, 60.0),
6383 make_v_line(50.0, 70.0, 20.0),
6384 make_v_line(90.0, 70.0, 60.0),
6385 ];
6386 let config = TableDetectionConfig::default();
6387 let tables = detect_tables_with_lines(&spans, &lines, &config);
6388 assert_eq!(tables.len(), 1);
6389 assert!(tables[0].rows[0].is_header);
6390 assert!(tables[0].rows[1].is_header);
6391 }
6392
6393 #[test]
6394 fn test_intersection_basic_2x2_table() {
6395 let lines = vec![
6396 make_h_line(50.0, 100.0, 350.0),
6397 make_h_line(50.0, 200.0, 350.0),
6398 make_h_line(50.0, 300.0, 350.0),
6399 make_v_line(50.0, 100.0, 200.0),
6400 make_v_line(200.0, 100.0, 200.0),
6401 make_v_line(400.0, 100.0, 200.0),
6402 ];
6403 let spans = vec![
6404 create_test_span("A1", 120.0, 145.0, 20.0, 10.0),
6405 create_test_span("B1", 295.0, 145.0, 20.0, 10.0),
6406 create_test_span("A2", 120.0, 245.0, 20.0, 10.0),
6407 create_test_span("B2", 295.0, 245.0, 20.0, 10.0),
6408 ];
6409 let config = TableDetectionConfig {
6410 horizontal_strategy: TableStrategy::Lines,
6411 vertical_strategy: TableStrategy::Lines,
6412 min_table_cells: 4,
6413 min_table_columns: 2,
6414 ..TableDetectionConfig::default()
6415 };
6416 let tables = detect_tables_with_lines(&spans, &lines, &config);
6417 assert_eq!(tables.len(), 1, "Should detect exactly 1 table");
6418 let table = &tables[0];
6419 assert_eq!(table.rows.len(), 2, "Should have 2 rows");
6420 assert_eq!(table.col_count, 2, "Should have 2 columns");
6421
6422 let r0_texts: Vec<&str> = table.rows[0].cells.iter().map(|c| c.text.as_str()).collect();
6426 let r1_texts: Vec<&str> = table.rows[1].cells.iter().map(|c| c.text.as_str()).collect();
6427 assert_eq!(r0_texts, vec!["A2", "B2"], "Top row (higher y) should be A2, B2");
6428 assert_eq!(r1_texts, vec!["A1", "B1"], "Bottom row (lower y) should be A1, B1");
6429 }
6430
6431 #[test]
6432 fn test_intersection_snap_and_merge_edges() {
6433 let mut edges = vec![
6435 Edge {
6436 coord: 100.0,
6437 start: 0.0,
6438 end: 50.0,
6439 },
6440 Edge {
6441 coord: 101.5,
6442 start: 0.0,
6443 end: 50.0,
6444 },
6445 ];
6446 snap_and_merge(&mut edges);
6447 assert_eq!(edges.len(), 1, "Snapped edges should merge into 1");
6448 assert!((edges[0].coord - 100.0).abs() < 0.01);
6449 }
6450
6451 #[test]
6452 fn test_intersection_join_collinear_segments() {
6453 let mut edges = vec![
6455 Edge {
6456 coord: 100.0,
6457 start: 0.0,
6458 end: 50.0,
6459 },
6460 Edge {
6461 coord: 100.0,
6462 start: 52.0,
6463 end: 100.0,
6464 },
6465 ];
6466 snap_and_merge(&mut edges);
6467 assert_eq!(edges.len(), 1, "Collinear segments within 3pt should join");
6468 assert!((edges[0].start - 0.0).abs() < 0.01);
6469 assert!((edges[0].end - 100.0).abs() < 0.01);
6470 }
6471
6472 #[test]
6473 fn test_intersection_discard_short_edges() {
6474 let mut edges = vec![
6475 Edge {
6476 coord: 100.0,
6477 start: 0.0,
6478 end: 4.0,
6479 }, Edge {
6481 coord: 200.0,
6482 start: 0.0,
6483 end: 50.0,
6484 },
6485 ];
6486 snap_and_merge(&mut edges);
6487 assert_eq!(edges.len(), 1, "Short edge should be discarded");
6488 assert!((edges[0].coord - 200.0).abs() < 0.01);
6489 }
6490
6491 #[test]
6492 fn test_intersection_find_intersections_basic() {
6493 let h = vec![
6494 Edge {
6495 coord: 100.0,
6496 start: 0.0,
6497 end: 200.0,
6498 },
6499 Edge {
6500 coord: 200.0,
6501 start: 0.0,
6502 end: 200.0,
6503 },
6504 ];
6505 let v = vec![
6506 Edge {
6507 coord: 50.0,
6508 start: 50.0,
6509 end: 250.0,
6510 },
6511 Edge {
6512 coord: 150.0,
6513 start: 50.0,
6514 end: 250.0,
6515 },
6516 ];
6517 let pts = find_intersections(&h, &v);
6518 assert_eq!(pts.len(), 4, "2 H x 2 V = 4 intersections");
6519 }
6520
6521 #[test]
6522 fn test_intersection_no_crossing_means_no_intersection() {
6523 let h = vec![Edge {
6524 coord: 100.0,
6525 start: 0.0,
6526 end: 50.0,
6527 }];
6528 let v = vec![Edge {
6529 coord: 100.0,
6530 start: 0.0,
6531 end: 200.0,
6532 }];
6533 let pts = find_intersections(&h, &v);
6534 assert!(pts.is_empty(), "Non-crossing edges should produce no intersection");
6535 }
6536
6537 #[test]
6538 fn test_intersection_build_cells() {
6539 let pts = vec![
6540 Intersection { x: 0.0, y: 0.0 },
6541 Intersection { x: 100.0, y: 0.0 },
6542 Intersection { x: 0.0, y: 100.0 },
6543 Intersection { x: 100.0, y: 100.0 },
6544 ];
6545 let v_edges = [
6548 Edge {
6549 coord: 0.0,
6550 start: 0.0,
6551 end: 100.0,
6552 },
6553 Edge {
6554 coord: 100.0,
6555 start: 0.0,
6556 end: 100.0,
6557 },
6558 ];
6559 let cells = build_cells_from_intersections(&pts, &[], &v_edges);
6560 assert_eq!(cells.len(), 1, "4 corners should produce 1 cell");
6561 }
6562
6563 #[test]
6564 fn test_intersection_group_adjacent_cells() {
6565 let cells = vec![
6566 IntersectionCell {
6567 x1: 0.0,
6568 y1: 0.0,
6569 x2: 100.0,
6570 y2: 100.0,
6571 },
6572 IntersectionCell {
6573 x1: 100.0,
6574 y1: 0.0,
6575 x2: 200.0,
6576 y2: 100.0,
6577 },
6578 ];
6579 let groups = group_cells_into_tables(&cells);
6580 assert_eq!(groups.len(), 1, "Adjacent cells should be in 1 group");
6581 }
6582
6583 #[test]
6584 fn test_intersection_separate_tables() {
6585 let cells = vec![
6586 IntersectionCell {
6587 x1: 0.0,
6588 y1: 0.0,
6589 x2: 100.0,
6590 y2: 100.0,
6591 },
6592 IntersectionCell {
6593 x1: 500.0,
6594 y1: 500.0,
6595 x2: 600.0,
6596 y2: 600.0,
6597 },
6598 ];
6599 let groups = group_cells_into_tables(&cells);
6600 assert_eq!(groups.len(), 2, "Distant cells should be in separate groups");
6601 }
6602
6603 #[test]
6613 fn gh1601_graphics_free_gap_does_not_bridge_two_tables() {
6614 let lines = vec![
6615 make_h_line(10.0, 10.0, 50.0),
6617 make_h_line(10.0, 30.0, 50.0),
6618 make_h_line(10.0, 50.0, 50.0),
6619 make_v_line(10.0, 10.0, 40.0),
6620 make_v_line(30.0, 10.0, 40.0),
6621 make_v_line(60.0, 10.0, 40.0),
6622 make_h_line(10.0, 90.0, 50.0),
6625 make_h_line(10.0, 110.0, 50.0),
6626 make_h_line(10.0, 130.0, 50.0),
6627 make_v_line(10.0, 90.0, 40.0),
6628 make_v_line(30.0, 90.0, 40.0),
6629 make_v_line(60.0, 90.0, 40.0),
6630 ];
6631 let spans = vec![
6632 create_test_span("B21", 12.0, 12.0, 8.0, 10.0),
6633 create_test_span("B22", 35.0, 12.0, 8.0, 10.0),
6634 create_test_span("B11", 12.0, 32.0, 8.0, 10.0),
6635 create_test_span("B12", 35.0, 32.0, 8.0, 10.0),
6636 create_test_span("HEADINGTAG", 12.0, 60.0, 20.0, 10.0),
6638 create_test_span("A21", 12.0, 92.0, 8.0, 10.0),
6639 create_test_span("A22", 35.0, 92.0, 8.0, 10.0),
6640 create_test_span("A11", 12.0, 112.0, 8.0, 10.0),
6641 create_test_span("A12", 35.0, 112.0, 8.0, 10.0),
6642 ];
6643 let config = TableDetectionConfig {
6644 horizontal_strategy: TableStrategy::Lines,
6645 vertical_strategy: TableStrategy::Lines,
6646 min_table_cells: 2,
6647 min_table_columns: 2,
6648 ..TableDetectionConfig::default()
6649 };
6650
6651 let tables = detect_tables_from_intersections(&spans, &lines, &config);
6652
6653 let heading_rows: Vec<&TableRow> = tables
6654 .iter()
6655 .flat_map(|t| t.rows.iter())
6656 .filter(|r| r.cells.iter().any(|c| c.text.contains("HEADINGTAG")))
6657 .collect();
6658 assert!(
6659 heading_rows.is_empty(),
6660 "the heading printed in the graphics-free gap must not become a row of either \
6661 table (it must stay out of the element stream as a table row entirely), got: \
6662 {heading_rows:?}"
6663 );
6664 assert_eq!(
6665 tables.len(),
6666 2,
6667 "two disjoint same-column grids separated by a graphics-free gap must remain \
6668 two tables, not be bridged into one, got: {tables:?}"
6669 );
6670 }
6671
6672 #[test]
6678 fn gh1601_heading_row_inside_a_ruled_band_stays_in_one_table() {
6679 let lines = vec![
6680 make_h_line(10.0, 10.0, 50.0),
6681 make_h_line(10.0, 30.0, 50.0),
6682 make_h_line(10.0, 50.0, 50.0),
6683 make_h_line(10.0, 70.0, 50.0),
6684 make_v_line(10.0, 10.0, 60.0),
6687 make_v_line(30.0, 10.0, 60.0),
6688 make_v_line(60.0, 10.0, 60.0),
6689 ];
6690 let spans = vec![
6691 create_test_span("R11", 12.0, 12.0, 8.0, 10.0),
6692 create_test_span("R12", 35.0, 12.0, 8.0, 10.0),
6693 create_test_span("HEADINGTAG", 12.0, 32.0, 8.0, 10.0),
6696 create_test_span("R21", 12.0, 52.0, 8.0, 10.0),
6697 create_test_span("R22", 35.0, 52.0, 8.0, 10.0),
6698 ];
6699 let config = TableDetectionConfig {
6700 horizontal_strategy: TableStrategy::Lines,
6701 vertical_strategy: TableStrategy::Lines,
6702 min_table_cells: 2,
6703 min_table_columns: 2,
6704 ..TableDetectionConfig::default()
6705 };
6706
6707 let tables = detect_tables_from_intersections(&spans, &lines, &config);
6708
6709 assert_eq!(
6710 tables.len(),
6711 1,
6712 "a heading inside a genuinely ruled band must not split the table apart, got: {tables:?}"
6713 );
6714 assert_eq!(
6715 tables[0].rows.len(),
6716 3,
6717 "all three ruled rows, including the heading's, must stay in the one table, got: {:?}",
6718 tables[0]
6719 );
6720 let heading_present = tables[0]
6721 .rows
6722 .iter()
6723 .any(|r| r.cells.iter().any(|c| c.text.contains("HEADINGTAG")));
6724 assert!(
6725 heading_present,
6726 "the heading row must still be present in the one table"
6727 );
6728 }
6729
6730 #[test]
6740 fn gh1601_cell_rule_inset_a_few_points_still_forms_the_cell() {
6741 let pts = vec![
6742 Intersection { x: 0.0, y: 0.0 },
6743 Intersection { x: 100.0, y: 0.0 },
6744 Intersection { x: 0.0, y: 20.0 },
6745 Intersection { x: 100.0, y: 20.0 },
6746 ];
6747 let v_edges = [
6751 Edge {
6752 coord: 0.0,
6753 start: 3.0,
6754 end: 17.0,
6755 },
6756 Edge {
6757 coord: 100.0,
6758 start: 3.0,
6759 end: 17.0,
6760 },
6761 ];
6762 let cells = build_cells_from_intersections(&pts, &[], &v_edges);
6763 assert_eq!(
6764 cells.len(),
6765 1,
6766 "a V edge inset a few points from its own row's true boundary must still form the cell"
6767 );
6768 }
6769
6770 #[test]
6777 fn gh1601_cell_rule_span_tol_does_not_reopen_the_gap_bridge() {
6778 let pts = vec![
6779 Intersection { x: 0.0, y: 0.0 },
6780 Intersection { x: 100.0, y: 0.0 },
6781 Intersection { x: 0.0, y: 40.0 },
6782 Intersection { x: 100.0, y: 40.0 },
6783 ];
6784 let v_edges = [
6788 Edge {
6789 coord: 0.0,
6790 start: 0.0,
6791 end: 10.0,
6792 },
6793 Edge {
6794 coord: 100.0,
6795 start: 0.0,
6796 end: 10.0,
6797 },
6798 Edge {
6799 coord: 0.0,
6800 start: 30.0,
6801 end: 40.0,
6802 },
6803 Edge {
6804 coord: 100.0,
6805 start: 30.0,
6806 end: 40.0,
6807 },
6808 ];
6809 let cells = build_cells_from_intersections(&pts, &[], &v_edges);
6810 assert!(
6811 cells.is_empty(),
6812 "a 40pt graphics-free gap must still be rejected under the looser CELL_RULE_SPAN_TOL, got: {cells:?}"
6813 );
6814 }
6815
6816 #[test]
6821 fn zebra_unshaded_rows_keep_their_outer_cells() {
6822 let mut lines: Vec<crate::elements::PathContent> = [0.0, 40.0, 80.0]
6823 .into_iter()
6824 .map(|y| make_rect_path(10.0, y, 150.0, 20.0))
6825 .collect();
6826 lines.push(make_v_line(60.0, 0.0, 100.0));
6827 lines.push(make_v_line(110.0, 0.0, 100.0));
6828 let mut spans = Vec::new();
6829 for row in 0..5 {
6830 let y = 85.0 - row as f32 * 20.0;
6831 spans.push(create_test_span(&format!("L{row}"), 12.0, y, 8.0, 10.0));
6832 spans.push(create_test_span(&format!("M{row}"), 70.0, y, 8.0, 10.0));
6833 spans.push(create_test_span(&format!("R{row}"), 120.0, y, 8.0, 10.0));
6834 }
6835 let config = TableDetectionConfig {
6836 horizontal_strategy: TableStrategy::Lines,
6837 vertical_strategy: TableStrategy::Lines,
6838 min_table_cells: 2,
6839 min_table_columns: 2,
6840 ..TableDetectionConfig::default()
6841 };
6842
6843 let tables = detect_tables_from_intersections(&spans, &lines, &config);
6844
6845 assert_eq!(tables.len(), 1, "got: {tables:?}");
6846 let texts: Vec<Vec<&str>> = tables[0]
6847 .rows
6848 .iter()
6849 .map(|r| r.cells.iter().map(|c| c.text.trim()).collect())
6850 .collect();
6851 for row in 0..5 {
6852 let want = [format!("L{row}"), format!("M{row}"), format!("R{row}")];
6853 assert!(
6854 texts.iter().any(|cells| cells == &want),
6855 "row {row} must keep all three cells, got: {texts:?}"
6856 );
6857 }
6858 }
6859
6860 #[test]
6861 fn cell_with_undrawn_sides_forms_when_a_rule_runs_through_its_band() {
6862 let edge = |coord, start, end| Edge { coord, start, end };
6863 let pts = vec![
6864 Intersection { x: 0.0, y: 0.0 },
6865 Intersection { x: 50.0, y: 0.0 },
6866 Intersection { x: 100.0, y: 0.0 },
6867 Intersection { x: 0.0, y: 20.0 },
6868 Intersection { x: 50.0, y: 20.0 },
6869 Intersection { x: 100.0, y: 20.0 },
6870 ];
6871 let h_edges = [edge(0.0, 0.0, 100.0), edge(20.0, 0.0, 100.0)];
6872 let v_edges = [
6874 edge(0.0, -20.0, 0.0),
6875 edge(0.0, 20.0, 40.0),
6876 edge(100.0, -20.0, 0.0),
6877 edge(100.0, 20.0, 40.0),
6878 edge(50.0, -20.0, 40.0),
6879 ];
6880 let cells = build_cells_from_intersections(&pts, &h_edges, &v_edges);
6881 assert_eq!(cells.len(), 2, "got: {cells:?}");
6882 }
6883
6884 #[test]
6887 fn cell_with_an_undrawn_side_forms_when_rules_close_both_ends_of_its_band() {
6888 let edge = |coord, start, end| Edge { coord, start, end };
6889 let pts = vec![
6890 Intersection { x: 0.0, y: 0.0 },
6891 Intersection { x: 50.0, y: 0.0 },
6892 Intersection { x: 100.0, y: 0.0 },
6893 Intersection { x: 0.0, y: 20.0 },
6894 Intersection { x: 50.0, y: 20.0 },
6895 Intersection { x: 100.0, y: 20.0 },
6896 ];
6897 let h_edges = [edge(0.0, 0.0, 100.0), edge(20.0, 0.0, 100.0)];
6898 let v_edges = [
6899 edge(0.0, -20.0, 40.0),
6900 edge(100.0, -20.0, 40.0),
6901 edge(50.0, -20.0, 0.0),
6902 edge(50.0, 20.0, 40.0),
6903 ];
6904 let cells = build_cells_from_intersections(&pts, &h_edges, &v_edges);
6905 assert_eq!(cells.len(), 2, "got: {cells:?}");
6906 }
6907
6908 #[test]
6909 fn cell_with_undrawn_sides_ignores_a_rule_at_or_past_the_ends_of_its_h_rules() {
6910 let edge = |coord, start, end| Edge { coord, start, end };
6911 let pts = vec![
6912 Intersection { x: 0.0, y: 0.0 },
6913 Intersection { x: 100.0, y: 0.0 },
6914 Intersection { x: 0.0, y: 40.0 },
6915 Intersection { x: 100.0, y: 40.0 },
6916 ];
6917 let h_edges = [edge(0.0, 0.0, 100.0), edge(40.0, 0.0, 100.0)];
6918 let v_edges = [
6921 edge(0.0, -20.0, 0.0),
6922 edge(0.0, 40.0, 60.0),
6923 edge(100.0, -20.0, 0.0),
6924 edge(100.0, 40.0, 60.0),
6925 edge(-2.5, -100.0, 200.0),
6926 edge(150.0, -100.0, 200.0),
6927 ];
6928 let cells = build_cells_from_intersections(&pts, &h_edges, &v_edges);
6929 assert!(cells.is_empty(), "got: {cells:?}");
6930 }
6931
6932 #[test]
6933 fn test_intersection_rect_decomposition() {
6934 let lines = vec![crate::elements::PathContent::rect(10.0, 10.0, 100.0, 50.0)];
6935 let (h, v) = extract_edges(&lines);
6936 assert_eq!(h.len(), 2, "Rectangle should produce 2 horizontal edges");
6937 assert_eq!(v.len(), 2, "Rectangle should produce 2 vertical edges");
6938 }
6939
6940 #[test]
6941 fn test_intersection_3x3_grid_produces_4_cells() {
6942 let pts = vec![
6943 Intersection { x: 0.0, y: 0.0 },
6944 Intersection { x: 50.0, y: 0.0 },
6945 Intersection { x: 100.0, y: 0.0 },
6946 Intersection { x: 0.0, y: 50.0 },
6947 Intersection { x: 50.0, y: 50.0 },
6948 Intersection { x: 100.0, y: 50.0 },
6949 Intersection { x: 0.0, y: 100.0 },
6950 Intersection { x: 50.0, y: 100.0 },
6951 Intersection { x: 100.0, y: 100.0 },
6952 ];
6953 let v_edges = [
6955 Edge {
6956 coord: 0.0,
6957 start: 0.0,
6958 end: 100.0,
6959 },
6960 Edge {
6961 coord: 50.0,
6962 start: 0.0,
6963 end: 100.0,
6964 },
6965 Edge {
6966 coord: 100.0,
6967 start: 0.0,
6968 end: 100.0,
6969 },
6970 ];
6971 let cells = build_cells_from_intersections(&pts, &[], &v_edges);
6972 assert_eq!(cells.len(), 4, "3x3 grid should produce 4 cells");
6973 let groups = group_cells_into_tables(&cells);
6974 assert_eq!(groups.len(), 1, "All 4 cells should form 1 table");
6975 }
6976
6977 #[test]
6978 fn test_dotted_line_reconstitution() {
6979 let mut edges: Vec<Edge> = (0..10)
6983 .map(|i| Edge {
6984 coord: 300.0,
6985 start: 50.0 + i as f32 * 30.0,
6986 end: 53.0 + i as f32 * 30.0,
6987 })
6988 .collect();
6989
6990 snap_and_merge(&mut edges);
6991
6992 assert_eq!(edges.len(), 1, "Dotted segments should reconstitute into 1 edge");
6993 assert!(
6994 (edges[0].coord - 300.0).abs() < 0.01,
6995 "Reconstituted edge should be at y=300"
6996 );
6997 assert!(
6998 (edges[0].start - 50.0).abs() < 0.01,
6999 "Reconstituted edge should start at x=50"
7000 );
7001 assert!(
7002 (edges[0].end - 323.0).abs() < 0.01,
7003 "Reconstituted edge should end at x=323"
7004 );
7005 }
7006
7007 #[test]
7008 fn test_dotted_line_too_few_segments_discarded() {
7009 let mut edges = vec![
7012 Edge {
7013 coord: 200.0,
7014 start: 10.0,
7015 end: 13.0,
7016 },
7017 Edge {
7018 coord: 200.0,
7019 start: 20.0,
7020 end: 23.0,
7021 },
7022 ];
7023 snap_and_merge(&mut edges);
7024 assert!(
7025 edges.is_empty(),
7026 "Two short segments should not be reconstituted or kept"
7027 );
7028 }
7029
7030 #[test]
7031 fn test_dotted_line_narrow_span_discarded() {
7032 let mut edges: Vec<Edge> = (0..5)
7035 .map(|i| Edge {
7036 coord: 400.0,
7037 start: 10.0 + i as f32 * 8.0,
7038 end: 13.0 + i as f32 * 8.0,
7039 })
7040 .collect();
7041 snap_and_merge(&mut edges);
7042 assert!(
7043 edges.is_empty(),
7044 "Short segments with narrow total span should be discarded"
7045 );
7046 }
7047
7048 #[test]
7049 fn test_dotted_line_mixed_with_long_edges() {
7050 let mut edges = vec![Edge {
7051 coord: 100.0,
7052 start: 0.0,
7053 end: 200.0,
7054 }];
7055 for i in 0..10 {
7056 edges.push(Edge {
7057 coord: 300.0,
7058 start: 50.0 + i as f32 * 30.0,
7059 end: 53.0 + i as f32 * 30.0,
7060 });
7061 }
7062 snap_and_merge(&mut edges);
7063 assert_eq!(edges.len(), 2, "Long edge + reconstituted dotted line = 2 edges");
7064 }
7065
7066 #[test]
7067 fn test_join_chain_of_short_segments() {
7068 let mut edges: Vec<Edge> = (0..10)
7069 .map(|i| Edge {
7070 coord: 100.0,
7071 start: i as f32 * 25.0,
7072 end: (i + 1) as f32 * 25.0,
7073 })
7074 .collect();
7075
7076 snap_and_merge(&mut edges);
7077
7078 assert_eq!(edges.len(), 1, "Chain of 10 touching H segments should join into 1");
7079 assert!((edges[0].start - 0.0).abs() < 0.01, "Joined edge should start at 0");
7080 assert!((edges[0].end - 250.0).abs() < 0.01, "Joined edge should end at 250");
7081 }
7082
7083 #[test]
7084 fn test_join_tiny_vertical_segments() {
7085 let mut edges: Vec<Edge> = (0..10)
7086 .map(|i| Edge {
7087 coord: 50.0,
7088 start: i as f32 * 6.0,
7089 end: (i + 1) as f32 * 6.0,
7090 })
7091 .collect();
7092
7093 snap_and_merge(&mut edges);
7094
7095 assert_eq!(edges.len(), 1, "Chain of 10 touching V segments should join into 1");
7096 assert!((edges[0].start - 0.0).abs() < 0.01, "Joined edge should start at 0");
7097 assert!((edges[0].end - 60.0).abs() < 0.01, "Joined edge should end at 60");
7098 }
7099
7100 #[test]
7101 fn test_join_segments_with_slightly_different_coords() {
7102 let mut edges = vec![
7105 Edge {
7106 coord: 87.4,
7107 start: 36.0,
7108 end: 117.0,
7109 },
7110 Edge {
7111 coord: 87.41,
7112 start: 117.0,
7113 end: 143.0,
7114 },
7115 Edge {
7116 coord: 87.39,
7117 start: 143.0,
7118 end: 170.0,
7119 },
7120 ];
7121
7122 snap_and_merge(&mut edges);
7123
7124 assert_eq!(edges.len(), 1, "Segments at near-identical coords should snap and join");
7125 assert!((edges[0].start - 36.0).abs() < 0.01, "Joined edge should start at 36");
7126 assert!((edges[0].end - 170.0).abs() < 0.01, "Joined edge should end at 170");
7127 }
7128
7129 #[test]
7130 fn rule_families_tied_on_first_coord_emit_in_input_order() {
7131 let edges: Vec<Edge> = (0..8)
7138 .flat_map(|k| {
7139 let x0 = 200.0 * k as f32;
7140 [
7141 Edge {
7142 coord: 700.0,
7143 start: x0,
7144 end: x0 + 150.0,
7145 },
7146 Edge {
7147 coord: 600.0,
7148 start: x0,
7149 end: x0 + 150.0,
7150 },
7151 ]
7152 })
7153 .collect();
7154 let wide: Vec<&Edge> = edges.iter().collect();
7155
7156 let families = x_coherent_rule_families(&wide);
7157
7158 assert_eq!(families.len(), 8, "Disjoint x-ranges should stay separate families");
7159 for (k, family) in families.iter().enumerate() {
7160 assert_eq!(family.len(), 2);
7161 assert!(
7162 (family[0].coord - 700.0).abs() < 0.01,
7163 "First rule should be the top rule"
7164 );
7165 assert!(
7166 (family[0].start - 200.0 * k as f32).abs() < 0.01,
7167 "Tied families should emit in input order, family {k} out of place"
7168 );
7169 }
7170 }
7171
7172 #[test]
7173 fn rule_families_with_distinct_first_coords_order_by_coord() {
7174 let edges = [
7178 Edge {
7179 coord: 500.0,
7180 start: 0.0,
7181 end: 150.0,
7182 },
7183 Edge {
7184 coord: 400.0,
7185 start: 0.0,
7186 end: 150.0,
7187 },
7188 Edge {
7189 coord: 300.0,
7190 start: 300.0,
7191 end: 450.0,
7192 },
7193 Edge {
7194 coord: 200.0,
7195 start: 300.0,
7196 end: 450.0,
7197 },
7198 ];
7199 let wide: Vec<&Edge> = edges.iter().collect();
7200
7201 let families = x_coherent_rule_families(&wide);
7202
7203 assert_eq!(families.len(), 2);
7204 assert!((families[0][0].coord - 300.0).abs() < 0.01);
7205 assert!((families[1][0].coord - 500.0).abs() < 0.01);
7206 }
7207
7208 #[test]
7209 fn test_hybrid_line_cols_text_rows() {
7210 let lines = vec![
7214 make_h_line(50.0, 100.0, 350.0),
7215 make_h_line(50.0, 300.0, 350.0),
7216 make_v_line(50.0, 100.0, 200.0),
7217 make_v_line(200.0, 100.0, 200.0),
7218 make_v_line(400.0, 100.0, 200.0),
7219 ];
7220 let spans = vec![
7221 create_test_span("A", 60.0, 265.0, 20.0, 10.0),
7222 create_test_span("B", 210.0, 265.0, 20.0, 10.0),
7223 create_test_span("C", 60.0, 205.0, 20.0, 10.0),
7224 create_test_span("D", 210.0, 205.0, 20.0, 10.0),
7225 create_test_span("E", 60.0, 145.0, 20.0, 10.0),
7226 create_test_span("F", 210.0, 145.0, 20.0, 10.0),
7227 ];
7228 let config = TableDetectionConfig {
7229 horizontal_strategy: TableStrategy::Lines,
7230 vertical_strategy: TableStrategy::Lines,
7231 min_table_cells: 4,
7232 min_table_columns: 2,
7233 ..TableDetectionConfig::default()
7234 };
7235 let tables = detect_tables_with_lines(&spans, &lines, &config);
7236 assert_eq!(tables.len(), 1, "Should detect exactly 1 table");
7237 let table = &tables[0];
7238 assert_eq!(
7239 table.rows.len(),
7240 3,
7241 "Should have 3 rows (split from text Y positions), got {}",
7242 table.rows.len()
7243 );
7244 assert_eq!(table.col_count, 2, "Should have 2 columns");
7245
7246 let r0: Vec<&str> = table.rows[0].cells.iter().map(|c| c.text.as_str()).collect();
7248 let r1: Vec<&str> = table.rows[1].cells.iter().map(|c| c.text.as_str()).collect();
7249 let r2: Vec<&str> = table.rows[2].cells.iter().map(|c| c.text.as_str()).collect();
7250 assert_eq!(r0, vec!["A", "B"], "Top row should be A, B");
7251 assert_eq!(r1, vec!["C", "D"], "Middle row should be C, D");
7252 assert_eq!(r2, vec!["E", "F"], "Bottom row should be E, F");
7253 }
7254
7255 #[test]
7256 fn test_strip_form_numbering_artifacts() {
7257 use crate::structure::table_extractor::{TableCell, TableRow};
7258
7259 let make_cell = |text: &str| TableCell {
7260 text: text.to_string(),
7261 spans: Vec::new(),
7262 colspan: 1,
7263 rowspan: 1,
7264 mcids: Vec::new(),
7265 bbox: None,
7266 is_header: false,
7267 };
7268
7269 let mut rows = vec![
7270 TableRow {
7272 cells: vec![make_cell("5"), make_cell(""), make_cell(""), make_cell("")],
7273 is_header: false,
7274 },
7275 TableRow {
7277 cells: vec![
7278 make_cell("1 Apr 11, 2025"),
7279 make_cell("1 12111 - Rinse-Fluoride Treatment"),
7280 make_cell("1 $14.60"),
7281 make_cell("1"),
7282 ],
7283 is_header: false,
7284 },
7285 TableRow {
7287 cells: vec![
7288 make_cell("Apr 11, 2025"),
7289 make_cell("11101 - One unit of time"),
7290 make_cell("$47.60"),
7291 make_cell(""),
7292 ],
7293 is_header: false,
7294 },
7295 TableRow {
7297 cells: vec![make_cell("3 items"), make_cell(""), make_cell(""), make_cell("")],
7298 is_header: false,
7299 },
7300 ];
7301
7302 strip_form_numbering_artifacts(&mut rows);
7303
7304 assert_eq!(rows.len(), 3, "Single-digit-only row should be removed");
7305
7306 let r0: Vec<&str> = rows[0].cells.iter().map(|c| c.text.as_str()).collect();
7307 assert_eq!(r0[0], "Apr 11, 2025", "Leading '1 ' should be stripped");
7308 assert_eq!(
7309 r0[1], "12111 - Rinse-Fluoride Treatment",
7310 "Leading '1 ' stripped, rest starts with digit but contains '-'"
7311 );
7312 assert_eq!(r0[2], "$14.60", "Leading '1 ' stripped, rest starts with '$'");
7313 assert_eq!(r0[3], "", "Lone '1' cleared when other cells in row were stripped");
7315
7316 let r1: Vec<&str> = rows[1].cells.iter().map(|c| c.text.as_str()).collect();
7317 assert_eq!(r1[0], "Apr 11, 2025");
7318 assert_eq!(r1[1], "11101 - One unit of time");
7319 assert_eq!(r1[2], "$47.60");
7320
7321 let r2: Vec<&str> = rows[2].cells.iter().map(|c| c.text.as_str()).collect();
7324 assert_eq!(
7325 r2[0], "3 items",
7326 "'3 items' should NOT be stripped (plain word, no date/code/currency)"
7327 );
7328 }
7329
7330 #[test]
7331 fn test_strip_dash_separator_cells() {
7332 use crate::structure::table_extractor::{TableCell, TableRow};
7335
7336 let make_cell = |text: &str| TableCell {
7337 text: text.to_string(),
7338 spans: Vec::new(),
7339 colspan: 1,
7340 rowspan: 1,
7341 mcids: Vec::new(),
7342 bbox: None,
7343 is_header: false,
7344 };
7345
7346 let mut rows = vec![
7347 TableRow {
7348 cells: vec![make_cell("------"), make_cell("Total"), make_cell("$500.00")],
7349 is_header: false,
7350 },
7351 TableRow {
7352 cells: vec![make_cell("____"), make_cell("Subtotal"), make_cell("$200.00")],
7353 is_header: false,
7354 },
7355 TableRow {
7356 cells: vec![make_cell("--__--"), make_cell("Tax"), make_cell("$10.00")],
7357 is_header: false,
7358 },
7359 TableRow {
7360 cells: vec![make_cell("------"), make_cell("---"), make_cell("------")],
7361 is_header: false,
7362 },
7363 TableRow {
7364 cells: vec![make_cell("2025-01-01"), make_cell("Payment"), make_cell("$100.00")],
7365 is_header: false,
7366 },
7367 ];
7368
7369 strip_form_numbering_artifacts(&mut rows);
7370
7371 assert_eq!(rows[0].cells[0].text.trim(), "", "Dash-only cell should be cleared");
7372 assert_eq!(rows[0].cells[1].text, "Total");
7373 assert_eq!(rows[0].cells[2].text, "$500.00");
7374
7375 assert_eq!(
7376 rows[1].cells[0].text.trim(),
7377 "",
7378 "Underscore-only cell should be cleared"
7379 );
7380
7381 assert_eq!(
7382 rows[2].cells[0].text.trim(),
7383 "",
7384 "Mixed dash/underscore cell should be cleared"
7385 );
7386
7387 assert_eq!(rows.len(), 5, "All-dash row kept as empty separator");
7390 assert!(
7391 rows[3].cells.iter().all(|c| c.text.trim().is_empty()),
7392 "All-dash row should now be all-empty"
7393 );
7394
7395 assert_eq!(rows[4].cells[0].text, "2025-01-01");
7396 }
7397
7398 #[test]
7399 fn test_separate_small_and_large_table_clusters() {
7400 let lines = vec![
7401 make_rect_path(409.0, 83.0, 125.0, 0.5),
7402 make_rect_path(409.0, 142.0, 125.0, 0.5),
7403 make_rect_path(409.0, 71.0, 0.5, 72.0),
7404 make_rect_path(534.0, 71.0, 0.5, 72.0),
7405 make_rect_path(22.0, 150.0, 567.0, 0.5),
7406 make_rect_path(22.0, 553.0, 567.0, 0.5),
7407 make_rect_path(22.0, 150.0, 0.5, 403.0),
7408 make_rect_path(490.0, 150.0, 0.5, 403.0),
7409 make_rect_path(589.0, 150.0, 0.5, 403.0),
7410 ];
7411
7412 let config = TableDetectionConfig::default();
7413 let clusters = group_lines_into_clusters(&lines, &config);
7414 assert!(
7415 clusters.len() >= 2,
7416 "Expected at least 2 clusters (header table + main table), got {}",
7417 clusters.len()
7418 );
7419
7420 for cluster in &clusters {
7421 let mut has_header_vline = false;
7422 let mut has_main_vline = false;
7423 for &idx in &cluster.lines {
7424 let bbox = &lines[idx].bbox;
7425 if bbox.width.abs() < 2.0 && bbox.height.abs() > 5.0 {
7426 let y_max = bbox.y + bbox.height;
7427 if y_max < 145.0 {
7428 has_header_vline = true;
7429 }
7430 if bbox.y >= 149.0 {
7431 has_main_vline = true;
7432 }
7433 }
7434 }
7435 assert!(
7436 !(has_header_vline && has_main_vline),
7437 "A single cluster should not contain both header V-lines (y<145) and main V-lines (y>149)"
7438 );
7439 }
7440 }
7441
7442 #[test]
7447 fn test_ws03b_header_row_above_included() {
7448 let lines = vec![
7449 make_h_line(100.0, 560.0, 300.0),
7450 make_h_line(100.0, 530.0, 300.0),
7451 make_h_line(100.0, 500.0, 300.0),
7452 make_v_line(100.0, 500.0, 60.0),
7453 make_v_line(200.0, 500.0, 60.0),
7454 make_v_line(300.0, 500.0, 60.0),
7455 make_v_line(400.0, 500.0, 60.0),
7456 ];
7457 let spans = vec![
7458 create_test_span("Name", 140.0, 568.0, 20.0, 10.0),
7459 create_test_span("Age", 240.0, 568.0, 20.0, 10.0),
7460 create_test_span("City", 340.0, 568.0, 20.0, 10.0),
7461 create_test_span("Ann", 145.0, 545.0, 10.0, 10.0),
7462 create_test_span("30", 245.0, 545.0, 10.0, 10.0),
7463 create_test_span("NYC", 345.0, 545.0, 10.0, 10.0),
7464 create_test_span("Bob", 145.0, 515.0, 10.0, 10.0),
7465 create_test_span("41", 245.0, 515.0, 10.0, 10.0),
7466 create_test_span("LA", 345.0, 515.0, 10.0, 10.0),
7467 ];
7468 let config = TableDetectionConfig::default();
7469 let clusters = group_lines_into_clusters(&lines, &config);
7470 assert_eq!(clusters.len(), 1, "single ruled grid → one cluster");
7471 let tables = detect_tables_in_cluster(&spans, &lines, &clusters[0], &config);
7472 assert_eq!(tables.len(), 1, "one table expected");
7473 let table = &tables[0];
7474 assert_eq!(
7475 table.rows.len(),
7476 3,
7477 "header row above the top ruling must be included (2 body + 1 header)"
7478 );
7479 assert!(table.has_header, "table must be flagged as having a header");
7480 assert!(table.rows[0].is_header, "row 0 must be the header row");
7481 let header_text: String = table.rows[0]
7482 .cells
7483 .iter()
7484 .map(|c| c.text.trim())
7485 .collect::<Vec<_>>()
7486 .join("|");
7487 for label in ["Name", "Age", "City"] {
7488 assert!(
7489 header_text.contains(label),
7490 "header row must contain {label:?}, got {header_text:?}"
7491 );
7492 }
7493 }
7494
7495 #[test]
7499 fn test_ws03b_unaligned_text_above_not_header() {
7500 let lines = vec![
7501 make_h_line(100.0, 560.0, 300.0),
7502 make_h_line(100.0, 530.0, 300.0),
7503 make_h_line(100.0, 500.0, 300.0),
7504 make_v_line(100.0, 500.0, 60.0),
7505 make_v_line(200.0, 500.0, 60.0),
7506 make_v_line(300.0, 500.0, 60.0),
7507 make_v_line(400.0, 500.0, 60.0),
7508 ];
7509 let spans = vec![
7510 create_test_span("Table", 520.0, 568.0, 20.0, 10.0),
7511 create_test_span("caption", 560.0, 568.0, 40.0, 10.0),
7512 create_test_span("Ann", 145.0, 545.0, 10.0, 10.0),
7513 create_test_span("30", 245.0, 545.0, 10.0, 10.0),
7514 create_test_span("NYC", 345.0, 545.0, 10.0, 10.0),
7515 create_test_span("Bob", 145.0, 515.0, 10.0, 10.0),
7516 create_test_span("41", 245.0, 515.0, 10.0, 10.0),
7517 create_test_span("LA", 345.0, 515.0, 10.0, 10.0),
7518 ];
7519 let config = TableDetectionConfig::default();
7520
7521 let mut row_ys = vec![560.0_f32, 530.0, 500.0];
7522 row_ys.sort_by(|a, b| crate::utils::safe_float_cmp(*b, *a));
7523 let col_xs = vec![100.0_f32, 200.0, 300.0, 400.0];
7524 assert!(
7525 detect_header_row_above(&spans, &row_ys, &col_xs).is_none(),
7526 "text outside the column extent must not be treated as a header row"
7527 );
7528
7529 let clusters = group_lines_into_clusters(&lines, &config);
7530 let tables = detect_tables_in_cluster(&spans, &lines, &clusters[0], &config);
7531 assert_eq!(tables.len(), 1);
7532 assert_eq!(
7533 tables[0].rows.len(),
7534 2,
7535 "no header row must be added for unaligned text above the grid"
7536 );
7537 let all_text: String = tables[0]
7538 .rows
7539 .iter()
7540 .flat_map(|r| r.cells.iter())
7541 .map(|c| c.text.clone())
7542 .collect();
7543 assert!(
7544 !all_text.contains("caption"),
7545 "unaligned caption text must stay out of the table, got {all_text:?}"
7546 );
7547 }
7548
7549 #[test]
7550 fn test_text_edge_columns_form_layout() {
7551 let mut spans = Vec::new();
7562 let col_xs = [48.0_f32, 210.0, 382.0, 516.0];
7563 let row_ys = [700.0_f32, 680.0, 660.0, 640.0, 620.0, 600.0];
7564
7565 for &cx in &col_xs {
7566 for &ry in &row_ys {
7567 spans.push(create_test_span("val", cx, ry, 40.0, 10.0));
7568 }
7569 }
7570
7571 spans.push(create_test_span("noise", 130.0, 700.0, 20.0, 10.0));
7574 spans.push(create_test_span("noise", 132.0, 680.0, 20.0, 10.0));
7575
7576 let config = TableDetectionConfig::default();
7577 let columns = detect_text_edge_columns(&spans, &config);
7578
7579 assert!(
7583 columns.len() >= 3 && columns.len() <= 6,
7584 "Expected 3-6 text-edge columns, got {}",
7585 columns.len()
7586 );
7587
7588 let centres: Vec<f32> = columns.iter().map(|c| c.x_center).collect();
7589 for &expected_x in &col_xs {
7590 assert!(
7591 centres
7592 .iter()
7593 .any(|&cx| (cx - expected_x).abs() < config.column_tolerance
7594 || (cx - (expected_x + 40.0)).abs() < config.column_tolerance),
7595 "Expected a column near x={expected_x} (or its right edge), centres={centres:?}"
7596 );
7597 }
7598 }
7599
7600 #[test]
7601 fn test_text_edge_columns_noise_filtered() {
7602 let spans = vec![
7603 create_test_span("a", 100.0, 500.0, 30.0, 10.0),
7605 create_test_span("c", 300.0, 500.0, 30.0, 10.0),
7606 create_test_span("d", 300.0, 480.0, 30.0, 10.0),
7607 create_test_span("e", 300.0, 460.0, 30.0, 10.0),
7608 create_test_span("f", 300.0, 440.0, 30.0, 10.0),
7609 ];
7610
7611 let config = TableDetectionConfig::default();
7612 let columns = detect_text_edge_columns(&spans, &config);
7613
7614 assert!(!columns.is_empty(), "Should produce at least one column from x=300");
7615 for c in &columns {
7616 assert!(
7617 (c.x_center - 100.0).abs() > 15.0,
7618 "x=100 edge should have been filtered (only 1 row), but got column at {}",
7619 c.x_center
7620 );
7621 }
7622 }
7623
7624 #[test]
7625 fn test_text_edge_fallback_integration() {
7626 let mut spans = Vec::new();
7632 let true_cols = [50.0_f32, 200.0, 350.0, 500.0];
7633 let row_ys = [700.0_f32, 680.0, 660.0, 640.0, 620.0];
7634
7635 for (ci, &cx) in true_cols.iter().enumerate() {
7636 for (ri, &ry) in row_ys.iter().enumerate() {
7637 let jitter = ((ci + ri) % 3) as f32 * 2.0;
7641 spans.push(create_test_span("v", cx + jitter, ry, 30.0, 10.0));
7642 }
7643 }
7644
7645 for i in 0..10 {
7648 let x = 80.0 + i as f32 * 30.0;
7649 spans.push(create_test_span("x", x, 700.0, 15.0, 10.0));
7650 }
7651
7652 let config = TableDetectionConfig {
7653 column_tolerance: 8.0, ..TableDetectionConfig::default()
7655 };
7656
7657 let greedy_cols = detect_columns(&spans, config.column_tolerance, config.column_merge_threshold);
7658 assert!(
7659 greedy_cols.len() > 6,
7660 "Precondition: greedy should produce >6 columns, got {}",
7661 greedy_cols.len()
7662 );
7663
7664 let te_cols = detect_text_edge_columns(&spans, &config);
7665 assert!(
7666 te_cols.len() < greedy_cols.len(),
7667 "Text-edge should produce fewer columns ({}) than greedy ({})",
7668 te_cols.len(),
7669 greedy_cols.len()
7670 );
7671 }
7672
7673 #[test]
7674 fn test_reject_table_with_too_many_empty_cells() {
7675 use crate::structure::table_extractor::{Table, TableCell, TableRow};
7676 let col_count = 12;
7677 let mut rows = Vec::new();
7678 let mut header = TableRow::new(true);
7679 for c in 0..col_count {
7680 header.cells.push(TableCell {
7681 text: if c < 3 { format!("H{c}") } else { String::new() },
7682 spans: Vec::new(),
7683 colspan: 1,
7684 rowspan: 1,
7685 mcids: vec![],
7686 bbox: None,
7687 is_header: true,
7688 });
7689 }
7690 rows.push(header);
7691 for r in 0..4 {
7692 let mut row = TableRow::new(false);
7693 for c in 0..col_count {
7694 row.cells.push(TableCell {
7695 text: if c < 2 { format!("R{r}C{c}") } else { String::new() },
7696 spans: Vec::new(),
7697 colspan: 1,
7698 rowspan: 1,
7699 mcids: vec![],
7700 bbox: None,
7701 is_header: false,
7702 });
7703 }
7704 rows.push(row);
7705 }
7706 let table = Table {
7707 rows,
7708 has_header: true,
7709 col_count,
7710 bbox: None,
7711 };
7712 assert!(
7714 !is_valid_table(&table),
7715 "Table with >60% empty cells should be rejected"
7716 );
7717 }
7718
7719 #[test]
7720 fn test_valid_table_passes_validation() {
7721 use crate::structure::table_extractor::{Table, TableCell, TableRow};
7722 let col_count = 3;
7723 let mut rows = Vec::new();
7724 for r in 0..4 {
7725 let mut row = TableRow::new(r == 0);
7726 for c in 0..col_count {
7727 row.cells.push(TableCell {
7728 text: format!("R{r}C{c}"),
7729 spans: Vec::new(),
7730 colspan: 1,
7731 rowspan: 1,
7732 mcids: vec![],
7733 bbox: None,
7734 is_header: r == 0,
7735 });
7736 }
7737 rows.push(row);
7738 }
7739 let table = Table {
7740 rows,
7741 has_header: true,
7742 col_count,
7743 bbox: None,
7744 };
7745 assert!(is_valid_table(&table), "Well-populated table should pass validation");
7746 }
7747
7748 #[test]
7755 fn test_narrow_shallow_table_rejected_as_false_positive() {
7756 use crate::structure::table_extractor::{Table, TableCell, TableRow};
7757 let col_count = 2;
7758 let rows_data: Vec<(&str, &str)> = vec![
7759 ("Temperature resistance", "adhered to aluminium, -56° C to +82° C"),
7760 (
7761 "Resistance to cleaning agents",
7762 "adhered to aluminium, 8 h in solution (0.5% household",
7763 ),
7764 ("", "cleaning agents) at room temperature and 65° C, no"),
7766 ];
7767 let mut rows = Vec::new();
7768 for (label, value) in &rows_data {
7769 let mut row = TableRow::new(false);
7770 row.cells.push(TableCell {
7771 text: label.to_string(),
7772 spans: Vec::new(),
7773 colspan: 1,
7774 rowspan: 1,
7775 mcids: vec![],
7776 bbox: None,
7777 is_header: false,
7778 });
7779 row.cells.push(TableCell {
7780 text: value.to_string(),
7781 spans: Vec::new(),
7782 colspan: 1,
7783 rowspan: 1,
7784 mcids: vec![],
7785 bbox: None,
7786 is_header: false,
7787 });
7788 rows.push(row);
7789 }
7790 let table = Table {
7791 rows,
7792 has_header: false,
7793 col_count,
7794 bbox: None,
7795 };
7796 assert!(
7797 !is_valid_table(&table),
7798 "Narrow 2-column 'table' with an empty continuation cell must \
7799 be rejected so its rows stay in the flow text"
7800 );
7801 }
7802
7803 #[test]
7807 fn test_narrow_deep_table_still_accepted() {
7808 use crate::structure::table_extractor::{Table, TableCell, TableRow};
7809 let col_count = 2;
7810 let mut rows = Vec::new();
7811 for i in 0..6 {
7812 let mut row = TableRow::new(i == 0);
7813 row.cells.push(TableCell {
7814 text: format!("Key {i}"),
7815 spans: Vec::new(),
7816 colspan: 1,
7817 rowspan: 1,
7818 mcids: vec![],
7819 bbox: None,
7820 is_header: i == 0,
7821 });
7822 row.cells.push(TableCell {
7823 text: format!("Value {i}"),
7824 spans: Vec::new(),
7825 colspan: 1,
7826 rowspan: 1,
7827 mcids: vec![],
7828 bbox: None,
7829 is_header: i == 0,
7830 });
7831 rows.push(row);
7832 }
7833 let table = Table {
7834 rows,
7835 has_header: true,
7836 col_count,
7837 bbox: None,
7838 };
7839 assert!(
7840 is_valid_table(&table),
7841 "A 2-col × 6-row data table should still be accepted"
7842 );
7843 }
7844
7845 #[test]
7850 fn test_narrow_sparse_table_with_missing_right_value_accepted() {
7851 use crate::structure::table_extractor::{Table, TableCell, TableRow};
7852 let col_count = 2;
7853 let rows_data: Vec<(&str, &str)> = vec![
7854 ("Name", "ACME Corp"),
7855 ("Registration", "12345"),
7856 ("Fax", ""),
7857 ("Email", "info@example.com"),
7858 ];
7859 let mut rows = Vec::new();
7860 for (label, value) in &rows_data {
7861 let mut row = TableRow::new(false);
7862 row.cells.push(TableCell {
7863 text: label.to_string(),
7864 spans: Vec::new(),
7865 colspan: 1,
7866 rowspan: 1,
7867 mcids: vec![],
7868 bbox: None,
7869 is_header: false,
7870 });
7871 row.cells.push(TableCell {
7872 text: value.to_string(),
7873 spans: Vec::new(),
7874 colspan: 1,
7875 rowspan: 1,
7876 mcids: vec![],
7877 bbox: None,
7878 is_header: false,
7879 });
7880 rows.push(row);
7881 }
7882 let table = Table {
7883 rows,
7884 has_header: false,
7885 col_count,
7886 bbox: None,
7887 };
7888 assert!(
7889 is_valid_table(&table),
7890 "A 2-col table with a missing right-hand value but no empty-left \
7891 continuation row must still validate"
7892 );
7893 }
7894
7895 #[test]
7896 fn test_text_only_tables_capped_at_max_columns() {
7897 let mut spans = Vec::new();
7898 let col_xs = [50.0_f32, 100.0, 150.0, 200.0, 250.0, 300.0, 350.0, 400.0];
7899 let row_ys = [700.0_f32, 680.0, 660.0, 640.0, 620.0];
7900
7901 for &cx in &col_xs {
7902 for &ry in &row_ys {
7903 spans.push(create_test_span("val", cx, ry, 30.0, 10.0));
7904 }
7905 }
7906
7907 let config = TableDetectionConfig {
7910 column_tolerance: 5.0,
7911 column_merge_threshold: 8.0,
7912 max_table_columns: 6,
7913 ..TableDetectionConfig::default()
7914 };
7915
7916 let tables = detect_tables_from_spans(&spans, &config);
7917 assert!(
7918 tables.is_empty(),
7919 "Text-only table with 8 columns should be rejected (max_table_columns=6), got {} table(s)",
7920 tables.len()
7921 );
7922 }
7923
7924 #[test]
7925 fn test_extended_grid_when_lines_dont_cross() {
7926 let lines = vec![
7931 make_h_line(0.0, 100.0, 500.0),
7932 make_h_line(0.0, 50.0, 500.0),
7933 make_v_line(0.0, 300.0, 50.0),
7934 make_v_line(100.0, 300.0, 50.0),
7935 make_v_line(200.0, 300.0, 50.0),
7936 ];
7937
7938 let spans = vec![
7939 create_test_span("A", 30.0, 70.0, 20.0, 10.0),
7940 create_test_span("B", 130.0, 70.0, 20.0, 10.0),
7941 ];
7942
7943 let config = TableDetectionConfig {
7944 horizontal_strategy: TableStrategy::Lines,
7945 vertical_strategy: TableStrategy::Lines,
7946 min_table_cells: 2,
7947 min_table_columns: 2,
7948 ..TableDetectionConfig::default()
7949 };
7950
7951 let tables = detect_tables_from_intersections(&spans, &lines, &config);
7952 assert!(
7953 !tables.is_empty(),
7954 "Extended grid should produce at least one table when H and V lines don't cross"
7955 );
7956 let table = &tables[0];
7957 assert!(
7958 table.col_count >= 2,
7959 "Extended grid table should have at least 2 columns, got {}",
7960 table.col_count
7961 );
7962 }
7963
7964 fn rotated_grid_fixture(rotation: f32) -> (Vec<crate::elements::PathContent>, Vec<TextSpan>) {
7976 let lines = vec![
7977 make_h_line(0.0, 0.0, 90.0),
7978 make_h_line(0.0, 20.0, 90.0),
7979 make_h_line(0.0, 40.0, 90.0),
7980 make_v_line(0.0, 0.0, 40.0),
7981 make_v_line(30.0, 0.0, 40.0),
7982 make_v_line(60.0, 0.0, 40.0),
7983 make_v_line(90.0, 0.0, 40.0),
7984 ];
7985 let col_left_x = [10.0_f32, 40.0, 70.0];
7989 let row_bottom_y = [25.0_f32, 5.0]; let mut spans = Vec::new();
7991 for (row, &y) in row_bottom_y.iter().enumerate() {
7992 for (col, &x) in col_left_x.iter().enumerate() {
7993 let text = format!("R{row}C{col}");
7994 spans.push(TextSpan {
7995 rotation_degrees: rotation,
7996 ..create_test_span(&text, x, y, 10.0, 10.0)
7997 });
7998 }
7999 }
8000 (lines, spans)
8001 }
8002
8003 fn rotated_grid_config() -> TableDetectionConfig {
8004 TableDetectionConfig {
8005 horizontal_strategy: TableStrategy::Lines,
8006 vertical_strategy: TableStrategy::Lines,
8007 min_table_cells: 2,
8008 min_table_columns: 2,
8009 ..TableDetectionConfig::default()
8010 }
8011 }
8012
8013 #[test]
8026 fn detect_tables_from_intersections_transposes_90_degree_rotated_table_grid() {
8027 let (lines, spans) = rotated_grid_fixture(90.0);
8028 let config = rotated_grid_config();
8029
8030 let tables = detect_tables_from_intersections(&spans, &lines, &config);
8031 assert_eq!(tables.len(), 1, "expected exactly one detected table");
8032 let table = &tables[0];
8033
8034 assert_eq!(
8035 table.col_count, 2,
8036 "a 90-degree table's 3 physical columns must become 2 table columns \
8037 (the physical row count), got {}",
8038 table.col_count
8039 );
8040 assert_eq!(
8041 table.rows.len(),
8042 3,
8043 "expected 3 rows after transposing 2 physical rows into 3"
8044 );
8045
8046 let row_texts: Vec<Vec<&str>> = table
8047 .rows
8048 .iter()
8049 .map(|r| r.cells.iter().map(|c| c.text.as_str()).collect())
8050 .collect();
8051 assert_eq!(
8052 row_texts,
8053 vec![vec!["R1C0", "R0C0"], vec!["R1C1", "R0C1"], vec!["R1C2", "R0C2"],],
8054 "90-degree rotation must rotate the grid 90 degrees clockwise \
8055 (new row = old column ascending, new column = old row reversed); got {row_texts:?}"
8056 );
8057 }
8058
8059 #[test]
8065 fn detect_tables_from_intersections_leaves_upright_table_grid_unchanged() {
8066 let (lines, spans) = rotated_grid_fixture(0.0);
8067 let config = rotated_grid_config();
8068
8069 let tables = detect_tables_from_intersections(&spans, &lines, &config);
8070 assert_eq!(tables.len(), 1, "expected exactly one detected table");
8071 let table = &tables[0];
8072
8073 assert_eq!(table.col_count, 3, "an upright table's column count must not change");
8074 assert_eq!(table.rows.len(), 2, "an upright table's row count must not change");
8075
8076 let row_texts: Vec<Vec<&str>> = table
8077 .rows
8078 .iter()
8079 .map(|r| r.cells.iter().map(|c| c.text.as_str()).collect())
8080 .collect();
8081 assert_eq!(
8082 row_texts,
8083 vec![vec!["R0C0", "R0C1", "R0C2"], vec!["R1C0", "R1C1", "R1C2"]],
8084 "an upright table must keep its physical top-to-bottom, \
8085 left-to-right row/column order; got {row_texts:?}"
8086 );
8087 }
8088
8089 #[test]
8103 fn dominant_table_rotation_quadrant_requires_strict_majority_not_plurality() {
8104 let cell = |texts_and_rotations: &[(&str, f32)]| TableCell {
8105 text: String::new(),
8106 spans: texts_and_rotations
8107 .iter()
8108 .map(|&(text, rotation)| TextSpan {
8109 rotation_degrees: rotation,
8110 ..create_test_span(text, 0.0, 0.0, 10.0, 10.0)
8111 })
8112 .collect(),
8113 colspan: 1,
8114 rowspan: 1,
8115 mcids: Vec::new(),
8116 bbox: None,
8117 is_header: false,
8118 };
8119
8120 let tied = Table {
8123 rows: vec![TableRow {
8124 cells: vec![cell(&[("a", 0.0), ("b", 0.0)]), cell(&[("c", 90.0), ("d", 90.0)])],
8125 is_header: false,
8126 }],
8127 has_header: false,
8128 col_count: 2,
8129 bbox: None,
8130 };
8131 assert_eq!(
8132 dominant_table_rotation_quadrant(&tied),
8133 0.0,
8134 "a 2-vs-2 tie between quadrants must not be resolved into a rotation"
8135 );
8136
8137 let majority = Table {
8139 rows: vec![TableRow {
8140 cells: vec![cell(&[("a", 90.0), ("b", 90.0)]), cell(&[("c", 90.0), ("d", 0.0)])],
8141 is_header: false,
8142 }],
8143 has_header: false,
8144 col_count: 2,
8145 bbox: None,
8146 };
8147 assert_eq!(
8148 dominant_table_rotation_quadrant(&majority),
8149 90.0,
8150 "a 3-vs-1 strict majority must select the majority quadrant"
8151 );
8152 }
8153
8154 fn borderless_rotated_grid_fixture(rotation: f32) -> Vec<TextSpan> {
8171 let col_left_x = [10.0_f32, 40.0, 70.0];
8172 let row_bottom_y = [25.0_f32, 5.0]; let mut spans = Vec::new();
8174 for (row, &y) in row_bottom_y.iter().enumerate() {
8175 for (col, &x) in col_left_x.iter().enumerate() {
8176 let text = format!("R{row}C{col}");
8177 spans.push(TextSpan {
8178 rotation_degrees: rotation,
8179 ..create_test_span(&text, x, y, 10.0, 10.0)
8180 });
8181 }
8182 }
8183 spans
8184 }
8185
8186 #[test]
8199 fn detect_tables_from_spans_transposes_90_degree_rotated_borderless_grid() {
8200 let spans = borderless_rotated_grid_fixture(90.0);
8201 let config = TableDetectionConfig::default();
8202
8203 let tables = detect_tables_from_spans(&spans, &config);
8204 assert_eq!(tables.len(), 1, "expected exactly one detected table");
8205 let table = &tables[0];
8206
8207 assert_eq!(
8208 table.col_count, 2,
8209 "a 90-degree borderless table's 3 physical columns must become 2 table \
8210 columns (the physical row count), got {}",
8211 table.col_count
8212 );
8213 assert_eq!(
8214 table.rows.len(),
8215 3,
8216 "expected 3 rows after transposing 2 physical rows into 3"
8217 );
8218
8219 let row_texts: Vec<Vec<&str>> = table
8220 .rows
8221 .iter()
8222 .map(|r| r.cells.iter().map(|c| c.text.as_str()).collect())
8223 .collect();
8224 assert_eq!(
8225 row_texts,
8226 vec![vec!["R1C0", "R0C0"], vec!["R1C1", "R0C1"], vec!["R1C2", "R0C2"],],
8227 "90-degree rotation must rotate the borderless grid 90 degrees clockwise \
8228 (new row = old column ascending, new column = old row reversed); got {row_texts:?}"
8229 );
8230 }
8231
8232 #[test]
8242 fn detect_tables_from_spans_transposes_270_degree_rotated_borderless_grid() {
8243 let spans = borderless_rotated_grid_fixture(270.0);
8244 let config = TableDetectionConfig::default();
8245
8246 let tables = detect_tables_from_spans(&spans, &config);
8247 assert_eq!(tables.len(), 1, "expected exactly one detected table");
8248 let table = &tables[0];
8249
8250 assert_eq!(
8251 table.col_count, 2,
8252 "a 270-degree borderless table's 3 physical columns must become 2 table \
8253 columns, got {}",
8254 table.col_count
8255 );
8256 assert_eq!(
8257 table.rows.len(),
8258 3,
8259 "expected 3 rows after transposing 2 physical rows into 3"
8260 );
8261
8262 let row_texts: Vec<Vec<&str>> = table
8263 .rows
8264 .iter()
8265 .map(|r| r.cells.iter().map(|c| c.text.as_str()).collect())
8266 .collect();
8267 assert_eq!(
8268 row_texts,
8269 vec![vec!["R0C2", "R1C2"], vec!["R0C1", "R1C1"], vec!["R0C0", "R1C0"],],
8270 "270-degree rotation must rotate the borderless grid the opposite way \
8271 round from 90 degrees (new row = old column reversed, new column = old \
8272 row ascending); got {row_texts:?}"
8273 );
8274 }
8275
8276 #[test]
8277 fn test_merge_vertically_adjacent_tables() {
8278 let table1 = Table {
8281 rows: vec![TableRow {
8282 cells: vec![
8283 TableCell {
8284 text: "A".into(),
8285 spans: Vec::new(),
8286 colspan: 1,
8287 rowspan: 1,
8288 mcids: vec![],
8289 bbox: None,
8290 is_header: false,
8291 },
8292 TableCell {
8293 text: "B".into(),
8294 spans: Vec::new(),
8295 colspan: 1,
8296 rowspan: 1,
8297 mcids: vec![],
8298 bbox: None,
8299 is_header: false,
8300 },
8301 TableCell {
8302 text: "C".into(),
8303 spans: Vec::new(),
8304 colspan: 1,
8305 rowspan: 1,
8306 mcids: vec![],
8307 bbox: None,
8308 is_header: false,
8309 },
8310 ],
8311 is_header: false,
8312 }],
8313 has_header: false,
8314 col_count: 3,
8315 bbox: Some(Rect::new(0.0, 100.0, 300.0, 50.0)),
8316 };
8317 let table2 = Table {
8318 rows: vec![TableRow {
8319 cells: vec![
8320 TableCell {
8321 text: "D".into(),
8322 spans: Vec::new(),
8323 colspan: 1,
8324 rowspan: 1,
8325 mcids: vec![],
8326 bbox: None,
8327 is_header: false,
8328 },
8329 TableCell {
8330 text: "E".into(),
8331 spans: Vec::new(),
8332 colspan: 1,
8333 rowspan: 1,
8334 mcids: vec![],
8335 bbox: None,
8336 is_header: false,
8337 },
8338 TableCell {
8339 text: "F".into(),
8340 spans: Vec::new(),
8341 colspan: 1,
8342 rowspan: 1,
8343 mcids: vec![],
8344 bbox: None,
8345 is_header: false,
8346 },
8347 ],
8348 is_header: false,
8349 }],
8350 has_header: false,
8351 col_count: 3,
8352 bbox: Some(Rect::new(0.0, 155.0, 300.0, 50.0)),
8353 };
8354
8355 let mut tables = vec![table1, table2];
8356 merge_vertically_adjacent_tables(&mut tables);
8357 assert_eq!(tables.len(), 1, "Adjacent tables should be merged into one");
8358 assert_eq!(tables[0].rows.len(), 2, "Merged table should have 2 rows");
8359 assert_eq!(tables[0].col_count, 3);
8360 }
8361
8362 #[test]
8363 fn test_no_merge_when_gap_too_large() {
8364 let table1 = Table {
8365 rows: vec![TableRow {
8366 cells: vec![TableCell {
8367 text: "A".into(),
8368 spans: Vec::new(),
8369 colspan: 1,
8370 rowspan: 1,
8371 mcids: vec![],
8372 bbox: None,
8373 is_header: false,
8374 }],
8375 is_header: false,
8376 }],
8377 has_header: false,
8378 col_count: 1,
8379 bbox: Some(Rect::new(0.0, 100.0, 300.0, 50.0)),
8380 };
8381 let table2 = Table {
8382 rows: vec![TableRow {
8383 cells: vec![TableCell {
8384 text: "B".into(),
8385 spans: Vec::new(),
8386 colspan: 1,
8387 rowspan: 1,
8388 mcids: vec![],
8389 bbox: None,
8390 is_header: false,
8391 }],
8392 is_header: false,
8393 }],
8394 has_header: false,
8395 col_count: 1,
8396 bbox: Some(Rect::new(0.0, 200.0, 300.0, 50.0)),
8398 };
8399
8400 let mut tables = vec![table1, table2];
8401 merge_vertically_adjacent_tables(&mut tables);
8402 assert_eq!(tables.len(), 2, "Tables with large gap should NOT be merged");
8403 }
8404
8405 #[test]
8406 fn test_census_h_and_v_in_different_regions() {
8407 let lines = vec![
8412 make_h_line(36.0, 100.0, 540.0),
8413 make_h_line(36.0, 50.0, 540.0),
8414 make_v_line(36.0, 500.0, 50.0),
8415 make_v_line(117.0, 500.0, 50.0),
8416 make_v_line(197.0, 500.0, 50.0),
8417 make_v_line(277.0, 500.0, 50.0),
8418 make_v_line(357.0, 500.0, 50.0),
8419 make_v_line(437.0, 500.0, 50.0),
8420 make_v_line(517.0, 500.0, 50.0),
8421 make_v_line(576.0, 500.0, 50.0),
8422 ];
8423
8424 let spans = vec![
8425 create_test_span("A", 60.0, 70.0, 20.0, 10.0),
8426 create_test_span("B", 140.0, 70.0, 20.0, 10.0),
8427 create_test_span("C", 220.0, 70.0, 20.0, 10.0),
8428 create_test_span("D", 300.0, 70.0, 20.0, 10.0),
8429 create_test_span("E", 380.0, 70.0, 20.0, 10.0),
8430 create_test_span("F", 460.0, 70.0, 20.0, 10.0),
8431 create_test_span("G", 540.0, 70.0, 20.0, 10.0),
8432 ];
8433
8434 let config = TableDetectionConfig {
8435 horizontal_strategy: TableStrategy::Lines,
8436 vertical_strategy: TableStrategy::Lines,
8437 min_table_cells: 2,
8438 min_table_columns: 2,
8439 ..TableDetectionConfig::default()
8440 };
8441
8442 let tables = detect_tables_with_lines(&spans, &lines, &config);
8443 assert!(
8444 !tables.is_empty(),
8445 "Census layout with H/V in different Y regions should produce at least 1 table"
8446 );
8447 let table = &tables[0];
8448 assert!(
8450 table.col_count >= 5,
8451 "Census table should have at least 5 columns, got {}",
8452 table.col_count
8453 );
8454 }
8455
8456 #[test]
8457 fn test_w2_grid_not_fragmented() {
8458 let lines = vec![
8463 make_v_line(100.0, 100.0, 600.0),
8464 make_v_line(200.0, 100.0, 600.0),
8465 make_v_line(300.0, 100.0, 600.0),
8466 make_v_line(350.0, 300.0, 200.0),
8467 make_v_line(450.0, 300.0, 200.0),
8468 make_h_line(100.0, 100.0, 350.0),
8469 make_h_line(100.0, 200.0, 350.0),
8470 make_h_line(100.0, 300.0, 350.0),
8471 make_h_line(100.0, 400.0, 350.0),
8472 make_h_line(100.0, 500.0, 350.0),
8473 make_h_line(100.0, 600.0, 350.0),
8474 make_h_line(100.0, 700.0, 350.0),
8475 ];
8476
8477 let spans = vec![
8478 create_test_span("R1C1", 120.0, 150.0, 30.0, 10.0),
8479 create_test_span("R1C2", 220.0, 150.0, 30.0, 10.0),
8480 create_test_span("R2C1", 120.0, 250.0, 30.0, 10.0),
8481 create_test_span("R2C2", 220.0, 250.0, 30.0, 10.0),
8482 create_test_span("R3C1", 120.0, 350.0, 30.0, 10.0),
8483 create_test_span("R3C2", 220.0, 350.0, 30.0, 10.0),
8484 create_test_span("R4C1", 120.0, 450.0, 30.0, 10.0),
8485 create_test_span("R4C2", 220.0, 450.0, 30.0, 10.0),
8486 create_test_span("R5C1", 120.0, 550.0, 30.0, 10.0),
8487 create_test_span("R5C2", 220.0, 550.0, 30.0, 10.0),
8488 create_test_span("R6C1", 120.0, 650.0, 30.0, 10.0),
8489 create_test_span("R6C2", 220.0, 650.0, 30.0, 10.0),
8490 ];
8491
8492 let config = TableDetectionConfig {
8493 horizontal_strategy: TableStrategy::Lines,
8494 vertical_strategy: TableStrategy::Lines,
8495 min_table_cells: 4,
8496 min_table_columns: 2,
8497 ..TableDetectionConfig::default()
8498 };
8499
8500 let tables = detect_tables_with_lines(&spans, &lines, &config);
8501 assert!(
8502 tables.len() <= 2,
8503 "W-2 grid should produce at most 2 tables (not fragmented into {})",
8504 tables.len()
8505 );
8506 let total_filled: usize = tables
8507 .iter()
8508 .flat_map(|t| &t.rows)
8509 .flat_map(|r| &r.cells)
8510 .filter(|c| !c.text.is_empty())
8511 .count();
8512 assert!(
8513 total_filled >= 8,
8514 "W-2 tables should capture most text spans, got {}",
8515 total_filled
8516 );
8517 }
8518
8519 #[test]
8520 fn test_invoice_still_separate_tables() {
8521 let lines = vec![
8522 make_h_line(410.0, 83.0, 125.0),
8523 make_h_line(410.0, 142.0, 125.0),
8524 make_v_line(410.0, 71.0, 72.0),
8525 make_v_line(535.0, 71.0, 72.0),
8526 make_h_line(22.0, 150.0, 567.0),
8527 make_h_line(22.0, 553.0, 567.0),
8528 make_v_line(22.0, 150.0, 403.0),
8529 make_v_line(103.0, 150.0, 403.0),
8530 make_v_line(490.0, 150.0, 403.0),
8531 make_v_line(541.0, 150.0, 403.0),
8532 make_v_line(589.0, 150.0, 403.0),
8533 ];
8534
8535 let mut spans = vec![
8536 create_test_span("Balance Due", 420.0, 100.0, 80.0, 10.0),
8537 create_test_span("$500.00", 420.0, 120.0, 60.0, 10.0),
8538 ];
8539 for i in 0..6 {
8540 let y = 160.0 + i as f32 * 60.0;
8541 spans.push(create_test_span("Date", 30.0, y, 40.0, 10.0));
8542 spans.push(create_test_span("Code", 110.0, y, 40.0, 10.0));
8543 spans.push(create_test_span("Desc", 200.0, y, 200.0, 10.0));
8544 spans.push(create_test_span("$100", 500.0, y, 30.0, 10.0));
8545 }
8546
8547 let config = TableDetectionConfig {
8548 horizontal_strategy: TableStrategy::Lines,
8549 vertical_strategy: TableStrategy::Lines,
8550 min_table_cells: 2,
8551 min_table_columns: 1,
8552 ..TableDetectionConfig::default()
8553 };
8554
8555 let tables = detect_tables_with_lines(&spans, &lines, &config);
8556 assert!(
8557 tables.len() >= 2,
8558 "Invoice should produce at least 2 separate tables (header + main), got {}",
8559 tables.len()
8560 );
8561 }
8562
8563 #[test]
8564 fn test_two_column_table_detection() {
8565 let mut spans = vec![
8566 create_test_span("Abstract", 50.0, 700.0, 60.0, 12.0),
8567 create_test_span("We present a novel approach to language", 50.0, 680.0, 230.0, 12.0),
8568 create_test_span("Results show improvements across all", 50.0, 660.0, 230.0, 12.0),
8569 create_test_span("benchmarks with significant gains on", 50.0, 640.0, 230.0, 12.0),
8570 create_test_span("standard evaluation metrics.", 50.0, 620.0, 180.0, 12.0),
8571 ];
8572
8573 spans.push(create_test_span("Model", 320.0, 700.0, 40.0, 12.0));
8574 spans.push(create_test_span("F1", 420.0, 700.0, 15.0, 12.0));
8575 spans.push(create_test_span("Acc", 500.0, 700.0, 20.0, 12.0));
8576 spans.push(create_test_span("BERT", 320.0, 680.0, 30.0, 12.0));
8577 spans.push(create_test_span("92.4", 420.0, 680.0, 25.0, 12.0));
8578 spans.push(create_test_span("89.1", 500.0, 680.0, 25.0, 12.0));
8579 spans.push(create_test_span("GPT", 320.0, 660.0, 25.0, 12.0));
8580 spans.push(create_test_span("91.2", 420.0, 660.0, 25.0, 12.0));
8581 spans.push(create_test_span("88.3", 500.0, 660.0, 25.0, 12.0));
8582
8583 let config = TableDetectionConfig::default();
8584 let tables = detect_tables_from_spans_column_aware(&spans, &config);
8585
8586 assert_eq!(
8587 tables.len(),
8588 1,
8589 "Should detect exactly 1 table in the right column, got {}",
8590 tables.len()
8591 );
8592 assert_eq!(
8593 tables[0].col_count, 3,
8594 "Table should have 3 columns, got {}",
8595 tables[0].col_count
8596 );
8597 }
8598
8599 #[test]
8600 fn test_single_column_no_regression() {
8601 let spans = vec![
8602 create_test_span("Introduction", 50.0, 700.0, 80.0, 14.0),
8603 create_test_span(
8604 "This paper presents a comprehensive study of natural language",
8605 50.0,
8606 680.0,
8607 450.0,
8608 12.0,
8609 ),
8610 create_test_span(
8611 "processing techniques applied to large-scale document analysis.",
8612 50.0,
8613 660.0,
8614 430.0,
8615 12.0,
8616 ),
8617 create_test_span(
8618 "Our approach builds on recent advances in transformer architectures",
8619 50.0,
8620 640.0,
8621 460.0,
8622 12.0,
8623 ),
8624 create_test_span(
8625 "and demonstrates improvements across multiple benchmarks.",
8626 50.0,
8627 620.0,
8628 400.0,
8629 12.0,
8630 ),
8631 create_test_span(
8632 "We evaluate our method on standard datasets and report results.",
8633 50.0,
8634 600.0,
8635 420.0,
8636 12.0,
8637 ),
8638 ];
8639
8640 let config = TableDetectionConfig::default();
8641 let tables = detect_tables_from_spans_column_aware(&spans, &config);
8642 assert!(
8643 tables.is_empty(),
8644 "Single-column paragraph text should not be detected as a table, got {} table(s)",
8645 tables.len()
8646 );
8647 }
8648
8649 #[test]
8650 fn test_h_rule_bounded_text_table() {
8651 let lines = vec![make_h_line(50.0, 750.0, 350.0), make_h_line(50.0, 700.0, 350.0)];
8652
8653 let spans = vec![
8654 create_test_span("Model", 60.0, 740.0, 50.0, 10.0),
8655 create_test_span("Acc", 180.0, 740.0, 30.0, 10.0),
8656 create_test_span("F1", 280.0, 740.0, 20.0, 10.0),
8657 create_test_span("BERT", 60.0, 728.0, 40.0, 10.0),
8658 create_test_span("84.6", 180.0, 728.0, 30.0, 10.0),
8659 create_test_span("83.4", 280.0, 728.0, 30.0, 10.0),
8660 create_test_span("GPT", 60.0, 716.0, 35.0, 10.0),
8661 create_test_span("82.1", 180.0, 716.0, 30.0, 10.0),
8662 create_test_span("81.0", 280.0, 716.0, 30.0, 10.0),
8663 create_test_span("XLNet", 60.0, 704.0, 45.0, 10.0),
8664 create_test_span("85.2", 180.0, 704.0, 30.0, 10.0),
8665 create_test_span("84.1", 280.0, 704.0, 30.0, 10.0),
8666 ];
8667
8668 let config = TableDetectionConfig::default();
8669 let tables = detect_tables_with_lines(&spans, &lines, &config);
8670 assert!(
8671 !tables.is_empty(),
8672 "Should detect at least 1 table from text within H-line boundaries"
8673 );
8674 let table = &tables[0];
8675 assert!(
8676 table.col_count >= 3,
8677 "Expected at least 3 columns, got {}",
8678 table.col_count
8679 );
8680 assert!(
8681 table.rows.len() >= 3,
8682 "Expected at least 3 rows, got {}",
8683 table.rows.len()
8684 );
8685 }
8686
8687 fn ruled_rows(rows: &[[&str; 3]], top: f32) -> Vec<TextSpan> {
8688 rows.iter()
8689 .enumerate()
8690 .flat_map(|(i, row)| {
8691 let y = top - 12.0 - i as f32 * 15.0;
8692 row.iter()
8693 .zip([60.0, 180.0, 280.0])
8694 .map(move |(text, x)| create_test_span(text, x, y, 40.0, 10.0))
8695 })
8696 .collect()
8697 }
8698
8699 #[test]
8700 fn strict_lines_keep_a_rules_only_table_beside_a_grid() {
8701 let grid = [
8702 ["Item", "Qty", "Price"],
8703 ["Bolt", "40", "0.10"],
8704 ["Nut", "40", "0.05"],
8705 ["Washer", "80", "0.02"],
8706 ];
8707 let ruled = [
8708 ["Region", "Sales", "Growth"],
8709 ["Northland", "120", "3.1"],
8710 ["Eastmark", "95", "2.4"],
8711 ["Southvale", "88", "1.9"],
8712 ];
8713 let mut lines: Vec<_> = (0..=4)
8714 .map(|i| make_h_line(50.0, 750.0 - i as f32 * 15.0, 300.0))
8715 .collect();
8716 lines.extend([50.0, 150.0, 250.0, 350.0].map(|x| make_v_line(x, 690.0, 60.0)));
8717 lines.extend([600.0, 585.0, 540.0].map(|y| make_h_line(50.0, y, 300.0)));
8718 let mut spans = ruled_rows(&grid, 750.0);
8719 spans.extend(ruled_rows(&ruled, 600.0));
8720
8721 let tables = detect_tables_with_lines(&spans, &lines, &TableDetectionConfig::strict());
8722
8723 let booktabs = tables
8724 .iter()
8725 .find(|t| t.rows.iter().any(|r| r.cells.iter().any(|c| c.text == "Southvale")))
8726 .expect("rules-only table beside the grid");
8727 assert_eq!(tables.len(), 2);
8728 assert_eq!(booktabs.rows.len(), 4, "header above the midrule stays in the table");
8729 assert_eq!(booktabs.rows[0].cells[0].text, "Region");
8730 }
8731
8732 #[test]
8733 fn h_rules_under_every_row_form_one_table() {
8734 let rows = [
8735 ["Code", "Name", "Rate"],
8736 ["A1", "Alpha", "1.5"],
8737 ["B2", "Beta", "2.5"],
8738 ["C3", "Gamma", "3.5"],
8739 ];
8740 let lines: Vec<_> = (0..=4)
8741 .map(|i| make_h_line(50.0, 600.0 - i as f32 * 15.0, 300.0))
8742 .collect();
8743 let spans = ruled_rows(&rows, 600.0);
8744
8745 let tables = detect_tables_with_lines(&spans, &lines, &TableDetectionConfig::default());
8746
8747 assert_eq!(tables.len(), 1);
8748 assert_eq!(tables[0].rows.len(), 4);
8749 }
8750
8751 #[test]
8752 fn joined_header_band_keeps_the_body_columns_past_its_rule() {
8753 let lines = vec![
8754 make_h_line(50.0, 600.0, 300.0),
8755 make_h_line(60.0, 585.0, 300.0),
8756 make_h_line(70.0, 540.0, 300.0),
8757 ];
8758 let mut spans: Vec<_> = ["Code", "Name", "Rate"]
8759 .iter()
8760 .zip([80.0, 180.0, 280.0])
8761 .map(|(t, x)| create_test_span(t, x, 589.0, 40.0, 10.0))
8762 .collect();
8763 for (i, row) in [
8764 ["A1", "Alpha", "1.5", "x"],
8765 ["B2", "Beta", "2.5", "y"],
8766 ["C3", "Gamma", "3.5", "z"],
8767 ]
8768 .iter()
8769 .enumerate()
8770 {
8771 let y = 574.0 - i as f32 * 12.0;
8772 spans.extend(
8773 row.iter()
8774 .zip([80.0, 180.0, 280.0, 345.0])
8775 .map(|(t, x)| create_test_span(t, x, y, 20.0, 10.0)),
8776 );
8777 }
8778
8779 let tables = detect_tables_with_lines(&spans, &lines, &TableDetectionConfig::default());
8780
8781 let cells: Vec<&str> = tables
8782 .iter()
8783 .flat_map(|t| &t.rows)
8784 .flat_map(|r| &r.cells)
8785 .map(|c| c.text.as_str())
8786 .collect();
8787 assert!(
8788 cells.contains(&"z"),
8789 "body column past the header rule stays in the table: {cells:?}"
8790 );
8791 }
8792
8793 #[test]
8794 fn test_split_table_at_section_dividers() {
8795 let mut lines: Vec<crate::elements::PathContent> = Vec::new();
8807
8808 for i in 0..=9 {
8809 let y = 10.0 + i as f32 * 10.0;
8810 lines.push(make_h_line(10.0, y, 90.0));
8811 }
8812
8813 for &x in &[10.0, 40.0, 70.0, 100.0] {
8814 lines.push(make_v_line(x, 10.0, 30.0));
8815 }
8816 for &x in &[10.0, 40.0, 70.0, 100.0] {
8817 lines.push(make_v_line(x, 40.0, 30.0));
8818 }
8819 for &x in &[10.0, 40.0, 70.0, 100.0] {
8820 lines.push(make_v_line(x, 70.0, 30.0));
8821 }
8822
8823 let mut spans = Vec::new();
8824 for row in 0..9 {
8825 let y = 15.0 + row as f32 * 10.0;
8826 for col in 0..3 {
8827 let x = 15.0 + col as f32 * 30.0;
8828 let label = format!("S{}-R{}-C{}", row / 3 + 1, row % 3 + 1, col + 1);
8829 spans.push(create_test_span(&label, x, y, 20.0, 8.0));
8830 }
8831 }
8832
8833 let config = TableDetectionConfig {
8834 horizontal_strategy: TableStrategy::Lines,
8835 vertical_strategy: TableStrategy::Lines,
8836 ..TableDetectionConfig::default()
8837 };
8838
8839 let tables = detect_tables_with_lines(&spans, &lines, &config);
8840
8841 assert!(
8845 tables.len() >= 3,
8846 "Expected at least 3 tables after section-divider splitting, got {}",
8847 tables.len()
8848 );
8849 for (i, t) in tables.iter().enumerate() {
8850 assert_eq!(t.col_count, 3, "Table {} should have 3 columns, got {}", i, t.col_count);
8851 }
8852 }
8853
8854 fn make_uniform_grid(num_cols: usize, num_rows: usize, populated_per_row: usize) -> GridStructure {
8876 let columns = (0..num_cols)
8877 .map(|_| ColumnCluster {
8878 x_center: 0.0,
8879 x_min: 0.0,
8880 x_max: 0.0,
8881 span_indices: vec![],
8882 })
8883 .collect();
8884 let rows = (0..num_rows)
8885 .map(|_| RowCluster {
8886 y_center: 0.0,
8887 y_min: 0.0,
8888 y_max: 0.0,
8889 span_indices: vec![],
8890 })
8891 .collect();
8892 let cells = (0..num_rows)
8893 .map(|_| {
8894 (0..num_cols)
8895 .map(|c| if c < populated_per_row { vec![0usize] } else { vec![] })
8896 .collect()
8897 })
8898 .collect();
8899 GridStructure { columns, rows, cells }
8900 }
8901
8902 fn make_split_grid(num_cols: usize, num_rows: usize) -> GridStructure {
8910 assert!(num_cols.is_multiple_of(2), "make_split_grid requires even num_cols");
8911 let half = num_cols / 2;
8912 let columns = (0..num_cols)
8913 .map(|_| ColumnCluster {
8914 x_center: 0.0,
8915 x_min: 0.0,
8916 x_max: 0.0,
8917 span_indices: vec![],
8918 })
8919 .collect();
8920 let rows = (0..num_rows)
8921 .map(|_| RowCluster {
8922 y_center: 0.0,
8923 y_min: 0.0,
8924 y_max: 0.0,
8925 span_indices: vec![],
8926 })
8927 .collect();
8928 let cells = (0..num_rows)
8929 .map(|r| {
8930 let left_row = r % 2 == 0;
8931 (0..num_cols)
8932 .map(|c| {
8933 let in_left_half = c < half;
8934 if left_row == in_left_half { vec![0usize] } else { vec![] }
8935 })
8936 .collect()
8937 })
8938 .collect();
8939 GridStructure { columns, rows, cells }
8940 }
8941
8942 fn make_grouped_grid(total_cols: usize, num_rows: usize, group_cols: usize, group_size: usize) -> GridStructure {
8953 assert!(group_cols < total_cols, "group_cols must be < total_cols");
8954 assert!(group_size > 0, "group_size must be positive");
8955 let columns = (0..total_cols)
8956 .map(|_| ColumnCluster {
8957 x_center: 0.0,
8958 x_min: 0.0,
8959 x_max: 0.0,
8960 span_indices: vec![],
8961 })
8962 .collect();
8963 let rows = (0..num_rows)
8964 .map(|_| RowCluster {
8965 y_center: 0.0,
8966 y_min: 0.0,
8967 y_max: 0.0,
8968 span_indices: vec![],
8969 })
8970 .collect();
8971 let cells = (0..num_rows)
8972 .map(|r| {
8973 let is_group_header = r % group_size == 0;
8974 (0..total_cols)
8975 .map(|c| {
8976 let populated = if c < group_cols { is_group_header } else { true };
8977 if populated { vec![0usize] } else { vec![] }
8978 })
8979 .collect()
8980 })
8981 .collect();
8982 GridStructure { columns, rows, cells }
8983 }
8984
8985 #[test]
8989 fn validate_rejects_split_column_groups() {
8990 let grid = make_split_grid(6, 6);
8991 let config = TableDetectionConfig::default();
8992 assert!(!validate_table_structure_internal(&grid, &config));
8993 }
8994
8995 #[test]
8999 fn validate_rejects_split_column_groups_under_strict_profile() {
9000 let grid = make_split_grid(6, 6);
9001 let config = TableDetectionConfig::strict();
9002 assert!(!validate_table_structure_internal(&grid, &config));
9003 }
9004
9005 #[test]
9008 fn validate_accepts_dense_table() {
9009 let grid = make_uniform_grid(11, 5, 11);
9010 let config = TableDetectionConfig::default();
9011 assert!(validate_table_structure_internal(&grid, &config));
9012 }
9013
9014 #[test]
9018 fn validate_accepts_sparse_connected_table() {
9019 let grid = make_uniform_grid(6, 5, 4);
9020 let config = TableDetectionConfig::default();
9021 assert!(validate_table_structure_internal(&grid, &config));
9022 }
9023
9024 #[test]
9034 fn validate_accepts_hierarchical_grouped_table() {
9035 let grid = make_grouped_grid(8, 12, 2, 4);
9036 let config = TableDetectionConfig::default();
9037 assert!(validate_table_structure_internal(&grid, &config));
9038 }
9039
9040 #[test]
9044 fn validate_accepts_three_column_grid() {
9045 let grid = make_uniform_grid(3, 4, 3);
9046 let config = TableDetectionConfig::default();
9047 assert!(validate_table_structure_internal(&grid, &config));
9048 }
9049
9050 fn make_fragment(x: f32, y: f32, width: f32, height: f32, cols: u32) -> Table {
9056 let mut t = Table::new();
9057 t.bbox = Some(Rect::new(x, y, width, height));
9058 t.col_count = cols as usize;
9059 t.rows.push(TableRow::new(false));
9062 t
9063 }
9064
9065 #[test]
9070 fn consolidate_merges_adjacent_aligned_fragments() {
9071 let upper = make_fragment(90.0, 480.0, 420.0, 16.0, 8);
9072 let lower = make_fragment(90.0, 464.0, 420.0, 16.0, 8);
9073 let merged = consolidate_adjacent_table_fragments(vec![upper, lower]);
9075 assert_eq!(merged.len(), 1, "two aligned adjacent fragments must merge");
9076 assert_eq!(merged[0].rows.len(), 2, "merged rows are concatenated");
9077 let bb = merged[0].bbox.expect("merged bbox preserved");
9078 assert!((bb.x - 90.0).abs() < 0.1);
9079 assert!((bb.width - 420.0).abs() < 0.1);
9080 assert!((bb.height - 32.0).abs() < 0.1);
9081 }
9082
9083 #[test]
9087 fn consolidate_does_not_merge_non_adjacent_fragments() {
9088 let upper = make_fragment(90.0, 600.0, 420.0, 16.0, 8);
9089 let lower = make_fragment(90.0, 400.0, 420.0, 16.0, 8); let result = consolidate_adjacent_table_fragments(vec![upper, lower]);
9091 assert_eq!(result.len(), 2, "well-separated fragments stay distinct");
9092 }
9093
9094 #[test]
9100 fn consolidate_with_tol_merges_row_pitch_separated_bands() {
9101 let frag = |x, y, cols, rows: usize| {
9103 let mut t = make_fragment(x, y, 420.0, 16.0, cols);
9104 for _ in 1..rows {
9105 t.rows.push(TableRow::new(false));
9106 }
9107 t
9108 };
9109 let upper = frag(90.0, 480.0, 6, 3);
9111 let lower = frag(90.0, 452.0, 6, 2);
9112 let split = consolidate_adjacent_table_fragments(vec![upper.clone(), lower.clone()]);
9114 assert_eq!(split.len(), 2, "3pt tolerance leaves row-pitch bands split");
9115 let merged = consolidate_adjacent_table_fragments_with_tol(vec![upper, lower], 2.0, 24.0);
9117 assert_eq!(merged.len(), 1, "row-pitch bands rejoin under scaled tolerance");
9118 assert_eq!(merged[0].rows.len(), 5, "[3,2] bands rejoin into a 5-row table");
9119 }
9120
9121 #[test]
9124 fn consolidate_with_tol_still_rejects_far_bands() {
9125 let upper = make_fragment(90.0, 600.0, 420.0, 16.0, 6);
9126 let lower = make_fragment(90.0, 400.0, 420.0, 16.0, 6);
9127 let merged = consolidate_adjacent_table_fragments_with_tol(vec![upper, lower], 2.0, 24.0);
9128 assert_eq!(merged.len(), 2, "a scaled tolerance is not an unbounded merge");
9129 }
9130
9131 #[test]
9134 fn consolidate_does_not_merge_different_col_counts() {
9135 let upper = make_fragment(90.0, 480.0, 420.0, 16.0, 8);
9136 let lower = make_fragment(90.0, 464.0, 420.0, 16.0, 5);
9137 let result = consolidate_adjacent_table_fragments(vec![upper, lower]);
9138 assert_eq!(result.len(), 2, "different col_count blocks merging");
9139 }
9140
9141 #[test]
9144 fn consolidate_does_not_merge_misaligned_x() {
9145 let upper = make_fragment(90.0, 480.0, 420.0, 16.0, 8);
9146 let lower = make_fragment(50.0, 464.0, 420.0, 16.0, 8);
9147 let result = consolidate_adjacent_table_fragments(vec![upper, lower]);
9148 assert_eq!(result.len(), 2, "misaligned-x blocks merging");
9149 }
9150
9151 #[test]
9155 fn consolidate_chains_multiple_adjacent_fragments() {
9156 let f1 = make_fragment(90.0, 480.0, 420.0, 16.0, 8);
9157 let f2 = make_fragment(90.0, 464.0, 420.0, 16.0, 8);
9158 let f3 = make_fragment(90.0, 448.0, 420.0, 16.0, 8);
9159 let f4 = make_fragment(90.0, 432.0, 420.0, 16.0, 8);
9160 let merged = consolidate_adjacent_table_fragments(vec![f1, f2, f3, f4]);
9161 assert_eq!(merged.len(), 1, "chain of 4 adjacent fragments → 1 table");
9162 assert_eq!(merged[0].rows.len(), 4, "all rows preserved in chain merge");
9163 }
9164
9165 #[test]
9168 fn consolidate_tolerates_small_overlap() {
9169 let upper = make_fragment(90.0, 480.0, 420.0, 16.0, 8);
9171 let lower = make_fragment(90.0, 468.0, 420.0, 16.0, 8);
9172 let merged = consolidate_adjacent_table_fragments(vec![upper, lower]);
9173 assert_eq!(merged.len(), 1, "small overlap (< ½ row) still merges");
9174 }
9175
9176 #[test]
9178 fn consolidate_empty_input() {
9179 let merged = consolidate_adjacent_table_fragments(vec![]);
9180 assert!(merged.is_empty());
9181 }
9182
9183 #[test]
9185 fn consolidate_single_table_passthrough() {
9186 let t = make_fragment(90.0, 480.0, 420.0, 16.0, 8);
9187 let merged = consolidate_adjacent_table_fragments(vec![t]);
9188 assert_eq!(merged.len(), 1);
9189 }
9190
9191 fn ts(text: &str, x: f32, y: f32, width: f32, fs: f32) -> TextSpan {
9198 TextSpan {
9199 provenance: None,
9200 text_rise: 0.0,
9201 artifact_type: None,
9202 text: text.to_string(),
9203 bbox: Rect::new(x, y, width, fs),
9204 font_size: fs,
9205 font_name: String::new(),
9206 font_weight: crate::layout::text_block::FontWeight::Normal,
9207 color: Color::black(),
9208 mcid: None,
9209 mcid_scope: None,
9210 sequence: 0,
9211 split_boundary_before: false,
9212 offset_semantic: false,
9213 is_italic: false,
9214 is_monospace: false,
9215 char_spacing: 0.0,
9216 word_spacing: 0.0,
9217 horizontal_scaling: 100.0,
9218 primary_detected: false,
9219 char_widths: vec![],
9220 char_x_offsets: Vec::new(),
9221 heading_level: None,
9222 rotation_degrees: 0.0,
9223 wmode: 0,
9224 rtl_draw_logical: false,
9225 mirrored: false,
9226 page_rotation_applied: 0,
9227 }
9228 }
9229
9230 #[test]
9235 fn cell_span_separator_no_space_for_tight_glyphs() {
9236 let prev = ts("60000≤Q", 100.0, 200.0, 39.8, 10.6);
9237 let curr = ts("<", 139.8, 200.0, 10.6, 10.6);
9238 assert_eq!(cell_span_separator(&prev, &curr), "");
9239 }
9240
9241 #[test]
9244 fn cell_span_separator_inserts_space_for_real_gap() {
9245 let prev = ts("Quarter", 100.0, 200.0, 30.0, 10.0);
9246 let curr = ts("Total", 140.0, 200.0, 25.0, 10.0); assert_eq!(cell_span_separator(&prev, &curr), " ");
9248 }
9249
9250 #[test]
9253 fn cell_span_separator_suppresses_cjk_fullwidth_boundary() {
9254 let prev = ts("中", 100.0, 200.0, 12.0, 12.0);
9255 let curr = ts("<", 115.0, 200.0, 12.0, 12.0); assert_eq!(cell_span_separator(&prev, &curr), "");
9257 }
9258
9259 #[test]
9262 fn cell_span_separator_suppresses_on_existing_trailing_space() {
9263 let prev = ts("Quarter ", 100.0, 200.0, 35.0, 10.0);
9264 let curr = ts("Total", 140.0, 200.0, 25.0, 10.0);
9265 assert_eq!(cell_span_separator(&prev, &curr), "");
9266 }
9267
9268 #[test]
9274 fn cell_text_keeps_a_source_space_between_abutting_words() {
9275 let spans = vec![
9276 ts("corrispettivi", 100.0, 200.0, 37.7, 7.83),
9277 ts(" superiori", 137.0, 200.0, 27.6, 7.83),
9278 ];
9279 assert_eq!(extract_cell_text(&[0, 1], &spans), "corrispettivi superiori");
9280 }
9281
9282 #[test]
9285 fn cell_text_keeps_numeric_separator_group_attached() {
9286 for separator in ['.', ',', '\u{066B}', '\u{066C}'] {
9287 let suffix = format!("{separator}804");
9288 let spans = vec![
9289 ts("59", 100.0, 200.0, 10.0, 10.0),
9290 ts(&suffix, 113.0, 200.0, 20.0, 10.0),
9291 ];
9292
9293 assert_eq!(extract_cell_text(&[0, 1], &spans), format!("59{separator}804"));
9294 }
9295 }
9296
9297 #[test]
9298 fn cell_text_does_not_attach_sentence_punctuation() {
9299 let spans = vec![
9300 ts("Section", 100.0, 200.0, 10.0, 10.0),
9301 ts(".", 113.0, 200.0, 20.0, 10.0),
9302 ];
9303
9304 assert_eq!(extract_cell_text(&[0, 1], &spans), "Section .");
9305 }
9306
9307 #[test]
9311 fn cell_text_trims_a_source_space_prefix_at_the_cell_start() {
9312 let spans = vec![ts(" 400", 100.0, 200.0, 20.0, 7.83)];
9313 assert_eq!(extract_cell_text(&[0], &spans), "400");
9314 }
9315
9316 #[test]
9324 fn split_rows_by_text_positions_keeps_drawn_band_as_one_row_when_only_one_cell_wraps() {
9325 let spans = vec![
9326 create_test_span("1", 90.2, 288.7, 5.0, 0.0),
9327 create_test_span("Versterking van het", 118.6, 294.7, 60.0, 0.0),
9328 create_test_span("MKB-segment", 118.6, 282.7, 40.0, 0.0),
9329 create_test_span("Ontwikkel gerichte campagnes", 246.1, 300.7, 90.0, 0.0),
9330 create_test_span("een vereenvoudigde waardepropositie", 246.1, 288.7, 100.0, 0.0),
9331 create_test_span("om de conversie te verhogen.", 246.1, 276.7, 90.0, 0.0),
9332 ];
9333
9334 let row = TableRow {
9335 cells: vec![
9336 prose_cell("1"),
9337 prose_cell("Versterking van het MKB-segment"),
9338 prose_cell(
9339 "Ontwikkel gerichte campagnes een vereenvoudigde waardepropositie \
9340 om de conversie te verhogen.",
9341 ),
9342 ],
9343 is_header: false,
9344 };
9345
9346 let row_cell_span_indices = vec![vec![vec![0], vec![1, 2], vec![3, 4, 5]]];
9347 let config = TableDetectionConfig::strict();
9348
9349 let result = split_rows_by_text_positions(vec![row], &row_cell_span_indices, &spans, &config);
9350
9351 assert_eq!(
9352 result.len(),
9353 1,
9354 "one wrapped cell must not fracture the drawn band into extra rows"
9355 );
9356 assert_eq!(result[0].cells[0].text, "1");
9357 assert_eq!(result[0].cells[1].text, "Versterking van het MKB-segment");
9358 }
9359
9360 #[test]
9361 fn split_rows_by_text_positions_keeps_single_column_split_behavior() {
9362 let spans = vec![
9363 create_test_span("Label", 10.0, 200.0, 30.0, 0.0),
9364 create_test_span("Value", 10.0, 180.0, 30.0, 0.0),
9365 ];
9366 let row = TableRow {
9367 cells: vec![prose_cell("Label Value")],
9368 is_header: false,
9369 };
9370 let row_cell_span_indices = vec![vec![vec![0, 1]]];
9371
9372 let result = split_rows_by_text_positions(
9373 vec![row],
9374 &row_cell_span_indices,
9375 &spans,
9376 &TableDetectionConfig::strict(),
9377 );
9378
9379 assert_eq!(
9380 result.len(),
9381 2,
9382 "the two-column evidence floor must be capped for one-column bands"
9383 );
9384 assert_eq!(result[0].cells[0].text, "Label");
9385 assert_eq!(result[1].cells[0].text, "Value");
9386 }
9387
9388 #[test]
9393 fn wrapped_drawn_bands_remain_a_real_four_row_grid() {
9394 let mut spans = vec![
9395 create_test_span("#", 90.2, 330.0, 5.0, 0.0),
9396 create_test_span("Aanbeveling", 118.6, 330.0, 60.0, 0.0),
9397 create_test_span("Toelichting", 246.1, 330.0, 90.0, 0.0),
9398 ];
9399 let mut rows = vec![TableRow {
9400 cells: vec![prose_cell("#"), prose_cell("Aanbeveling"), prose_cell("Toelichting")],
9401 is_header: true,
9402 }];
9403 let mut row_cell_span_indices = vec![vec![vec![0], vec![1], vec![2]]];
9404
9405 for (row_number, baseline) in [(1, 288.7), (2, 242.7), (3, 196.7)] {
9406 let start = spans.len();
9407 spans.extend([
9408 create_test_span(&row_number.to_string(), 90.2, baseline, 5.0, 0.0),
9409 create_test_span("Versterking van het", 118.6, baseline + 6.0, 60.0, 0.0),
9410 create_test_span("MKB-segment", 118.6, baseline - 6.0, 40.0, 0.0),
9411 create_test_span("Ontwikkel gerichte campagnes", 246.1, baseline + 12.0, 90.0, 0.0),
9412 create_test_span("een vereenvoudigde waardepropositie", 246.1, baseline, 100.0, 0.0),
9413 create_test_span("om de conversie te verhogen.", 246.1, baseline - 12.0, 90.0, 0.0),
9414 ]);
9415 rows.push(TableRow {
9416 cells: vec![
9417 prose_cell(&row_number.to_string()),
9418 prose_cell("Versterking van het MKB-segment"),
9419 prose_cell(
9420 "Ontwikkel gerichte campagnes een vereenvoudigde waardepropositie \
9421 om de conversie te verhogen.",
9422 ),
9423 ],
9424 is_header: false,
9425 });
9426 row_cell_span_indices.push(vec![
9427 vec![start],
9428 vec![start + 1, start + 2],
9429 vec![start + 3, start + 4, start + 5],
9430 ]);
9431 }
9432
9433 let rows = split_rows_by_text_positions(rows, &row_cell_span_indices, &spans, &TableDetectionConfig::strict());
9434 let table = Table {
9435 rows,
9436 has_header: true,
9437 col_count: 3,
9438 bbox: None,
9439 };
9440
9441 assert_eq!(table.rows.len(), 4, "four producer-drawn bands must remain four rows");
9442 assert!(
9443 table.is_real_grid(),
9444 "the repaired table must survive the downstream real-grid gate"
9445 );
9446 assert_eq!(table.rows[1].cells[0].text, "1");
9447 assert_eq!(table.rows[1].cells[1].text, "Versterking van het MKB-segment");
9448 assert_eq!(table.rows[3].cells[0].text, "3");
9449 assert_eq!(table.rows[3].cells[1].text, "Versterking van het MKB-segment");
9450 }
9451
9452 #[test]
9458 fn split_rows_by_text_positions_splits_genuine_multi_row_band_with_two_column_evidence() {
9459 let spans = vec![
9460 create_test_span("Alice", 10.0, 200.0, 30.0, 0.0),
9461 create_test_span("30", 60.0, 200.0, 10.0, 0.0),
9462 create_test_span("Bob", 10.0, 180.0, 30.0, 0.0),
9463 create_test_span("25", 60.0, 180.0, 10.0, 0.0),
9464 ];
9465
9466 let row = TableRow {
9467 cells: vec![prose_cell("Alice Bob"), prose_cell("30 25")],
9468 is_header: false,
9469 };
9470
9471 let row_cell_span_indices = vec![vec![vec![0, 2], vec![1, 3]]];
9472 let config = TableDetectionConfig::strict();
9473
9474 let result = split_rows_by_text_positions(vec![row], &row_cell_span_indices, &spans, &config);
9475
9476 assert_eq!(result.len(), 2, "two genuinely distinct rows must survive the split");
9477 assert_eq!(result[0].cells[0].text, "Alice");
9478 assert_eq!(result[0].cells[1].text, "30");
9479 assert_eq!(result[1].cells[0].text, "Bob");
9480 assert_eq!(result[1].cells[1].text, "25");
9481 }
9482
9483 #[test]
9491 fn split_rows_by_text_positions_keeps_heading_line_separate_amid_multi_column_rows() {
9492 let spans = vec![
9493 create_test_span("Mogelijke oorzaken:", 10.0, 220.0, 100.0, 0.0),
9494 create_test_span("Symptom A", 10.0, 200.0, 40.0, 0.0),
9495 create_test_span("Cause A", 60.0, 200.0, 40.0, 0.0),
9496 create_test_span("Symptom B", 10.0, 180.0, 40.0, 0.0),
9497 create_test_span("Cause B", 60.0, 180.0, 40.0, 0.0),
9498 ];
9499
9500 let row = TableRow {
9501 cells: vec![
9502 prose_cell("Mogelijke oorzaken: Symptom A Symptom B"),
9503 prose_cell("Cause A Cause B"),
9504 ],
9505 is_header: false,
9506 };
9507
9508 let row_cell_span_indices = vec![vec![vec![0, 1, 3], vec![2, 4]]];
9509 let config = TableDetectionConfig::strict();
9510
9511 let result = split_rows_by_text_positions(vec![row], &row_cell_span_indices, &spans, &config);
9512
9513 assert_eq!(
9514 result.len(),
9515 3,
9516 "the heading and both genuine data rows must each survive as their own row"
9517 );
9518 assert_eq!(result[0].cells[0].text, "Mogelijke oorzaken:");
9519 assert_eq!(
9520 result[0].cells[1].text, "",
9521 "the heading must not be fused into a data row's cell text"
9522 );
9523 assert_eq!(result[1].cells[0].text, "Symptom A");
9524 assert_eq!(result[1].cells[1].text, "Cause A");
9525 assert_eq!(result[2].cells[0].text, "Symptom B");
9526 assert_eq!(result[2].cells[1].text, "Cause B");
9527 }
9528
9529 #[test]
9541 fn finalize_intersection_tables_deletes_lone_digit_lead_in_row() {
9542 let spans = vec![
9543 create_test_span("5", 10.0, 220.0, 5.0, 0.0),
9544 create_test_span("Symptom A", 10.0, 200.0, 40.0, 0.0),
9545 create_test_span("Cause A", 60.0, 200.0, 40.0, 0.0),
9546 create_test_span("Symptom B", 10.0, 180.0, 40.0, 0.0),
9547 create_test_span("Cause B", 60.0, 180.0, 40.0, 0.0),
9548 ];
9549
9550 let row = TableRow {
9551 cells: vec![prose_cell("5 Symptom A Symptom B"), prose_cell("Cause A Cause B")],
9552 is_header: false,
9553 };
9554
9555 let row_cell_span_indices = vec![vec![vec![0, 1, 3], vec![2, 4]]];
9556 let config = TableDetectionConfig {
9557 min_table_cells: 1,
9558 ..TableDetectionConfig::strict()
9559 };
9560
9561 let split_only = split_rows_by_text_positions(vec![row.clone()], &row_cell_span_indices, &spans, &config);
9566 assert_eq!(
9567 split_only.len(),
9568 3,
9569 "checkpoint: split must isolate the lead-in as its own row"
9570 );
9571 assert_eq!(split_only[0].cells[0].text, "5");
9572 assert_eq!(split_only[0].cells[1].text, "");
9573
9574 let tables = finalize_intersection_tables(vec![row], &row_cell_span_indices, &spans, &config, 2);
9575
9576 assert_eq!(tables.len(), 1, "the two evidenced data rows must still form one table");
9577 let surviving_rows: Vec<Vec<&str>> = tables[0]
9578 .rows
9579 .iter()
9580 .map(|r| r.cells.iter().map(|c| c.text.as_str()).collect())
9581 .collect();
9582
9583 assert_eq!(
9592 surviving_rows,
9593 vec![vec!["Symptom A", "Cause A"], vec!["Symptom B", "Cause B"]],
9594 "lone-digit lead-in row is removed by Phase 1, same as a pre-existing standalone artifact row"
9595 );
9596 }
9597
9598 #[test]
9607 fn split_rows_by_text_positions_fold_chain_does_not_fabricate_column_evidence() {
9608 let spans = vec![
9609 create_test_span("Alice", 10.0, 180.0, 30.0, 0.0),
9611 create_test_span("30", 60.0, 180.0, 10.0, 0.0),
9612 create_test_span("continued", 10.0, 200.0, 40.0, 0.0),
9615 create_test_span("Bob", 10.0, 220.0, 30.0, 0.0),
9617 create_test_span("25", 60.0, 220.0, 10.0, 0.0),
9618 ];
9619
9620 let row = TableRow {
9621 cells: vec![prose_cell("Alice continued Bob"), prose_cell("30 25")],
9622 is_header: false,
9623 };
9624
9625 let row_cell_span_indices = vec![vec![vec![0, 2, 3], vec![1, 4]]];
9626 let config = TableDetectionConfig::strict();
9627
9628 let result = split_rows_by_text_positions(vec![row], &row_cell_span_indices, &spans, &config);
9629
9630 assert_eq!(
9631 result.len(),
9632 2,
9633 "the deficient middle cluster must fold, not survive alone"
9634 );
9635 assert_eq!(result[0].cells[0].text, "Bob\ncontinued");
9638 assert_eq!(
9639 result[0].cells[1].text, "25",
9640 "folding a column-0-only cluster must not fabricate evidence in column 1"
9641 );
9642 assert_eq!(result[1].cells[0].text, "Alice");
9643 assert_eq!(result[1].cells[1].text, "30");
9644 }
9645
9646 #[test]
9652 fn split_rows_by_text_positions_fold_chain_drops_no_span_and_duplicates_none() {
9653 let spans = vec![
9654 create_test_span("Alice", 10.0, 180.0, 30.0, 0.0),
9655 create_test_span("30", 60.0, 180.0, 10.0, 0.0),
9656 create_test_span("continued", 10.0, 200.0, 40.0, 0.0),
9657 create_test_span("Bob", 10.0, 220.0, 30.0, 0.0),
9658 create_test_span("25", 60.0, 220.0, 10.0, 0.0),
9659 ];
9660
9661 let row = TableRow {
9662 cells: vec![prose_cell("Alice continued Bob"), prose_cell("30 25")],
9663 is_header: false,
9664 };
9665
9666 let row_cell_span_indices = vec![vec![vec![0, 2, 3], vec![1, 4]]];
9667 let config = TableDetectionConfig::strict();
9668
9669 let result = split_rows_by_text_positions(vec![row], &row_cell_span_indices, &spans, &config);
9670
9671 let mut seen_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
9672 for r in &result {
9673 for c in &r.cells {
9674 for word in c.text.split_whitespace() {
9675 *seen_counts.entry(word).or_insert(0) += 1;
9676 }
9677 }
9678 }
9679 for expected in ["Alice", "30", "continued", "Bob", "25"] {
9680 assert_eq!(
9681 seen_counts.get(expected).copied().unwrap_or(0),
9682 1,
9683 "span text {expected:?} must appear exactly once across all output cells, got {:?}",
9684 seen_counts.get(expected)
9685 );
9686 }
9687 }
9688}