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