use super::super::cleanup::is_likely_sentence;
use super::super::models::*;
use super::formatting::{
extract_numbering_info, extract_paragraph_text, reconstruct_heading_number,
};
use super::list::is_likely_list_item;
use super::numbering::{extract_heading_number_from_text, HeadingInfo};
pub(crate) fn detect_heading_from_paragraph_style(para: &docx_rs::Paragraph) -> Option<u8> {
if let Some(style) = ¶.property.style {
if style.val.starts_with("Heading") || style.val.starts_with("heading") {
if let Some(level_char) = style.val.chars().last() {
if let Some(level) = level_char.to_digit(10) {
return Some(level.min(6) as u8);
}
}
return Some(1);
}
}
None
}
pub(crate) fn detect_heading_with_numbering(para: &docx_rs::Paragraph) -> Option<HeadingInfo> {
let heading_level = detect_heading_from_paragraph_style(para)?;
let text = extract_paragraph_text(para);
if let Some((number, remaining_text)) = extract_heading_number_from_text(&text) {
return Some(HeadingInfo {
level: heading_level,
number: Some(number),
clean_text: Some(remaining_text),
});
}
if let Some(num_pr) = ¶.property.numbering_property {
if let Some((num_id, level)) = extract_numbering_info(num_pr) {
let number = reconstruct_heading_number(num_id, level, heading_level);
return Some(HeadingInfo {
level: heading_level,
number: Some(number),
clean_text: Some(text), });
}
}
Some(HeadingInfo {
level: heading_level,
number: None,
clean_text: None,
})
}
pub(crate) fn detect_heading_from_text(text: &str, formatting: &TextFormatting) -> Option<u8> {
let text = text.trim();
if text.len() < 100 && !text.contains('\n') {
if is_likely_list_item(text) || is_likely_sentence(text) {
return None;
}
if text.contains(" the ")
|| text.contains(" and ")
|| text.contains(" with ")
|| text.contains(" for ")
{
return None;
}
if formatting.bold && text.len() < 60 && text.len() > 5 {
if !text.ends_with('.')
&& !text.ends_with(',')
&& !text.ends_with(';')
&& !text.ends_with(':')
{
return Some(determine_heading_level_from_text(text));
}
}
if text.len() > 15
&& text.len() < 50
&& text.chars().all(|c| {
c.is_uppercase() || c.is_whitespace() || c.is_numeric() || c.is_ascii_punctuation()
})
{
return Some(1);
}
if text.starts_with("Chapter ") || text.starts_with("Section ") || text.starts_with("Part ")
{
return Some(determine_heading_level_from_text(text));
}
if text.len() < 40
&& text.len() > 10
&& !text.ends_with('.')
&& !text.contains(',')
&& !text.contains('(')
&& !text.contains(':')
{
let words = text.split_whitespace().count();
if (2..=5).contains(&words) {
let has_meaningful_word = text
.split_whitespace()
.any(|word| word.len() > 3 && word.chars().all(|c| c.is_alphabetic()));
if has_meaningful_word && text.chars().next().is_some_and(|c| c.is_uppercase()) {
return Some(determine_heading_level_from_text(text));
}
}
}
}
None
}
pub(crate) fn determine_heading_level_from_text(text: &str) -> u8 {
if text.len() < 20 {
1
} else if text.len() < 40 {
2
} else {
3
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_heading_number_extraction() {
assert_eq!(
extract_heading_number_from_text("1. Introduction"),
Some(("1".to_string(), "Introduction".to_string()))
);
assert_eq!(
extract_heading_number_from_text("1.1 Project Overview"),
Some(("1.1".to_string(), "Project Overview".to_string()))
);
assert_eq!(
extract_heading_number_from_text("2.1.1 Something Important"),
Some(("2.1.1".to_string(), "Something Important".to_string()))
);
assert_eq!(
extract_heading_number_from_text("A. First Section"),
Some(("A".to_string(), "First Section".to_string()))
);
assert_eq!(
extract_heading_number_from_text("I. Roman Numeral"),
Some(("I".to_string(), "Roman Numeral".to_string()))
);
assert_eq!(
extract_heading_number_from_text("Section 1.2 Overview"),
Some(("Section 1.2".to_string(), "Overview".to_string()))
);
assert_eq!(extract_heading_number_from_text("Introduction"), None);
assert_eq!(extract_heading_number_from_text("Heading 1"), None);
assert_eq!(
extract_heading_number_from_text("Chapter 5 Summary"),
Some(("Chapter 5".to_string(), "Summary".to_string()))
);
assert_eq!(extract_heading_number_from_text("Version 2"), None);
}
}