Skip to main content

tess/
image_render.rs

1//! Pure image → ASCII-art kernel. Decodes nothing and touches no terminal;
2//! callers pass an already-decoded `RgbaImage`. Mirrors `render`'s discipline:
3//! plain inputs, plain cell outputs, exhaustively unit-tested.
4
5use crate::ansi::{Color, Style};
6use crate::render::Cell;
7use image::RgbaImage;
8use std::time::Duration;
9
10/// Decoded animation: per-frame RGBA + the delay until the next frame, plus the
11/// loop count (`None` = infinite).
12pub struct Animation {
13    pub frames: Vec<(RgbaImage, Duration)>,
14    pub loop_count: Option<u32>,
15}
16
17/// Outcome of `decode_animation`.
18pub enum AnimationDecode {
19    /// Not an animation (single frame / static): the caller decodes and
20    /// displays it normally, with no hint.
21    Static,
22    /// A decoded multi-frame animation to play.
23    Animated(Animation),
24    /// The source *is* an animation, but its frames could not be decoded
25    /// (e.g. a 16-bit APNG — the `image` crate's APNG decoder rejects
26    /// `Rgb16`). The caller should fall back to the static first frame and
27    /// hint the user, rather than silently dropping the animation. Carries a
28    /// short human-facing reason.
29    Unsupported(String),
30}
31
32/// True if the PNG carries an `acTL` (animation-control) chunk — i.e. it is an
33/// APNG, not a plain still PNG. Lets `decode_animation` distinguish "static
34/// PNG" (even 16-bit) from "animated PNG that failed to decode", so a still
35/// image is never reported as an animation failure.
36fn png_has_actl(bytes: &[u8]) -> bool {
37    bytes.windows(4).any(|w| w == b"acTL")
38}
39
40/// Extract the loop count from a GIF's NETSCAPE2.0 application extension.
41/// Returns `Some(0)` for infinite, `Some(n)` for a finite count, or `None`
42/// when the extension is absent (callers treat that as infinite). The `image`
43/// crate's high-level decoder does not expose this, so we read it directly.
44pub fn parse_gif_loop_count(bytes: &[u8]) -> Option<u32> {
45    let needle = b"\x21\xFF\x0BNETSCAPE2.0";
46    let pos = bytes.windows(needle.len()).position(|w| w == needle)?;
47    let sub = pos + needle.len();
48    // After the 11-byte "NETSCAPE2.0" name comes the loop sub-block:
49    // 0x03 (size=3), 0x01 (id=loop), then a u16 little-endian loop count.
50    if bytes.len() >= sub + 4 && bytes[sub] == 0x03 && bytes[sub + 1] == 0x01 {
51        let lo = bytes[sub + 2] as u32;
52        let hi = bytes[sub + 3] as u32;
53        return Some(lo | (hi << 8));
54    }
55    None
56}
57
58/// Decode an animated image. Returns `Static` for a single-frame / non-animated
59/// source (caller falls back to `decode_image`), `Animated` for a playable
60/// multi-frame animation, or `Unsupported` when the source is animated but its
61/// frames can't be decoded (so the caller can show the first frame *and* hint).
62/// GIF loop count comes from the NETSCAPE extension; other formats default to
63/// infinite. GIF, 8-bit APNG, and animated WebP decode to `Animated`; a 16-bit
64/// APNG (which the `image` crate rejects) decodes to `Unsupported` — both
65/// covered by `tests/animation_decode.rs`.
66pub fn decode_animation(bytes: &[u8]) -> AnimationDecode {
67    use image::AnimationDecoder;
68    let fmt = match image::guess_format(bytes) {
69        Ok(f) => f,
70        Err(_) => return AnimationDecode::Static,
71    };
72    // Decode the container's frames. A failed *decode* of an animated source
73    // becomes `Unsupported`; "not an animation at all" becomes `Static`.
74    let frames: Vec<image::Frame> = match fmt {
75        image::ImageFormat::Gif => match image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)) {
76            Ok(d) => match d.into_frames().collect_frames() {
77                Ok(f) => f,
78                Err(e) => return AnimationDecode::Unsupported(format!("GIF: {e}")),
79            },
80            Err(_) => return AnimationDecode::Static,
81        },
82        image::ImageFormat::WebP => match image::codecs::webp::WebPDecoder::new(std::io::Cursor::new(bytes)) {
83            Ok(d) => match d.into_frames().collect_frames() {
84                Ok(f) => f,
85                Err(e) => return AnimationDecode::Unsupported(format!("WebP: {e}")),
86            },
87            Err(_) => return AnimationDecode::Static,
88        },
89        image::ImageFormat::Png => {
90            // Only an APNG (acTL present) can be an animation; a plain still
91            // PNG — even 16-bit — is `Static`, never an animation failure.
92            if !png_has_actl(bytes) {
93                return AnimationDecode::Static;
94            }
95            match image::codecs::png::PngDecoder::new(std::io::Cursor::new(bytes)) {
96                Ok(d) => match d.apng() {
97                    Ok(apng) => match apng.into_frames().collect_frames() {
98                        Ok(f) => f,
99                        Err(e) => return AnimationDecode::Unsupported(format!("APNG: {e}")),
100                    },
101                    Err(e) => return AnimationDecode::Unsupported(format!("APNG: {e}")),
102                },
103                Err(_) => return AnimationDecode::Static,
104            }
105        }
106        _ => return AnimationDecode::Static,
107    };
108    if frames.len() <= 1 {
109        return AnimationDecode::Static;
110    }
111    let loop_count = if fmt == image::ImageFormat::Gif {
112        parse_gif_loop_count(bytes)
113    } else {
114        None
115    };
116    let frames = frames
117        .into_iter()
118        .map(|f| {
119            let delay: Duration = f.delay().into();
120            (f.into_buffer(), delay)
121        })
122        .collect();
123    AnimationDecode::Animated(Animation { frames, loop_count })
124}
125
126/// Rendering aesthetic.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum AsciiStyle {
129    /// Luminance → character ramp, one source block per cell.
130    Ramp,
131    /// Unicode half-block (▀): fg = top sub-block, bg = bottom sub-block.
132    Blocks,
133}
134
135/// Luminance ramp, darkest → brightest. Index by `lum * (len-1) / 255`.
136pub const RAMP: &[char] = &[' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
137
138/// Block-shade ramp for `--blocks` under `--no-color` (no SGR available).
139pub const BLOCK_SHADES: &[char] = &[' ', '░', '▒', '▓', '█'];
140
141/// Terminal cells are about twice as tall as wide.
142pub const CELL_ASPECT: u32 = 2;
143
144/// Luminance of an RGB pixel (BT.601), 0..=255.
145fn luminance(r: u8, g: u8, b: u8) -> u8 {
146    ((77 * r as u32 + 150 * g as u32 + 29 * b as u32) >> 8) as u8
147}
148
149/// Number of source-pixel rows collapsed into one cell row for `style`.
150fn pixels_per_cell_row(style: AsciiStyle, px_per_col: u32) -> u32 {
151    match style {
152        AsciiStyle::Ramp => (px_per_col * CELL_ASPECT).max(1),
153        AsciiStyle::Blocks => (px_per_col * CELL_ASPECT).max(2),
154    }
155}
156
157/// How many cell rows `render_image` produces for an image of the given pixel
158/// dimensions at `cols` columns. Pure; used for scroll math.
159pub fn output_rows(img_w: u32, img_h: u32, cols: u16, style: AsciiStyle) -> usize {
160    let cols = (cols.max(1)) as u32;
161    let img_w = img_w.max(1);
162    let px_per_col = img_w.div_ceil(cols).max(1);
163    let ppr = pixels_per_cell_row(style, px_per_col);
164    (img_h.div_ceil(ppr)).max(1) as usize
165}
166
167/// Alpha-weighted average of an image block → one RGB triple. Transparent
168/// pixels contribute proportionally less; a fully transparent block is black.
169/// Uses u64 accumulators so large blocks (few columns, big image) can't overflow.
170fn average_block(img: &RgbaImage, x0: u32, y0: u32, w: u32, h: u32) -> (u8, u8, u8) {
171    let (iw, ih) = img.dimensions();
172    let (mut r, mut g, mut b, mut sum_a) = (0u64, 0u64, 0u64, 0u64);
173    for y in y0..(y0 + h).min(ih) {
174        for x in x0..(x0 + w).min(iw) {
175            let p = img.get_pixel(x, y).0;
176            let a = p[3] as u64;
177            r += p[0] as u64 * a;
178            g += p[1] as u64 * a;
179            b += p[2] as u64 * a;
180            sum_a += a;
181        }
182    }
183    if sum_a == 0 { return (0, 0, 0); }
184    ((r / sum_a) as u8, (g / sum_a) as u8, (b / sum_a) as u8)
185}
186
187fn ramp_char(lum: u8) -> char {
188    let idx = (lum as usize * (RAMP.len() - 1)) / 255;
189    RAMP[idx]
190}
191
192fn cell_char(ch: char, fg: Option<Color>) -> Cell {
193    Cell::Char { ch, width: 1, style: Style { fg, bg: None, ..Default::default() }, hyperlink: None }
194}
195
196/// Render the image to a grid of styled cells `cols` wide. `color` controls
197/// whether per-cell foreground color is set (false ≈ `--no-color`).
198pub fn render_image(img: &RgbaImage, cols: u16, style: AsciiStyle, color: bool) -> Vec<Vec<Cell>> {
199    match style {
200        AsciiStyle::Ramp => render_ramp(img, cols, color),
201        AsciiStyle::Blocks => render_blocks(img, cols, color),
202    }
203}
204
205fn render_ramp(img: &RgbaImage, cols: u16, color: bool) -> Vec<Vec<Cell>> {
206    let (iw, ih) = img.dimensions();
207    let cols_u = cols.max(1) as u32;
208    let px_per_col = iw.max(1).div_ceil(cols_u).max(1);
209    let ppr = pixels_per_cell_row(AsciiStyle::Ramp, px_per_col);
210    let rows = output_rows(iw, ih, cols, AsciiStyle::Ramp);
211    let mut grid = Vec::with_capacity(rows);
212    for ry in 0..rows {
213        let mut row = Vec::with_capacity(cols as usize);
214        for cx in 0..cols_u {
215            let (r, g, b) = average_block(img, cx * px_per_col, ry as u32 * ppr, px_per_col, ppr);
216            let ch = ramp_char(luminance(r, g, b));
217            let fg = if color { Some(Color::Rgb(r, g, b)) } else { None };
218            row.push(cell_char(ch, fg));
219        }
220        grid.push(row);
221    }
222    grid
223}
224
225fn block_shade_char(lum: u8) -> char {
226    let idx = (lum as usize * (BLOCK_SHADES.len() - 1)) / 255;
227    BLOCK_SHADES[idx]
228}
229
230fn render_blocks(img: &RgbaImage, cols: u16, color: bool) -> Vec<Vec<Cell>> {
231    let (iw, ih) = img.dimensions();
232    let cols_u = cols.max(1) as u32;
233    let px_per_col = iw.max(1).div_ceil(cols_u).max(1);
234    let ppr = pixels_per_cell_row(AsciiStyle::Blocks, px_per_col); // even, >= 2
235    let half = (ppr / 2).max(1);
236    let rows = output_rows(iw, ih, cols, AsciiStyle::Blocks);
237    let mut grid = Vec::with_capacity(rows);
238    for ry in 0..rows {
239        let mut row = Vec::with_capacity(cols as usize);
240        let y_top = ry as u32 * ppr;
241        for cx in 0..cols_u {
242            let x0 = cx * px_per_col;
243            let (tr, tg, tb) = average_block(img, x0, y_top, px_per_col, half);
244            let (br, bg, bb) = average_block(img, x0, y_top + half, px_per_col, half);
245            if color {
246                row.push(Cell::Char {
247                    ch: '▀',
248                    width: 1,
249                    style: Style {
250                        fg: Some(Color::Rgb(tr, tg, tb)),
251                        bg: Some(Color::Rgb(br, bg, bb)),
252                        ..Default::default()
253                    },
254                    hyperlink: None,
255                });
256            } else {
257                let lum = luminance(
258                    ((tr as u16 + br as u16) / 2) as u8,
259                    ((tg as u16 + bg as u16) / 2) as u8,
260                    ((tb as u16 + bb as u16) / 2) as u8,
261                );
262                row.push(cell_char(block_shade_char(lum), None));
263            }
264        }
265        grid.push(row);
266    }
267    grid
268}
269
270/// Identify an image by its leading bytes. Returns a short format name (for the
271/// status line) or `None` if the bytes are not a supported image. Content-based
272/// only — never guesses from a file extension — so text never misfires.
273pub fn sniff_image_format(head: &[u8]) -> Option<&'static str> {
274    match image::guess_format(head).ok()? {
275        image::ImageFormat::Png => Some("png"),
276        image::ImageFormat::Jpeg => Some("jpeg"),
277        image::ImageFormat::Gif => Some("gif"),
278        image::ImageFormat::Bmp => Some("bmp"),
279        image::ImageFormat::WebP => Some("webp"),
280        image::ImageFormat::Tiff => Some("tiff"),
281        image::ImageFormat::Tga => Some("tga"),
282        image::ImageFormat::Ico => Some("ico"),
283        image::ImageFormat::Pnm => Some("pnm"),
284        _ => None,
285    }
286}
287
288/// Decode the full image bytes to RGBA8. For animated GIFs this yields the
289/// first frame. Returns the decoder error string on failure.
290pub fn decode_image(bytes: &[u8]) -> Result<RgbaImage, String> {
291    image::load_from_memory(bytes)
292        .map(|img| img.to_rgba8())
293        .map_err(|e| e.to_string())
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use image::{Rgba, RgbaImage};
300
301    #[test]
302    fn sniff_detects_png_and_gif_and_rejects_text() {
303        let png = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
304        assert_eq!(sniff_image_format(&png), Some("png"));
305        let gif = b"GIF89a............";
306        assert_eq!(sniff_image_format(gif), Some("gif"));
307        assert_eq!(sniff_image_format(b"hello, world\n"), None);
308        assert_eq!(sniff_image_format(b""), None);
309    }
310
311    #[test]
312    fn decode_roundtrips_a_generated_png() {
313        let src = RgbaImage::from_pixel(3, 2, Rgba([10, 20, 30, 255]));
314        let mut buf = std::io::Cursor::new(Vec::new());
315        image::DynamicImage::ImageRgba8(src.clone())
316            .write_to(&mut buf, image::ImageFormat::Png)
317            .unwrap();
318        let decoded = decode_image(buf.get_ref()).unwrap();
319        assert_eq!(decoded.dimensions(), (3, 2));
320        assert_eq!(decoded.get_pixel(0, 0).0, [10, 20, 30, 255]);
321    }
322
323    fn solid(w: u32, h: u32, px: [u8; 4]) -> RgbaImage {
324        RgbaImage::from_pixel(w, h, Rgba(px))
325    }
326
327    #[test]
328    fn output_rows_corrects_aspect_for_ramp() {
329        let rows = output_rows(100, 100, 50, AsciiStyle::Ramp);
330        assert_eq!(rows, 25);
331    }
332
333    #[test]
334    fn output_rows_blocks_same_cell_rows_as_ramp() {
335        let ramp = output_rows(100, 100, 50, AsciiStyle::Ramp);
336        let blocks = output_rows(100, 100, 50, AsciiStyle::Blocks);
337        assert_eq!(blocks, ramp);
338    }
339
340    #[test]
341    fn ramp_white_pixel_is_densest_glyph() {
342        let img = solid(4, 4, [255, 255, 255, 255]);
343        let grid = render_image(&img, 4, AsciiStyle::Ramp, true);
344        match &grid[0][0] {
345            Cell::Char { ch, style, .. } => {
346                assert_eq!(*ch, '@');
347                assert_eq!(style.fg, Some(Color::Rgb(255, 255, 255)));
348            }
349            other => panic!("expected Char, got {other:?}"),
350        }
351    }
352
353    #[test]
354    fn ramp_black_pixel_is_space() {
355        let img = solid(4, 4, [0, 0, 0, 255]);
356        let grid = render_image(&img, 4, AsciiStyle::Ramp, true);
357        match &grid[0][0] {
358            Cell::Char { ch, .. } => assert_eq!(*ch, ' '),
359            other => panic!("expected Char, got {other:?}"),
360        }
361    }
362
363    #[test]
364    fn ramp_no_color_sets_default_fg() {
365        let img = solid(4, 4, [255, 255, 255, 255]);
366        let grid = render_image(&img, 4, AsciiStyle::Ramp, false);
367        match &grid[0][0] {
368            Cell::Char { ch, style, .. } => {
369                assert_eq!(*ch, '@');
370                assert_eq!(style.fg, None);
371            }
372            other => panic!("expected Char, got {other:?}"),
373        }
374    }
375
376    #[test]
377    fn grid_width_matches_requested_cols() {
378        let img = solid(40, 40, [128, 128, 128, 255]);
379        let grid = render_image(&img, 20, AsciiStyle::Ramp, true);
380        assert!(grid.iter().all(|row| row.len() == 20));
381    }
382
383    #[test]
384    fn average_block_weights_by_alpha_not_pixel_count() {
385        // 2x1: one opaque white, one fully transparent. Result must be ~white.
386        let mut img = RgbaImage::new(2, 1);
387        img.put_pixel(0, 0, Rgba([255, 255, 255, 255]));
388        img.put_pixel(1, 0, Rgba([0, 0, 0, 0]));
389        // Render at 1 col so both pixels fall in one cell block.
390        let grid = render_image(&img, 1, AsciiStyle::Ramp, true);
391        match &grid[0][0] {
392            Cell::Char { style, .. } => {
393                assert_eq!(style.fg, Some(Color::Rgb(255, 255, 255)),
394                    "opaque white must dominate the transparent pixel");
395            }
396            other => panic!("expected Char, got {other:?}"),
397        }
398    }
399
400    #[test]
401    fn blocks_sets_fg_top_and_bg_bottom() {
402        // 2px wide, 2px tall: top row white, bottom row black.
403        let mut img = RgbaImage::new(2, 2);
404        for x in 0..2 { img.put_pixel(x, 0, Rgba([255, 255, 255, 255])); }
405        for x in 0..2 { img.put_pixel(x, 1, Rgba([0, 0, 0, 255])); }
406        let grid = render_image(&img, 2, AsciiStyle::Blocks, true);
407        match &grid[0][0] {
408            Cell::Char { ch, style, .. } => {
409                assert_eq!(*ch, '▀');
410                assert_eq!(style.fg, Some(Color::Rgb(255, 255, 255)), "fg = top");
411                assert_eq!(style.bg, Some(Color::Rgb(0, 0, 0)), "bg = bottom");
412            }
413            other => panic!("expected Char, got {other:?}"),
414        }
415    }
416
417    #[test]
418    fn blocks_no_color_uses_block_shades() {
419        let img = RgbaImage::from_pixel(2, 2, Rgba([255, 255, 255, 255]));
420        let grid = render_image(&img, 2, AsciiStyle::Blocks, false);
421        match &grid[0][0] {
422            Cell::Char { ch, style, .. } => {
423                assert_eq!(*ch, '█', "brightest → full block");
424                assert_eq!(style.fg, None);
425                assert_eq!(style.bg, None);
426            }
427            other => panic!("expected Char, got {other:?}"),
428        }
429    }
430
431    #[test]
432    fn gif_loop_count_parses_netscape_extension() {
433        let mut g = Vec::new();
434        g.extend_from_slice(b"GIF89a");
435        g.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0]); // logical screen descriptor (values irrelevant)
436        g.extend_from_slice(&[0x21, 0xFF, 0x0B]);
437        g.extend_from_slice(b"NETSCAPE2.0");
438        g.extend_from_slice(&[0x03, 0x01, 0x00, 0x00, 0x00]); // loop count 0 = infinite
439        assert_eq!(parse_gif_loop_count(&g), Some(0));
440
441        let mut g3 = g.clone();
442        let pos = g3.len() - 3; // the loop_lo byte
443        g3[pos] = 3;
444        assert_eq!(parse_gif_loop_count(&g3), Some(3));
445
446        assert_eq!(parse_gif_loop_count(b"GIF89a not animated"), None);
447    }
448
449    fn make_two_frame_gif() -> Vec<u8> {
450        use image::codecs::gif::GifEncoder;
451        use image::{Delay, Frame};
452        let mut out = Vec::new();
453        {
454            let mut enc = GifEncoder::new(&mut out);
455            for c in [0u8, 200] {
456                let img = RgbaImage::from_pixel(2, 2, Rgba([c, c, c, 255]));
457                let frame = Frame::from_parts(img, 0, 0, Delay::from_numer_denom_ms(100, 1));
458                enc.encode_frame(frame).unwrap();
459            }
460        }
461        out
462    }
463
464    #[test]
465    fn decode_animation_reads_frames_or_static() {
466        // Static PNG → Static.
467        let png = {
468            let src = RgbaImage::from_pixel(2, 2, Rgba([1, 2, 3, 255]));
469            let mut buf = std::io::Cursor::new(Vec::new());
470            image::DynamicImage::ImageRgba8(src)
471                .write_to(&mut buf, image::ImageFormat::Png)
472                .unwrap();
473            buf.into_inner()
474        };
475        assert!(
476            matches!(decode_animation(&png), AnimationDecode::Static),
477            "static image is not an animation"
478        );
479
480        // Two-frame GIF → Animated with 2 frames.
481        let gif = make_two_frame_gif();
482        match decode_animation(&gif) {
483            AnimationDecode::Animated(anim) => assert_eq!(anim.frames.len(), 2),
484            _ => panic!("two-frame GIF should decode as Animated"),
485        }
486    }
487
488    #[test]
489    fn png_has_actl_detects_apng_chunk() {
490        // acTL anywhere in the byte stream → treated as an APNG.
491        assert!(png_has_actl(b"\x89PNG....acTL....IDAT...."));
492        // A plain PNG without the animation-control chunk → not an APNG.
493        assert!(!png_has_actl(b"\x89PNG....IHDR....IDAT....IEND"));
494    }
495}