1use serde::{Deserialize, Serialize};
19
20use crate::analytics::statistic::{FinalizationContext, Statistic};
21use crate::types::{BoundingBox, PdfTextElement};
22
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct Region {
35 pub r#box: RegionBox,
36 pub axis: Option<CutAxis>,
37 pub cut_coords: Vec<f32>,
38 pub children: Vec<Region>,
39 pub label: String,
42 pub element_indices: Vec<u32>,
48}
49
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct PageRegions {
56 pub page_number: u32,
58 pub body_box: RegionBox,
61 pub median_line_height: f32,
64 pub body_element_indices: Vec<u32>,
68 pub root: Region,
70 pub diagnostic: PageRegionDiagnostic,
73}
74
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76pub struct PageRegionDiagnostic {
77 pub merged_subtrees: u32,
79 pub bbox_filtered: u32,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85pub enum CutAxis {
86 H,
88 V,
90}
91
92#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
95pub struct RegionBox {
96 pub x0: f32,
97 pub y0: f32,
98 pub x1: f32,
99 pub y1: f32,
100}
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct RegionStats {
104 pub per_page: Vec<PageRegions>,
108 pub source_pages: u32,
110}
111
112#[derive(Debug, Clone)]
120pub struct RegionStatsConfig {
121 pub abs_min_band_pt: f32,
123 pub rel_min_band_line_heights: f32,
127 pub top_k_fraction: f32,
131 pub retry_factors: Vec<f32>,
135 pub max_cuts_at_retry: usize,
139 pub max_depth: usize,
142 pub column_divider_tolerance_pt: f32,
146 pub bbox_crossing_band_perp_pt: f32,
149 pub bbox_crossing_band_inset_pt: f32,
154}
155
156impl Default for RegionStatsConfig {
157 fn default() -> Self {
158 Self {
159 abs_min_band_pt: 8.0,
160 rel_min_band_line_heights: 1.2,
161 top_k_fraction: 0.60,
162 retry_factors: vec![1.0, 0.75, 0.55, 0.4],
163 max_cuts_at_retry: 3,
164 max_depth: 8,
165 column_divider_tolerance_pt: 15.0,
166 bbox_crossing_band_perp_pt: 8.0,
167 bbox_crossing_band_inset_pt: 5.0,
168 }
169 }
170}
171
172#[derive(Debug, Clone)]
180struct Observed {
181 global_idx: u32,
184 bbox: BoundingBox,
185}
186
187#[derive(Debug, Default)]
188struct PageObservation {
189 page_number: u32,
190 width: f32,
191 height: f32,
192 elements: Vec<Observed>,
193}
194
195#[derive(Debug, Default)]
201pub struct RegionStatsBuilder {
202 config: RegionStatsConfig,
203 pages: Vec<PageObservation>,
204 next_global_idx: u32,
208}
209
210impl RegionStatsBuilder {
211 pub fn new(config: RegionStatsConfig) -> Self {
215 Self {
216 config,
217 pages: Vec::new(),
218 next_global_idx: 0,
219 }
220 }
221
222 fn page_slot(&mut self, page_number: u32) -> usize {
223 if let Some(idx) = self.pages.iter().position(|p| p.page_number == page_number) {
224 return idx;
225 }
226 self.pages.push(PageObservation {
227 page_number,
228 width: 0.0,
229 height: 0.0,
230 elements: Vec::new(),
231 });
232 self.pages.len() - 1
233 }
234}
235
236impl Statistic for RegionStatsBuilder {
237 type Output = RegionStats;
238 const NAME: &'static str = "region";
239
240 fn observe(&mut self, element: &PdfTextElement) {
241 let global_idx = self.next_global_idx;
242 self.next_global_idx = self.next_global_idx.saturating_add(1);
243
244 if element.rotation() != 0 {
245 return;
246 }
247
248 let bbox = element.bounding_box().clone();
249 let page_w = element.placement.page_width;
250 let page_h = element.placement.page_height;
251 let page_number = element.page_number();
252 let idx = self.page_slot(page_number);
253 let page = &mut self.pages[idx];
254
255 if page_w > 0.0 {
260 page.width = page_w;
261 } else {
262 let right = bbox.x + bbox.width;
263 if right > page.width {
264 page.width = right;
265 }
266 }
267 if page_h > 0.0 {
268 page.height = page_h;
269 } else {
270 let bottom = bbox.y + bbox.height;
271 if bottom > page.height {
272 page.height = bottom;
273 }
274 }
275
276 page.elements.push(Observed { global_idx, bbox });
277 }
278
279 fn finalize(self, ctx: &FinalizationContext<'_>) -> Self::Output {
280 let geometry = match ctx.geometry {
281 Some(g) => g,
282 None => return RegionStats::default(),
287 };
288
289 let mut per_page = Vec::with_capacity(self.pages.len());
290 for page in self.pages.iter() {
291 per_page.push(finalize_page(page, geometry, &self.config));
292 }
293 let source_pages = per_page.len() as u32;
294 RegionStats {
295 per_page,
296 source_pages,
297 }
298 }
299}
300
301fn finalize_page(
306 page: &PageObservation,
307 geometry: &crate::analytics::geometry::GeometryStats,
308 config: &RegionStatsConfig,
309) -> PageRegions {
310 let body_box = body_box_for_page(geometry);
311 let mut body_indices_local: Vec<u32> = Vec::new();
314 let mut body_global_indices: Vec<u32> = Vec::new();
315 let mut body_bboxes: Vec<BoundingBox> = Vec::new();
316 for o in &page.elements {
317 if overlaps(&o.bbox, &body_box) {
318 body_indices_local.push(body_indices_local.len() as u32);
319 body_global_indices.push(o.global_idx);
320 body_bboxes.push(o.bbox.clone());
321 }
322 }
323
324 let mlh = median_line_height(&body_bboxes);
325
326 let mut root = xy_cut(body_box, &body_bboxes, &body_indices_local, 0, mlh, config);
328
329 let merged = merge_overfragmented(
331 &mut root,
332 &geometry.column_layout.column_dividers,
333 config.column_divider_tolerance_pt,
334 );
335
336 let bbox_filtered = remove_bbox_crossing_cuts(
338 &mut root,
339 &body_bboxes,
340 config.bbox_crossing_band_perp_pt,
341 config.bbox_crossing_band_inset_pt,
342 );
343
344 label_tree(&mut root, "");
346
347 PageRegions {
348 page_number: page.page_number,
349 body_box,
350 median_line_height: mlh,
351 body_element_indices: body_global_indices,
352 root,
353 diagnostic: PageRegionDiagnostic {
354 merged_subtrees: merged,
355 bbox_filtered,
356 },
357 }
358}
359
360fn body_box_for_page(geometry: &crate::analytics::geometry::GeometryStats) -> RegionBox {
365 RegionBox {
366 x0: geometry.left_x,
367 y0: geometry.header_y,
368 x1: geometry.right_x,
369 y1: geometry.doc_footer_y,
370 }
371}
372
373fn overlaps(bbox: &BoundingBox, region: &RegionBox) -> bool {
374 let x1 = bbox.x + bbox.width;
375 let y1 = bbox.y + bbox.height;
376 !(x1 <= region.x0 || bbox.x >= region.x1 || y1 <= region.y0 || bbox.y >= region.y1)
377}
378
379fn median_line_height(bboxes: &[BoundingBox]) -> f32 {
383 let mut heights: Vec<f32> = bboxes
384 .iter()
385 .map(|b| b.height)
386 .filter(|h| *h > 0.0)
387 .collect();
388 if heights.is_empty() {
389 return 12.0;
390 }
391 heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
392 heights[heights.len() / 2]
393}
394
395fn xy_cut(
411 region_box: RegionBox,
412 bboxes: &[BoundingBox],
413 bbox_indices: &[u32],
414 depth: usize,
415 mlh: f32,
416 config: &RegionStatsConfig,
417) -> Region {
418 debug_assert_eq!(bboxes.len(), bbox_indices.len());
419
420 if depth >= config.max_depth {
421 return leaf(region_box, bbox_indices);
422 }
423
424 for (attempt, &factor) in config.retry_factors.iter().enumerate() {
425 let h_bands = find_interior_bands(region_box, bboxes, CutAxis::H, mlh, factor, config);
426 let v_bands = find_interior_bands(region_box, bboxes, CutAxis::V, mlh, factor, config);
427 if h_bands.is_empty() && v_bands.is_empty() {
428 continue;
429 }
430
431 let h_largest = h_bands.iter().map(|b| b.thickness).fold(0.0, f32::max);
432 let v_largest = v_bands.iter().map(|b| b.thickness).fold(0.0, f32::max);
433 let (chosen_axis, chosen_bands, chosen_max) = if h_largest >= v_largest {
434 (CutAxis::H, h_bands, h_largest)
435 } else {
436 (CutAxis::V, v_bands, v_largest)
437 };
438
439 let mut kept: Vec<Band> = chosen_bands
441 .into_iter()
442 .filter(|b| b.thickness >= config.top_k_fraction * chosen_max)
443 .collect();
444 kept.sort_by(|a, b| {
445 a.mid
446 .partial_cmp(&b.mid)
447 .unwrap_or(std::cmp::Ordering::Equal)
448 });
449
450 if attempt > 0 && kept.len() > config.max_cuts_at_retry {
452 return leaf(region_box, bbox_indices);
453 }
454
455 let cut_coords: Vec<f32> = kept.iter().map(|b| b.mid).collect();
456 let cut_coords = filter_cuts_with_content(region_box, bboxes, chosen_axis, &cut_coords);
457 if cut_coords.is_empty() {
458 continue;
461 }
462
463 let children = split_and_recurse(
464 region_box,
465 bboxes,
466 bbox_indices,
467 chosen_axis,
468 &cut_coords,
469 depth,
470 mlh,
471 config,
472 );
473
474 return Region {
475 r#box: region_box,
476 axis: Some(chosen_axis),
477 cut_coords,
478 children,
479 label: String::new(),
480 element_indices: Vec::new(),
481 };
482 }
483
484 leaf(region_box, bbox_indices)
486}
487
488fn leaf(region_box: RegionBox, indices: &[u32]) -> Region {
489 Region {
490 r#box: region_box,
491 axis: None,
492 cut_coords: Vec::new(),
493 children: Vec::new(),
494 label: String::new(),
495 element_indices: indices.to_vec(),
496 }
497}
498
499#[allow(clippy::too_many_arguments)]
500fn split_and_recurse(
501 region_box: RegionBox,
502 bboxes: &[BoundingBox],
503 bbox_indices: &[u32],
504 axis: CutAxis,
505 cuts: &[f32],
506 depth: usize,
507 mlh: f32,
508 config: &RegionStatsConfig,
509) -> Vec<Region> {
510 let mut children: Vec<Region> = Vec::with_capacity(cuts.len() + 1);
511 let strips = strip_boxes(region_box, axis, cuts);
512 for strip in strips {
513 let (sub_bboxes, sub_indices) = bboxes_in(strip, bboxes, bbox_indices);
514 children.push(xy_cut(
515 strip,
516 &sub_bboxes,
517 &sub_indices,
518 depth + 1,
519 mlh,
520 config,
521 ));
522 }
523 children
524}
525
526fn strip_boxes(region_box: RegionBox, axis: CutAxis, cuts: &[f32]) -> Vec<RegionBox> {
527 let mut strips: Vec<RegionBox> = Vec::with_capacity(cuts.len() + 1);
528 match axis {
529 CutAxis::H => {
530 let mut prev = region_box.y0;
531 for &c in cuts {
532 strips.push(RegionBox {
533 x0: region_box.x0,
534 y0: prev,
535 x1: region_box.x1,
536 y1: c,
537 });
538 prev = c;
539 }
540 strips.push(RegionBox {
541 x0: region_box.x0,
542 y0: prev,
543 x1: region_box.x1,
544 y1: region_box.y1,
545 });
546 }
547 CutAxis::V => {
548 let mut prev = region_box.x0;
549 for &c in cuts {
550 strips.push(RegionBox {
551 x0: prev,
552 y0: region_box.y0,
553 x1: c,
554 y1: region_box.y1,
555 });
556 prev = c;
557 }
558 strips.push(RegionBox {
559 x0: prev,
560 y0: region_box.y0,
561 x1: region_box.x1,
562 y1: region_box.y1,
563 });
564 }
565 }
566 strips
567}
568
569fn bboxes_in(
570 region: RegionBox,
571 bboxes: &[BoundingBox],
572 indices: &[u32],
573) -> (Vec<BoundingBox>, Vec<u32>) {
574 let mut out_b: Vec<BoundingBox> = Vec::new();
575 let mut out_i: Vec<u32> = Vec::new();
576 for (b, &i) in bboxes.iter().zip(indices.iter()) {
577 if overlaps(b, ®ion) {
578 out_b.push(b.clone());
579 out_i.push(i);
580 }
581 }
582 (out_b, out_i)
583}
584
585#[derive(Debug, Clone, Copy)]
586struct Band {
587 #[allow(dead_code)]
588 start: f32,
589 #[allow(dead_code)]
590 end: f32,
591 mid: f32,
592 thickness: f32,
593}
594
595fn find_interior_bands(
600 region_box: RegionBox,
601 bboxes: &[BoundingBox],
602 axis: CutAxis,
603 mlh: f32,
604 factor: f32,
605 config: &RegionStatsConfig,
606) -> Vec<Band> {
607 let (lo, hi) = match axis {
608 CutAxis::H => (region_box.y0.round() as i32, region_box.y1.round() as i32),
609 CutAxis::V => (region_box.x0.round() as i32, region_box.x1.round() as i32),
610 };
611 if hi <= lo {
612 return Vec::new();
613 }
614
615 let span = (hi - lo) as usize;
616 let mut filled = vec![false; span];
617 for b in bboxes {
618 let (a_raw, b_raw) = match axis {
619 CutAxis::H => ((b.y).round() as i32, (b.y + b.height).round() as i32),
620 CutAxis::V => ((b.x).round() as i32, (b.x + b.width).round() as i32),
621 };
622 let a = a_raw.max(lo);
623 let bb = b_raw.min(hi);
624 if bb <= a {
625 continue;
626 }
627 filled[(a - lo) as usize..(bb - lo) as usize].fill(true);
628 }
629
630 let mut runs: Vec<(i32, i32)> = Vec::new();
632 let mut in_run = false;
633 let mut run_start = lo;
634 for (i, &f) in filled.iter().enumerate() {
635 let pos = lo + i as i32;
636 if !f && !in_run {
637 in_run = true;
638 run_start = pos;
639 } else if f && in_run {
640 in_run = false;
641 runs.push((run_start, pos));
642 }
643 }
644 if in_run {
645 runs.push((run_start, hi));
646 }
647
648 let min_thickness =
649 (factor * config.abs_min_band_pt).max(factor * config.rel_min_band_line_heights * mlh);
650
651 let mut bands: Vec<Band> = Vec::new();
652 for (start, end) in runs {
653 if start <= lo || end >= hi {
655 continue;
656 }
657 let thickness = (end - start) as f32;
658 if thickness < min_thickness {
659 continue;
660 }
661 bands.push(Band {
662 start: start as f32,
663 end: end as f32,
664 mid: (start as f32 + end as f32) / 2.0,
665 thickness,
666 });
667 }
668 bands
669}
670
671fn filter_cuts_with_content(
673 region_box: RegionBox,
674 bboxes: &[BoundingBox],
675 axis: CutAxis,
676 cuts: &[f32],
677) -> Vec<f32> {
678 if cuts.is_empty() {
679 return Vec::new();
680 }
681 let mut accepted: Vec<f32> = Vec::new();
682 let mut prev = match axis {
683 CutAxis::H => region_box.y0,
684 CutAxis::V => region_box.x0,
685 };
686 for &c in cuts {
687 let strip = match axis {
688 CutAxis::H => RegionBox {
689 x0: region_box.x0,
690 y0: prev,
691 x1: region_box.x1,
692 y1: c,
693 },
694 CutAxis::V => RegionBox {
695 x0: prev,
696 y0: region_box.y0,
697 x1: c,
698 y1: region_box.y1,
699 },
700 };
701 if bboxes.iter().any(|b| overlaps(b, &strip)) {
702 accepted.push(c);
703 prev = c;
704 }
705 }
707 while let Some(&last) = accepted.last() {
709 let tail = match axis {
710 CutAxis::H => RegionBox {
711 x0: region_box.x0,
712 y0: last,
713 x1: region_box.x1,
714 y1: region_box.y1,
715 },
716 CutAxis::V => RegionBox {
717 x0: last,
718 y0: region_box.y0,
719 x1: region_box.x1,
720 y1: region_box.y1,
721 },
722 };
723 if bboxes.iter().any(|b| overlaps(b, &tail)) {
724 break;
725 }
726 accepted.pop();
727 }
728 accepted
729}
730
731fn merge_overfragmented(region: &mut Region, dividers: &[f32], tolerance: f32) -> u32 {
740 let mut n = 0;
741 for child in region.children.iter_mut() {
742 n += merge_overfragmented(child, dividers, tolerance);
743 }
744 if matches!(region.axis, Some(CutAxis::V)) && !region.cut_coords.is_empty() {
745 let any_aligned = region
746 .cut_coords
747 .iter()
748 .any(|c| aligns_with_divider(*c, dividers, tolerance));
749 if !any_aligned {
750 collapse_to_leaf(region);
751 n += 1;
752 }
753 }
754 n
755}
756
757fn aligns_with_divider(cut: f32, dividers: &[f32], tolerance: f32) -> bool {
758 dividers.iter().any(|d| (cut - *d).abs() <= tolerance)
759}
760
761fn collapse_to_leaf(region: &mut Region) {
762 let indices = gather_leaf_indices(region);
763 region.axis = None;
764 region.cut_coords.clear();
765 region.children.clear();
766 region.element_indices = indices;
767}
768
769fn gather_leaf_indices(region: &Region) -> Vec<u32> {
770 if region.children.is_empty() {
771 return region.element_indices.clone();
772 }
773 let mut out: Vec<u32> = Vec::new();
774 for child in ®ion.children {
775 out.extend(gather_leaf_indices(child));
776 }
777 out
778}
779
780fn remove_bbox_crossing_cuts(
791 region: &mut Region,
792 all_bboxes: &[BoundingBox],
793 perp: f32,
794 along_inset: f32,
795) -> u32 {
796 let mut n = 0;
797 for child in region.children.iter_mut() {
798 n += remove_bbox_crossing_cuts(child, all_bboxes, perp, along_inset);
799 }
800 if region.children.is_empty() || region.cut_coords.is_empty() {
801 return n;
802 }
803
804 let region_bboxes: Vec<&BoundingBox> = all_bboxes
808 .iter()
809 .filter(|b| overlaps(b, ®ion.r#box))
810 .collect();
811
812 let half = perp / 2.0;
813 let mut crossing = false;
814 for &cut in ®ion.cut_coords {
815 let band = match region.axis {
816 Some(CutAxis::H) => RegionBox {
817 x0: region.r#box.x0 + along_inset,
818 y0: cut - half,
819 x1: region.r#box.x1 - along_inset,
820 y1: cut + half,
821 },
822 Some(CutAxis::V) => RegionBox {
823 x0: cut - half,
824 y0: region.r#box.y0 + along_inset,
825 x1: cut + half,
826 y1: region.r#box.y1 - along_inset,
827 },
828 None => continue,
829 };
830 for b in ®ion_bboxes {
831 if overlaps(b, &band) {
832 crossing = true;
833 break;
834 }
835 }
836 if crossing {
837 break;
838 }
839 }
840
841 if crossing {
842 let indices = gather_leaf_indices(region);
843 region.axis = None;
844 region.cut_coords.clear();
845 region.children.clear();
846 region.element_indices = indices;
847 n += 1;
848 }
849 n
850}
851
852fn label_tree(region: &mut Region, prefix: &str) {
857 if region.children.is_empty() {
858 region.label = if prefix.is_empty() {
859 "1".to_string()
860 } else {
861 prefix.to_string()
862 };
863 return;
864 }
865 for (i, child) in region.children.iter_mut().enumerate() {
866 let n = i + 1;
867 let child_prefix = if prefix.is_empty() {
868 n.to_string()
869 } else {
870 format!("{prefix}-{n}")
871 };
872 label_tree(child, &child_prefix);
873 }
874}
875
876#[cfg(test)]
881mod tests {
882 use super::*;
883 use crate::analytics::geometry::{ColumnLayout, GeometryStats};
884
885 fn mk_bbox(x: f32, y: f32, w: f32, h: f32) -> BoundingBox {
886 BoundingBox {
887 x,
888 y,
889 width: w,
890 height: h,
891 }
892 }
893
894 fn mk_geometry(
895 header_y: f32,
896 doc_footer_y: f32,
897 left_x: f32,
898 right_x: f32,
899 dividers: Vec<f32>,
900 ) -> GeometryStats {
901 GeometryStats {
902 header_y,
903 doc_footer_y,
904 left_x,
905 right_x,
906 column_layout: ColumnLayout {
907 column_count: (dividers.len() + 1) as u32,
908 column_dividers: dividers,
909 },
910 ..Default::default()
911 }
912 }
913
914 fn run(bboxes: Vec<BoundingBox>, geometry: &GeometryStats) -> PageRegions {
915 let indices: Vec<u32> = (0..bboxes.len() as u32).collect();
916 let cfg = RegionStatsConfig::default();
917 let body_box = body_box_for_page(geometry);
918 let body_bboxes: Vec<BoundingBox> = bboxes
919 .iter()
920 .filter(|b| overlaps(b, &body_box))
921 .cloned()
922 .collect();
923 let body_indices: Vec<u32> = (0..body_bboxes.len() as u32).collect();
924 let mlh = median_line_height(&body_bboxes);
925 let mut root = xy_cut(body_box, &body_bboxes, &body_indices, 0, mlh, &cfg);
926 let merged = merge_overfragmented(
927 &mut root,
928 &geometry.column_layout.column_dividers,
929 cfg.column_divider_tolerance_pt,
930 );
931 let bbox_filtered = remove_bbox_crossing_cuts(
932 &mut root,
933 &body_bboxes,
934 cfg.bbox_crossing_band_perp_pt,
935 cfg.bbox_crossing_band_inset_pt,
936 );
937 label_tree(&mut root, "");
938 PageRegions {
939 page_number: 1,
940 body_box,
941 median_line_height: mlh,
942 body_element_indices: indices,
943 root,
944 diagnostic: PageRegionDiagnostic {
945 merged_subtrees: merged,
946 bbox_filtered,
947 },
948 }
949 }
950
951 fn count_leaves(region: &Region) -> u32 {
952 if region.children.is_empty() {
953 1
954 } else {
955 region.children.iter().map(count_leaves).sum()
956 }
957 }
958
959 fn collect_leaves<'a>(region: &'a Region, out: &mut Vec<&'a Region>) {
960 if region.children.is_empty() {
961 out.push(region);
962 } else {
963 for c in ®ion.children {
964 collect_leaves(c, out);
965 }
966 }
967 }
968
969 #[test]
971 fn single_block_yields_single_leaf() {
972 let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
975 let mut bboxes = Vec::new();
976 let mut y = 80.0;
977 while y + 14.0 <= 700.0 {
978 bboxes.push(mk_bbox(110.0, y, 380.0, 14.0));
979 y += 14.0;
980 }
981 let pr = run(bboxes, &geometry);
982 assert_eq!(count_leaves(&pr.root), 1);
983 let mut leaves = Vec::new();
984 collect_leaves(&pr.root, &mut leaves);
985 assert_eq!(leaves[0].label, "1");
986 }
987
988 #[test]
990 fn body_with_section_gap_splits_horizontally() {
991 let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
992 let mut bboxes = Vec::new();
993 let mut y = 80.0;
997 while y + 14.0 <= 300.0 {
998 bboxes.push(mk_bbox(110.0, y, 380.0, 14.0));
999 y += 14.0;
1000 }
1001 y = 350.0;
1002 while y + 14.0 <= 700.0 {
1003 bboxes.push(mk_bbox(110.0, y, 380.0, 14.0));
1004 y += 14.0;
1005 }
1006 let pr = run(bboxes, &geometry);
1007 assert_eq!(pr.root.axis, Some(CutAxis::H));
1008 assert_eq!(pr.root.cut_coords.len(), 1);
1009 assert_eq!(count_leaves(&pr.root), 2);
1010 let mut leaves = Vec::new();
1012 collect_leaves(&pr.root, &mut leaves);
1013 assert_eq!(leaves[0].label, "1");
1014 assert_eq!(leaves[1].label, "2");
1015 }
1016
1017 #[test]
1019 fn two_column_with_divider_keeps_gutter() {
1020 let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![300.0]);
1024 let mut bboxes = Vec::new();
1025 let mut y = 80.0;
1026 while y + 14.0 <= 700.0 {
1027 bboxes.push(mk_bbox(110.0, y, 170.0, 14.0));
1028 bboxes.push(mk_bbox(320.0, y, 170.0, 14.0));
1029 y += 14.0;
1030 }
1031 let pr = run(bboxes, &geometry);
1032 assert_eq!(pr.root.axis, Some(CutAxis::V));
1034 assert_eq!(pr.root.cut_coords.len(), 1);
1035 assert!(
1036 (pr.root.cut_coords[0] - 300.0).abs() < 15.0,
1037 "v-cut at {:?} not within tolerance of divider 300",
1038 pr.root.cut_coords
1039 );
1040 assert_eq!(pr.root.children.len(), 2);
1041 assert_eq!(pr.diagnostic.merged_subtrees, 0);
1043 }
1044
1045 #[test]
1047 fn two_column_without_divider_collapses() {
1048 let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
1051 let mut bboxes = Vec::new();
1052 let mut y = 80.0;
1053 while y + 14.0 <= 700.0 {
1054 bboxes.push(mk_bbox(110.0, y, 170.0, 14.0));
1055 bboxes.push(mk_bbox(320.0, y, 170.0, 14.0));
1056 y += 14.0;
1057 }
1058 let pr = run(bboxes, &geometry);
1059 assert_eq!(count_leaves(&pr.root), 1);
1062 assert_eq!(pr.diagnostic.merged_subtrees, 1);
1063 }
1064
1065 #[test]
1067 fn inline_word_gap_gets_merged_away_in_single_column() {
1068 let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
1073 let mut bboxes = Vec::new();
1074 bboxes.push(mk_bbox(110.0, 80.0, 12.0, 14.0));
1076 bboxes.push(mk_bbox(145.0, 80.0, 100.0, 14.0));
1077 let mut y = 110.0;
1079 while y + 14.0 <= 700.0 {
1080 bboxes.push(mk_bbox(110.0, y, 380.0, 14.0));
1081 y += 14.0;
1082 }
1083 let pr = run(bboxes, &geometry);
1084 let mut walk = vec![&pr.root];
1087 let mut found_unaligned_v = false;
1088 while let Some(r) = walk.pop() {
1089 if r.axis == Some(CutAxis::V) {
1090 found_unaligned_v = true;
1091 }
1092 walk.extend(r.children.iter());
1093 }
1094 assert!(
1095 !found_unaligned_v,
1096 "single-col page must not retain any v-cut"
1097 );
1098 }
1099
1100 #[test]
1102 fn labels_follow_depth_first_top_then_left() {
1103 let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![300.0]);
1106 let mut bboxes = Vec::new();
1107 let mut y = 80.0;
1109 while y + 14.0 <= 300.0 {
1110 bboxes.push(mk_bbox(110.0, y, 380.0, 14.0));
1111 y += 14.0;
1112 }
1113 y = 350.0;
1115 while y + 14.0 <= 700.0 {
1116 bboxes.push(mk_bbox(110.0, y, 170.0, 14.0));
1117 bboxes.push(mk_bbox(320.0, y, 170.0, 14.0));
1118 y += 14.0;
1119 }
1120 let pr = run(bboxes, &geometry);
1121 let mut leaves = Vec::new();
1122 collect_leaves(&pr.root, &mut leaves);
1123 assert_eq!(leaves.len(), 3, "expected 3 leaves");
1125 assert_eq!(leaves[0].label, "1");
1126 assert_eq!(leaves[1].label, "2-1");
1127 assert_eq!(leaves[2].label, "2-2");
1128 }
1129
1130 #[test]
1132 fn leaf_element_indices_cover_body_elements() {
1133 let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
1134 let bboxes = vec![
1135 mk_bbox(110.0, 80.0, 380.0, 14.0),
1136 mk_bbox(110.0, 100.0, 380.0, 14.0),
1137 mk_bbox(110.0, 120.0, 380.0, 14.0),
1138 ];
1139 let pr = run(bboxes, &geometry);
1140 let mut leaves = Vec::new();
1141 collect_leaves(&pr.root, &mut leaves);
1142 let mut idxs: Vec<u32> = leaves
1143 .iter()
1144 .flat_map(|l| l.element_indices.clone())
1145 .collect();
1146 idxs.sort();
1147 assert_eq!(idxs, vec![0, 1, 2]);
1148 }
1149
1150 #[test]
1152 fn empty_body_yields_single_empty_leaf() {
1153 let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
1154 let pr = run(vec![], &geometry);
1155 assert_eq!(count_leaves(&pr.root), 1);
1156 let mut leaves = Vec::new();
1157 collect_leaves(&pr.root, &mut leaves);
1158 assert!(leaves[0].element_indices.is_empty());
1159 }
1160
1161 #[test]
1163 fn determinism_byte_identical_json() {
1164 let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![300.0]);
1165 let mut bboxes = Vec::new();
1166 let mut y = 80.0;
1167 while y + 14.0 <= 700.0 {
1168 bboxes.push(mk_bbox(110.0, y, 170.0, 14.0));
1169 bboxes.push(mk_bbox(320.0, y, 170.0, 14.0));
1170 y += 14.0;
1171 }
1172 let a = serde_json::to_string(&run(bboxes.clone(), &geometry).root).unwrap();
1173 let b = serde_json::to_string(&run(bboxes, &geometry).root).unwrap();
1174 assert_eq!(a, b);
1175 }
1176
1177 #[test]
1179 fn body_box_uses_doc_footer_y_per_marcus_2026_05_06() {
1180 let mut geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
1184 geometry.per_page_footer_y = vec![Some(550.0)];
1185 let body = body_box_for_page(&geometry);
1186 assert_eq!(body.y1, 720.0, "body box y1 must use doc_footer_y");
1187 }
1188}