use regex::Regex;
use std::collections::HashMap;
use std::sync::LazyLock;
static ROMAN_1_99: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^(?:xc|xl|l?x{0,3})(?:ix|iv|v?i{0,3})$").unwrap());
static HYPHEN_WRAP: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\w)-\n(\w)").unwrap());
fn is_page_number(line: &str) -> bool {
let t = line.trim();
if t.is_empty() {
return false;
}
if t.len() <= 4 && t.chars().all(|c| c.is_ascii_digit()) {
return true;
}
ROMAN_1_99.is_match(t)
}
pub fn dehyphenate(text: &str) -> String {
HYPHEN_WRAP.replace_all(text, "$1$2").into_owned()
}
pub fn clean_paged_text(text: &str) -> String {
let pages: Vec<&str> = text.split('\u{c}').collect();
if pages.len() < 3 {
return dehyphenate(&text.replace('\u{c}', "\n"));
}
let mut edge_counts: HashMap<&str, usize> = HashMap::new();
for page in &pages {
let non_blank: Vec<&str> = page
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
if let (Some(first), Some(last)) = (non_blank.first(), non_blank.last()) {
*edge_counts.entry(*first).or_insert(0) += 1;
if first != last {
*edge_counts.entry(*last).or_insert(0) += 1;
}
}
}
let threshold = pages.len() / 2;
let boilerplate: Vec<&str> = edge_counts
.into_iter()
.filter(|(_, count)| *count > threshold)
.map(|(line, _)| line)
.collect();
let mut kept: Vec<&str> = Vec::new();
for page in &pages {
let lines: Vec<&str> = page.lines().collect();
let non_blank_idx: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| !l.trim().is_empty())
.map(|(i, _)| i)
.collect();
let first = non_blank_idx.first().copied();
let last = non_blank_idx.last().copied();
for (i, line) in lines.iter().enumerate() {
let s = line.trim();
if boilerplate.contains(&s) {
continue;
}
if (Some(i) == first || Some(i) == last) && is_page_number(s) {
continue;
}
kept.push(line);
}
}
dehyphenate(&kept.join("\n"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn joins_hyphenated_line_breaks() {
assert_eq!(dehyphenate("resid-\nual learning"), "residual learning");
}
#[test]
fn strips_running_header_and_page_numbers() {
let text = "The Book\nreal content one\n1\u{c}The Book\nreal content two\n2\u{c}\
The Book\nreal content three\n3\u{c}The Book\nreal content four\n4";
let out = clean_paged_text(text);
assert!(!out.contains("The Book"), "header survived:\n{out}");
assert!(out.contains("real content one"));
assert!(out.contains("real content four"));
assert!(!out.lines().any(|l| l.trim() == "1"));
}
#[test]
fn keeps_real_words_that_look_like_roman_numerals() {
for word in ["MIX", "CIVIL", "DIM", "MILD", "VIVID", "C", "M"] {
assert!(!is_page_number(word), "{word} was treated as a page number");
}
}
#[test]
fn recognizes_front_matter_numerals() {
for numeral in ["i", "iv", "xiv", "XC", "42", "7"] {
assert!(is_page_number(numeral), "{numeral} was not recognized");
}
}
#[test]
fn short_documents_keep_every_line() {
let text = "only page\ncontent here";
assert_eq!(clean_paged_text(text), "only page\ncontent here");
}
}