Files
catena/crates/pdftriage/src/score.rs
T

171 lines
6.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Cheap, script-blind measurements of whether text is text.
use std::sync::LazyLock;
use regex::Regex;
use unicode_general_category::{GeneralCategory, get_general_category};
/// Characters that ordinary Latin-script text is made of.
///
/// Prose in any Latin-script language sits near 1.0 on the share of its
/// non-space characters that are in here; a text layer stored through a
/// reflected font sits near 0.0. The em dash is in the set despite not being
/// ASCII, because it is ordinary in a book and its absence would penalise
/// well-typeset text.
const ASCII_TEXT: &str = concat!(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
".,;:!?'\"()[]{}-\u{2014}/&%$#@*+=<>|~^_`\\"
);
/// Words of three letters or more, which is enough to have a shape.
static WORDS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[A-Za-z]{3,}").expect("valid"));
static VOWEL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[aeiouyAEIOUY]").expect("valid"));
/// Whether a character is a letter, by Python's definition.
///
/// Python's `str.isalpha` is true for the letter categories and nothing else.
/// Rust's [`char::is_alphabetic`] is true for the Unicode `Alphabetic`
/// property, which also covers some seventeen hundred combining marks —
/// including `U+0363`–`U+036F`, the medieval superscript letters that appear
/// in exactly the scanned manuscripts this has to judge. The thresholds below
/// were measured against the narrower definition.
pub fn is_letter(c: char) -> bool {
matches!(
get_general_category(c),
GeneralCategory::UppercaseLetter
| GeneralCategory::LowercaseLetter
| GeneralCategory::TitlecaseLetter
| GeneralCategory::ModifierLetter
| GeneralCategory::OtherLetter
)
}
/// The non-space characters of a text, which every share below is taken over.
pub fn body(text: &str) -> Vec<char> {
text.chars().filter(|c| !textkit::is_space(*c)).collect()
}
/// Share of non-space characters that are ordinary Latin-script text.
pub fn ascii_text_score(text: &str) -> f64 {
let body = body(text);
if body.is_empty() {
return 0.0;
}
let ordinary = body.iter().filter(|c| ASCII_TEXT.contains(**c)).count();
ordinary as f64 / body.len() as f64
}
/// Share of the non-space characters that are letters, in any alphabet.
pub fn letter_share(body: &[char]) -> f64 {
if body.is_empty() {
return 0.0;
}
body.iter().filter(|c| is_letter(**c)).count() as f64 / body.len() as f64
}
/// Share of the non-space characters sitting in the Latin-1 band, which is
/// where a broken character map falls back to.
pub fn latin1_share(body: &[char]) -> f64 {
if body.is_empty() {
return 0.0;
}
let banded = body
.iter()
.filter(|c| (0xa1..=0xff).contains(&(**c as u32)))
.count();
banded as f64 / body.len() as f64
}
/// The share of words carrying a vowel, and the share in a sane case
/// pattern. `None` when there are too few words to judge.
///
/// A cheap, script-blind proxy for "is this a language", used to tell a real
/// decode from one done with the wrong key.
pub fn word_shape(text: &str, min_words: usize) -> Option<(f64, f64)> {
let words: Vec<&str> = WORDS.find_iter(text).map(|found| found.as_str()).collect();
if words.len() < min_words {
return None;
}
let vowelled = words.iter().filter(|word| VOWEL.is_match(word)).count();
let tidy = words.iter().filter(|word| tidy_case(word)).count();
Some((
vowelled as f64 / words.len() as f64,
tidy as f64 / words.len() as f64,
))
}
/// Whether a word is all lower case, Capitalised, or ALL CAPS — which real
/// words are, and `zgW$c_` is not.
fn tidy_case(word: &str) -> bool {
let mut chars = word.chars();
let Some(first) = chars.next() else {
return false;
};
let rest = chars.as_str();
let all_lower = word.chars().all(|c| c.is_ascii_lowercase());
let all_upper = word.chars().all(|c| c.is_ascii_uppercase());
let title = first.is_ascii_uppercase() && rest.chars().all(|c| c.is_ascii_lowercase());
all_lower || all_upper || title
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prose_scores_near_one() {
let score = ascii_text_score("The second estate held its land in exchange for service.");
assert!(score > 0.95, "{score}");
}
#[test]
fn a_latin1_soup_scores_near_zero() {
let score = ascii_text_score("\u{d7}\u{b2}\u{ac}\u{ae}\u{b1}\u{bc}\u{ab}\u{bd}");
assert_eq!(score, 0.0);
}
#[test]
fn empty_text_scores_zero_rather_than_dividing_by_nothing() {
assert_eq!(ascii_text_score(""), 0.0);
assert_eq!(ascii_text_score(" \n "), 0.0);
assert_eq!(letter_share(&[]), 0.0);
assert_eq!(latin1_share(&[]), 0.0);
}
#[test]
fn a_medieval_combining_letter_is_not_a_letter_here() {
// Python's isalpha says no; Rust's is_alphabetic says yes.
assert!(!is_letter('\u{363}'));
assert!('\u{363}'.is_alphabetic());
assert!(is_letter('a'));
assert!(is_letter('\u{3b1}'));
assert!(!is_letter('1'));
}
#[test]
fn word_shape_needs_enough_words_to_judge() {
assert_eq!(word_shape("one two three", 40), None);
}
#[test]
fn real_words_are_vowelled_and_tidily_cased() {
let text = "The second estate held its land in exchange for service and the service \
was military That much is agreed what follows is not The rolls do not say \
how often the obligation was tested and the clerk had no vocabulary for it "
.repeat(3);
let (vowelled, tidy) = word_shape(&text, 40).expect("enough words");
assert!(vowelled > 0.95, "{vowelled}");
assert!(tidy > 0.95, "{tidy}");
}
#[test]
fn the_case_test_is_what_it_says() {
assert!(tidy_case("lower"));
assert!(tidy_case("Title"));
assert!(tidy_case("UPPER"));
assert!(!tidy_case("mIxEd"));
assert!(!tidy_case("aBc"));
}
}