Skip to main content

blazegraph_io_core/analytics/
geometry.rs

1// Geometry-level descriptive statistics: header, doc-footer, margins,
2// per-page footer, column layout, and a downsampled bbox-stacking heatmap.
3// Ported from `scripts/heatmap_prototype.py` (canonical for algorithm
4// behaviour). Block 06 of the document-analytics flow — see
5// `docs/P2/core/design-flows/2026-04-28-document-analytics-and-header-footer-classification.md`
6// (Block 03 spec) and `docs/P2/core/handoffs/2026-05-01-block03-geometry-header-line.md`
7// for the type-shape contract and the empirical rationale behind every config
8// default. Validation oracle: `scripts/output/{stem}/geometry.json`.
9
10use serde::{Deserialize, Serialize};
11
12use crate::analytics::statistic::{FinalizationContext, Statistic};
13use crate::types::{BoundingBox, PdfTextElement};
14
15// ---------------------------------------------------------------------------
16// Output type shape
17// ---------------------------------------------------------------------------
18
19/// Document-level geometry: five lines defining six regions on the page,
20/// plus the column layout and a downsampled persistent heatmap.
21///
22/// Coordinate convention: PDF/Tika points, y increasing downward.
23/// `header_y` < `doc_footer_y`; `left_x` < `right_x`. Body region is
24/// `header_y <= y < doc_footer_y` ∩ `left_x < x < right_x`.
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26pub struct GeometryStats {
27    /// Top boundary of body. Body at y > header_y; header zone at y < header_y.
28    pub header_y: f32,
29
30    /// Bottom boundary of body at the document level. Body at y < doc_footer_y;
31    /// below is the doc-level footer zone (running footer chrome + bottom margin).
32    /// Per-page footers (variable footnotes) are captured separately via
33    /// `per_page_footer_y` and are always at y <= doc_footer_y.
34    pub doc_footer_y: f32,
35
36    /// Horizontal body bounds. Body at left_x < x < right_x; outside is margin.
37    pub left_x: f32,
38    pub right_x: f32,
39
40    /// Per-page footer line, indexed by sample-page order (one entry per page
41    /// in `source_pages`). `Some(y)`: per-page body bottom; always
42    /// `<= doc_footer_y` by construction. `None`: page has no detectable body
43    /// in its bottom half (e.g., references-only page, near-empty page, cover
44    /// page with no body content). Downstream consumers should fall back to
45    /// `doc_footer_y` for `None` entries.
46    pub per_page_footer_y: Vec<Option<f32>>,
47
48    /// Number of pages stacked into the heatmap and analyzed for per-page
49    /// footers. Capped at config.page_analysis_count; may be less for short docs.
50    pub source_pages: u32,
51
52    /// Page dimensions used for the heatmap (max across sampled pages).
53    pub page_dimensions: PageDimensions,
54
55    /// Detected column structure inside the body region.
56    pub column_layout: ColumnLayout,
57
58    /// Downsampled persistence of the bbox-stacking heatmap. See
59    /// `DensityGrid` doc. Available to downstream pipes (e.g., Page
60    /// Outlier Detection) as the document's spatial signature.
61    pub heatmap: DensityGrid,
62
63    /// Diagnostic summary of the walks and per-page analysis.
64    pub diagnostic: GeometryDiagnostic,
65}
66
67/// Downsampled persistence of the per-page bbox-stacking heatmap. Cell value
68/// is the SUM of full-resolution cell counts in the corresponding
69/// `cell_size × cell_size` region. Sum (not mean, not max) is what the
70/// consumer needs: weighted-overlap scores like
71/// `sum_over_cells(page_covers_cell × cell_density)` compose directly. Mean
72/// discards the cell-area normalization; max discards multi-page consistency.
73///
74/// `u16` fits: max value = `cell_size² × page_analysis_count` (at default
75/// 8 × 8 × 10 = 640, well below 65535). Implementations clip to `u16::MAX`
76/// defensively in case overlapping bboxes push a cell past the steady-state
77/// upper bound.
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct DensityGrid {
80    /// Page-coordinate pt per cell. From `config.heatmap_cell_size`.
81    pub cell_size: u32,
82    /// `ceil(page_dimensions.width / cell_size)`.
83    pub cols: u32,
84    /// `ceil(page_dimensions.height / cell_size)`.
85    pub rows: u32,
86    /// Row-major: `cells[row * cols + col]`.
87    pub cells: Vec<u16>,
88}
89
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct ColumnLayout {
92    /// Number of reading columns detected in the body region.
93    /// 1 = single-column. 2 = typical two-column paper. >2 = rare.
94    pub column_count: u32,
95
96    /// X-positions (in pt) of inter-column dividers (gap centers). Length is
97    /// always `column_count - 1`. Empty vec for single-column layouts.
98    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    /// Maximum cell value in the full-resolution heatmap (= source_pages when
110    /// text fully aligned across pages).
111    pub heatmap_max: u32,
112    /// Reason returned by each doc-level walk. Useful for post-hoc inspection.
113    /// Examples: "found-significant-gap", "found-chrome-then-tail",
114    /// "gap-to-page-bottom", "no-significant-gap-found".
115    pub header_reason: String,
116    pub doc_footer_reason: String,
117    pub left_margin_reason: String,
118    pub right_margin_reason: String,
119    /// Column-detection metadata: peak, drop_threshold, high_threshold (the
120    /// flanking-cell threshold). Useful for tuning.
121    pub column_peak: f32,
122    pub column_drop_threshold: f32,
123    pub column_high_threshold: f32,
124}
125
126// ---------------------------------------------------------------------------
127// Configuration
128// ---------------------------------------------------------------------------
129
130/// Tunable parameters for geometry-level statistics.
131///
132/// Defaults are baked in for now; YAML wiring lands when consumers are
133/// re-tuned. Every default is empirically validated on the 6-PDF corpus
134/// (academic 2-col, single-col academic, RFC, EU legal with article
135/// numeration, UK legal multi-line header, math-heavy paper).
136#[derive(Debug, Clone)]
137pub struct GeometryStatsConfig {
138    /// Number of pages to load into the analysis window. Pages are taken
139    /// as a linear span from the start of the document (pages [0, N)),
140    /// not random sample. Default: 10. Validated on the 6-PDF corpus;
141    /// "first 10" is the regime where the column-divider X-projection
142    /// signal is cleanest (gpt2's full-document projection has full-width
143    /// spanners that dilute the gutter past detection threshold).
144    pub page_analysis_count: usize,
145
146    /// Minimum sustained-gap length (in pt rows) for the header walk and
147    /// the doc-footer walk's "sustained gap" branch. Default: 15.
148    /// Tuned to skip body inter-line / inter-paragraph gaps but catch
149    /// header→body and body→footer gaps.
150    pub min_gap_rows: usize,
151
152    /// Minimum sustained-gap length (in pt cols) for the margin walks.
153    /// Default: 35. Wider than min_gap_rows because the body has more
154    /// internal X-direction structure (inter-column gutters ~20-25pt;
155    /// numeration→body gaps in legal layouts ~20-30pt).
156    pub min_gap_cols: usize,
157
158    /// Maximum content-patch height (in pt rows) the doc-footer walk
159    /// classifies as "footer chrome" rather than "body continuation".
160    /// Default: 50. Above this, a content patch is treated as body
161    /// (the gap preceding it is body-internal, not body-bottom).
162    pub max_footer_extent: usize,
163
164    /// Per-page footer tolerance (in pt). A row is body-sized if its
165    /// average per-token font_size >= page_median - tolerance.
166    /// Default: 1.0. Wider tolerance admits embedded equations/captions
167    /// as body; narrower excludes more, risking false-positive footer
168    /// detection on body rows with mixed sizing.
169    pub per_page_tolerance: f32,
170
171    /// Per-page minimum-token-size filter. Tokens with font_size below
172    /// this value are excluded from per-page analysis. Default: 1.0.
173    /// Filters PDF rendering artifacts (e.g., size=0.1 parens around
174    /// inline math glyphs in the Shannon paper) that contaminate per-row
175    /// size averages without contributing visible content.
176    pub min_token_size: f32,
177
178    /// Column-layout drop threshold as fraction of body-row X-projection
179    /// peak. A column position is "low" when sum_per_col[x] < ratio * peak.
180    /// Default: 0.10. Tighter (e.g., 0.05) misses gpt2-style inter-col
181    /// gutters that have ~5-7% page-1 content bleed; wider (e.g., 0.25)
182    /// risks catching layout taper.
183    pub column_drop_ratio: f32,
184
185    /// Minimum sustained low-run length (in pt cols) for a column-divider
186    /// candidate. Default: 8. Smaller than min_gap_cols because inter-col
187    /// gutters are typically 10-30pt wide, narrower than outer margins.
188    pub column_min_drop_cols: usize,
189
190    /// Flanking-cell threshold as fraction of peak. A divider candidate
191    /// must have its IMMEDIATE-NEIGHBOR cells (drop_start - 1 and
192    /// drop_end + 1) at >= ratio * peak. Default: 0.50. Rejects edge
193    /// taper (no body density on margin side) and indented-list markers
194    /// (no body density on margin side). The drop must be SANDWICHED
195    /// between body-density text on both sides — a real reading-flow
196    /// break, not a boundary artifact.
197    pub column_high_ratio: f32,
198
199    /// Cell size (in pt) for the persisted `DensityGrid` downsample of
200    /// the bbox-stacking heatmap. Default: 8. The full-resolution 1pt
201    /// heatmap drives line detection (header/footer/margins/columns);
202    /// this downsampled grid persists in the output for downstream
203    /// consumers (Page Outlier Detection). 8pt cells (~7.6 KB per
204    /// letter page) preserve enough resolution to distinguish body row
205    /// from inter-line gap while collapsing PDF rendering noise.
206    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// ---------------------------------------------------------------------------
227// Builder
228// ---------------------------------------------------------------------------
229
230/// Per-page accumulation. Bboxes drive the heatmap (presence-based, no size
231/// filter). Tokens drive the per-page footer walk (filtered by
232/// rotation/text/min-size). The two views are kept separate to stay faithful
233/// to the prototype's filter semantics.
234#[derive(Debug, Default)]
235struct PageAccumulator {
236    page_number: u32,
237    width: f32,
238    height: f32,
239    /// Bboxes for heatmap construction. Filter: rotation == 0 only.
240    bboxes: Vec<BoundingBox>,
241    /// Tokens for per-page footer analysis. Filter: rotation == 0,
242    /// non-empty trimmed text, font_size >= config.min_token_size.
243    tokens: Vec<TokenForGeometry>,
244}
245
246#[derive(Debug, Clone)]
247struct TokenForGeometry {
248    bbox: BoundingBox,
249    font_size: f32,
250}
251
252/// Builder for geometry-level descriptive statistics.
253///
254/// Constructed once per document. Call [`observe`] for every
255/// [`PdfTextElement`] in reading order, then [`finalize`] to produce a
256/// [`GeometryStats`].
257#[derive(Debug, Default)]
258pub struct GeometryStatsBuilder {
259    config: GeometryStatsConfig,
260    /// Pages in observation order. The first `config.page_analysis_count`
261    /// distinct page numbers are kept; further pages are dropped.
262    pages: Vec<PageAccumulator>,
263}
264
265impl GeometryStatsBuilder {
266    /// Construct a builder with explicit config. The `AnalysisBuilder` in
267    /// `builder.rs` uses `GeometryStatsBuilder::default()` which picks up
268    /// `GeometryStatsConfig::default()`.
269    pub fn new(config: GeometryStatsConfig) -> Self {
270        Self {
271            config,
272            pages: Vec::new(),
273        }
274    }
275
276    /// Index of the page in `pages` for this element, or `None` if the
277    /// page-window is full and this is a new page.
278    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        // The accumulator's width/height tracks the max bbox extent observed
288        // on this page so far; final page dimensions come from these via
289        // ceil() in build_heatmap (we don't have access to true page rect
290        // in PdfTextElement — content extent is the available proxy and
291        // matches what Tika reports).
292        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        // Page dimensions: prefer Tika's `<div class="page-meta" data-width
324        // data-height />` (sourced via Placement.page_{width,height}). Fall
325        // back to bbox-content extent for c2 caches written before the
326        // page-meta tag landed (page_width/page_height = 0.0) and for unit
327        // tests that synthesize PdfTextElement directly. The fallback
328        // under-approximates the true page rect — adequate for header /
329        // margin / column walks but biases doc_footer_y upward toward the
330        // deepest content row.
331        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        // Heatmap input: every bbox (no size filter).
349        page.bboxes.push(bbox.clone());
350
351        // Per-page footer input: trimmed-non-empty text + size >= min_token_size.
352        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        // Pull the document-level body-size signal from FontStats when it has
359        // observations. The default FontStats value (12.0) on an empty
360        // document is not meaningful — guard with a `font_size_counts`
361        // non-empty check so synthetic tests (which don't run FontStats)
362        // and empty docs both fall back to the per-page median.
363        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
374// ---------------------------------------------------------------------------
375// Finalization orchestrator
376// ---------------------------------------------------------------------------
377
378fn 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
476// ---------------------------------------------------------------------------
477// Heatmap construction
478// ---------------------------------------------------------------------------
479
480/// Build a 1pt-resolution bbox-stacking heatmap. Cell `(y, x)` counts the
481/// number of pages on which at least one non-rotated bbox covers `(y, x)`.
482/// Ported from `heatmap_prototype.py::build_heatmap`.
483fn 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        // Per-page presence mask — flat row-major bool grid.
503        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
533// ---------------------------------------------------------------------------
534// Header / footer / margin walks
535// ---------------------------------------------------------------------------
536
537struct WalkResult {
538    line: usize,
539    reason: String,
540}
541
542/// Walk UP from the middle and stop at the first sustained low-run of
543/// `min_gap_rows` rows. Return the BOTTOM of that gap (the row closest to
544/// the middle within the gap). Ported from `find_header_line` in
545/// `heatmap_prototype.py`.
546fn 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
586/// Walk DOWN from the middle, distinguishing body→chrome→tail (footer chrome
587/// present) from body→sustained-gap (no chrome) from body-internal gap+patch
588/// (skip and keep walking). Returns the TOP of the body-bottom gap.
589/// Ported from `find_footer_line` in `heatmap_prototype.py`.
590fn 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        // Skip body content
608        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        // In a gap — record top, measure length
619        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            // Gap extends to page bottom — body ended at gap_top, no chrome.
627            return WalkResult {
628                line: gap_top,
629                reason: "gap-to-page-bottom".to_string(),
630            };
631        }
632
633        // Peek ahead: how big is the content patch beyond the gap?
634        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            // Chrome-sized patch. Verify a sustained tail follows (not body again).
642            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            // Tail too short — chrome-sized patch was actually a brief body
654            // interruption. Keep walking.
655        } else if gap_length >= min_gap_rows {
656            // Big content patch BUT preceded by a sustained gap. The sustained
657            // gap is the body-bottom signal regardless of what comes after.
658            return WalkResult {
659                line: gap_top,
660                reason: "found-significant-gap".to_string(),
661            };
662        }
663        // Else: small gap + large content = body-internal gap. y is already
664        // past the body region; loop continues.
665    }
666
667    WalkResult {
668        line: height.saturating_sub(1),
669        reason: "no-gap-found".to_string(),
670    }
671}
672
673/// Walk LEFT from the horizontal middle, return the RIGHT edge of the first
674/// sustained low-run of `min_gap_cols` columns. The X-projection is computed
675/// over body rows only (`body_y_start..body_y_end`), so wide running headers
676/// or footers don't bias the margin outward. Ported from `find_left_margin`.
677fn 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
723/// Mirror of `find_left_margin`. Walk RIGHT, return LEFT edge of first
724/// sustained gap. Ported from `find_right_margin`.
725fn 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
793// ---------------------------------------------------------------------------
794// Column layout
795// ---------------------------------------------------------------------------
796
797struct ColumnLayoutResult {
798    column_count: u32,
799    column_dividers: Vec<f32>,
800    peak: f32,
801    drop_threshold: f32,
802    high_threshold: f32,
803}
804
805/// Detect reading columns via sharp-drop analysis in the body-row X-projection.
806/// A divider is a sustained low-run flanked on BOTH sides by body-density
807/// columns. Ported from `find_column_layout`.
808#[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
903// ---------------------------------------------------------------------------
904// Per-page footer line (font-size-based)
905// ---------------------------------------------------------------------------
906
907/// Per-page footer detection by font-size transition. Walking UP from
908/// `doc_footer_y`, find the first row where the average token font_size
909/// meets the body-size threshold (within `tolerance`). That row is body;
910/// the per-page footer line is one row below it. Ported from
911/// `find_per_page_footer_line`.
912///
913/// `doc_body_size` is the document-level body size from FontStats — used as
914/// the body reference when available. Falls back to the per-page median
915/// when `None` (e.g. synthetic tests, or when FontStats is disabled). The
916/// document-level reference is more robust on documents where the page's
917/// median is dragged down by abundant non-body content (academic papers
918/// with long footnote blocks, where Tika's per-segment span granularity
919/// over-counts 8/9pt fragments and pulls the median below body size).
920fn 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    // Body-size reference: prefer the document-level signal from FontStats;
943    // fall back to the per-page median when unavailable.
944    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        // Degenerate: no usable size gradient. Hits when Tika reports
957        // uniform nominal `font_size=1.0` (common for CELEX-style legal
958        // PDFs where the embedded font metrics don't expose real point
959        // sizes). The per-page footer algorithm depends on a body→footer
960        // size transition; without one, every content row passes the
961        // threshold and the result is meaningless. Returning `None`
962        // matches the established "no detectable body" contract —
963        // consumers fall back to `doc_footer_y`.
964        return None;
965    }
966
967    // Per-row token-size aggregates: each token contributes once per row
968    // its bbox covers (floor..ceil).
969    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    // Walk UP from doc_footer_y, skip empty rows, stop at first body row.
983    //
984    // Strict `>` (vs the Python prototype's `>=`): when Tika's per-segment
985    // span granularity drives the body-size reference downward by one pt
986    // (e.g. attention's most_common_font_size=9 because Tika overcounts 9pt
987    // body fragments vs PyMuPDF's per-glyph clusters), a tied threshold
988    // would admit footer rows that share that one-pt margin. Strict `>`
989    // requires body rows to be measurably above the body-size − tolerance
990    // band; Tika 9pt body still passes (9 > 8) but Tika 8pt footers do not
991    // (8 > 8 is false). Body rows that genuinely sit at exactly threshold
992    // are extremely rare in real corpus data.
993    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
1009// ---------------------------------------------------------------------------
1010// DensityGrid downsample
1011// ---------------------------------------------------------------------------
1012
1013/// Downsample the full-resolution heatmap into a `DensityGrid` with cell
1014/// size `cell_size`. Each output cell holds the SUM of input cells in the
1015/// corresponding `cell_size × cell_size` region (clipped to `u16::MAX`
1016/// defensively). Sum semantics is what consumers need — see DensityGrid doc.
1017fn 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// ---------------------------------------------------------------------------
1061// Tests
1062// ---------------------------------------------------------------------------
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067    use crate::types::{FontClass, Placement};
1068
1069    /// Construct a synthetic `PdfTextElement` with explicit bbox + page +
1070    /// font_size + rotation. Style fields default to body-like values.
1071    #[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    /// Helper: create N pages of body content as a solid block (no inter-line
1130    /// gaps). bbox height equals row step so each row in [body_y_lo, body_y_hi)
1131    /// is filled — keeps the chrome state machine's "tail" check from
1132    /// triggering on synthetic body-internal patterns that wouldn't occur in
1133    /// real corpus data (where consecutive body lines pack tighter than
1134    /// `max_footer_extent`).
1135    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; // solid: no inter-line gap
1152            }
1153            // Anchor page extent
1154            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    /// Inline body builder used by tests that need explicit y-control. Solid
1168    /// block of body bboxes filling EXACTLY [body_y_lo, body_y_hi). The final
1169    /// bbox is extended (or shrunk) so the band closes at `body_y_hi` — keeps
1170    /// test arithmetic simple (no off-by-line_h leftover gap before the
1171    /// declared body bottom).
1172    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        // Cap to body_y_hi exactly with one final bbox covering [y, body_y_hi).
1185        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    // --- 1. Synthetic running header ------------------------------------------
1191    #[test]
1192    fn header_running_is_detected() {
1193        let mut elements = Vec::new();
1194        for p in 1..=10 {
1195            // running header at y=[35, 47]
1196            elements.push(make_element(p, 100.0, 35.0, 200.0, 12.0, 10.0, 0));
1197            // body solid block y=[80, 700]
1198            push_solid_body(&mut elements, p, 80.0, 700.0);
1199            // anchor
1200            elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
1201        }
1202        let s = build_stats(&elements);
1203        // gap_bottom = first low row going up from middle. With body starting
1204        // at y=80 the algorithm returns y=79 (the row just above body). The
1205        // semantic invariant: header_y < body_top, gap >= min_gap_rows.
1206        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    // --- 2. No-header case ----------------------------------------------------
1220    #[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    // --- 3. Multi-line header -------------------------------------------------
1232    #[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        // Lowest header band ends at y=95 (65+30). Body starts at y=130. Gap
1243        // is [95, 130) = 35 rows. Walking up from middle, first low row hit
1244        // is y=129. Algorithm returns gap_bottom=129. Allow a small slack.
1245        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    // --- 4. Footer chrome present ---------------------------------------------
1253    #[test]
1254    fn footer_chrome_lands_above_chrome() {
1255        let mut elements = Vec::new();
1256        for p in 1..=10 {
1257            // solid body to y=720
1258            push_solid_body(&mut elements, p, 80.0, 720.0);
1259            // page-number patch at y=[734, 747]
1260            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        // Body→gap→chrome→tail pattern. Body ends at y=716 (last bbox y=702
1265        // covers [702, 716)). Gap [716, 734) = 18 rows; chrome [734, 747);
1266        // tail [747, 791) = 44 rows. Algorithm returns gap_top=716.
1267        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    // --- 5. No footer chrome --------------------------------------------------
1276    #[test]
1277    fn footer_no_chrome_returns_gap_to_bottom_or_significant_gap() {
1278        // Note: even with no "footer chrome" by the test's intent, the page
1279        // anchor element acts as a tiny near-bottom content patch and
1280        // legitimately matches the chrome-then-tail predicate. The line
1281        // value (gap_top) is what matters; the reason label is diagnostic.
1282        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    // --- 6. Two-column with margins -------------------------------------------
1304    #[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    // --- 7. Body-row filter for margins ---------------------------------------
1329    #[test]
1330    fn margins_ignore_running_header_width() {
1331        let mut elements = Vec::new();
1332        for p in 1..=10 {
1333            // wide running header spanning x=[20, 580]
1334            elements.push(make_element(p, 20.0, 35.0, 560.0, 12.0, 10.0, 0));
1335            // narrower body x=[100, 500]
1336            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    // --- 8. Rotation filter ---------------------------------------------------
1357    #[test]
1358    fn header_ignores_rotated_decorations_at_top() {
1359        let mut elements = Vec::new();
1360        for p in 1..=10 {
1361            // rotated decorative elements at y=[10, 20]
1362            elements.push(make_element(p, 50.0, 10.0, 20.0, 10.0, 10.0, 90));
1363            // body starting y=80
1364            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    // --- 9. Short document ----------------------------------------------------
1380    #[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    // --- 10. Determinism ------------------------------------------------------
1388    #[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    // --- 11. Per-page footer: smaller-font footnote block ---------------------
1397    #[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            // 8pt footnote block at y=[620, 660]
1407            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    // --- 12. Per-page: page with no footer ------------------------------------
1427    #[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    // --- 13. Per-page: embedded equation walked past --------------------------
1451    #[test]
1452    fn per_page_footer_skips_embedded_equation() {
1453        let mut elements = Vec::new();
1454        for p in 1..=3 {
1455            // body to y=550
1456            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            // 8pt equation at y=[560, 590]
1462            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            // body again to y=680
1468            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            // 8pt footer at y=[700, 740]
1474            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    // --- 14. Per-page: garbage-token filter -----------------------------------
1494    #[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                // size=0.1 artifact tokens at the same row
1502                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    // --- 15. Per-page: all-small page treats small text AS body (per-page median)
1521    #[test]
1522    fn per_page_footer_all_small_page_uses_per_page_median() {
1523        // The per-page algorithm uses the page's OWN median font size as the
1524        // body reference (not the document body size). An all-8pt page has
1525        // median=8pt; the algorithm classifies that 8pt content as "body" for
1526        // that page and returns Some(line). This test pins that contract —
1527        // future swap to document-level body size (when FontStats is plumbed
1528        // into the FinalizationContext) will need to revisit.
1529        let mut elements = Vec::new();
1530        // Page 1 (anchor): solid 10pt body for the doc-level walks.
1531        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        // Page 2: only 8pt content in the bottom half.
1534        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    // --- 16. Per-page: cover page with no body in bottom half -----------------
1548    #[test]
1549    fn per_page_footer_cover_page_returns_none() {
1550        let mut elements = Vec::new();
1551        // Page 1: full body anchor.
1552        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        // Page 2 cover: title only at top; nothing below height/2.
1559        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    // --- 17. Single-column doc ------------------------------------------------
1569    #[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    // --- 18. Two-column with clean gutter (gpt2-style) ------------------------
1578    #[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    // --- 19. Two-column with full-width interruption (attention-style) --------
1601    #[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        // Pages 1-3 also have full-width abstract spanning [100, 500] across
1613        // the inter-col band. With heatmap counting page-presence, the gutter
1614        // accumulates ~3/10 of body density — well above drop_ratio=0.10.
1615        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    // --- 20. Right-edge taper rejection (rfc-quic-style) ----------------------
1626    #[test]
1627    fn column_layout_right_edge_taper_rejected() {
1628        let mut elements = Vec::new();
1629        for p in 1..=10 {
1630            // body x=[100, 480], lighter density in [480, 510]
1631            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            // sparse trailing density (every 3rd row)
1637            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    // --- 21. Indented-list rejection (Police-style) ---------------------------
1654    #[test]
1655    fn column_layout_indented_list_marker_rejected() {
1656        let mut elements = Vec::new();
1657        for p in 1..=10 {
1658            // narrow indented marker column (sparse) at x=[90, 115]
1659            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            // full body at x=[120, 500]
1667            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    // --- 22. DensityGrid populated and shape-correct --------------------------
1682    #[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    // --- 23. DensityGrid sum semantics ----------------------------------------
1697    #[test]
1698    fn density_grid_uses_sum_not_mean_or_max() {
1699        // 10 pages, each with one bbox covering exactly x∈[0,8), y∈[0,8).
1700        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            // anchor for page extent
1704            elements.push(make_element(p, 99.9, 99.9, 0.05, 0.05, 10.0, 0));
1705        }
1706        let s = build_stats(&elements);
1707        // Cell (0,0) should be sum of 8x8 = 64 input cells, each at value 10.
1708        // Total = 640 (matches the handoff's "8×8×10 = 640").
1709        let cell_00 = s.heatmap.cells[0];
1710        assert_eq!(cell_00, 640, "expected sum 640, got {}", cell_00);
1711        // All other cells in row 0 should be 0.
1712        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    // --- 24. Block 01 smoke test still passes (analytics builder default) -----
1721    #[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}