Skip to content
111 changes: 111 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6259,6 +6259,13 @@ mod tests {
}
}

fn test_text_item_with_font(page: u32, font: &str, text: &str) -> TextItem {
TextItem {
font: font.to_string(),
..test_text_item_on_page(page, text)
}
}

#[test]
fn removed_sparse_folios_leave_no_layout_evidence() {
let items = vec![
Expand Down Expand Up @@ -6581,6 +6588,110 @@ mod tests {
assert_eq!(quality.pages_needing_ocr, vec![1]);
}

#[test]
fn test_text_quality_flags_garbled_font_on_mixed_page() {
let healthy = CAESAR_PROSE.repeat(3);
let items = vec![
test_text_item_with_font(1, "Helvetica", &healthy),
test_text_item_with_font(1, "FixtureFont", SHIFTED_CIPHER_TEXT),
];

let quality = analyze_text_quality(&items);

assert_eq!(quality.pages_needing_ocr, vec![1]);
assert_eq!(
quality.reasons_by_page.get(&1).cloned(),
Some(vec![OCR_REASON_SUSPECTED_GARBLED_TEXT.to_string()])
);
}

#[test]
fn test_text_quality_allows_clean_multi_font_page() {
let items = vec![
test_text_item_with_font(1, "Helvetica", CAESAR_PROSE),
test_text_item_with_font(1, "TimesRoman", &CAESAR_PROSE.repeat(2)),
];

let quality = analyze_text_quality(&items);

assert!(!quality.has_encoding_issues);
assert!(quality.pages_needing_ocr.is_empty());
}

#[test]
fn test_text_quality_allows_structured_ascii_in_dedicated_fonts() {
let healthy = CAESAR_PROSE.repeat(3);
let mixed_case_code = "myXMLParser ".repeat(20);
let uppercase_code = "MYXMLPARSER ".repeat(20);
let items = vec![
test_text_item_with_font(1, "Helvetica", &healthy),
test_text_item_with_font(1, "CodeFont", &mixed_case_code),
test_text_item_with_font(2, "Helvetica", &healthy),
test_text_item_with_font(2, "AcronymFont", &uppercase_code),
];

let quality = analyze_text_quality(&items);

assert!(!quality.has_encoding_issues);
assert!(quality.pages_needing_ocr.is_empty());
}

#[test]
fn test_text_quality_allows_base64_in_dedicated_font() {
// Base64 for the bytes 0..=255. Its broad alphabet and frequent
// lowercase-to-uppercase transitions satisfy the cipher thresholds,
// but the uninterrupted structured token is not prose.
let base64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==";
let healthy = CAESAR_PROSE.repeat(3);
let items = vec![
test_text_item_with_font(1, "Helvetica", &healthy),
test_text_item_with_font(1, "DataFont", base64),
];

let quality = analyze_text_quality(&items);

assert!(!quality.has_encoding_issues);
assert!(quality.pages_needing_ocr.is_empty());
}

#[test]
fn test_text_quality_flags_uniform_case_garbled_fonts_on_mixed_pages() {
let healthy = CAESAR_PROSE.repeat(3);
let lowercase_garble = caesar_shift(&CAESAR_PROSE.to_lowercase(), 5);
let uppercase_garble = caesar_shift(&CAESAR_PROSE.to_uppercase(), 7);
let items = vec![
test_text_item_with_font(1, "Helvetica", &healthy),
test_text_item_with_font(1, "BrokenLowerFont", &lowercase_garble),
test_text_item_with_font(2, "Helvetica", &healthy),
test_text_item_with_font(2, "BrokenUpperFont", &uppercase_garble),
];

let quality = analyze_text_quality(&items);

assert_eq!(quality.pages_needing_ocr, vec![1, 2]);
}

#[test]
fn test_text_quality_cipher_stats_still_accumulate_across_fonts() {
let items: Vec<TextItem> = SHIFTED_CIPHER_TEXT
.split_whitespace()
.enumerate()
.map(|(index, chunk)| {
let font = match index % 4 {
0 => "FixtureFontA",
1 => "FixtureFontB",
2 => "FixtureFontC",
_ => "FixtureFontD",
};
test_text_item_with_font(1, font, chunk)
})
.collect();

let quality = analyze_text_quality(&items);

assert_eq!(quality.pages_needing_ocr, vec![1]);
}

#[test]
fn test_text_quality_flags_localized_cid_mojibake_span() {
let items = vec![
Expand Down
86 changes: 82 additions & 4 deletions src/text_quality.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
//! backstop on the region-extraction and whole-document paths.
//! - **Item/span-level** ([`analyze_text_quality`],
//! [`region_items_have_decoding_issue`]) run on individual `TextItem`s and
//! accumulate per-page evidence, so localized garbled spans on an otherwise
//! clean page are caught without a single span having to condemn the page.
//! accumulate page-wide and per-font evidence, so localized garbled spans on
//! an otherwise clean page are caught without a single span having to
//! condemn the page.
//!
//! Detection classes, roughly by signal:
//! - **Replacement runs**: U+FFFD clusters ([`has_replacement_text_run`]).
Expand Down Expand Up @@ -103,12 +104,28 @@ struct CipherGarbleStats {
/// lowercase straight to uppercase mid-word.
letter_bigrams: usize,
case_shift_bigrams: usize,
/// Characters in Base64-compatible tokens, and the subset belonging to
/// long uninterrupted tokens. The latter distinguishes structured blobs
/// from prose before applying page-calibrated heuristics to one font.
structured_token_chars: usize,
long_structured_token_chars: usize,
}

impl CipherGarbleStats {
const MIN_FONT_SAMPLE_LETTER_KINDS: usize = 15;
const MIN_LONG_STRUCTURED_TOKEN_CHARS: usize = 32;

fn add_text(&mut self, text: &str) {
let mut prev: Option<char> = None;
let mut structured_token_chars = 0usize;
Comment thread
AnnaSuSu marked this conversation as resolved.
Outdated
for ch in text.chars() {
if ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=') {
structured_token_chars += 1;
} else {
self.add_structured_token(structured_token_chars);
structured_token_chars = 0;
}

if ch.is_ascii_alphabetic() {
let idx = (ch.to_ascii_lowercase() as u8 - b'a') as usize;
self.letter_counts[idx] += 1;
Expand All @@ -134,6 +151,14 @@ impl CipherGarbleStats {
prev = None;
}
}
self.add_structured_token(structured_token_chars);
}

fn add_structured_token(&mut self, chars: usize) {
self.structured_token_chars += chars;
if chars >= Self::MIN_LONG_STRUCTURED_TOKEN_CHARS {
self.long_structured_token_chars += chars;
}
}

/// Cosine similarity between the observed letter histogram and English
Expand Down Expand Up @@ -223,6 +248,33 @@ impl CipherGarbleStats {

case_shifts || permuted_language
}

/// A per-font slice is more likely than a whole page to contain repeated
/// identifiers, acronyms, or other structured ASCII. A small alphabet can
/// accidentally resemble the sorted English frequency profile even when
/// the text is legitimate (for example, a dedicated code font containing
/// repeated `myXMLParser` labels). Require broad alphabet coverage before
/// applying the page-calibrated cipher heuristic to an isolated font.
/// Likewise, a Base64-style blob can cover the whole alphabet and flip
/// case frequently, but long uninterrupted structured tokens are not
/// prose and should not make a readable mixed-font page fall back to OCR.
///
/// This guard is intentionally font-only: page-wide detection keeps its
/// existing behavior, including all-lowercase and all-uppercase shifted
/// prose, while the reported mixed-font cipher sample covers well over
/// half of the ASCII alphabet.
fn font_sample_looks_garbled(&self) -> bool {
let letter_kinds = self
.letter_counts
.iter()
.filter(|&&count| count > 0)
.count();
let structured_data_dominates =
self.long_structured_token_chars.saturating_mul(2) >= self.structured_token_chars;
Comment thread
AnnaSuSu marked this conversation as resolved.
Outdated
letter_kinds >= Self::MIN_FONT_SAMPLE_LETTER_KINDS
&& !structured_data_dominates
&& self.looks_garbled()
}
}

#[derive(Debug, Default)]
Expand All @@ -239,6 +291,32 @@ struct PageTextQualityEvidence {
replacement_spans: usize,
longest_replacement_run: usize,
cipher_garble: CipherGarbleStats,
cipher_garble_by_font: BTreeMap<String, CipherGarbleStats>,
}

impl PageTextQualityEvidence {
fn add_cipher_text(&mut self, font: &str, text: &str) {
self.cipher_garble.add_text(text);
if font.is_empty() {
return;
}

if let Some(stats) = self.cipher_garble_by_font.get_mut(font) {
stats.add_text(text);
} else {
let mut stats = CipherGarbleStats::default();
stats.add_text(text);
self.cipher_garble_by_font.insert(font.to_string(), stats);
}
}

fn cipher_sample_looks_garbled(&self) -> bool {
self.cipher_garble.looks_garbled()
|| self
.cipher_garble_by_font
.values()
.any(CipherGarbleStats::font_sample_looks_garbled)
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand All @@ -258,7 +336,7 @@ pub(crate) fn analyze_text_quality(items: &[TextItem]) -> TextQualityReport {

let evidence = evidence_by_page.entry(item.page).or_default();
evidence.chars += item.text.chars().filter(|ch| !ch.is_whitespace()).count();
evidence.cipher_garble.add_text(&item.text);
evidence.add_cipher_text(&item.font, &item.text);

match text_span_decoding_issue_kind(&item.text) {
Some(TextSpanIssueKind::Strong) => {
Expand All @@ -282,7 +360,7 @@ pub(crate) fn analyze_text_quality(items: &[TextItem]) -> TextQualityReport {
if reasons_by_page.contains_key(&page) {
continue;
}
if page_replacement_evidence_needs_ocr(&evidence) || evidence.cipher_garble.looks_garbled()
if page_replacement_evidence_needs_ocr(&evidence) || evidence.cipher_sample_looks_garbled()
{
add_ocr_reason(
&mut reasons_by_page,
Expand Down