Skip to main content

computer_use/vision/
ocr.rs

1//! OCR backend selector.
2//!
3//! macOS uses `VNRecognizeTextRequest` (accurate, language correction on).
4//! Windows and Linux return `Unsupported` until P12 (Windows) and later
5//! phases. The mock backend short-circuits to an in-memory provider.
6
7use crate::error::ComputerError;
8use crate::image_util::ScreenshotMap;
9use image::RgbaImage;
10
11/// One recognized text line with image-pixel coordinates.
12#[derive(Debug, Clone, PartialEq)]
13pub struct OcrBlock {
14    /// Recognized text. Never logged (see [`crate::audit`]).
15    pub text: String,
16    /// Top-left in image pixels.
17    pub x: i32,
18    /// Top-left in image pixels.
19    pub y: i32,
20    /// Width in image pixels.
21    pub width: u32,
22    /// Height in image pixels.
23    pub height: u32,
24    /// Confidence in `[0, 1]`. Vision returns `0` when the engine cannot say.
25    pub confidence: f32,
26}
27
28/// Result of one OCR pass on a captured window.
29#[derive(Debug, Clone, Default, PartialEq)]
30pub struct OcrResult {
31    /// Empty list = success with no detected text.
32    pub blocks: Vec<OcrBlock>,
33    /// Warning to surface on the JSON response (e.g. language fallback).
34    pub note: Option<String>,
35}
36
37/// Run OCR on `image` and return per-line blocks.
38///
39/// macOS uses `VNRecognizeTextRequest`, Windows uses
40/// `Windows.Media.Ocr.OcrEngine` over the user's profile languages, and Linux
41/// returns `Unsupported` (no engine in 0.2.0).
42pub fn recognize(image: &RgbaImage, _map: &ScreenshotMap) -> Result<OcrResult, ComputerError> {
43    #[cfg(target_os = "macos")]
44    {
45        macos::recognize(image)
46    }
47    #[cfg(target_os = "windows")]
48    {
49        windows::recognize(image)
50    }
51    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
52    {
53        let _ = image;
54        Err(unsupported_on_this_os())
55    }
56}
57
58/// Image-pixel rectangle (top-left origin), used by both the recognizer and
59/// the secure-field redaction logic.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct ImageRect {
62    /// Left edge.
63    pub x: i32,
64    /// Top edge.
65    pub y: i32,
66    /// Width in pixels.
67    pub width: u32,
68    /// Height in pixels.
69    pub height: u32,
70}
71
72impl ImageRect {
73    /// Whether `other` is fully inside `self`. Touching edges count as inside.
74    pub fn contains(&self, other: &ImageRect) -> bool {
75        let other_right = other.x.saturating_add(other.width as i32);
76        let other_bottom = other.y.saturating_add(other.height as i32);
77        let self_right = self.x.saturating_add(self.width as i32);
78        let self_bottom = self.y.saturating_add(self.height as i32);
79        other.x >= self.x
80            && other.y >= self.y
81            && other_right <= self_right
82            && other_bottom <= self_bottom
83    }
84
85    /// Translate to screen points using the screenshot map. Returns `None` when
86    /// the box would land outside the captured region.
87    pub fn to_screen(&self, map: &ScreenshotMap) -> Option<(f64, f64)> {
88        let center_x = self.x as f64 + (self.width as f64) / 2.0;
89        let center_y = self.y as f64 + (self.height as f64) / 2.0;
90        let content_w = f64::from(map.content_width.max(1));
91        let content_h = f64::from(map.content_height.max(1));
92        let pad_x = map.pad.x as f64;
93        let pad_y = map.pad.y as f64;
94        if center_x < pad_x || center_y < pad_y {
95            return None;
96        }
97        if center_x > pad_x + content_w || center_y > pad_y + content_h {
98            return None;
99        }
100        let logical_w = f64::from(map.logical_width.max(1));
101        let logical_h = f64::from(map.logical_height.max(1));
102        let screen_x = map.origin.x as f64 + (center_x - pad_x) * logical_w / content_w;
103        let screen_y = map.origin.y as f64 + (center_y - pad_y) * logical_h / content_h;
104        Some((screen_x, screen_y))
105    }
106}
107
108/// Drop every OCR block whose rect falls inside any secure-field frame.
109pub fn redact_blocks<I, R>(blocks: I, secure_frames: R) -> Vec<OcrBlock>
110where
111    I: IntoIterator<Item = OcrBlock>,
112    R: IntoIterator<Item = ImageRect>,
113{
114    let frames: Vec<ImageRect> = secure_frames.into_iter().collect();
115    if frames.is_empty() {
116        return blocks.into_iter().collect();
117    }
118    blocks
119        .into_iter()
120        .filter(|block| {
121            let image_rect = ImageRect {
122                x: block.x,
123                y: block.y,
124                width: block.width,
125                height: block.height,
126            };
127            !frames.iter().any(|frame| frame.contains(&image_rect))
128        })
129        .collect()
130}
131
132/// `computer_error::Unsupported("ocr is not available on linux")` etc. Lives
133/// here so the `unsupported` branch has one source of truth.
134#[cfg_attr(any(target_os = "macos", target_os = "windows"), allow(dead_code))]
135fn unsupported_on_this_os() -> ComputerError {
136    #[cfg(target_os = "linux")]
137    {
138        ComputerError::Unsupported(
139            "no OCR backend on Linux in 0.2.0 — OCR returns unsupported".into(),
140        )
141    }
142    #[cfg(not(target_os = "linux"))]
143    {
144        ComputerError::Unsupported("ocr backend missing on this platform".into())
145    }
146}
147
148/// Convert a Windows.Media.Ocr bounding box to an [`ImageRect`].
149///
150/// `OcrWord::BoundingRect` already reports device-independent image pixels with
151/// a **top-left** origin, so unlike Vision there is no flip — only clamping to
152/// the image and a minimum of one pixel. Kept platform-independent so the
153/// conversion is unit-tested on every runner, not only in the Windows job.
154#[cfg_attr(all(not(target_os = "windows"), not(test)), allow(dead_code))]
155pub(crate) fn ocr_box_to_image(
156    image_width: u32,
157    image_height: u32,
158    x: f64,
159    y: f64,
160    width: f64,
161    height: f64,
162) -> ImageRect {
163    let max_x = image_width as f64;
164    let max_y = image_height as f64;
165    let left = x.round().clamp(0.0, max_x) as i32;
166    let top = y.round().clamp(0.0, max_y) as i32;
167    let right = (x + width).round().clamp(f64::from(left), max_x) as i32;
168    let bottom = (y + height).round().clamp(f64::from(top), max_y) as i32;
169    ImageRect {
170        x: left,
171        y: top,
172        width: (right - left).max(1) as u32,
173        height: (bottom - top).max(1) as u32,
174    }
175}
176
177/// Convert a Vision normalized rect (origin bottom-left) to image pixels.
178#[cfg(target_os = "macos")]
179pub(crate) fn vision_box_to_image(image: &RgbaImage, rect: VisionRect) -> ImageRect {
180    let img_w = image.width() as f64;
181    let img_h = image.height() as f64;
182    let left = (rect.left.clamp(0.0, 1.0) * img_w).round() as i32;
183    let right = (rect.right.clamp(0.0, 1.0) * img_w).round() as i32;
184    let top_v = rect.top.clamp(0.0, 1.0);
185    let bot_v = rect.bottom.clamp(0.0, 1.0);
186    let flipped_top = (1.0 - bot_v) * img_h;
187    let flipped_bottom = (1.0 - top_v) * img_h;
188    let top = flipped_top.round().min(flipped_bottom.round()) as i32;
189    let bottom = flipped_top.round().max(flipped_bottom.round()) as i32;
190    let img_h_i = image.height() as i32;
191    ImageRect {
192        x: left.max(0),
193        y: top.max(0).min(img_h_i),
194        width: (right - left).max(1) as u32,
195        height: (bottom - top).max(1) as u32,
196    }
197}
198
199/// A Vision normalized rect, normalized in `[0, 1]`, **origin bottom-left**.
200#[cfg(target_os = "macos")]
201#[derive(Debug, Clone, Copy, PartialEq)]
202pub(crate) struct VisionRect {
203    pub left: f64,
204    pub top: f64,
205    pub right: f64,
206    pub bottom: f64,
207}
208
209// ---------------------------------------------------------------------------
210// macOS implementation
211// ---------------------------------------------------------------------------
212//
213// Real `VNRecognizeTextRequest` call through `objc2-vision` 0.3.2. Pipeline:
214// encode the captured RGBA window as PNG → `NSData` →
215// `VNImageRequestHandler::initWithData_options` → run a `VNRecognizeTextRequest`
216// (accurate + language correction on) → walk `VNRecognizedTextObservation`
217// results → top candidate text + normalized bounding box → flip the box
218// (Vision origin is bottom-left) into image pixels → `OcrBlock`.
219//
220// Only `boundingBox` requires `unsafe`; all other Vision methods in this call
221// graph are safe per the `objc2-vision` Rust bindings. The `#[allow(unsafe_code)]`
222// keeps the audit surface tight.
223
224#[cfg(target_os = "macos")]
225mod macos {
226    use super::{vision_box_to_image, OcrBlock, OcrResult, VisionRect};
227    use crate::error::ComputerError;
228    use image::{ExtendedColorType, ImageEncoder, RgbaImage};
229    use objc2::rc::Retained;
230    use objc2::runtime::AnyObject;
231    use objc2::AnyThread;
232    use objc2_foundation::{NSArray, NSData, NSDictionary, NSString};
233    use objc2_vision::{
234        VNDetectedObjectObservation, VNImageRequestHandler, VNRecognizeTextRequest,
235        VNRecognizedText, VNRecognizedTextObservation, VNRequest, VNRequestTextRecognitionLevel,
236    };
237
238    /// Run Vision on the supplied image. Real macOS only.
239    #[allow(unsafe_code)]
240    pub fn recognize(image: &RgbaImage) -> Result<OcrResult, ComputerError> {
241        let (img_w, img_h) = (image.width(), image.height());
242
243        // 1. Encode the RGBA buffer to PNG; Vision reads the image bytes from a
244        // PNG `NSData` via `initWithData:options:`.
245        let mut bytes = Vec::new();
246        let encoder = image::codecs::png::PngEncoder::new(&mut bytes);
247        encoder
248            .write_image(image.as_raw(), img_w, img_h, ExtendedColorType::Rgba8)
249            .map_err(|error| {
250                ComputerError::ExecutionFailed(format!("encode capture for OCR: {error}"))
251            })?;
252        let png = NSData::with_bytes(&bytes);
253
254        // 2. Empty options dictionary is the canonical "let Vision infer"
255        // configuration. `VNImageOption` is a type alias for `NSString`, so
256        // `NSDictionary<NSString, AnyObject>` is the right parameter type.
257        let options: Retained<NSDictionary<NSString, AnyObject>> = NSDictionary::new();
258
259        // 3. Build the handler. `initWithData:options:` is a safe method in the
260        // `objc2-vision` bindings; the `png` Retained is kept alive until the
261        // handler drops, which keeps the byte buffer pinned during
262        // `performRequests`.
263        let handler = VNImageRequestHandler::initWithData_options(
264            VNImageRequestHandler::alloc(),
265            &png,
266            &options,
267        );
268
269        // 4. Build the request. Accurate + language correction on is the
270        // recognizable-text default Apple recommends for screen content.
271        // Setters are safe Rust functions.
272        let request = VNRecognizeTextRequest::new();
273        request.setRecognitionLevel(VNRequestTextRecognitionLevel::Accurate);
274        request.setUsesLanguageCorrection(true);
275
276        // 5. Upcast to `VNRequest` for the `NSArray<VNRequest>` argument.
277        // `into_super()` re-retains the same object under its superclass
278        // type. `VNRecognizeTextRequest -> VNImageBasedRequest -> VNRequest`,
279        // so we call it twice. We keep a separate clone of the original
280        // request intact so we can call `results()` on the concrete subclass
281        // after `performRequests` finishes.
282        let upcast: Retained<VNRequest> = request.clone().into_super().into_super();
283        let requests: Retained<NSArray<VNRequest>> = NSArray::from_retained_slice(&[upcast]);
284
285        // 6. Run. `performRequests:error:_` returns a `Result` where the
286        // `Err(NSError)` carries the localized description. On `Err` we map
287        // to `ExecutionFailed` with the description.
288        if let Err(error) = handler.performRequests_error(&requests) {
289            return Err(ComputerError::ExecutionFailed(format!(
290                "VNImageRequestHandler performRequests failed: {error}"
291            )));
292        }
293
294        // 7. Pull the observations. `results()` may be `None` if the request
295        // returned nothing — treat that as "no text recognized" and return an
296        // empty block list rather than an error.
297        let observations = match request.results() {
298            Some(obs) => obs,
299            None => {
300                return Ok(OcrResult {
301                    blocks: Vec::new(),
302                    note: None,
303                })
304            }
305        };
306
307        // 8. Walk the observations; build `OcrBlock`s with the top candidate
308        // and an image-pixel rectangle. `objectAtIndex` returns a fresh
309        // `Retained<VNRecognizedTextObservation>`; we deref to call methods.
310        let observations_ref: &NSArray<VNRecognizedTextObservation> = &observations;
311        let count = observations_ref.len();
312        let mut blocks = Vec::with_capacity(count);
313
314        for i in 0..count {
315            let observation_retained: Retained<VNRecognizedTextObservation> =
316                observations_ref.objectAtIndex(i);
317
318            // SAFETY: `boundingBox` is declared by the bindings on the
319            // `VNDetectedObjectObservation` superclass. The `objc2-vision`
320            // Rust surface re-exposes that method only on the superclass
321            // impl, so we re-type the same Objective-C instance to the
322            // superclass via `Retained::cast_unchecked` and call it there.
323            // `cast_unchecked` is documented for Objective-C ISA-consistent
324            // reinterpretation; this is exactly that case because
325            // `VNRecognizedTextObservation` IS-A `VNDetectedObjectObservation`
326            // in the Vision framework. `cast_unchecked` consumes the clone
327            // and yields an extra strong reference that we drop immediately
328            // after reading the rect; the `observation_retained` below
329            // remains the surviving refcount and is reused by
330            // `topCandidates`.
331            let rect = unsafe {
332                let obs_super: Retained<VNDetectedObjectObservation> =
333                    Retained::cast_unchecked(observation_retained.clone());
334                (*obs_super).boundingBox()
335            };
336
337            let vision_rect = VisionRect {
338                left: rect.origin.x,
339                bottom: rect.origin.y,
340                right: rect.origin.x + rect.size.width,
341                top: rect.origin.y + rect.size.height,
342            };
343
344            // `topCandidates(1)` is the canonical helper on
345            // `VNRecognizedTextObservation`. It returns the top-1 array of
346            // `VNRecognizedText` candidates; each has a `string()` and
347            // `confidence()`. All three are safe methods in the bindings.
348            let candidates = observation_retained.topCandidates(1);
349            let candidate: Retained<VNRecognizedText> = match candidates.into_iter().next() {
350                Some(c) => c,
351                None => continue,
352            };
353            let text_ns = candidate.string();
354            let confidence: f32 = candidate.confidence();
355
356            let image_rect = vision_box_to_image(image, vision_rect);
357            blocks.push(OcrBlock {
358                text: text_ns.to_string(),
359                x: image_rect.x,
360                y: image_rect.y,
361                width: image_rect.width,
362                height: image_rect.height,
363                confidence,
364            });
365        }
366
367        Ok(OcrResult { blocks, note: None })
368    }
369}
370
371// ---------------------------------------------------------------------------
372// Windows implementation
373// ---------------------------------------------------------------------------
374//
375// Real `Windows.Media.Ocr` call through the `windows` 0.62 crate feature
376// `Media_Ocr`. Pipeline: swizzle the RGBA capture to the BGRA the engine
377// requires -> copy it into a WinRT `IBuffer` -> `SoftwareBitmap` ->
378// `OcrEngine::TryCreateFromUserProfileLanguages()` -> `RecognizeAsync` ->
379// `join()` -> one `OcrBlock` per `OcrLine`, whose box is the union of its
380// words' `BoundingRect`s.
381//
382// The whole pass runs on a dedicated worker that calls `RoInitialize`
383// itself (Media OCR refuses an uninitialized thread) and is joined with a
384// `recv_timeout`, so a wedged engine cannot hang the CLI. Only `Send`-able
385// `OcrResult` / `ComputerError` values cross back, matching the UIA worker in
386// P12a.
387//
388// The engine reports no confidence, so `confidence` stays 0 exactly like the
389// macOS path when Vision cannot say.
390
391#[cfg(target_os = "windows")]
392mod windows {
393    // `RoInitialize`, `CoIncrementMTAUsage` and friends have no safe Rust
394    // equivalent in the `windows` crate. The audit surface stays limited to
395    // this module, which is target-gated to Windows.
396    #![allow(unsafe_code)]
397
398    use std::sync::mpsc::{self, RecvTimeoutError};
399    use std::thread;
400    use std::time::Duration;
401
402    use super::{ocr_box_to_image, OcrBlock, OcrResult};
403    use crate::error::ComputerError;
404    use image::RgbaImage;
405    use windows::Graphics::Imaging::{BitmapAlphaMode, BitmapPixelFormat, SoftwareBitmap};
406    use windows::Media::Ocr::{OcrEngine, OcrLine};
407    use windows::Security::Cryptography::CryptographicBuffer;
408
409    use windows::Win32::Foundation::S_FALSE;
410    use windows::Win32::System::Com::{
411        CoDecrementMTAUsage, CoIncrementMTAUsage, CO_MTA_USAGE_COOKIE,
412    };
413    use windows::Win32::System::WinRT::{RoInitialize, RoUninitialize, RO_INIT_MULTITHREADED};
414
415    /// How long one Media OCR pass may take before the CLI gives up on it.
416    const OCR_TIMEOUT: Duration = Duration::from_secs(10);
417
418    /// Run Media OCR on the supplied image. Real Windows only.
419    pub fn recognize(image: &RgbaImage) -> Result<OcrResult, ComputerError> {
420        let image = image.clone();
421        let (sender, receiver) = mpsc::channel::<Result<OcrResult, ComputerError>>();
422        let worker = thread::Builder::new()
423            .name("computer-use-ocr".into())
424            .spawn(move || {
425                let result = recognize_on_worker(&image);
426                let _ = sender.send(result);
427            });
428        if worker.is_err() {
429            return Err(ComputerError::ExecutionFailed(
430                "Windows.Media.Ocr worker thread could not start".into(),
431            ));
432        }
433        match receiver.recv_timeout(OCR_TIMEOUT) {
434            Ok(result) => result,
435            Err(RecvTimeoutError::Timeout) => Err(ComputerError::ExecutionFailed(format!(
436                "Windows.Media.Ocr did not finish within {}s",
437                OCR_TIMEOUT.as_secs()
438            ))),
439            Err(RecvTimeoutError::Disconnected) => Err(ComputerError::ExecutionFailed(
440                "Windows.Media.Ocr worker stopped without a result".into(),
441            )),
442        }
443    }
444
445    /// The actual pass, on a thread that has initialized WinRT.
446    fn recognize_on_worker(image: &RgbaImage) -> Result<OcrResult, ComputerError> {
447        let _mta = MtaUsage::new()?;
448        let _apartment = Apartment::new()?;
449
450        let (width, height) = (image.width(), image.height());
451        let bitmap = software_bitmap(image)?;
452        let engine = engine()?;
453
454        let operation = engine.RecognizeAsync(&bitmap).map_err(|error| {
455            ComputerError::ExecutionFailed(format!("Windows.Media.Ocr RecognizeAsync: {error}"))
456        })?;
457        let result = operation.join().map_err(|error| {
458            ComputerError::ExecutionFailed(format!("Windows.Media.Ocr RecognizeAsync: {error}"))
459        })?;
460
461        let mut blocks = Vec::new();
462        let lines = result.Lines().map_err(|error| {
463            ComputerError::ExecutionFailed(format!("Windows.Media.Ocr OcrResult.Lines: {error}"))
464        })?;
465        for line in &lines {
466            let Some((text, box_rect)) = line_block(&line) else {
467                continue;
468            };
469            let rect = ocr_box_to_image(
470                width, height, box_rect.0, box_rect.1, box_rect.2, box_rect.3,
471            );
472            blocks.push(OcrBlock {
473                text,
474                x: rect.x,
475                y: rect.y,
476                width: rect.width,
477                height: rect.height,
478                confidence: 0.0,
479            });
480        }
481
482        Ok(OcrResult {
483            blocks,
484            note: language_note(&engine),
485        })
486    }
487
488    /// One `OcrLine` as `(text, (x, y, width, height))`, or `None` when the
489    /// line has no non-empty word.
490    fn line_block(line: &OcrLine) -> Option<(String, (f64, f64, f64, f64))> {
491        let words = line.Words().ok()?;
492        let mut text_parts: Vec<String> = Vec::new();
493        let mut left = f64::MAX;
494        let mut top = f64::MAX;
495        let mut right = f64::MIN;
496        let mut bottom = f64::MIN;
497        for word in &words {
498            let word_text = word.Text().ok()?.to_string();
499            if word_text.trim().is_empty() {
500                continue;
501            }
502            let rect = word.BoundingRect().ok()?;
503            text_parts.push(word_text);
504            let (x, y) = (f64::from(rect.X), f64::from(rect.Y));
505            left = left.min(x);
506            top = top.min(y);
507            right = right.max(x + f64::from(rect.Width));
508            bottom = bottom.max(y + f64::from(rect.Height));
509        }
510        if text_parts.is_empty() {
511            return None;
512        }
513        Some((
514            text_parts.join(" "),
515            (left, top, (right - left).max(1.0), (bottom - top).max(1.0)),
516        ))
517    }
518
519    /// An engine for the user's profile languages.
520    fn engine() -> Result<OcrEngine, ComputerError> {
521        OcrEngine::TryCreateFromUserProfileLanguages().map_err(|error| {
522            ComputerError::ExecutionFailed(format!(
523                "Windows.Media.Ocr has no engine for the user-profile languages. Install a \
524                 language pack (Settings > Time & Language > Language > Language options, or \
525                 the OCR Recognizer in Settings > Apps > Optional features): {error}"
526            ))
527        })
528    }
529
530    /// Report which recognizer language ran, when the engine will say.
531    fn language_note(engine: &OcrEngine) -> Option<String> {
532        let tag = engine
533            .RecognizerLanguage()
534            .ok()?
535            .LanguageTag()
536            .ok()?
537            .to_string();
538        if tag.is_empty() {
539            None
540        } else {
541            Some(format!("Media OCR recognizer language: {tag}"))
542        }
543    }
544
545    /// Copy the RGBA capture into a BGRA `SoftwareBitmap`.
546    ///
547    /// Media OCR takes BGRA8 premultiplied. A screenshot is fully opaque, so
548    /// straight and premultiplied alpha are the same bytes; the channel swap
549    /// is the only real work.
550    fn software_bitmap(image: &RgbaImage) -> Result<SoftwareBitmap, ComputerError> {
551        let (width, height) = (image.width(), image.height());
552        let width = i32::try_from(width).map_err(|_| {
553            ComputerError::ExecutionFailed(format!("capture width {width} is too large"))
554        })?;
555        let height = i32::try_from(height).map_err(|_| {
556            ComputerError::ExecutionFailed(format!("capture height {height} is too large"))
557        })?;
558        let max_edge = OcrEngine::MaxImageDimension().unwrap_or(0);
559        if max_edge > 0
560            && (u32::try_from(width).unwrap_or(u32::MAX) > max_edge
561                || u32::try_from(height).unwrap_or(u32::MAX) > max_edge)
562        {
563            return Err(ComputerError::ExecutionFailed(format!(
564                "Windows.Media.Ocr accepts at most {max_edge} px per edge; the capture is \
565                 {width}x{height}"
566            )));
567        }
568        let source = image.as_raw();
569        let mut bgra = Vec::with_capacity(source.len());
570        for [r, g, b, a] in source.as_chunks::<4>().0 {
571            bgra.extend_from_slice(&[*b, *g, *r, *a]);
572        }
573        let buffer = CryptographicBuffer::CreateFromByteArray(&bgra).map_err(|error| {
574            ComputerError::ExecutionFailed(format!(
575                "CryptographicBuffer::CreateFromByteArray: {error}"
576            ))
577        })?;
578        SoftwareBitmap::CreateCopyWithAlphaFromBuffer(
579            &buffer,
580            BitmapPixelFormat::Bgra8,
581            width,
582            height,
583            BitmapAlphaMode::Premultiplied,
584        )
585        .map_err(|error| {
586            ComputerError::ExecutionFailed(format!(
587                "SoftwareBitmap::CreateCopyWithAlphaFromBuffer: {error}"
588            ))
589        })
590    }
591
592    /// Keeps the MTA alive for as long as the WinRT calls run.
593    struct MtaUsage {
594        cookie: CO_MTA_USAGE_COOKIE,
595    }
596
597    impl MtaUsage {
598        fn new() -> Result<Self, ComputerError> {
599            let cookie = unsafe { CoIncrementMTAUsage() }.map_err(|error| {
600                ComputerError::ExecutionFailed(format!("CoIncrementMTAUsage: {error}"))
601            })?;
602            Ok(Self { cookie })
603        }
604    }
605
606    impl Drop for MtaUsage {
607        fn drop(&mut self) {
608            let _ = unsafe { CoDecrementMTAUsage(self.cookie) };
609        }
610    }
611
612    /// `RoInitialize` / `RoUninitialize` for this worker only.
613    struct Apartment {
614        initialized: bool,
615    }
616
617    impl Apartment {
618        fn new() -> Result<Self, ComputerError> {
619            match unsafe { RoInitialize(RO_INIT_MULTITHREADED) } {
620                Ok(()) => Ok(Self { initialized: true }),
621                Err(error) if error.code() == S_FALSE => Ok(Self { initialized: false }),
622                Err(error) => Err(ComputerError::ExecutionFailed(format!(
623                    "RoInitialize(RO_INIT_MULTITHREADED) failed: {error}"
624                ))),
625            }
626        }
627    }
628
629    impl Drop for Apartment {
630        fn drop(&mut self) {
631            if self.initialized {
632                unsafe { RoUninitialize() };
633            }
634        }
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    // ------------------------------------------------------------------
643    // P12b: Windows.Media.Ocr box conversion. Pure math, tested on every
644    // runner so the Windows job is not the only place it is exercised.
645    // ------------------------------------------------------------------
646
647    #[test]
648    fn ocr_box_passes_through_image_pixels() {
649        let rect = ocr_box_to_image(800, 600, 100.0, 50.0, 200.0, 30.0);
650        assert_eq!(
651            rect,
652            ImageRect {
653                x: 100,
654                y: 50,
655                width: 200,
656                height: 30
657            }
658        );
659    }
660
661    #[test]
662    fn ocr_box_keeps_the_top_left_origin() {
663        // Unlike Vision, `OcrWord::BoundingRect` is already top-left, so a
664        // word in the upper half must stay in the upper half.
665        let rect = ocr_box_to_image(400, 400, 10.0, 20.0, 50.0, 15.0);
666        assert_eq!(rect.y, 20);
667        assert!(rect.y < 200, "{rect:?}");
668    }
669
670    #[test]
671    fn ocr_box_rounds_to_whole_pixels() {
672        // left 10.4 -> 10, right 10.4+30.5 = 40.9 -> 41, so 31 px wide.
673        // top 20.6 -> 21, bottom 20.6+40.4 = 61.0 -> 61, so 40 px tall.
674        let rect = ocr_box_to_image(800, 600, 10.4, 20.6, 30.5, 40.4);
675        assert_eq!(rect.x, 10);
676        assert_eq!(rect.y, 21);
677        assert_eq!(rect.width, 31);
678        assert_eq!(rect.height, 40);
679    }
680
681    #[test]
682    fn ocr_box_is_clamped_to_the_image() {
683        let rect = ocr_box_to_image(200, 100, 190.0, 90.0, 80.0, 80.0);
684        assert_eq!(rect.x, 190);
685        assert_eq!(rect.y, 90);
686        assert!(rect.x + rect.width as i32 <= 200, "{rect:?}");
687        assert!(rect.y + rect.height as i32 <= 100, "{rect:?}");
688    }
689
690    #[test]
691    fn ocr_box_clamps_a_negative_origin_to_zero() {
692        let rect = ocr_box_to_image(200, 100, -40.0, -10.0, 60.0, 20.0);
693        assert_eq!(rect.x, 0);
694        assert_eq!(rect.y, 0);
695        assert_eq!(rect.width, 20);
696        assert_eq!(rect.height, 10);
697    }
698
699    #[test]
700    fn ocr_box_is_never_empty() {
701        let rect = ocr_box_to_image(200, 100, 50.0, 50.0, 0.0, 0.0);
702        assert_eq!(rect.width, 1);
703        assert_eq!(rect.height, 1);
704    }
705
706    #[test]
707    fn ocr_box_feeds_the_screenshot_map_back_to_a_screen_point() {
708        let map = crate::image_util::ScreenshotMap {
709            width: 800,
710            height: 600,
711            scale: 1.0,
712            logical_width: 800,
713            logical_height: 600,
714            origin: crate::image_util::MapPoint { x: 120, y: 40 },
715            fit: "none".into(),
716            pad: crate::image_util::MapPoint { x: 0, y: 0 },
717            content_width: 800,
718            content_height: 600,
719            map: crate::image_util::MapFormulas {
720                image_to_screen: String::new(),
721                screen_to_image: String::new(),
722            },
723        };
724        let rect = ocr_box_to_image(800, 600, 100.0, 50.0, 200.0, 30.0);
725        let (screen_x, screen_y) = rect.to_screen(&map).expect("on screen");
726        assert!((screen_x - 320.0).abs() < 0.001, "{screen_x}");
727        assert!((screen_y - 105.0).abs() < 0.001, "{screen_y}");
728    }
729
730    fn block(x: i32, y: i32, w: u32, h: u32) -> OcrBlock {
731        OcrBlock {
732            text: "secret".into(),
733            x,
734            y,
735            width: w,
736            height: h,
737            confidence: 0.95,
738        }
739    }
740
741    fn rect(x: i32, y: i32, w: u32, h: u32) -> ImageRect {
742        ImageRect {
743            x,
744            y,
745            width: w,
746            height: h,
747        }
748    }
749
750    #[test]
751    fn redaction_drops_blocks_inside_secure_frame() {
752        let blocks = vec![block(10, 10, 50, 20), block(200, 200, 5, 5)];
753        let frames = vec![rect(0, 0, 100, 100)];
754        let kept = redact_blocks(blocks, frames);
755        assert_eq!(kept.len(), 1, "secure frame must drop the inner block");
756        assert_eq!((kept[0].x, kept[0].y), (200, 200));
757    }
758
759    #[test]
760    fn redaction_keeps_blocks_overlapping_an_edge() {
761        let blocks = vec![block(90, 90, 30, 30)];
762        let frames = vec![rect(0, 0, 100, 100)];
763        let kept = redact_blocks(blocks, frames);
764        assert_eq!(kept.len(), 1, "blocks that overflow the secure frame stay");
765    }
766
767    #[test]
768    fn redaction_with_no_frames_returns_all_blocks() {
769        let blocks = vec![block(0, 0, 10, 10), block(50, 50, 5, 5)];
770        let kept = redact_blocks(blocks, Vec::<ImageRect>::new());
771        assert_eq!(kept.len(), 2);
772    }
773
774    #[test]
775    #[cfg(target_os = "linux")]
776    fn linux_ocr_returns_unsupported() {
777        use image::RgbaImage;
778        let image = RgbaImage::from_pixel(8, 8, image::Rgba([255, 255, 255, 255]));
779        let map = make_stub_map();
780        let err = recognize(&image, &map).unwrap_err();
781        match err {
782            ComputerError::Unsupported(reason) => {
783                assert!(
784                    reason.to_lowercase().contains("linux"),
785                    "Linux unsupported message must mention linux: {reason}"
786                );
787            }
788            other => panic!("expected Unsupported, got {other:?}"),
789        }
790    }
791
792    #[test]
793    #[cfg(target_os = "windows")]
794    fn windows_media_ocr_runs_and_never_returns_unsupported() {
795        // P12b replaced the P11 `unsupported` stub with a real
796        // `Windows.Media.Ocr.OcrEngine` call, so `Unsupported` is now a
797        // contract violation, not an accepted outcome.
798        //
799        // A blank image legitimately recognizes to zero blocks. A runner with
800        // no OCR recognizer language pack installed fails with
801        // `execution_failed` naming the pack — also acceptable. Anything else,
802        // including a panic from the WinRT apartment, is a bug.
803        use image::RgbaImage;
804        let image = RgbaImage::from_pixel(64, 32, image::Rgba([255, 255, 255, 255]));
805        let map = make_stub_map();
806        match recognize(&image, &map) {
807            Ok(result) => {
808                assert!(
809                    result.blocks.iter().all(|block| {
810                        block.width >= 1 && block.height >= 1 && block.x >= 0 && block.y >= 0
811                    }),
812                    "blocks must be non-empty image rects: {:?}",
813                    result.blocks
814                );
815            }
816            Err(ComputerError::Unsupported(_)) => {
817                panic!("Windows OCR must not be unsupported after P12b")
818            }
819            Err(ComputerError::ExecutionFailed(reason)) => assert!(
820                reason.contains("Windows.Media.Ocr"),
821                "a Windows OCR failure must name the engine: {reason}"
822            ),
823            Err(other) => panic!("unexpected error from Windows OCR: {other:?}"),
824        }
825    }
826
827    /// Vision-derived image rect with origin flip stays inside the image.
828    #[cfg(target_os = "macos")]
829    #[test]
830    fn vision_rect_flips_origin() {
831        use image::{Rgba, RgbaImage};
832        let image = RgbaImage::from_pixel(100, 80, Rgba([255, 255, 255, 255]));
833        let rect = super::VisionRect {
834            left: 0.0,
835            top: 1.0,
836            right: 1.0,
837            bottom: 0.0,
838        };
839        let image_rect = super::vision_box_to_image(&image, rect);
840        assert_eq!(image_rect.x, 0);
841        assert_eq!(image_rect.y, 0);
842        assert_eq!(image_rect.width, 100);
843        assert_eq!(image_rect.height, 80);
844    }
845
846    /// End-to-end smoke: render a known label into an RGBA buffer with a
847    /// small embedded 5x7 bitmap font, run the real Vision pipeline through
848    /// the public entry point, and assert the top candidate contains the
849    /// label text. Skipped on CI / headless (no display context, Vision may
850    /// still work but text rendering and disk read of fixtures is enough —
851    /// run locally with `cargo test --release`).
852    #[cfg(target_os = "macos")]
853    #[test]
854    #[ignore = "requires a live Vision framework; run locally with `cargo test --release vision_recognizes_drawn_text -- --ignored --nocapture`"]
855    fn vision_recognizes_drawn_text() {
856        use image::{Rgba, RgbaImage};
857
858        // Tiny 5x7 ASCII font covering letters that spell `HELLO`.
859        const FONT_W: usize = 5;
860        const FONT_H: usize = 7;
861        const FONT: &[(char, [u8; FONT_H])] = &[
862            (
863                'H',
864                [
865                    0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001,
866                ],
867            ),
868            (
869                'E',
870                [
871                    0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111,
872                ],
873            ),
874            (
875                'L',
876                [
877                    0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111,
878                ],
879            ),
880            (
881                'O',
882                [
883                    0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110,
884                ],
885            ),
886            (' ', [0, 0, 0, 0, 0, 0, 0]),
887        ];
888        fn put_pixel(image: &mut RgbaImage, x: i32, y: i32, color: Rgba<u8>) {
889            if x < 0 || y < 0 {
890                return;
891            }
892            let w = image.width() as i32;
893            let h = image.height() as i32;
894            if x >= w || y >= h {
895                return;
896            }
897            image.put_pixel(x as u32, y as u32, color);
898        }
899
900        // Build a 200x60 white image and render `HELLO` in black at scale 4x.
901        let mut image = RgbaImage::from_pixel(200, 60, Rgba([255, 255, 255, 255]));
902        let ink = Rgba([0, 0, 0, 255]);
903        let scale = 4_u32;
904        let text = "HELLO";
905        let origin_x: i32 = 10;
906        let origin_y: i32 = 10;
907        let cell_w = (FONT_W as u32) * scale;
908        let cell_h = (FONT_H as u32) * scale;
909        let mut pen_x = origin_x;
910        for ch in text.chars() {
911            let glyph = match FONT.iter().find(|(key, _)| *key == ch) {
912                Some((_, g)) => *g,
913                None => {
914                    pen_x += cell_w as i32;
915                    continue;
916                }
917            };
918            for (row, byte) in glyph.iter().enumerate() {
919                for col in 0..FONT_W {
920                    if (byte >> (FONT_W - 1 - col)) & 1 == 1 {
921                        let px0 = pen_x + (col as i32) * scale as i32;
922                        let py0 = origin_y + (row as i32) * scale as i32;
923                        for dy in 0..scale as i32 {
924                            for dx in 0..scale as i32 {
925                                put_pixel(&mut image, px0 + dx, py0 + dy, ink);
926                            }
927                        }
928                    }
929                }
930            }
931            pen_x += cell_w as i32;
932            if pen_x + cell_w as i32 > image.width() as i32 {
933                break;
934            }
935            if origin_y + cell_h as i32 > image.height() as i32 {
936                break;
937            }
938        }
939
940        let map = make_stub_map();
941        let result =
942            recognize(&image, &map).expect("recognize should not error on a synthetic image");
943
944        assert!(
945            !result.blocks.is_empty(),
946            "Vision must return at least one block for a drawn text buffer; got {:?}",
947            result
948        );
949        let joined: String = result
950            .blocks
951            .iter()
952            .map(|block| block.text.to_uppercase())
953            .collect::<Vec<_>>()
954            .join(" ");
955        assert!(
956            joined.contains("HELLO"),
957            "Vision output should contain HELLO; got: {joined:?}"
958        );
959    }
960
961    #[cfg(any(test, target_os = "linux", target_os = "windows"))]
962    #[allow(dead_code)]
963    fn make_stub_map() -> crate::image_util::ScreenshotMap {
964        crate::image_util::ScreenshotMap {
965            width: 8,
966            height: 8,
967            scale: 1.0,
968            logical_width: 8,
969            logical_height: 8,
970            origin: crate::image_util::MapPoint { x: 0, y: 0 },
971            fit: "none".into(),
972            pad: crate::image_util::MapPoint { x: 0, y: 0 },
973            content_width: 8,
974            content_height: 8,
975            map: crate::image_util::MapFormulas {
976                image_to_screen: String::new(),
977                screen_to_image: String::new(),
978            },
979        }
980    }
981}