1use crate::ansi::{Color, Style};
6use crate::render::Cell;
7use image::RgbaImage;
8use std::time::Duration;
9
10pub struct Animation {
13 pub frames: Vec<(RgbaImage, Duration)>,
14 pub loop_count: Option<u32>,
15}
16
17pub 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 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
35pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum AsciiStyle {
88 Ramp,
90 Blocks,
92}
93
94pub const RAMP: &[char] = &[' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
96
97pub const BLOCK_SHADES: &[char] = &[' ', '░', '▒', '▓', '█'];
99
100pub const CELL_ASPECT: u32 = 2;
102
103fn 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
108fn 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
116pub 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
126fn 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
155pub 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); 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
229pub 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
247pub 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 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 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 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]); 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]); assert_eq!(parse_gif_loop_count(&g), Some(0));
399
400 let mut g3 = g.clone();
401 let pos = g3.len() - 3; 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 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 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}