Skip to content
217 changes: 217 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,216 @@ 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 garbled = caesar_shift(CAESAR_PROSE, 8);
let items = vec![
test_text_item_with_font(1, "Helvetica", &healthy),
test_text_item_with_font(1, "FixtureFont", &garbled),
];

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());
}

const BASE64_BINARY_SAMPLE: &str =
"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==";
const BASE64_PROSE_SAMPLE: &str =
"VGhlIHJlZ2lzdHJhbnQgaGVyZWJ5IGFncmVlcyB0byBmdXJuaXNoIGEgY29weSBvZiBhbnkgc3VjaCBpbnN0cnVtZW50IHRvIHRoZSBDb21taXNzaW9uIHVwb24gcmVxdWVzdC4gVGhpcyBjZXJ0aWZpY2F0ZSBvZiBkZXNpZ25hdGlvbnMgd2FzIGZpbGVkIEZlYnJ1YXJ5IHdpdGggcmVzcGVjdCB0byBTZXJpZXMgUHJlZmVycmVkIFN0b2NrIGFuZCBpbmNvcnBvcmF0ZWQgaGVyZWluIGJ5IHJlZmVyZW5jZSB0byB0aGUgYW5udWFsIHJlcG9ydCBvbiBmb3JtIGZvciB0aGUgcGVyaW9kIGVuZGVkIERlY2VtYmVyIGFzIGFtZW5kZWQgYW5kIHJlc3RhdGVkIHRoZXJlYWZ0ZXIu";

#[test]
fn test_text_quality_allows_base64_in_dedicated_font() {
let healthy = CAESAR_PROSE.repeat(3);
let mut items = Vec::new();
for (index, base64) in [BASE64_BINARY_SAMPLE, BASE64_PROSE_SAMPLE]
.into_iter()
.enumerate()
{
let page = index as u32 + 1;
items.push(test_text_item_with_font(page, "Helvetica", &healthy));
items.push(test_text_item_with_font(page, "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_allows_chunked_base64_in_dedicated_font() {
// PDF content streams may split one Base64 token into many Tj/TJ
// fragments. Classification must not depend on those item boundaries.
let healthy = CAESAR_PROSE.repeat(3);
let mut items = Vec::new();
for (index, base64) in [BASE64_BINARY_SAMPLE, BASE64_PROSE_SAMPLE]
.into_iter()
.enumerate()
{
let page = index as u32 + 1;
items.push(test_text_item_with_font(page, "Helvetica", &healthy));
items.extend(base64.as_bytes().chunks(16).map(|chunk| {
test_text_item_with_font(
page,
"DataFont",
std::str::from_utf8(chunk).expect("Base64 is ASCII"),
)
}));
}

let quality = analyze_text_quality(&items);

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

#[test]
fn test_text_quality_allows_line_wrapped_base64_in_dedicated_font() {
// MIME-style Base64 wraps at 76 characters. Those long encoded lines
// are not the explicitly delimited short words required for a
// digit/symbol-heavy font sample to be classified as prose.
let wrapped = BASE64_PROSE_SAMPLE
.as_bytes()
.chunks(76)
.map(|chunk| std::str::from_utf8(chunk).expect("Base64 is ASCII"))
.collect::<Vec<_>>()
.join("\n");
let healthy = CAESAR_PROSE.repeat(3);
let items = vec![
test_text_item_with_font(1, "Helvetica", &healthy),
test_text_item_with_font(1, "DataFont", &wrapped),
];

let quality = analyze_text_quality(&items);

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

#[test]
fn test_text_quality_flags_spaceless_garbled_font_on_mixed_page() {
// Removing separators must not make genuine shifted prose look like a
// structured token and bypass the per-font detector.
let healthy = CAESAR_PROSE.repeat(3);
let spaceless_garble: String = SHIFTED_CIPHER_TEXT
.chars()
.filter(char::is_ascii_alphabetic)
.collect();
let items = vec![
test_text_item_with_font(1, "Helvetica", &healthy),
test_text_item_with_font(1, "BrokenFont", &spaceless_garble),
];

let quality = analyze_text_quality(&items);

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

#[test]
fn test_text_quality_flags_digit_heavy_garbled_font_on_mixed_page() {
// Broken maps can also shift identifiers and quantities. Keep the
// real word boundaries but remove punctuation and inject enough
// digits to satisfy the former broad Base64 exemption.
let mut digit_heavy_garble = String::new();
let mut letters = 0usize;
for ch in caesar_shift(CAESAR_PROSE, 8).chars() {
if ch.is_ascii_alphanumeric() || ch.is_whitespace() {
digit_heavy_garble.push(ch);
}
if ch.is_ascii_alphabetic() {
letters += 1;
if letters.is_multiple_of(17) {
digit_heavy_garble.push('7');
}
}
}

let healthy = CAESAR_PROSE.repeat(3);
let items = vec![
test_text_item_with_font(1, "Helvetica", &healthy),
test_text_item_with_font(1, "BrokenIdFont", &digit_heavy_garble),
];

let quality = analyze_text_quality(&items);

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

#[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
100 changes: 96 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,13 +104,36 @@ struct CipherGarbleStats {
/// lowercase straight to uppercase mid-word.
letter_bigrams: usize,
case_shift_bigrams: usize,
/// Aggregate character composition and explicitly delimited short words.
/// Item boundaries do not count as delimiters because a PDF may split one
/// structured token across arbitrary text-showing operators.
non_whitespace_chars: usize,
explicit_word_tokens: usize,
explicit_word_letters: usize,
}

impl CipherGarbleStats {
const MIN_FONT_SAMPLE_LETTER_KINDS: usize = 15;
const MIN_FONT_SAMPLE_WORD_TOKENS: usize = 8;
const MIN_FONT_SAMPLE_WORD_LETTERS: usize = 100;
const MAX_WORD_TOKEN_CHARS: usize = 32;

fn add_text(&mut self, text: &str) {
let mut prev: Option<char> = None;
let mut token_chars = 0usize;
let mut token_letters = 0usize;
for ch in text.chars() {
if ch.is_whitespace() {
self.add_explicit_word(token_chars, token_letters);
Comment thread
AnnaSuSu marked this conversation as resolved.
token_chars = 0;
token_letters = 0;
} else {
self.non_whitespace_chars += 1;
token_chars += 1;
}

if ch.is_ascii_alphabetic() {
token_letters += 1;
let idx = (ch.to_ascii_lowercase() as u8 - b'a') as usize;
self.letter_counts[idx] += 1;
self.ascii_letters += 1;
Expand All @@ -136,6 +160,18 @@ impl CipherGarbleStats {
}
}

fn add_explicit_word(&mut self, chars: usize, letters: usize) {
if (2..=Self::MAX_WORD_TOKEN_CHARS).contains(&chars) && letters * 2 >= chars {
self.explicit_word_tokens += 1;
self.explicit_word_letters += letters;
}
}

fn has_explicit_prose_structure(&self) -> bool {
self.explicit_word_tokens >= Self::MIN_FONT_SAMPLE_WORD_TOKENS
&& self.explicit_word_letters >= Self::MIN_FONT_SAMPLE_WORD_LETTERS
}

/// Cosine similarity between the observed letter histogram and English
/// letter frequencies. A shifted alphabet permutes the histogram, which
/// destroys the similarity regardless of the shift amount.
Expand Down Expand Up @@ -223,6 +259,36 @@ 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.
/// Structured blobs such as Base64 can also cover the whole alphabet and
/// flip case frequently. For a sample with a meaningful digit/symbol
/// share, require multiple short words separated by whitespace observed in
/// the extracted text itself. This keeps digit-heavy shifted tables in
/// scope without pretending arbitrary alphanumeric blobs can be
/// distinguished from Base64. Letter-only samples retain the original
/// cipher behavior, including uninterrupted shifted prose.
///
/// 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 letter_only = self.non_whitespace_chars == self.ascii_letters;
letter_kinds >= Self::MIN_FONT_SAMPLE_LETTER_KINDS
&& (letter_only || self.has_explicit_prose_structure())
Comment thread
AnnaSuSu marked this conversation as resolved.
&& self.looks_garbled()
}
}

#[derive(Debug, Default)]
Expand All @@ -239,6 +305,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 +350,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 +374,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