#[cfg(feature = "ocr-surface")]
use anyhow::Context as _;
#[cfg(feature = "ocr-surface")]
use anyhow::{Result, bail};
#[cfg(feature = "ocr-surface")]
use xberg::{ExtractionConfig, LlmConfig, OcrConfig};
use super::ExtractionOverrides;
#[cfg(feature = "ocr-surface")]
const VALID_OCR_BACKENDS: &[&str] = &[
"tesseract",
"paddle-ocr",
"sceptre",
"vlm",
"candle-trocr",
"candle-paddleocr-vl",
"candle-glm-ocr",
"candle-deepseek-ocr",
];
#[cfg(feature = "ocr-surface")]
pub(super) const DEFAULT_OCR_LANGUAGE: &str = "eng";
#[cfg(feature = "ocr-surface")]
pub(super) const DEFAULT_PADDLE_OCR_LANGUAGE: &str = "en";
#[cfg(feature = "ocr-surface")]
const PADDLE_LANGUAGE_BACKENDS: &[&str] = &[
"paddle-ocr",
"candle-paddleocr-vl",
"candle-glm-ocr",
"candle-deepseek-ocr",
];
impl ExtractionOverrides {
#[cfg(feature = "ocr-surface")]
pub(super) fn validate_ocr(&self) -> Result<()> {
if self.ocr == Some(false) && self.ocr_scanned_pages {
bail!("--ocr false cannot be combined with --ocr-scanned-pages");
}
if self.ocr == Some(false) && self.force_ocr == Some(true) {
bail!("--ocr false cannot be combined with --force-ocr true");
}
if let (Some(ocr), Some(disable_ocr)) = (self.ocr, self.disable_ocr)
&& ocr == disable_ocr
{
bail!("--ocr and --disable-ocr specify contradictory values");
}
if self.ocr_scanned_pages && self.disable_ocr == Some(true) {
bail!("--ocr-scanned-pages cannot be combined with --disable-ocr");
}
if let Some(confidence) = self.scanned_min_confidence
&& !(0.0..=1.0).contains(&confidence)
{
bail!("Invalid scan confidence: {confidence}. Value must be between 0.0 and 1.0.");
}
if self.force_ocr == Some(true) && self.disable_ocr == Some(true) {
bail!("--force-ocr and --disable-ocr cannot both be true");
}
if let Some(ref backend) = self.ocr_backend
&& !VALID_OCR_BACKENDS.contains(&backend.as_str())
{
bail!(
"Invalid OCR backend '{}'. Valid backends: {}",
backend,
VALID_OCR_BACKENDS.join(", ")
);
}
self.parsed_backend_options()?;
self.validate_vlm_model_required()?;
Ok(())
}
#[cfg(feature = "ocr-surface")]
fn validate_vlm_model_required(&self) -> Result<()> {
if self.vlm_api_key.is_some() && self.vlm_model.is_none() {
bail!("--vlm-api-key requires --vlm-model to be specified");
}
if self.vlm_prompt.is_some() && self.vlm_model.is_none() {
bail!("--vlm-prompt requires --vlm-model to be specified");
}
if self.ocr_backend.as_deref() == Some("vlm") && self.vlm_model.is_none() {
bail!("--ocr-backend vlm requires --vlm-model to be specified");
}
Ok(())
}
#[cfg(feature = "ocr-surface")]
pub(super) fn apply_ocr(&self, config: &mut ExtractionConfig) {
if self.ocr == Some(false) {
config.ocr = None;
config.disable_ocr = true;
config.force_ocr = false;
config.ocr_strategy = xberg::OcrStrategy::Auto;
config.force_ocr_pages = None;
} else {
if self.ocr == Some(true) {
config.ocr.get_or_insert_with(OcrConfig::default).enabled = true;
config.disable_ocr = false;
} else if self.has_ocr_field_flag() {
config.ocr.get_or_insert_with(OcrConfig::default);
}
if let Some(ocr) = config.ocr.as_mut() {
self.apply_ocr_fields(ocr);
}
}
if self.ocr != Some(false)
&& let Some(force_ocr_flag) = self.force_ocr
{
config.force_ocr = force_ocr_flag;
}
if self.ocr.is_none()
&& let Some(disable_ocr_flag) = self.disable_ocr
{
config.disable_ocr = disable_ocr_flag;
}
if self.ocr != Some(false) && self.ocr_scanned_pages {
config.ocr_strategy = xberg::OcrStrategy::ScannedPages {
min_confidence: self
.scanned_min_confidence
.unwrap_or(xberg::core::config::DEFAULT_SCANNED_MIN_CONFIDENCE),
};
config.pages.get_or_insert_with(Default::default);
}
}
#[cfg(feature = "ocr-surface")]
fn has_ocr_field_flag(&self) -> bool {
self.ocr_backend.is_some()
|| self.ocr_backend_options.is_some()
|| self.ocr_auto_rotate.is_some()
|| self.ocr_language.is_some()
}
#[cfg(feature = "ocr-surface")]
fn apply_ocr_fields(&self, ocr: &mut OcrConfig) {
if let Some(ref backend) = self.ocr_backend {
ocr.backend = backend.clone();
}
if let Some(options) = self.parsed_backend_options().ok().flatten() {
ocr.backend_options = Some(options);
}
if let Some(rotate) = self.ocr_auto_rotate {
ocr.auto_rotate = rotate;
}
if let Some(no_cache) = self.ocr_no_cache {
apply_ocr_no_cache(ocr, no_cache);
}
if let Some(ref language) = self.ocr_language {
set_ocr_language(ocr, vec![language.clone()]);
return;
}
let backend_selected = self.ocr == Some(true) || self.ocr_backend.is_some();
let backend_default = default_language_for_backend(&ocr.backend);
if backend_selected && backend_default != DEFAULT_OCR_LANGUAGE && is_default_ocr_language(&ocr.language) {
ocr.language = vec![backend_default.to_string()];
}
}
#[cfg(feature = "ocr-surface")]
pub(super) fn apply_vlm_ocr(&self, config: &mut ExtractionConfig) {
if let Some(ref vlm_model) = self.vlm_model {
let vlm_llm_config = LlmConfig {
model: vlm_model.clone(),
api_key: self.vlm_api_key.clone(),
..Default::default()
};
let backend_options = self.parsed_backend_options().ok().flatten();
let ocr = config.ocr.get_or_insert_with(|| OcrConfig {
enabled: true,
backend: "vlm".to_string(),
language: vec!["eng".to_string()],
tesseract_config: None,
output_format: None,
paddle_ocr_config: None,
element_config: None,
quality_thresholds: None,
pipeline: None,
auto_rotate: false,
vlm_config: None,
vlm_fallback: Default::default(),
vlm_prompt: None,
acceleration: None,
security_limits: None,
tessdata_bytes: None,
tessdata_path: None,
backend_options,
});
ocr.backend = "vlm".to_string();
ocr.vlm_config = Some(vlm_llm_config);
if let Some(ref prompt) = self.vlm_prompt {
ocr.vlm_prompt = Some(prompt.clone());
}
}
}
#[cfg(feature = "ocr-surface")]
fn parsed_backend_options(&self) -> Result<Option<serde_json::Value>> {
let Some(ref s) = self.ocr_backend_options else {
return Ok(None);
};
let value: serde_json::Value =
serde_json::from_str(s).with_context(|| format!("invalid --ocr-backend-options JSON: {s}"))?;
if !value.is_object() {
bail!("--ocr-backend-options must be a JSON object");
}
Ok(Some(value))
}
}
#[cfg(feature = "ocr-surface")]
pub(super) fn default_language_for_backend(backend: &str) -> &'static str {
if PADDLE_LANGUAGE_BACKENDS.contains(&backend) {
DEFAULT_PADDLE_OCR_LANGUAGE
} else {
DEFAULT_OCR_LANGUAGE
}
}
#[cfg(feature = "ocr-surface")]
fn is_default_ocr_language(language: &[String]) -> bool {
matches!(language, [only] if only == DEFAULT_OCR_LANGUAGE)
}
#[cfg(feature = "ocr-surface")]
fn apply_ocr_no_cache(ocr: &mut OcrConfig, no_cache: bool) {
let Some(tesseract_config) = ocr.tesseract_config.as_mut() else {
tracing::warn!(
"--ocr-no-cache has no effect: no `tesseract_config` is set yet (e.g. via a \
config file's `ocr.tesseract_config`). Materialising one just to carry \
`use_cache: false` would also silently change Tesseract's PSM and other \
defaults for this run (see issue #693), so this flag is a no-op here instead \
of risking that. Clear the on-disk OCR cache directory instead, or set \
`ocr.tesseract_config` explicitly before using --ocr-no-cache."
);
return;
};
tesseract_config.use_cache = !no_cache;
}
#[cfg(feature = "ocr-surface")]
fn set_ocr_language(ocr: &mut OcrConfig, language: Vec<String>) {
ocr.language = language.clone();
if let Some(tesseract_config) = ocr.tesseract_config.as_mut() {
tesseract_config.language = language.clone();
}
if let Some(pipeline) = ocr.pipeline.as_mut() {
for stage in &mut pipeline.stages {
if stage.backend != "tesseract" {
continue;
}
stage.language = Some(language.clone());
if let Some(tesseract_config) = stage.tesseract_config.as_mut() {
tesseract_config.language = language.clone();
}
}
}
}