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 enum AnimationDecode {
19 Static,
22 Animated(Animation),
24 Unsupported(String),
30}
31
32fn png_has_actl(bytes: &[u8]) -> bool {
37 bytes.windows(4).any(|w| w == b"acTL")
38}
39
40pub 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 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
58pub 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum AsciiStyle {
129 Ramp,
131 Blocks,
133}
134
135pub const RAMP: &[char] = &[' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
137
138pub const BLOCK_SHADES: &[char] = &[' ', '░', '▒', '▓', '█'];
140
141pub const CELL_ASPECT: u32 = 2;
143
144fn 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
149fn 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
157pub 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
167fn 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
196pub 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); 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
270pub 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
288pub 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 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 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 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]); 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]); assert_eq!(parse_gif_loop_count(&g), Some(0));
440
441 let mut g3 = g.clone();
442 let pos = g3.len() - 3; 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 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 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 assert!(png_has_actl(b"\x89PNG....acTL....IDAT...."));
492 assert!(!png_has_actl(b"\x89PNG....IHDR....IDAT....IEND"));
494 }
495}