Replace ad-hoc bold/ratio heading checks with a unified scoring system based on font size rarity. For each line, compute: score = font_rarity * 0.5 + bold * 0.3 + standalone * 0.2 Font rarity measures how infrequently a font size appears across the document — heading fonts are rare while body text is common. This approach (from opendataloader's ModeWeightStatistics) naturally adapts to each document's font distribution instead of relying on fixed thresholds. Guards: require font_size >= 0.95 * base_size (no small-font headings), word_count >= 3, and standalone (paragraph break before). Benchmark improvement: MHS 0.56→0.58, MHS-S 0.66→0.70, overall +0.003. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
323 lines
11 KiB
Rust
323 lines
11 KiB
Rust
//! Font statistics, heading detection, and document structure analysis.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use crate::types::{TextItem, TextLine};
|
|
use log::debug;
|
|
|
|
/// Font statistics for a document
|
|
pub(crate) struct FontStats {
|
|
pub(crate) most_common_size: f32,
|
|
/// Font size frequency distribution (size_key → line count).
|
|
/// Used for rarity-based heading detection.
|
|
pub(crate) size_counts: HashMap<i32, usize>,
|
|
/// Total number of lines counted.
|
|
pub(crate) total_lines: usize,
|
|
}
|
|
|
|
/// Compute how rare a font size is in the document (0.0 = most common, 1.0 = unique).
|
|
/// Mirrors opendataloader's font rarity boosting approach: heading fonts appear on
|
|
/// far fewer lines than body text, so their percentile rank is high.
|
|
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);
|
|
// Rarity = 1 - (frequency ratio). A size used on 1/100 lines has rarity ~0.99.
|
|
1.0 - (count as f32 / stats.total_lines as f32)
|
|
}
|
|
|
|
/// Calculate font stats directly from items (before grouping into lines)
|
|
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();
|
|
|
|
// Break ties by preferring the smaller font size for deterministic output
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// Calculate font stats from grouped lines
|
|
pub(crate) fn calculate_font_stats(lines: &[TextLine]) -> FontStats {
|
|
let mut size_counts: HashMap<i32, usize> = HashMap::new();
|
|
|
|
for line in lines {
|
|
// Count once per line (first item) to give each line equal weight
|
|
// Prevents small captions/footnotes from skewing the base
|
|
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();
|
|
|
|
// Break ties by preferring the smaller font size for deterministic output
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// Determine the heading level for a bold-only line that didn't meet the font-size
|
|
/// threshold. These are common in academic papers where section headings are bold
|
|
/// at the same size as body text.
|
|
///
|
|
/// Returns a level below the lowest font-size tier (or H2 when no tiers exist).
|
|
pub(crate) fn bold_heading_level(heading_tiers: &[f32]) -> usize {
|
|
let level = heading_tiers.len() + 1;
|
|
// Clamp to 1..=6 — if no font-size tiers, bold headings become H2
|
|
// (H1 is reserved for titles which are typically larger)
|
|
level.clamp(2, 6)
|
|
}
|
|
|
|
/// Detect TOC-style lines that contain dot leaders (e.g., "Section Name .... 42").
|
|
/// These lines should never be joined with adjacent lines into a paragraph.
|
|
/// Handles both consecutive dots ("....") and spaced dots ("... ...").
|
|
pub(crate) fn has_dot_leaders(text: &str) -> bool {
|
|
// Consecutive dots (4+)
|
|
if text.contains("....") {
|
|
return true;
|
|
}
|
|
// Spaced dot leaders: "..." followed by whitespace and more dots
|
|
// Count occurrences of "..." (3+ dots) — if 2+ groups, it's a dot leader
|
|
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
|
|
}
|
|
|
|
/// Compute the Y-gap threshold for paragraph break detection.
|
|
///
|
|
/// Instead of using a fixed multiple of base_size (which fails for double-spaced
|
|
/// documents), we compute the document's typical (median) line spacing and use
|
|
/// a multiplier on that. A gap significantly larger than typical indicates a
|
|
/// paragraph break.
|
|
///
|
|
/// Fallback: if we can't compute typical spacing, use base_size * 1.8.
|
|
pub(crate) fn compute_paragraph_threshold(lines: &[TextLine], base_size: f32) -> f32 {
|
|
let fallback = base_size * 1.8;
|
|
|
|
// Collect Y gaps between consecutive lines on the same page
|
|
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;
|
|
// Only consider positive gaps within a reasonable range
|
|
// (skip huge gaps from page headers/footers)
|
|
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) {
|
|
// Gap histogram
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Per-line detail: Y position, gap, ratio, bold, text preview, paragraph marker
|
|
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
|
|
}
|
|
|
|
/// Discover distinct heading font-size tiers in the document.
|
|
/// Returns tiers sorted largest-first (tier 0 = H1, tier 1 = H2, …).
|
|
/// Sizes within 0.5pt are clustered into the same tier. Capped at 4 tiers.
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort descending
|
|
heading_sizes.sort_by(|a, b| b.total_cmp(a));
|
|
|
|
// Cluster sizes within 0.5pt into same tier (use first value as representative)
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Cap at 4 tiers
|
|
tiers.truncate(4);
|
|
tiers
|
|
}
|
|
|
|
/// Detect header level from font size using document-specific heading tiers.
|
|
/// When tiers are available, maps tier 0→H1, tier 1→H2, etc.
|
|
/// Falls back to ratio-based thresholds when no tiers exist.
|
|
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; // Regular text
|
|
}
|
|
|
|
if !heading_tiers.is_empty() {
|
|
// Match font_size to a tier (within 0.5pt tolerance)
|
|
for (i, &tier_size) in heading_tiers.iter().enumerate() {
|
|
if (font_size - tier_size).abs() < 0.5 {
|
|
return Some(i + 1); // tier 0 → H1, tier 1 → H2, etc.
|
|
}
|
|
}
|
|
// No tier match but large ratio — assign level after last tier
|
|
if ratio >= 1.5 {
|
|
let level = (heading_tiers.len() + 1).min(4);
|
|
return Some(level);
|
|
}
|
|
// No tier match and small ratio — not a heading
|
|
return None;
|
|
}
|
|
|
|
// Fallback: original ratio-based thresholds (no tiers discovered)
|
|
if ratio >= 2.0 {
|
|
Some(1)
|
|
} else if ratio >= 1.5 {
|
|
Some(2)
|
|
} else if ratio >= 1.25 {
|
|
Some(3)
|
|
} else {
|
|
Some(4)
|
|
}
|
|
}
|