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