use serde::{Deserialize, Deserializer, Serialize};
use std::path::PathBuf;
use super::formats::OutputFormat;
use crate::core::config_validation::validate_ocr_backend;
use crate::error::XbergError;
use crate::types::OcrElementConfig;
fn deserialize_languages<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
let value: serde_json::Value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::String(s) => {
if s.contains('+') {
Ok(s.split('+').map(|l| l.to_string()).collect())
} else {
Ok(vec![s])
}
}
serde_json::Value::Array(arr) => arr
.into_iter()
.map(|v| {
v.as_str()
.map(String::from)
.ok_or_else(|| Error::custom("each language must be a string"))
})
.collect(),
_ => Err(Error::custom(
"language must be a string (e.g., \"eng\") or an array of strings (e.g., [\"eng\", \"deu\"])",
)),
}
}
fn deserialize_optional_languages<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
match value {
None => Ok(None),
Some(serde_json::Value::String(s)) => {
if s.contains('+') {
Ok(Some(s.split('+').map(|l| l.to_string()).collect()))
} else {
Ok(Some(vec![s]))
}
}
Some(serde_json::Value::Array(arr)) => {
let langs: Result<Vec<String>, D::Error> = arr
.into_iter()
.map(|v| {
v.as_str()
.map(String::from)
.ok_or_else(|| Error::custom("each language must be a string"))
})
.collect();
langs.map(Some)
}
Some(_) => Err(Error::custom("language must be a string or an array of strings")),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OcrQualityThresholds {
#[serde(default = "default_min_total_non_whitespace")]
pub min_total_non_whitespace: usize,
#[serde(default = "default_min_non_whitespace_per_page")]
pub min_non_whitespace_per_page: f64,
#[serde(default = "default_min_meaningful_word_len")]
pub min_meaningful_word_len: usize,
#[serde(default = "default_min_meaningful_words")]
pub min_meaningful_words: usize,
#[serde(default = "default_min_alnum_ratio")]
pub min_alnum_ratio: f64,
#[serde(default = "default_min_garbage_chars")]
pub min_garbage_chars: usize,
#[serde(default = "default_max_fragmented_word_ratio")]
pub max_fragmented_word_ratio: f64,
#[serde(default = "default_critical_fragmented_word_ratio")]
pub critical_fragmented_word_ratio: f64,
#[serde(default = "default_max_ocr_output_fragmented_word_ratio")]
pub max_ocr_output_fragmented_word_ratio: f64,
#[serde(default = "default_min_ocr_mean_confidence")]
pub min_ocr_mean_confidence: f64,
#[serde(default = "default_min_words_for_ocr_output_check")]
pub min_words_for_ocr_output_check: usize,
#[serde(default = "default_max_ocr_output_dict_invalid_word_ratio")]
pub max_ocr_output_dict_invalid_word_ratio: f64,
#[serde(default)]
pub discard_suspected_ocr_noise: bool,
#[serde(default = "default_min_avg_word_length")]
pub min_avg_word_length: f64,
#[serde(default = "default_min_words_for_avg_length_check")]
pub min_words_for_avg_length_check: usize,
#[serde(default = "default_min_consecutive_repeat_ratio")]
pub min_consecutive_repeat_ratio: f64,
#[serde(default = "default_min_words_for_repeat_check")]
pub min_words_for_repeat_check: usize,
#[serde(default = "default_substantive_min_chars")]
pub substantive_min_chars: usize,
#[serde(default = "default_non_text_min_chars")]
pub non_text_min_chars: usize,
#[serde(default = "default_alnum_ws_ratio_threshold")]
pub alnum_ws_ratio_threshold: f64,
#[serde(default = "default_pipeline_min_quality")]
pub pipeline_min_quality: f64,
#[serde(default = "default_min_undecodable_ratio")]
pub min_undecodable_ratio: f64,
#[serde(default = "default_enable_provenance_ocr_routing")]
pub enable_provenance_ocr_routing: bool,
#[serde(default = "default_min_provenance_fallback_ratio")]
pub min_provenance_fallback_ratio: f64,
}
const DISABLED_DICTIONARY_INVALID_WORD_RATIO: f64 = 1.01;
impl OcrQualityThresholds {
fn validate(&self, path: &str) -> Result<(), XbergError> {
let unit_ratios = [
("min_alnum_ratio", self.min_alnum_ratio),
("max_fragmented_word_ratio", self.max_fragmented_word_ratio),
("critical_fragmented_word_ratio", self.critical_fragmented_word_ratio),
(
"max_ocr_output_fragmented_word_ratio",
self.max_ocr_output_fragmented_word_ratio,
),
("min_consecutive_repeat_ratio", self.min_consecutive_repeat_ratio),
("alnum_ws_ratio_threshold", self.alnum_ws_ratio_threshold),
("pipeline_min_quality", self.pipeline_min_quality),
("min_undecodable_ratio", self.min_undecodable_ratio),
("min_provenance_fallback_ratio", self.min_provenance_fallback_ratio),
];
for (field, value) in unit_ratios {
validate_quality_value(path, field, value, 0.0, 1.0)?;
}
validate_quality_value(
path,
"min_ocr_mean_confidence",
self.min_ocr_mean_confidence,
0.0,
100.0,
)?;
validate_nonnegative_quality_value(path, "min_non_whitespace_per_page", self.min_non_whitespace_per_page)?;
validate_nonnegative_quality_value(path, "min_avg_word_length", self.min_avg_word_length)?;
if self.max_ocr_output_dict_invalid_word_ratio != DISABLED_DICTIONARY_INVALID_WORD_RATIO {
validate_quality_value(
path,
"max_ocr_output_dict_invalid_word_ratio",
self.max_ocr_output_dict_invalid_word_ratio,
0.0,
1.0,
)?;
}
Ok(())
}
}
fn validate_quality_value(path: &str, field: &str, value: f64, min: f64, max: f64) -> Result<(), XbergError> {
if value.is_finite() && (min..=max).contains(&value) {
return Ok(());
}
Err(XbergError::validation(format!(
"{path}.{field} must be a finite value between {min} and {max}, got {value}"
)))
}
fn validate_nonnegative_quality_value(path: &str, field: &str, value: f64) -> Result<(), XbergError> {
if value.is_finite() && value >= 0.0 {
return Ok(());
}
Err(XbergError::validation(format!(
"{path}.{field} must be a finite nonnegative value, got {value}"
)))
}
impl Default for OcrQualityThresholds {
fn default() -> Self {
Self {
min_total_non_whitespace: 64,
min_non_whitespace_per_page: 32.0,
min_meaningful_word_len: 4,
min_meaningful_words: 3,
min_alnum_ratio: 0.3,
min_garbage_chars: 5,
max_fragmented_word_ratio: 0.6,
critical_fragmented_word_ratio: 0.80,
min_ocr_mean_confidence: default_min_ocr_mean_confidence(),
max_ocr_output_fragmented_word_ratio: default_max_ocr_output_fragmented_word_ratio(),
min_words_for_ocr_output_check: default_min_words_for_ocr_output_check(),
max_ocr_output_dict_invalid_word_ratio: default_max_ocr_output_dict_invalid_word_ratio(),
discard_suspected_ocr_noise: false,
min_avg_word_length: 2.0,
min_words_for_avg_length_check: 50,
min_consecutive_repeat_ratio: 0.08,
min_words_for_repeat_check: 50,
substantive_min_chars: 100,
non_text_min_chars: 20,
alnum_ws_ratio_threshold: 0.4,
pipeline_min_quality: 0.5,
min_undecodable_ratio: default_min_undecodable_ratio(),
enable_provenance_ocr_routing: default_enable_provenance_ocr_routing(),
min_provenance_fallback_ratio: default_min_provenance_fallback_ratio(),
}
}
}
fn default_min_total_non_whitespace() -> usize {
64
}
fn default_min_non_whitespace_per_page() -> f64 {
32.0
}
fn default_min_meaningful_word_len() -> usize {
4
}
fn default_min_meaningful_words() -> usize {
3
}
fn default_min_alnum_ratio() -> f64 {
0.3
}
fn default_min_garbage_chars() -> usize {
5
}
fn default_max_fragmented_word_ratio() -> f64 {
0.6
}
fn default_critical_fragmented_word_ratio() -> f64 {
0.80
}
fn default_min_ocr_mean_confidence() -> f64 {
75.0
}
fn default_max_ocr_output_fragmented_word_ratio() -> f64 {
0.35
}
fn default_min_words_for_ocr_output_check() -> usize {
20
}
fn default_max_ocr_output_dict_invalid_word_ratio() -> f64 {
DISABLED_DICTIONARY_INVALID_WORD_RATIO
}
fn default_min_avg_word_length() -> f64 {
2.0
}
fn default_min_words_for_avg_length_check() -> usize {
50
}
fn default_min_consecutive_repeat_ratio() -> f64 {
0.08
}
fn default_min_words_for_repeat_check() -> usize {
50
}
fn default_substantive_min_chars() -> usize {
100
}
fn default_non_text_min_chars() -> usize {
20
}
fn default_alnum_ws_ratio_threshold() -> f64 {
0.4
}
fn default_pipeline_min_quality() -> f64 {
0.5
}
fn default_min_undecodable_ratio() -> f64 {
0.5
}
fn default_enable_provenance_ocr_routing() -> bool {
true
}
fn default_min_provenance_fallback_ratio() -> f64 {
0.5
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OcrPipelineStage {
pub backend: String,
#[serde(default = "default_priority")]
pub priority: u32,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_languages"
)]
pub language: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tesseract_config: Option<crate::types::TesseractConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paddle_ocr_config: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vlm_config: Option<super::llm::LlmConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend_options: Option<serde_json::Value>,
}
#[allow(dead_code)]
pub(crate) const SOURCE_DPI_BACKEND_OPTION: &str = "source_dpi";
fn default_priority() -> u32 {
100
}
#[cfg(all(feature = "pdf", any(feature = "ocr", feature = "ocr-pipeline")))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum OcrPipelineSelection {
#[default]
HighestScore,
PreferLastNonEmpty,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OcrPipelineConfig {
pub stages: Vec<OcrPipelineStage>,
#[serde(default)]
pub quality_thresholds: OcrQualityThresholds,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum VlmFallbackPolicy {
#[default]
Disabled,
OnLowQuality {
quality_threshold: f64,
},
Always,
}
#[derive(Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
enum VlmFallbackPolicyWire {
Disabled {},
OnLowQuality { quality_threshold: f64 },
Always {},
}
impl<'de> Deserialize<'de> for VlmFallbackPolicy {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(match VlmFallbackPolicyWire::deserialize(deserializer)? {
VlmFallbackPolicyWire::Disabled {} => Self::Disabled,
VlmFallbackPolicyWire::OnLowQuality { quality_threshold } => Self::OnLowQuality { quality_threshold },
VlmFallbackPolicyWire::Always {} => Self::Always,
})
}
}
pub const DEFAULT_SCANNED_MIN_CONFIDENCE: f64 = 0.70;
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum OcrStrategy {
#[default]
Auto,
ScannedPages {
min_confidence: f64,
},
}
#[derive(Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
enum OcrStrategyWire {
Auto {},
ScannedPages { min_confidence: f64 },
}
impl<'de> Deserialize<'de> for OcrStrategy {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(match OcrStrategyWire::deserialize(deserializer)? {
OcrStrategyWire::Auto {} => Self::Auto,
OcrStrategyWire::ScannedPages { min_confidence } => Self::ScannedPages { min_confidence },
})
}
}
impl OcrStrategy {
pub(crate) fn validate(&self) -> Result<(), XbergError> {
if let Self::ScannedPages { min_confidence } = self {
validate_quality_value("ocr_strategy", "min_confidence", *min_confidence, 0.0, 1.0)?;
}
Ok(())
}
#[must_use]
pub fn effective_min_confidence(&self) -> f64 {
match self {
Self::Auto => DEFAULT_SCANNED_MIN_CONFIDENCE,
Self::ScannedPages { min_confidence } if min_confidence.is_finite() => min_confidence.clamp(0.0, 1.0),
Self::ScannedPages { .. } => DEFAULT_SCANNED_MIN_CONFIDENCE,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OcrConfig {
#[serde(default = "default_ocr_enabled")]
pub enabled: bool,
#[serde(default = "default_tesseract_backend")]
pub backend: String,
#[serde(default = "default_eng", deserialize_with = "deserialize_languages")]
pub language: Vec<String>,
#[serde(default)]
pub tesseract_config: Option<crate::types::TesseractConfig>,
#[serde(default)]
pub output_format: Option<OutputFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paddle_ocr_config: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend_options: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub element_config: Option<OcrElementConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quality_thresholds: Option<OcrQualityThresholds>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pipeline: Option<OcrPipelineConfig>,
#[serde(default)]
pub auto_rotate: bool,
#[serde(default)]
pub vlm_fallback: VlmFallbackPolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vlm_config: Option<super::llm::LlmConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vlm_prompt: Option<String>,
#[serde(skip)]
pub acceleration: Option<super::acceleration::AccelerationConfig>,
#[serde(skip)]
pub security_limits: Option<crate::extractors::security::SecurityLimits>,
#[serde(skip)]
pub tessdata_bytes: Option<std::collections::HashMap<String, Vec<u8>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tessdata_path: Option<PathBuf>,
}
impl Default for OcrConfig {
fn default() -> Self {
Self {
enabled: true,
backend: default_tesseract_backend(),
language: vec!["eng".to_string()],
tesseract_config: None,
output_format: None,
paddle_ocr_config: None,
backend_options: None,
element_config: None,
quality_thresholds: None,
pipeline: None,
auto_rotate: false,
vlm_fallback: VlmFallbackPolicy::Disabled,
vlm_config: None,
vlm_prompt: None,
acceleration: None,
security_limits: None,
tessdata_bytes: None,
tessdata_path: None,
}
}
}
impl OcrConfig {
#[cfg(any(feature = "ocr", feature = "ocr-wasm", feature = "ocr-pipeline"))]
pub(crate) fn select_public_elements(
&self,
elements: Option<Vec<crate::types::OcrElement>>,
) -> Option<Vec<crate::types::OcrElement>> {
let element_config = self.element_config.as_ref()?;
let selected = element_config.select_elements(elements.as_deref().unwrap_or_default());
(!selected.is_empty()).then_some(selected)
}
#[cfg(any(feature = "ocr", all(feature = "pdf", feature = "ocr-pipeline")))]
pub(crate) fn apply_public_element_policy(&self, document: &mut crate::types::ExtractedDocument) {
document.ocr_elements = self.select_public_elements(document.ocr_elements.take());
}
fn validate_quality_thresholds(&self) -> Result<(), XbergError> {
if let Some(thresholds) = self.quality_thresholds.as_ref() {
thresholds.validate("ocr.quality_thresholds")?;
}
if let Some(pipeline) = self.pipeline.as_ref() {
pipeline
.quality_thresholds
.validate("ocr.pipeline.quality_thresholds")?;
}
Ok(())
}
pub(crate) fn validate(&self) -> Result<(), XbergError> {
validate_ocr_backend(&self.backend)?;
crate::core::config_validation::validate_vlm_backend_config(&self.backend, self.vlm_config.as_ref())?;
validate_languages(&self.language)?;
if let VlmFallbackPolicy::OnLowQuality { quality_threshold } = &self.vlm_fallback {
crate::core::config_validation::validate_confidence(*quality_threshold)?;
}
if let Some(element_config) = self.element_config.as_ref()
&& (!element_config.min_confidence.is_finite() || !(0.0..=1.0).contains(&element_config.min_confidence))
{
return Err(XbergError::validation(format!(
"ocr.element_config.min_confidence must be a finite value between 0.0 and 1.0, got {}",
element_config.min_confidence
)));
}
self.validate_quality_thresholds()?;
validate_tesseract_tuning(self.tesseract_config.as_ref())?;
if let Some(ref pipeline) = self.pipeline {
for stage in &pipeline.stages {
validate_ocr_backend(&stage.backend)?;
crate::core::config_validation::validate_vlm_backend_config(&stage.backend, stage.vlm_config.as_ref())?;
if let Some(ref languages) = stage.language {
validate_languages(languages)?;
}
validate_tesseract_tuning(stage.tesseract_config.as_ref())?;
}
} else if self.vlm_fallback != VlmFallbackPolicy::Disabled && self.vlm_config.is_none() {
return Err(XbergError::validation(
"vlm_fallback is set but vlm_config is missing; \
provide an LlmConfig with the model and API key"
.to_string(),
));
}
Ok(())
}
#[cfg(any(
feature = "ocr",
feature = "ocr-wasm",
feature = "ocr-pipeline",
paddle_ocr,
all(feature = "liter-llm", not(target_arch = "wasm32")),
))]
pub(crate) fn effective_languages(&self) -> Vec<String> {
let langs: Vec<String> = self
.language
.iter()
.map(|lang| lang.trim())
.filter(|lang| !lang.is_empty())
.map(str::to_string)
.collect();
if langs.is_empty() {
vec![DEFAULT_OCR_LANGUAGE.to_string()]
} else {
langs
}
}
#[cfg(any(
feature = "ocr",
feature = "ocr-wasm",
feature = "ocr-pipeline",
paddle_ocr,
all(feature = "liter-llm", not(target_arch = "wasm32")),
))]
pub(crate) fn effective_tesseract_language(&self) -> Vec<String> {
if self.language != [DEFAULT_OCR_LANGUAGE.to_string()] {
return self.effective_languages();
}
match &self.tesseract_config {
Some(tess) if !tess.language.is_empty() => tess.language.clone(),
_ => self.effective_languages(),
}
}
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), feature = "pdf"))]
pub(crate) fn effective_thresholds(&self) -> OcrQualityThresholds {
self.quality_thresholds.clone().unwrap_or_default()
}
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), feature = "pdf"))]
pub(crate) fn effective_pipeline(&self) -> Option<OcrPipelineConfig> {
if self.pipeline.is_some() {
return self.pipeline.clone();
}
match &self.vlm_fallback {
VlmFallbackPolicy::OnLowQuality { quality_threshold } => {
let Some(vlm_cfg) = self.vlm_config.clone() else {
tracing::warn!(
"vlm_fallback=OnLowQuality is set but vlm_config is missing; \
falling through to single-backend mode"
);
return self.effective_pipeline_classical();
};
let mut thresholds = self.effective_thresholds();
thresholds.pipeline_min_quality = *quality_threshold;
let classical_stage = OcrPipelineStage {
backend: self.backend.clone(),
priority: 100,
language: if self.language.len() == 1 && self.language[0] == "eng" {
None
} else {
Some(self.language.clone())
},
tesseract_config: self.tesseract_config.clone(),
paddle_ocr_config: None,
vlm_config: None,
backend_options: self.backend_options.clone(),
};
let vlm_stage = OcrPipelineStage {
backend: "vlm".to_string(),
priority: 50,
language: None,
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: Some(vlm_cfg),
backend_options: None,
};
return Some(OcrPipelineConfig {
stages: vec![classical_stage, vlm_stage],
quality_thresholds: thresholds,
});
}
VlmFallbackPolicy::Always => {
let Some(vlm_cfg) = self.vlm_config.clone() else {
tracing::warn!(
"vlm_fallback=Always is set but vlm_config is missing; \
falling through to single-backend mode"
);
return self.effective_pipeline_classical();
};
let vlm_stage = OcrPipelineStage {
backend: "vlm".to_string(),
priority: 100,
language: None,
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: Some(vlm_cfg),
backend_options: None,
};
return Some(OcrPipelineConfig {
stages: vec![vlm_stage],
quality_thresholds: self.effective_thresholds(),
});
}
VlmFallbackPolicy::Disabled => {}
}
self.effective_pipeline_classical()
}
#[cfg(all(any(feature = "ocr", feature = "ocr-pipeline"), feature = "pdf"))]
fn effective_pipeline_classical(&self) -> Option<OcrPipelineConfig> {
#[cfg(paddle_ocr)]
{
if self.backend != default_tesseract_backend() {
return None;
}
let stages = vec![
OcrPipelineStage {
backend: self.backend.clone(),
priority: 100,
language: if self.language.len() == 1 && self.language[0] == "eng" {
None
} else {
Some(self.language.clone())
},
tesseract_config: self.tesseract_config.clone(),
paddle_ocr_config: None,
vlm_config: self.vlm_config.clone(),
backend_options: self.backend_options.clone(),
},
OcrPipelineStage {
backend: "paddleocr".to_string(),
priority: 50,
language: None,
tesseract_config: None,
paddle_ocr_config: self.paddle_ocr_config.clone(),
vlm_config: None,
backend_options: None,
},
];
Some(OcrPipelineConfig {
stages,
quality_thresholds: self.effective_thresholds(),
})
}
#[cfg(not(paddle_ocr))]
{
None
}
}
#[cfg(all(feature = "pdf", any(feature = "ocr", feature = "ocr-pipeline")))]
pub(crate) fn pipeline_selection(&self) -> OcrPipelineSelection {
if self.pipeline.is_some() {
return OcrPipelineSelection::HighestScore;
}
if self.vlm_fallback != VlmFallbackPolicy::Disabled && self.vlm_config.is_some() {
OcrPipelineSelection::PreferLastNonEmpty
} else {
OcrPipelineSelection::HighestScore
}
}
}
fn validate_languages(languages: &[String]) -> Result<(), XbergError> {
for language in languages
.iter()
.map(|language| language.trim())
.filter(|l| !l.is_empty())
{
crate::core::config_validation::validate_language_code(language)?;
}
Ok(())
}
fn validate_tesseract_tuning(tesseract_config: Option<&crate::types::TesseractConfig>) -> Result<(), XbergError> {
let Some(tesseract_config) = tesseract_config else {
return Ok(());
};
if let Some(psm) = tesseract_config.psm {
crate::core::config_validation::validate_tesseract_psm(psm)?;
}
crate::core::config_validation::validate_tesseract_oem(tesseract_config.oem)?;
if let Some(ref preprocessing) = tesseract_config.preprocessing {
crate::core::config_validation::validate_image_preprocessing_config(preprocessing)?;
}
Ok(())
}
fn default_ocr_enabled() -> bool {
true
}
fn default_tesseract_backend() -> String {
"tesseract".to_string()
}
pub(crate) const DEFAULT_OCR_LANGUAGE: &str = "eng";
fn default_eng() -> Vec<String> {
vec![DEFAULT_OCR_LANGUAGE.to_string()]
}
#[cfg(test)]
mod tests {
use super::*;
fn tesseract_config_with(psm: i32, oem: i32) -> crate::types::TesseractConfig {
crate::types::TesseractConfig {
psm: Some(psm),
oem,
..Default::default()
}
}
#[test]
fn should_accept_ocr_config_when_tesseract_psm_and_oem_are_in_range() {
let config = OcrConfig {
tesseract_config: Some(tesseract_config_with(6, 1)),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn should_accept_ocr_config_when_tesseract_psm_is_unset() {
let config = OcrConfig {
tesseract_config: Some(crate::types::TesseractConfig {
psm: None,
oem: 1,
..Default::default()
}),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn should_reject_ocr_config_when_tesseract_psm_is_above_range() {
let config = OcrConfig {
tesseract_config: Some(tesseract_config_with(14, 1)),
..Default::default()
};
let message = config
.validate()
.expect_err("psm 14 is out of the 0-13 range")
.to_string();
assert!(
message.contains("PSM"),
"error should name the PSM field; got: {message}"
);
}
#[test]
fn should_reject_ocr_config_when_tesseract_oem_is_above_range() {
let config = OcrConfig {
tesseract_config: Some(tesseract_config_with(6, 4)),
..Default::default()
};
let message = config
.validate()
.expect_err("oem 4 is out of the 0-3 range")
.to_string();
assert!(
message.contains("OEM"),
"error should name the OEM field; got: {message}"
);
}
#[test]
fn should_reject_ocr_config_when_binarization_method_is_unknown() {
let mut tesseract_config = tesseract_config_with(6, 1);
tesseract_config.preprocessing = Some(crate::types::ImagePreprocessingConfig {
binarization_method: "not-a-method".to_string(),
..Default::default()
});
let config = OcrConfig {
tesseract_config: Some(tesseract_config),
..Default::default()
};
let message = config
.validate()
.expect_err("an unknown binarization method must be rejected")
.to_string();
assert!(
message.contains("binarization"),
"error should name the binarization method; got: {message}"
);
}
#[test]
fn should_reject_ocr_config_when_deskew_has_no_binarization() {
let mut tesseract_config = tesseract_config_with(6, 1);
tesseract_config.preprocessing = Some(crate::types::ImagePreprocessingConfig {
deskew: true,
binarization_method: "none".to_string(),
..Default::default()
});
let config = OcrConfig {
tesseract_config: Some(tesseract_config),
..Default::default()
};
let message = config
.validate()
.expect_err("grayscale-preserving deskew is unavailable")
.to_string();
assert_eq!(
message,
"Validation error: deskew must be false when binarization_method is none or off"
);
}
#[test]
fn test_ocr_config_default() {
let config = OcrConfig::default();
assert_eq!(config.backend, "tesseract");
assert_eq!(config.language, vec!["eng".to_string()]);
assert!(config.tesseract_config.is_none());
assert!(config.output_format.is_none());
}
#[test]
fn test_ocr_config_with_tesseract() {
let config = OcrConfig {
backend: "tesseract".to_string(),
language: vec!["fra".to_string()],
..Default::default()
};
assert_eq!(config.backend, "tesseract");
assert_eq!(config.language, vec!["fra".to_string()]);
}
#[test]
fn test_ocr_config_with_multiple_languages() {
let config = OcrConfig {
backend: "tesseract".to_string(),
language: vec!["eng".to_string(), "deu".to_string()],
..Default::default()
};
assert_eq!(config.backend, "tesseract");
assert_eq!(config.language, vec!["eng".to_string(), "deu".to_string()]);
}
#[test]
fn test_language_deserialization_single_string() {
let json = r#"{"language": "eng"}"#;
let config: OcrConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.language, vec!["eng".to_string()]);
}
#[test]
fn test_language_deserialization_array() {
let json = r#"{"language": ["eng", "deu", "fra"]}"#;
let config: OcrConfig = serde_json::from_str(json).unwrap();
assert_eq!(
config.language,
vec!["eng".to_string(), "deu".to_string(), "fra".to_string()]
);
}
#[test]
fn test_language_deserialization_tesseract_format() {
let json = r#"{"language": "eng+deu"}"#;
let config: OcrConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.language, vec!["eng".to_string(), "deu".to_string()]);
}
#[test]
fn test_validate_tesseract_backend() {
let config = OcrConfig {
backend: "tesseract".to_string(),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_unknown_backend_rejected() {
let config = OcrConfig {
backend: "unsupported-ocr".to_string(),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid OCR backend"));
}
#[test]
fn test_validate_paddleocr_backend() {
let config = OcrConfig {
backend: "paddleocr".to_string(),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_invalid_backend_typo() {
let config = OcrConfig {
backend: "tesseract_typo".to_string(),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Invalid OCR backend"));
}
#[test]
fn test_validate_invalid_backend_completely_wrong() {
let config = OcrConfig {
backend: "ocr_lib".to_string(),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Invalid OCR backend") || err_msg.contains("Valid options are"));
}
#[test]
fn test_validate_default_backend() {
let config = OcrConfig::default();
assert!(config.validate().is_ok());
}
#[cfg(feature = "ocr")]
#[test]
fn test_effective_languages_defaults_and_filters() {
let empty = OcrConfig {
language: vec![],
..Default::default()
};
assert_eq!(empty.effective_languages(), vec!["eng".to_string()]);
let blank = OcrConfig {
language: vec![String::new(), " ".to_string()],
..Default::default()
};
assert_eq!(blank.effective_languages(), vec!["eng".to_string()]);
let mixed = OcrConfig {
language: vec!["eng".to_string(), " ".to_string(), " deu".to_string()],
..Default::default()
};
assert_eq!(mixed.effective_languages(), vec!["eng".to_string(), "deu".to_string()]);
}
#[cfg(all(feature = "ocr", feature = "pdf"))]
#[test]
fn test_effective_pipeline_explicit_pipeline_returned_unchanged() {
let explicit_pipeline = OcrPipelineConfig {
stages: vec![OcrPipelineStage {
backend: "paddleocr".to_string(),
priority: 200,
language: Some(vec!["fra".to_string()]),
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: None,
backend_options: None,
}],
quality_thresholds: OcrQualityThresholds::default(),
};
let config = OcrConfig {
pipeline: Some(explicit_pipeline.clone()),
..Default::default()
};
let result = config.effective_pipeline().unwrap();
assert_eq!(result.stages.len(), 1);
assert_eq!(result.stages[0].backend, "paddleocr");
assert_eq!(result.stages[0].priority, 200);
assert_eq!(result.stages[0].language, Some(vec!["fra".to_string()]));
assert_eq!(
config.pipeline_selection(),
OcrPipelineSelection::HighestScore,
"an explicit pipeline must keep score-based selection"
);
}
#[cfg(all(feature = "ocr", feature = "pdf"))]
#[test]
fn test_effective_pipeline_explicit_paddleocr_no_autofallback() {
let config = OcrConfig {
backend: "paddleocr".to_string(),
..Default::default()
};
assert!(config.effective_pipeline().is_none());
}
#[cfg(feature = "ocr")]
#[test]
fn test_effective_pipeline_unknown_backend_rejected_by_validation() {
let config = OcrConfig {
backend: "unsupported-ocr".to_string(),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid OCR backend"));
}
#[cfg(all(feature = "ocr", feature = "pdf"))]
#[test]
fn test_effective_pipeline_default_tesseract_backend() {
let config = OcrConfig::default();
let result = config.effective_pipeline();
#[cfg(paddle_ocr)]
{
let pipeline = result.unwrap();
assert_eq!(pipeline.stages.len(), 2);
assert_eq!(pipeline.stages[0].backend, "tesseract");
assert_eq!(pipeline.stages[0].priority, 100);
assert_eq!(pipeline.stages[1].backend, "paddleocr");
assert_eq!(pipeline.stages[1].priority, 50);
}
#[cfg(not(paddle_ocr))]
{
assert!(result.is_none());
}
}
#[cfg(all(feature = "ocr", feature = "pdf"))]
#[test]
fn test_effective_thresholds_custom_vs_default() {
let custom = OcrQualityThresholds {
min_total_non_whitespace: 128,
min_meaningful_words: 10,
..Default::default()
};
let config_custom = OcrConfig {
quality_thresholds: Some(custom.clone()),
..Default::default()
};
let eff = config_custom.effective_thresholds();
assert_eq!(eff.min_total_non_whitespace, 128);
assert_eq!(eff.min_meaningful_words, 10);
let config_default = OcrConfig::default();
let eff_default = config_default.effective_thresholds();
assert_eq!(eff_default.min_total_non_whitespace, 64);
assert_eq!(eff_default.min_meaningful_words, 3);
}
#[test]
fn test_vlm_fallback_policy_default_is_disabled() {
let config = OcrConfig::default();
assert_eq!(config.vlm_fallback, VlmFallbackPolicy::Disabled);
}
#[test]
fn auto_strategy_reports_the_default_scan_threshold() {
assert_eq!(
OcrStrategy::Auto.effective_min_confidence(),
DEFAULT_SCANNED_MIN_CONFIDENCE
);
}
#[test]
fn scanned_pages_strategy_clamps_its_threshold_into_the_unit_interval() {
assert_eq!(
OcrStrategy::ScannedPages { min_confidence: -0.5 }.effective_min_confidence(),
0.0
);
assert_eq!(
OcrStrategy::ScannedPages { min_confidence: 1.5 }.effective_min_confidence(),
1.0
);
assert_eq!(
OcrStrategy::ScannedPages { min_confidence: 0.42 }.effective_min_confidence(),
0.42
);
}
#[test]
fn scanned_pages_strategy_uses_the_default_for_non_finite_direct_values() {
for min_confidence in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(
OcrStrategy::ScannedPages { min_confidence }.effective_min_confidence(),
DEFAULT_SCANNED_MIN_CONFIDENCE
);
}
}
#[test]
fn should_reject_invalid_typed_scanned_pages_confidence_at_the_public_boundary() {
for min_confidence in [-0.1, 1.1, f64::NAN, f64::INFINITY] {
let config = crate::ExtractionConfig {
ocr_strategy: OcrStrategy::ScannedPages { min_confidence },
..Default::default()
};
let error = config.validate().unwrap_err();
assert!(
error.to_string().contains("ocr_strategy.min_confidence"),
"unexpected error for {min_confidence}: {error}"
);
}
}
#[test]
fn should_reject_invalid_json_scanned_pages_confidence_at_the_public_boundary() {
let json = r#"{"ocr_strategy":{"mode":"scanned_pages","min_confidence":1.1}}"#;
let config: crate::ExtractionConfig = serde_json::from_str(json).unwrap();
let error = config.validate().unwrap_err();
assert!(error.to_string().contains("ocr_strategy.min_confidence"));
}
#[test]
fn test_vlm_fallback_disabled_validate_ok_without_vlm_config() {
let config = OcrConfig {
vlm_fallback: VlmFallbackPolicy::Disabled,
vlm_config: None,
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_vlm_fallback_on_low_quality_missing_vlm_config_validate_err() {
let config = OcrConfig {
vlm_fallback: VlmFallbackPolicy::OnLowQuality { quality_threshold: 0.6 },
vlm_config: None,
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("vlm_config"),
"error must mention vlm_config; got: {err_msg}"
);
}
#[test]
fn test_vlm_fallback_always_missing_vlm_config_validate_err() {
let config = OcrConfig {
vlm_fallback: VlmFallbackPolicy::Always,
vlm_config: None,
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("vlm_config"),
"error must mention vlm_config; got: {err_msg}"
);
}
#[cfg(all(feature = "ocr", feature = "pdf"))]
#[test]
fn test_vlm_fallback_on_low_quality_synthesises_two_stage_pipeline() {
use super::super::llm::LlmConfig;
let vlm_cfg = LlmConfig {
model: "openai/gpt-4o-mini".to_string(),
base_url: Some("http://localhost:9999".to_string()),
..Default::default()
};
let config = OcrConfig {
backend: "tesseract".to_string(),
vlm_fallback: VlmFallbackPolicy::OnLowQuality { quality_threshold: 0.6 },
vlm_config: Some(vlm_cfg.clone()),
..Default::default()
};
let explicit = OcrConfig {
pipeline: Some(OcrPipelineConfig {
stages: vec![
OcrPipelineStage {
backend: "tesseract".to_string(),
priority: 100,
language: None,
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: None,
backend_options: None,
},
OcrPipelineStage {
backend: "vlm".to_string(),
priority: 50,
language: None,
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: Some(vlm_cfg),
backend_options: None,
},
],
quality_thresholds: OcrQualityThresholds {
pipeline_min_quality: 0.6,
..Default::default()
},
}),
..Default::default()
};
let synthesised = config.effective_pipeline().expect("must synthesise a pipeline");
let hand_written = explicit
.effective_pipeline()
.expect("explicit pipeline must be returned");
assert_eq!(
synthesised.stages.len(),
hand_written.stages.len(),
"stage count mismatch"
);
assert_eq!(synthesised.stages[0].backend, hand_written.stages[0].backend);
assert_eq!(synthesised.stages[0].priority, hand_written.stages[0].priority);
assert_eq!(synthesised.stages[1].backend, hand_written.stages[1].backend);
assert_eq!(synthesised.stages[1].priority, hand_written.stages[1].priority);
let s_vlm = synthesised.stages[1]
.vlm_config
.as_ref()
.expect("synthesised stage 1 must have vlm_config");
let h_vlm = hand_written.stages[1]
.vlm_config
.as_ref()
.expect("hand-written stage 1 must have vlm_config");
assert_eq!(s_vlm.model, h_vlm.model);
assert!(
(synthesised.quality_thresholds.pipeline_min_quality - 0.6).abs() < f64::EPSILON,
"threshold must be 0.6, got {}",
synthesised.quality_thresholds.pipeline_min_quality
);
assert!((hand_written.quality_thresholds.pipeline_min_quality - 0.6).abs() < f64::EPSILON,);
assert_eq!(
config.pipeline_selection(),
OcrPipelineSelection::PreferLastNonEmpty,
"OnLowQuality synthesis must prefer the last non-empty stage as its best-effort fallback"
);
}
#[cfg(all(feature = "ocr", feature = "pdf"))]
#[test]
fn test_vlm_fallback_always_synthesises_single_stage_vlm_pipeline() {
use super::super::llm::LlmConfig;
let vlm_cfg = LlmConfig {
model: "anthropic/claude-sonnet-4-20250514".to_string(),
..Default::default()
};
let config = OcrConfig {
vlm_fallback: VlmFallbackPolicy::Always,
vlm_config: Some(vlm_cfg),
..Default::default()
};
let pipeline = config.effective_pipeline().expect("Always must produce a pipeline");
assert_eq!(pipeline.stages.len(), 1, "Always must produce exactly one stage");
assert_eq!(pipeline.stages[0].backend, "vlm");
assert_eq!(pipeline.stages[0].priority, 100);
assert!(pipeline.stages[0].vlm_config.is_some());
assert_eq!(
config.pipeline_selection(),
OcrPipelineSelection::PreferLastNonEmpty,
"Always synthesis must prefer the last non-empty stage as its best-effort fallback"
);
}
#[cfg(all(feature = "ocr", feature = "pdf"))]
#[test]
fn test_vlm_fallback_disabled_no_synthesis() {
let config = OcrConfig {
backend: "paddleocr".to_string(),
vlm_fallback: VlmFallbackPolicy::Disabled,
vlm_config: None,
..Default::default()
};
assert!(config.effective_pipeline().is_none());
}
#[cfg(all(feature = "ocr", feature = "pdf"))]
#[test]
fn test_explicit_pipeline_wins_over_vlm_fallback() {
use super::super::llm::LlmConfig;
let explicit = OcrPipelineConfig {
stages: vec![OcrPipelineStage {
backend: "paddleocr".to_string(),
priority: 99,
language: None,
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: None,
backend_options: None,
}],
quality_thresholds: OcrQualityThresholds::default(),
};
let config = OcrConfig {
pipeline: Some(explicit),
vlm_fallback: VlmFallbackPolicy::Always,
vlm_config: Some(LlmConfig {
model: "openai/gpt-4o".to_string(),
..Default::default()
}),
..Default::default()
};
let pipeline = config.effective_pipeline().expect("explicit pipeline must be returned");
assert_eq!(pipeline.stages.len(), 1, "explicit pipeline must win");
assert_eq!(pipeline.stages[0].backend, "paddleocr", "explicit pipeline must win");
}
#[test]
fn test_vlm_fallback_policy_serde_roundtrip_disabled() {
let policy = VlmFallbackPolicy::Disabled;
let json = serde_json::to_string(&policy).unwrap();
let deserialized: VlmFallbackPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, VlmFallbackPolicy::Disabled);
}
#[test]
fn test_vlm_fallback_policy_serde_roundtrip_on_low_quality() {
let policy = VlmFallbackPolicy::OnLowQuality {
quality_threshold: 0.42,
};
let json = serde_json::to_string(&policy).unwrap();
let deserialized: VlmFallbackPolicy = serde_json::from_str(&json).unwrap();
match deserialized {
VlmFallbackPolicy::OnLowQuality { quality_threshold } => {
assert!((quality_threshold - 0.42).abs() < f64::EPSILON);
}
other => panic!("expected OnLowQuality, got {other:?}"),
}
}
#[test]
fn test_vlm_fallback_policy_serde_roundtrip_always() {
let policy = VlmFallbackPolicy::Always;
let json = serde_json::to_string(&policy).unwrap();
let deserialized: VlmFallbackPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, VlmFallbackPolicy::Always);
}
#[test]
fn test_ocr_config_vlm_fallback_omitted_when_disabled_default() {
let config = OcrConfig::default();
let json = serde_json::to_string(&config).unwrap();
let deserialized: OcrConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.vlm_fallback, VlmFallbackPolicy::Disabled);
}
#[test]
fn test_pipeline_config_serde_roundtrip() {
let pipeline = OcrPipelineConfig {
stages: vec![
OcrPipelineStage {
backend: "tesseract".to_string(),
priority: 100,
language: Some(vec!["eng".to_string()]),
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: None,
backend_options: None,
},
OcrPipelineStage {
backend: "paddleocr".to_string(),
priority: 50,
language: None,
tesseract_config: None,
paddle_ocr_config: Some(serde_json::json!({"use_gpu": false})),
vlm_config: None,
backend_options: None,
},
],
quality_thresholds: OcrQualityThresholds::default(),
};
let json = serde_json::to_string(&pipeline).unwrap();
let deserialized: OcrPipelineConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.stages.len(), 2);
assert_eq!(deserialized.stages[0].backend, "tesseract");
assert_eq!(deserialized.stages[0].priority, 100);
assert_eq!(deserialized.stages[1].backend, "paddleocr");
assert_eq!(deserialized.stages[1].priority, 50);
assert!(deserialized.stages[1].paddle_ocr_config.is_some());
}
#[cfg(all(feature = "ocr", feature = "pdf"))]
#[test]
fn test_pipeline_selection_derivation() {
use super::super::llm::LlmConfig;
assert_eq!(
OcrConfig::default().pipeline_selection(),
OcrPipelineSelection::HighestScore
);
let missing_vlm = OcrConfig {
vlm_fallback: VlmFallbackPolicy::Always,
vlm_config: None,
..Default::default()
};
assert_eq!(missing_vlm.pipeline_selection(), OcrPipelineSelection::HighestScore);
let with_vlm = OcrConfig {
vlm_fallback: VlmFallbackPolicy::Always,
vlm_config: Some(LlmConfig {
model: "openai/gpt-4o-mini".to_string(),
..Default::default()
}),
..Default::default()
};
assert_eq!(with_vlm.pipeline_selection(), OcrPipelineSelection::PreferLastNonEmpty);
}
#[test]
fn test_pipeline_stage_deserialization_missing_optional_fields() {
let json = r#"{"backend": "tesseract"}"#;
let stage: OcrPipelineStage = serde_json::from_str(json).unwrap();
assert_eq!(stage.backend, "tesseract");
assert_eq!(stage.priority, 100);
assert!(stage.language.is_none());
assert!(stage.tesseract_config.is_none());
assert!(stage.paddle_ocr_config.is_none());
}
#[test]
fn test_pipeline_stage_language_deserialization_single() {
let json = r#"{"backend": "tesseract", "language": "eng"}"#;
let stage: OcrPipelineStage = serde_json::from_str(json).unwrap();
assert_eq!(stage.language, Some(vec!["eng".to_string()]));
}
#[test]
fn test_pipeline_stage_language_deserialization_array() {
let json = r#"{"backend": "tesseract", "language": ["eng", "deu"]}"#;
let stage: OcrPipelineStage = serde_json::from_str(json).unwrap();
assert_eq!(stage.language, Some(vec!["eng".to_string(), "deu".to_string()]));
}
#[test]
fn test_pipeline_stage_default_priority_is_100() {
let json = r#"{"backend": "paddleocr"}"#;
let stage: OcrPipelineStage = serde_json::from_str(json).unwrap();
assert_eq!(stage.priority, 100);
}
#[test]
fn test_ocr_config_deserialization_missing_optional_fields() {
let json = r#"{}"#;
let config: OcrConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.backend, "tesseract");
assert_eq!(config.language, vec!["eng".to_string()]);
assert!(config.pipeline.is_none());
assert!(config.quality_thresholds.is_none());
assert!(config.element_config.is_none());
}
#[test]
fn test_quality_thresholds_deserialization_partial() {
let json = r#"{"min_total_non_whitespace": 256}"#;
let thresholds: OcrQualityThresholds = serde_json::from_str(json).unwrap();
assert_eq!(thresholds.min_total_non_whitespace, 256);
assert_eq!(thresholds.min_meaningful_words, 3);
assert_eq!(thresholds.min_garbage_chars, 5);
assert!((thresholds.pipeline_min_quality - 0.5).abs() < f64::EPSILON);
assert!(!thresholds.discard_suspected_ocr_noise);
}
#[test]
fn destructive_ocr_noise_filter_defaults_disabled() {
assert!(!OcrQualityThresholds::default().discard_suspected_ocr_noise);
}
#[test]
fn should_roundtrip_destructive_ocr_noise_filter_opt_in() {
let thresholds: OcrQualityThresholds = serde_json::from_str(r#"{"discard_suspected_ocr_noise":true}"#).unwrap();
assert!(thresholds.discard_suspected_ocr_noise);
let serialized = serde_json::to_string(&thresholds).unwrap();
let roundtripped: OcrQualityThresholds = serde_json::from_str(&serialized).unwrap();
assert!(roundtripped.discard_suspected_ocr_noise);
}
fn config_with_quality_thresholds(thresholds: OcrQualityThresholds) -> crate::ExtractionConfig {
crate::ExtractionConfig {
ocr: Some(OcrConfig {
quality_thresholds: Some(thresholds),
..Default::default()
}),
..Default::default()
}
}
fn assert_invalid_quality_ratio(field: &str, mutate: fn(&mut OcrQualityThresholds)) {
let mut thresholds = OcrQualityThresholds::default();
mutate(&mut thresholds);
let error = config_with_quality_thresholds(thresholds).validate().unwrap_err();
assert!(
error.to_string().contains(field),
"unexpected error for {field}: {error}"
);
}
#[test]
fn should_reject_invalid_typed_ocr_quality_ratios() {
assert_invalid_quality_ratio("min_alnum_ratio", |value| value.min_alnum_ratio = -0.1);
assert_invalid_quality_ratio("max_fragmented_word_ratio", |value| {
value.max_fragmented_word_ratio = 1.1;
});
assert_invalid_quality_ratio("critical_fragmented_word_ratio", |value| {
value.critical_fragmented_word_ratio = f64::NAN;
});
assert_invalid_quality_ratio("max_ocr_output_fragmented_word_ratio", |value| {
value.max_ocr_output_fragmented_word_ratio = f64::INFINITY;
});
assert_invalid_quality_ratio("min_consecutive_repeat_ratio", |value| {
value.min_consecutive_repeat_ratio = -0.1;
});
assert_invalid_quality_ratio("alnum_ws_ratio_threshold", |value| {
value.alnum_ws_ratio_threshold = 1.1;
});
assert_invalid_quality_ratio("min_undecodable_ratio", |value| {
value.min_undecodable_ratio = -0.1;
});
assert_invalid_quality_ratio("min_provenance_fallback_ratio", |value| {
value.min_provenance_fallback_ratio = 1.1;
});
}
#[test]
fn should_reject_invalid_typed_ocr_quality_scalars() {
let mut thresholds = OcrQualityThresholds {
min_non_whitespace_per_page: -0.1,
..Default::default()
};
assert!(config_with_quality_thresholds(thresholds.clone()).validate().is_err());
thresholds.min_non_whitespace_per_page = 1.0;
thresholds.min_avg_word_length = f64::INFINITY;
assert!(config_with_quality_thresholds(thresholds.clone()).validate().is_err());
thresholds.min_avg_word_length = 1.0;
thresholds.min_ocr_mean_confidence = 100.1;
assert!(config_with_quality_thresholds(thresholds.clone()).validate().is_err());
thresholds.min_ocr_mean_confidence = 50.0;
thresholds.pipeline_min_quality = f64::NAN;
assert!(config_with_quality_thresholds(thresholds).validate().is_err());
}
#[test]
fn should_validate_json_ocr_quality_thresholds_at_the_public_config_boundary() {
let invalid_json = r#"{"ocr":{"quality_thresholds":{"pipeline_min_quality":1.1}}}"#;
let invalid: crate::ExtractionConfig = serde_json::from_str(invalid_json).unwrap();
let error = invalid.validate().unwrap_err();
assert!(
error
.to_string()
.contains("ocr.quality_thresholds.pipeline_min_quality")
);
let sentinel_json = r#"{"ocr":{"quality_thresholds":{"max_ocr_output_dict_invalid_word_ratio":1.01}}}"#;
let sentinel: crate::ExtractionConfig = serde_json::from_str(sentinel_json).unwrap();
sentinel.validate().unwrap();
let negative_count = r#"{"ocr":{"quality_thresholds":{"min_meaningful_words":-1}}}"#;
assert!(serde_json::from_str::<crate::ExtractionConfig>(negative_count).is_err());
}
#[test]
fn should_reject_invalid_explicit_pipeline_quality_thresholds() {
let json = r#"{
"ocr": {
"pipeline": {
"stages": [],
"quality_thresholds": {"max_ocr_output_dict_invalid_word_ratio": 1.001}
}
}
}"#;
let config: crate::ExtractionConfig = serde_json::from_str(json).unwrap();
let error = config.validate().unwrap_err();
assert!(error.to_string().contains("max_ocr_output_dict_invalid_word_ratio"));
}
#[test]
fn test_provenance_ocr_routing_defaults_enabled_with_half_ratio() {
let thresholds = OcrQualityThresholds::default();
assert!(thresholds.enable_provenance_ocr_routing);
assert!((thresholds.min_provenance_fallback_ratio - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_provenance_ocr_routing_deserializes_overrides() {
let json = r#"{"enable_provenance_ocr_routing": false, "min_provenance_fallback_ratio": 0.75}"#;
let thresholds: OcrQualityThresholds = serde_json::from_str(json).unwrap();
assert!(!thresholds.enable_provenance_ocr_routing);
assert!((thresholds.min_provenance_fallback_ratio - 0.75).abs() < f64::EPSILON);
}
#[test]
fn test_validate_catches_invalid_pipeline_stage_backend() {
let config = OcrConfig {
pipeline: Some(OcrPipelineConfig {
stages: vec![
OcrPipelineStage {
backend: "tesseract".to_string(),
priority: 100,
language: None,
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: None,
backend_options: None,
},
OcrPipelineStage {
backend: "invalid_backend".to_string(),
priority: 50,
language: None,
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: None,
backend_options: None,
},
],
quality_thresholds: OcrQualityThresholds::default(),
}),
..Default::default()
};
let result = config.validate();
assert!(result.is_err(), "Should catch invalid backend in pipeline stages");
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Invalid OCR backend") || err_msg.contains("invalid_backend"));
}
#[test]
fn test_validate_passes_with_valid_pipeline_stages() {
let config = OcrConfig {
pipeline: Some(OcrPipelineConfig {
stages: vec![
OcrPipelineStage {
backend: "tesseract".to_string(),
priority: 100,
language: None,
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: None,
backend_options: None,
},
OcrPipelineStage {
backend: "paddleocr".to_string(),
priority: 50,
language: None,
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: None,
backend_options: None,
},
],
quality_thresholds: OcrQualityThresholds::default(),
}),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_ocr_config_backend_options_default_is_none() {
let config = OcrConfig::default();
assert!(config.backend_options.is_none());
}
#[test]
fn test_ocr_config_backend_options_serde_roundtrip() {
let config = OcrConfig {
backend_options: Some(serde_json::json!({"mode": "fast", "threshold": 0.8, "enable_layout": true})),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: OcrConfig = serde_json::from_str(&json).unwrap();
let opts = deserialized.backend_options.unwrap();
assert_eq!(opts["mode"], "fast");
assert!((opts["threshold"].as_f64().unwrap() - 0.8).abs() < f64::EPSILON);
assert_eq!(opts["enable_layout"], true);
}
#[test]
fn test_ocr_config_backend_options_omitted_when_none() {
let config = OcrConfig::default();
let json = serde_json::to_string(&config).unwrap();
assert!(
!json.contains("backend_options"),
"backend_options must be omitted when None"
);
}
#[test]
fn test_pipeline_stage_backend_options_serde_roundtrip() {
let stage = OcrPipelineStage {
backend: "custom".to_string(),
priority: 80,
language: None,
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: None,
backend_options: Some(serde_json::json!({"batch_size": 4, "device": "cpu"})),
};
let json = serde_json::to_string(&stage).unwrap();
let deserialized: OcrPipelineStage = serde_json::from_str(&json).unwrap();
let opts = deserialized.backend_options.unwrap();
assert_eq!(opts["batch_size"], 4);
assert_eq!(opts["device"], "cpu");
}
#[test]
fn test_pipeline_stage_backend_options_omitted_when_none() {
let stage = OcrPipelineStage {
backend: "tesseract".to_string(),
priority: 100,
language: None,
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: None,
backend_options: None,
};
let json = serde_json::to_string(&stage).unwrap();
assert!(
!json.contains("backend_options"),
"backend_options must be omitted when None"
);
}
#[cfg(all(feature = "ocr", paddle_ocr, feature = "pdf"))]
#[test]
fn test_effective_pipeline_propagates_backend_options_to_primary_stage() {
let config = OcrConfig {
backend_options: Some(serde_json::json!({"mode": "fast"})),
..Default::default()
};
let pipeline = config
.effective_pipeline()
.expect("paddle-ocr feature must produce a pipeline");
assert_eq!(pipeline.stages.len(), 2);
let primary = &pipeline.stages[0];
assert_eq!(primary.backend, "tesseract");
let opts = primary
.backend_options
.as_ref()
.expect("primary stage must carry backend_options");
assert_eq!(opts["mode"], "fast");
let fallback = &pipeline.stages[1];
assert_eq!(fallback.backend, "paddleocr");
assert!(
fallback.backend_options.is_none(),
"paddleocr stage must not inherit backend_options"
);
}
#[cfg(all(feature = "ocr", feature = "pdf"))]
#[test]
fn test_explicit_pipeline_ignores_top_level_backend_options() {
let config = OcrConfig {
backend_options: Some(serde_json::json!({"mode": "fast"})),
pipeline: Some(OcrPipelineConfig {
stages: vec![OcrPipelineStage {
backend: "tesseract".to_string(),
priority: 100,
language: None,
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: None,
backend_options: None,
}],
quality_thresholds: OcrQualityThresholds::default(),
}),
..Default::default()
};
let pipeline = config
.effective_pipeline()
.expect("explicit pipeline must be returned as-is");
assert_eq!(pipeline.stages.len(), 1);
assert!(
pipeline.stages[0].backend_options.is_none(),
"top-level backend_options must not be injected into an explicit pipeline stage"
);
}
#[cfg(all(feature = "ocr", feature = "pdf"))]
#[test]
fn test_stage_level_backend_options_preserved_in_explicit_pipeline() {
let stage_opts = serde_json::json!({"device": "gpu", "batch": 8});
let config = OcrConfig {
backend_options: Some(serde_json::json!({"mode": "fast"})),
pipeline: Some(OcrPipelineConfig {
stages: vec![OcrPipelineStage {
backend: "custom".to_string(),
priority: 100,
language: None,
tesseract_config: None,
paddle_ocr_config: None,
vlm_config: None,
backend_options: Some(stage_opts.clone()),
}],
quality_thresholds: OcrQualityThresholds::default(),
}),
..Default::default()
};
let pipeline = config
.effective_pipeline()
.expect("explicit pipeline must be returned as-is");
let returned_opts = pipeline.stages[0]
.backend_options
.as_ref()
.expect("stage-level backend_options must be preserved");
assert_eq!(returned_opts["device"], "gpu");
assert_eq!(returned_opts["batch"], 8);
}
}
#[test]
fn tagged_ocr_policies_reject_fields_for_unit_variants() {
let strategy = r#"{"mode":"auto","min_confidence":0.95}"#;
assert!(serde_json::from_str::<OcrStrategy>(strategy).is_err());
let fallback = r#"{"mode":"disabled","quality_threshold":0.8}"#;
assert!(serde_json::from_str::<VlmFallbackPolicy>(fallback).is_err());
}