use crate::{TextBlock, TextLine, TextSpan};
use std::collections::HashMap;
pub const HEADING_PREFIXES: [&str; 6] =
["# ", "## ", "### ", "#### ", "##### ", "###### "];
pub fn build_heading_size_map(blocks: &[TextBlock]) -> (HashMap<u32, usize>, u32) {
let mut size_counter: HashMap<u32, u64> = HashMap::new();
for block in blocks {
for line in &block.lines {
for span in &line.spans {
let rounded = span.font_size.round() as u32;
let len = span.text.chars().count() as u64;
*size_counter.entry(rounded).or_insert(0) += len;
}
}
}
const BODY_LIMIT: u32 = 12;
if size_counter.is_empty() {
return (HashMap::new(), BODY_LIMIT);
}
let mode = size_counter
.iter()
.max_by_key(|&(_, count)| *count)
.map(|(s, _)| *s)
.unwrap_or(10);
let body_limit = BODY_LIMIT.max(mode);
let mut heading_sizes: Vec<u32> = size_counter
.keys()
.copied()
.filter(|&s| s > body_limit)
.collect();
heading_sizes.sort_by(|a, b| b.cmp(a));
heading_sizes.truncate(6);
let header_map: HashMap<u32, usize> = heading_sizes
.iter()
.enumerate()
.map(|(i, &s)| (s, i + 1))
.collect();
(header_map, body_limit)
}
pub fn detect_structural_heading(
trimmed: &str,
all_bold: bool,
stands_alone: bool,
) -> Option<usize> {
if trimmed.is_empty() {
return None;
}
let word_count = trimmed.split_whitespace().count();
let ends_in_terminator = trimmed
.chars()
.last()
.map(|c| matches!(c, '.' | ';' | ',' | '!' | '?'))
.unwrap_or(false);
if word_count == 0 || word_count > 16 {
return None;
}
if stands_alone {
if let Some(level) = detect_numbered_prefix_level(trimmed) {
return Some(level);
}
}
let letter_chars: Vec<char> = trimmed.chars().filter(|c| c.is_alphabetic()).collect();
if stands_alone && letter_chars.len() >= 4 {
let upper_ratio = letter_chars.iter().filter(|c| c.is_uppercase()).count() as f32
/ letter_chars.len() as f32;
if upper_ratio >= 0.85 && word_count <= 12 && !ends_in_terminator {
return Some(2);
}
}
if all_bold
&& stands_alone
&& word_count >= 2
&& word_count <= 10
&& !ends_in_terminator
&& letter_chars.len() >= 3
{
return Some(3);
}
None
}
pub fn detect_numbered_prefix_level(s: &str) -> Option<usize> {
let head = s.trim_start();
let lower = head.to_lowercase();
for prefix in ["part ", "chapter ", "section "] {
if let Some(rest) = lower.strip_prefix(prefix) {
let mut chars = rest.chars();
if let Some(first) = chars.next() {
if first.is_alphanumeric() {
return Some(1);
}
}
}
}
let mut depth = 0usize;
let mut chars = head.chars().peekable();
let mut consumed_digit = false;
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
consumed_digit = true;
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() {
chars.next();
} else {
break;
}
}
depth += 1;
if let Some(&dot) = chars.peek() {
if dot == '.' {
chars.next();
continue;
}
}
break;
} else {
break;
}
}
if consumed_digit && depth >= 1 {
let after = chars.collect::<String>();
let after_trim = after.trim_start();
if !after_trim.is_empty()
&& after_trim
.chars()
.next()
.map(|c| c.is_alphabetic())
.unwrap_or(false)
{
return Some(depth.clamp(1, 6));
}
}
None
}
pub fn normalize_heading_lookup(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_space = false;
for c in s.chars() {
if matches!(c, '*' | '_' | '`') {
continue;
}
if c.is_whitespace() {
if !prev_space && !out.is_empty() {
out.push(' ');
}
prev_space = true;
} else {
out.push(c.to_ascii_lowercase());
prev_space = false;
}
}
while let Some(c) = out.chars().last() {
if matches!(c, '.' | ':' | ',' | ';' | '!' | '?' | '-' | ' ') {
out.pop();
} else {
break;
}
}
out
}
pub fn merge_adjacent_heading_blocks(
blocks: Vec<TextBlock>,
body_limit: u32,
) -> Vec<TextBlock> {
let dom_size = |b: &TextBlock| {
let mut counter: HashMap<u32, u64> = HashMap::new();
for line in &b.lines {
for span in &line.spans {
*counter.entry(span.font_size.round() as u32).or_insert(0) +=
span.text.chars().count() as u64;
}
}
counter
.into_iter()
.max_by_key(|&(_, c)| c)
.map(|(s, _)| s)
.unwrap_or(0)
};
let mut consumed: Vec<bool> = vec![false; blocks.len()];
let mut out: Vec<TextBlock> = Vec::with_capacity(blocks.len());
for i in 0..blocks.len() {
if consumed[i] {
continue;
}
let mut cur = blocks[i].clone();
let cur_size = dom_size(&cur);
if cur_size <= body_limit {
out.push(cur);
continue;
}
const MAX_LOOKAHEAD: usize = 6;
for j in (i + 1)..(i + 1 + MAX_LOOKAHEAD).min(blocks.len()) {
if consumed[j] {
continue;
}
let cand = &blocks[j];
if cand.page != cur.page {
break;
}
let cand_size = dom_size(cand);
if cand_size != cur_size {
continue;
}
let x_diff = (cand.bbox.x0 - cur.bbox.x0).abs();
if x_diff > 5.0 {
continue;
}
let v_gap = cur.bbox.y0 - cand.bbox.y1;
let v_gap_above = cand.bbox.y0 - cur.bbox.y1;
let near_below = v_gap >= 0.0 && v_gap < (cur_size as f32) * 1.5;
let near_above = v_gap_above >= 0.0 && v_gap_above < (cur_size as f32) * 1.5;
if !(near_below || near_above) {
continue;
}
for line in &cand.lines {
cur.lines.push(line.clone());
}
cur.text.push(' ');
cur.text.push_str(&cand.text);
cur.bbox.x0 = cur.bbox.x0.min(cand.bbox.x0);
cur.bbox.x1 = cur.bbox.x1.max(cand.bbox.x1);
cur.bbox.y0 = cur.bbox.y0.min(cand.bbox.y0);
cur.bbox.y1 = cur.bbox.y1.max(cand.bbox.y1);
consumed[j] = true;
}
out.push(cur);
}
out
}
pub fn collect_bold_text(
spans: &[TextSpan],
styles: &HashMap<(u32, String), (bool, bool)>,
page: u32,
) -> String {
let mut out = String::new();
for span in spans {
let is_bold = styles
.get(&(page, span.font.clone()))
.map(|(b, _)| *b)
.unwrap_or(false);
if is_bold {
out.push_str(&span.text);
}
}
out
}
pub fn line_dominant_size(line: &TextLine) -> u32 {
let mut counter: HashMap<u32, u64> = HashMap::new();
for span in &line.spans {
let rounded = span.font_size.round() as u32;
*counter.entry(rounded).or_insert(0) += span.text.chars().count() as u64;
}
counter
.into_iter()
.max_by_key(|&(_, c)| c)
.map(|(s, _)| s)
.unwrap_or(0)
}
pub fn render_styled_line(
spans: &[TextSpan],
page: u32,
styles: &HashMap<(u32, String), (bool, bool)>,
) -> String {
let mut out = String::with_capacity(spans.iter().map(|s| s.text.len() + 6).sum());
let mut current_bold = false;
let mut current_italic = false;
let close = |out: &mut String, bold: bool, italic: bool| {
if bold && italic {
out.push_str("***");
} else if bold {
out.push_str("**");
} else if italic {
out.push('*');
}
};
let open = |out: &mut String, bold: bool, italic: bool| {
if bold && italic {
out.push_str("***");
} else if bold {
out.push_str("**");
} else if italic {
out.push('*');
}
};
for (i, span) in spans.iter().enumerate() {
let (bold, italic) = styles
.get(&(page, span.font.clone()))
.copied()
.unwrap_or((false, false));
if i == 0 {
open(&mut out, bold, italic);
current_bold = bold;
current_italic = italic;
} else if bold != current_bold || italic != current_italic {
close(&mut out, current_bold, current_italic);
open(&mut out, bold, italic);
current_bold = bold;
current_italic = italic;
}
out.push_str(&span.text);
if i + 1 < spans.len() && !span.text.ends_with(' ') {
out.push(' ');
}
}
close(&mut out, current_bold, current_italic);
out
}