Skip to content
163 changes: 163 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,162 @@ 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());
}

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_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_alphanumeric)
.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_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
90 changes: 86 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,25 @@ struct CipherGarbleStats {
/// lowercase straight to uppercase mid-word.
letter_bigrams: usize,
case_shift_bigrams: usize,
/// Aggregate character composition for recognizing Base64-like data
/// independently of how a PDF splits it across text-showing operators.
non_whitespace_chars: usize,
base64_chars: usize,
}

impl CipherGarbleStats {
const MIN_FONT_SAMPLE_LETTER_KINDS: usize = 15;

fn add_text(&mut self, text: &str) {
let mut prev: Option<char> = None;
for ch in text.chars() {
if !ch.is_whitespace() {
self.non_whitespace_chars += 1;
if ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '-' | '_') {
self.base64_chars += 1;
}
}

if ch.is_ascii_alphabetic() {
let idx = (ch.to_ascii_lowercase() as u8 - b'a') as usize;
self.letter_counts[idx] += 1;
Expand Down Expand Up @@ -136,6 +150,23 @@ impl CipherGarbleStats {
}
}

/// Base64 and Base64url use almost exclusively their 64-character
/// alphabet and, for real payloads with broad letter coverage, retain a
/// meaningful share of digits or symbols. Shifted prose remains nearly
/// all alphabetic. Both ratios aggregate across calls so artificial PDF
/// item boundaries cannot change the result.
fn looks_like_base64_data(&self) -> bool {
if self.non_whitespace_chars == 0 || self.base64_chars == 0 {
return false;
}

let base64_dominates =
self.base64_chars.saturating_mul(20) >= self.non_whitespace_chars.saturating_mul(19);
let non_letter_chars = self.base64_chars.saturating_sub(self.ascii_letters);
let has_base64_symbol_mix = non_letter_chars.saturating_mul(20) >= self.base64_chars;
Comment thread
AnnaSuSu marked this conversation as resolved.
Outdated
base64_dominates && has_base64_symbol_mix
}

/// 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 +254,31 @@ 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.
/// Base64 can also cover the whole alphabet and flip case frequently, so
/// exclude samples whose aggregate character composition identifies that
/// format, regardless of PDF item boundaries.
///
/// 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();
letter_kinds >= Self::MIN_FONT_SAMPLE_LETTER_KINDS
&& !self.looks_like_base64_data()
&& self.looks_garbled()
}
}

#[derive(Debug, Default)]
Expand All @@ -239,6 +295,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 +340,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 +364,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