Files
catena/crates/bibclean/src/fix.rs
T
funkpower 05b712d084 0.2.2 — a subtitle begins with a capital
title_case counted small words from the start of the whole string, so
every subtitle that opened with one was set in lower case: 'Aesthetic in
Kant: a Critique', 'Born Fighting: the Scots-Irish'. A colon starts a
title of its own and the first word of one is capitalised however small
it is.

Found by book-pipeline, which had been proposing a title page's capitals
verbatim into a catalogue and needed this to stop.
2026-09-21 11:30:26 -04:00

265 lines
9.0 KiB
Rust

//! Repairs that can be made without looking anything up.
use std::sync::LazyLock;
use regex::Regex;
use textkit::pattern::SPACE;
/// A format name stuck on the end of a title taken from a filename.
static FORMAT_SUFFIX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"(?i)[_\-{SPACE}]*(txt|pdf|epub|mobi|azw3?|htmlz?|djvu){SPACE}*$"
))
.expect("a valid pattern")
});
/// An underscore standing in for the colon before a subtitle.
static SUBTITLE_UNDERSCORE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(&format!(r"_{SPACE}+")).expect("a valid pattern"));
/// Two or more spaces where one belongs.
static SPACE_RUN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(&format!(r"{SPACE}{{2,}}")).expect("a valid pattern"));
/// A run of letters, by Python's definition of a letter.
static LETTERS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\p{L}+").expect("a valid pattern"));
/// Words that stay lowercase inside a title.
const SMALL_WORDS: &[&str] = &[
"a", "an", "and", "as", "at", "but", "by", "for", "from", "in", "into", "nor", "of", "on",
"or", "over", "per", "the", "to", "upon", "via", "with",
];
/// Whether a title is in capitals throughout.
///
/// A space is required, so a title that is only an acronym is left alone.
pub fn is_shouting(title: &str) -> bool {
let title = title.trim_matches(textkit::is_space);
!title.is_empty()
&& title == title.to_uppercase()
&& title.contains(' ')
&& title
.chars()
.collect::<Vec<_>>()
.windows(2)
.any(|pair| pair[0].is_ascii_uppercase() && pair[1].is_ascii_uppercase())
}
/// Title-case a shouting title without mangling it.
///
/// The obvious library function is unusable here: it gives "Don'T" and
/// capitalises every preposition. Small words stay lowercase unless they open
/// or close the title, hyphenated parts are cased on both sides, and the
/// first *letter* is capitalised rather than the first character — an
/// ASCII-only rule skips an accented opening letter and capitalises the one
/// after it, turning "ÊTRE" into "êTre", and a third of a real library is not
/// in English.
pub fn title_case(title: &str) -> String {
let words: Vec<&str> = title.split_whitespace().collect();
let last = words.len().saturating_sub(1);
words
.iter()
.enumerate()
.map(|(at, word)| {
let core: String = word
.trim_start_matches(['(', '[', '"', '\''])
.to_lowercase()
.trim_end_matches(['.', ',', ':', ';', '!', '?', ')', ']', '"', '\''])
.to_string();
// A small word stays small in the middle of a title, but not
// where a title actually starts. A colon starts a subtitle, and
// "Aesthetic in Kant: a Critique" is the giveaway that the rule
// was only ever counting from the beginning of the whole string.
let opens_a_subtitle = at > 0
&& words[at - 1]
.trim_end_matches(['"', '\'', ')', ']'])
.ends_with([':', ';', '?', '!', '.']);
if SMALL_WORDS.binary_search(&core.as_str()).is_ok()
&& at > 0
&& at < last
&& !opens_a_subtitle
{
return word.to_lowercase();
}
let lowered = word.to_lowercase();
if lowered.contains('-') {
lowered
.split('-')
.map(capitalise)
.collect::<Vec<_>>()
.join("-")
} else {
capitalise_first_letters(&lowered)
}
})
.collect::<Vec<_>>()
.join(" ")
}
/// Undo the underscore mangling a filename-derived title carries.
///
/// Three habits, in order: a format name on the end, an underscore standing
/// in for the colon before a subtitle, and underscores used as plain spaces.
pub fn clean_underscores(title: &str) -> String {
let title = FORMAT_SUFFIX.replace(title, "");
let title = title.trim_matches(textkit::is_space);
let title = SUBTITLE_UNDERSCORE.replace_all(title, ": ");
let title = title.replace('_', " ");
SPACE_RUN
.replace_all(&title, " ")
.trim_matches(|c: char| textkit::is_space(c) || c == ':' || c == ',' || c == '-')
.to_string()
}
/// Capitalise the first letter and lowercase the rest.
fn capitalise(word: &str) -> String {
let lowered = word.to_lowercase();
capitalise_first_letters(&lowered)
}
/// Capitalise the first run of letters, leaving everything before it alone.
fn capitalise_first_letters(lowered: &str) -> String {
match LETTERS.find(lowered) {
None => lowered.to_string(),
Some(found) => {
let mut out = String::with_capacity(lowered.len());
out.push_str(&lowered[..found.start()]);
let mut letters = found.as_str().chars();
if let Some(first) = letters.next() {
out.extend(first.to_uppercase());
out.push_str(letters.as_str());
}
out.push_str(&lowered[found.end()..]);
out
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_small_word_list_is_sorted_so_it_can_be_searched() {
assert!(SMALL_WORDS.windows(2).all(|pair| pair[0] < pair[1]));
}
#[test]
fn shouting_needs_more_than_one_word() {
assert!(is_shouting("THE HUMAN CONDITION"));
assert!(!is_shouting("MIT"));
assert!(!is_shouting("The Human Condition"));
assert!(!is_shouting(""));
assert!(!is_shouting("A B"));
}
#[test]
fn a_shouting_title_comes_back_readable() {
assert_eq!(title_case("THE HUMAN CONDITION"), "The Human Condition");
assert_eq!(
title_case("A HISTORY OF THE WORLD IN TEN CHAPTERS"),
"A History of the World in Ten Chapters"
);
}
#[test]
fn a_small_word_at_either_end_is_capitalised() {
assert_eq!(title_case("OF MICE AND MEN"), "Of Mice and Men");
assert_eq!(title_case("WHAT IS IT ALL FOR"), "What Is It All For");
}
#[test]
fn an_apostrophe_does_not_start_a_new_word() {
assert_eq!(title_case("DON'T LOOK NOW"), "Don't Look Now");
}
#[test]
fn a_hyphenated_word_is_cased_on_both_sides() {
assert_eq!(title_case("THE WELL-BUILT WHEEL"), "The Well-Built Wheel");
}
#[test]
fn an_accented_first_letter_is_the_one_capitalised() {
// The small-word list is English, so a French preposition is cased
// like any other word. That is what the original does, and a list per
// language is a different tool.
assert_eq!(title_case("ÊTRE ET TEMPS"), "Être Et Temps");
assert_eq!(
title_case("ÉTUDES SUR LE MOYEN ÂGE"),
"Études Sur Le Moyen Âge"
);
}
#[test]
fn a_bracketed_word_keeps_its_bracket() {
assert_eq!(title_case("THE (SECOND) ESTATE"), "The (Second) Estate");
}
#[test]
fn a_format_suffix_comes_off() {
assert_eq!(clean_underscores("The Fall_txt"), "The Fall");
assert_eq!(clean_underscores("The Fall - pdf"), "The Fall");
// A format name takes no separator to be one, so an extension goes
// too and leaves its dot behind. Faithful to the original; a title
// that is really a filename is being repaired either way.
assert_eq!(clean_underscores("The Fall.epub"), "The Fall.");
}
#[test]
fn an_underscore_before_a_subtitle_becomes_a_colon() {
assert_eq!(
clean_underscores("The 9/11 Mystery Plane_ And the Vanishing of America"),
"The 9/11 Mystery Plane: And the Vanishing of America"
);
}
#[test]
fn underscores_used_as_spaces_become_spaces() {
assert_eq!(
clean_underscores("Board_Advisors_etc"),
"Board Advisors etc"
);
}
#[test]
fn a_title_with_nothing_wrong_with_it_is_unchanged() {
assert_eq!(
clean_underscores("The Human Condition"),
"The Human Condition"
);
}
}
#[cfg(test)]
mod subtitle_case_tests {
use super::title_case;
#[test]
fn a_subtitle_starts_with_a_capital() {
// The rule counted small words from the start of the string, so every
// subtitle beginning "A", "The" or "Of" was set lower case.
assert_eq!(
title_case("AESTHETIC IN KANT: A CRITIQUE"),
"Aesthetic in Kant: A Critique"
);
assert_eq!(
title_case("BORN FIGHTING: THE SCOTS-IRISH"),
"Born Fighting: The Scots-Irish"
);
assert_eq!(
title_case("A HISTORY: AN INTRODUCTION TO IT"),
"A History: An Introduction to It"
);
}
#[test]
fn a_small_word_in_the_middle_is_still_small() {
assert_eq!(
title_case("THE RISE AND FALL OF THE THIRD REICH"),
"The Rise and Fall of the Third Reich"
);
assert_eq!(title_case("WAR OF THE WORLDS"), "War of the Worlds");
}
}