pub fn is_page_number_line(line: &str) -> bool {
if line.chars().all(|c| c.is_ascii_digit()) {
return true;
}
let lower = line.to_ascii_lowercase();
if lower.len() > 8 {
return false;
}
lower
.chars()
.all(|c| matches!(c, 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm'))
}
pub fn contains_page_number_token(line: &str) -> bool {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() > 5 {
return false;
}
tokens.iter().any(|token| {
let trimmed = token
.trim_matches(|c: char| !c.is_ascii_alphanumeric())
.to_string();
if trimmed.is_empty() {
return false;
}
is_page_number_line(trimmed.as_str())
})
}
pub fn looks_like_running_header(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.len() < 3 || trimmed.len() > 60 {
return false;
}
let mut letters = 0usize;
let mut uppercase = 0usize;
for ch in trimmed.chars() {
if ch.is_ascii_alphabetic() {
letters += 1;
if ch.is_ascii_uppercase() {
uppercase += 1;
}
}
}
letters > 0 && uppercase == letters
}
pub fn looks_like_numbered_running_header(line: &str) -> bool {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 3 || tokens.len() > 10 {
return false;
}
let page_token = tokens[tokens.len() - 1].trim_matches(|c: char| !c.is_ascii_alphanumeric());
if !is_page_number_line(page_token) {
return false;
}
let mut letters = 0usize;
let mut uppercase = 0usize;
for token in &tokens[..tokens.len() - 1] {
for ch in token.chars() {
if ch.is_ascii_alphabetic() {
letters += 1;
if ch.is_ascii_uppercase() {
uppercase += 1;
}
}
}
}
letters > 0 && uppercase * 100 / letters >= 80
}
pub fn looks_like_boundary_header(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.len() < 3 || trimmed.len() > 60 || starts_with_block(trimmed) {
return false;
}
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if tokens.is_empty() || tokens.len() > 6 {
return false;
}
if tokens
.iter()
.any(|token| token.chars().any(|ch| ch.is_ascii_digit()))
{
return false;
}
if tokens.iter().any(|token| {
token
.chars()
.any(|ch| matches!(ch, '.' | ',' | ';' | ':' | '!' | '?'))
}) {
return false;
}
tokens.iter().all(|token| is_title_or_upper_word(token))
}
pub fn is_title_or_upper_word(token: &str) -> bool {
let word = token.trim_matches(|c: char| !c.is_ascii_alphabetic());
if word.is_empty() {
return false;
}
if word.chars().all(|ch| ch.is_ascii_uppercase()) {
return true;
}
let mut chars = word.chars();
let Some(first) = chars.next() else {
return false;
};
first.is_ascii_uppercase() && chars.all(|ch| ch.is_ascii_lowercase())
}
pub fn starts_with_block(s: &str) -> bool {
let trimmed = s.trim_start();
trimmed.starts_with('#')
|| trimmed.starts_with("```")
|| trimmed.starts_with("<pre>")
|| trimmed.starts_with('>')
|| trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
|| trimmed.starts_with("+ ")
|| is_numbered_list(trimmed)
}
pub fn ends_with_block(s: &str) -> bool {
let trimmed = s.trim_end();
trimmed.ends_with("</pre>")
|| trimmed.ends_with("</table>")
|| trimmed.ends_with("</blockquote>")
|| trimmed.ends_with("***")
|| trimmed.ends_with("---")
}
pub fn is_numbered_list(s: &str) -> bool {
let mut chars = s.chars();
let mut saw_digit = false;
for ch in &mut chars {
if ch.is_ascii_digit() {
saw_digit = true;
continue;
}
if saw_digit && (ch == '.' || ch == ')') {
return true;
}
break;
}
false
}
pub fn ends_with_hyphen(s: &str) -> bool {
s.ends_with('-') || s.ends_with('‐') || s.ends_with('‑')
}
pub fn last_line_is_list_item(s: &str) -> bool {
let last_line = s.rsplit('\n').next().unwrap_or(s).trim_start();
last_line.starts_with("- ") || last_line.starts_with("* ") || last_line.starts_with("+ ")
}
pub fn is_terminal_punct(ch: char) -> bool {
matches!(ch, '.' | '!' | '?' | '…')
}
pub fn last_significant_char(s: &str) -> Option<char> {
for ch in s.trim_end().chars().rev() {
if ch.is_whitespace() {
continue;
}
if matches!(ch, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']' | '}') {
continue;
}
return Some(ch);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn page_number_pure_digits() {
assert!(is_page_number_line("42"));
assert!(is_page_number_line("1"));
assert!(is_page_number_line("999"));
}
#[test]
fn page_number_roman_numerals() {
assert!(is_page_number_line("iv"));
assert!(is_page_number_line("xii"));
assert!(is_page_number_line("VII"));
assert!(is_page_number_line("MCMLXIV")); }
#[test]
fn page_number_roman_too_long() {
assert!(!is_page_number_line("mmmmmmmmm"));
}
#[test]
fn page_number_false_positive_short_roman_words() {
assert!(is_page_number_line("civil"));
assert!(is_page_number_line("dim"));
assert!(!is_page_number_line("civilization"));
}
#[test]
fn page_number_empty_string() {
assert!(is_page_number_line(""));
}
#[test]
fn page_number_mixed_content() {
assert!(!is_page_number_line("page 5"));
assert!(!is_page_number_line("42a"));
}
#[test]
fn page_token_with_punctuation() {
assert!(contains_page_number_token("(7)"));
assert!(contains_page_number_token("—42—"));
assert!(contains_page_number_token("CHAPTER iv"));
}
#[test]
fn page_token_too_many_tokens() {
assert!(!contains_page_number_token("one two three four five 42"));
}
#[test]
fn page_token_at_five_tokens() {
assert!(contains_page_number_token("A B C D 42"));
}
#[test]
fn page_token_all_punctuation_stripped_empty() {
assert!(!contains_page_number_token("---"));
}
#[test]
fn running_header_all_caps() {
assert!(looks_like_running_header("CHAPTER ONE"));
assert!(looks_like_running_header("THE GREAT WAR"));
}
#[test]
fn running_header_mixed_case_rejected() {
assert!(!looks_like_running_header("Chapter One"));
assert!(!looks_like_running_header("the great war"));
}
#[test]
fn running_header_too_short() {
assert!(!looks_like_running_header("AB")); }
#[test]
fn running_header_at_boundary_length() {
assert!(looks_like_running_header("ABC")); let long = "A".repeat(60);
assert!(looks_like_running_header(&long)); let too_long = "A".repeat(61);
assert!(!looks_like_running_header(&too_long));
}
#[test]
fn running_header_with_spaces_and_numbers() {
assert!(looks_like_running_header("CHAPTER 3"));
}
#[test]
fn numbered_header_typical() {
assert!(looks_like_numbered_running_header("FREDERICK THE GREAT 9"));
}
#[test]
fn numbered_header_too_few_tokens() {
assert!(!looks_like_numbered_running_header("TITLE 9")); }
#[test]
fn numbered_header_no_page_number_at_end() {
assert!(!looks_like_numbered_running_header("CHAPTER THE THIRD end"));
}
#[test]
fn numbered_header_below_80_percent_uppercase() {
assert!(looks_like_numbered_running_header("ABc DEF 9"));
assert!(!looks_like_numbered_running_header("Abcd EF 9"));
}
#[test]
fn numbered_header_at_80_percent_uppercase() {
assert!(looks_like_numbered_running_header("ABCd EFG 9"));
}
#[test]
fn numbered_header_eleven_tokens_rejected() {
let line = "A B C D E F G H I J 9";
let tokens: Vec<&str> = line.split_whitespace().collect();
assert_eq!(tokens.len(), 11);
assert!(!looks_like_numbered_running_header(line));
}
#[test]
fn boundary_header_title_case() {
assert!(looks_like_boundary_header("Introduction"));
assert!(looks_like_boundary_header("Chapter One"));
assert!(looks_like_boundary_header("CONCLUSION"));
}
#[test]
fn boundary_header_rejects_digits() {
assert!(!looks_like_boundary_header("Chapter 3"));
}
#[test]
fn boundary_header_rejects_punctuation() {
assert!(!looks_like_boundary_header("Hello, World"));
assert!(!looks_like_boundary_header("Section:"));
}
#[test]
fn boundary_header_rejects_block_start() {
assert!(!looks_like_boundary_header("# Introduction"));
assert!(!looks_like_boundary_header("> Quote"));
assert!(!looks_like_boundary_header("- List item"));
}
#[test]
fn boundary_header_rejects_too_many_tokens() {
assert!(!looks_like_boundary_header(
"One Two Three Four Five Six Seven"
)); }
#[test]
fn boundary_header_rejects_lowercase_word() {
assert!(!looks_like_boundary_header("hello world"));
}
#[test]
fn boundary_header_too_short_or_long() {
assert!(!looks_like_boundary_header("AB")); let long = format!("{} End", "Abcdefghij".repeat(6)); assert!(!looks_like_boundary_header(&long));
}
#[test]
fn title_word_cases() {
assert!(is_title_or_upper_word("Hello"));
assert!(is_title_or_upper_word("HELLO"));
assert!(!is_title_or_upper_word("hello"));
assert!(!is_title_or_upper_word("hELLO"));
assert!(!is_title_or_upper_word("HeLLO"));
}
#[test]
fn title_word_with_surrounding_punctuation() {
assert!(is_title_or_upper_word("(Hello)"));
assert!(is_title_or_upper_word("\"WORLD\""));
}
#[test]
fn title_word_empty_or_no_alpha() {
assert!(!is_title_or_upper_word(""));
assert!(!is_title_or_upper_word("123"));
assert!(!is_title_or_upper_word("---"));
}
#[test]
fn starts_with_block_variants() {
assert!(starts_with_block("# Heading"));
assert!(starts_with_block("```rust"));
assert!(starts_with_block("<pre>code</pre>"));
assert!(starts_with_block("> quote"));
assert!(starts_with_block("- item"));
assert!(starts_with_block("* item"));
assert!(starts_with_block("+ item"));
assert!(starts_with_block("1. numbered"));
assert!(starts_with_block("12) numbered"));
assert!(!starts_with_block("regular text"));
}
#[test]
fn starts_with_block_leading_whitespace() {
assert!(starts_with_block(" # Heading"));
assert!(starts_with_block("\t- item"));
}
#[test]
fn ends_with_block_variants() {
assert!(ends_with_block("text</pre>"));
assert!(ends_with_block("text</table>"));
assert!(ends_with_block("text</blockquote>"));
assert!(ends_with_block("***"));
assert!(ends_with_block("---"));
assert!(!ends_with_block("regular text"));
}
#[test]
fn ends_with_block_trailing_whitespace() {
assert!(ends_with_block("text</pre> "));
assert!(ends_with_block("---\n"));
}
#[test]
fn numbered_list_cases() {
assert!(is_numbered_list("1. item"));
assert!(is_numbered_list("12. item"));
assert!(is_numbered_list("1) item"));
assert!(!is_numbered_list("a. item"));
assert!(!is_numbered_list("text"));
assert!(!is_numbered_list("5 text")); }
#[test]
fn hyphen_ascii_and_unicode() {
assert!(ends_with_hyphen("word-"));
assert!(ends_with_hyphen("word\u{2010}")); assert!(ends_with_hyphen("word\u{2011}")); assert!(!ends_with_hyphen("word"));
assert!(!ends_with_hyphen("word—")); }
#[test]
fn last_line_list_item_multiline() {
assert!(last_line_is_list_item("paragraph\n- item"));
assert!(last_line_is_list_item("paragraph\n* item"));
assert!(last_line_is_list_item("paragraph\n+ item"));
assert!(!last_line_is_list_item("paragraph\nnot a list"));
}
#[test]
fn last_line_list_item_single_line() {
assert!(last_line_is_list_item("- item"));
assert!(!last_line_is_list_item("no dash"));
}
#[test]
fn terminal_punct() {
assert!(is_terminal_punct('.'));
assert!(is_terminal_punct('!'));
assert!(is_terminal_punct('?'));
assert!(is_terminal_punct('…'));
assert!(!is_terminal_punct(','));
assert!(!is_terminal_punct(';'));
assert!(!is_terminal_punct('-'));
}
#[test]
fn last_significant_skips_quotes_and_brackets() {
assert_eq!(last_significant_char("word.\""), Some('.'));
assert_eq!(last_significant_char("word!)"), Some('!'));
assert_eq!(last_significant_char("end']"), Some('d'));
}
#[test]
fn last_significant_skips_smart_quotes() {
assert_eq!(last_significant_char("word.\u{201D}"), Some('.'));
assert_eq!(last_significant_char("word\u{2019}"), Some('d'));
}
#[test]
fn last_significant_all_skippable() {
assert_eq!(last_significant_char("\"')}]"), None);
assert_eq!(last_significant_char(" "), None);
assert_eq!(last_significant_char(""), None);
}
#[test]
fn last_significant_trailing_whitespace() {
assert_eq!(last_significant_char("word "), Some('d'));
assert_eq!(last_significant_char("end.\t\n"), Some('.'));
}
}