use std::collections::HashMap;
use crate::types::{TextItem, TextLine};
use log::debug;
pub(crate) struct FontStats {
pub(crate) most_common_size: f32,
pub(crate) size_counts: HashMap<i32, usize>,
pub(crate) total_lines: usize,
}
pub(crate) fn font_size_rarity(font_size: f32, stats: &FontStats) -> f32 {
if stats.total_lines == 0 {
return 0.0;
}
let key = (font_size * 10.0) as i32;
let count = stats.size_counts.get(&key).copied().unwrap_or(0);
1.0 - (count as f32 / stats.total_lines as f32)
}
pub(crate) fn calculate_font_stats_from_items(items: &[TextItem]) -> FontStats {
let mut size_counts: HashMap<i32, usize> = HashMap::new();
for item in items {
if item.font_size >= 9.0 {
let size_key = (item.font_size * 10.0) as i32;
*size_counts.entry(size_key).or_insert(0) += 1;
}
}
let total_lines = size_counts.values().sum();
let most_common_size = size_counts
.iter()
.max_by(|(size_a, count_a), (size_b, count_b)| {
count_a.cmp(count_b).then_with(|| size_b.cmp(size_a))
})
.map(|(size, _)| *size as f32 / 10.0)
.unwrap_or(12.0);
FontStats {
most_common_size,
size_counts,
total_lines,
}
}
pub(crate) fn calculate_font_stats(lines: &[TextLine]) -> FontStats {
let mut size_counts: HashMap<i32, usize> = HashMap::new();
for line in lines {
if let Some(first) = line.items.first() {
if first.font_size >= 9.0 {
let size_key = (first.font_size * 10.0) as i32;
*size_counts.entry(size_key).or_insert(0) += 1;
}
}
}
let total_lines = size_counts.values().sum();
let most_common_size = size_counts
.iter()
.max_by(|(size_a, count_a), (size_b, count_b)| {
count_a.cmp(count_b).then_with(|| size_b.cmp(size_a))
})
.map(|(size, _)| *size as f32 / 10.0)
.unwrap_or(12.0);
FontStats {
most_common_size,
size_counts,
total_lines,
}
}
pub(crate) fn bold_heading_level(heading_tiers: &[f32]) -> usize {
let level = heading_tiers.len() + 1;
level.clamp(2, 6)
}
pub(crate) fn has_dot_leaders(text: &str) -> bool {
if text.contains("....") {
return true;
}
let mut dot_groups = 0;
let mut consecutive_dots = 0;
for ch in text.chars() {
if ch == '.' {
consecutive_dots += 1;
} else {
if consecutive_dots >= 3 {
dot_groups += 1;
}
consecutive_dots = 0;
}
}
if consecutive_dots >= 3 {
dot_groups += 1;
}
dot_groups >= 2
}
pub(crate) fn compute_paragraph_threshold(lines: &[TextLine], base_size: f32) -> f32 {
let fallback = base_size * 1.8;
let mut gaps: Vec<f32> = Vec::new();
let mut prev_y: Option<(u32, f32)> = None;
for line in lines {
if let Some((prev_page, py)) = prev_y {
if line.page == prev_page {
let gap = py - line.y;
if gap > 0.0 && gap < base_size * 10.0 {
gaps.push(gap);
}
}
}
prev_y = Some((line.page, line.y));
}
if gaps.len() < 5 {
return fallback;
}
gaps.sort_by(|a, b| a.total_cmp(b));
let median = gaps[gaps.len() / 2];
let threshold = (median * 1.3).max(base_size * 1.5);
debug!(
"paragraph_threshold: base_size={:.1} median_gap={:.1} threshold={:.1} ({} gaps sampled)",
base_size,
median,
threshold,
gaps.len()
);
if log::log_enabled!(log::Level::Debug) {
let buckets: &[f32] = &[0.0, 0.5, 1.0, 1.2, 1.5, 1.8, 2.0, 2.5, 3.0, 5.0, 10.0];
for i in 0..buckets.len() - 1 {
let count = gaps
.iter()
.filter(|&&g| {
let r = g / base_size;
r >= buckets[i] && r < buckets[i + 1]
})
.count();
if count > 0 {
debug!(
" gap_ratio {:.1}-{:.1}: {}",
buckets[i],
buckets[i + 1],
count
);
}
}
let over = gaps.iter().filter(|&&g| g / base_size >= 10.0).count();
if over > 0 {
debug!(" gap_ratio 10.0+: {}", over);
}
}
if log::log_enabled!(log::Level::Trace) {
let mut prev: Option<(u32, f32)> = None;
for line in lines {
let font_size = line.items.first().map(|i| i.font_size).unwrap_or(0.0);
let is_bold = line.items.first().map(|i| i.is_bold).unwrap_or(false);
let text = line.text();
let display: String = text.chars().take(80).collect();
let (gap_str, ratio_str, marker) = if let Some((pp, py)) = prev {
if pp == line.page {
let gap = py - line.y;
let ratio = gap / base_size;
let is_para = gap > threshold;
(
format!("{:8.1}", gap),
format!("{:8.2}", ratio),
if is_para { " <<PARA>>" } else { "" },
)
} else {
(" ---".to_string(), " ---".to_string(), "")
}
} else {
(" ---".to_string(), " ---".to_string(), "")
};
log::trace!(
" p={} y={:8.1} gap={} ratio={} fs={:5.1} {} {}{}",
line.page,
line.y,
gap_str,
ratio_str,
font_size,
if is_bold { "B" } else { " " },
display,
marker
);
prev = Some((line.page, line.y));
}
}
threshold
}
pub(crate) fn compute_heading_tiers(lines: &[TextLine], base_size: f32) -> Vec<f32> {
let mut heading_sizes: Vec<f32> = Vec::new();
for line in lines {
if let Some(first) = line.items.first() {
if first.font_size / base_size >= 1.2 {
heading_sizes.push(first.font_size);
}
}
}
heading_sizes.sort_by(|a, b| b.total_cmp(a));
let mut tiers: Vec<f32> = Vec::new();
for size in heading_sizes {
let already_in_tier = tiers.iter().any(|&t| (t - size).abs() < 0.5);
if !already_in_tier {
tiers.push(size);
}
}
tiers.truncate(4);
tiers
}
pub(crate) fn detect_header_level(
font_size: f32,
base_size: f32,
heading_tiers: &[f32],
) -> Option<usize> {
let ratio = font_size / base_size;
if ratio < 1.2 {
return None; }
if !heading_tiers.is_empty() {
for (i, &tier_size) in heading_tiers.iter().enumerate() {
if (font_size - tier_size).abs() < 0.5 {
return Some(i + 1); }
}
if ratio >= 1.5 {
let level = (heading_tiers.len() + 1).min(4);
return Some(level);
}
return None;
}
if ratio >= 2.0 {
Some(1)
} else if ratio >= 1.5 {
Some(2)
} else if ratio >= 1.25 {
Some(3)
} else {
Some(4)
}
}