use serde::{Deserialize, Deserializer, Serialize};
use std::path::PathBuf;
use super::formats::OutputFormat;
#[cfg(test)]
use crate::core::config_validation::validate_ocr_backend;
#[cfg(test)]
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)]
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_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,
}
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_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_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)]
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>,
}
fn default_priority() -> u32 {
100
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OcrPipelineConfig {
pub stages: Vec<OcrPipelineStage>,
#[serde(default)]
pub quality_thresholds: OcrQualityThresholds,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum VlmFallbackPolicy {
#[default]
Disabled,
OnLowQuality {
quality_threshold: f64,
},
Always,
}
pub const DEFAULT_SCANNED_MIN_CONFIDENCE: f64 = 0.70;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum OcrStrategy {
#[default]
Auto,
ScannedPages {
min_confidence: f64,
},
}
impl OcrStrategy {
#[must_use]
pub fn effective_min_confidence(&self) -> f64 {
match self {
Self::Auto => DEFAULT_SCANNED_MIN_CONFIDENCE,
Self::ScannedPages { min_confidence } => min_confidence.clamp(0.0, 1.0),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
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 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,
tessdata_bytes: None,
tessdata_path: None,
}
}
}
impl OcrConfig {
#[cfg(test)]
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())?;
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())?;
}
} 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 = "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(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(feature = "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(feature = "paddle-ocr"))]
{
None
}
}
}
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::*;
#[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()]));
}
#[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(feature = "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(feature = "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 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,);
}
#[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());
}
#[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());
}
#[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);
}
#[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", feature = "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);
}
}