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;
8
9/// Rendering aesthetic.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum AsciiStyle {
12    /// Luminance → character ramp, one source block per cell.
13    Ramp,
14    /// Unicode half-block (▀): fg = top sub-block, bg = bottom sub-block.
15    Blocks,
16}
17
18/// Luminance ramp, darkest → brightest. Index by `lum * (len-1) / 255`.
19pub const RAMP: &[char] = &[' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
20
21/// Block-shade ramp for `--blocks` under `--no-color` (no SGR available).
22pub const BLOCK_SHADES: &[char] = &[' ', '░', '▒', '▓', '█'];
23
24/// Terminal cells are about twice as tall as wide.
25pub const CELL_ASPECT: u32 = 2;
26
27/// Luminance of an RGB pixel (BT.601), 0..=255.
28fn luminance(r: u8, g: u8, b: u8) -> u8 {
29    ((77 * r as u32 + 150 * g as u32 + 29 * b as u32) >> 8) as u8
30}
31
32/// Number of source-pixel rows collapsed into one cell row for `style`.
33fn pixels_per_cell_row(style: AsciiStyle, px_per_col: u32) -> u32 {
34    match style {
35        AsciiStyle::Ramp => (px_per_col * CELL_ASPECT).max(1),
36        AsciiStyle::Blocks => (px_per_col * CELL_ASPECT).max(2),
37    }
38}
39
40/// How many cell rows `render_image` produces for an image of the given pixel
41/// dimensions at `cols` columns. Pure; used for scroll math.
42pub fn output_rows(img_w: u32, img_h: u32, cols: u16, style: AsciiStyle) -> usize {
43    let cols = (cols.max(1)) as u32;
44    let img_w = img_w.max(1);
45    let px_per_col = img_w.div_ceil(cols).max(1);
46    let ppr = pixels_per_cell_row(style, px_per_col);
47    (img_h.div_ceil(ppr)).max(1) as usize
48}
49
50/// Alpha-weighted average of an image block → one RGB triple. Transparent
51/// pixels contribute proportionally less; a fully transparent block is black.
52/// Uses u64 accumulators so large blocks (few columns, big image) can't overflow.
53fn average_block(img: &RgbaImage, x0: u32, y0: u32, w: u32, h: u32) -> (u8, u8, u8) {
54    let (iw, ih) = img.dimensions();
55    let (mut r, mut g, mut b, mut sum_a) = (0u64, 0u64, 0u64, 0u64);
56    for y in y0..(y0 + h).min(ih) {
57        for x in x0..(x0 + w).min(iw) {
58            let p = img.get_pixel(x, y).0;
59            let a = p[3] as u64;
60            r += p[0] as u64 * a;
61            g += p[1] as u64 * a;
62            b += p[2] as u64 * a;
63            sum_a += a;
64        }
65    }
66    if sum_a == 0 { return (0, 0, 0); }
67    ((r / sum_a) as u8, (g / sum_a) as u8, (b / sum_a) as u8)
68}
69
70fn ramp_char(lum: u8) -> char {
71    let idx = (lum as usize * (RAMP.len() - 1)) / 255;
72    RAMP[idx]
73}
74
75fn cell_char(ch: char, fg: Option<Color>) -> Cell {
76    Cell::Char { ch, width: 1, style: Style { fg, bg: None, ..Default::default() }, hyperlink: None }
77}
78
79/// Render the image to a grid of styled cells `cols` wide. `color` controls
80/// whether per-cell foreground color is set (false ≈ `--no-color`).
81pub fn render_image(img: &RgbaImage, cols: u16, style: AsciiStyle, color: bool) -> Vec<Vec<Cell>> {
82    match style {
83        AsciiStyle::Ramp => render_ramp(img, cols, color),
84        AsciiStyle::Blocks => render_blocks(img, cols, color),
85    }
86}
87
88fn render_ramp(img: &RgbaImage, cols: u16, color: bool) -> Vec<Vec<Cell>> {
89    let (iw, ih) = img.dimensions();
90    let cols_u = cols.max(1) as u32;
91    let px_per_col = iw.max(1).div_ceil(cols_u).max(1);
92    let ppr = pixels_per_cell_row(AsciiStyle::Ramp, px_per_col);
93    let rows = output_rows(iw, ih, cols, AsciiStyle::Ramp);
94    let mut grid = Vec::with_capacity(rows);
95    for ry in 0..rows {
96        let mut row = Vec::with_capacity(cols as usize);
97        for cx in 0..cols_u {
98            let (r, g, b) = average_block(img, cx * px_per_col, ry as u32 * ppr, px_per_col, ppr);
99            let ch = ramp_char(luminance(r, g, b));
100            let fg = if color { Some(Color::Rgb(r, g, b)) } else { None };
101            row.push(cell_char(ch, fg));
102        }
103        grid.push(row);
104    }
105    grid
106}
107
108fn block_shade_char(lum: u8) -> char {
109    let idx = (lum as usize * (BLOCK_SHADES.len() - 1)) / 255;
110    BLOCK_SHADES[idx]
111}
112
113fn render_blocks(img: &RgbaImage, cols: u16, color: bool) -> Vec<Vec<Cell>> {
114    let (iw, ih) = img.dimensions();
115    let cols_u = cols.max(1) as u32;
116    let px_per_col = iw.max(1).div_ceil(cols_u).max(1);
117    let ppr = pixels_per_cell_row(AsciiStyle::Blocks, px_per_col); // even, >= 2
118    let half = (ppr / 2).max(1);
119    let rows = output_rows(iw, ih, cols, AsciiStyle::Blocks);
120    let mut grid = Vec::with_capacity(rows);
121    for ry in 0..rows {
122        let mut row = Vec::with_capacity(cols as usize);
123        let y_top = ry as u32 * ppr;
124        for cx in 0..cols_u {
125            let x0 = cx * px_per_col;
126            let (tr, tg, tb) = average_block(img, x0, y_top, px_per_col, half);
127            let (br, bg, bb) = average_block(img, x0, y_top + half, px_per_col, half);
128            if color {
129                row.push(Cell::Char {
130                    ch: '▀',
131                    width: 1,
132                    style: Style {
133                        fg: Some(Color::Rgb(tr, tg, tb)),
134                        bg: Some(Color::Rgb(br, bg, bb)),
135                        ..Default::default()
136                    },
137                    hyperlink: None,
138                });
139            } else {
140                let lum = luminance(
141                    ((tr as u16 + br as u16) / 2) as u8,
142                    ((tg as u16 + bg as u16) / 2) as u8,
143                    ((tb as u16 + bb as u16) / 2) as u8,
144                );
145                row.push(cell_char(block_shade_char(lum), None));
146            }
147        }
148        grid.push(row);
149    }
150    grid
151}
152
153/// Identify an image by its leading bytes. Returns a short format name (for the
154/// status line) or `None` if the bytes are not a supported image. Content-based
155/// only — never guesses from a file extension — so text never misfires.
156pub fn sniff_image_format(head: &[u8]) -> Option<&'static str> {
157    match image::guess_format(head).ok()? {
158        image::ImageFormat::Png => Some("png"),
159        image::ImageFormat::Jpeg => Some("jpeg"),
160        image::ImageFormat::Gif => Some("gif"),
161        image::ImageFormat::Bmp => Some("bmp"),
162        image::ImageFormat::WebP => Some("webp"),
163        image::ImageFormat::Tiff => Some("tiff"),
164        image::ImageFormat::Tga => Some("tga"),
165        image::ImageFormat::Ico => Some("ico"),
166        image::ImageFormat::Pnm => Some("pnm"),
167        _ => None,
168    }
169}
170
171/// Decode the full image bytes to RGBA8. For animated GIFs this yields the
172/// first frame. Returns the decoder error string on failure.
173pub fn decode_image(bytes: &[u8]) -> Result<RgbaImage, String> {
174    image::load_from_memory(bytes)
175        .map(|img| img.to_rgba8())
176        .map_err(|e| e.to_string())
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use image::{Rgba, RgbaImage};
183
184    #[test]
185    fn sniff_detects_png_and_gif_and_rejects_text() {
186        let png = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
187        assert_eq!(sniff_image_format(&png), Some("png"));
188        let gif = b"GIF89a............";
189        assert_eq!(sniff_image_format(gif), Some("gif"));
190        assert_eq!(sniff_image_format(b"hello, world\n"), None);
191        assert_eq!(sniff_image_format(b""), None);
192    }
193
194    #[test]
195    fn decode_roundtrips_a_generated_png() {
196        let src = RgbaImage::from_pixel(3, 2, Rgba([10, 20, 30, 255]));
197        let mut buf = std::io::Cursor::new(Vec::new());
198        image::DynamicImage::ImageRgba8(src.clone())
199            .write_to(&mut buf, image::ImageFormat::Png)
200            .unwrap();
201        let decoded = decode_image(buf.get_ref()).unwrap();
202        assert_eq!(decoded.dimensions(), (3, 2));
203        assert_eq!(decoded.get_pixel(0, 0).0, [10, 20, 30, 255]);
204    }
205
206    fn solid(w: u32, h: u32, px: [u8; 4]) -> RgbaImage {
207        RgbaImage::from_pixel(w, h, Rgba(px))
208    }
209
210    #[test]
211    fn output_rows_corrects_aspect_for_ramp() {
212        let rows = output_rows(100, 100, 50, AsciiStyle::Ramp);
213        assert_eq!(rows, 25);
214    }
215
216    #[test]
217    fn output_rows_blocks_same_cell_rows_as_ramp() {
218        let ramp = output_rows(100, 100, 50, AsciiStyle::Ramp);
219        let blocks = output_rows(100, 100, 50, AsciiStyle::Blocks);
220        assert_eq!(blocks, ramp);
221    }
222
223    #[test]
224    fn ramp_white_pixel_is_densest_glyph() {
225        let img = solid(4, 4, [255, 255, 255, 255]);
226        let grid = render_image(&img, 4, AsciiStyle::Ramp, true);
227        match &grid[0][0] {
228            Cell::Char { ch, style, .. } => {
229                assert_eq!(*ch, '@');
230                assert_eq!(style.fg, Some(Color::Rgb(255, 255, 255)));
231            }
232            other => panic!("expected Char, got {other:?}"),
233        }
234    }
235
236    #[test]
237    fn ramp_black_pixel_is_space() {
238        let img = solid(4, 4, [0, 0, 0, 255]);
239        let grid = render_image(&img, 4, AsciiStyle::Ramp, true);
240        match &grid[0][0] {
241            Cell::Char { ch, .. } => assert_eq!(*ch, ' '),
242            other => panic!("expected Char, got {other:?}"),
243        }
244    }
245
246    #[test]
247    fn ramp_no_color_sets_default_fg() {
248        let img = solid(4, 4, [255, 255, 255, 255]);
249        let grid = render_image(&img, 4, AsciiStyle::Ramp, false);
250        match &grid[0][0] {
251            Cell::Char { ch, style, .. } => {
252                assert_eq!(*ch, '@');
253                assert_eq!(style.fg, None);
254            }
255            other => panic!("expected Char, got {other:?}"),
256        }
257    }
258
259    #[test]
260    fn grid_width_matches_requested_cols() {
261        let img = solid(40, 40, [128, 128, 128, 255]);
262        let grid = render_image(&img, 20, AsciiStyle::Ramp, true);
263        assert!(grid.iter().all(|row| row.len() == 20));
264    }
265
266    #[test]
267    fn average_block_weights_by_alpha_not_pixel_count() {
268        // 2x1: one opaque white, one fully transparent. Result must be ~white.
269        let mut img = RgbaImage::new(2, 1);
270        img.put_pixel(0, 0, Rgba([255, 255, 255, 255]));
271        img.put_pixel(1, 0, Rgba([0, 0, 0, 0]));
272        // Render at 1 col so both pixels fall in one cell block.
273        let grid = render_image(&img, 1, AsciiStyle::Ramp, true);
274        match &grid[0][0] {
275            Cell::Char { style, .. } => {
276                assert_eq!(style.fg, Some(Color::Rgb(255, 255, 255)),
277                    "opaque white must dominate the transparent pixel");
278            }
279            other => panic!("expected Char, got {other:?}"),
280        }
281    }
282
283    #[test]
284    fn blocks_sets_fg_top_and_bg_bottom() {
285        // 2px wide, 2px tall: top row white, bottom row black.
286        let mut img = RgbaImage::new(2, 2);
287        for x in 0..2 { img.put_pixel(x, 0, Rgba([255, 255, 255, 255])); }
288        for x in 0..2 { img.put_pixel(x, 1, Rgba([0, 0, 0, 255])); }
289        let grid = render_image(&img, 2, AsciiStyle::Blocks, true);
290        match &grid[0][0] {
291            Cell::Char { ch, style, .. } => {
292                assert_eq!(*ch, '▀');
293                assert_eq!(style.fg, Some(Color::Rgb(255, 255, 255)), "fg = top");
294                assert_eq!(style.bg, Some(Color::Rgb(0, 0, 0)), "bg = bottom");
295            }
296            other => panic!("expected Char, got {other:?}"),
297        }
298    }
299
300    #[test]
301    fn blocks_no_color_uses_block_shades() {
302        let img = RgbaImage::from_pixel(2, 2, Rgba([255, 255, 255, 255]));
303        let grid = render_image(&img, 2, AsciiStyle::Blocks, false);
304        match &grid[0][0] {
305            Cell::Char { ch, style, .. } => {
306                assert_eq!(*ch, '█', "brightest → full block");
307                assert_eq!(style.fg, None);
308                assert_eq!(style.bg, None);
309            }
310            other => panic!("expected Char, got {other:?}"),
311        }
312    }
313}