Skip to main content

blazegraph_io_core/analytics/
region.rs

1// Per-page Region tree from XY-cut + retries + column-divider-aligned merge
2// + bbox-crossing safety filter. Ported from `scripts/xy_cut_prototype.py`
3// (canonical for algorithm behaviour). Block 03b of the document-analytics
4// flow — see `docs/P2/core/handoffs/2026-05-04-xy-cut-section-detection-prototype.md`.
5//
6// The Region tree is the geometric prepass for section detection: leaves are
7// structural units (section headers, paragraph blocks, figures, footnotes),
8// and depth-first traversal yields reading order. Classification (which leaf
9// is a section vs paragraph vs caption) is downstream — this pass is pure
10// geometry.
11//
12// Body box: per Marcus 2026-05-06, uses `geometry.doc_footer_y` rather than
13// `geometry.per_page_footer_y[p]`. Per-page footer is currently fragile
14// (Tika size-uniformity + per-segment span granularity); doc-level gives a
15// deterministic single rectangle. Easy revert when per-page detection gets
16// its own CR — see `body_box_for_page` for the swap point.
17
18use serde::{Deserialize, Serialize};
19
20use crate::analytics::statistic::{FinalizationContext, Statistic};
21use crate::types::{BoundingBox, PdfTextElement};
22
23// ---------------------------------------------------------------------------
24// Output type shape
25// ---------------------------------------------------------------------------
26
27/// One node in the partition tree (interior or leaf).
28///
29/// - Interior: `axis` is `Some(H|V)`, `cut_coords` is non-empty, `children`
30///   is non-empty, `element_indices` is empty.
31/// - Leaf: `axis` is `None`, `cut_coords` is empty, `children` is empty,
32///   `element_indices` carries indices into the page's body-element list.
33#[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    /// Reading-order path label: `"1"`, `"2-1"`, `"2-1-3"`, etc. Filled by
40    /// `label_tree` after the algorithm finishes. Empty until then.
41    pub label: String,
42    /// Page-local indices into `PageRegions.body_element_indices`. Each
43    /// entry is itself an index into the document-wide `text_elements` list
44    /// the analytics builder consumed. Resolving a leaf to elements:
45    /// `for i in leaf.element_indices: page.body_element_indices[i]` →
46    /// document-wide index → `preprocessor_output.text_elements[that]`.
47    pub element_indices: Vec<u32>,
48}
49
50/// Per-page Region tree plus the body-element index map the leaves refer
51/// to. The two fields are paired: leaf indices are positions in
52/// `body_element_indices`, which themselves are document-wide element
53/// indices (so consumers can dereference the original `PdfTextElement`).
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct PageRegions {
56    /// 1-indexed page number on the source PDF.
57    pub page_number: u32,
58    /// Body box used for this page (same shape per page in the
59    /// doc_footer_y formulation; varies if/when per-page footer is wired).
60    pub body_box: RegionBox,
61    /// Median line height of body elements on this page, the band-threshold
62    /// driver for XY-cut.
63    pub median_line_height: f32,
64    /// Document-wide indices into the analytics builder's element stream.
65    /// Already filtered: rotation == 0 + bbox overlaps body_box. Region
66    /// leaves index into this list, not the global one.
67    pub body_element_indices: Vec<u32>,
68    /// The tree.
69    pub root: Region,
70    /// Per-page run telemetry — useful for debugging without re-running the
71    /// algorithm.
72    pub diagnostic: PageRegionDiagnostic,
73}
74
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76pub struct PageRegionDiagnostic {
77    /// Number of v-cut subtrees collapsed by the column-divider-aligned merge.
78    pub merged_subtrees: u32,
79    /// Number of nodes collapsed by the bbox-crossing safety filter.
80    pub bbox_filtered: u32,
81}
82
83/// Cut axis for an interior Region node.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85pub enum CutAxis {
86    /// Horizontal cut: child boxes stack vertically (top-to-bottom reading order).
87    H,
88    /// Vertical cut: child boxes sit side-by-side (left-to-right reading order).
89    V,
90}
91
92/// Inclusive-on-low, exclusive-on-high box in PDF/Tika point coordinates
93/// (y increases downward).
94#[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    /// Per-page Region trees in observation order. Empty when GeometryStats
105    /// is not available in the FinalizationContext (the algorithm needs the
106    /// body box and column dividers from geometry to run).
107    pub per_page: Vec<PageRegions>,
108    /// Number of pages the algorithm ran over.
109    pub source_pages: u32,
110}
111
112// ---------------------------------------------------------------------------
113// Configuration
114// ---------------------------------------------------------------------------
115
116/// Tunable parameters for the Region tree construction. Defaults exactly
117/// match `scripts/xy_cut_prototype.py` and were validated on the 6-PDF
118/// corpus per the 2026-05-04 session.
119#[derive(Debug, Clone)]
120pub struct RegionStatsConfig {
121    /// Absolute floor on band thickness in pt. Default: 8.0.
122    pub abs_min_band_pt: f32,
123    /// Multiple of median line height in box. Default: 1.2. Combined with
124    /// `abs_min_band_pt` via `max(abs, rel × mlh)` to set the per-region
125    /// band-thickness threshold.
126    pub rel_min_band_line_heights: f32,
127    /// Adaptive top-K filter: keep all bands whose thickness is at least
128    /// this fraction of the largest band's thickness. Default: 0.60.
129    /// Captures simultaneous structural breaks (e.g., a 3-row table split).
130    pub top_k_fraction: f32,
131    /// Threshold retry factors. The first attempt uses `1.0` (= the default
132    /// threshold); if no cuts emerge, retry with progressively lower
133    /// factors. Default: `[1.0, 0.75, 0.55, 0.4]` — 1 default + 3 retries.
134    pub retry_factors: Vec<f32>,
135    /// Explosion guard: if a *retry* attempt would produce more than this
136    /// many cuts after the top-K filter, abandon and treat as a single
137    /// block. Default: 3. The default attempt is never gated.
138    pub max_cuts_at_retry: usize,
139    /// Recursion depth cap. Default: 8. The algorithmic stop is "no bands";
140    /// this is a safety floor.
141    pub max_depth: usize,
142    /// Tolerance (pt) on doc-level column-divider alignment. A v-cut whose
143    /// any cut sits within ±`tolerance` of a divider preserves the entire
144    /// node; otherwise the node collapses. Default: 15.0.
145    pub column_divider_tolerance_pt: f32,
146    /// Total band thickness perpendicular to the cut for the bbox-crossing
147    /// safety filter. Default: 8.0 → cuts span ±4pt around the cut line.
148    pub bbox_crossing_band_perp_pt: f32,
149    /// Inset at each end of the cut (along its direction). Elements at the
150    /// box edges (page numbers, marginalia) shouldn't invalidate a body
151    /// cut just because they sit on the same line near the margin.
152    /// Default: 5.0.
153    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// ---------------------------------------------------------------------------
173// Builder
174// ---------------------------------------------------------------------------
175
176/// Per-element observation: just enough to run XY-cut without dragging the
177/// full PdfTextElement (rotation filter applied at observe time; bbox
178/// overlap with body box applied at finalize time).
179#[derive(Debug, Clone)]
180struct Observed {
181    /// Index into the document-wide element stream the analytics builder
182    /// consumed. Region leaves carry indices into a per-page subset of these.
183    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/// Builder for the per-page Region tree statistic.
196///
197/// Constructed once per document. Call [`observe`] for every
198/// [`PdfTextElement`] in reading order, then [`finalize`] with a
199/// FinalizationContext that has GeometryStats available.
200#[derive(Debug, Default)]
201pub struct RegionStatsBuilder {
202    config: RegionStatsConfig,
203    pages: Vec<PageObservation>,
204    /// Document-wide element counter, incremented on every observe call
205    /// (regardless of filtering). Stored on each Observed so leaves can
206    /// resolve back to the original PdfTextElement.
207    next_global_idx: u32,
208}
209
210impl RegionStatsBuilder {
211    /// Construct a builder with explicit config. The `AnalysisBuilder` in
212    /// `builder.rs` uses `RegionStatsBuilder::default()` which picks up
213    /// `RegionStatsConfig::default()`.
214    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        // Page dimensions: prefer Tika's page-meta over bbox-extent (same
256        // pattern as GeometryStatsBuilder.observe). Used for diagnostic /
257        // future per-region column-detection work; xy_cut itself uses the
258        // body box from GeometryStats, not page.{width,height}.
259        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            // Without geometry we don't have a body box or column dividers
283            // — emit empty output and let downstream fall back. AnalysisBuilder
284            // wires the dependency correctly; this branch is for tests / other
285            // direct callers that skip geometry.
286            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
301// ---------------------------------------------------------------------------
302// Per-page finalize: body box → xy_cut → merge → bbox filter → label
303// ---------------------------------------------------------------------------
304
305fn 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    // Filter to body elements: bbox overlaps body box. Rotation == 0 was
312    // already enforced in observe.
313    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    // 1. XY-cut.
327    let mut root = xy_cut(body_box, &body_bboxes, &body_indices_local, 0, mlh, config);
328
329    // 2. Column-divider-aligned merge.
330    let merged = merge_overfragmented(
331        &mut root,
332        &geometry.column_layout.column_dividers,
333        config.column_divider_tolerance_pt,
334    );
335
336    // 3. Bbox-crossing safety filter.
337    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    // 4. Reading-order labels.
345    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
360/// Body box for a page. Per Marcus 2026-05-06, uses `doc_footer_y` (not
361/// `per_page_footer_y[p]`). When per-page footer detection gets its own CR
362/// and becomes reliable, swap the `y1` here to:
363///     `geometry.per_page_footer_y[p].unwrap_or(geometry.doc_footer_y)`
364fn 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
379/// Median bbox height across body elements. Drives the per-region band
380/// threshold via `rel_min_band_line_heights`. Defaults to 12.0 when no
381/// elements (matches the prototype's fallback).
382fn 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
395// ---------------------------------------------------------------------------
396// XY-cut core (pass 1)
397// ---------------------------------------------------------------------------
398
399/// Recursive whitespace-band partitioning with threshold retries.
400///
401/// Sweeps both axes for interior whitespace bands at a given threshold
402/// factor; chooses the axis with the largest band; admits cuts via the
403/// adaptive top-K filter; recurses on each sub-region. If the default
404/// threshold finds nothing, retries with progressively lower factors. On
405/// retry attempts only, an explosion guard rejects when too many cuts
406/// emerge (a sign the threshold dipped into inter-line noise).
407///
408/// `bbox_indices` is parallel to `bboxes`: each entry in `bbox_indices` is
409/// the local index that a leaf will record for that element.
410fn 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        // Adaptive top-K filter: keep bands ≥ top_k_fraction × max.
440        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        // Explosion guard — only on retry attempts.
451        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            // All cuts filtered — try a lower threshold (might reveal a
459            // different separating band).
460            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    // Exhausted all retry factors with no usable cut → leaf.
485    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, &region) {
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
595/// Find whitespace bands in `region_box` along the perpendicular axis.
596/// Restricted to interior bands (strictly avoiding the box's outer edges)
597/// whose thickness exceeds `max(factor × abs_min_band_pt,
598/// factor × rel_min_band_line_heights × mlh)`.
599fn 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    // Whitespace runs.
631    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        // Interior only — skip bands touching the box's outer edge.
654        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
671/// Keep only cuts that produce non-empty strips on both sides.
672fn 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        // else: empty strip → drop this cut, prev unchanged (merge with next).
706    }
707    // Drop any trailing-empty-strip cuts.
708    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
731// ---------------------------------------------------------------------------
732// Pass 2: column-divider-aligned merge
733// ---------------------------------------------------------------------------
734
735/// Walk the tree bottom-up. For each v-cut node, collapse it iff none of
736/// its cuts align with a doc-level column divider.
737///
738/// Returns the count of v-cut subtrees collapsed (telemetry).
739fn 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 &region.children {
775        out.extend(gather_leaf_indices(child));
776    }
777    out
778}
779
780// ---------------------------------------------------------------------------
781// Pass 3: bbox-crossing safety filter
782// ---------------------------------------------------------------------------
783
784/// Walk the tree bottom-up. For each surviving cut, thicken into a band
785/// (perpendicular ±perp/2, along-direction inset by along_inset on each
786/// end) and test for AABB intersection with every element bbox in the
787/// region. On the first hit, collapse the entire node into a leaf.
788///
789/// Returns the count of nodes collapsed (telemetry).
790fn 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    // Gather only the bboxes that fall within this region's box. Keeping
805    // this scoped per-node prevents marginalia from another part of the
806    // page from triggering a collapse here.
807    let region_bboxes: Vec<&BoundingBox> = all_bboxes
808        .iter()
809        .filter(|b| overlaps(b, &region.r#box))
810        .collect();
811
812    let half = perp / 2.0;
813    let mut crossing = false;
814    for &cut in &region.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 &region_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
852// ---------------------------------------------------------------------------
853// Pass 4: reading-order labels
854// ---------------------------------------------------------------------------
855
856fn 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// ---------------------------------------------------------------------------
877// Tests
878// ---------------------------------------------------------------------------
879
880#[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 &region.children {
964                collect_leaves(c, out);
965            }
966        }
967    }
968
969    // --- 1. Single-block body — no cuts ---------------------------------------
970    #[test]
971    fn single_block_yields_single_leaf() {
972        // Solid body block: 20 lines of body text, no internal whitespace gaps
973        // wide enough to trigger a cut.
974        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    // --- 2. Two-section body — one h-cut --------------------------------------
989    #[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        // Section 1 body at y=80..300, section 2 body at y=350..700.
994        // Gap [300, 350) = 50pt — comfortably above abs_min_band_pt=8 and
995        // 1.2 × line_height (14×1.2=16.8).
996        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        // Top section labeled "1", bottom "2" (h-cut → top-to-bottom).
1011        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    // --- 3. Two-column body with aligned divider — gutter survives merge ------
1018    #[test]
1019    fn two_column_with_divider_keeps_gutter() {
1020        // Doc divider at x=300 (gutter center). Body x ∈ [100, 500],
1021        // left col x ∈ [110, 280], right col x ∈ [320, 490], inter-col
1022        // gap [280, 320] = 40pt — well above abs_min_band_pt=8.
1023        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        // Root should be a v-cut at ~x=300, with 2 children.
1033        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        // No collapses on this page: aligned divider preserves the v-cut.
1042        assert_eq!(pr.diagnostic.merged_subtrees, 0);
1043    }
1044
1045    // --- 4. Two-column body without divider in geometry — gutter collapses ----
1046    #[test]
1047    fn two_column_without_divider_collapses() {
1048        // Same body shape as test 3, but `column_dividers` is empty
1049        // (e.g., heatmap couldn't detect the gutter).
1050        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        // Without a divider to align against, the v-cut is "over-fragmentation"
1060        // and the merge collapses it into one leaf.
1061        assert_eq!(count_leaves(&pr.root), 1);
1062        assert_eq!(pr.diagnostic.merged_subtrees, 1);
1063    }
1064
1065    // --- 5. Section-number split inside single column gets merged away --------
1066    #[test]
1067    fn inline_word_gap_gets_merged_away_in_single_column() {
1068        // Single-col page (no dividers). One section header line "1.
1069        // Introduction" with a 30pt inter-word gap will trigger a v-cut on
1070        // a retry threshold. Merge collapses it because no doc-level
1071        // divider aligns.
1072        let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![]);
1073        let mut bboxes = Vec::new();
1074        // Header line: "1." at x=110, "Introduction" at x=145
1075        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        // Body lines below
1078        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        // Should collapse v-cuts; final tree may still have h-cuts but no
1085        // v-cuts that aren't aligned with the (empty) divider list.
1086        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    // --- 6. Reading-order labels: depth-first, top-then-left ------------------
1101    #[test]
1102    fn labels_follow_depth_first_top_then_left() {
1103        // Two h-strips, top strip is single-col, bottom strip is two-col with
1104        // aligned divider.
1105        let geometry = mk_geometry(60.0, 720.0, 100.0, 500.0, vec![300.0]);
1106        let mut bboxes = Vec::new();
1107        // Top single-col block y=[80, 300]
1108        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        // Bottom two-col block y=[350, 700]
1114        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        // Expect labels: "1" (top single-col), "2-1" (bottom-left col), "2-2" (bottom-right col).
1124        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    // --- 7. Element-index leaves resolve to body indices ----------------------
1131    #[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    // --- 8. Empty body box → empty leaf ---------------------------------------
1151    #[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    // --- 9. Determinism: same input → byte-identical JSON ---------------------
1162    #[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    // --- 10. Body box uses doc_footer_y, not per_page_footer_y ----------------
1178    #[test]
1179    fn body_box_uses_doc_footer_y_per_marcus_2026_05_06() {
1180        // Set doc_footer_y above per_page_footer_y to verify we're using
1181        // doc_footer_y. (per_page_footer_y is irrelevant in body_box_for_page
1182        // — this test pins the contract.)
1183        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}