Skip to main content

urushi_graphics/
sixel.rs

1//! Stateless and cached immediate-mode Sixel presentation.
2
3use std::collections::{HashMap, HashSet};
4use std::io;
5use std::sync::Arc;
6
7use icy_sixel::{EncodeOptions, QuantizeMethod};
8use urushi::{Key, ResolvedView, View};
9use urushi_terminal::{
10    ClearRegion, Command, CommandWriter, ControlString, CursorMove, PixelSize as CellPixelSize,
11    Position as TerminalPosition, TerminalOutput,
12};
13
14use crate::{GraphicPlacement, Image, RgbaRaster};
15
16const MAX_COLORS: u16 = 256;
17const MAX_ENCODED_BANDS: usize = 64;
18const MAX_ENCODED_BYTES: usize = 64 * 1024 * 1024;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21struct BandKey {
22    asset_key: Key,
23    logical_width: usize,
24    logical_height: usize,
25    target_x: usize,
26    target_y: usize,
27    width: usize,
28    height: usize,
29}
30
31#[derive(Debug, Clone)]
32struct EncodedBand {
33    payload: Arc<str>,
34    last_used: u64,
35}
36
37#[derive(Debug, Clone, Copy)]
38struct DesiredBand<'a> {
39    key: BandKey,
40    raster: &'a RgbaRaster,
41    column: usize,
42    row: usize,
43}
44
45/// Renderer-owned cache for immediate-mode Sixel presentation.
46///
47/// Sixel placements are never treated as persistent terminal state. Every
48/// successful call to [`Self::present`] emits the complete visible image scene,
49/// while encoded cell-row bands are reused across frames. A host that changes
50/// the scene first calls [`Self::clear`], redraws its cell frame, and then calls
51/// `present`; the combined transaction belongs to that host.
52#[derive(Debug, Default)]
53pub struct SixelLifecycle {
54    encoded: HashMap<BandKey, EncodedBand>,
55    generation: u64,
56    cursor_restore_pending: bool,
57}
58
59impl SixelLifecycle {
60    #[must_use]
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Emits every visible image in one immutable desired scene.
66    ///
67    /// Encoding cache updates are safe to retain after output failure because
68    /// they describe only immutable raster transformations. A later call still
69    /// emits every visible band and therefore converges after the host redraws
70    /// the cell frame.
71    pub fn present(
72        &mut self,
73        view: &View,
74        resolved: &ResolvedView,
75        cell_pixels: CellPixelSize,
76        terminal: &mut (impl CommandWriter + ?Sized),
77    ) -> io::Result<()> {
78        self.present_images(resolved, crate::image::collect(view), cell_pixels, terminal)
79    }
80
81    /// Clears terminal cells without discarding reusable encoded raster bands.
82    ///
83    /// The caller must redraw the complete cell frame before presenting the
84    /// complete visible Sixel scene again.
85    pub fn clear(&mut self, terminal: &mut (impl CommandWriter + ?Sized)) -> io::Result<()> {
86        self.restore_cursor(terminal)?;
87        terminal.write_command(Command::Clear(ClearRegion::Screen))?;
88        terminal.write_command(Command::MoveCursor(CursorMove::To(TerminalPosition::new(
89            0, 0,
90        ))))?;
91        TerminalOutput::flush(terminal)
92    }
93
94    fn present_images<'a>(
95        &mut self,
96        resolved: &ResolvedView,
97        images: impl IntoIterator<Item = &'a Image>,
98        cell_pixels: CellPixelSize,
99        terminal: &mut (impl CommandWriter + ?Sized),
100    ) -> io::Result<()> {
101        validate_cell_pixels(cell_pixels)?;
102        self.restore_cursor(terminal)?;
103        self.generation = self.generation.saturating_add(1);
104        let bands = desired_bands(resolved, images, cell_pixels)?;
105        let live = bands.iter().map(|band| band.key).collect::<HashSet<_>>();
106
107        let result = (|| {
108            for band in bands {
109                let payload = self.encoded_band(band)?;
110                write_band(
111                    band.column,
112                    band.row,
113                    &payload,
114                    terminal,
115                    &mut self.cursor_restore_pending,
116                )?;
117            }
118            TerminalOutput::flush(terminal)
119        })();
120        if result.is_ok() {
121            self.cursor_restore_pending = false;
122        }
123        self.enforce_cache_limits(&live, MAX_ENCODED_BANDS, MAX_ENCODED_BYTES);
124        result
125    }
126
127    fn encoded_band(&mut self, band: DesiredBand<'_>) -> io::Result<Arc<str>> {
128        if let Some(encoded) = self.encoded.get_mut(&band.key) {
129            encoded.last_used = self.generation;
130            return Ok(Arc::clone(&encoded.payload));
131        }
132
133        let rgba = scale_band(band)?;
134        let options = EncodeOptions {
135            max_colors: MAX_COLORS,
136            diffusion: 0.0,
137            quantize_method: QuantizeMethod::Wu,
138        };
139        #[allow(deprecated)]
140        let encoded = icy_sixel::sixel_encode(&rgba, band.key.width, band.key.height, &options)
141            .map_err(|error| io::Error::other(format!("could not encode Sixel: {error}")))?;
142        let payload = encoded
143            .strip_prefix("\x1bP")
144            .and_then(|encoded| encoded.strip_suffix("\x1b\\"))
145            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid Sixel framing"))?;
146        ControlString::try_from(payload)
147            .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
148        let payload = Arc::<str>::from(payload);
149        self.encoded.insert(
150            band.key,
151            EncodedBand {
152                payload: Arc::clone(&payload),
153                last_used: self.generation,
154            },
155        );
156        Ok(payload)
157    }
158
159    fn enforce_cache_limits(
160        &mut self,
161        live: &HashSet<BandKey>,
162        max_entries: usize,
163        max_bytes: usize,
164    ) {
165        loop {
166            let bytes = self.encoded.values().fold(0usize, |total, band| {
167                total.saturating_add(band.payload.len())
168            });
169            if self.encoded.len() <= max_entries && bytes <= max_bytes {
170                break;
171            }
172            let Some(key) = self
173                .encoded
174                .iter()
175                .min_by_key(|(key, band)| {
176                    (
177                        live.contains(key),
178                        band.last_used,
179                        key.target_y,
180                        key.target_x,
181                    )
182                })
183                .map(|(key, _)| *key)
184            else {
185                break;
186            };
187            self.encoded.remove(&key);
188        }
189    }
190
191    fn restore_cursor(&mut self, terminal: &mut (impl CommandWriter + ?Sized)) -> io::Result<()> {
192        if !self.cursor_restore_pending {
193            return Ok(());
194        }
195        terminal.write_command(Command::RestoreCursorPosition)?;
196        TerminalOutput::flush(terminal)?;
197        self.cursor_restore_pending = false;
198        Ok(())
199    }
200}
201
202/// Queues every visible image through a stateless Sixel path.
203pub fn render_sixel<'a>(
204    view: &ResolvedView,
205    images: impl IntoIterator<Item = &'a Image>,
206    cell_pixels: CellPixelSize,
207    terminal: &mut (impl CommandWriter + ?Sized),
208) -> io::Result<()> {
209    SixelLifecycle::new().present_images(view, images, cell_pixels, terminal)
210}
211
212fn validate_cell_pixels(cell_pixels: CellPixelSize) -> io::Result<()> {
213    if cell_pixels.width() == 0 || cell_pixels.height() == 0 {
214        return Err(io::Error::new(
215            io::ErrorKind::InvalidInput,
216            "Sixel rendering requires non-zero character-cell pixel dimensions",
217        ));
218    }
219    Ok(())
220}
221
222fn desired_bands<'a>(
223    view: &ResolvedView,
224    images: impl IntoIterator<Item = &'a Image>,
225    cell_pixels: CellPixelSize,
226) -> io::Result<Vec<DesiredBand<'a>>> {
227    let mut bands = Vec::new();
228    for placement in images
229        .into_iter()
230        .filter_map(|image| image.placement(view, Some(cell_pixels)))
231    {
232        append_bands(placement, cell_pixels, &mut bands)?;
233    }
234    Ok(bands)
235}
236
237fn append_bands<'a>(
238    placement: GraphicPlacement<'a>,
239    cell_pixels: CellPixelSize,
240    bands: &mut Vec<DesiredBand<'a>>,
241) -> io::Result<()> {
242    let Some(origin) = placement.visible_origin() else {
243        return Ok(());
244    };
245    let Some(size) = placement.visible_size() else {
246        return Ok(());
247    };
248    if size.is_empty() {
249        return Ok(());
250    }
251    let column = usize::try_from(origin.x).map_err(|_| {
252        io::Error::new(
253            io::ErrorKind::InvalidInput,
254            "a Sixel image column cannot be negative",
255        )
256    })?;
257    let row = usize::try_from(origin.y).map_err(|_| {
258        io::Error::new(
259            io::ErrorKind::InvalidInput,
260            "a Sixel image row cannot be negative",
261        )
262    })?;
263    let logical = placement.logical_size();
264    let logical_width = logical
265        .width()
266        .checked_mul(cell_pixels.width())
267        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Sixel width overflow"))?;
268    let logical_height = logical
269        .height()
270        .checked_mul(cell_pixels.height())
271        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Sixel height overflow"))?;
272    let offset_x = usize::try_from(
273        origin
274            .x
275            .checked_sub(placement.logical_origin().x)
276            .ok_or_else(|| {
277                io::Error::new(
278                    io::ErrorKind::InvalidData,
279                    "Sixel clip precedes its logical image",
280                )
281            })?,
282    )
283    .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Sixel clip offset overflow"))?;
284    let offset_y = usize::try_from(
285        origin
286            .y
287            .checked_sub(placement.logical_origin().y)
288            .ok_or_else(|| {
289                io::Error::new(
290                    io::ErrorKind::InvalidData,
291                    "Sixel clip precedes its logical image",
292                )
293            })?,
294    )
295    .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Sixel clip offset overflow"))?;
296    let target_x = offset_x
297        .checked_mul(cell_pixels.width())
298        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Sixel offset overflow"))?;
299    let width = size
300        .width()
301        .checked_mul(cell_pixels.width())
302        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Sixel width overflow"))?;
303
304    for band in 0..size.height() {
305        let target_y = offset_y
306            .checked_add(band)
307            .and_then(|row| row.checked_mul(cell_pixels.height()))
308            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Sixel offset overflow"))?;
309        bands.push(DesiredBand {
310            key: BandKey {
311                asset_key: placement.raster().key(),
312                logical_width,
313                logical_height,
314                target_x,
315                target_y,
316                width,
317                height: cell_pixels.height(),
318            },
319            raster: placement.raster(),
320            column,
321            row: row + band,
322        });
323    }
324    Ok(())
325}
326
327fn scale_band(band: DesiredBand<'_>) -> io::Result<Vec<u8>> {
328    let source_width = usize::try_from(band.raster.size().width())
329        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "source width overflow"))?;
330    let source_height = usize::try_from(band.raster.size().height())
331        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "source height overflow"))?;
332    let length = band
333        .key
334        .width
335        .checked_mul(band.key.height)
336        .and_then(|pixels| pixels.checked_mul(4))
337        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "scaled raster overflow"))?;
338    let mut scaled = vec![0; length];
339    for target_y in 0..band.key.height {
340        let full_y =
341            band.key.target_y.checked_add(target_y).ok_or_else(|| {
342                io::Error::new(io::ErrorKind::InvalidInput, "scaled row overflow")
343            })?;
344        let raster_y = full_y as u128 * source_height as u128 / band.key.logical_height as u128;
345        let raster_y = usize::try_from(raster_y).map_err(|_| {
346            io::Error::new(io::ErrorKind::InvalidInput, "scaled source row overflow")
347        })?;
348        for target_x in 0..band.key.width {
349            let full_x = band.key.target_x.checked_add(target_x).ok_or_else(|| {
350                io::Error::new(io::ErrorKind::InvalidInput, "scaled column overflow")
351            })?;
352            let raster_x = full_x as u128 * source_width as u128 / band.key.logical_width as u128;
353            let raster_x = usize::try_from(raster_x).map_err(|_| {
354                io::Error::new(io::ErrorKind::InvalidInput, "scaled source column overflow")
355            })?;
356            let source = raster_y
357                .checked_mul(source_width)
358                .and_then(|offset| offset.checked_add(raster_x))
359                .and_then(|offset| offset.checked_mul(4))
360                .ok_or_else(|| {
361                    io::Error::new(io::ErrorKind::InvalidInput, "source raster offset overflow")
362                })?;
363            let target = (target_y * band.key.width + target_x) * 4;
364            let pixel = band.raster.bytes().get(source..source + 4).ok_or_else(|| {
365                io::Error::new(io::ErrorKind::InvalidData, "source raster pixel is missing")
366            })?;
367            scaled[target..target + 4].copy_from_slice(pixel);
368        }
369    }
370    Ok(scaled)
371}
372
373fn write_band(
374    column: usize,
375    row: usize,
376    encoded: &str,
377    terminal: &mut (impl CommandWriter + ?Sized),
378    cursor_restore_pending: &mut bool,
379) -> io::Result<()> {
380    let payload = ControlString::try_from(encoded)
381        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
382    terminal.write_command(Command::SaveCursorPosition)?;
383    *cursor_restore_pending = true;
384    let result = terminal
385        .write_command(Command::MoveCursor(CursorMove::To(TerminalPosition::new(
386            column, row,
387        ))))
388        .and_then(|()| terminal.write_command(Command::DeviceControl(payload)))
389        .and_then(|()| terminal.write_command(Command::RestoreCursorPosition));
390    if result.is_ok() {
391        *cursor_restore_pending = false;
392    }
393    result
394}
395
396#[cfg(test)]
397mod tests {
398    use urushi::{Available, Canvas, CanvasContext, CanvasItem, Position, Size, View, resolve};
399    use urushi_terminal::backend::ansi::AnsiWriter;
400
401    use crate::{CellSize, ImagePresentation, PixelSize};
402
403    use super::*;
404
405    #[derive(Default)]
406    struct RecordingTerminal {
407        commands: Vec<String>,
408        command_count: usize,
409        fail_command: Option<usize>,
410        flushes: usize,
411    }
412
413    impl CommandWriter for RecordingTerminal {
414        fn write_command(&mut self, command: Command<'_>) -> io::Result<()> {
415            let index = self.command_count;
416            self.command_count += 1;
417            if self.fail_command == Some(index) {
418                return Err(io::Error::other("injected command failure"));
419            }
420            self.commands.push(match command {
421                Command::SaveCursorPosition => "save".to_owned(),
422                Command::RestoreCursorPosition => "restore".to_owned(),
423                Command::MoveCursor(CursorMove::To(position)) => {
424                    format!("move {},{}", position.column(), position.row())
425                }
426                Command::DeviceControl(payload) => format!("DCS {}", payload.as_str()),
427                Command::Clear(ClearRegion::Screen) => "clear".to_owned(),
428                other => format!("{other:?}"),
429            });
430            Ok(())
431        }
432    }
433
434    impl TerminalOutput for RecordingTerminal {
435        fn flush(&mut self) -> io::Result<()> {
436            self.flushes += 1;
437            Ok(())
438        }
439    }
440
441    fn image() -> Image {
442        Image::rgba(
443            "placement",
444            "asset",
445            PixelSize::new(2, 12),
446            vec![255; 2 * 12 * 4],
447        )
448        .unwrap()
449    }
450
451    #[derive(Debug, Clone, PartialEq)]
452    struct PlacedImage {
453        view: View,
454        y: i64,
455    }
456
457    impl CanvasItem for PlacedImage {
458        fn draw(&self, context: &mut CanvasContext) {
459            context.view(
460                Position::new(0, self.y),
461                self.view.clone(),
462                Some(1),
463                Some(4),
464            );
465        }
466    }
467
468    fn viewport(image: &Image, origin: i64) -> (View, ResolvedView) {
469        let image = ImagePresentation::new().compose(image, CellSize::new(1, 4));
470        let view = View::canvas(Canvas::new().extent(Size::new(1, 3)).item(PlacedImage {
471            view: image,
472            y: -origin,
473        }));
474        let resolved = resolve(&view, Available::NONE).unwrap();
475        (view, resolved)
476    }
477
478    #[test]
479    fn scales_and_writes_one_image_as_cell_row_bands() {
480        let image = image();
481        let view = ImagePresentation::new().compose(&image, CellSize::new(1, 4));
482        let resolved = resolve(&view, Available::NONE).unwrap();
483        let mut output = AnsiWriter::new(Vec::new());
484
485        render_sixel(&resolved, [&image], CellPixelSize::new(2, 3), &mut output).unwrap();
486
487        let output = String::from_utf8(output.into_inner()).unwrap();
488        assert_eq!(output.matches("\x1bP").count(), 4);
489        assert!(output.starts_with("\x1b7\x1b[1;1H\x1bP"));
490        assert!(output.contains("\"1;1;2;3"));
491        assert!(output.ends_with("\x1b\\\x1b8"));
492    }
493
494    #[test]
495    fn scrolling_reuses_overlapping_encoded_bands() {
496        let image = image();
497        let mut lifecycle = SixelLifecycle::new();
498        let (_, first_resolved) = viewport(&image, 0);
499        lifecycle
500            .present_images(
501                &first_resolved,
502                [&image],
503                CellPixelSize::new(2, 3),
504                &mut AnsiWriter::new(Vec::new()),
505            )
506            .unwrap();
507        assert_eq!(lifecycle.encoded.len(), 3);
508
509        let (_, second_resolved) = viewport(&image, 1);
510        lifecycle
511            .present_images(
512                &second_resolved,
513                [&image],
514                CellPixelSize::new(2, 3),
515                &mut AnsiWriter::new(Vec::new()),
516            )
517            .unwrap();
518
519        assert_eq!(lifecycle.encoded.len(), 4);
520        assert_eq!(
521            lifecycle
522                .encoded
523                .keys()
524                .filter(|key| matches!(key.target_y, 3 | 6))
525                .count(),
526            2
527        );
528    }
529
530    #[test]
531    fn horizontal_clip_selects_the_matching_source_pixels() {
532        #[derive(Debug, Clone, PartialEq)]
533        struct ShiftedImage(View);
534
535        impl CanvasItem for ShiftedImage {
536            fn draw(&self, context: &mut CanvasContext) {
537                context.view(Position::new(-1, 0), self.0.clone(), Some(4), Some(1));
538            }
539        }
540
541        let image = Image::rgba(
542            "placement",
543            "asset",
544            PixelSize::new(4, 1),
545            [
546                255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
547            ],
548        )
549        .unwrap();
550        let image_view = ImagePresentation::new().compose(&image, CellSize::new(4, 1));
551        let view = View::canvas(
552            Canvas::new()
553                .extent(Size::new(2, 1))
554                .item(ShiftedImage(image_view)),
555        );
556        let resolved = resolve(&view, Available::NONE).unwrap();
557
558        let bands = desired_bands(&resolved, [&image], CellPixelSize::new(1, 1)).unwrap();
559        assert_eq!(bands.len(), 1);
560        assert_eq!(bands[0].key.target_x, 1);
561        assert_eq!(bands[0].key.width, 2);
562        assert_eq!(
563            scale_band(bands[0]).unwrap(),
564            [0, 255, 0, 255, 0, 0, 255, 255]
565        );
566    }
567
568    #[test]
569    fn clear_preserves_encoded_cache_for_the_full_redraw() {
570        let image = image();
571        let (_, resolved) = viewport(&image, 0);
572        let mut lifecycle = SixelLifecycle::new();
573        lifecycle
574            .present_images(
575                &resolved,
576                [&image],
577                CellPixelSize::new(2, 3),
578                &mut AnsiWriter::new(Vec::new()),
579            )
580            .unwrap();
581        let cached = lifecycle.encoded.len();
582        let mut output = AnsiWriter::new(Vec::new());
583
584        lifecycle.clear(&mut output).unwrap();
585
586        assert_eq!(output.into_inner(), b"\x1b[2J\x1b[1;1H");
587        assert_eq!(lifecycle.encoded.len(), cached);
588    }
589
590    #[test]
591    fn failed_output_restores_the_cursor_then_repaints_the_complete_scene() {
592        let image = image();
593        let (_, resolved) = viewport(&image, 0);
594        let mut lifecycle = SixelLifecycle::new();
595        let mut failed = RecordingTerminal {
596            fail_command: Some(2),
597            ..RecordingTerminal::default()
598        };
599
600        lifecycle
601            .present_images(&resolved, [&image], CellPixelSize::new(2, 3), &mut failed)
602            .expect_err("the injected DCS failure should abort the frame");
603        let mut recovered = RecordingTerminal::default();
604        lifecycle
605            .present_images(
606                &resolved,
607                [&image],
608                CellPixelSize::new(2, 3),
609                &mut recovered,
610            )
611            .unwrap();
612
613        assert_eq!(
614            recovered.commands.first().map(String::as_str),
615            Some("restore")
616        );
617        assert_eq!(
618            recovered
619                .commands
620                .iter()
621                .filter(|command| command.starts_with("DCS "))
622                .count(),
623            3
624        );
625        assert_eq!(recovered.flushes, 2);
626    }
627
628    #[test]
629    fn encoded_cache_evicts_the_oldest_unused_bands() {
630        let image = Image::rgba(
631            "placement",
632            "asset",
633            PixelSize::new(1, 80),
634            vec![255; 80 * 4],
635        )
636        .unwrap();
637        let mut lifecycle = SixelLifecycle::new();
638
639        for target_y in 0..70 {
640            lifecycle.generation = target_y + 1;
641            let key = BandKey {
642                asset_key: image.raster().key(),
643                logical_width: 1,
644                logical_height: 80,
645                target_x: 0,
646                target_y: usize::try_from(target_y).unwrap(),
647                width: 1,
648                height: 1,
649            };
650            lifecycle
651                .encoded_band(DesiredBand {
652                    key,
653                    raster: image.raster(),
654                    column: 0,
655                    row: 0,
656                })
657                .unwrap();
658            lifecycle.enforce_cache_limits(
659                &HashSet::from([key]),
660                MAX_ENCODED_BANDS,
661                MAX_ENCODED_BYTES,
662            );
663        }
664
665        assert_eq!(lifecycle.encoded.len(), MAX_ENCODED_BANDS);
666        assert!(lifecycle.encoded.keys().all(|key| key.target_y >= 6));
667    }
668
669    #[test]
670    fn encoded_cache_limits_apply_when_every_entry_is_in_the_visible_scene() {
671        let mut lifecycle = SixelLifecycle::new();
672        let mut live = HashSet::new();
673        for target_y in 0..3 {
674            let key = BandKey {
675                asset_key: Key::from("asset"),
676                logical_width: 1,
677                logical_height: 3,
678                target_x: 0,
679                target_y,
680                width: 1,
681                height: 1,
682            };
683            live.insert(key);
684            lifecycle.encoded.insert(
685                key,
686                EncodedBand {
687                    payload: Arc::from("1234"),
688                    last_used: u64::try_from(target_y).unwrap(),
689                },
690            );
691        }
692
693        lifecycle.enforce_cache_limits(&live, 2, 6);
694
695        assert_eq!(lifecycle.encoded.len(), 1);
696        assert_eq!(
697            lifecycle
698                .encoded
699                .values()
700                .map(|band| band.payload.len())
701                .sum::<usize>(),
702            4
703        );
704        assert!(lifecycle.encoded.keys().all(|key| key.target_y == 2));
705    }
706
707    #[test]
708    fn rejects_missing_cell_pixel_geometry() {
709        let image = image();
710        let view = ImagePresentation::new().compose(&image, CellSize::new(1, 1));
711        let resolved = resolve(&view, Available::NONE).unwrap();
712        let mut output = AnsiWriter::new(Vec::new());
713
714        let error = render_sixel(&resolved, [&image], CellPixelSize::default(), &mut output)
715            .expect_err("zero geometry is rejected");
716
717        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
718        assert!(output.into_inner().is_empty());
719    }
720}