use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::analytics::statistic::{FinalizationContext, Statistic};
use crate::types::PdfTextElement;
#[derive(Debug, Clone)]
pub struct FontStatsConfig {
pub top_k: usize,
pub distinct_size_min_fraction: f32,
}
impl Default for FontStatsConfig {
fn default() -> Self {
Self {
top_k: 5,
distinct_size_min_fraction: 0.05,
}
}
}
fn size_key(size: f32) -> String {
format!("{:.1}", size)
}
#[derive(Debug, Default)]
pub struct FontStatsBuilder {
config: FontStatsConfig,
size_counts: BTreeMap<String, usize>, family_counts: BTreeMap<String, usize>, bold_count: usize,
non_bold_count: usize,
italic_count: usize,
non_italic_count: usize,
bold_count_per_size: BTreeMap<String, usize>, distinct_sizes: BTreeSet<u32>, }
impl FontStatsBuilder {
pub fn new(config: FontStatsConfig) -> Self {
Self {
config,
..Default::default()
}
}
}
impl Statistic for FontStatsBuilder {
type Output = FontStats;
const NAME: &'static str = "font";
fn observe(&mut self, element: &PdfTextElement) {
if element.rotation() != 0 {
return;
}
let style = &element.style_info;
let key = size_key(style.font_size);
*self.size_counts.entry(key.clone()).or_insert(0) += 1;
self.distinct_sizes.insert(style.font_size.to_bits());
*self
.family_counts
.entry(style.font_family.clone())
.or_insert(0) += 1;
let is_bold = style.font_weight.to_lowercase().contains("bold");
if is_bold {
self.bold_count += 1;
*self.bold_count_per_size.entry(key).or_insert(0) += 1;
} else {
self.non_bold_count += 1;
}
let is_italic = style.font_style.to_lowercase().contains("italic");
if is_italic {
self.italic_count += 1;
} else {
self.non_italic_count += 1;
}
}
fn finalize(self, _ctx: &FinalizationContext<'_>) -> Self::Output {
let most_common_font_size = self
.size_counts
.iter()
.max_by(|(key_a, &cnt_a), (key_b, &cnt_b)| {
cnt_a.cmp(&cnt_b).then_with(|| {
let a: f32 = key_a.parse().unwrap_or(0.0);
let b: f32 = key_b.parse().unwrap_or(0.0);
a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal)
})
})
.and_then(|(key, _)| key.parse::<f32>().ok())
.unwrap_or(12.0);
let most_common_font_family = self
.family_counts
.iter()
.max_by(|(name_a, &cnt_a), (name_b, &cnt_b)| {
cnt_a.cmp(&cnt_b).then_with(|| name_b.cmp(name_a)) })
.map(|(name, _)| name.clone())
.unwrap_or_else(|| "unknown".to_string());
let all_font_sizes: Vec<f32> = self
.distinct_sizes
.iter()
.map(|&bits| f32::from_bits(bits))
.collect();
let total_count: usize = self.size_counts.values().sum();
let mut sorted_sizes: Vec<(f32, usize)> = self
.size_counts
.iter()
.filter_map(|(key, &cnt)| key.parse::<f32>().ok().map(|sz| (sz, cnt)))
.collect();
sorted_sizes.sort_by(|(sz_a, cnt_a), (sz_b, cnt_b)| {
cnt_b
.cmp(cnt_a)
.then_with(|| sz_b.partial_cmp(sz_a).unwrap_or(std::cmp::Ordering::Equal))
});
let top_k_dominant_sizes: Vec<f32> = sorted_sizes
.iter()
.take(self.config.top_k)
.map(|(sz, _)| *sz)
.collect();
let size_gap_first_to_second = if sorted_sizes.len() >= 2 {
(sorted_sizes[0].0 - sorted_sizes[1].0).abs()
} else {
0.0
};
let mut profile: Vec<(f32, DistinctSizeFrequency)> = if total_count > 0 {
self.size_counts
.iter()
.filter_map(|(key, &cnt)| {
let fraction = cnt as f32 / total_count as f32;
if fraction >= self.config.distinct_size_min_fraction {
key.parse::<f32>().ok().map(|sz| {
(
sz,
DistinctSizeFrequency {
size_key: key.clone(),
fraction_of_elements: fraction,
},
)
})
} else {
None
}
})
.collect()
} else {
vec![]
};
profile.sort_by(|(sz_a, entry_a), (sz_b, entry_b)| {
entry_b
.fraction_of_elements
.partial_cmp(&entry_a.fraction_of_elements)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| sz_b.partial_cmp(sz_a).unwrap_or(std::cmp::Ordering::Equal))
});
let distinct_size_frequency_profile: Vec<DistinctSizeFrequency> =
profile.into_iter().map(|(_, entry)| entry).collect();
let bold_density_per_size: BTreeMap<String, f32> = self
.size_counts
.iter()
.map(|(key, &cnt)| {
let bold_cnt = self.bold_count_per_size.get(key).copied().unwrap_or(0);
(key.clone(), bold_cnt as f32 / cnt as f32)
})
.collect();
let (mean_font_size, median_font_size, variance_font_size) = if total_count == 0 {
(0.0, 0.0, 0.0)
} else {
let mut asc: Vec<(f32, usize)> = self
.size_counts
.iter()
.filter_map(|(key, &cnt)| key.parse::<f32>().ok().map(|sz| (sz, cnt)))
.collect();
asc.sort_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mean =
asc.iter().map(|(sz, cnt)| sz * *cnt as f32).sum::<f32>() / total_count as f32;
let midpoint = (total_count - 1) / 2;
let mut cumulative = 0usize;
let mut median = asc[0].0;
for (sz, cnt) in &asc {
cumulative += cnt;
if cumulative > midpoint {
median = *sz;
break;
}
}
let variance = asc
.iter()
.map(|(sz, cnt)| {
let diff = sz - mean;
diff * diff * *cnt as f32
})
.sum::<f32>()
/ total_count as f32;
(mean, median, variance)
};
FontStats {
font_size_counts: self.size_counts,
font_family_counts: self.family_counts,
bold_counts: BoldCounts {
bold: self.bold_count,
non_bold: self.non_bold_count,
},
italic_counts: ItalicCounts {
italic: self.italic_count,
non_italic: self.non_italic_count,
},
most_common_font_size,
most_common_font_family,
all_font_sizes,
top_k_dominant_sizes,
distinct_size_frequency_profile,
size_gap_first_to_second,
bold_density_per_size,
mean_font_size,
median_font_size,
variance_font_size,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FontStats {
pub font_size_counts: BTreeMap<String, usize>,
pub font_family_counts: BTreeMap<String, usize>,
pub bold_counts: BoldCounts,
pub italic_counts: ItalicCounts,
pub most_common_font_size: f32,
pub most_common_font_family: String,
pub all_font_sizes: Vec<f32>,
pub top_k_dominant_sizes: Vec<f32>,
pub distinct_size_frequency_profile: Vec<DistinctSizeFrequency>,
pub size_gap_first_to_second: f32,
pub bold_density_per_size: BTreeMap<String, f32>,
pub mean_font_size: f32,
pub median_font_size: f32,
pub variance_font_size: f32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BoldCounts {
pub bold: usize,
pub non_bold: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ItalicCounts {
pub italic: usize,
pub non_italic: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DistinctSizeFrequency {
pub size_key: String,
pub fraction_of_elements: f32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{BoundingBox, FontClass, Placement};
fn make_element(
text: &str,
size: f32,
family: &str,
weight: &str,
style: &str,
rotation: i32,
) -> PdfTextElement {
PdfTextElement {
text: text.to_string(),
style_info: FontClass {
class_name: "test".to_string(),
font_family: family.to_string(),
font_size: size,
font_style: style.to_string(),
font_weight: weight.to_string(),
color: "#000000".to_string(),
},
placement: Placement {
page_number: 0,
bounding_box: BoundingBox {
x: 0.0,
y: 0.0,
width: 100.0,
height: size,
},
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(elements: &[PdfTextElement]) -> FontStats {
let mut b = FontStatsBuilder::default();
for e in elements {
b.observe(e);
}
b.finalize(&FinalizationContext::default())
}
fn build_stats_with_config(elements: &[PdfTextElement], config: FontStatsConfig) -> FontStats {
let mut b = FontStatsBuilder::new(config);
for e in elements {
b.observe(e);
}
b.finalize(&FinalizationContext::default())
}
#[test]
fn test_deterministic_tiebreaking() {
let mut elements: Vec<PdfTextElement> = vec![];
for _ in 0..5 {
elements.push(make_element("a", 12.0, "Arial", "normal", "normal", 0));
}
for _ in 0..5 {
elements.push(make_element("b", 14.0, "Arial", "normal", "normal", 0));
}
let run1 = build_stats(&elements);
let run2 = build_stats(&elements);
assert_eq!(
run1.most_common_font_size, 14.0,
"larger size wins on tie: expected 14.0"
);
assert_eq!(
run2.most_common_font_size, 14.0,
"second run must also resolve to 14.0"
);
let json1 = serde_json::to_string(&run1).expect("serialize run1");
let json2 = serde_json::to_string(&run2).expect("serialize run2");
assert_eq!(json1, json2, "two runs must produce byte-identical JSON");
}
#[test]
fn test_rotation_filter_preserved() {
let mut elements: Vec<PdfTextElement> = vec![];
for _ in 0..5 {
elements.push(make_element("body", 12.0, "Arial", "normal", "normal", 0));
}
for _ in 0..3 {
elements.push(make_element(
"rotated",
20.0,
"OtherFamily",
"normal",
"normal",
90,
));
}
let stats = build_stats(&elements);
assert_eq!(
stats.font_size_counts.get("12.0"),
Some(&5),
"12pt should have count 5"
);
assert_eq!(
stats.font_size_counts.get("20.0"),
None,
"rotated 20pt should not appear"
);
assert_eq!(
stats.bold_counts.bold + stats.bold_counts.non_bold,
5,
"total element count should be 5"
);
}
#[test]
fn test_top_k_dominant_sizes() {
let mut elements: Vec<PdfTextElement> = vec![];
for _ in 0..5 {
elements.push(make_element("a", 10.0, "F", "normal", "normal", 0));
}
for _ in 0..5 {
elements.push(make_element("b", 12.0, "F", "normal", "normal", 0));
}
for _ in 0..3 {
elements.push(make_element("c", 14.0, "F", "normal", "normal", 0));
}
let stats = build_stats(&elements);
assert_eq!(
stats.top_k_dominant_sizes,
vec![12.0, 10.0, 14.0],
"top_k ordering: (count desc, size desc)"
);
}
#[test]
fn test_distinct_size_frequency_profile() {
let mut elements: Vec<PdfTextElement> = vec![];
for _ in 0..100 {
elements.push(make_element("a", 12.0, "F", "normal", "normal", 0));
}
for _ in 0..5 {
elements.push(make_element("b", 10.0, "F", "normal", "normal", 0));
}
elements.push(make_element("c", 14.0, "F", "normal", "normal", 0));
let stats = build_stats(&elements);
assert_eq!(
stats.distinct_size_frequency_profile.len(),
1,
"only 12pt qualifies at default 0.05 threshold"
);
assert_eq!(stats.distinct_size_frequency_profile[0].size_key, "12.0");
let expected_frac = 100.0f32 / 106.0;
assert!(
(stats.distinct_size_frequency_profile[0].fraction_of_elements - expected_frac).abs()
< 1e-4,
"12pt fraction expected ~{:.4}, got {:.4}",
expected_frac,
stats.distinct_size_frequency_profile[0].fraction_of_elements,
);
let config = FontStatsConfig {
distinct_size_min_fraction: 0.04,
..Default::default()
};
let elements2 = elements.clone();
let stats2 = build_stats_with_config(&elements2, config);
assert_eq!(
stats2.distinct_size_frequency_profile.len(),
2,
"12pt and 10pt both qualify at 0.04 threshold"
);
assert_eq!(stats2.distinct_size_frequency_profile[0].size_key, "12.0");
assert_eq!(stats2.distinct_size_frequency_profile[1].size_key, "10.0");
}
#[test]
fn test_size_gap_first_to_second() {
let stats = build_stats(&[]);
assert_eq!(stats.size_gap_first_to_second, 0.0, "empty: gap = 0.0");
let elems: Vec<PdfTextElement> = (0..10)
.map(|_| make_element("a", 12.0, "F", "normal", "normal", 0))
.collect();
let stats = build_stats(&elems);
assert_eq!(
stats.size_gap_first_to_second, 0.0,
"single size: gap = 0.0"
);
let mut elems: Vec<PdfTextElement> = (0..10)
.map(|_| make_element("a", 12.0, "F", "normal", "normal", 0))
.collect();
for _ in 0..5 {
elems.push(make_element("b", 16.0, "F", "normal", "normal", 0));
}
let stats = build_stats(&elems);
assert!(
(stats.size_gap_first_to_second - 4.0).abs() < 1e-4,
"gap expected 4.0, got {}",
stats.size_gap_first_to_second
);
}
#[test]
fn test_bold_density_per_size() {
let elements: Vec<PdfTextElement> = vec![
make_element("a", 12.0, "F", "bold", "normal", 0),
make_element("b", 12.0, "F", "normal", "normal", 0),
make_element("c", 12.0, "F", "normal", "normal", 0),
make_element("d", 16.0, "F", "bold", "normal", 0),
make_element("e", 16.0, "F", "bold", "normal", 0),
];
let stats = build_stats(&elements);
let d12 = *stats.bold_density_per_size.get("12.0").expect("12.0 key");
let d16 = *stats.bold_density_per_size.get("16.0").expect("16.0 key");
assert!(
(d12 - 1.0f32 / 3.0).abs() < 1e-4,
"12pt bold density: expected ~0.3333, got {:.4}",
d12
);
assert!(
(d16 - 1.0f32).abs() < 1e-4,
"16pt bold density: expected 1.0, got {:.4}",
d16
);
}
#[test]
fn test_mean_median_variance() {
let mut elements: Vec<PdfTextElement> = vec![];
for _ in 0..2 {
elements.push(make_element("a", 10.0, "F", "normal", "normal", 0));
}
for _ in 0..5 {
elements.push(make_element("b", 12.0, "F", "normal", "normal", 0));
}
for _ in 0..3 {
elements.push(make_element("c", 14.0, "F", "normal", "normal", 0));
}
let stats = build_stats(&elements);
assert!(
(stats.mean_font_size - 12.2).abs() < 1e-3,
"mean expected 12.2, got {}",
stats.mean_font_size
);
assert!(
(stats.median_font_size - 12.0).abs() < 1e-3,
"median expected 12.0, got {}",
stats.median_font_size
);
assert!(
(stats.variance_font_size - 1.96).abs() < 1e-3,
"variance expected 1.96, got {}",
stats.variance_font_size
);
}
#[test]
fn test_idempotency() {
let mut elements: Vec<PdfTextElement> = vec![];
for _ in 0..8 {
elements.push(make_element("a", 12.0, "Arial", "normal", "normal", 0));
}
for _ in 0..4 {
elements.push(make_element("b", 14.0, "Times", "bold", "italic", 0));
}
for _ in 0..2 {
elements.push(make_element("c", 10.0, "Courier", "normal", "italic", 0));
}
elements.push(make_element("d", 18.0, "Courier", "bold", "normal", 90));
let run1 = build_stats(&elements);
let run2 = build_stats(&elements);
let json1 = serde_json::to_string(&run1).expect("serialize run1");
let json2 = serde_json::to_string(&run2).expect("serialize run2");
assert_eq!(json1, json2, "two runs must produce byte-identical JSON");
}
}