Skip to main content

smix_annotate/
lib.rs

1//! smix-annotate — screenshot annotation.
2//!
3//! Compose circle / arrow / text / box / line primitives onto a PNG,
4//! then emit compressed output. RFC: `docs/ai-guide/rfc-v0.3.0-
5//! annotated-screenshots.md`.
6//!
7//! # Quick tour
8//!
9//! ```rust,ignore
10//! use smix_annotate::{Annotator, Annotation, Color, Position, Compression};
11//!
12//! let png = std::fs::read("input.png")?;
13//! let out = Annotator::new(&png)?
14//!     .add(Annotation::circle(Position::pixel(100, 100))
15//!         .color(Color::RED)
16//!         .radius(40))
17//!     .compression(Compression::BALANCED)
18//!     .render()?;
19//! std::fs::write("output.png", out)?;
20//! ```
21//!
22//! # v0.3.0 scope
23//!
24//! - 5 primitives: circle, arrow, text, box, line
25//! - Position resolvers: absolute pixel + normalized (0..1)
26//!   (selector-relative via a11y tree — wire lives in
27//!   `smix-adapter-maestro` runtime, not in this crate; caller
28//!   resolves selector → pixel before adding annotation)
29//! - Named + hex + rgba color palette
30//! - Compression 3 presets: fast, balanced, aggressive
31//! - Text rendering via `ab_glyph`; font supplied by caller (v0.3.5
32//!   will bundle Inter + Noto Sans SC)
33
34use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
35use image::{DynamicImage, Rgba, RgbaImage};
36use imageproc::drawing;
37use thiserror::Error;
38
39pub mod color;
40pub use color::Color;
41
42#[derive(Debug, Error)]
43pub enum AnnotateError {
44    #[error("decode input PNG: {0}")]
45    Decode(#[from] image::ImageError),
46    #[error("compress output PNG: {0}")]
47    Compress(String),
48    #[error("font load: {0}")]
49    Font(String),
50    #[error("text annotation without font — call `.font(bytes)` before `.render()`")]
51    MissingFont,
52}
53
54/// Position of an annotation on the image.
55#[derive(Clone, Copy, Debug, PartialEq)]
56pub enum Position {
57    /// Absolute pixel coordinates, top-left origin.
58    Pixel { x: i32, y: i32 },
59    /// Normalized 0..1 in each dimension. Resolved against the image
60    /// size at [`Annotator::render`] time.
61    Normalized { nx: f32, ny: f32 },
62}
63
64impl Position {
65    pub fn pixel(x: i32, y: i32) -> Self {
66        Position::Pixel { x, y }
67    }
68
69    pub fn normalized(nx: f32, ny: f32) -> Self {
70        Position::Normalized { nx, ny }
71    }
72
73    fn resolve(self, w: u32, h: u32) -> (i32, i32) {
74        match self {
75            Position::Pixel { x, y } => (x, y),
76            Position::Normalized { nx, ny } => ((nx * w as f32) as i32, (ny * h as f32) as i32),
77        }
78    }
79}
80
81/// One drawing primitive.
82#[derive(Clone, Debug)]
83pub enum Annotation {
84    Circle {
85        at: Position,
86        color: Color,
87        radius: i32,
88        stroke: i32,
89    },
90    Arrow {
91        from: Position,
92        to: Position,
93        color: Color,
94        stroke: i32,
95        head_filled: bool,
96    },
97    Text {
98        at: Position,
99        content: String,
100        color: Color,
101        size: f32,
102    },
103    Box {
104        at: Position,
105        width: i32,
106        height: i32,
107        color: Color,
108        stroke: i32,
109    },
110    Line {
111        from: Position,
112        to: Position,
113        color: Color,
114        stroke: i32,
115    },
116}
117
118impl Annotation {
119    pub fn circle(at: Position) -> CircleBuilder {
120        CircleBuilder {
121            at,
122            color: Color::RED,
123            radius: 30,
124            stroke: 3,
125        }
126    }
127
128    pub fn arrow(from: Position, to: Position) -> ArrowBuilder {
129        ArrowBuilder {
130            from,
131            to,
132            color: Color::BLUE,
133            stroke: 4,
134            head_filled: true,
135        }
136    }
137
138    pub fn text(at: Position, content: impl Into<String>) -> TextBuilder {
139        TextBuilder {
140            at,
141            content: content.into(),
142            color: Color::WHITE,
143            size: 24.0,
144        }
145    }
146
147    pub fn box_(at: Position, width: i32, height: i32) -> BoxBuilder {
148        BoxBuilder {
149            at,
150            width,
151            height,
152            color: Color::YELLOW,
153            stroke: 2,
154        }
155    }
156
157    pub fn line(from: Position, to: Position) -> LineBuilder {
158        LineBuilder {
159            from,
160            to,
161            color: Color::CYAN,
162            stroke: 2,
163        }
164    }
165}
166
167pub struct CircleBuilder {
168    at: Position,
169    color: Color,
170    radius: i32,
171    stroke: i32,
172}
173
174impl CircleBuilder {
175    pub fn color(mut self, c: Color) -> Self {
176        self.color = c;
177        self
178    }
179    pub fn radius(mut self, r: i32) -> Self {
180        self.radius = r;
181        self
182    }
183    pub fn stroke(mut self, s: i32) -> Self {
184        self.stroke = s;
185        self
186    }
187    pub fn build(self) -> Annotation {
188        Annotation::Circle {
189            at: self.at,
190            color: self.color,
191            radius: self.radius,
192            stroke: self.stroke,
193        }
194    }
195}
196
197impl From<CircleBuilder> for Annotation {
198    fn from(b: CircleBuilder) -> Self {
199        b.build()
200    }
201}
202
203pub struct ArrowBuilder {
204    from: Position,
205    to: Position,
206    color: Color,
207    stroke: i32,
208    head_filled: bool,
209}
210
211impl ArrowBuilder {
212    pub fn color(mut self, c: Color) -> Self {
213        self.color = c;
214        self
215    }
216    pub fn stroke(mut self, s: i32) -> Self {
217        self.stroke = s;
218        self
219    }
220    pub fn head_open(mut self) -> Self {
221        self.head_filled = false;
222        self
223    }
224    pub fn build(self) -> Annotation {
225        Annotation::Arrow {
226            from: self.from,
227            to: self.to,
228            color: self.color,
229            stroke: self.stroke,
230            head_filled: self.head_filled,
231        }
232    }
233}
234
235impl From<ArrowBuilder> for Annotation {
236    fn from(b: ArrowBuilder) -> Self {
237        b.build()
238    }
239}
240
241pub struct TextBuilder {
242    at: Position,
243    content: String,
244    color: Color,
245    size: f32,
246}
247
248impl TextBuilder {
249    pub fn color(mut self, c: Color) -> Self {
250        self.color = c;
251        self
252    }
253    pub fn size(mut self, s: f32) -> Self {
254        self.size = s;
255        self
256    }
257    pub fn build(self) -> Annotation {
258        Annotation::Text {
259            at: self.at,
260            content: self.content,
261            color: self.color,
262            size: self.size,
263        }
264    }
265}
266
267impl From<TextBuilder> for Annotation {
268    fn from(b: TextBuilder) -> Self {
269        b.build()
270    }
271}
272
273pub struct BoxBuilder {
274    at: Position,
275    width: i32,
276    height: i32,
277    color: Color,
278    stroke: i32,
279}
280
281impl BoxBuilder {
282    pub fn color(mut self, c: Color) -> Self {
283        self.color = c;
284        self
285    }
286    pub fn stroke(mut self, s: i32) -> Self {
287        self.stroke = s;
288        self
289    }
290    pub fn build(self) -> Annotation {
291        Annotation::Box {
292            at: self.at,
293            width: self.width,
294            height: self.height,
295            color: self.color,
296            stroke: self.stroke,
297        }
298    }
299}
300
301impl From<BoxBuilder> for Annotation {
302    fn from(b: BoxBuilder) -> Self {
303        b.build()
304    }
305}
306
307pub struct LineBuilder {
308    from: Position,
309    to: Position,
310    color: Color,
311    stroke: i32,
312}
313
314impl LineBuilder {
315    pub fn color(mut self, c: Color) -> Self {
316        self.color = c;
317        self
318    }
319    pub fn stroke(mut self, s: i32) -> Self {
320        self.stroke = s;
321        self
322    }
323    pub fn build(self) -> Annotation {
324        Annotation::Line {
325            from: self.from,
326            to: self.to,
327            color: self.color,
328            stroke: self.stroke,
329        }
330    }
331}
332
333impl From<LineBuilder> for Annotation {
334    fn from(b: LineBuilder) -> Self {
335        b.build()
336    }
337}
338
339/// PNG output compression preset.
340#[derive(Clone, Copy, Debug, PartialEq, Eq)]
341pub enum Compression {
342    /// No oxipng pass; small file overhead but ~5× faster to write.
343    Fast,
344    /// Balanced default (oxipng optimization 2).
345    Balanced,
346    /// Maximum shrink (oxipng optimization 6 + zopfli).
347    Aggressive,
348}
349
350impl Compression {
351    pub const FAST: Self = Compression::Fast;
352    pub const BALANCED: Self = Compression::Balanced;
353    pub const AGGRESSIVE: Self = Compression::Aggressive;
354}
355
356#[allow(clippy::derivable_impls)]
357impl Default for Compression {
358    fn default() -> Self {
359        Compression::Balanced
360    }
361}
362
363/// Annotator builder.
364pub struct Annotator {
365    image: RgbaImage,
366    annotations: Vec<Annotation>,
367    font_bytes: Option<Vec<u8>>,
368    compression: Compression,
369}
370
371impl Annotator {
372    /// Decode `png_bytes` and prepare for annotation.
373    pub fn new(png_bytes: &[u8]) -> Result<Self, AnnotateError> {
374        let dyn_img = image::load_from_memory(png_bytes)?;
375        let image = dyn_img.to_rgba8();
376        Ok(Self {
377            image,
378            annotations: Vec::new(),
379            font_bytes: None,
380            compression: Compression::default(),
381        })
382    }
383
384    /// Add an annotation. Accepts any builder or a pre-built
385    /// [`Annotation`].
386    #[allow(clippy::should_implement_trait)]
387    pub fn add(mut self, a: impl Into<Annotation>) -> Self {
388        self.annotations.push(a.into());
389        self
390    }
391
392    /// Provide a TTF font for text annotations. Optional (unused
393    /// annotations without text primitives).
394    pub fn font(mut self, bytes: Vec<u8>) -> Self {
395        self.font_bytes = Some(bytes);
396        self
397    }
398
399    pub fn compression(mut self, c: Compression) -> Self {
400        self.compression = c;
401        self
402    }
403
404    /// Render all annotations and return the PNG-encoded (+ optionally
405    /// compressed) output bytes.
406    pub fn render(mut self) -> Result<Vec<u8>, AnnotateError> {
407        let (w, h) = (self.image.width(), self.image.height());
408        // Font is only needed for text primitives; compile lazily so
409        // fontless callers still work.
410        let font = self
411            .font_bytes
412            .as_ref()
413            .map(|b| FontRef::try_from_slice(b).map_err(|e| AnnotateError::Font(e.to_string())))
414            .transpose()?;
415        for ann in std::mem::take(&mut self.annotations) {
416            match ann {
417                Annotation::Circle {
418                    at,
419                    color,
420                    radius,
421                    stroke,
422                } => {
423                    let (cx, cy) = at.resolve(w, h);
424                    let rgba = Rgba(color.to_rgba());
425                    for i in 0..stroke {
426                        drawing::draw_hollow_circle_mut(
427                            &mut self.image,
428                            (cx, cy),
429                            (radius - i).max(1),
430                            rgba,
431                        );
432                    }
433                }
434                Annotation::Arrow {
435                    from,
436                    to,
437                    color,
438                    stroke,
439                    head_filled,
440                } => {
441                    let (fx, fy) = from.resolve(w, h);
442                    let (tx, ty) = to.resolve(w, h);
443                    let rgba = Rgba(color.to_rgba());
444                    // Main shaft (thick line = repeat draws with offset).
445                    for i in -(stroke / 2)..=stroke / 2 {
446                        drawing::draw_line_segment_mut(
447                            &mut self.image,
448                            (fx as f32 + i as f32, fy as f32),
449                            (tx as f32 + i as f32, ty as f32),
450                            rgba,
451                        );
452                    }
453                    // Arrow head — two short segments at ±30° from the shaft.
454                    let dx = (tx - fx) as f32;
455                    let dy = (ty - fy) as f32;
456                    let len = (dx * dx + dy * dy).sqrt().max(1.0);
457                    let ux = dx / len;
458                    let uy = dy / len;
459                    let head_len = 15.0_f32.min(len / 3.0);
460                    // Rotate ±150° from tip.
461                    let angle = 30.0_f32.to_radians();
462                    let (sin, cos) = (angle.sin(), angle.cos());
463                    let hx1 = tx as f32 - head_len * (ux * cos - uy * sin);
464                    let hy1 = ty as f32 - head_len * (uy * cos + ux * sin);
465                    let hx2 = tx as f32 - head_len * (ux * cos + uy * sin);
466                    let hy2 = ty as f32 - head_len * (uy * cos - ux * sin);
467                    drawing::draw_line_segment_mut(
468                        &mut self.image,
469                        (tx as f32, ty as f32),
470                        (hx1, hy1),
471                        rgba,
472                    );
473                    drawing::draw_line_segment_mut(
474                        &mut self.image,
475                        (tx as f32, ty as f32),
476                        (hx2, hy2),
477                        rgba,
478                    );
479                    if head_filled {
480                        // Simple filled head — draw a tiny triangle
481                        // (three lines connecting the three head points).
482                        drawing::draw_line_segment_mut(
483                            &mut self.image,
484                            (hx1, hy1),
485                            (hx2, hy2),
486                            rgba,
487                        );
488                    }
489                }
490                Annotation::Text {
491                    at,
492                    content,
493                    color,
494                    size,
495                } => {
496                    let font = font.as_ref().ok_or(AnnotateError::MissingFont)?;
497                    let (x, y) = at.resolve(w, h);
498                    let scale = PxScale::from(size);
499                    let scaled = font.as_scaled(scale);
500                    let ascent = scaled.ascent();
501                    let rgba = Rgba(color.to_rgba());
502                    let mut caret_x = x as f32;
503                    let baseline_y = y as f32 + ascent;
504                    for ch in content.chars() {
505                        let glyph = scaled.scaled_glyph(ch);
506                        let h_adv = scaled.h_advance(glyph.id);
507                        if let Some(outlined) = font.outline_glyph(glyph) {
508                            let bounds = outlined.px_bounds();
509                            outlined.draw(|gx, gy, alpha| {
510                                let px = caret_x + bounds.min.x + gx as f32;
511                                let py = baseline_y + bounds.min.y + gy as f32;
512                                if px >= 0.0 && py >= 0.0 && (px as u32) < w && (py as u32) < h {
513                                    let blended = blend_pixel(
514                                        self.image.get_pixel(px as u32, py as u32).0,
515                                        rgba.0,
516                                        alpha,
517                                    );
518                                    self.image.put_pixel(px as u32, py as u32, Rgba(blended));
519                                }
520                            });
521                        }
522                        caret_x += h_adv;
523                    }
524                }
525                Annotation::Box {
526                    at,
527                    width,
528                    height,
529                    color,
530                    stroke,
531                } => {
532                    let (x, y) = at.resolve(w, h);
533                    let rgba = Rgba(color.to_rgba());
534                    for i in 0..stroke {
535                        let rect = imageproc::rect::Rect::at(x + i, y + i).of_size(
536                            (width - 2 * i).max(1) as u32,
537                            (height - 2 * i).max(1) as u32,
538                        );
539                        drawing::draw_hollow_rect_mut(&mut self.image, rect, rgba);
540                    }
541                }
542                Annotation::Line {
543                    from,
544                    to,
545                    color,
546                    stroke,
547                } => {
548                    let (fx, fy) = from.resolve(w, h);
549                    let (tx, ty) = to.resolve(w, h);
550                    let rgba = Rgba(color.to_rgba());
551                    for i in -(stroke / 2)..=stroke / 2 {
552                        drawing::draw_line_segment_mut(
553                            &mut self.image,
554                            (fx as f32, fy as f32 + i as f32),
555                            (tx as f32, ty as f32 + i as f32),
556                            rgba,
557                        );
558                    }
559                }
560            }
561        }
562        // Encode.
563        let mut raw = Vec::new();
564        let dyn_img = DynamicImage::ImageRgba8(self.image);
565        dyn_img
566            .write_to(&mut std::io::Cursor::new(&mut raw), image::ImageFormat::Png)
567            .map_err(AnnotateError::Decode)?;
568        // Compression pass.
569        match self.compression {
570            Compression::Fast => Ok(raw),
571            Compression::Balanced => oxipng::optimize_from_memory(
572                &raw,
573                &oxipng::Options {
574                    optimize_alpha: false,
575                    ..oxipng::Options::from_preset(2)
576                },
577            )
578            .map_err(|e| AnnotateError::Compress(e.to_string())),
579            Compression::Aggressive => oxipng::optimize_from_memory(
580                &raw,
581                &oxipng::Options {
582                    optimize_alpha: false,
583                    ..oxipng::Options::from_preset(6)
584                },
585            )
586            .map_err(|e| AnnotateError::Compress(e.to_string())),
587        }
588    }
589}
590
591fn blend_pixel(bg: [u8; 4], fg: [u8; 4], alpha: f32) -> [u8; 4] {
592    let a = (fg[3] as f32 / 255.0) * alpha;
593    let inv_a = 1.0 - a;
594    [
595        (bg[0] as f32 * inv_a + fg[0] as f32 * a) as u8,
596        (bg[1] as f32 * inv_a + fg[1] as f32 * a) as u8,
597        (bg[2] as f32 * inv_a + fg[2] as f32 * a) as u8,
598        (bg[3] as f32).max(fg[3] as f32) as u8,
599    ]
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    fn blank_png() -> Vec<u8> {
607        let img: RgbaImage = image::ImageBuffer::from_pixel(100, 100, Rgba([0, 0, 0, 255]));
608        let mut out = Vec::new();
609        DynamicImage::ImageRgba8(img)
610            .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
611            .unwrap();
612        out
613    }
614
615    #[test]
616    fn circle_pixels_written() {
617        let png = blank_png();
618        let out = Annotator::new(&png)
619            .unwrap()
620            .add(
621                Annotation::circle(Position::pixel(50, 50))
622                    .color(Color::RED)
623                    .radius(20),
624            )
625            .compression(Compression::Fast)
626            .render()
627            .unwrap();
628        let img = image::load_from_memory(&out).unwrap().to_rgba8();
629        // The circle passes through (50+20, 50) = (70, 50). Check that
630        // pixel is red-ish (dominant R channel).
631        let px = img.get_pixel(70, 50);
632        assert!(px[0] > 100, "expected red-dominant, got {px:?}");
633    }
634
635    #[test]
636    fn arrow_pixels_written() {
637        let png = blank_png();
638        let out = Annotator::new(&png)
639            .unwrap()
640            .add(Annotation::arrow(
641                Position::pixel(10, 10),
642                Position::pixel(90, 90),
643            ))
644            .compression(Compression::Fast)
645            .render()
646            .unwrap();
647        let img = image::load_from_memory(&out).unwrap().to_rgba8();
648        // Midpoint (50, 50) should be blue-dominant.
649        let px = img.get_pixel(50, 50);
650        assert!(px[2] > 100, "expected blue-dominant midpoint, got {px:?}");
651    }
652
653    #[test]
654    fn box_pixels_written() {
655        let png = blank_png();
656        let out = Annotator::new(&png)
657            .unwrap()
658            .add(Annotation::box_(Position::pixel(20, 20), 60, 60).color(Color::YELLOW))
659            .compression(Compression::Fast)
660            .render()
661            .unwrap();
662        let img = image::load_from_memory(&out).unwrap().to_rgba8();
663        // Top edge of the box: (25, 20) should be yellow (R + G high).
664        let px = img.get_pixel(25, 20);
665        assert!(
666            px[0] > 100 && px[1] > 100,
667            "expected yellow edge, got {px:?}"
668        );
669    }
670
671    #[test]
672    fn line_pixels_written() {
673        let png = blank_png();
674        let out = Annotator::new(&png)
675            .unwrap()
676            .add(Annotation::line(
677                Position::pixel(0, 50),
678                Position::pixel(99, 50),
679            ))
680            .compression(Compression::Fast)
681            .render()
682            .unwrap();
683        let img = image::load_from_memory(&out).unwrap().to_rgba8();
684        // Middle of the line: (50, 50) should be cyan (G + B high).
685        let px = img.get_pixel(50, 50);
686        assert!(
687            px[1] > 100 && px[2] > 100,
688            "expected cyan midpoint, got {px:?}"
689        );
690    }
691
692    #[test]
693    fn text_without_font_errors() {
694        let png = blank_png();
695        let err = Annotator::new(&png)
696            .unwrap()
697            .add(Annotation::text(Position::pixel(10, 10), "hello"))
698            .render()
699            .unwrap_err();
700        assert!(matches!(err, AnnotateError::MissingFont));
701    }
702
703    #[test]
704    fn compression_presets_all_encode() {
705        for preset in [
706            Compression::Fast,
707            Compression::Balanced,
708            Compression::Aggressive,
709        ] {
710            let png = blank_png();
711            let out = Annotator::new(&png)
712                .unwrap()
713                .add(
714                    Annotation::circle(Position::pixel(50, 50))
715                        .color(Color::RED)
716                        .radius(20),
717                )
718                .compression(preset)
719                .render()
720                .unwrap();
721            assert!(!out.is_empty(), "preset {preset:?} produced empty output");
722            // Verify it's valid PNG.
723            let _ = image::load_from_memory(&out).unwrap();
724        }
725    }
726
727    #[test]
728    fn normalized_position_resolves() {
729        let (x, y) = Position::normalized(0.5, 0.5).resolve(100, 100);
730        assert_eq!((x, y), (50, 50));
731    }
732
733    #[test]
734    fn compression_default_is_balanced() {
735        assert_eq!(Compression::default(), Compression::Balanced);
736    }
737}