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}