#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
use crate::core::config::OcrQualityThresholds;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) const MIN_AVG_NON_WHITESPACE_TO_TRUST: f64 = 150.0;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) const PUA_RANGE_START: u32 = 0xE000;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) const PUA_RANGE_END: u32 = 0xF8FF;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) const MIN_OCR_NATIVE_ALNUM_RETENTION_RATIO: f64 = 0.5;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn is_undecodable_char(ch: char) -> bool {
let code = ch as u32;
(PUA_RANGE_START..=PUA_RANGE_END).contains(&code) || ch == '\u{FFFD}' || (ch.is_control() && !ch.is_whitespace())
}
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Default)]
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub struct NativeTextStats {
pub non_whitespace: usize,
pub alnum: usize,
pub meaningful_words: usize,
pub alnum_ratio: f64,
pub garbage_char_count: usize,
pub fragmented_word_ratio: f64,
pub consecutive_repeat_ratio: f64,
pub avg_word_length: f64,
pub word_count: usize,
pub undecodable_ratio: f64,
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub struct OcrFallbackDecision {
pub stats: NativeTextStats,
pub avg_non_whitespace: f64,
pub avg_alnum: f64,
pub fallback: bool,
pub failing_pages: Vec<u32>,
pub whole_doc_failure: bool,
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum OcrGateOutcome {
SkipNonText,
SkipSubstantive,
RunFallback,
RunFallbackOnPages(Vec<u32>),
UseNative,
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn evaluate_ocr_skip_gate(
pre_rendered_doc_present: bool,
total_chars: usize,
alnum_ws_ratio: f64,
decision: &OcrFallbackDecision,
thresholds: &crate::core::config::OcrQualityThresholds,
) -> OcrGateOutcome {
let skip_for_non_text = pre_rendered_doc_present
&& total_chars >= thresholds.non_text_min_chars
&& alnum_ws_ratio < thresholds.alnum_ws_ratio_threshold
&& !decision.fallback
&& !decision.whole_doc_failure;
let has_substantive_doc = pre_rendered_doc_present
&& total_chars >= thresholds.substantive_min_chars
&& alnum_ws_ratio >= thresholds.alnum_ws_ratio_threshold;
if skip_for_non_text {
OcrGateOutcome::SkipNonText
} else if has_substantive_doc && !decision.fallback {
OcrGateOutcome::SkipSubstantive
} else if decision.fallback {
if decision.whole_doc_failure || decision.failing_pages.is_empty() {
OcrGateOutcome::RunFallback
} else {
OcrGateOutcome::RunFallbackOnPages(decision.failing_pages.clone())
}
} else {
OcrGateOutcome::UseNative
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
impl NativeTextStats {
pub(crate) fn compute(text: &str, thresholds: &OcrQualityThresholds) -> Self {
let mut non_whitespace = 0usize;
let mut alnum = 0usize;
let mut garbage_char_count = 0usize;
let mut undecodable_count = 0usize;
for ch in text.chars() {
if ch == '\u{FFFD}' {
garbage_char_count += 1;
}
if is_undecodable_char(ch) {
undecodable_count += 1;
}
if !ch.is_whitespace() {
non_whitespace += 1;
if ch.is_alphanumeric() {
alnum += 1;
}
}
}
let undecodable_ratio = if non_whitespace == 0 {
0.0
} else {
undecodable_count as f64 / non_whitespace as f64
};
let meaningful_words = text
.split_whitespace()
.filter(|word| {
word.chars()
.filter(|c| c.is_alphanumeric())
.take(thresholds.min_meaningful_word_len)
.count()
>= thresholds.min_meaningful_word_len
})
.count();
let alnum_ratio = if non_whitespace == 0 {
0.0
} else {
alnum as f64 / non_whitespace as f64
};
let words: Vec<&str> = text.split_whitespace().collect();
let scorable_words: Vec<&&str> = words.iter().filter(|w| w.chars().any(char::is_alphabetic)).collect();
let fragmented_word_ratio = if words.len() >= 10 && !scorable_words.is_empty() {
let short_count = scorable_words.iter().filter(|w| w.len() <= 2).count();
short_count as f64 / scorable_words.len() as f64
} else {
0.0
};
let consecutive_repeat_ratio = if words.len() >= thresholds.min_words_for_repeat_check {
let repeat_count = words.windows(2).filter(|pair| pair[0] == pair[1]).count();
repeat_count as f64 / (words.len() - 1) as f64
} else {
0.0
};
let avg_word_length = if words.is_empty() {
0.0
} else {
words.iter().map(|w| w.len()).sum::<usize>() as f64 / words.len() as f64
};
Self {
non_whitespace,
alnum,
meaningful_words,
alnum_ratio,
garbage_char_count,
fragmented_word_ratio,
consecutive_repeat_ratio,
avg_word_length,
word_count: words.len(),
undecodable_ratio,
}
}
#[cfg(all(test, feature = "ocr"))]
pub(crate) fn from(text: &str) -> Self {
Self::compute(text, &OcrQualityThresholds::default())
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn evaluate_native_text_for_ocr(
native_text: &str,
page_count: Option<u32>,
thresholds: &OcrQualityThresholds,
) -> OcrFallbackDecision {
evaluate_native_text_for_ocr_with_garbage_threshold(native_text, page_count, thresholds, true)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn evaluate_native_text_for_ocr_with_garbage_threshold(
native_text: &str,
page_count: Option<u32>,
thresholds: &OcrQualityThresholds,
apply_absolute_garbage_threshold: bool,
) -> OcrFallbackDecision {
let trimmed = native_text.trim();
if trimmed.is_empty() {
let empty_stats = NativeTextStats {
non_whitespace: 0,
alnum: 0,
meaningful_words: 0,
alnum_ratio: 0.0,
garbage_char_count: 0,
fragmented_word_ratio: 0.0,
consecutive_repeat_ratio: 0.0,
avg_word_length: 0.0,
word_count: 0,
undecodable_ratio: 0.0,
};
return OcrFallbackDecision {
stats: empty_stats,
avg_non_whitespace: 0.0,
avg_alnum: 0.0,
fallback: true,
failing_pages: Vec::new(),
whole_doc_failure: true,
};
}
let stats = NativeTextStats::compute(trimmed, thresholds);
let pages = page_count.unwrap_or(1).max(1);
let avg_non_whitespace = stats.non_whitespace as f64 / f64::from(pages);
let avg_alnum = stats.alnum as f64 / f64::from(pages);
let has_substantial_text = stats.non_whitespace >= thresholds.min_total_non_whitespace
&& avg_non_whitespace >= thresholds.min_non_whitespace_per_page
&& stats.meaningful_words >= thresholds.min_meaningful_words;
let has_substantial_content = avg_non_whitespace >= MIN_AVG_NON_WHITESPACE_TO_TRUST;
let has_undecodable_text_layer = stats.non_whitespace >= thresholds.min_total_non_whitespace
&& stats.undecodable_ratio >= thresholds.min_undecodable_ratio;
let has_excessive_garbage =
apply_absolute_garbage_threshold && stats.garbage_char_count >= thresholds.min_garbage_chars;
let definitive_failure = stats.non_whitespace == 0
|| stats.alnum == 0
|| has_excessive_garbage
|| stats.fragmented_word_ratio >= thresholds.critical_fragmented_word_ratio
|| has_undecodable_text_layer
|| (!has_substantial_content
&& (stats.fragmented_word_ratio >= thresholds.max_fragmented_word_ratio
&& stats.meaningful_words < thresholds.min_meaningful_words))
|| (!has_substantial_content
&& (stats.avg_word_length < thresholds.min_avg_word_length
&& stats.word_count >= thresholds.min_words_for_avg_length_check))
|| (!has_substantial_content && stats.consecutive_repeat_ratio >= thresholds.min_consecutive_repeat_ratio);
let fallback = if definitive_failure {
true
} else if has_substantial_text {
false
} else if (stats.alnum_ratio < thresholds.min_alnum_ratio && avg_alnum < thresholds.min_non_whitespace_per_page)
|| (stats.non_whitespace < thresholds.min_total_non_whitespace
&& avg_non_whitespace < thresholds.min_non_whitespace_per_page)
{
true
} else {
stats.meaningful_words == 0 && avg_non_whitespace < thresholds.min_non_whitespace_per_page
};
OcrFallbackDecision {
stats,
avg_non_whitespace,
avg_alnum,
fallback,
failing_pages: Vec::new(),
whole_doc_failure: fallback,
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn normalize_markdown_for_scoring(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
continue;
}
let compact: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
if !compact.is_empty() && compact.chars().all(|c| matches!(c, '|' | '-' | ':' | '+')) {
continue;
}
let mut content = trimmed.trim_start_matches('#').trim_start();
content = content.trim_start_matches('>').trim_start();
for bullet in ["- ", "* ", "+ "] {
if let Some(rest) = content.strip_prefix(bullet) {
content = rest;
break;
}
}
let digit_prefix_len = content.chars().take_while(char::is_ascii_digit).count();
if digit_prefix_len > 0
&& let Some(rest) = content[digit_prefix_len..]
.strip_prefix(". ")
.or_else(|| content[digit_prefix_len..].strip_prefix(") "))
{
content = rest;
}
for ch in content.chars() {
if matches!(ch, '|' | '`' | '*' | '_' | '~' | '#') {
out.push(' ');
} else {
out.push(ch);
}
}
out.push('\n');
}
out
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) const ENABLE_WIDENED_OCR_LIST_MARKER_REPAIR: bool = true;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn repair_ocr_list_markers(text: &str) -> std::borrow::Cow<'_, str> {
let lines: Vec<&str> = text.lines().collect();
let kinds: Vec<LineMarkerKind> = lines.iter().map(|line| classify_marker_line(line)).collect();
let mut repair_flags = vec![false; lines.len()];
let mut any_repair = false;
for (index, kind) in kinds.iter().enumerate() {
let repair = match kind {
LineMarkerKind::LegacyRepairableDigit | LineMarkerKind::LegacyRepairableL => true,
LineMarkerKind::DoubledOneMisread if ENABLE_WIDENED_OCR_LIST_MARKER_REPAIR => true,
LineMarkerKind::AmbiguousLetter(_) if ENABLE_WIDENED_OCR_LIST_MARKER_REPAIR => {
ambiguous_marker_is_numeric_context(&kinds, index)
}
_ => false,
};
if repair {
repair_flags[index] = true;
any_repair = true;
}
}
if !any_repair {
return std::borrow::Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len());
for (index, line) in lines.iter().enumerate() {
if index > 0 {
out.push('\n');
}
if repair_flags[index] {
out.push_str(&repaired_marker_line(line, &kinds[index]));
} else {
out.push_str(line);
}
}
if text.ends_with('\n') {
out.push('\n');
}
std::borrow::Cow::Owned(out)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum LineMarkerKind {
None,
LegacyRepairableDigit,
LegacyRepairableL,
DoubledOneMisread,
AmbiguousLetter(char),
Digit,
Letter,
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn classify_marker_line(line: &str) -> LineMarkerKind {
let Some((marker, rest)) = line.split_once(' ') else {
return LineMarkerKind::None;
};
if !rest.chars().next().is_some_and(char::is_uppercase) {
return LineMarkerKind::None;
}
if let Some(digits) = marker.strip_suffix(',') {
if !digits.is_empty() && digits.len() <= 2 && digits.bytes().all(|b| b.is_ascii_digit()) {
return LineMarkerKind::LegacyRepairableDigit;
}
return LineMarkerKind::None;
}
if marker == "l." {
return LineMarkerKind::LegacyRepairableL;
}
if marker == "lL." {
return LineMarkerKind::DoubledOneMisread;
}
let Some(body) = marker.strip_suffix('.') else {
return LineMarkerKind::None;
};
if !body.is_empty() && body.len() <= 2 && body.bytes().all(|b| b.is_ascii_digit()) {
return LineMarkerKind::Digit;
}
let mut chars = body.chars();
if let (Some(ch), None) = (chars.next(), chars.next())
&& ch.is_ascii_alphabetic()
{
return match confusable_digit_for_letter(ch) {
Some(_) => LineMarkerKind::AmbiguousLetter(ch),
None => LineMarkerKind::Letter,
};
}
LineMarkerKind::None
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn confusable_digit_for_letter(ch: char) -> Option<char> {
match ch {
'L' => Some('1'),
'G' | 'b' => Some('6'),
'S' => Some('5'),
'O' | 'D' => Some('0'),
'I' => Some('1'),
_ => None,
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn nearest_marker_is_digit(kind: &LineMarkerKind) -> Option<bool> {
match kind {
LineMarkerKind::Digit | LineMarkerKind::LegacyRepairableDigit | LineMarkerKind::LegacyRepairableL => Some(true),
LineMarkerKind::Letter => Some(false),
LineMarkerKind::AmbiguousLetter(_) | LineMarkerKind::DoubledOneMisread | LineMarkerKind::None => None,
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn ambiguous_marker_is_numeric_context(kinds: &[LineMarkerKind], index: usize) -> bool {
let before = kinds[..index].iter().rev().find_map(nearest_marker_is_digit);
let after = kinds[index + 1..].iter().find_map(nearest_marker_is_digit);
matches!(
(before, after),
(Some(true), Some(true)) | (Some(true), None) | (None, Some(true))
)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn repaired_marker_line(line: &str, kind: &LineMarkerKind) -> String {
let space_index = line.find(' ').unwrap_or(0);
let (marker, rest) = line.split_at(space_index);
let mut out = String::with_capacity(line.len());
match kind {
LineMarkerKind::LegacyRepairableDigit => out.push_str(&marker[..marker.len() - 1]),
LineMarkerKind::LegacyRepairableL | LineMarkerKind::DoubledOneMisread => out.push('1'),
LineMarkerKind::AmbiguousLetter(ch) => {
out.push(confusable_digit_for_letter(*ch).expect("classified as ambiguous only when confusable"));
}
LineMarkerKind::None | LineMarkerKind::Digit | LineMarkerKind::Letter => out.push_str(marker),
}
out.push('.');
out.push_str(rest);
out
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn confidence_gate_rejects(
semantics: crate::plugins::ConfidenceSemantics,
confidence: Option<f64>,
min_ocr_mean_confidence: f64,
) -> bool {
let crate::plugins::ConfidenceSemantics::Legibility { scale_max } = semantics else {
return false;
};
if scale_max <= 0.0 || min_ocr_mean_confidence <= 0.0 {
return false;
}
confidence.is_some_and(|c| c / scale_max < min_ocr_mean_confidence / 100.0)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[derive(Debug, Clone, Copy)]
pub(super) struct OcrRecognitionNoiseDecision {
pub(super) low_confidence: bool,
pub(super) fragmented_noise: bool,
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
impl OcrRecognitionNoiseDecision {
fn suspected(self) -> bool {
self.low_confidence || self.fragmented_noise
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn ocr_recognition_noise_decision(
content: &str,
thresholds: &OcrQualityThresholds,
semantics: crate::plugins::ConfidenceSemantics,
confidence: Option<f64>,
) -> OcrRecognitionNoiseDecision {
let low_confidence = confidence_gate_rejects(semantics, confidence, thresholds.min_ocr_mean_confidence);
let fragmented_noise = is_ocr_recognition_noise(content, thresholds);
OcrRecognitionNoiseDecision {
low_confidence,
fragmented_noise,
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn mean_text_conf_of(
metadata: &ahash::AHashMap<std::borrow::Cow<'_, str>, serde_json::Value>,
) -> Option<f64> {
let value = metadata.get("mean_text_conf")?;
let conf = value.as_f64().or_else(|| value.as_i64().map(|v| v as f64))?;
(conf >= 0.0).then_some(conf)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn word_count_of(metadata: &ahash::AHashMap<std::borrow::Cow<'_, str>, serde_json::Value>) -> Option<u32> {
let count = metadata.get("word_count")?.as_u64()?;
Some(u32::try_from(count).unwrap_or(u32::MAX))
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn page_ocr_confidence(
semantics: crate::plugins::ConfidenceSemantics,
raw_confidence: Option<f64>,
word_count: u32,
backend: &str,
) -> Option<crate::types::page::PageOcrConfidence> {
let score = match semantics {
crate::plugins::ConfidenceSemantics::Legibility { scale_max } if scale_max > 0.0 => {
raw_confidence.map(|raw| (raw / scale_max).clamp(0.0, 1.0))
}
_ => None,
};
Some(crate::types::page::PageOcrConfidence {
score,
word_count,
backend: backend.to_string(),
})
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn ocr_output_stats(text: &str, thresholds: &OcrQualityThresholds) -> NativeTextStats {
let normalized = normalize_markdown_for_scoring(text.trim());
let scoring_input = if normalized.trim().is_empty() {
text.trim()
} else {
normalized.as_str()
};
NativeTextStats::compute(scoring_input, thresholds)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn is_ocr_recognition_noise(text: &str, thresholds: &OcrQualityThresholds) -> bool {
let stats = ocr_output_stats(text, thresholds);
if stats.word_count < thresholds.min_words_for_ocr_output_check {
return false;
}
stats.fragmented_word_ratio >= thresholds.max_ocr_output_fragmented_word_ratio
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn is_dictionary_invalid_noise(
dict_invalid_word_ratio: Option<f64>,
thresholds: &OcrQualityThresholds,
) -> bool {
dict_invalid_word_ratio.is_some_and(|ratio| ratio > thresholds.max_ocr_output_dict_invalid_word_ratio)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[derive(Debug, Clone, Copy)]
pub(crate) struct OcrPageNoiseVerdict {
pub(crate) page_index: usize,
pub(crate) low_confidence: bool,
pub(crate) fragmented_noise: bool,
pub(crate) dictionary_noise: bool,
pub(crate) fragmented_word_ratio: f64,
pub(crate) word_count: usize,
pub(crate) mean_confidence: Option<f64>,
pub(crate) dict_invalid_word_ratio: Option<f64>,
pub(crate) discarded: bool,
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) struct OcrPageAcceptance {
pub(super) content: String,
pub(super) discarded: bool,
pub(super) verdict: Option<OcrPageNoiseVerdict>,
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn accept_or_reject_ocr_page(
page_index: usize,
content: String,
thresholds: &OcrQualityThresholds,
warnings: &mut Vec<crate::types::ProcessingWarning>,
dict_invalid_word_ratio: Option<f64>,
confidence_semantics: crate::plugins::ConfidenceSemantics,
confidence: Option<f64>,
) -> OcrPageAcceptance {
if content.trim().is_empty() {
return OcrPageAcceptance {
content,
discarded: false,
verdict: None,
};
}
let recognition_noise = ocr_recognition_noise_decision(&content, thresholds, confidence_semantics, confidence);
let dictionary_noise = is_dictionary_invalid_noise(dict_invalid_word_ratio, thresholds);
if !recognition_noise.suspected() && !dictionary_noise {
return OcrPageAcceptance {
content,
discarded: false,
verdict: None,
};
}
let discarded = thresholds.discard_suspected_ocr_noise;
let stats = ocr_output_stats(&content, thresholds);
tracing::warn!(
page = page_index + 1,
words = stats.word_count,
fragmented_word_ratio = stats.fragmented_word_ratio,
threshold = thresholds.max_ocr_output_fragmented_word_ratio,
dict_invalid_word_ratio = dict_invalid_word_ratio,
dict_invalid_word_ratio_threshold = thresholds.max_ocr_output_dict_invalid_word_ratio,
mean_text_confidence = confidence,
low_confidence = recognition_noise.low_confidence,
rejected_by_dictionary_signal = dictionary_noise,
discarded,
"OCR output triggered recognition-noise diagnostics"
);
let mut reasons: Vec<String> = Vec::new();
if recognition_noise.low_confidence {
let scale_max = match confidence_semantics {
crate::plugins::ConfidenceSemantics::Legibility { scale_max } => scale_max,
_ => 100.0,
};
reasons.push(format!(
"mean confidence {:.0}% of scale is below threshold {:.0}%",
(confidence.unwrap_or_default() / scale_max) * 100.0,
thresholds.min_ocr_mean_confidence
));
}
if recognition_noise.fragmented_noise {
reasons.push(format!(
"{:.0}% of {} words are 1-2 characters, threshold {:.0}%",
stats.fragmented_word_ratio * 100.0,
stats.word_count,
thresholds.max_ocr_output_fragmented_word_ratio * 100.0
));
}
if let Some(ratio) = dict_invalid_word_ratio
&& dictionary_noise
{
reasons.push(format!(
"{:.0}% of dictionary-checkable words are dictionary-invalid, threshold {:.0}%",
ratio * 100.0,
thresholds.max_ocr_output_dict_invalid_word_ratio * 100.0
));
}
warnings.push(crate::types::ProcessingWarning {
source: std::borrow::Cow::Borrowed("ocr"),
message: std::borrow::Cow::Owned(if discarded {
format!(
"Page {} produced suspected OCR recognition noise ({}); its text was discarded \
because discard_suspected_ocr_noise is enabled.",
page_index + 1,
reasons.join("; ")
)
} else {
format!(
"Page {} produced suspected OCR recognition noise ({}); its text was retained. \
Set discard_suspected_ocr_noise to true to discard suspected noise.",
page_index + 1,
reasons.join("; ")
)
}),
});
let verdict = Some(OcrPageNoiseVerdict {
page_index,
low_confidence: recognition_noise.low_confidence,
fragmented_noise: recognition_noise.fragmented_noise,
dictionary_noise,
fragmented_word_ratio: stats.fragmented_word_ratio,
word_count: stats.word_count,
mean_confidence: confidence,
dict_invalid_word_ratio,
discarded,
});
if discarded {
OcrPageAcceptance {
content: String::new(),
discarded: true,
verdict,
}
} else {
OcrPageAcceptance {
content,
discarded: false,
verdict,
}
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn scoring_input(trimmed: &str) -> std::borrow::Cow<'_, str> {
let normalized = normalize_markdown_for_scoring(trimmed);
if normalized.trim().is_empty() {
std::borrow::Cow::Borrowed(trimmed)
} else {
std::borrow::Cow::Owned(normalized)
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn compute_quality_score(text: &str, thresholds: &OcrQualityThresholds) -> f64 {
let trimmed = text.trim();
if trimmed.is_empty() {
return 0.0;
}
let input = scoring_input(trimmed);
let stats = NativeTextStats::compute(&input, thresholds);
let alnum_score = stats.alnum_ratio.min(1.0);
let fragmentation_score = 1.0 - stats.fragmented_word_ratio.min(1.0);
let word_length_score = (stats.avg_word_length / 5.0).min(1.0);
let repeat_score = if thresholds.min_consecutive_repeat_ratio > 0.0 {
1.0 - (stats.consecutive_repeat_ratio / thresholds.min_consecutive_repeat_ratio).min(1.0)
} else {
1.0
};
let meaningful_score = if thresholds.min_meaningful_words == 0 {
1.0
} else {
(stats.meaningful_words as f64 / thresholds.min_meaningful_words as f64).min(1.0)
};
let garbage_score = if stats.garbage_char_count == 0 {
1.0
} else if thresholds.min_garbage_chars == 0 {
0.0
} else {
(1.0 - stats.garbage_char_count as f64 / (thresholds.min_garbage_chars as f64 * 2.0)).max(0.0)
};
(alnum_score * 0.25
+ fragmentation_score * 0.20
+ word_length_score * 0.15
+ repeat_score * 0.15
+ meaningful_score * 0.15
+ garbage_score * 0.10)
.clamp(0.0, 1.0)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(super) fn pipeline_stage_score(text_score: f64, mean_conf: Option<f64>) -> f64 {
match mean_conf {
Some(conf) => text_score * 0.7 + conf * 0.3,
None => text_score,
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn evaluate_per_page_ocr(
native_text: &str,
boundaries: Option<&[crate::types::PageBoundary]>,
page_count: Option<u32>,
thresholds: &OcrQualityThresholds,
) -> OcrFallbackDecision {
let boundaries = match boundaries {
Some(b) if !b.is_empty() => b,
_ => return evaluate_native_text_for_ocr(native_text, page_count, thresholds),
};
let boundary_count_matches_pages = page_count.is_none_or(|count| count as usize == boundaries.len());
let all_boundaries_are_valid = boundaries.iter().all(|boundary| {
boundary.byte_start <= boundary.byte_end
&& native_text.is_char_boundary(boundary.byte_start)
&& native_text.is_char_boundary(boundary.byte_end)
});
let boundaries_are_ordered = boundaries.windows(2).all(|pair| pair[0].byte_end <= pair[1].byte_start);
let page_numbers_are_complete = boundaries.iter().enumerate().all(|(index, boundary)| {
usize::try_from(boundary.page_number).is_ok_and(|page_number| page_number == index + 1)
});
let all_garbage_is_covered = all_boundaries_are_valid
&& boundaries_are_ordered
&& boundaries
.iter()
.map(|boundary| {
native_text[boundary.byte_start..boundary.byte_end]
.chars()
.filter(|character| *character == '\u{FFFD}')
.count()
})
.sum::<usize>()
== native_text.chars().filter(|character| *character == '\u{FFFD}').count();
let can_defer_absolute_garbage_threshold = boundaries.len() > 1
&& boundary_count_matches_pages
&& all_boundaries_are_valid
&& boundaries_are_ordered
&& page_numbers_are_complete
&& all_garbage_is_covered;
let mut document_decision = evaluate_native_text_for_ocr_with_garbage_threshold(
native_text,
page_count,
thresholds,
!can_defer_absolute_garbage_threshold,
);
if document_decision.whole_doc_failure {
return document_decision;
}
let mut failing_pages: Vec<u32> = Vec::with_capacity(boundaries.len());
let mut valid_boundary_count: usize = 0;
for boundary in boundaries {
if boundary.byte_start > boundary.byte_end
|| !native_text.is_char_boundary(boundary.byte_start)
|| !native_text.is_char_boundary(boundary.byte_end)
{
tracing::warn!(
page = boundary.page_number,
byte_start = boundary.byte_start,
byte_end = boundary.byte_end,
"skipping OCR quality evaluation for page with invalid text boundary"
);
continue;
}
valid_boundary_count += 1;
let page_text = &native_text[boundary.byte_start..boundary.byte_end];
if evaluate_native_text_for_ocr(page_text, Some(1), thresholds).fallback {
failing_pages.push(boundary.page_number);
}
}
if !failing_pages.is_empty() {
document_decision.fallback = true;
if failing_pages.len() == valid_boundary_count {
document_decision.whole_doc_failure = true;
}
}
document_decision.failing_pages = failing_pages;
document_decision
}