Skip to main content

logo_art/
lib.rs

1use image::imageops::FilterType;
2use image::{DynamicImage, GenericImageView};
3use std::fmt::Write;
4
5/// Converts image data (PNG, etc.) to a string of ANSI escape codes that render
6/// the image in the terminal using Unicode half-block characters (`▄`/`▀`) and
7/// 24-bit true color sequences.
8///
9/// Each output character cell encodes two vertical pixels: one via the background
10/// color and one via the foreground color of a half-block character.
11///
12/// # Arguments
13/// * `image_data` — Raw image bytes (e.g. from `include_bytes!` or `std::fs::read`)
14/// * `width` — Desired output width in terminal columns. Height is derived
15///   proportionally from the source image's aspect ratio.
16pub fn image_to_ansi(image_data: &[u8], width: u32) -> String {
17    let img = image::load_from_memory(image_data).expect("Failed to decode image");
18    let (orig_w, orig_h) = img.dimensions();
19    let height = ((orig_h as f64 * width as f64) / orig_w as f64).round() as u32;
20    let img = img.resize_exact(width, height, FilterType::Lanczos3);
21    render(&img)
22}
23
24/// Convenience wrapper: converts and prints the image directly to stdout.
25pub fn print_image(image_data: &[u8], width: u32) {
26    print!("{}", image_to_ansi(image_data, width));
27}
28
29/// Alpha below this threshold is treated as fully transparent (matches the
30/// reference JS implementation which uses `a < 13`).
31#[inline]
32fn is_transparent(a: u8) -> bool {
33    a < 13
34}
35
36/// Format an RGB(A) value as a foreground ANSI parameter string.
37/// Returns `"39"` (default fg) for transparent pixels.
38fn ansi_fg(r: u8, g: u8, b: u8, a: u8) -> String {
39    if is_transparent(a) {
40        "39".into()
41    } else {
42        format!("38;2;{r};{g};{b}")
43    }
44}
45
46/// Format an RGB(A) value as a background ANSI parameter string.
47/// Returns `"49"` (default bg) for transparent pixels.
48fn ansi_bg(r: u8, g: u8, b: u8, a: u8) -> String {
49    if is_transparent(a) {
50        "49".into()
51    } else {
52        format!("48;2;{r};{g};{b}")
53    }
54}
55
56/// Core rendering loop. Iterates pixel rows in pairs (top/bottom) and emits
57/// the appropriate half-block character with combined fg+bg escape sequences.
58fn render(img: &DynamicImage) -> String {
59    let (width, height) = img.dimensions();
60    let mut out = String::new();
61
62    let mut y = 0u32;
63    while y < height {
64        for x in 0..width {
65            let [tr, tg, tb, ta] = img.get_pixel(x, y).0;
66            let (br, bg, bb, ba) = if y + 1 < height {
67                let p = img.get_pixel(x, y + 1).0;
68                (p[0], p[1], p[2], p[3])
69            } else {
70                (0, 0, 0, 0) // treat out-of-bounds as transparent
71            };
72
73            let top_t = is_transparent(ta);
74            let bot_t = is_transparent(ba);
75
76            if (tr == br && tg == bg && tb == bb && !top_t && !bot_t) || (top_t && bot_t) {
77                // Both pixels same color, or both transparent → space with bg
78                let _ = write!(out, "\x1b[{}m ", ansi_bg(tr, tg, tb, ta));
79            } else if bot_t && !top_t {
80                // Top visible, bottom transparent → ▀ (upper half block)
81                // fg = top color, bg = bottom (default)
82                let _ = write!(
83                    out,
84                    "\x1b[{};{}m▀",
85                    ansi_bg(br, bg, bb, ba),
86                    ansi_fg(tr, tg, tb, ta)
87                );
88            } else {
89                // General case → ▄ (lower half block)
90                // fg = bottom color, bg = top color
91                let _ = write!(
92                    out,
93                    "\x1b[{};{}m▄",
94                    ansi_fg(br, bg, bb, ba),
95                    ansi_bg(tr, tg, tb, ta)
96                );
97            }
98        }
99        out.push_str("\x1b[m\n");
100        y += 2;
101    }
102
103    out
104}