Skip to main content

computer_use/vision/
marks.rs

1//! Pure-`image` set-of-marks overlay.
2//!
3//! Numbers every rectangle in `boxes` on a copy of `source` and draws a small
4//! filled label in the top-left of each rect. Numerals are rendered with an
5//! embedded 4x6 bitmap font so the module stays free of `fontdue` or any
6//! platform font APIs.
7
8use image::{Pixel, Rgba, RgbaImage};
9
10use crate::image_util::{screen_rect_to_image, ScreenshotMap};
11use crate::tree::{UiNode, CHILD_CAP, DEPTH_CAP, LINE_CAP};
12
13/// One rectangle plus its human-readable label.
14///
15/// `label` is what the user sees inside the box. The protocol decides whether
16/// to use a tree index, an `oN` OCR index, or `?` when an index is not yet
17/// known. Keep `label` short — at most two characters fit at the default size.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct MarkBox {
20    /// Image pixel rectangle (top-left inclusive).
21    pub rect: ImageRect,
22    /// Human-readable label drawn in the box.
23    pub label: String,
24}
25
26/// Rectangle in image pixels.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct ImageRect {
29    /// Left edge.
30    pub x: i32,
31    /// Top edge.
32    pub y: i32,
33    /// Width in pixels. May be smaller than the rendered number — clip, don't fail.
34    pub width: u32,
35    /// Height in pixels.
36    pub height: u32,
37}
38
39impl ImageRect {
40    fn right(&self) -> i32 {
41        self.x.saturating_add(self.width as i32)
42    }
43
44    fn bottom(&self) -> i32 {
45        self.y.saturating_add(self.height as i32)
46    }
47
48    fn is_empty(&self) -> bool {
49        self.width == 0 || self.height == 0
50    }
51}
52
53/// Draw `boxes` on a copy of `source` and return the marked image.
54///
55/// Empty or off-image rectangles are skipped. Clipped rectangles still draw
56/// what fits; the borders and numbers stay legible at very small sizes.
57pub fn draw_marks(source: &RgbaImage, boxes: &[MarkBox]) -> RgbaImage {
58    let mut image = source.clone();
59    for mark in boxes {
60        if mark.rect.is_empty() {
61            continue;
62        }
63        draw_box_border(&mut image, mark.rect);
64        draw_label(&mut image, mark.rect, &mark.label);
65    }
66    image
67}
68
69/// Map an element index from the latest tree to its label.
70#[must_use]
71pub fn tree_label(index: u32) -> String {
72    index.to_string()
73}
74
75/// Map an OCR observation (zero-based) to the `oN` label the protocol uses.
76#[must_use]
77pub fn ocr_label(index: u32) -> String {
78    format!("o{index}")
79}
80
81/// Numbered boxes for every node of `root` whose frame lands inside `map`.
82///
83/// Element frames are in screen points while `map` describes the pixels of one
84/// capture, so every frame goes through [`screen_rect_to_image`]. A node with no
85/// frame contributes no box but its children are still walked: a frameless
86/// group must never hide the subtree below it.
87///
88/// Indices mirror [`crate::tree::render`] exactly — same preorder, same
89/// `CHILD_CAP` / `DEPTH_CAP` / `LINE_CAP` budget — so the number drawn on the
90/// image is the number `tree` prints for that element.
91#[must_use]
92pub fn marks_for_nodes(root: &UiNode, map: &ScreenshotMap) -> Vec<MarkBox> {
93    let mut out = Vec::new();
94    let mut index = 0_u32;
95    collect_marks(root, map, 0, &mut index, &mut out);
96    out
97}
98
99fn collect_marks(
100    node: &UiNode,
101    map: &ScreenshotMap,
102    depth: usize,
103    index: &mut u32,
104    out: &mut Vec<MarkBox>,
105) {
106    let label = tree_label(*index);
107    *index = index.saturating_add(1);
108    if let Some(frame) = node.frame {
109        if let Some((x, y, width, height)) =
110            screen_rect_to_image(map, frame.x, frame.y, frame.width, frame.height)
111        {
112            out.push(MarkBox {
113                rect: ImageRect {
114                    x,
115                    y,
116                    width,
117                    height,
118                },
119                label,
120            });
121        }
122    }
123    let total = node.child_count.unwrap_or(node.children.len());
124    if depth >= DEPTH_CAP {
125        return;
126    }
127    let show = total.min(CHILD_CAP).min(node.children.len());
128    for child in node.children.iter().take(show) {
129        if usize::try_from(*index).unwrap_or(usize::MAX) >= LINE_CAP {
130            return;
131        }
132        collect_marks(child, map, depth + 1, index, out);
133    }
134}
135
136// ---------------------------------------------------------------------------
137// Internals
138// ---------------------------------------------------------------------------
139
140/// Draw a 2-pixel hollow rectangle in `color` (or the default orange-red).
141fn draw_box_border(image: &mut RgbaImage, rect: ImageRect) {
142    let color = MARK_BORDER;
143    let w = image.width() as i32;
144    let h = image.height() as i32;
145    let left = rect.x.clamp(0, w);
146    let top = rect.y.clamp(0, h);
147    let right = rect.right().clamp(0, w);
148    let bottom = rect.bottom().clamp(0, h);
149    if left >= right || top >= bottom {
150        return;
151    }
152    let stroke = 1_i32;
153    // Top + bottom
154    for stroke_y in 0..stroke {
155        for x in left..right {
156            put_pixel(image, x, top + stroke_y, color);
157            put_pixel(image, x, bottom - 1 - stroke_y, color);
158        }
159    }
160    // Left + right
161    for stroke_x in 0..stroke {
162        for y in top..bottom {
163            put_pixel(image, left + stroke_x, y, color);
164            put_pixel(image, right - 1 - stroke_x, y, color);
165        }
166    }
167    // Bumped second pass at low alpha for legibility on bright backgrounds.
168    let halo = MARK_HALO;
169    for stroke_y in 0..stroke {
170        for x in left..right {
171            put_pixel(image, x, top.saturating_sub(1) + stroke_y, halo);
172            put_pixel(image, x, bottom + stroke_y, halo);
173        }
174    }
175    for stroke_x in 0..stroke {
176        for y in top..bottom {
177            put_pixel(image, left.saturating_sub(1) + stroke_x, y, halo);
178            put_pixel(image, right + stroke_x, y, halo);
179        }
180    }
181}
182
183/// Draw `label` in the top-left of `rect`. Silently drops characters the
184/// font does not know — only digits, lowercase `o`, and `-` are supported.
185fn draw_label(image: &mut RgbaImage, rect: ImageRect, label: &str) {
186    let pixels = label_to_pixels(label);
187    let rect_w = rect.width.max(1);
188    let rect_h = rect.height.max(1);
189    // The cell is one font pixel. It may shrink to fit a small box, but it is
190    // capped: a glyph that scales with the box made the window's own mark
191    // paint a 452x678 white digit and buried the screenshot under the overlay.
192    let cell = (rect_w / pixels.cols.max(1) as u32)
193        .min(rect_h / FONT_HEIGHT.max(1) as u32)
194        .min(MAX_LABEL_CELL);
195    if cell == 0 {
196        return;
197    }
198    let cell_i32 = cell as i32;
199    let origin_x = rect.x;
200    let origin_y = rect.y;
201    let w = image.width() as i32;
202    let h = image.height() as i32;
203    for (row, line) in pixels.rows.iter().enumerate() {
204        let y = origin_y + (row as i32) * cell_i32;
205        if y >= h {
206            break;
207        }
208        for (col, on) in line.iter().enumerate() {
209            if !*on {
210                continue;
211            }
212            let x = origin_x + (col as i32) * cell_i32;
213            if x >= w {
214                break;
215            }
216            for dy in 0..cell_i32 {
217                if y + dy < 0 || y + dy >= h {
218                    continue;
219                }
220                for dx in 0..cell_i32 {
221                    if x + dx < 0 || x + dx >= w {
222                        continue;
223                    }
224                    put_pixel(image, x + dx, y + dy, MARK_TEXT);
225                }
226            }
227        }
228    }
229}
230
231fn put_pixel(image: &mut RgbaImage, x: i32, y: i32, color: Rgba<u8>) {
232    if x < 0 || y < 0 {
233        return;
234    }
235    let (xi, yi) = (x as u32, y as u32);
236    if xi >= image.width() || yi >= image.height() {
237        return;
238    }
239    let blended = blend(image.get_pixel(xi, yi).to_rgba(), color);
240    image.put_pixel(xi, yi, blended);
241}
242
243/// Alpha-blend `overlay` onto `base`. Matches the PNG-over-PNG semantics used
244/// by the screenshot encoder for capture overlays.
245fn blend(base: Rgba<u8>, overlay: Rgba<u8>) -> Rgba<u8> {
246    let alpha = u32::from(overlay.0[3]);
247    if alpha == 0 {
248        return base;
249    }
250    let inv = 255 - alpha;
251    let mut out = [0_u8; 4];
252    for (i, slot) in out.iter_mut().enumerate().take(3) {
253        let mixed = (u32::from(base.0[i]) * inv + u32::from(overlay.0[i]) * alpha) / 255;
254        *slot = mixed.min(255) as u8;
255    }
256    out[3] = 255;
257    Rgba(out)
258}
259
260/// Color of the rectangle border (orange-red, opaque).
261const MARK_BORDER: Rgba<u8> = Rgba([255, 64, 32, 255]);
262/// Color of the rectangle text.
263const MARK_TEXT: Rgba<u8> = Rgba([255, 255, 255, 255]);
264/// Halo outside the border to keep it visible over white backgrounds.
265const MARK_HALO: Rgba<u8> = Rgba([255, 64, 32, 80]);
266
267// ---------------------------------------------------------------------------
268// Tiny bitmap font
269// ---------------------------------------------------------------------------
270
271const FONT_HEIGHT: usize = 6;
272const FONT_WIDTH: usize = 4;
273const FONT_GLYPHS: &[(&str, [u8; FONT_HEIGHT])] = &[
274    ("0", [0b0110, 0b1001, 0b1001, 0b1001, 0b1001, 0b0110]),
275    ("1", [0b0010, 0b0110, 0b0010, 0b0010, 0b0010, 0b0111]),
276    ("2", [0b0110, 0b1001, 0b0001, 0b0010, 0b0100, 0b1111]),
277    ("3", [0b1110, 0b0001, 0b0001, 0b0110, 0b0001, 0b1110]),
278    ("4", [0b0001, 0b0011, 0b0101, 0b1001, 0b1111, 0b0001]),
279    ("5", [0b1111, 0b1000, 0b1110, 0b0001, 0b0001, 0b1110]),
280    ("6", [0b0010, 0b0100, 0b1000, 0b1110, 0b1001, 0b0110]),
281    ("7", [0b1111, 0b0001, 0b0010, 0b0100, 0b0100, 0b0100]),
282    ("8", [0b0110, 0b1001, 0b1001, 0b0110, 0b1001, 0b0110]),
283    ("9", [0b0110, 0b1001, 0b0111, 0b0001, 0b0010, 0b0100]),
284    ("o", [0b0000, 0b0000, 0b0110, 0b1001, 0b1001, 0b0110]),
285    ("-", [0b0000, 0b0000, 0b0000, 0b0111, 0b0000, 0b0000]),
286];
287
288struct GlyphPixels {
289    rows: Vec<[bool; FONT_WIDTH * MAX_LABEL_CHARS]>,
290    cols: usize,
291}
292
293/// Maximum glyphs `draw_label` will render. Keeps the column buffer small
294/// and bounds the size of a set-of-marks overlay.
295const MAX_LABEL_CHARS: usize = 4;
296
297/// Largest font pixel in a label, in image pixels.
298///
299/// A number is a corner badge, not a watermark: at 4 a one-digit label is a
300/// 16x24 box and a two-digit one is 32x24, whatever the element's own size is.
301const MAX_LABEL_CELL: u32 = 4;
302
303fn label_to_pixels(label: &str) -> GlyphPixels {
304    // MAX_LABEL_CHARS hard caps the number of glyphs; each glyph is
305    // FONT_WIDTH columns wide, so the column buffer must hold that many
306    // columns, not just one glyph's worth. Allocating FONT_WIDTH columns
307    // (the previous size) panicked as soon as a second glyph was rendered
308    // because `cols + col` walked past the end of every row.
309    let mut rows = vec![[false; FONT_WIDTH * MAX_LABEL_CHARS]; FONT_HEIGHT];
310    let mut cols = 0_usize;
311    for (char_index, ch) in label.chars().enumerate() {
312        if char_index >= MAX_LABEL_CHARS {
313            // Hard cap: marks only label short IDs.
314            break;
315        }
316        let glyph = glyph_for(ch);
317        if glyph.is_empty() {
318            // Unknown character: fill with a question-mark dot to keep the
319            // label position visible without confusing it with a digit.
320            fill_unknown(&mut rows, cols);
321            cols += FONT_WIDTH;
322            continue;
323        }
324        for (row, byte) in glyph.iter().enumerate() {
325            for col in 0..FONT_WIDTH {
326                let on = (byte >> (FONT_WIDTH - 1 - col)) & 1 == 1;
327                rows[row][cols + col] = on;
328            }
329        }
330        cols += FONT_WIDTH;
331    }
332    GlyphPixels { rows, cols }
333}
334
335fn glyph_for(ch: char) -> [u8; FONT_HEIGHT] {
336    let target: &str = match ch {
337        '0' => "0",
338        '1' => "1",
339        '2' => "2",
340        '3' => "3",
341        '4' => "4",
342        '5' => "5",
343        '6' => "6",
344        '7' => "7",
345        '8' => "8",
346        '9' => "9",
347        'o' | 'O' => "o",
348        '-' => "-",
349        _ => "",
350    };
351    FONT_GLYPHS
352        .iter()
353        .find(|(key, _)| *key == target)
354        .map(|(_, glyph)| *glyph)
355        .unwrap_or(EMPTY_GLYPH)
356}
357
358const EMPTY_GLYPH: [u8; FONT_HEIGHT] = [0; FONT_HEIGHT];
359
360fn fill_unknown(rows: &mut [[bool; FONT_WIDTH * MAX_LABEL_CHARS]], col_offset: usize) {
361    // Single center dot — narrower than a digit — keeps the column count.
362    rows[2][col_offset + 1] = true;
363    rows[3][col_offset + 1] = true;
364}
365
366// ---------------------------------------------------------------------------
367// Tests
368// ---------------------------------------------------------------------------
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::image_util::{prepare_shot, ShotFit};
374    use crate::tree::NodeFrame;
375    use image::{Rgba, RgbaImage};
376
377    fn white_square(size: u32) -> RgbaImage {
378        RgbaImage::from_pixel(size, size, Rgba([255, 255, 255, 255]))
379    }
380
381    /// Map of a Retina window capture: an 800x500-point window at screen
382    /// `(120, 40)`, a 1600x1000 buffer at scale 2, fitted down to 1440 wide.
383    /// This is the P4 example, so every element-frame box in this module is
384    /// built against a map a real capture really produces.
385    fn retina_window_map() -> ScreenshotMap {
386        let buffer = RgbaImage::from_pixel(1600, 1000, Rgba([120, 120, 120, 255]));
387        prepare_shot(
388            &buffer,
389            ShotFit {
390                scale: 2.0,
391                origin_x: 120,
392                origin_y: 40,
393                logical_width: 800,
394                logical_height: 500,
395                max_width: 1440,
396                canvas: None,
397            },
398        )
399        .expect("prepare_shot")
400        .map
401    }
402
403    fn framed(role: &str, frame: NodeFrame) -> UiNode {
404        let mut node = UiNode::bare(role);
405        node.frame = Some(frame);
406        node
407    }
408
409    fn rect(x: f64, y: f64, width: f64, height: f64) -> NodeFrame {
410        NodeFrame {
411            x,
412            y,
413            width,
414            height,
415        }
416    }
417
418    #[test]
419    fn screen_point_frames_at_a_nonzero_origin_become_in_canvas_marks() {
420        // The reported failure: on a real Retina window every element frame
421        // came back as screen points while the capture map describes pixels of
422        // a window that does not start at (0, 0), so nothing survived the
423        // conversion and `screenshot --marks` wrote a clean image.
424        let map = retina_window_map();
425        assert_eq!(map.origin.x, 120);
426        assert_eq!(map.scale, 2.0);
427
428        let root = framed(
429            "AXWindow",
430            rect(120.0, 40.0, 800.0, 500.0), // exactly the captured window
431        );
432        let mut inner = framed("AXTextArea", rect(140.0, 60.0, 760.0, 100.0));
433        inner.value = Some("hello".into());
434        let root = UiNode {
435            children: vec![inner],
436            ..root
437        };
438
439        let marks = marks_for_nodes(&root, &map);
440        assert_eq!(
441            marks.len(),
442            2,
443            "both the window and the text area are inside the capture"
444        );
445
446        // The window covers the whole fitted capture, and the text area sits
447        // 20 points inside it, which is 36 pixels at 1440/800.
448        assert_eq!(marks[0].label, "0");
449        assert_eq!(marks[1].label, "1");
450        assert_eq!(
451            marks[0].rect,
452            ImageRect {
453                x: 0,
454                y: 0,
455                width: 1440,
456                height: 900
457            }
458        );
459        assert_eq!(
460            marks[1].rect,
461            ImageRect {
462                x: 36,
463                y: 36,
464                width: 1368,
465                height: 180
466            }
467        );
468
469        for mark in &marks {
470            assert!(
471                mark.rect.x >= 0
472                    && mark.rect.y >= 0
473                    && mark.rect.x + mark.rect.width as i32 <= map.width as i32
474                    && mark.rect.y + mark.rect.height as i32 <= map.height as i32,
475                "mark {:?} escaped the {}x{} canvas",
476                mark.rect,
477                map.width,
478                map.height
479            );
480        }
481    }
482
483    #[test]
484    fn mark_indices_match_the_numbers_the_tree_prints() {
485        let map = retina_window_map();
486        let root = UiNode {
487            children: vec![
488                framed("AXTextArea", rect(140.0, 60.0, 760.0, 100.0)),
489                framed("AXButton", rect(140.0, 200.0, 120.0, 40.0)),
490            ],
491            ..framed("AXWindow", rect(120.0, 40.0, 800.0, 500.0))
492        };
493        let rendered = crate::tree::render(&root);
494        let marks = marks_for_nodes(&root, &map);
495        assert_eq!(marks.len(), 3);
496        for mark in &marks {
497            assert!(
498                rendered.text.contains(&format!("[{}]", mark.label)),
499                "index {} is not in the rendered tree:\n{}",
500                mark.label,
501                rendered.text
502            );
503        }
504    }
505
506    #[test]
507    fn a_frameless_group_does_not_hide_its_subtree() {
508        // The macOS walk used to `return` on a node with no frame, so a
509        // frameless container took every element below it down with it.
510        let map = retina_window_map();
511        let mut group = UiNode::bare("AXGroup");
512        group.frame = None;
513        group.children = vec![framed("AXButton", rect(140.0, 200.0, 120.0, 40.0))];
514        let root = UiNode {
515            children: vec![group],
516            ..framed("AXWindow", rect(120.0, 40.0, 800.0, 500.0))
517        };
518
519        let marks = marks_for_nodes(&root, &map);
520        assert_eq!(
521            marks.len(),
522            2,
523            "the window and the button under the frameless group"
524        );
525        assert_eq!(marks[1].label, "2", "the group still consumed index 1");
526    }
527
528    #[test]
529    fn frames_outside_the_capture_produce_no_box() {
530        let map = retina_window_map();
531        let root = framed("AXWindow", rect(4_000.0, 4_000.0, 300.0, 200.0));
532        assert!(marks_for_nodes(&root, &map).is_empty());
533    }
534
535    #[test]
536    fn a_large_box_does_not_paint_a_giant_label() {
537        // A live Finder window reported 73% of its pixels rewritten: the
538        // window's own mark scaled its digit to the whole capture and buried
539        // the screenshot under white. A label stays a corner badge.
540        let image = RgbaImage::from_pixel(1440, 682, Rgba([20, 20, 20, 255]));
541        let marks = vec![MarkBox {
542            rect: ImageRect {
543                x: 0,
544                y: 0,
545                width: 1440,
546                height: 682,
547            },
548            label: tree_label(0),
549        }];
550        let marked = draw_marks(&image, &marks);
551
552        let badge_w = (FONT_WIDTH * MAX_LABEL_CELL as usize) as i32;
553        let badge_h = (FONT_HEIGHT * MAX_LABEL_CELL as usize) as i32;
554        let dark = Rgba([20, 20, 20, 255]);
555        let mut text_outside_badge = 0_u32;
556        for y in 0..marked.height() as i32 {
557            for x in 0..marked.width() as i32 {
558                // The border and its halo legitimately span the whole box;
559                // only the white glyph has to stay inside the badge.
560                if marked.get_pixel(x as u32, y as u32).to_rgba() == MARK_TEXT
561                    && (x >= badge_w || y >= badge_h)
562                {
563                    text_outside_badge += 1;
564                }
565            }
566        }
567
568        let total_changed = marked
569            .pixels()
570            .zip(image.pixels())
571            .filter(|(after, before)| **after != dark && **after != **before)
572            .count() as u32;
573
574        assert_eq!(
575            text_outside_badge, 0,
576            "the digit must stay inside its {badge_w}x{badge_h} badge"
577        );
578        assert!(
579            total_changed < 1440 * 682 / 4,
580            "the overlay rewrote {total_changed} of {} pixels",
581            1440 * 682
582        );
583    }
584
585    #[test]
586    fn marks_leave_a_non_white_pixel_inside_box_zero() {
587        // The Roadmap-mandated fixture: a 20x20 white canvas, a single
588        // element rect (tree index 0), and the expectation that the marks
589        // overlay tinted a pixel inside that box.
590        let image = white_square(20);
591        let marks = vec![MarkBox {
592            rect: ImageRect {
593                x: 4,
594                y: 4,
595                width: 8,
596                height: 8,
597            },
598            label: tree_label(0),
599        }];
600        let marked = draw_marks(&image, &marks);
601        let mut saw_drawn_pixel = false;
602        // Scan the inside of the box (centroid and near-label area). With
603        // cell=1, the digit "0" paints some on-pixels inside x=[4..8),
604        // y=[4..10). Any of those qualifies as a marks overlay.
605        for y in 4..12 {
606            for x in 4..12 {
607                if marked.get_pixel(x, y).to_rgba() != Rgba([255, 255, 255, 255]) {
608                    saw_drawn_pixel = true;
609                    break;
610                }
611            }
612            if saw_drawn_pixel {
613                break;
614            }
615        }
616        assert!(
617            saw_drawn_pixel,
618            "marks must leave a non-white pixel inside the labelled box"
619        );
620    }
621
622    #[test]
623    fn empty_boxes_are_skipped() {
624        let image = white_square(8);
625        let marks = vec![
626            MarkBox {
627                rect: ImageRect {
628                    x: 0,
629                    y: 0,
630                    width: 0,
631                    height: 0,
632                },
633                label: "?".to_string(),
634            },
635            MarkBox {
636                rect: ImageRect {
637                    x: -10,
638                    y: -10,
639                    width: 4,
640                    height: 4,
641                },
642                label: "?".to_string(),
643            },
644        ];
645        let marked = draw_marks(&image, &marks);
646        // No pixels should have been written.
647        for y in 0..marked.height() {
648            for x in 0..marked.width() {
649                assert_eq!(*marked.get_pixel(x, y), Rgba([255, 255, 255, 255]));
650            }
651        }
652    }
653
654    #[test]
655    fn ocr_label_format() {
656        assert_eq!(ocr_label(0), "o0");
657        assert_eq!(ocr_label(12), "o12");
658    }
659
660    #[test]
661    fn tree_label_is_decimal() {
662        assert_eq!(tree_label(0), "0");
663        assert_eq!(tree_label(42), "42");
664    }
665
666    #[test]
667    fn multi_char_label_renders_without_panicking() {
668        // Before the live the glyph column buffer was sized to one glyph
669        // (FONT_WIDTH=4). The second glyph in 'o12' pushed cols + col to
670        // index 4, past the end of a length-4 row, and
671        // `rows[row][cols + col] = on` panicked with
672        // "index out of bounds: the len is 4 but the index is 4".
673        // The label lives in an OCR observation whose box touches the
674        // right edge of the image — the clamp in draw_label must keep
675        // every on-pixel inside the canvas.
676        let image = RgbaImage::from_pixel(40, 12, Rgba([255, 255, 255, 255]));
677        let marks = vec![MarkBox {
678            rect: ImageRect {
679                x: 24,
680                y: 0,
681                width: 16,
682                height: 12,
683            },
684            label: "o12".to_string(),
685        }];
686        let marked = draw_marks(&image, &marks);
687        // The cell size is width / cols = 16 / 12 = 1, so every on-pixel
688        // should fall inside x in [24..40) and y in [0..12). No crash, and
689        // at least one pixel inside the box must differ from the white
690        // background.
691        let mut saw_drawn_pixel = false;
692        for y in 0..12 {
693            for x in 24..40 {
694                if marked.get_pixel(x, y).to_rgba() != Rgba([255, 255, 255, 255]) {
695                    saw_drawn_pixel = true;
696                    break;
697                }
698            }
699            if saw_drawn_pixel {
700                break;
701            }
702        }
703        assert!(
704            saw_drawn_pixel,
705            "marks must leave a non-white pixel inside the labelled box"
706        );
707    }
708
709    #[test]
710    fn multi_char_label_with_touching_right_edge_stays_inside() {
711        // OCR labels can be two or three chars (o0, o12) inside a box flush
712        // with the right edge of the canvas. draw_label clamps every on-pixel
713        // to `x + dx < w` so this used to be the panic site, not a stray
714        // pixel. The box border still paints, so we only assert the call
715        // returns and that no on-pixel would have walked past the right
716        // edge of the bitmap (the input width is 8, the box right edge is
717        // x = 8, the cell width is 4 / cols(8) = 0 so draw_label returns
718        // before drawing any glyph).
719        let image = RgbaImage::from_pixel(8, 8, Rgba([255, 255, 255, 255]));
720        let marks = vec![MarkBox {
721            rect: ImageRect {
722                x: 4,
723                y: 0,
724                width: 4,
725                height: 8,
726            },
727            label: "12".to_string(),
728        }];
729        let _marked = draw_marks(&image, &marks);
730    }
731}