//! Says whether a PDF's text layer is worth reading, or whether it has to be //! OCRed. //! //! OCR is the expensive step in any ingest: minutes of GPU time a page, and a //! result that is worse than a text layer the file already had. So the //! question "is this layer any good" is worth answering carefully, and the //! thresholds below are measurements rather than guesses — each one is the //! middle of a gap observed across three thousand real PDFs, with the reason //! recorded beside it. //! //! Everything here is a function over already-extracted text. Running //! `pdftotext` is the binary's job, so that the judgement can be tested //! against the numbers without shipping anybody's books. pub mod decode; pub mod reflect; pub mod score; use corpus_core::TextLayer; /// Nothing legitimate carries more than two per cent of its characters in the /// Latin-1 band, and the lowest genuine letter share measured was 0.39 — /// chess notation, recipe lists, a page index. Both gates sit clear of that /// by a wide margin, because the cost of a false positive is a needless /// re-OCR of a book that already reads. const DEAD_LETTER_SHARE: f64 = 0.25; const DEAD_LATIN1_SHARE: f64 = 0.35; /// Non-space characters a page below which a text layer is not really there. /// /// Chosen from the measured gap, not from a round number. The broken books /// sit at 3, 86, 94 and 132 characters a page — a scan whose layer holds only /// the page numbers and the running heads. The lowest-density legitimate /// books are small-format e-reader PDFs at 258 to 382. Two hundred sits in /// the middle of that gap with margin on both sides. const SPARSE_CHARS_PER_PAGE: f64 = 200.0; /// Function words that make up a large, stable share of any English prose. /// /// A damaged synthetic-font layer still emits letters, so counting letters /// proves nothing — but it cannot reproduce these. Healthy layers score 0.25 /// to 0.40; garbled ones score near zero. A tenth sits in the gap with room /// on both sides. const COMMON_WORDS: &[&str] = &[ "a", "all", "an", "and", "are", "as", "at", "be", "been", "but", "by", "for", "from", "had", "have", "his", "in", "is", "it", "more", "not", "of", "on", "one", "or", "that", "the", "there", "they", "this", "to", "was", "we", "were", "which", "with", "you", ]; const HEALTHY_WORD_RATIO: f64 = 0.10; /// Characters stripped from the ends of a word before it is looked up. const WORD_TRIM: &[char] = &['.', ',', ';', ':', '!', '?', '(', ')', '[', ']', '"', '\'']; /// How much text has to be there before a judgement means anything. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Limits { /// Non-space characters in the opening sample, below which the layer is /// treated as absent. pub min_chars_present: usize, /// Non-space characters in the middle sample, below which damage cannot /// be judged. pub min_chars_to_judge: usize, /// Words needed before the English prose check means anything. pub min_words: usize, /// Pages below which density says nothing — a handful of pages cannot be /// judged that way. pub min_pages_for_density: u32, } impl Default for Limits { fn default() -> Self { Self { min_chars_present: 200, min_chars_to_judge: 400, min_words: 80, min_pages_for_density: 8, } } } /// What was read off a file, and what it means. #[derive(Debug, Clone, PartialEq)] pub struct Triage { /// The verdict. pub layer: TextLayer, /// Whether the file has to be OCRed to become searchable. pub needs_ocr: bool, /// What the numbers were, in the order they were taken. pub notes: Vec, } /// The text a judgement is made from. #[derive(Debug, Clone, Copy)] pub struct Sample<'a> { /// Text extracted from the opening pages, which answers whether there is /// a layer at all. pub opening: &'a str, /// Text extracted from the middle of the document, which answers whether /// the layer is any good. The middle, because front matter is often a /// bare scan in a book whose body carries perfectly good text — and is /// often cleanly typeset boilerplate in a book whose body is damaged. pub middle: &'a str, /// How many pages the middle sample covers. pub middle_pages: u32, /// Pages in the whole document, when they could be counted. pub pages: Option, } /// Judge a text layer. /// /// The order is not arbitrary. A layer that is not there at all cannot be /// judged for damage; a layer that is only permuted is recoverable in full /// and must not be sent for OCR; damage is a stronger statement than /// thinness, so it is tested first. pub fn triage(sample: &Sample<'_>, limits: &Limits) -> Triage { let mut notes = Vec::new(); let opening_chars = score::body(sample.opening).len(); notes.push(format!("{opening_chars} characters on the opening pages")); if opening_chars < limits.min_chars_present { return Triage { layer: TextLayer::Absent, needs_ocr: true, notes, }; } let middle = score::body(sample.middle); let ascii = score::ascii_text_score(sample.middle); notes.push(format!( "{} characters over {} sample pages, ascii text score {ascii:.2}", middle.len(), sample.middle_pages )); // Damage can only be judged where there is enough text to judge it, and // that guard comes first — including before the recovery attempt, which // on a short sample would otherwise answer for the whole document. if middle.len() >= limits.min_chars_to_judge { if reflect::undo(sample.middle).is_some() { notes.push("reflecting the character codes recovers ordinary text".into()); return Triage { layer: TextLayer::ReflectedFont, needs_ocr: false, notes, }; } let letters = score::letter_share(&middle); let latin1 = score::latin1_share(&middle); notes.push(format!("letters {letters:.2}, latin-1 {latin1:.2}")); if letters < DEAD_LETTER_SHARE || latin1 > DEAD_LATIN1_SHARE { return Triage { layer: TextLayer::Garbled, needs_ocr: true, notes, }; } } if let Some(pages) = sample.pages && pages >= limits.min_pages_for_density { let density = middle.len() as f64 / sample.middle_pages.max(1) as f64; notes.push(format!("{density:.0} characters a page")); if density < SPARSE_CHARS_PER_PAGE { return Triage { layer: TextLayer::Sparse, needs_ocr: true, notes, }; } } if let Some(share) = english_prose_share(sample.middle, limits.min_words) { notes.push(format!("english function words {share:.2}")); if share < HEALTHY_WORD_RATIO { notes.push("not English, or not prose — judged on shape alone".into()); } } Triage { layer: TextLayer::Healthy, needs_ocr: false, notes, } } /// Share of words that are common English function words, or `None` when /// there is too little text to judge. /// /// English-only on purpose, and so never the whole verdict: it cannot judge /// the German, Greek and Church Slavonic books that sit beside the English /// ones. It is reported because when it does apply it is the most direct /// evidence there is that a layer reads as prose. pub fn english_prose_share(text: &str, min_words: usize) -> Option { let words: Vec = text .split(textkit::is_space) .map(|word| word.trim_matches(WORD_TRIM).to_lowercase()) .filter(|word| !word.is_empty()) .collect(); if words.len() < min_words { return None; } let common = words .iter() .filter(|word| COMMON_WORDS.binary_search(&word.as_str()).is_ok()) .count(); Some(common as f64 / words.len() as f64) } /// The pages to judge a layer by: the middle of the document. /// /// A fixed window near the front is only ever the middle of a pamphlet. On a /// real book it reads front matter, which is cleanly typeset boilerplate even /// when the body is damaged — a 424-page commentary scored healthy on pages /// 10 to 14 and garbled on both 160 to 164 and 300 to 304. pub fn middle_window(pages: Option, span: u32) -> (u32, u32) { let Some(pages) = pages.filter(|pages| *pages >= 1) else { // The old window, for a document whose length is unknown. return (10, 14); }; if pages <= span { return (1, pages); } let start = (pages / 2).saturating_sub(span / 2).max(1); (start, pages.min(start + span - 1)) } #[cfg(test)] mod tests { use super::*; fn prose() -> String { "The second estate held its land in exchange for service, and the service was \ military. That much is agreed; what follows is not. The court rolls do not say \ how often the obligation was tested, because the clerk who recorded it had no \ vocabulary for the difference between a rebellion and a bad harvest. This is the \ central difficulty of the sources, and everything that follows depends on reading \ around it rather than through it." .repeat(3) } fn sample<'a>(opening: &'a str, middle: &'a str, pages: Option) -> Sample<'a> { Sample { opening, middle, middle_pages: 5, pages, } } #[test] fn the_common_word_list_is_sorted_and_whole() { assert!(COMMON_WORDS.windows(2).all(|pair| pair[0] < pair[1])); assert_eq!(COMMON_WORDS.len(), 37, "a word has gone missing"); } #[test] fn a_short_sample_is_judged_on_density_rather_than_damage() { // Too little text to call it damaged, which is what the density gate // is for. Answering from the recovery attempt instead would route the // file away from the OCR it needs. let stored: String = "The mill stood at the northern end. " .repeat(4) .chars() .map(|c| { let reflected = 0x120 - c as u32; char::from_u32(reflected) .filter(|_| (0xa1..=0xff).contains(&reflected)) .unwrap_or(c) }) .collect(); let found = triage(&sample(&prose(), &stored, Some(400)), &Limits::default()); assert_eq!(found.layer, TextLayer::Sparse, "{:#?}", found.notes); assert!(found.needs_ocr); } #[test] fn a_book_that_reads_is_healthy() { let text = prose(); let found = triage(&sample(&text, &text, Some(400)), &Limits::default()); assert_eq!(found.layer, TextLayer::Healthy); assert!(!found.needs_ocr); } #[test] fn a_scan_with_no_layer_is_absent() { let found = triage(&sample(" \n 12 \n", "", Some(400)), &Limits::default()); assert_eq!(found.layer, TextLayer::Absent); assert!(found.needs_ocr); } #[test] fn a_layer_of_latin1_soup_is_garbled() { // Scrambled with some key other than the reflection, so that the // recovery pass declines it and the damage stands. let soup: String = (0..800) .map(|i: u32| char::from_u32(0xa1 + (i * 7) % 0x5f).expect("in the latin-1 band")) .collect(); let opening = prose(); let found = triage(&sample(&opening, &soup, Some(400)), &Limits::default()); assert_eq!(found.layer, TextLayer::Garbled, "{:#?}", found.notes); assert!(found.needs_ocr); } #[test] fn a_layer_holding_only_running_heads_is_sparse() { let thin = "The Second Estate 41\n".repeat(8); let opening = prose(); let found = triage(&sample(&opening, &thin, Some(400)), &Limits::default()); assert_eq!(found.layer, TextLayer::Sparse, "{:#?}", found.notes); assert!(found.needs_ocr); } #[test] fn a_short_document_is_not_judged_on_density() { let thin = "Page 3\n".repeat(8); let found = triage(&sample(&prose(), &thin, Some(4)), &Limits::default()); assert_ne!(found.layer, TextLayer::Sparse); } #[test] fn a_permuted_layer_is_recovered_rather_than_sent_for_ocr() { let stored: String = prose() .chars() .map(|c| { let reflected = 0x120 - c as u32; if (0xa1..=0xff).contains(&reflected) { char::from_u32(reflected).expect("in range") } else { c } }) .collect(); let found = triage(&sample(&stored, &stored, Some(400)), &Limits::default()); assert_eq!(found.layer, TextLayer::ReflectedFont, "{:#?}", found.notes); assert!(!found.needs_ocr, "a recoverable layer must not be OCRed"); } #[test] fn the_window_is_the_middle_of_the_book() { assert_eq!(middle_window(Some(400), 5), (198, 202)); assert_eq!(middle_window(Some(3), 5), (1, 3)); assert_eq!(middle_window(Some(5), 5), (1, 5)); assert_eq!(middle_window(None, 5), (10, 14)); assert_eq!(middle_window(Some(0), 5), (10, 14)); } #[test] fn english_prose_is_recognised_and_soup_is_not() { let share = english_prose_share(&prose(), 80).expect("enough words"); assert!(share > 0.25, "{share}"); let soup = "xqz ".repeat(200); assert!(english_prose_share(&soup, 80).expect("enough words") < 0.01); } }