1use serde::{Deserialize, Serialize};
11
12use crate::analytics::statistic::{FinalizationContext, Statistic};
13use crate::types::{BoundingBox, PdfTextElement};
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26pub struct GeometryStats {
27 pub header_y: f32,
29
30 pub doc_footer_y: f32,
35
36 pub left_x: f32,
38 pub right_x: f32,
39
40 pub per_page_footer_y: Vec<Option<f32>>,
47
48 pub source_pages: u32,
51
52 pub page_dimensions: PageDimensions,
54
55 pub column_layout: ColumnLayout,
57
58 pub heatmap: DensityGrid,
62
63 pub diagnostic: GeometryDiagnostic,
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct DensityGrid {
80 pub cell_size: u32,
82 pub cols: u32,
84 pub rows: u32,
86 pub cells: Vec<u16>,
88}
89
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct ColumnLayout {
92 pub column_count: u32,
95
96 pub column_dividers: Vec<f32>,
99}
100
101#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
102pub struct PageDimensions {
103 pub width: f32,
104 pub height: f32,
105}
106
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct GeometryDiagnostic {
109 pub heatmap_max: u32,
112 pub header_reason: String,
116 pub doc_footer_reason: String,
117 pub left_margin_reason: String,
118 pub right_margin_reason: String,
119 pub column_peak: f32,
122 pub column_drop_threshold: f32,
123 pub column_high_threshold: f32,
124}
125
126#[derive(Debug, Clone)]
137pub struct GeometryStatsConfig {
138 pub page_analysis_count: usize,
145
146 pub min_gap_rows: usize,
151
152 pub min_gap_cols: usize,
157
158 pub max_footer_extent: usize,
163
164 pub per_page_tolerance: f32,
170
171 pub min_token_size: f32,
177
178 pub column_drop_ratio: f32,
184
185 pub column_min_drop_cols: usize,
189
190 pub column_high_ratio: f32,
198
199 pub heatmap_cell_size: u32,
207}
208
209impl Default for GeometryStatsConfig {
210 fn default() -> Self {
211 Self {
212 page_analysis_count: 10,
213 min_gap_rows: 15,
214 min_gap_cols: 35,
215 max_footer_extent: 50,
216 per_page_tolerance: 1.0,
217 min_token_size: 1.0,
218 column_drop_ratio: 0.10,
219 column_min_drop_cols: 8,
220 column_high_ratio: 0.50,
221 heatmap_cell_size: 8,
222 }
223 }
224}
225
226#[derive(Debug, Default)]
235struct PageAccumulator {
236 page_number: u32,
237 width: f32,
238 height: f32,
239 bboxes: Vec<BoundingBox>,
241 tokens: Vec<TokenForGeometry>,
244}
245
246#[derive(Debug, Clone)]
247struct TokenForGeometry {
248 bbox: BoundingBox,
249 font_size: f32,
250}
251
252#[derive(Debug, Default)]
258pub struct GeometryStatsBuilder {
259 config: GeometryStatsConfig,
260 pages: Vec<PageAccumulator>,
263}
264
265impl GeometryStatsBuilder {
266 pub fn new(config: GeometryStatsConfig) -> Self {
270 Self {
271 config,
272 pages: Vec::new(),
273 }
274 }
275
276 fn page_slot(&mut self, element: &PdfTextElement) -> Option<usize> {
279 let page_number = element.page_number();
280 if let Some(idx) = self.pages.iter().position(|p| p.page_number == page_number) {
281 return Some(idx);
282 }
283 if self.pages.len() >= self.config.page_analysis_count {
284 return None;
285 }
286 let bbox = element.bounding_box();
287 let _ = bbox;
293 self.pages.push(PageAccumulator {
294 page_number,
295 width: 0.0,
296 height: 0.0,
297 bboxes: Vec::new(),
298 tokens: Vec::new(),
299 });
300 Some(self.pages.len() - 1)
301 }
302}
303
304impl Statistic for GeometryStatsBuilder {
305 type Output = GeometryStats;
306 const NAME: &'static str = "geometry";
307
308 fn observe(&mut self, element: &PdfTextElement) {
309 if element.rotation() != 0 {
310 return;
311 }
312 let bbox = element.bounding_box().clone();
313 let font_size = element.style_info.font_size;
314 let text = &element.text;
315 let page_w = element.placement.page_width;
316 let page_h = element.placement.page_height;
317
318 let Some(idx) = self.page_slot(element) else {
319 return;
320 };
321 let page = &mut self.pages[idx];
322
323 if page_w > 0.0 {
332 page.width = page_w;
333 } else {
334 let right = bbox.x + bbox.width;
335 if right > page.width {
336 page.width = right;
337 }
338 }
339 if page_h > 0.0 {
340 page.height = page_h;
341 } else {
342 let bottom = bbox.y + bbox.height;
343 if bottom > page.height {
344 page.height = bottom;
345 }
346 }
347
348 page.bboxes.push(bbox.clone());
350
351 if font_size >= self.config.min_token_size && !text.trim().is_empty() {
353 page.tokens.push(TokenForGeometry { bbox, font_size });
354 }
355 }
356
357 fn finalize(self, ctx: &FinalizationContext<'_>) -> Self::Output {
358 let doc_body_size = ctx.font.and_then(|f| {
364 if f.font_size_counts.is_empty() {
365 None
366 } else {
367 Some(f.most_common_font_size)
368 }
369 });
370 finalize_geometry(self.pages, &self.config, doc_body_size)
371 }
372}
373
374fn finalize_geometry(
379 pages: Vec<PageAccumulator>,
380 config: &GeometryStatsConfig,
381 doc_body_size: Option<f32>,
382) -> GeometryStats {
383 if pages.is_empty() {
384 return GeometryStats::default();
385 }
386
387 let (heatmap, width, height) = build_heatmap(&pages);
388 let n_pages = pages.len() as u32;
389
390 let header = find_header_line(&heatmap, height, config.min_gap_rows);
391 let footer = find_footer_line(
392 &heatmap,
393 height,
394 config.min_gap_rows,
395 config.max_footer_extent,
396 );
397 let body_y_start = header.line.min(footer.line);
398 let body_y_end = header.line.max(footer.line);
399 let left = find_left_margin(
400 &heatmap,
401 width,
402 config.min_gap_cols,
403 body_y_start,
404 body_y_end,
405 );
406 let right = find_right_margin(
407 &heatmap,
408 width,
409 config.min_gap_cols,
410 body_y_start,
411 body_y_end,
412 );
413
414 let column_layout_result = find_column_layout(
415 &heatmap,
416 header.line,
417 footer.line,
418 left.line,
419 right.line,
420 config.column_drop_ratio,
421 config.column_min_drop_cols,
422 config.column_high_ratio,
423 );
424
425 let per_page_footer_y = pages
426 .iter()
427 .map(|p| {
428 find_per_page_footer_line(
429 p,
430 footer.line,
431 config.per_page_tolerance,
432 config.min_token_size,
433 doc_body_size,
434 )
435 })
436 .collect();
437
438 let heatmap_max = heatmap
439 .iter()
440 .flat_map(|row| row.iter().copied())
441 .max()
442 .unwrap_or(0);
443
444 let density_grid =
445 downsample_to_density_grid(&heatmap, width, height, config.heatmap_cell_size);
446
447 GeometryStats {
448 header_y: header.line as f32,
449 doc_footer_y: footer.line as f32,
450 left_x: left.line as f32,
451 right_x: right.line as f32,
452 per_page_footer_y,
453 source_pages: n_pages,
454 page_dimensions: PageDimensions {
455 width: width as f32,
456 height: height as f32,
457 },
458 column_layout: ColumnLayout {
459 column_count: column_layout_result.column_count,
460 column_dividers: column_layout_result.column_dividers,
461 },
462 heatmap: density_grid,
463 diagnostic: GeometryDiagnostic {
464 heatmap_max,
465 header_reason: header.reason,
466 doc_footer_reason: footer.reason,
467 left_margin_reason: left.reason,
468 right_margin_reason: right.reason,
469 column_peak: column_layout_result.peak,
470 column_drop_threshold: column_layout_result.drop_threshold,
471 column_high_threshold: column_layout_result.high_threshold,
472 },
473 }
474}
475
476fn build_heatmap(pages: &[PageAccumulator]) -> (Vec<Vec<u32>>, usize, usize) {
484 let max_w = pages
485 .iter()
486 .map(|p| p.width.ceil() as usize)
487 .max()
488 .unwrap_or(0);
489 let max_h = pages
490 .iter()
491 .map(|p| p.height.ceil() as usize)
492 .max()
493 .unwrap_or(0);
494
495 if max_w == 0 || max_h == 0 {
496 return (vec![vec![0; max_w.max(1)]; max_h.max(1)], max_w, max_h);
497 }
498
499 let mut heatmap = vec![vec![0u32; max_w]; max_h];
500
501 for page in pages {
502 let mut mask = vec![false; max_w * max_h];
504 for bbox in &page.bboxes {
505 let xa = clamp_usize(bbox.x.floor() as i64, 0, max_w as i64);
506 let xb = clamp_usize((bbox.x + bbox.width).ceil() as i64, 0, max_w as i64);
507 let ya = clamp_usize(bbox.y.floor() as i64, 0, max_h as i64);
508 let yb = clamp_usize((bbox.y + bbox.height).ceil() as i64, 0, max_h as i64);
509 if xb > xa && yb > ya {
510 for y in ya..yb {
511 let row = y * max_w;
512 mask[row + xa..row + xb].fill(true);
513 }
514 }
515 }
516 for (y, row) in heatmap.iter_mut().enumerate().take(max_h) {
517 let base = y * max_w;
518 for (x, cell) in row.iter_mut().enumerate().take(max_w) {
519 if mask[base + x] {
520 *cell += 1;
521 }
522 }
523 }
524 }
525
526 (heatmap, max_w, max_h)
527}
528
529fn clamp_usize(v: i64, lo: i64, hi: i64) -> usize {
530 v.max(lo).min(hi) as usize
531}
532
533struct WalkResult {
538 line: usize,
539 reason: String,
540}
541
542fn find_header_line(heatmap: &[Vec<u32>], height: usize, min_gap_rows: usize) -> WalkResult {
547 if height == 0 {
548 return WalkResult {
549 line: 0,
550 reason: "empty-heatmap".to_string(),
551 };
552 }
553 let middle = height / 2;
554 let sum_per_row = sum_rows(heatmap);
555
556 let mut gap_bottom: Option<usize> = None;
557 let mut gap_length: usize = 0;
558 let mut y = middle;
559 loop {
560 if sum_per_row[y] > 0 {
561 gap_bottom = None;
562 gap_length = 0;
563 } else {
564 if gap_bottom.is_none() {
565 gap_bottom = Some(y);
566 }
567 gap_length += 1;
568 if gap_length >= min_gap_rows {
569 return WalkResult {
570 line: gap_bottom.unwrap(),
571 reason: "found-significant-gap".to_string(),
572 };
573 }
574 }
575 if y == 0 {
576 break;
577 }
578 y -= 1;
579 }
580 WalkResult {
581 line: 0,
582 reason: "no-significant-gap-found".to_string(),
583 }
584}
585
586fn find_footer_line(
591 heatmap: &[Vec<u32>],
592 height: usize,
593 min_gap_rows: usize,
594 max_footer_extent: usize,
595) -> WalkResult {
596 if height == 0 {
597 return WalkResult {
598 line: 0,
599 reason: "empty-heatmap".to_string(),
600 };
601 }
602 let middle = height / 2;
603 let sum_per_row = sum_rows(heatmap);
604
605 let mut y = middle;
606 while y < height {
607 while y < height && sum_per_row[y] > 0 {
609 y += 1;
610 }
611 if y >= height {
612 return WalkResult {
613 line: height.saturating_sub(1),
614 reason: "no-gap-found".to_string(),
615 };
616 }
617
618 let gap_top = y;
620 while y < height && sum_per_row[y] == 0 {
621 y += 1;
622 }
623 let gap_length = y - gap_top;
624
625 if y >= height {
626 return WalkResult {
628 line: gap_top,
629 reason: "gap-to-page-bottom".to_string(),
630 };
631 }
632
633 let content_top = y;
635 while y < height && sum_per_row[y] > 0 {
636 y += 1;
637 }
638 let content_extent = y - content_top;
639
640 if content_extent <= max_footer_extent {
641 let tail_start = y;
643 while y < height && sum_per_row[y] == 0 {
644 y += 1;
645 }
646 let tail_length = y - tail_start;
647 if y >= height || tail_length >= min_gap_rows {
648 return WalkResult {
649 line: gap_top,
650 reason: "found-chrome-then-tail".to_string(),
651 };
652 }
653 } else if gap_length >= min_gap_rows {
656 return WalkResult {
659 line: gap_top,
660 reason: "found-significant-gap".to_string(),
661 };
662 }
663 }
666
667 WalkResult {
668 line: height.saturating_sub(1),
669 reason: "no-gap-found".to_string(),
670 }
671}
672
673fn find_left_margin(
678 heatmap: &[Vec<u32>],
679 width: usize,
680 min_gap_cols: usize,
681 body_y_start: usize,
682 body_y_end: usize,
683) -> WalkResult {
684 if width == 0 {
685 return WalkResult {
686 line: 0,
687 reason: "empty-heatmap".to_string(),
688 };
689 }
690 let middle = width / 2;
691 let sum_per_col = sum_cols_in_y_range(heatmap, body_y_start, body_y_end, width);
692
693 let mut gap_right_edge: Option<usize> = None;
694 let mut gap_length: usize = 0;
695 let mut x = middle;
696 loop {
697 if sum_per_col[x] > 0 {
698 gap_right_edge = None;
699 gap_length = 0;
700 } else {
701 if gap_right_edge.is_none() {
702 gap_right_edge = Some(x);
703 }
704 gap_length += 1;
705 if gap_length >= min_gap_cols {
706 return WalkResult {
707 line: gap_right_edge.unwrap(),
708 reason: "found-significant-gap".to_string(),
709 };
710 }
711 }
712 if x == 0 {
713 break;
714 }
715 x -= 1;
716 }
717 WalkResult {
718 line: 0,
719 reason: "no-significant-gap-found".to_string(),
720 }
721}
722
723fn find_right_margin(
726 heatmap: &[Vec<u32>],
727 width: usize,
728 min_gap_cols: usize,
729 body_y_start: usize,
730 body_y_end: usize,
731) -> WalkResult {
732 if width == 0 {
733 return WalkResult {
734 line: 0,
735 reason: "empty-heatmap".to_string(),
736 };
737 }
738 let middle = width / 2;
739 let sum_per_col = sum_cols_in_y_range(heatmap, body_y_start, body_y_end, width);
740
741 let mut gap_left_edge: Option<usize> = None;
742 let mut gap_length: usize = 0;
743 let mut x = middle;
744 while x < width {
745 if sum_per_col[x] > 0 {
746 gap_left_edge = None;
747 gap_length = 0;
748 } else {
749 if gap_left_edge.is_none() {
750 gap_left_edge = Some(x);
751 }
752 gap_length += 1;
753 if gap_length >= min_gap_cols {
754 return WalkResult {
755 line: gap_left_edge.unwrap(),
756 reason: "found-significant-gap".to_string(),
757 };
758 }
759 }
760 x += 1;
761 }
762 WalkResult {
763 line: width.saturating_sub(1),
764 reason: "no-significant-gap-found".to_string(),
765 }
766}
767
768fn sum_rows(heatmap: &[Vec<u32>]) -> Vec<u64> {
769 heatmap
770 .iter()
771 .map(|row| row.iter().map(|&v| v as u64).sum())
772 .collect()
773}
774
775fn sum_cols_in_y_range(
776 heatmap: &[Vec<u32>],
777 y_start: usize,
778 y_end: usize,
779 width: usize,
780) -> Vec<u64> {
781 let mut sums = vec![0u64; width];
782 let height = heatmap.len();
783 let lo = y_start.min(height);
784 let hi = y_end.min(height);
785 for row in &heatmap[lo..hi] {
786 for (x, &v) in row.iter().enumerate().take(width) {
787 sums[x] += v as u64;
788 }
789 }
790 sums
791}
792
793struct ColumnLayoutResult {
798 column_count: u32,
799 column_dividers: Vec<f32>,
800 peak: f32,
801 drop_threshold: f32,
802 high_threshold: f32,
803}
804
805#[allow(clippy::too_many_arguments)]
809fn find_column_layout(
810 heatmap: &[Vec<u32>],
811 header_y: usize,
812 doc_footer_y: usize,
813 left_x: usize,
814 right_x: usize,
815 drop_ratio: f32,
816 min_drop_cols: usize,
817 high_ratio: f32,
818) -> ColumnLayoutResult {
819 if right_x <= left_x || heatmap.is_empty() || heatmap[0].is_empty() {
820 return ColumnLayoutResult {
821 column_count: 1,
822 column_dividers: Vec::new(),
823 peak: 0.0,
824 drop_threshold: 0.0,
825 high_threshold: 0.0,
826 };
827 }
828
829 let width = heatmap[0].len();
830 let sum_per_col = sum_cols_in_y_range(heatmap, header_y, doc_footer_y, width);
831
832 let body_lo = left_x.min(width);
833 let body_hi = (right_x + 1).min(width);
834 if body_hi <= body_lo {
835 return ColumnLayoutResult {
836 column_count: 1,
837 column_dividers: Vec::new(),
838 peak: 0.0,
839 drop_threshold: 0.0,
840 high_threshold: 0.0,
841 };
842 }
843 let peak = sum_per_col[body_lo..body_hi]
844 .iter()
845 .copied()
846 .max()
847 .unwrap_or(0) as f32;
848 let drop_threshold = drop_ratio * peak;
849 let high_threshold = high_ratio * peak;
850
851 let mut dividers: Vec<f32> = Vec::new();
852 let mut in_drop = false;
853 let mut drop_start: Option<usize> = None;
854
855 let close_drop = |start: usize, end_exclusive: usize, dividers: &mut Vec<f32>| {
856 let drop_end = end_exclusive.saturating_sub(1);
857 let drop_length = end_exclusive.saturating_sub(start);
858 if drop_length < min_drop_cols {
859 return;
860 }
861 let left_ok = start > left_x && (sum_per_col[start - 1] as f32) >= high_threshold;
862 let right_ok = drop_end < right_x && (sum_per_col[drop_end + 1] as f32) >= high_threshold;
863 if left_ok && right_ok {
864 dividers.push((start as f32 + drop_end as f32) / 2.0);
865 }
866 };
867
868 let scan_hi = right_x.min(width.saturating_sub(1));
869 for (x, &v) in sum_per_col
870 .iter()
871 .enumerate()
872 .take(scan_hi + 1)
873 .skip(left_x)
874 {
875 if (v as f32) < drop_threshold {
876 if !in_drop {
877 drop_start = Some(x);
878 in_drop = true;
879 }
880 } else if in_drop {
881 if let Some(start) = drop_start {
882 close_drop(start, x, &mut dividers);
883 }
884 in_drop = false;
885 drop_start = None;
886 }
887 }
888 if in_drop {
889 if let Some(start) = drop_start {
890 close_drop(start, right_x + 1, &mut dividers);
891 }
892 }
893
894 ColumnLayoutResult {
895 column_count: (dividers.len() as u32) + 1,
896 column_dividers: dividers,
897 peak,
898 drop_threshold,
899 high_threshold,
900 }
901}
902
903fn find_per_page_footer_line(
921 page: &PageAccumulator,
922 doc_footer_y: usize,
923 tolerance: f32,
924 min_token_size: f32,
925 doc_body_size: Option<f32>,
926) -> Option<f32> {
927 let elements: Vec<&TokenForGeometry> = page
928 .tokens
929 .iter()
930 .filter(|t| t.font_size >= min_token_size)
931 .collect();
932 if elements.is_empty() {
933 return None;
934 }
935
936 let height = page.height.ceil() as usize;
937 if height == 0 {
938 return None;
939 }
940 let middle = height / 2;
941
942 let body_size = doc_body_size.unwrap_or_else(|| {
945 let mut sizes: Vec<f32> = elements.iter().map(|t| t.font_size).collect();
946 sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
947 if sizes.len() % 2 == 1 {
948 sizes[sizes.len() / 2]
949 } else {
950 let mid = sizes.len() / 2;
951 (sizes[mid - 1] + sizes[mid]) / 2.0
952 }
953 });
954 let threshold = body_size - tolerance;
955 if threshold <= 0.0 {
956 return None;
965 }
966
967 let mut row_sums = vec![0f64; height];
970 let mut row_counts = vec![0u32; height];
971 for t in &elements {
972 let ya = t.bbox.y.floor().max(0.0) as usize;
973 let yb = ((t.bbox.y + t.bbox.height).ceil() as usize).min(height);
974 if yb > ya {
975 for y in ya..yb {
976 row_sums[y] += t.font_size as f64;
977 row_counts[y] += 1;
978 }
979 }
980 }
981
982 let mut y = doc_footer_y.min(height.saturating_sub(1));
994 while y >= middle {
995 if row_counts[y] > 0 {
996 let avg = (row_sums[y] / row_counts[y] as f64) as f32;
997 if avg > threshold {
998 return Some((y + 1) as f32);
999 }
1000 }
1001 if y == 0 {
1002 break;
1003 }
1004 y -= 1;
1005 }
1006 None
1007}
1008
1009fn downsample_to_density_grid(
1018 heatmap: &[Vec<u32>],
1019 width: usize,
1020 height: usize,
1021 cell_size: u32,
1022) -> DensityGrid {
1023 if cell_size == 0 || width == 0 || height == 0 {
1024 return DensityGrid {
1025 cell_size: cell_size.max(1),
1026 cols: 0,
1027 rows: 0,
1028 cells: Vec::new(),
1029 };
1030 }
1031 let cs = cell_size as usize;
1032 let cols = width.div_ceil(cs);
1033 let rows = height.div_ceil(cs);
1034 let mut cells = vec![0u16; rows * cols];
1035
1036 for out_row in 0..rows {
1037 let y_start = out_row * cs;
1038 let y_end = (y_start + cs).min(height);
1039 for out_col in 0..cols {
1040 let x_start = out_col * cs;
1041 let x_end = (x_start + cs).min(width);
1042 let mut sum: u32 = 0;
1043 for row in &heatmap[y_start..y_end] {
1044 for &v in &row[x_start..x_end] {
1045 sum = sum.saturating_add(v);
1046 }
1047 }
1048 cells[out_row * cols + out_col] = sum.min(u16::MAX as u32) as u16;
1049 }
1050 }
1051
1052 DensityGrid {
1053 cell_size,
1054 cols: cols as u32,
1055 rows: rows as u32,
1056 cells,
1057 }
1058}
1059
1060#[cfg(test)]
1065mod tests {
1066 use super::*;
1067 use crate::types::{FontClass, Placement};
1068
1069 #[allow(clippy::too_many_arguments)]
1072 fn make_element(
1073 page: u32,
1074 x: f32,
1075 y: f32,
1076 w: f32,
1077 h: f32,
1078 font_size: f32,
1079 rotation: i32,
1080 ) -> PdfTextElement {
1081 PdfTextElement {
1082 text: "lorem".to_string(),
1083 style_info: FontClass {
1084 class_name: "body".to_string(),
1085 font_family: "Times".to_string(),
1086 font_size,
1087 font_style: "normal".to_string(),
1088 font_weight: "normal".to_string(),
1089 color: "#000000".to_string(),
1090 },
1091 placement: Placement {
1092 page_number: page,
1093 bounding_box: BoundingBox {
1094 x,
1095 y,
1096 width: w,
1097 height: h,
1098 },
1099 line_number: 0,
1100 segment_number: 0,
1101 rotation,
1102 paragraph_number: 0,
1103 region_label: None,
1104 page_width: 0.0,
1105 page_height: 0.0,
1106 },
1107 reading_order: 0,
1108 bookmark_match: None,
1109 token_count: 1,
1110 raw_tags: vec![],
1111 }
1112 }
1113
1114 fn build_stats_with_config(
1115 elements: &[PdfTextElement],
1116 config: GeometryStatsConfig,
1117 ) -> GeometryStats {
1118 let mut b = GeometryStatsBuilder::new(config);
1119 for e in elements {
1120 b.observe(e);
1121 }
1122 b.finalize(&FinalizationContext::default())
1123 }
1124
1125 fn build_stats(elements: &[PdfTextElement]) -> GeometryStats {
1126 build_stats_with_config(elements, GeometryStatsConfig::default())
1127 }
1128
1129 fn synth_body_pages(
1136 n_pages: u32,
1137 page_w: f32,
1138 page_h: f32,
1139 body_x_ranges: &[(f32, f32)],
1140 body_y_lo: f32,
1141 body_y_hi: f32,
1142 line_h: f32,
1143 ) -> Vec<PdfTextElement> {
1144 let mut elements = Vec::new();
1145 for p in 1..=n_pages {
1146 let mut y = body_y_lo;
1147 while y + line_h <= body_y_hi {
1148 for (x0, x1) in body_x_ranges {
1149 elements.push(make_element(p, *x0, y, x1 - x0, line_h, 10.0, 0));
1150 }
1151 y += line_h; }
1153 elements.push(make_element(
1155 p,
1156 page_w - 0.1,
1157 page_h - 0.1,
1158 0.05,
1159 0.05,
1160 10.0,
1161 0,
1162 ));
1163 }
1164 elements
1165 }
1166
1167 fn push_solid_body(
1173 elements: &mut Vec<PdfTextElement>,
1174 page: u32,
1175 body_y_lo: f32,
1176 body_y_hi: f32,
1177 ) {
1178 let line_h = 14.0;
1179 let mut y = body_y_lo;
1180 while y + line_h <= body_y_hi {
1181 elements.push(make_element(page, 100.0, y, 400.0, line_h, 10.0, 0));
1182 y += line_h;
1183 }
1184 if y < body_y_hi {
1186 elements.push(make_element(page, 100.0, y, 400.0, body_y_hi - y, 10.0, 0));
1187 }
1188 }
1189
1190 #[test]
1192 fn header_running_is_detected() {
1193 let mut elements = Vec::new();
1194 for p in 1..=10 {
1195 elements.push(make_element(p, 100.0, 35.0, 200.0, 12.0, 10.0, 0));
1197 push_solid_body(&mut elements, p, 80.0, 700.0);
1199 elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1201 }
1202 let s = build_stats(&elements);
1203 assert!(
1207 s.header_y >= 60.0 && s.header_y < 80.0,
1208 "header_y {} not in [60, 80) — should sit just above body_top=80",
1209 s.header_y
1210 );
1211 assert!(
1212 s.doc_footer_y >= 700.0,
1213 "doc_footer_y {} not at/above body bottom",
1214 s.doc_footer_y
1215 );
1216 assert_eq!(s.diagnostic.header_reason, "found-significant-gap");
1217 }
1218
1219 #[test]
1221 fn header_top_margin_is_caught_when_no_header() {
1222 let elements = synth_body_pages(10, 600.0, 800.0, &[(100.0, 500.0)], 70.0, 700.0, 10.0);
1223 let s = build_stats(&elements);
1224 assert!(
1225 s.header_y >= 55.0 && s.header_y <= 70.0,
1226 "header_y {} not in [55, 70] for top-margin gap",
1227 s.header_y
1228 );
1229 }
1230
1231 #[test]
1233 fn header_multi_line_lands_below_lowest_band() {
1234 let mut elements = Vec::new();
1235 for p in 1..=10 {
1236 elements.push(make_element(p, 100.0, 30.0, 200.0, 15.0, 10.0, 0));
1237 elements.push(make_element(p, 100.0, 65.0, 200.0, 30.0, 10.0, 0));
1238 push_solid_body(&mut elements, p, 130.0, 700.0);
1239 elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1240 }
1241 let s = build_stats(&elements);
1242 assert!(
1246 s.header_y >= 95.0 && s.header_y < 130.0,
1247 "header_y {} not in [95, 130) — should sit just above body_top=130",
1248 s.header_y
1249 );
1250 }
1251
1252 #[test]
1254 fn footer_chrome_lands_above_chrome() {
1255 let mut elements = Vec::new();
1256 for p in 1..=10 {
1257 push_solid_body(&mut elements, p, 80.0, 720.0);
1259 elements.push(make_element(p, 280.0, 734.0, 40.0, 13.0, 10.0, 0));
1261 elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1262 }
1263 let s = build_stats(&elements);
1264 assert!(
1268 s.doc_footer_y >= 715.0 && s.doc_footer_y <= 735.0,
1269 "doc_footer_y {} not in [715, 735] (above chrome)",
1270 s.doc_footer_y
1271 );
1272 assert_eq!(s.diagnostic.doc_footer_reason, "found-chrome-then-tail");
1273 }
1274
1275 #[test]
1277 fn footer_no_chrome_returns_gap_to_bottom_or_significant_gap() {
1278 let mut elements = Vec::new();
1283 for p in 1..=10 {
1284 push_solid_body(&mut elements, p, 80.0, 750.0);
1285 elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1286 }
1287 let s = build_stats(&elements);
1288 assert!(
1289 s.doc_footer_y >= 749.0 && s.doc_footer_y <= 760.0,
1290 "doc_footer_y {} not near body bottom (750)",
1291 s.doc_footer_y
1292 );
1293 let r = &s.diagnostic.doc_footer_reason;
1294 assert!(
1295 r == "gap-to-page-bottom"
1296 || r == "found-significant-gap"
1297 || r == "found-chrome-then-tail"
1298 || r == "no-gap-found",
1299 "unexpected reason: {r}"
1300 );
1301 }
1302
1303 #[test]
1305 fn margins_two_column_with_inter_gap_skipped() {
1306 let elements = synth_body_pages(
1307 10,
1308 612.0,
1309 792.0,
1310 &[(100.0, 290.0), (310.0, 500.0)],
1311 80.0,
1312 720.0,
1313 10.0,
1314 );
1315 let s = build_stats(&elements);
1316 assert!(
1317 s.left_x >= 85.0 && s.left_x <= 110.0,
1318 "left_x {} not in [85, 110]",
1319 s.left_x
1320 );
1321 assert!(
1322 s.right_x >= 490.0 && s.right_x <= 515.0,
1323 "right_x {} not in [490, 515]",
1324 s.right_x
1325 );
1326 }
1327
1328 #[test]
1330 fn margins_ignore_running_header_width() {
1331 let mut elements = Vec::new();
1332 for p in 1..=10 {
1333 elements.push(make_element(p, 20.0, 35.0, 560.0, 12.0, 10.0, 0));
1335 let mut y = 100.0;
1337 while y < 700.0 {
1338 elements.push(make_element(p, 100.0, y, 400.0, 10.0, 10.0, 0));
1339 y += 14.0;
1340 }
1341 elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1342 }
1343 let s = build_stats(&elements);
1344 assert!(
1345 s.left_x >= 90.0 && s.left_x <= 110.0,
1346 "left_x {} should track body, not header — running header polluted X-projection",
1347 s.left_x
1348 );
1349 assert!(
1350 s.right_x >= 490.0 && s.right_x <= 515.0,
1351 "right_x {} should track body, not header",
1352 s.right_x
1353 );
1354 }
1355
1356 #[test]
1358 fn header_ignores_rotated_decorations_at_top() {
1359 let mut elements = Vec::new();
1360 for p in 1..=10 {
1361 elements.push(make_element(p, 50.0, 10.0, 20.0, 10.0, 10.0, 90));
1363 let mut y = 80.0;
1365 while y < 700.0 {
1366 elements.push(make_element(p, 100.0, y, 400.0, 10.0, 10.0, 0));
1367 y += 14.0;
1368 }
1369 elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1370 }
1371 let s = build_stats(&elements);
1372 assert!(
1373 s.header_y >= 55.0 && s.header_y <= 80.0,
1374 "header_y {} should be in top-margin range — rotated decoration polluted",
1375 s.header_y
1376 );
1377 }
1378
1379 #[test]
1381 fn source_pages_reflects_short_document() {
1382 let elements = synth_body_pages(3, 612.0, 792.0, &[(100.0, 500.0)], 80.0, 720.0, 10.0);
1383 let s = build_stats(&elements);
1384 assert_eq!(s.source_pages, 3);
1385 }
1386
1387 #[test]
1389 fn determinism_same_input_byte_identical_json() {
1390 let elements = synth_body_pages(5, 612.0, 792.0, &[(100.0, 500.0)], 80.0, 720.0, 10.0);
1391 let a = serde_json::to_string(&build_stats(&elements)).unwrap();
1392 let b = serde_json::to_string(&build_stats(&elements)).unwrap();
1393 assert_eq!(a, b);
1394 }
1395
1396 #[test]
1398 fn per_page_footer_with_footnote_block() {
1399 let mut elements = Vec::new();
1400 for p in 1..=3 {
1401 let mut y = 80.0;
1402 while y < 600.0 {
1403 elements.push(make_element(p, 100.0, y, 400.0, 10.0, 10.0, 0));
1404 y += 14.0;
1405 }
1406 let mut yf = 620.0;
1408 while yf < 660.0 {
1409 elements.push(make_element(p, 100.0, yf, 400.0, 8.0, 8.0, 0));
1410 yf += 10.0;
1411 }
1412 elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1413 }
1414 let s = build_stats(&elements);
1415 for (idx, ppf) in s.per_page_footer_y.iter().enumerate() {
1416 let v = ppf.unwrap_or(-1.0);
1417 assert!(
1418 (600.0..=625.0).contains(&v),
1419 "page {} per_page_footer_y={} not in [600, 625]",
1420 idx,
1421 v
1422 );
1423 }
1424 }
1425
1426 #[test]
1428 fn per_page_footer_no_footer_returns_doc_line() {
1429 let mut elements = Vec::new();
1430 for p in 1..=3 {
1431 let mut y = 80.0;
1432 while y < 740.0 {
1433 elements.push(make_element(p, 100.0, y, 400.0, 10.0, 10.0, 0));
1434 y += 14.0;
1435 }
1436 elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1437 }
1438 let s = build_stats(&elements);
1439 for ppf in &s.per_page_footer_y {
1440 let v = ppf.unwrap_or(-1.0);
1441 assert!(
1442 (v - s.doc_footer_y).abs() < 5.0 || v >= s.doc_footer_y,
1443 "per_page_footer_y={} should be ~doc_footer_y={}",
1444 v,
1445 s.doc_footer_y
1446 );
1447 }
1448 }
1449
1450 #[test]
1452 fn per_page_footer_skips_embedded_equation() {
1453 let mut elements = Vec::new();
1454 for p in 1..=3 {
1455 let mut y = 80.0;
1457 while y < 550.0 {
1458 elements.push(make_element(p, 100.0, y, 400.0, 10.0, 10.0, 0));
1459 y += 14.0;
1460 }
1461 let mut ye = 560.0;
1463 while ye < 590.0 {
1464 elements.push(make_element(p, 200.0, ye, 100.0, 8.0, 8.0, 0));
1465 ye += 10.0;
1466 }
1467 let mut y2 = 600.0;
1469 while y2 < 680.0 {
1470 elements.push(make_element(p, 100.0, y2, 400.0, 10.0, 10.0, 0));
1471 y2 += 14.0;
1472 }
1473 let mut yf = 700.0;
1475 while yf < 740.0 {
1476 elements.push(make_element(p, 100.0, yf, 400.0, 8.0, 8.0, 0));
1477 yf += 10.0;
1478 }
1479 elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1480 }
1481 let s = build_stats(&elements);
1482 for (idx, ppf) in s.per_page_footer_y.iter().enumerate() {
1483 let v = ppf.unwrap_or(-1.0);
1484 assert!(
1485 (680.0..=705.0).contains(&v),
1486 "page {} per_page_footer_y={} should land above the real footnote (680..705)",
1487 idx,
1488 v
1489 );
1490 }
1491 }
1492
1493 #[test]
1495 fn per_page_footer_filters_size_artifacts() {
1496 let mut elements = Vec::new();
1497 for p in 1..=3 {
1498 let mut y = 80.0;
1499 while y < 700.0 {
1500 elements.push(make_element(p, 100.0, y, 400.0, 10.0, 10.0, 0));
1501 elements.push(make_element(p, 200.0, y + 2.0, 5.0, 1.0, 0.1, 0));
1503 y += 14.0;
1504 }
1505 elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1506 }
1507 let s = build_stats(&elements);
1508 for (idx, ppf) in s.per_page_footer_y.iter().enumerate() {
1509 if let Some(v) = ppf {
1510 assert!(
1511 *v >= 698.0 && *v <= 715.0,
1512 "page {} per_page_footer_y={} should land near body bottom — artifacts polluted",
1513 idx,
1514 v
1515 );
1516 }
1517 }
1518 }
1519
1520 #[test]
1522 fn per_page_footer_all_small_page_uses_per_page_median() {
1523 let mut elements = Vec::new();
1530 push_solid_body(&mut elements, 1, 80.0, 720.0);
1532 elements.push(make_element(1, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1533 let mut yf = 500.0;
1535 while yf < 700.0 {
1536 elements.push(make_element(2, 100.0, yf, 400.0, 8.0, 8.0, 0));
1537 yf += 10.0;
1538 }
1539 elements.push(make_element(2, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1540 let s = build_stats(&elements);
1541 assert!(
1542 s.per_page_footer_y[1].is_some(),
1543 "per-page median treats 8pt as body for an all-8pt page"
1544 );
1545 }
1546
1547 #[test]
1549 fn per_page_footer_cover_page_returns_none() {
1550 let mut elements = Vec::new();
1551 let mut y = 80.0;
1553 while y < 720.0 {
1554 elements.push(make_element(1, 100.0, y, 400.0, 10.0, 10.0, 0));
1555 y += 14.0;
1556 }
1557 elements.push(make_element(1, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1558 elements.push(make_element(2, 200.0, 100.0, 200.0, 30.0, 24.0, 0));
1560 elements.push(make_element(2, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1561 let s = build_stats(&elements);
1562 assert!(
1563 s.per_page_footer_y[1].is_none(),
1564 "cover page with no body in bottom half should yield None"
1565 );
1566 }
1567
1568 #[test]
1570 fn column_layout_single_column() {
1571 let elements = synth_body_pages(10, 612.0, 792.0, &[(100.0, 500.0)], 80.0, 720.0, 10.0);
1572 let s = build_stats(&elements);
1573 assert_eq!(s.column_layout.column_count, 1);
1574 assert!(s.column_layout.column_dividers.is_empty());
1575 }
1576
1577 #[test]
1579 fn column_layout_two_column_clean_gutter() {
1580 let elements = synth_body_pages(
1581 10,
1582 612.0,
1583 792.0,
1584 &[(100.0, 280.0), (310.0, 500.0)],
1585 80.0,
1586 720.0,
1587 10.0,
1588 );
1589 let s = build_stats(&elements);
1590 assert_eq!(s.column_layout.column_count, 2);
1591 assert_eq!(s.column_layout.column_dividers.len(), 1);
1592 let d = s.column_layout.column_dividers[0];
1593 assert!(
1594 d > 280.0 && d < 310.0,
1595 "divider {} not in inter-col range (280, 310)",
1596 d
1597 );
1598 }
1599
1600 #[test]
1602 fn column_layout_full_width_spanner_collapses_to_one() {
1603 let mut elements = synth_body_pages(
1604 10,
1605 612.0,
1606 792.0,
1607 &[(100.0, 280.0), (310.0, 500.0)],
1608 80.0,
1609 720.0,
1610 10.0,
1611 );
1612 for p in 1..=3 {
1616 push_solid_body(&mut elements, p, 200.0, 600.0);
1617 }
1618 let s = build_stats(&elements);
1619 assert_eq!(
1620 s.column_layout.column_count, 1,
1621 "full-width spanner should defeat sharp-drop detection"
1622 );
1623 }
1624
1625 #[test]
1627 fn column_layout_right_edge_taper_rejected() {
1628 let mut elements = Vec::new();
1629 for p in 1..=10 {
1630 let mut y = 80.0;
1632 while y < 720.0 {
1633 elements.push(make_element(p, 100.0, y, 380.0, 10.0, 10.0, 0));
1634 y += 14.0;
1635 }
1636 let mut y2 = 80.0;
1638 while y2 < 720.0 {
1639 if ((y2 as i32) / 14) % 3 == 0 {
1640 elements.push(make_element(p, 480.0, y2, 30.0, 10.0, 10.0, 0));
1641 }
1642 y2 += 14.0;
1643 }
1644 elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1645 }
1646 let s = build_stats(&elements);
1647 assert_eq!(
1648 s.column_layout.column_count, 1,
1649 "right-edge taper must not be detected as a column boundary"
1650 );
1651 }
1652
1653 #[test]
1655 fn column_layout_indented_list_marker_rejected() {
1656 let mut elements = Vec::new();
1657 for p in 1..=10 {
1658 let mut ym = 80.0;
1660 while ym < 720.0 {
1661 if ((ym as i32) / 14) % 3 == 0 {
1662 elements.push(make_element(p, 90.0, ym, 25.0, 10.0, 10.0, 0));
1663 }
1664 ym += 14.0;
1665 }
1666 let mut y = 80.0;
1668 while y < 720.0 {
1669 elements.push(make_element(p, 120.0, y, 380.0, 10.0, 10.0, 0));
1670 y += 14.0;
1671 }
1672 elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1673 }
1674 let s = build_stats(&elements);
1675 assert_eq!(
1676 s.column_layout.column_count, 1,
1677 "indented list marker must not be detected as a column boundary"
1678 );
1679 }
1680
1681 #[test]
1683 fn density_grid_shape_letter_page_at_default_cell_size() {
1684 let elements = synth_body_pages(10, 612.0, 792.0, &[(100.0, 500.0)], 80.0, 720.0, 10.0);
1685 let s = build_stats(&elements);
1686 assert_eq!(s.heatmap.cell_size, 8);
1687 assert_eq!(s.heatmap.cols, (612u32).div_ceil(8));
1688 assert_eq!(s.heatmap.rows, (792u32).div_ceil(8));
1689 assert_eq!(
1690 s.heatmap.cells.len(),
1691 (s.heatmap.rows * s.heatmap.cols) as usize
1692 );
1693 assert!(s.heatmap.cells.iter().any(|&v| v > 0));
1694 }
1695
1696 #[test]
1698 fn density_grid_uses_sum_not_mean_or_max() {
1699 let mut elements = Vec::new();
1701 for p in 1..=10 {
1702 elements.push(make_element(p, 0.0, 0.0, 8.0, 8.0, 10.0, 0));
1703 elements.push(make_element(p, 99.9, 99.9, 0.05, 0.05, 10.0, 0));
1705 }
1706 let s = build_stats(&elements);
1707 let cell_00 = s.heatmap.cells[0];
1710 assert_eq!(cell_00, 640, "expected sum 640, got {}", cell_00);
1711 for col in 1..s.heatmap.cols as usize {
1713 assert_eq!(
1714 s.heatmap.cells[col], 0,
1715 "cell (0,{col}) should be 0 (no bbox there)"
1716 );
1717 }
1718 }
1719
1720 #[test]
1722 fn empty_input_returns_default_stats() {
1723 let s = build_stats(&[]);
1724 assert_eq!(s.source_pages, 0);
1725 assert_eq!(s.column_layout.column_count, 0);
1726 assert!(s.column_layout.column_dividers.is_empty());
1727 assert!(s.per_page_footer_y.is_empty());
1728 }
1729}