clipmem 0.3.1

macOS clipboard memory backed by SQLite and searchable from OpenClaw
Documentation
use anyhow::{Context, Result};
use objc2::runtime::AnyObject;
use objc2::{rc::Retained, AnyThread};
use objc2_foundation::{NSArray, NSData, NSDictionary};
use objc2_vision::{
    VNImageOption, VNImageRequestHandler, VNRecognizeTextRequest, VNRequest,
    VNRequestTextRecognitionLevel,
};

use super::OcrEngine;

pub(crate) struct VisionOcrEngine;

impl OcrEngine for VisionOcrEngine {
    fn engine_name(&self) -> &'static str {
        "apple_vision"
    }

    fn recognition_level(&self) -> &'static str {
        "fast"
    }

    fn recognize_text(&self, image_bytes: &[u8]) -> Result<String> {
        objc2::rc::autoreleasepool(|_| recognize_text_with_vision(image_bytes))
    }
}

fn recognize_text_with_vision(image_bytes: &[u8]) -> Result<String> {
    let image_data = NSData::with_bytes(image_bytes);
    let options = NSDictionary::<VNImageOption, AnyObject>::new();
    let handler = VNImageRequestHandler::initWithData_options(
        VNImageRequestHandler::alloc(),
        &image_data,
        &options,
    );

    let request = VNRecognizeTextRequest::new();
    request.setRecognitionLevel(VNRequestTextRecognitionLevel::Fast);
    request.setUsesLanguageCorrection(true);
    request.setAutomaticallyDetectsLanguage(true);

    let request_for_array: Retained<VNRequest> = request.clone().into_super().into_super();
    let requests = NSArray::from_retained_slice(&[request_for_array]);
    handler
        .performRequests_error(&requests)
        .map_err(|error| anyhow::anyhow!("{}", error.localizedDescription()))?;

    let observations = request
        .results()
        .context("Vision returned no text-recognition results")?;
    let mut lines = Vec::new();
    for idx in 0..observations.len() {
        let observation = observations.objectAtIndex(idx);
        let candidates = observation.topCandidates(1);
        if candidates.len() == 0 {
            continue;
        }
        let candidate = candidates.objectAtIndex(0);
        let text = candidate.string().to_string();
        let trimmed = text.trim();
        if !trimmed.is_empty() {
            lines.push(trimmed.to_string());
        }
    }

    Ok(lines.join("\n"))
}

#[cfg(test)]
mod tests {
    use super::VisionOcrEngine;
    use crate::ocr::OcrEngine;

    #[test]
    #[ignore = "requires the macOS Vision runtime"]
    fn vision_ocr_smoke_test_accepts_png_bytes() -> anyhow::Result<()> {
        let png = [
            0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48,
            0x44, 0x52, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x08, 0x06, 0x00, 0x00,
            0x00, 0xc4, 0x0f, 0xbe, 0x8b, 0x00, 0x00, 0x00, 0x0f, 0x49, 0x44, 0x41, 0x54, 0x78,
            0x9c, 0x63, 0xf8, 0x4f, 0x00, 0x30, 0x8c, 0x0c, 0x05, 0x00, 0x84, 0xb5, 0xff, 0x01,
            0x74, 0x42, 0xd9, 0x80, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42,
            0x60, 0x82,
        ];

        let _ = VisionOcrEngine.recognize_text(&png)?;
        Ok(())
    }
}