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