use std::collections::BTreeSet;
use std::sync::LazyLock;
use regex::Regex;
use super::Line;
static NUMBERED_SECTION: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\d+(\.\d+)*\.?\s+\S").unwrap());
const MAX_HEADING_CHARS: usize = 160;
const HEADING_MIN_RATIO: f32 = 1.12;
fn size_bucket(size: f32) -> u32 {
(size * 2.0).round() as u32
}
pub(super) struct HeadingScale {
tiers: Vec<u32>,
}
impl HeadingScale {
#[cfg(test)]
pub(super) fn empty() -> Self {
Self { tiers: Vec::new() }
}
pub(super) fn is_empty(&self) -> bool {
self.tiers.is_empty()
}
pub(super) fn build<'a>(lines: impl Iterator<Item = &'a Line>, body_font_size: f32) -> Self {
let mut buckets: BTreeSet<u32> = BTreeSet::new();
for line in lines {
if is_size_heading_candidate(line, body_font_size) {
buckets.insert(size_bucket(line.font_size));
}
}
let mut tiers: Vec<u32> = buckets.into_iter().rev().collect();
tiers.truncate(6);
Self { tiers }
}
fn level_for(&self, size: f32) -> Option<usize> {
let bucket = size_bucket(size);
self.tiers
.iter()
.position(|&tier| bucket >= tier)
.map(|index| (index + 1).min(6))
}
}
fn is_size_heading_candidate(line: &Line, body_font_size: f32) -> bool {
let char_count = line.text.trim().chars().count();
char_count > 0
&& char_count <= MAX_HEADING_CHARS
&& line.font_size >= body_font_size * HEADING_MIN_RATIO
}
pub(super) fn heading_level(
line: &Line,
body_font_size: f32,
scale: &HeadingScale,
) -> Option<usize> {
let text = line.text.trim();
if text.is_empty() || text.chars().count() > MAX_HEADING_CHARS {
return None;
}
let size_level = if scale.is_empty() {
fixed_ratio_level(line.font_size / body_font_size)
} else if is_size_heading_candidate(line, body_font_size) {
scale.level_for(line.font_size)
} else {
None
};
if let Some(level) = size_level {
return Some(level);
}
let ratio = line.font_size / body_font_size;
if (0.98..HEADING_MIN_RATIO).contains(&ratio) && NUMBERED_SECTION.is_match(text) {
return Some(section_depth(text));
}
None
}
fn fixed_ratio_level(ratio: f32) -> Option<usize> {
if ratio >= 1.6 {
Some(1)
} else if ratio >= 1.3 {
Some(2)
} else if ratio >= HEADING_MIN_RATIO {
Some(3)
} else {
None
}
}
fn section_depth(text: &str) -> usize {
text.split_whitespace()
.next()
.map(|number| number.trim_end_matches('.').matches('.').count() + 1)
.unwrap_or(1)
.min(6)
}
#[cfg(test)]
mod tests {
use super::*;
fn line(text: &str, font_size: f32) -> Line {
Line {
text: text.to_string(),
y: 0.0,
font_size,
cells: Vec::new(),
}
}
fn scale_of(lines: &[Line], body: f32) -> HeadingScale {
HeadingScale::build(lines.iter(), body)
}
#[test]
fn large_font_is_a_top_level_heading() {
let lines = [line("Chapter One", 20.0)];
let scale = scale_of(&lines, 10.0);
assert_eq!(heading_level(&lines[0], 10.0, &scale), Some(1));
}
#[test]
fn body_sized_text_is_not_a_heading() {
let lines = [line("Just a normal sentence.", 10.0)];
let scale = scale_of(&lines, 10.0);
assert_eq!(heading_level(&lines[0], 10.0, &scale), None);
}
#[test]
fn numbered_section_at_body_size_is_a_heading() {
let lines = [line("3.4 Hyperfocus", 10.0)];
let scale = scale_of(&lines, 10.0);
assert_eq!(heading_level(&lines[0], 10.0, &scale), Some(2));
}
#[test]
fn overly_long_line_is_never_a_heading_even_if_large() {
let text = "word ".repeat(60);
let lines = [line(&text, 20.0)];
let scale = scale_of(&lines, 10.0);
assert_eq!(heading_level(&lines[0], 10.0, &scale), None);
}
#[test]
fn three_font_tiers_map_to_h1_h2_h3_in_descending_size_order() {
let lines = [
line("Biggest", 24.0),
line("Middle", 18.0),
line("Smallest heading", 14.0),
line("body text here", 10.0),
];
let scale = scale_of(&lines, 10.0);
assert_eq!(heading_level(&lines[0], 10.0, &scale), Some(1));
assert_eq!(heading_level(&lines[1], 10.0, &scale), Some(2));
assert_eq!(heading_level(&lines[2], 10.0, &scale), Some(3));
assert_eq!(heading_level(&lines[3], 10.0, &scale), None);
}
#[test]
fn uniform_font_page_has_no_size_headings() {
let lines = [
line("A line of text", 10.0),
line("Another line of text", 10.0),
line("A third line of text", 10.0),
];
let scale = scale_of(&lines, 10.0);
assert!(scale.is_empty());
for l in &lines {
assert_eq!(heading_level(l, 10.0, &scale), None);
}
}
#[test]
fn two_tiers_rank_relative_to_the_document_not_a_fixed_ratio() {
let lines = [line("Larger", 15.0), line("Smaller", 13.0)];
let scale = scale_of(&lines, 10.0);
assert_eq!(heading_level(&lines[0], 10.0, &scale), Some(1));
assert_eq!(heading_level(&lines[1], 10.0, &scale), Some(2));
}
}