use crate::Result;
use crate::core::config::OcrConfig;
use crate::plugins::Plugin;
use crate::types::ExtractedDocument;
use async_trait::async_trait;
use std::path::Path;
use std::sync::Arc;
#[cfg(not(feature = "tokio-runtime"))]
use crate::XbergError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
pub enum OcrBackendType {
#[default]
Tesseract,
PaddleOCR,
Candle,
Custom,
}
#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Deserialize, serde::Serialize)]
pub enum ConfidenceSemantics {
Legibility {
scale_max: f64,
},
#[default]
Uncalibrated,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
pub enum PageOrientationHandling {
SelfCorrecting,
RecognisesRotatedText,
#[default]
RequiresUpright,
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
pub trait OcrBackend: Plugin {
async fn process_image(&self, image_bytes: &[u8], config: &OcrConfig) -> Result<ExtractedDocument>;
#[cfg_attr(alef, alef(skip))]
async fn process_image_owned(&self, image_bytes: Arc<Vec<u8>>, config: &OcrConfig) -> Result<ExtractedDocument> {
self.process_image(image_bytes.as_slice(), config).await
}
async fn process_image_file(&self, path: &Path, config: &OcrConfig) -> Result<ExtractedDocument> {
#[cfg(feature = "tokio-runtime")]
{
use crate::core::io;
let bytes = io::read_file_async(path).await?;
self.process_image(&bytes, config).await
}
#[cfg(not(feature = "tokio-runtime"))]
{
let _ = (path, config);
Err(XbergError::Other(
"File-based OCR processing requires the tokio-runtime feature".to_string(),
))
}
}
fn supports_language(&self, lang: &str) -> bool;
fn backend_type(&self) -> OcrBackendType;
fn supported_languages(&self) -> Vec<String> {
vec![]
}
fn supports_table_detection(&self) -> bool {
false
}
fn supports_document_processing(&self) -> bool {
false
}
fn emits_structured_markdown(&self) -> bool {
false
}
fn confidence_semantics(&self) -> ConfidenceSemantics {
ConfidenceSemantics::Uncalibrated
}
fn page_orientation_handling(&self) -> PageOrientationHandling {
PageOrientationHandling::RequiresUpright
}
async fn process_document(&self, _path: &Path, _config: &OcrConfig) -> Result<ExtractedDocument> {
Err(crate::XbergError::Other(
"Document-level OCR processing not supported by this backend".to_string(),
))
}
#[cfg_attr(alef, alef(skip))]
fn probe(&self, _config: &OcrConfig) -> crate::doctor::DoctorCheck {
crate::doctor::DoctorCheck::skip(self.name(), "no probe implemented for this backend")
}
}
#[cfg_attr(alef, alef(skip))]
pub fn register_ocr_backend(backend: Arc<dyn OcrBackend>) -> crate::Result<()> {
use crate::plugins::registry::get_ocr_backend_registry;
let registry = get_ocr_backend_registry();
let mut registry = registry.write();
registry.register(backend)
}
#[cfg_attr(alef, alef(skip))]
pub fn unregister_ocr_backend(name: &str) -> crate::Result<()> {
use crate::plugins::registry::get_ocr_backend_registry;
let registry = get_ocr_backend_registry();
let mut registry = registry.write();
registry.remove(name)
}
pub fn list_ocr_backends() -> crate::Result<Vec<String>> {
use crate::plugins::registry::get_ocr_backend_registry;
let registry = get_ocr_backend_registry();
let registry = registry.read();
Ok(registry.list())
}
pub fn clear_ocr_backends() -> crate::Result<()> {
use crate::plugins::registry::get_ocr_backend_registry;
let registry = get_ocr_backend_registry();
let mut registry = registry.write();
registry.shutdown_all()
}
#[cfg(any(feature = "ocr", feature = "ocr-wasm", feature = "ocr-pipeline"))]
pub(crate) fn ensure_ocr_backends_initialized() {
use crate::plugins::registry::get_ocr_backend_registry;
let registry = get_ocr_backend_registry();
{
let registry = registry.read();
if !registry.is_missing_default_backend() {
return;
}
}
registry.write().ensure_defaults();
}
#[cfg(test)]
mod tests {
use super::*;
use std::borrow::Cow;
struct MockOcrBackend {
languages: Vec<String>,
}
impl Plugin for MockOcrBackend {
fn name(&self) -> &str {
"mock-ocr"
}
fn version(&self) -> String {
"1.0.0".to_string()
}
fn initialize(&self) -> Result<()> {
Ok(())
}
fn shutdown(&self) -> Result<()> {
Ok(())
}
}
#[async_trait]
impl OcrBackend for MockOcrBackend {
async fn process_image(&self, _image_bytes: &[u8], _config: &OcrConfig) -> Result<ExtractedDocument> {
Ok(ExtractedDocument {
content: "Mocked OCR text".to_string(),
mime_type: Cow::Borrowed("text/plain"),
..Default::default()
})
}
fn supports_language(&self, lang: &str) -> bool {
self.languages.iter().any(|l| l == lang)
}
fn backend_type(&self) -> OcrBackendType {
OcrBackendType::Custom
}
fn supported_languages(&self) -> Vec<String> {
self.languages.clone()
}
}
#[tokio::test]
async fn test_ocr_backend_process_image() {
let backend = MockOcrBackend {
languages: vec!["eng".to_string(), "deu".to_string()],
};
let config = OcrConfig {
backend: "mock".to_string(),
language: vec!["eng".to_string()],
..Default::default()
};
let result = backend.process_image(b"fake image data", &config).await.unwrap();
assert_eq!(result.content, "Mocked OCR text");
assert_eq!(result.mime_type, "text/plain");
}
#[tokio::test]
async fn test_ocr_backend_process_image_owned_default_impl_is_object_safe() {
let backend: Arc<dyn OcrBackend> = Arc::new(MockOcrBackend {
languages: vec!["eng".to_string()],
});
let result = backend
.process_image_owned(Arc::new(b"fake image data".to_vec()), &OcrConfig::default())
.await
.unwrap();
assert_eq!(result.content, "Mocked OCR text");
assert_eq!(result.mime_type, "text/plain");
}
#[test]
fn test_ocr_backend_supports_language() {
let backend = MockOcrBackend {
languages: vec!["eng".to_string(), "deu".to_string()],
};
assert!(backend.supports_language("eng"));
assert!(backend.supports_language("deu"));
assert!(!backend.supports_language("fra"));
}
#[test]
fn test_ocr_backend_type() {
let backend = MockOcrBackend {
languages: vec!["eng".to_string()],
};
assert_eq!(backend.backend_type(), OcrBackendType::Custom);
}
#[test]
fn test_ocr_backend_supported_languages() {
let backend = MockOcrBackend {
languages: vec!["eng".to_string(), "deu".to_string(), "fra".to_string()],
};
let supported = backend.supported_languages();
assert_eq!(supported.len(), 3);
assert!(supported.contains(&"eng".to_string()));
assert!(supported.contains(&"deu".to_string()));
assert!(supported.contains(&"fra".to_string()));
}
#[test]
fn test_ocr_backend_type_variants() {
assert_eq!(OcrBackendType::Tesseract, OcrBackendType::Tesseract);
assert_ne!(OcrBackendType::Tesseract, OcrBackendType::PaddleOCR);
assert_ne!(OcrBackendType::PaddleOCR, OcrBackendType::Custom);
}
#[test]
fn test_ocr_backend_type_debug() {
let backend_type = OcrBackendType::Tesseract;
let debug_str = format!("{:?}", backend_type);
assert!(debug_str.contains("Tesseract"));
}
#[test]
fn test_ocr_backend_type_clone() {
let backend_type = OcrBackendType::PaddleOCR;
let cloned = backend_type;
assert_eq!(backend_type, cloned);
}
#[test]
fn test_ocr_backend_default_table_detection() {
let backend = MockOcrBackend {
languages: vec!["eng".to_string()],
};
assert!(!backend.supports_table_detection());
}
#[test]
fn should_default_to_uncalibrated_for_a_backend_that_does_not_declare_semantics() {
let backend = MockOcrBackend {
languages: vec!["eng".to_string()],
};
assert_eq!(backend.confidence_semantics(), ConfidenceSemantics::Uncalibrated);
}
#[test]
fn should_report_declared_semantics_through_a_trait_object() {
struct CalibratedBackend;
impl Plugin for CalibratedBackend {
fn name(&self) -> &str {
"calibrated"
}
fn version(&self) -> String {
"1.0.0".to_string()
}
fn initialize(&self) -> Result<()> {
Ok(())
}
fn shutdown(&self) -> Result<()> {
Ok(())
}
}
#[async_trait]
impl OcrBackend for CalibratedBackend {
async fn process_image(&self, _image_bytes: &[u8], _config: &OcrConfig) -> Result<ExtractedDocument> {
unreachable!("this backend exists only to declare confidence semantics")
}
fn backend_type(&self) -> OcrBackendType {
OcrBackendType::Custom
}
fn supports_language(&self, lang: &str) -> bool {
lang == "eng"
}
fn supported_languages(&self) -> Vec<String> {
vec!["eng".to_string()]
}
fn confidence_semantics(&self) -> ConfidenceSemantics {
ConfidenceSemantics::Legibility { scale_max: 255.0 }
}
}
let backend: &dyn OcrBackend = &CalibratedBackend;
match backend.confidence_semantics() {
ConfidenceSemantics::Legibility { scale_max } => assert_eq!(scale_max, 255.0),
other => panic!("expected the declared Legibility semantics, got {other:?}"),
}
}
#[test]
fn should_default_to_requires_upright_for_a_backend_that_does_not_declare_orientation_handling() {
let backend = MockOcrBackend {
languages: vec!["eng".to_string()],
};
let dynamic: &dyn OcrBackend = &backend;
assert_eq!(
dynamic.page_orientation_handling(),
PageOrientationHandling::RequiresUpright
);
}
#[cfg(feature = "tokio-runtime")]
#[tokio::test]
async fn test_ocr_backend_process_image_file_default_impl() {
use std::io::Write;
use tempfile::NamedTempFile;
let backend = MockOcrBackend {
languages: vec!["eng".to_string()],
};
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(b"fake image data").unwrap();
let path = temp_file.path();
let config = OcrConfig {
backend: "mock".to_string(),
language: vec!["eng".to_string()],
..Default::default()
};
let result = backend.process_image_file(path, &config).await.unwrap();
assert_eq!(result.content, "Mocked OCR text");
}
#[test]
fn test_ocr_backend_plugin_interface() {
let backend = MockOcrBackend {
languages: vec!["eng".to_string()],
};
assert_eq!(backend.name(), "mock-ocr");
assert_eq!(backend.version(), "1.0.0");
assert!(backend.initialize().is_ok());
assert!(backend.shutdown().is_ok());
}
#[test]
fn test_ocr_backend_empty_languages() {
let backend = MockOcrBackend { languages: vec![] };
let supported = backend.supported_languages();
assert_eq!(supported.len(), 0);
assert!(!backend.supports_language("eng"));
}
#[tokio::test]
async fn test_ocr_backend_with_empty_image() {
let backend = MockOcrBackend {
languages: vec!["eng".to_string()],
};
let config = OcrConfig {
backend: "mock".to_string(),
language: vec!["eng".to_string()],
..Default::default()
};
let result = backend.process_image(b"", &config).await;
assert!(result.is_ok());
}
struct OptionAwareBackend;
impl Plugin for OptionAwareBackend {
fn name(&self) -> &str {
"option-aware"
}
fn version(&self) -> String {
"1.0.0".to_string()
}
fn initialize(&self) -> Result<()> {
Ok(())
}
fn shutdown(&self) -> Result<()> {
Ok(())
}
}
#[async_trait]
impl OcrBackend for OptionAwareBackend {
async fn process_image(&self, _image_bytes: &[u8], config: &OcrConfig) -> Result<ExtractedDocument> {
let mode = config
.backend_options
.as_ref()
.and_then(|v| v.get("mode"))
.and_then(|v| v.as_str())
.unwrap_or("standard");
Ok(ExtractedDocument {
content: format!("mode={mode}"),
mime_type: Cow::Borrowed("text/plain"),
..Default::default()
})
}
fn supports_language(&self, _: &str) -> bool {
true
}
fn backend_type(&self) -> OcrBackendType {
OcrBackendType::Custom
}
}
#[tokio::test]
async fn test_backend_reads_backend_options() {
let backend = OptionAwareBackend;
let config_with_options = OcrConfig {
backend_options: Some(serde_json::json!({"mode": "fast", "threshold": 0.8})),
..Default::default()
};
let result = backend.process_image(b"img", &config_with_options).await.unwrap();
assert_eq!(result.content, "mode=fast");
let config_without_options = OcrConfig::default();
let result = backend.process_image(b"img", &config_without_options).await.unwrap();
assert_eq!(result.content, "mode=standard");
}
#[tokio::test]
async fn test_backend_options_unknown_keys_silently_ignored() {
let backend = OptionAwareBackend;
let config = OcrConfig {
backend_options: Some(serde_json::json!({
"unknown_key": "value",
"another_unknown": 42
})),
..Default::default()
};
let result = backend.process_image(b"img", &config).await;
assert!(result.is_ok(), "unknown backend_options keys must not cause errors");
}
}