use crate::ansi::{Color, Style};
use crate::render::Cell;
use image::RgbaImage;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AsciiStyle {
Ramp,
Blocks,
}
pub const RAMP: &[char] = &[' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
pub const BLOCK_SHADES: &[char] = &[' ', '░', '▒', '▓', '█'];
pub const CELL_ASPECT: u32 = 2;
fn luminance(r: u8, g: u8, b: u8) -> u8 {
((77 * r as u32 + 150 * g as u32 + 29 * b as u32) >> 8) as u8
}
fn pixels_per_cell_row(style: AsciiStyle, px_per_col: u32) -> u32 {
match style {
AsciiStyle::Ramp => (px_per_col * CELL_ASPECT).max(1),
AsciiStyle::Blocks => (px_per_col * CELL_ASPECT).max(2),
}
}
pub fn output_rows(img_w: u32, img_h: u32, cols: u16, style: AsciiStyle) -> usize {
let cols = (cols.max(1)) as u32;
let img_w = img_w.max(1);
let px_per_col = img_w.div_ceil(cols).max(1);
let ppr = pixels_per_cell_row(style, px_per_col);
(img_h.div_ceil(ppr)).max(1) as usize
}
fn average_block(img: &RgbaImage, x0: u32, y0: u32, w: u32, h: u32) -> (u8, u8, u8) {
let (iw, ih) = img.dimensions();
let (mut r, mut g, mut b, mut sum_a) = (0u64, 0u64, 0u64, 0u64);
for y in y0..(y0 + h).min(ih) {
for x in x0..(x0 + w).min(iw) {
let p = img.get_pixel(x, y).0;
let a = p[3] as u64;
r += p[0] as u64 * a;
g += p[1] as u64 * a;
b += p[2] as u64 * a;
sum_a += a;
}
}
if sum_a == 0 { return (0, 0, 0); }
((r / sum_a) as u8, (g / sum_a) as u8, (b / sum_a) as u8)
}
fn ramp_char(lum: u8) -> char {
let idx = (lum as usize * (RAMP.len() - 1)) / 255;
RAMP[idx]
}
fn cell_char(ch: char, fg: Option<Color>) -> Cell {
Cell::Char { ch, width: 1, style: Style { fg, bg: None, ..Default::default() }, hyperlink: None }
}
pub fn render_image(img: &RgbaImage, cols: u16, style: AsciiStyle, color: bool) -> Vec<Vec<Cell>> {
match style {
AsciiStyle::Ramp => render_ramp(img, cols, color),
AsciiStyle::Blocks => render_blocks(img, cols, color),
}
}
fn render_ramp(img: &RgbaImage, cols: u16, color: bool) -> Vec<Vec<Cell>> {
let (iw, ih) = img.dimensions();
let cols_u = cols.max(1) as u32;
let px_per_col = iw.max(1).div_ceil(cols_u).max(1);
let ppr = pixels_per_cell_row(AsciiStyle::Ramp, px_per_col);
let rows = output_rows(iw, ih, cols, AsciiStyle::Ramp);
let mut grid = Vec::with_capacity(rows);
for ry in 0..rows {
let mut row = Vec::with_capacity(cols as usize);
for cx in 0..cols_u {
let (r, g, b) = average_block(img, cx * px_per_col, ry as u32 * ppr, px_per_col, ppr);
let ch = ramp_char(luminance(r, g, b));
let fg = if color { Some(Color::Rgb(r, g, b)) } else { None };
row.push(cell_char(ch, fg));
}
grid.push(row);
}
grid
}
fn block_shade_char(lum: u8) -> char {
let idx = (lum as usize * (BLOCK_SHADES.len() - 1)) / 255;
BLOCK_SHADES[idx]
}
fn render_blocks(img: &RgbaImage, cols: u16, color: bool) -> Vec<Vec<Cell>> {
let (iw, ih) = img.dimensions();
let cols_u = cols.max(1) as u32;
let px_per_col = iw.max(1).div_ceil(cols_u).max(1);
let ppr = pixels_per_cell_row(AsciiStyle::Blocks, px_per_col); let half = (ppr / 2).max(1);
let rows = output_rows(iw, ih, cols, AsciiStyle::Blocks);
let mut grid = Vec::with_capacity(rows);
for ry in 0..rows {
let mut row = Vec::with_capacity(cols as usize);
let y_top = ry as u32 * ppr;
for cx in 0..cols_u {
let x0 = cx * px_per_col;
let (tr, tg, tb) = average_block(img, x0, y_top, px_per_col, half);
let (br, bg, bb) = average_block(img, x0, y_top + half, px_per_col, half);
if color {
row.push(Cell::Char {
ch: '▀',
width: 1,
style: Style {
fg: Some(Color::Rgb(tr, tg, tb)),
bg: Some(Color::Rgb(br, bg, bb)),
..Default::default()
},
hyperlink: None,
});
} else {
let lum = luminance(
((tr as u16 + br as u16) / 2) as u8,
((tg as u16 + bg as u16) / 2) as u8,
((tb as u16 + bb as u16) / 2) as u8,
);
row.push(cell_char(block_shade_char(lum), None));
}
}
grid.push(row);
}
grid
}
pub fn sniff_image_format(head: &[u8]) -> Option<&'static str> {
match image::guess_format(head).ok()? {
image::ImageFormat::Png => Some("png"),
image::ImageFormat::Jpeg => Some("jpeg"),
image::ImageFormat::Gif => Some("gif"),
image::ImageFormat::Bmp => Some("bmp"),
image::ImageFormat::WebP => Some("webp"),
image::ImageFormat::Tiff => Some("tiff"),
image::ImageFormat::Tga => Some("tga"),
image::ImageFormat::Ico => Some("ico"),
image::ImageFormat::Pnm => Some("pnm"),
_ => None,
}
}
pub fn decode_image(bytes: &[u8]) -> Result<RgbaImage, String> {
image::load_from_memory(bytes)
.map(|img| img.to_rgba8())
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use image::{Rgba, RgbaImage};
#[test]
fn sniff_detects_png_and_gif_and_rejects_text() {
let png = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
assert_eq!(sniff_image_format(&png), Some("png"));
let gif = b"GIF89a............";
assert_eq!(sniff_image_format(gif), Some("gif"));
assert_eq!(sniff_image_format(b"hello, world\n"), None);
assert_eq!(sniff_image_format(b""), None);
}
#[test]
fn decode_roundtrips_a_generated_png() {
let src = RgbaImage::from_pixel(3, 2, Rgba([10, 20, 30, 255]));
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(src.clone())
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
let decoded = decode_image(buf.get_ref()).unwrap();
assert_eq!(decoded.dimensions(), (3, 2));
assert_eq!(decoded.get_pixel(0, 0).0, [10, 20, 30, 255]);
}
fn solid(w: u32, h: u32, px: [u8; 4]) -> RgbaImage {
RgbaImage::from_pixel(w, h, Rgba(px))
}
#[test]
fn output_rows_corrects_aspect_for_ramp() {
let rows = output_rows(100, 100, 50, AsciiStyle::Ramp);
assert_eq!(rows, 25);
}
#[test]
fn output_rows_blocks_same_cell_rows_as_ramp() {
let ramp = output_rows(100, 100, 50, AsciiStyle::Ramp);
let blocks = output_rows(100, 100, 50, AsciiStyle::Blocks);
assert_eq!(blocks, ramp);
}
#[test]
fn ramp_white_pixel_is_densest_glyph() {
let img = solid(4, 4, [255, 255, 255, 255]);
let grid = render_image(&img, 4, AsciiStyle::Ramp, true);
match &grid[0][0] {
Cell::Char { ch, style, .. } => {
assert_eq!(*ch, '@');
assert_eq!(style.fg, Some(Color::Rgb(255, 255, 255)));
}
other => panic!("expected Char, got {other:?}"),
}
}
#[test]
fn ramp_black_pixel_is_space() {
let img = solid(4, 4, [0, 0, 0, 255]);
let grid = render_image(&img, 4, AsciiStyle::Ramp, true);
match &grid[0][0] {
Cell::Char { ch, .. } => assert_eq!(*ch, ' '),
other => panic!("expected Char, got {other:?}"),
}
}
#[test]
fn ramp_no_color_sets_default_fg() {
let img = solid(4, 4, [255, 255, 255, 255]);
let grid = render_image(&img, 4, AsciiStyle::Ramp, false);
match &grid[0][0] {
Cell::Char { ch, style, .. } => {
assert_eq!(*ch, '@');
assert_eq!(style.fg, None);
}
other => panic!("expected Char, got {other:?}"),
}
}
#[test]
fn grid_width_matches_requested_cols() {
let img = solid(40, 40, [128, 128, 128, 255]);
let grid = render_image(&img, 20, AsciiStyle::Ramp, true);
assert!(grid.iter().all(|row| row.len() == 20));
}
#[test]
fn average_block_weights_by_alpha_not_pixel_count() {
let mut img = RgbaImage::new(2, 1);
img.put_pixel(0, 0, Rgba([255, 255, 255, 255]));
img.put_pixel(1, 0, Rgba([0, 0, 0, 0]));
let grid = render_image(&img, 1, AsciiStyle::Ramp, true);
match &grid[0][0] {
Cell::Char { style, .. } => {
assert_eq!(style.fg, Some(Color::Rgb(255, 255, 255)),
"opaque white must dominate the transparent pixel");
}
other => panic!("expected Char, got {other:?}"),
}
}
#[test]
fn blocks_sets_fg_top_and_bg_bottom() {
let mut img = RgbaImage::new(2, 2);
for x in 0..2 { img.put_pixel(x, 0, Rgba([255, 255, 255, 255])); }
for x in 0..2 { img.put_pixel(x, 1, Rgba([0, 0, 0, 255])); }
let grid = render_image(&img, 2, AsciiStyle::Blocks, true);
match &grid[0][0] {
Cell::Char { ch, style, .. } => {
assert_eq!(*ch, '▀');
assert_eq!(style.fg, Some(Color::Rgb(255, 255, 255)), "fg = top");
assert_eq!(style.bg, Some(Color::Rgb(0, 0, 0)), "bg = bottom");
}
other => panic!("expected Char, got {other:?}"),
}
}
#[test]
fn blocks_no_color_uses_block_shades() {
let img = RgbaImage::from_pixel(2, 2, Rgba([255, 255, 255, 255]));
let grid = render_image(&img, 2, AsciiStyle::Blocks, false);
match &grid[0][0] {
Cell::Char { ch, style, .. } => {
assert_eq!(*ch, '█', "brightest → full block");
assert_eq!(style.fg, None);
assert_eq!(style.bg, None);
}
other => panic!("expected Char, got {other:?}"),
}
}
}