use serde::{Deserialize, Serialize};
use crate::analytics::statistic::{FinalizationContext, Statistic};
use crate::types::{BoundingBox, PdfTextElement};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Region {
pub r#box: RegionBox,
pub axis: Option<CutAxis>,
pub cut_coords: Vec<f32>,
pub children: Vec<Region>,
pub label: String,
pub element_indices: Vec<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PageRegions {
pub page_number: u32,
pub body_box: RegionBox,
pub median_line_height: f32,
pub body_element_indices: Vec<u32>,
pub root: Region,
pub diagnostic: PageRegionDiagnostic,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PageRegionDiagnostic {
pub merged_subtrees: u32,
pub bbox_filtered: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CutAxis {
H,
V,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct RegionBox {
pub x0: f32,
pub y0: f32,
pub x1: f32,
pub y1: f32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RegionStats {
pub per_page: Vec<PageRegions>,
pub source_pages: u32,
}
#[derive(Debug, Clone)]
pub struct RegionStatsConfig {
pub abs_min_band_pt: f32,
pub rel_min_band_line_heights: f32,
pub top_k_fraction: f32,
pub retry_factors: Vec<f32>,
pub max_cuts_at_retry: usize,
pub max_depth: usize,
pub column_divider_tolerance_pt: f32,
pub bbox_crossing_band_perp_pt: f32,
pub bbox_crossing_band_inset_pt: f32,
}
impl Default for RegionStatsConfig {
fn default() -> Self {
Self {
abs_min_band_pt: 8.0,
rel_min_band_line_heights: 1.2,
top_k_fraction: 0.60,
retry_factors: vec![1.0, 0.75, 0.55, 0.4],
max_cuts_at_retry: 3,
max_depth: 8,
column_divider_tolerance_pt: 15.0,
bbox_crossing_band_perp_pt: 8.0,
bbox_crossing_band_inset_pt: 5.0,
}
}
}
#[derive(Debug, Clone)]
struct Observed {
global_idx: u32,
bbox: BoundingBox,
}
#[derive(Debug, Default)]
struct PageObservation {
page_number: u32,
width: f32,
height: f32,
elements: Vec<Observed>,
}
#[derive(Debug, Default)]
pub struct RegionStatsBuilder {
config: RegionStatsConfig,
pages: Vec<PageObservation>,
next_global_idx: u32,
}
impl RegionStatsBuilder {
pub fn new(config: RegionStatsConfig) -> Self {
Self {
config,
pages: Vec::new(),
next_global_idx: 0,
}
}
fn page_slot(&mut self, page_number: u32) -> usize {
if let Some(idx) = self.pages.iter().position(|p| p.page_number == page_number) {
return idx;
}
self.pages.push(PageObservation {
page_number,
width: 0.0,
height: 0.0,
elements: Vec::new(),
});
self.pages.len() - 1
}
}
impl Statistic for RegionStatsBuilder {
type Output = RegionStats;
const NAME: &'static str = "region";
fn observe(&mut self, element: &PdfTextElement) {
let global_idx = self.next_global_idx;
self.next_global_idx = self.next_global_idx.saturating_add(1);
if element.rotation() != 0 {
return;
}
let bbox = element.bounding_box().clone();
let page_w = element.placement.page_width;
let page_h = element.placement.page_height;
let page_number = element.page_number();
let idx = self.page_slot(page_number);
let page = &mut self.pages[idx];
if page_w > 0.0 {
page.width = page_w;
} else {
let right = bbox.x + bbox.width;
if right > page.width {
page.width = right;
}
}
if page_h > 0.0 {
page.height = page_h;
} else {
let bottom = bbox.y + bbox.height;
if bottom > page.height {
page.height = bottom;
}
}
page.elements.push(Observed { global_idx, bbox });
}
fn finalize(self, ctx: &FinalizationContext<'_>) -> Self::Output {
let geometry = match ctx.geometry {
Some(g) => g,
None => return RegionStats::default(),
};
let mut per_page = Vec::with_capacity(self.pages.len());
for page in self.pages.iter() {
per_page.push(finalize_page(page, geometry, &self.config));
}
let source_pages = per_page.len() as u32;
RegionStats {
per_page,
source_pages,
}
}
}
fn finalize_page(
page: &PageObservation,
geometry: &crate::analytics::geometry::GeometryStats,
config: &RegionStatsConfig,
) -> PageRegions {
let body_box = body_box_for_page(geometry);
let mut body_indices_local: Vec<u32> = Vec::new();
let mut body_global_indices: Vec<u32> = Vec::new();
let mut body_bboxes: Vec<BoundingBox> = Vec::new();
for o in &page.elements {
if overlaps(&o.bbox, &body_box) {
body_indices_local.push(body_indices_local.len() as u32);
body_global_indices.push(o.global_idx);
body_bboxes.push(o.bbox.clone());
}
}
let mlh = median_line_height(&body_bboxes);
let mut root = xy_cut(body_box, &body_bboxes, &body_indices_local, 0, mlh, config);
let merged = merge_overfragmented(
&mut root,
&geometry.column_layout.column_dividers,
config.column_divider_tolerance_pt,
);
let bbox_filtered = remove_bbox_crossing_cuts(
&mut root,
&body_bboxes,
config.bbox_crossing_band_perp_pt,
config.bbox_crossing_band_inset_pt,
);
label_tree(&mut root, "");
PageRegions {
page_number: page.page_number,
body_box,
median_line_height: mlh,
body_element_indices: body_global_indices,
root,
diagnostic: PageRegionDiagnostic {
merged_subtrees: merged,
bbox_filtered,
},
}
}
fn body_box_for_page(geometry: &crate::analytics::geometry::GeometryStats) -> RegionBox {
RegionBox {
x0: geometry.left_x,
y0: geometry.header_y,
x1: geometry.right_x,
y1: geometry.doc_footer_y,
}
}
fn overlaps(bbox: &BoundingBox, region: &RegionBox) -> bool {
let x1 = bbox.x + bbox.width;
let y1 = bbox.y + bbox.height;
!(x1 <= region.x0 || bbox.x >= region.x1 || y1 <= region.y0 || bbox.y >= region.y1)
}
fn median_line_height(bboxes: &[BoundingBox]) -> f32 {
let mut heights: Vec<f32> = bboxes
.iter()
.map(|b| b.height)
.filter(|h| *h > 0.0)
.collect();
if heights.is_empty() {
return 12.0;
}
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
heights[heights.len() / 2]
}
fn xy_cut(
region_box: RegionBox,
bboxes: &[BoundingBox],
bbox_indices: &[u32],
depth: usize,
mlh: f32,
config: &RegionStatsConfig,
) -> Region {
debug_assert_eq!(bboxes.len(), bbox_indices.len());
if depth >= config.max_depth {
return leaf(region_box, bbox_indices);
}
for (attempt, &factor) in config.retry_factors.iter().enumerate() {
let h_bands = find_interior_bands(region_box, bboxes, CutAxis::H, mlh, factor, config);
let v_bands = find_interior_bands(region_box, bboxes, CutAxis::V, mlh, factor, config);
if h_bands.is_empty() && v_bands.is_empty() {
continue;
}
let h_largest = h_bands.iter().map(|b| b.thickness).fold(0.0, f32::max);
let v_largest = v_bands.iter().map(|b| b.thickness).fold(0.0, f32::max);
let (chosen_axis, chosen_bands, chosen_max) = if h_largest >= v_largest {
(CutAxis::H, h_bands, h_largest)
} else {
(CutAxis::V, v_bands, v_largest)
};
let mut kept: Vec<Band> = chosen_bands
.into_iter()
.filter(|b| b.thickness >= config.top_k_fraction * chosen_max)
.collect();
kept.sort_by(|a, b| {
a.mid
.partial_cmp(&b.mid)
.unwrap_or(std::cmp::Ordering::Equal)
});
if attempt > 0 && kept.len() > config.max_cuts_at_retry {
return leaf(region_box, bbox_indices);
}
let cut_coords: Vec<f32> = kept.iter().map(|b| b.mid).collect();
let cut_coords = filter_cuts_with_content(region_box, bboxes, chosen_axis, &cut_coords);
if cut_coords.is_empty() {
continue;
}
let children = split_and_recurse(
region_box,
bboxes,
bbox_indices,
chosen_axis,
&cut_coords,
depth,
mlh,
config,
);
return Region {
r#box: region_box,
axis: Some(chosen_axis),
cut_coords,
children,
label: String::new(),
element_indices: Vec::new(),
};
}
leaf(region_box, bbox_indices)
}
fn leaf(region_box: RegionBox, indices: &[u32]) -> Region {
Region {
r#box: region_box,
axis: None,
cut_coords: Vec::new(),
children: Vec::new(),
label: String::new(),
element_indices: indices.to_vec(),
}
}
#[allow(clippy::too_many_arguments)]
fn split_and_recurse(
region_box: RegionBox,
bboxes: &[BoundingBox],
bbox_indices: &[u32],
axis: CutAxis,
cuts: &[f32],
depth: usize,
mlh: f32,
config: &RegionStatsConfig,
) -> Vec<Region> {
let mut children: Vec<Region> = Vec::with_capacity(cuts.len() + 1);
let strips = strip_boxes(region_box, axis, cuts);
for strip in strips {
let (sub_bboxes, sub_indices) = bboxes_in(strip, bboxes, bbox_indices);
children.push(xy_cut(
strip,
&sub_bboxes,
&sub_indices,
depth + 1,
mlh,
config,
));
}
children
}
fn strip_boxes(region_box: RegionBox, axis: CutAxis, cuts: &[f32]) -> Vec<RegionBox> {
let mut strips: Vec<RegionBox> = Vec::with_capacity(cuts.len() + 1);
match axis {
CutAxis::H => {
let mut prev = region_box.y0;
for &c in cuts {
strips.push(RegionBox {
x0: region_box.x0,
y0: prev,
x1: region_box.x1,
y1: c,
});
prev = c;
}
strips.push(RegionBox {
x0: region_box.x0,
y0: prev,
x1: region_box.x1,
y1: region_box.y1,
});
}
CutAxis::V => {
let mut prev = region_box.x0;
for &c in cuts {
strips.push(RegionBox {
x0: prev,
y0: region_box.y0,
x1: c,
y1: region_box.y1,
});
prev = c;
}
strips.push(RegionBox {
x0: prev,
y0: region_box.y0,
x1: region_box.x1,
y1: region_box.y1,
});
}
}
strips
}
fn bboxes_in(
region: RegionBox,
bboxes: &[BoundingBox],
indices: &[u32],
) -> (Vec<BoundingBox>, Vec<u32>) {
let mut out_b: Vec<BoundingBox> = Vec::new();
let mut out_i: Vec<u32> = Vec::new();
for (b, &i) in bboxes.iter().zip(indices.iter()) {
if overlaps(b, ®ion) {
out_b.push(b.clone());
out_i.push(i);
}
}
(out_b, out_i)
}
#[derive(Debug, Clone, Copy)]
struct Band {
#[allow(dead_code)]
start: f32,
#[allow(dead_code)]
end: f32,
mid: f32,
thickness: f32,
}
fn find_interior_bands(
region_box: RegionBox,
bboxes: &[BoundingBox],
axis: CutAxis,
mlh: f32,
factor: f32,
config: &RegionStatsConfig,
) -> Vec<Band> {
let (lo, hi) = match axis {
CutAxis::H => (region_box.y0.round() as i32, region_box.y1.round() as i32),
CutAxis::V => (region_box.x0.round() as i32, region_box.x1.round() as i32),
};
if hi <= lo {
return Vec::new();
}
let span = (hi - lo) as usize;
let mut filled = vec![false; span];
for b in bboxes {
let (a_raw, b_raw) = match axis {
CutAxis::H => ((b.y).round() as i32, (b.y + b.height).round() as i32),
CutAxis::V => ((b.x).round() as i32, (b.x + b.width).round() as i32),
};
let a = a_raw.max(lo);
let bb = b_raw.min(hi);
if bb <= a {
continue;
}
filled[(a - lo) as usize..(bb - lo) as usize].fill(true);
}
let mut runs: Vec<(i32, i32)> = Vec::new();
let mut in_run = false;
let mut run_start = lo;
for (i, &f) in filled.iter().enumerate() {
let pos = lo + i as i32;
if !f && !in_run {
in_run = true;
run_start = pos;
} else if f && in_run {
in_run = false;
runs.push((run_start, pos));
}
}
if in_run {
runs.push((run_start, hi));
}
let min_thickness =
(factor * config.abs_min_band_pt).max(factor * config.rel_min_band_line_heights * mlh);
let mut bands: Vec<Band> = Vec::new();
for (start, end) in runs {
if start <= lo || end >= hi {
continue;
}
let thickness = (end - start) as f32;
if thickness < min_thickness {
continue;
}
bands.push(Band {
start: start as f32,
end: end as f32,
mid: (start as f32 + end as f32) / 2.0,
thickness,
});
}
bands
}
fn filter_cuts_with_content(
region_box: RegionBox,
bboxes: &[BoundingBox],
axis: CutAxis,
cuts: &[f32],
) -> Vec<f32> {
if cuts.is_empty() {
return Vec::new();
}
let mut accepted: Vec<f32> = Vec::new();
let mut prev = match axis {
CutAxis::H => region_box.y0,
CutAxis::V => region_box.x0,
};
for &c in cuts {
let strip = match axis {
CutAxis::H => RegionBox {
x0: region_box.x0,
y0: prev,
x1: region_box.x1,
y1: c,
},
CutAxis::V => RegionBox {
x0: prev,
y0: region_box.y0,
x1: c,
y1: region_box.y1,
},
};
if bboxes.iter().any(|b| overlaps(b, &strip)) {
accepted.push(c);
prev = c;
}
}
while let Some(&last) = accepted.last() {
let tail = match axis {
CutAxis::H => RegionBox {
x0: region_box.x0,
y0: last,
x1: region_box.x1,
y1: region_box.y1,
},
CutAxis::V => RegionBox {
x0: last,
y0: region_box.y0,
x1: region_box.x1,
y1: region_box.y1,
},
};
if bboxes.iter().any(|b| overlaps(b, &tail)) {
break;
}
accepted.pop();
}
accepted
}
fn merge_overfragmented(region: &mut Region, dividers: &[f32], tolerance: f32) -> u32 {
let mut n = 0;
for child in region.children.iter_mut() {
n += merge_overfragmented(child, dividers, tolerance);
}
if matches!(region.axis, Some(CutAxis::V)) && !region.cut_coords.is_empty() {
let any_aligned = region
.cut_coords
.iter()
.any(|c| aligns_with_divider(*c, dividers, tolerance));
if !any_aligned {
collapse_to_leaf(region);
n += 1;
}
}
n
}
fn aligns_with_divider(cut: f32, dividers: &[f32], tolerance: f32) -> bool {
dividers.iter().any(|d| (cut - *d).abs() <= tolerance)
}
fn collapse_to_leaf(region: &mut Region) {
let indices = gather_leaf_indices(region);
region.axis = None;
region.cut_coords.clear();
region.children.clear();
region.element_indices = indices;
}
fn gather_leaf_indices(region: &Region) -> Vec<u32> {
if region.children.is_empty() {
return region.element_indices.clone();
}
let mut out: Vec<u32> = Vec::new();
for child in ®ion.children {
out.extend(gather_leaf_indices(child));
}
out
}
fn remove_bbox_crossing_cuts(
region: &mut Region,
all_bboxes: &[BoundingBox],
perp: f32,
along_inset: f32,
) -> u32 {
let mut n = 0;
for child in region.children.iter_mut() {
n += remove_bbox_crossing_cuts(child, all_bboxes, perp, along_inset);
}
if region.children.is_empty() || region.cut_coords.is_empty() {
return n;
}
let region_bboxes: Vec<&BoundingBox> = all_bboxes
.iter()
.filter(|b| overlaps(b, ®ion.r#box))
.collect();
let half = perp / 2.0;
let mut crossing = false;
for &cut in ®ion.cut_coords {
let band = match region.axis {
Some(CutAxis::H) => RegionBox {
x0: region.r#box.x0 + along_inset,
y0: cut - half,
x1: region.r#box.x1 - along_inset,
y1: cut + half,
},
Some(CutAxis::V) => RegionBox {
x0: cut - half,
y0: region.r#box.y0 + along_inset,
x1: cut + half,
y1: region.r#box.y1 - along_inset,
},
None => continue,
};
for b in ®ion_bboxes {
if overlaps(b, &band) {
crossing = true;
break;
}
}
if crossing {
break;
}
}
if crossing {
let indices = gather_leaf_indices(region);
region.axis = None;
region.cut_coords.clear();
region.children.clear();
region.element_indices = indices;
n += 1;
}
n
}
fn label_tree(region: &mut Region, prefix: &str) {
if region.children.is_empty() {
region.label = if prefix.is_empty() {
"1".to_string()
} else {
prefix.to_string()
};
return;
}
for (i, child) in region.children.iter_mut().enumerate() {
let n = i + 1;
let child_prefix = if prefix.is_empty() {
n.to_string()
} else {
format!("{prefix}-{n}")
};
label_tree(child, &child_prefix);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analytics::geometry::{ColumnLayout, GeometryStats};
fn mk_bbox(x: f32, y: f32, w: f32, h: f32) -> BoundingBox {
BoundingBox {
x,
y,
width: w,
height: h,
}
}
fn mk_geometry(
header_y: f32,
doc_footer_y: f32,
left_x: f32,
right_x: f32,
dividers: Vec<f32>,
) -> GeometryStats {
GeometryStats {
header_y,
doc_footer_y,
left_x,
right_x,
column_layout: ColumnLayout {
column_count: (dividers.len() + 1) as u32,
column_dividers: dividers,
},
..Default::default()
}
}
fn run(bboxes: Vec<BoundingBox>, geometry: &GeometryStats) -> PageRegions {
let indices: Vec<u32> = (0..bboxes.len() as u32).collect();
let cfg = RegionStatsConfig::default();
let body_box = body_box_for_page(geometry);
let body_bboxes: Vec<BoundingBox> = bboxes
.iter()
.filter(|b| overlaps(b, &body_box))
.cloned()
.collect();
let body_indices: Vec<u32> = (0..body_bboxes.len() as u32).collect();
let mlh = median_line_height(&body_bboxes);
let mut root = xy_cut(body_box, &body_bboxes, &body_indices, 0, mlh, &cfg);
let merged = merge_overfragmented(
&mut root,
&geometry.column_layout.column_dividers,
cfg.column_divider_tolerance_pt,
);
let bbox_filtered = remove_bbox_crossing_cuts(
&mut root,
&body_bboxes,
cfg.bbox_crossing_band_perp_pt,
cfg.bbox_crossing_band_inset_pt,
);
label_tree(&mut root, "");
PageRegions {
page_number: 1,
body_box,
median_line_height: mlh,
body_element_indices: indices,
root,
diagnostic: PageRegionDiagnostic {
merged_subtrees: merged,
bbox_filtered,
},
}
}
fn count_leaves(region: &Region) -> u32 {
if region.children.is_empty() {
1
} else {
region.children.iter().map(count_leaves).sum()
}
}
fn collect_leaves<'a>(region: &'a Region, out: &mut Vec<&'a Region>) {
if region.children.is_empty() {
out.push(region);
} else {
for c in ®ion.children {
collect_leaves(c, out);
}
}
}
#[test]
fn single_block_yields_single_leaf() {
let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
let mut bboxes = Vec::new();
let mut y = 80.0;
while y + 14.0 <= 700.0 {
bboxes.push(mk_bbox(110.0, y, 380.0, 14.0));
y += 14.0;
}
let pr = run(bboxes, &geometry);
assert_eq!(count_leaves(&pr.root), 1);
let mut leaves = Vec::new();
collect_leaves(&pr.root, &mut leaves);
assert_eq!(leaves[0].label, "1");
}
#[test]
fn body_with_section_gap_splits_horizontally() {
let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
let mut bboxes = Vec::new();
let mut y = 80.0;
while y + 14.0 <= 300.0 {
bboxes.push(mk_bbox(110.0, y, 380.0, 14.0));
y += 14.0;
}
y = 350.0;
while y + 14.0 <= 700.0 {
bboxes.push(mk_bbox(110.0, y, 380.0, 14.0));
y += 14.0;
}
let pr = run(bboxes, &geometry);
assert_eq!(pr.root.axis, Some(CutAxis::H));
assert_eq!(pr.root.cut_coords.len(), 1);
assert_eq!(count_leaves(&pr.root), 2);
let mut leaves = Vec::new();
collect_leaves(&pr.root, &mut leaves);
assert_eq!(leaves[0].label, "1");
assert_eq!(leaves[1].label, "2");
}
#[test]
fn two_column_with_divider_keeps_gutter() {
let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![300.0]);
let mut bboxes = Vec::new();
let mut y = 80.0;
while y + 14.0 <= 700.0 {
bboxes.push(mk_bbox(110.0, y, 170.0, 14.0));
bboxes.push(mk_bbox(320.0, y, 170.0, 14.0));
y += 14.0;
}
let pr = run(bboxes, &geometry);
assert_eq!(pr.root.axis, Some(CutAxis::V));
assert_eq!(pr.root.cut_coords.len(), 1);
assert!(
(pr.root.cut_coords[0] - 300.0).abs() < 15.0,
"v-cut at {:?} not within tolerance of divider 300",
pr.root.cut_coords
);
assert_eq!(pr.root.children.len(), 2);
assert_eq!(pr.diagnostic.merged_subtrees, 0);
}
#[test]
fn two_column_without_divider_collapses() {
let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
let mut bboxes = Vec::new();
let mut y = 80.0;
while y + 14.0 <= 700.0 {
bboxes.push(mk_bbox(110.0, y, 170.0, 14.0));
bboxes.push(mk_bbox(320.0, y, 170.0, 14.0));
y += 14.0;
}
let pr = run(bboxes, &geometry);
assert_eq!(count_leaves(&pr.root), 1);
assert_eq!(pr.diagnostic.merged_subtrees, 1);
}
#[test]
fn inline_word_gap_gets_merged_away_in_single_column() {
let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
let mut bboxes = Vec::new();
bboxes.push(mk_bbox(110.0, 80.0, 12.0, 14.0));
bboxes.push(mk_bbox(145.0, 80.0, 100.0, 14.0));
let mut y = 110.0;
while y + 14.0 <= 700.0 {
bboxes.push(mk_bbox(110.0, y, 380.0, 14.0));
y += 14.0;
}
let pr = run(bboxes, &geometry);
let mut walk = vec![&pr.root];
let mut found_unaligned_v = false;
while let Some(r) = walk.pop() {
if r.axis == Some(CutAxis::V) {
found_unaligned_v = true;
}
walk.extend(r.children.iter());
}
assert!(
!found_unaligned_v,
"single-col page must not retain any v-cut"
);
}
#[test]
fn labels_follow_depth_first_top_then_left() {
let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![300.0]);
let mut bboxes = Vec::new();
let mut y = 80.0;
while y + 14.0 <= 300.0 {
bboxes.push(mk_bbox(110.0, y, 380.0, 14.0));
y += 14.0;
}
y = 350.0;
while y + 14.0 <= 700.0 {
bboxes.push(mk_bbox(110.0, y, 170.0, 14.0));
bboxes.push(mk_bbox(320.0, y, 170.0, 14.0));
y += 14.0;
}
let pr = run(bboxes, &geometry);
let mut leaves = Vec::new();
collect_leaves(&pr.root, &mut leaves);
assert_eq!(leaves.len(), 3, "expected 3 leaves");
assert_eq!(leaves[0].label, "1");
assert_eq!(leaves[1].label, "2-1");
assert_eq!(leaves[2].label, "2-2");
}
#[test]
fn leaf_element_indices_cover_body_elements() {
let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
let bboxes = vec![
mk_bbox(110.0, 80.0, 380.0, 14.0),
mk_bbox(110.0, 100.0, 380.0, 14.0),
mk_bbox(110.0, 120.0, 380.0, 14.0),
];
let pr = run(bboxes, &geometry);
let mut leaves = Vec::new();
collect_leaves(&pr.root, &mut leaves);
let mut idxs: Vec<u32> = leaves
.iter()
.flat_map(|l| l.element_indices.clone())
.collect();
idxs.sort();
assert_eq!(idxs, vec![0, 1, 2]);
}
#[test]
fn empty_body_yields_single_empty_leaf() {
let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
let pr = run(vec![], &geometry);
assert_eq!(count_leaves(&pr.root), 1);
let mut leaves = Vec::new();
collect_leaves(&pr.root, &mut leaves);
assert!(leaves[0].element_indices.is_empty());
}
#[test]
fn determinism_byte_identical_json() {
let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![300.0]);
let mut bboxes = Vec::new();
let mut y = 80.0;
while y + 14.0 <= 700.0 {
bboxes.push(mk_bbox(110.0, y, 170.0, 14.0));
bboxes.push(mk_bbox(320.0, y, 170.0, 14.0));
y += 14.0;
}
let a = serde_json::to_string(&run(bboxes.clone(), &geometry).root).unwrap();
let b = serde_json::to_string(&run(bboxes, &geometry).root).unwrap();
assert_eq!(a, b);
}
#[test]
fn body_box_uses_doc_footer_y_per_marcus_2026_05_06() {
let mut geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
geometry.per_page_footer_y = vec![Some(550.0)];
let body = body_box_for_page(&geometry);
assert_eq!(body.y1, 720.0, "body box y1 must use doc_footer_y");
}
}