use async_trait::async_trait;
use thiserror::Error;
#[cfg(target_os = "macos")]
pub mod apple_vision;
pub mod fetch_integration;
#[cfg(not(target_os = "macos"))]
pub mod stub;
#[cfg(target_os = "macos")]
pub use apple_vision::AppleVisionEngine;
#[cfg(not(target_os = "macos"))]
pub use stub::StubEngine;
#[derive(Debug, Error)]
pub enum OcrError {
#[error("OCR engine '{0}' is not available: {1}")]
NotAvailable(String, String),
#[error("failed to decode image: {0}")]
ImageDecode(String),
#[error("OCR framework error: {0}")]
Framework(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone)]
pub struct OcrRegion {
pub text: String,
pub bounding_box: [f32; 4],
pub confidence: f32,
}
#[derive(Debug, Clone)]
pub struct OcrResult {
pub text: String,
pub language: Option<String>,
pub confidence: f32,
pub regions: Vec<OcrRegion>,
}
#[async_trait]
pub trait OcrEngine: Send + Sync {
fn name(&self) -> &'static str;
fn supported_languages(&self) -> &'static [&'static str];
fn is_available(&self) -> bool;
async fn ocr_image(&self, image_bytes: &[u8]) -> Result<OcrResult, OcrError>;
}
pub fn default_engine() -> Box<dyn OcrEngine> {
#[cfg(target_os = "macos")]
{
Box::new(AppleVisionEngine::new())
}
#[cfg(not(target_os = "macos"))]
{
Box::new(StubEngine)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_engine_has_non_empty_name() {
let engine = default_engine();
assert!(!engine.name().is_empty());
}
#[test]
fn available_default_engine_lists_supported_languages() {
let engine = default_engine();
if engine.is_available() {
assert!(!engine.supported_languages().is_empty());
}
}
#[test]
#[cfg(target_os = "macos")]
fn default_engine_is_apple_vision_on_macos() {
let engine = default_engine();
assert_eq!(engine.name(), "apple_vision");
}
#[test]
#[cfg(not(target_os = "macos"))]
fn default_engine_is_stub_on_non_macos() {
let engine = default_engine();
assert_eq!(engine.name(), "stub");
}
#[test]
fn ocr_result_fields_are_accessible() {
let result = OcrResult {
text: "Hello World".to_string(),
language: Some("en".to_string()),
confidence: 0.95,
regions: vec![OcrRegion {
text: "Hello World".to_string(),
bounding_box: [0.0, 0.0, 1.0, 1.0],
confidence: 0.95,
}],
};
assert_eq!(result.text, "Hello World");
assert_eq!(result.language.as_deref(), Some("en"));
assert!((result.confidence - 0.95).abs() < f32::EPSILON);
assert_eq!(result.regions.len(), 1);
assert_eq!(result.regions[0].bounding_box, [0.0, 0.0, 1.0, 1.0]);
}
#[test]
fn ocr_error_not_available_formats() {
let err = OcrError::NotAvailable("apple_vision".to_string(), "no macOS".to_string());
let msg = err.to_string();
assert!(msg.contains("apple_vision"), "msg: {msg}");
assert!(msg.contains("no macOS"), "msg: {msg}");
}
#[test]
fn ocr_error_image_decode_formats() {
let err = OcrError::ImageDecode("invalid PNG header".to_string());
assert!(err.to_string().contains("invalid PNG"));
}
}