use serde::{Deserialize, Serialize};
use crate::analytics::statistic::{FinalizationContext, Statistic};
use crate::types::{BoundingBox, PdfTextElement};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GeometryStats {
pub header_y: f32,
pub doc_footer_y: f32,
pub left_x: f32,
pub right_x: f32,
pub per_page_footer_y: Vec<Option<f32>>,
pub source_pages: u32,
pub page_dimensions: PageDimensions,
pub column_layout: ColumnLayout,
pub heatmap: DensityGrid,
pub diagnostic: GeometryDiagnostic,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DensityGrid {
pub cell_size: u32,
pub cols: u32,
pub rows: u32,
pub cells: Vec<u16>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ColumnLayout {
pub column_count: u32,
pub column_dividers: Vec<f32>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct PageDimensions {
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GeometryDiagnostic {
pub heatmap_max: u32,
pub header_reason: String,
pub doc_footer_reason: String,
pub left_margin_reason: String,
pub right_margin_reason: String,
pub column_peak: f32,
pub column_drop_threshold: f32,
pub column_high_threshold: f32,
}
#[derive(Debug, Clone)]
pub struct GeometryStatsConfig {
pub page_analysis_count: usize,
pub min_gap_rows: usize,
pub min_gap_cols: usize,
pub max_footer_extent: usize,
pub per_page_tolerance: f32,
pub min_token_size: f32,
pub column_drop_ratio: f32,
pub column_min_drop_cols: usize,
pub column_high_ratio: f32,
pub heatmap_cell_size: u32,
}
impl Default for GeometryStatsConfig {
fn default() -> Self {
Self {
page_analysis_count: 10,
min_gap_rows: 15,
min_gap_cols: 35,
max_footer_extent: 50,
per_page_tolerance: 1.0,
min_token_size: 1.0,
column_drop_ratio: 0.10,
column_min_drop_cols: 8,
column_high_ratio: 0.50,
heatmap_cell_size: 8,
}
}
}
#[derive(Debug, Default)]
struct PageAccumulator {
page_number: u32,
width: f32,
height: f32,
bboxes: Vec<BoundingBox>,
tokens: Vec<TokenForGeometry>,
}
#[derive(Debug, Clone)]
struct TokenForGeometry {
bbox: BoundingBox,
font_size: f32,
}
#[derive(Debug, Default)]
pub struct GeometryStatsBuilder {
config: GeometryStatsConfig,
pages: Vec<PageAccumulator>,
}
impl GeometryStatsBuilder {
pub fn new(config: GeometryStatsConfig) -> Self {
Self {
config,
pages: Vec::new(),
}
}
fn page_slot(&mut self, element: &PdfTextElement) -> Option<usize> {
let page_number = element.page_number();
if let Some(idx) = self.pages.iter().position(|p| p.page_number == page_number) {
return Some(idx);
}
if self.pages.len() >= self.config.page_analysis_count {
return None;
}
let bbox = element.bounding_box();
let _ = bbox;
self.pages.push(PageAccumulator {
page_number,
width: 0.0,
height: 0.0,
bboxes: Vec::new(),
tokens: Vec::new(),
});
Some(self.pages.len() - 1)
}
}
impl Statistic for GeometryStatsBuilder {
type Output = GeometryStats;
const NAME: &'static str = "geometry";
fn observe(&mut self, element: &PdfTextElement) {
if element.rotation() != 0 {
return;
}
let bbox = element.bounding_box().clone();
let font_size = element.style_info.font_size;
let text = &element.text;
let page_w = element.placement.page_width;
let page_h = element.placement.page_height;
let Some(idx) = self.page_slot(element) else {
return;
};
let page = &mut self.pages[idx];
if page_w > 0.0 {
page.width = page_w;
} else {
let right = bbox.x + bbox.width;
if right > page.width {
page.width = right;
}
}
if page_h > 0.0 {
page.height = page_h;
} else {
let bottom = bbox.y + bbox.height;
if bottom > page.height {
page.height = bottom;
}
}
page.bboxes.push(bbox.clone());
if font_size >= self.config.min_token_size && !text.trim().is_empty() {
page.tokens.push(TokenForGeometry { bbox, font_size });
}
}
fn finalize(self, ctx: &FinalizationContext<'_>) -> Self::Output {
let doc_body_size = ctx.font.and_then(|f| {
if f.font_size_counts.is_empty() {
None
} else {
Some(f.most_common_font_size)
}
});
finalize_geometry(self.pages, &self.config, doc_body_size)
}
}
fn finalize_geometry(
pages: Vec<PageAccumulator>,
config: &GeometryStatsConfig,
doc_body_size: Option<f32>,
) -> GeometryStats {
if pages.is_empty() {
return GeometryStats::default();
}
let (heatmap, width, height) = build_heatmap(&pages);
let n_pages = pages.len() as u32;
let header = find_header_line(&heatmap, height, config.min_gap_rows);
let footer = find_footer_line(
&heatmap,
height,
config.min_gap_rows,
config.max_footer_extent,
);
let body_y_start = header.line.min(footer.line);
let body_y_end = header.line.max(footer.line);
let left = find_left_margin(
&heatmap,
width,
config.min_gap_cols,
body_y_start,
body_y_end,
);
let right = find_right_margin(
&heatmap,
width,
config.min_gap_cols,
body_y_start,
body_y_end,
);
let column_layout_result = find_column_layout(
&heatmap,
header.line,
footer.line,
left.line,
right.line,
config.column_drop_ratio,
config.column_min_drop_cols,
config.column_high_ratio,
);
let per_page_footer_y = pages
.iter()
.map(|p| {
find_per_page_footer_line(
p,
footer.line,
config.per_page_tolerance,
config.min_token_size,
doc_body_size,
)
})
.collect();
let heatmap_max = heatmap
.iter()
.flat_map(|row| row.iter().copied())
.max()
.unwrap_or(0);
let density_grid =
downsample_to_density_grid(&heatmap, width, height, config.heatmap_cell_size);
GeometryStats {
header_y: header.line as f32,
doc_footer_y: footer.line as f32,
left_x: left.line as f32,
right_x: right.line as f32,
per_page_footer_y,
source_pages: n_pages,
page_dimensions: PageDimensions {
width: width as f32,
height: height as f32,
},
column_layout: ColumnLayout {
column_count: column_layout_result.column_count,
column_dividers: column_layout_result.column_dividers,
},
heatmap: density_grid,
diagnostic: GeometryDiagnostic {
heatmap_max,
header_reason: header.reason,
doc_footer_reason: footer.reason,
left_margin_reason: left.reason,
right_margin_reason: right.reason,
column_peak: column_layout_result.peak,
column_drop_threshold: column_layout_result.drop_threshold,
column_high_threshold: column_layout_result.high_threshold,
},
}
}
fn build_heatmap(pages: &[PageAccumulator]) -> (Vec<Vec<u32>>, usize, usize) {
let max_w = pages
.iter()
.map(|p| p.width.ceil() as usize)
.max()
.unwrap_or(0);
let max_h = pages
.iter()
.map(|p| p.height.ceil() as usize)
.max()
.unwrap_or(0);
if max_w == 0 || max_h == 0 {
return (vec![vec![0; max_w.max(1)]; max_h.max(1)], max_w, max_h);
}
let mut heatmap = vec![vec![0u32; max_w]; max_h];
for page in pages {
let mut mask = vec![false; max_w * max_h];
for bbox in &page.bboxes {
let xa = clamp_usize(bbox.x.floor() as i64, 0, max_w as i64);
let xb = clamp_usize((bbox.x + bbox.width).ceil() as i64, 0, max_w as i64);
let ya = clamp_usize(bbox.y.floor() as i64, 0, max_h as i64);
let yb = clamp_usize((bbox.y + bbox.height).ceil() as i64, 0, max_h as i64);
if xb > xa && yb > ya {
for y in ya..yb {
let row = y * max_w;
mask[row + xa..row + xb].fill(true);
}
}
}
for (y, row) in heatmap.iter_mut().enumerate().take(max_h) {
let base = y * max_w;
for (x, cell) in row.iter_mut().enumerate().take(max_w) {
if mask[base + x] {
*cell += 1;
}
}
}
}
(heatmap, max_w, max_h)
}
fn clamp_usize(v: i64, lo: i64, hi: i64) -> usize {
v.max(lo).min(hi) as usize
}
struct WalkResult {
line: usize,
reason: String,
}
fn find_header_line(heatmap: &[Vec<u32>], height: usize, min_gap_rows: usize) -> WalkResult {
if height == 0 {
return WalkResult {
line: 0,
reason: "empty-heatmap".to_string(),
};
}
let middle = height / 2;
let sum_per_row = sum_rows(heatmap);
let mut gap_bottom: Option<usize> = None;
let mut gap_length: usize = 0;
let mut y = middle;
loop {
if sum_per_row[y] > 0 {
gap_bottom = None;
gap_length = 0;
} else {
if gap_bottom.is_none() {
gap_bottom = Some(y);
}
gap_length += 1;
if gap_length >= min_gap_rows {
return WalkResult {
line: gap_bottom.unwrap(),
reason: "found-significant-gap".to_string(),
};
}
}
if y == 0 {
break;
}
y -= 1;
}
WalkResult {
line: 0,
reason: "no-significant-gap-found".to_string(),
}
}
fn find_footer_line(
heatmap: &[Vec<u32>],
height: usize,
min_gap_rows: usize,
max_footer_extent: usize,
) -> WalkResult {
if height == 0 {
return WalkResult {
line: 0,
reason: "empty-heatmap".to_string(),
};
}
let middle = height / 2;
let sum_per_row = sum_rows(heatmap);
let mut y = middle;
while y < height {
while y < height && sum_per_row[y] > 0 {
y += 1;
}
if y >= height {
return WalkResult {
line: height.saturating_sub(1),
reason: "no-gap-found".to_string(),
};
}
let gap_top = y;
while y < height && sum_per_row[y] == 0 {
y += 1;
}
let gap_length = y - gap_top;
if y >= height {
return WalkResult {
line: gap_top,
reason: "gap-to-page-bottom".to_string(),
};
}
let content_top = y;
while y < height && sum_per_row[y] > 0 {
y += 1;
}
let content_extent = y - content_top;
if content_extent <= max_footer_extent {
let tail_start = y;
while y < height && sum_per_row[y] == 0 {
y += 1;
}
let tail_length = y - tail_start;
if y >= height || tail_length >= min_gap_rows {
return WalkResult {
line: gap_top,
reason: "found-chrome-then-tail".to_string(),
};
}
} else if gap_length >= min_gap_rows {
return WalkResult {
line: gap_top,
reason: "found-significant-gap".to_string(),
};
}
}
WalkResult {
line: height.saturating_sub(1),
reason: "no-gap-found".to_string(),
}
}
fn find_left_margin(
heatmap: &[Vec<u32>],
width: usize,
min_gap_cols: usize,
body_y_start: usize,
body_y_end: usize,
) -> WalkResult {
if width == 0 {
return WalkResult {
line: 0,
reason: "empty-heatmap".to_string(),
};
}
let middle = width / 2;
let sum_per_col = sum_cols_in_y_range(heatmap, body_y_start, body_y_end, width);
let mut gap_right_edge: Option<usize> = None;
let mut gap_length: usize = 0;
let mut x = middle;
loop {
if sum_per_col[x] > 0 {
gap_right_edge = None;
gap_length = 0;
} else {
if gap_right_edge.is_none() {
gap_right_edge = Some(x);
}
gap_length += 1;
if gap_length >= min_gap_cols {
return WalkResult {
line: gap_right_edge.unwrap(),
reason: "found-significant-gap".to_string(),
};
}
}
if x == 0 {
break;
}
x -= 1;
}
WalkResult {
line: 0,
reason: "no-significant-gap-found".to_string(),
}
}
fn find_right_margin(
heatmap: &[Vec<u32>],
width: usize,
min_gap_cols: usize,
body_y_start: usize,
body_y_end: usize,
) -> WalkResult {
if width == 0 {
return WalkResult {
line: 0,
reason: "empty-heatmap".to_string(),
};
}
let middle = width / 2;
let sum_per_col = sum_cols_in_y_range(heatmap, body_y_start, body_y_end, width);
let mut gap_left_edge: Option<usize> = None;
let mut gap_length: usize = 0;
let mut x = middle;
while x < width {
if sum_per_col[x] > 0 {
gap_left_edge = None;
gap_length = 0;
} else {
if gap_left_edge.is_none() {
gap_left_edge = Some(x);
}
gap_length += 1;
if gap_length >= min_gap_cols {
return WalkResult {
line: gap_left_edge.unwrap(),
reason: "found-significant-gap".to_string(),
};
}
}
x += 1;
}
WalkResult {
line: width.saturating_sub(1),
reason: "no-significant-gap-found".to_string(),
}
}
fn sum_rows(heatmap: &[Vec<u32>]) -> Vec<u64> {
heatmap
.iter()
.map(|row| row.iter().map(|&v| v as u64).sum())
.collect()
}
fn sum_cols_in_y_range(
heatmap: &[Vec<u32>],
y_start: usize,
y_end: usize,
width: usize,
) -> Vec<u64> {
let mut sums = vec![0u64; width];
let height = heatmap.len();
let lo = y_start.min(height);
let hi = y_end.min(height);
for row in &heatmap[lo..hi] {
for (x, &v) in row.iter().enumerate().take(width) {
sums[x] += v as u64;
}
}
sums
}
struct ColumnLayoutResult {
column_count: u32,
column_dividers: Vec<f32>,
peak: f32,
drop_threshold: f32,
high_threshold: f32,
}
#[allow(clippy::too_many_arguments)]
fn find_column_layout(
heatmap: &[Vec<u32>],
header_y: usize,
doc_footer_y: usize,
left_x: usize,
right_x: usize,
drop_ratio: f32,
min_drop_cols: usize,
high_ratio: f32,
) -> ColumnLayoutResult {
if right_x <= left_x || heatmap.is_empty() || heatmap[0].is_empty() {
return ColumnLayoutResult {
column_count: 1,
column_dividers: Vec::new(),
peak: 0.0,
drop_threshold: 0.0,
high_threshold: 0.0,
};
}
let width = heatmap[0].len();
let sum_per_col = sum_cols_in_y_range(heatmap, header_y, doc_footer_y, width);
let body_lo = left_x.min(width);
let body_hi = (right_x + 1).min(width);
if body_hi <= body_lo {
return ColumnLayoutResult {
column_count: 1,
column_dividers: Vec::new(),
peak: 0.0,
drop_threshold: 0.0,
high_threshold: 0.0,
};
}
let peak = sum_per_col[body_lo..body_hi]
.iter()
.copied()
.max()
.unwrap_or(0) as f32;
let drop_threshold = drop_ratio * peak;
let high_threshold = high_ratio * peak;
let mut dividers: Vec<f32> = Vec::new();
let mut in_drop = false;
let mut drop_start: Option<usize> = None;
let close_drop = |start: usize, end_exclusive: usize, dividers: &mut Vec<f32>| {
let drop_end = end_exclusive.saturating_sub(1);
let drop_length = end_exclusive.saturating_sub(start);
if drop_length < min_drop_cols {
return;
}
let left_ok = start > left_x && (sum_per_col[start - 1] as f32) >= high_threshold;
let right_ok = drop_end < right_x && (sum_per_col[drop_end + 1] as f32) >= high_threshold;
if left_ok && right_ok {
dividers.push((start as f32 + drop_end as f32) / 2.0);
}
};
let scan_hi = right_x.min(width.saturating_sub(1));
for (x, &v) in sum_per_col
.iter()
.enumerate()
.take(scan_hi + 1)
.skip(left_x)
{
if (v as f32) < drop_threshold {
if !in_drop {
drop_start = Some(x);
in_drop = true;
}
} else if in_drop {
if let Some(start) = drop_start {
close_drop(start, x, &mut dividers);
}
in_drop = false;
drop_start = None;
}
}
if in_drop {
if let Some(start) = drop_start {
close_drop(start, right_x + 1, &mut dividers);
}
}
ColumnLayoutResult {
column_count: (dividers.len() as u32) + 1,
column_dividers: dividers,
peak,
drop_threshold,
high_threshold,
}
}
fn find_per_page_footer_line(
page: &PageAccumulator,
doc_footer_y: usize,
tolerance: f32,
min_token_size: f32,
doc_body_size: Option<f32>,
) -> Option<f32> {
let elements: Vec<&TokenForGeometry> = page
.tokens
.iter()
.filter(|t| t.font_size >= min_token_size)
.collect();
if elements.is_empty() {
return None;
}
let height = page.height.ceil() as usize;
if height == 0 {
return None;
}
let middle = height / 2;
let body_size = doc_body_size.unwrap_or_else(|| {
let mut sizes: Vec<f32> = elements.iter().map(|t| t.font_size).collect();
sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
if sizes.len() % 2 == 1 {
sizes[sizes.len() / 2]
} else {
let mid = sizes.len() / 2;
(sizes[mid - 1] + sizes[mid]) / 2.0
}
});
let threshold = body_size - tolerance;
if threshold <= 0.0 {
return None;
}
let mut row_sums = vec![0f64; height];
let mut row_counts = vec![0u32; height];
for t in &elements {
let ya = t.bbox.y.floor().max(0.0) as usize;
let yb = ((t.bbox.y + t.bbox.height).ceil() as usize).min(height);
if yb > ya {
for y in ya..yb {
row_sums[y] += t.font_size as f64;
row_counts[y] += 1;
}
}
}
let mut y = doc_footer_y.min(height.saturating_sub(1));
while y >= middle {
if row_counts[y] > 0 {
let avg = (row_sums[y] / row_counts[y] as f64) as f32;
if avg > threshold {
return Some((y + 1) as f32);
}
}
if y == 0 {
break;
}
y -= 1;
}
None
}
fn downsample_to_density_grid(
heatmap: &[Vec<u32>],
width: usize,
height: usize,
cell_size: u32,
) -> DensityGrid {
if cell_size == 0 || width == 0 || height == 0 {
return DensityGrid {
cell_size: cell_size.max(1),
cols: 0,
rows: 0,
cells: Vec::new(),
};
}
let cs = cell_size as usize;
let cols = width.div_ceil(cs);
let rows = height.div_ceil(cs);
let mut cells = vec![0u16; rows * cols];
for out_row in 0..rows {
let y_start = out_row * cs;
let y_end = (y_start + cs).min(height);
for out_col in 0..cols {
let x_start = out_col * cs;
let x_end = (x_start + cs).min(width);
let mut sum: u32 = 0;
for row in &heatmap[y_start..y_end] {
for &v in &row[x_start..x_end] {
sum = sum.saturating_add(v);
}
}
cells[out_row * cols + out_col] = sum.min(u16::MAX as u32) as u16;
}
}
DensityGrid {
cell_size,
cols: cols as u32,
rows: rows as u32,
cells,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{FontClass, Placement};
#[allow(clippy::too_many_arguments)]
fn make_element(
page: u32,
x: f32,
y: f32,
w: f32,
h: f32,
font_size: f32,
rotation: i32,
) -> PdfTextElement {
PdfTextElement {
text: "lorem".to_string(),
style_info: FontClass {
class_name: "body".to_string(),
font_family: "Times".to_string(),
font_size,
font_style: "normal".to_string(),
font_weight: "normal".to_string(),
color: "#000000".to_string(),
},
placement: Placement {
page_number: page,
bounding_box: BoundingBox {
x,
y,
width: w,
height: h,
},
line_number: 0,
segment_number: 0,
rotation,
paragraph_number: 0,
region_label: None,
page_width: 0.0,
page_height: 0.0,
},
reading_order: 0,
bookmark_match: None,
token_count: 1,
raw_tags: vec![],
}
}
fn build_stats_with_config(
elements: &[PdfTextElement],
config: GeometryStatsConfig,
) -> GeometryStats {
let mut b = GeometryStatsBuilder::new(config);
for e in elements {
b.observe(e);
}
b.finalize(&FinalizationContext::default())
}
fn build_stats(elements: &[PdfTextElement]) -> GeometryStats {
build_stats_with_config(elements, GeometryStatsConfig::default())
}
fn synth_body_pages(
n_pages: u32,
page_w: f32,
page_h: f32,
body_x_ranges: &[(f32, f32)],
body_y_lo: f32,
body_y_hi: f32,
line_h: f32,
) -> Vec<PdfTextElement> {
let mut elements = Vec::new();
for p in 1..=n_pages {
let mut y = body_y_lo;
while y + line_h <= body_y_hi {
for (x0, x1) in body_x_ranges {
elements.push(make_element(p, *x0, y, x1 - x0, line_h, 10.0, 0));
}
y += line_h; }
elements.push(make_element(
p,
page_w - 0.1,
page_h - 0.1,
0.05,
0.05,
10.0,
0,
));
}
elements
}
fn push_solid_body(
elements: &mut Vec<PdfTextElement>,
page: u32,
body_y_lo: f32,
body_y_hi: f32,
) {
let line_h = 14.0;
let mut y = body_y_lo;
while y + line_h <= body_y_hi {
elements.push(make_element(page, 100.0, y, 400.0, line_h, 10.0, 0));
y += line_h;
}
if y < body_y_hi {
elements.push(make_element(page, 100.0, y, 400.0, body_y_hi - y, 10.0, 0));
}
}
#[test]
fn header_running_is_detected() {
let mut elements = Vec::new();
for p in 1..=10 {
elements.push(make_element(p, 100.0, 35.0, 200.0, 12.0, 10.0, 0));
push_solid_body(&mut elements, p, 80.0, 700.0);
elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
}
let s = build_stats(&elements);
assert!(
s.header_y >= 60.0 && s.header_y < 80.0,
"header_y {} not in [60, 80) — should sit just above body_top=80",
s.header_y
);
assert!(
s.doc_footer_y >= 700.0,
"doc_footer_y {} not at/above body bottom",
s.doc_footer_y
);
assert_eq!(s.diagnostic.header_reason, "found-significant-gap");
}
#[test]
fn header_top_margin_is_caught_when_no_header() {
let elements = synth_body_pages(10, 600.0, 800.0, &[(100.0, 500.0)], 70.0, 700.0, 10.0);
let s = build_stats(&elements);
assert!(
s.header_y >= 55.0 && s.header_y <= 70.0,
"header_y {} not in [55, 70] for top-margin gap",
s.header_y
);
}
#[test]
fn header_multi_line_lands_below_lowest_band() {
let mut elements = Vec::new();
for p in 1..=10 {
elements.push(make_element(p, 100.0, 30.0, 200.0, 15.0, 10.0, 0));
elements.push(make_element(p, 100.0, 65.0, 200.0, 30.0, 10.0, 0));
push_solid_body(&mut elements, p, 130.0, 700.0);
elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
}
let s = build_stats(&elements);
assert!(
s.header_y >= 95.0 && s.header_y < 130.0,
"header_y {} not in [95, 130) — should sit just above body_top=130",
s.header_y
);
}
#[test]
fn footer_chrome_lands_above_chrome() {
let mut elements = Vec::new();
for p in 1..=10 {
push_solid_body(&mut elements, p, 80.0, 720.0);
elements.push(make_element(p, 280.0, 734.0, 40.0, 13.0, 10.0, 0));
elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
}
let s = build_stats(&elements);
assert!(
s.doc_footer_y >= 715.0 && s.doc_footer_y <= 735.0,
"doc_footer_y {} not in [715, 735] (above chrome)",
s.doc_footer_y
);
assert_eq!(s.diagnostic.doc_footer_reason, "found-chrome-then-tail");
}
#[test]
fn footer_no_chrome_returns_gap_to_bottom_or_significant_gap() {
let mut elements = Vec::new();
for p in 1..=10 {
push_solid_body(&mut elements, p, 80.0, 750.0);
elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
}
let s = build_stats(&elements);
assert!(
s.doc_footer_y >= 749.0 && s.doc_footer_y <= 760.0,
"doc_footer_y {} not near body bottom (750)",
s.doc_footer_y
);
let r = &s.diagnostic.doc_footer_reason;
assert!(
r == "gap-to-page-bottom"
|| r == "found-significant-gap"
|| r == "found-chrome-then-tail"
|| r == "no-gap-found",
"unexpected reason: {r}"
);
}
#[test]
fn margins_two_column_with_inter_gap_skipped() {
let elements = synth_body_pages(
10,
612.0,
792.0,
&[(100.0, 290.0), (310.0, 500.0)],
80.0,
720.0,
10.0,
);
let s = build_stats(&elements);
assert!(
s.left_x >= 85.0 && s.left_x <= 110.0,
"left_x {} not in [85, 110]",
s.left_x
);
assert!(
s.right_x >= 490.0 && s.right_x <= 515.0,
"right_x {} not in [490, 515]",
s.right_x
);
}
#[test]
fn margins_ignore_running_header_width() {
let mut elements = Vec::new();
for p in 1..=10 {
elements.push(make_element(p, 20.0, 35.0, 560.0, 12.0, 10.0, 0));
let mut y = 100.0;
while y < 700.0 {
elements.push(make_element(p, 100.0, y, 400.0, 10.0, 10.0, 0));
y += 14.0;
}
elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
}
let s = build_stats(&elements);
assert!(
s.left_x >= 90.0 && s.left_x <= 110.0,
"left_x {} should track body, not header — running header polluted X-projection",
s.left_x
);
assert!(
s.right_x >= 490.0 && s.right_x <= 515.0,
"right_x {} should track body, not header",
s.right_x
);
}
#[test]
fn header_ignores_rotated_decorations_at_top() {
let mut elements = Vec::new();
for p in 1..=10 {
elements.push(make_element(p, 50.0, 10.0, 20.0, 10.0, 10.0, 90));
let mut y = 80.0;
while y < 700.0 {
elements.push(make_element(p, 100.0, y, 400.0, 10.0, 10.0, 0));
y += 14.0;
}
elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
}
let s = build_stats(&elements);
assert!(
s.header_y >= 55.0 && s.header_y <= 80.0,
"header_y {} should be in top-margin range — rotated decoration polluted",
s.header_y
);
}
#[test]
fn source_pages_reflects_short_document() {
let elements = synth_body_pages(3, 612.0, 792.0, &[(100.0, 500.0)], 80.0, 720.0, 10.0);
let s = build_stats(&elements);
assert_eq!(s.source_pages, 3);
}
#[test]
fn determinism_same_input_byte_identical_json() {
let elements = synth_body_pages(5, 612.0, 792.0, &[(100.0, 500.0)], 80.0, 720.0, 10.0);
let a = serde_json::to_string(&build_stats(&elements)).unwrap();
let b = serde_json::to_string(&build_stats(&elements)).unwrap();
assert_eq!(a, b);
}
#[test]
fn per_page_footer_with_footnote_block() {
let mut elements = Vec::new();
for p in 1..=3 {
let mut y = 80.0;
while y < 600.0 {
elements.push(make_element(p, 100.0, y, 400.0, 10.0, 10.0, 0));
y += 14.0;
}
let mut yf = 620.0;
while yf < 660.0 {
elements.push(make_element(p, 100.0, yf, 400.0, 8.0, 8.0, 0));
yf += 10.0;
}
elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
}
let s = build_stats(&elements);
for (idx, ppf) in s.per_page_footer_y.iter().enumerate() {
let v = ppf.unwrap_or(-1.0);
assert!(
(600.0..=625.0).contains(&v),
"page {} per_page_footer_y={} not in [600, 625]",
idx,
v
);
}
}
#[test]
fn per_page_footer_no_footer_returns_doc_line() {
let mut elements = Vec::new();
for p in 1..=3 {
let mut y = 80.0;
while y < 740.0 {
elements.push(make_element(p, 100.0, y, 400.0, 10.0, 10.0, 0));
y += 14.0;
}
elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
}
let s = build_stats(&elements);
for ppf in &s.per_page_footer_y {
let v = ppf.unwrap_or(-1.0);
assert!(
(v - s.doc_footer_y).abs() < 5.0 || v >= s.doc_footer_y,
"per_page_footer_y={} should be ~doc_footer_y={}",
v,
s.doc_footer_y
);
}
}
#[test]
fn per_page_footer_skips_embedded_equation() {
let mut elements = Vec::new();
for p in 1..=3 {
let mut y = 80.0;
while y < 550.0 {
elements.push(make_element(p, 100.0, y, 400.0, 10.0, 10.0, 0));
y += 14.0;
}
let mut ye = 560.0;
while ye < 590.0 {
elements.push(make_element(p, 200.0, ye, 100.0, 8.0, 8.0, 0));
ye += 10.0;
}
let mut y2 = 600.0;
while y2 < 680.0 {
elements.push(make_element(p, 100.0, y2, 400.0, 10.0, 10.0, 0));
y2 += 14.0;
}
let mut yf = 700.0;
while yf < 740.0 {
elements.push(make_element(p, 100.0, yf, 400.0, 8.0, 8.0, 0));
yf += 10.0;
}
elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
}
let s = build_stats(&elements);
for (idx, ppf) in s.per_page_footer_y.iter().enumerate() {
let v = ppf.unwrap_or(-1.0);
assert!(
(680.0..=705.0).contains(&v),
"page {} per_page_footer_y={} should land above the real footnote (680..705)",
idx,
v
);
}
}
#[test]
fn per_page_footer_filters_size_artifacts() {
let mut elements = Vec::new();
for p in 1..=3 {
let mut y = 80.0;
while y < 700.0 {
elements.push(make_element(p, 100.0, y, 400.0, 10.0, 10.0, 0));
elements.push(make_element(p, 200.0, y + 2.0, 5.0, 1.0, 0.1, 0));
y += 14.0;
}
elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
}
let s = build_stats(&elements);
for (idx, ppf) in s.per_page_footer_y.iter().enumerate() {
if let Some(v) = ppf {
assert!(
*v >= 698.0 && *v <= 715.0,
"page {} per_page_footer_y={} should land near body bottom — artifacts polluted",
idx,
v
);
}
}
}
#[test]
fn per_page_footer_all_small_page_uses_per_page_median() {
let mut elements = Vec::new();
push_solid_body(&mut elements, 1, 80.0, 720.0);
elements.push(make_element(1, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
let mut yf = 500.0;
while yf < 700.0 {
elements.push(make_element(2, 100.0, yf, 400.0, 8.0, 8.0, 0));
yf += 10.0;
}
elements.push(make_element(2, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
let s = build_stats(&elements);
assert!(
s.per_page_footer_y[1].is_some(),
"per-page median treats 8pt as body for an all-8pt page"
);
}
#[test]
fn per_page_footer_cover_page_returns_none() {
let mut elements = Vec::new();
let mut y = 80.0;
while y < 720.0 {
elements.push(make_element(1, 100.0, y, 400.0, 10.0, 10.0, 0));
y += 14.0;
}
elements.push(make_element(1, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
elements.push(make_element(2, 200.0, 100.0, 200.0, 30.0, 24.0, 0));
elements.push(make_element(2, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
let s = build_stats(&elements);
assert!(
s.per_page_footer_y[1].is_none(),
"cover page with no body in bottom half should yield None"
);
}
#[test]
fn column_layout_single_column() {
let elements = synth_body_pages(10, 612.0, 792.0, &[(100.0, 500.0)], 80.0, 720.0, 10.0);
let s = build_stats(&elements);
assert_eq!(s.column_layout.column_count, 1);
assert!(s.column_layout.column_dividers.is_empty());
}
#[test]
fn column_layout_two_column_clean_gutter() {
let elements = synth_body_pages(
10,
612.0,
792.0,
&[(100.0, 280.0), (310.0, 500.0)],
80.0,
720.0,
10.0,
);
let s = build_stats(&elements);
assert_eq!(s.column_layout.column_count, 2);
assert_eq!(s.column_layout.column_dividers.len(), 1);
let d = s.column_layout.column_dividers[0];
assert!(
d > 280.0 && d < 310.0,
"divider {} not in inter-col range (280, 310)",
d
);
}
#[test]
fn column_layout_full_width_spanner_collapses_to_one() {
let mut elements = synth_body_pages(
10,
612.0,
792.0,
&[(100.0, 280.0), (310.0, 500.0)],
80.0,
720.0,
10.0,
);
for p in 1..=3 {
push_solid_body(&mut elements, p, 200.0, 600.0);
}
let s = build_stats(&elements);
assert_eq!(
s.column_layout.column_count, 1,
"full-width spanner should defeat sharp-drop detection"
);
}
#[test]
fn column_layout_right_edge_taper_rejected() {
let mut elements = Vec::new();
for p in 1..=10 {
let mut y = 80.0;
while y < 720.0 {
elements.push(make_element(p, 100.0, y, 380.0, 10.0, 10.0, 0));
y += 14.0;
}
let mut y2 = 80.0;
while y2 < 720.0 {
if ((y2 as i32) / 14) % 3 == 0 {
elements.push(make_element(p, 480.0, y2, 30.0, 10.0, 10.0, 0));
}
y2 += 14.0;
}
elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
}
let s = build_stats(&elements);
assert_eq!(
s.column_layout.column_count, 1,
"right-edge taper must not be detected as a column boundary"
);
}
#[test]
fn column_layout_indented_list_marker_rejected() {
let mut elements = Vec::new();
for p in 1..=10 {
let mut ym = 80.0;
while ym < 720.0 {
if ((ym as i32) / 14) % 3 == 0 {
elements.push(make_element(p, 90.0, ym, 25.0, 10.0, 10.0, 0));
}
ym += 14.0;
}
let mut y = 80.0;
while y < 720.0 {
elements.push(make_element(p, 120.0, y, 380.0, 10.0, 10.0, 0));
y += 14.0;
}
elements.push(make_element(p, 599.9, 791.9, 0.05, 0.05, 10.0, 0));
}
let s = build_stats(&elements);
assert_eq!(
s.column_layout.column_count, 1,
"indented list marker must not be detected as a column boundary"
);
}
#[test]
fn density_grid_shape_letter_page_at_default_cell_size() {
let elements = synth_body_pages(10, 612.0, 792.0, &[(100.0, 500.0)], 80.0, 720.0, 10.0);
let s = build_stats(&elements);
assert_eq!(s.heatmap.cell_size, 8);
assert_eq!(s.heatmap.cols, (612u32).div_ceil(8));
assert_eq!(s.heatmap.rows, (792u32).div_ceil(8));
assert_eq!(
s.heatmap.cells.len(),
(s.heatmap.rows * s.heatmap.cols) as usize
);
assert!(s.heatmap.cells.iter().any(|&v| v > 0));
}
#[test]
fn density_grid_uses_sum_not_mean_or_max() {
let mut elements = Vec::new();
for p in 1..=10 {
elements.push(make_element(p, 0.0, 0.0, 8.0, 8.0, 10.0, 0));
elements.push(make_element(p, 99.9, 99.9, 0.05, 0.05, 10.0, 0));
}
let s = build_stats(&elements);
let cell_00 = s.heatmap.cells[0];
assert_eq!(cell_00, 640, "expected sum 640, got {}", cell_00);
for col in 1..s.heatmap.cols as usize {
assert_eq!(
s.heatmap.cells[col], 0,
"cell (0,{col}) should be 0 (no bbox there)"
);
}
}
#[test]
fn empty_input_returns_default_stats() {
let s = build_stats(&[]);
assert_eq!(s.source_pages, 0);
assert_eq!(s.column_layout.column_count, 0);
assert!(s.column_layout.column_dividers.is_empty());
assert!(s.per_page_footer_y.is_empty());
}
}