#![cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
use crate::core::config::OcrQualityThresholds;
use crate::types::PageBoundary;
const PROSE_WORD_MIN_ALPHA_CHARS: usize = 2;
const PROSE_WORD_MIN_ALPHA_RATIO: f64 = 0.75;
const PROSE_LINE_MIN_ALPHA_WORDS: usize = 5;
const PROSE_LINE_MAX_DIGIT_RATIO: f64 = 0.15;
const PROSE_LINE_MAX_SYMBOL_RATIO: f64 = 0.10;
const FORMULA_SYMBOLS: &[char] = &[
'=', '+', '*', '/', '^', '∑', '∫', '√', '±', '≤', '≥', '<', '>', '(', ')', '[', ']', '{', '}', '|', '\\', '%', '∂',
'∞', 'π', 'θ', 'α', 'β', 'δ', 'λ', 'μ', 'σ',
];
const PLAUSIBILITY_MIN_TAIL_CHUNK_CHARS: usize = 100;
const PLAUSIBILITY_MIN_PROSE_CHUNKS: usize = 3;
pub(super) const PLAUSIBILITY_MAX_MEAN_CONFIDENCE: f64 = 0.35;
pub(super) const MOJIBAKE_LATIN1_SUPPLEMENT_ALPHA_RATIO: f64 = 0.90;
const LATIN1_SUPPLEMENT_ALPHA_START: char = '\u{C0}';
const LATIN1_SUPPLEMENT_ALPHA_END: char = '\u{FF}';
pub(super) fn latin1_supplement_alpha_ratio(text: &str) -> f64 {
let mut alpha = 0usize;
let mut in_range = 0usize;
for c in text.chars().filter(|c| c.is_alphabetic()) {
alpha += 1;
if (LATIN1_SUPPLEMENT_ALPHA_START..=LATIN1_SUPPLEMENT_ALPHA_END).contains(&c) {
in_range += 1;
}
}
if alpha == 0 {
0.0
} else {
in_range as f64 / alpha as f64
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PlausibilityVerdict {
NotEvaluated,
Plausible,
Implausible,
}
pub(super) fn is_prose_word(word: &str) -> bool {
let total = word.chars().count();
if total == 0 {
return false;
}
let alpha = word.chars().filter(|c| c.is_alphabetic()).count();
alpha >= PROSE_WORD_MIN_ALPHA_CHARS && (alpha as f64 / total as f64) >= PROSE_WORD_MIN_ALPHA_RATIO
}
pub(super) fn is_prose_line(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return false;
}
let alpha_words = trimmed.split_whitespace().filter(|word| is_prose_word(word)).count();
if alpha_words < PROSE_LINE_MIN_ALPHA_WORDS {
return false;
}
let total_chars = trimmed.chars().count();
let digit_ratio = trimmed.chars().filter(char::is_ascii_digit).count() as f64 / total_chars as f64;
if digit_ratio > PROSE_LINE_MAX_DIGIT_RATIO {
return false;
}
let symbol_ratio = trimmed.chars().filter(|c| FORMULA_SYMBOLS.contains(c)).count() as f64 / total_chars as f64;
symbol_ratio <= PROSE_LINE_MAX_SYMBOL_RATIO
}
pub(super) fn select_prose_text(text: &str) -> String {
text.lines()
.filter(|line| is_prose_line(line))
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn prose_chunks(prose_text: &str) -> Vec<String> {
let chars: Vec<char> = prose_text.chars().collect();
let mut chunks: Vec<String> = chars
.chunks(crate::language_detection::CHUNK_SIZE)
.map(|chunk| chunk.iter().collect())
.collect();
if chunks
.last()
.is_some_and(|tail| tail.chars().count() < PLAUSIBILITY_MIN_TAIL_CHUNK_CHARS)
{
chunks.pop();
}
chunks
}
pub(super) fn evaluate_text_plausibility(text: &str, thresholds: &OcrQualityThresholds) -> PlausibilityVerdict {
let prose_text = select_prose_text(text);
let chunks = prose_chunks(&prose_text);
if chunks.len() < PLAUSIBILITY_MIN_PROSE_CHUNKS {
return PlausibilityVerdict::NotEvaluated;
}
let chunk_refs: Vec<&str> = chunks.iter().map(String::as_str).collect();
let reliability = crate::language_detection::chunk_reliability(&chunk_refs);
let low_confidence_implausible = reliability.reliable_ratio() < thresholds.min_reliable_language_chunk_ratio
&& reliability.mean_confidence() < PLAUSIBILITY_MAX_MEAN_CONFIDENCE;
let mojibake_implausible = latin1_supplement_alpha_ratio(&prose_text) >= MOJIBAKE_LATIN1_SUPPLEMENT_ALPHA_RATIO;
if low_confidence_implausible || mojibake_implausible {
PlausibilityVerdict::Implausible
} else {
PlausibilityVerdict::Plausible
}
}
pub(super) fn page_verdict(
native_text: &str,
boundary: &PageBoundary,
thresholds: &OcrQualityThresholds,
) -> Option<PlausibilityVerdict> {
if boundary.byte_start > boundary.byte_end
|| !native_text.is_char_boundary(boundary.byte_start)
|| !native_text.is_char_boundary(boundary.byte_end)
{
return None;
}
let page_text = &native_text[boundary.byte_start..boundary.byte_end];
if super::scoring::evaluate_native_text_for_ocr(page_text, Some(1), thresholds).fallback {
return None;
}
Some(evaluate_text_plausibility(page_text, thresholds))
}
pub(super) fn whole_document_verdict(
native_text: &str,
thresholds: &OcrQualityThresholds,
) -> Option<PlausibilityVerdict> {
if super::scoring::evaluate_native_text_for_ocr(native_text, Some(1), thresholds).fallback {
return None;
}
Some(evaluate_text_plausibility(native_text, thresholds))
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct PlausibilityScan {
pub implausible: Vec<u32>,
pub unjudged: Vec<u32>,
pub judged: u32,
}
pub(crate) fn scan_text_plausibility(
native_text: &str,
boundaries: Option<&[PageBoundary]>,
page_count: Option<u32>,
thresholds: &OcrQualityThresholds,
) -> PlausibilityScan {
let mut scan = PlausibilityScan::default();
if !thresholds.enable_plausibility_ocr_routing {
return scan;
}
match boundaries {
Some(bounds) if !bounds.is_empty() => {
for boundary in bounds {
match page_verdict(native_text, boundary, thresholds) {
Some(PlausibilityVerdict::Implausible) => {
scan.implausible.push(boundary.page_number);
scan.judged += 1;
}
Some(PlausibilityVerdict::Plausible) => scan.judged += 1,
Some(PlausibilityVerdict::NotEvaluated) => scan.unjudged.push(boundary.page_number),
None => {}
}
}
}
_ => {
let pages = page_count.unwrap_or(1).max(1);
match whole_document_verdict(native_text, thresholds) {
Some(PlausibilityVerdict::Implausible) => {
scan.implausible = (1..=pages).collect();
scan.judged = pages;
}
Some(PlausibilityVerdict::Plausible) => scan.judged = pages,
Some(PlausibilityVerdict::NotEvaluated) => scan.unjudged = (1..=pages).collect(),
None => {}
}
}
}
scan
}