Skip to main content

slt/
halfblock.rs

1//! Half-block image rendering for terminals with truecolor support.
2//!
3//! Uses `▀` (upper half block) with foreground/background colors to render
4//! two vertical pixels per terminal cell, achieving 2x vertical resolution.
5
6#[cfg(feature = "image")]
7use image::DynamicImage;
8
9use crate::style::Color;
10
11/// A terminal-renderable image stored as a grid of [`Color`] values.
12///
13/// Each cell contains a foreground color (upper pixel) and background color
14/// (lower pixel), rendered using the `▀` half-block character.
15///
16/// Create from an [`image::DynamicImage`] with [`HalfBlockImage::from_dynamic`]
17/// (requires `image` feature), or construct manually from raw RGB data with
18/// [`HalfBlockImage::from_rgb`].
19pub struct HalfBlockImage {
20    /// Width in terminal columns.
21    pub width: u32,
22    /// Height in terminal rows (each row = 2 image pixels).
23    pub height: u32,
24    /// Row-major pairs of (upper_color, lower_color) for each cell.
25    pub pixels: Vec<(Color, Color)>,
26}
27
28#[cfg(feature = "image")]
29impl HalfBlockImage {
30    /// Create a half-block image from a [`DynamicImage`], resized to fit
31    /// the given terminal cell dimensions.
32    ///
33    /// The image is resized to `width x (height * 2)` pixels using Lanczos3
34    /// filtering, then each pair of vertically adjacent pixels is packed
35    /// into one terminal cell.
36    ///
37    /// If `width * height * 2` would overflow or exceed the crate-internal
38    /// image pixel cap (~16M pixels), returns an empty image rather than
39    /// panicking or allocating gigabytes. Legitimate terminal-scale inputs
40    /// are far below this cap.
41    pub fn from_dynamic(img: &DynamicImage, width: u32, height: u32) -> Self {
42        let Some(pixel_height) = height.checked_mul(2) else {
43            return Self::empty(width, height);
44        };
45        let pixels_total = u64::from(width).saturating_mul(u64::from(pixel_height));
46        if pixels_total == 0 || pixels_total > crate::buffer::MAX_IMAGE_PIXELS {
47            return Self::empty(width, height);
48        }
49        let resized = img.resize_exact(width, pixel_height, image::imageops::FilterType::Lanczos3);
50        let rgba = resized.to_rgba8();
51
52        let mut pixels = Vec::with_capacity((width as usize) * (height as usize));
53        for row in 0..height {
54            for col in 0..width {
55                let upper_y = row * 2;
56                let lower_y = row * 2 + 1;
57
58                let up = rgba.get_pixel(col, upper_y);
59                let lo = rgba.get_pixel(col, lower_y);
60
61                let upper = Color::Rgb(up[0], up[1], up[2]);
62                let lower = Color::Rgb(lo[0], lo[1], lo[2]);
63                pixels.push((upper, lower));
64            }
65        }
66
67        Self {
68            width,
69            height,
70            pixels,
71        }
72    }
73}
74
75impl HalfBlockImage {
76    /// Create a half-block image from raw RGB pixel data.
77    ///
78    /// `rgb_data` must contain `width x pixel_height x 3` bytes in row-major
79    /// RGB order, where `pixel_height = height * 2`. Oversized inputs that
80    /// would overflow or exceed the crate-internal image pixel cap are
81    /// returned as an empty image instead of allocating.
82    pub fn from_rgb(rgb_data: &[u8], width: u32, height: u32) -> Self {
83        let Some(pixel_height) = height.checked_mul(2) else {
84            return Self::empty(width, height);
85        };
86        let pixels_total = u64::from(width).saturating_mul(u64::from(pixel_height));
87        if pixels_total == 0 || pixels_total > crate::buffer::MAX_IMAGE_PIXELS {
88            return Self::empty(width, height);
89        }
90        let Some(stride) = (width as usize).checked_mul(3) else {
91            return Self::empty(width, height);
92        };
93        let mut pixels = Vec::with_capacity((width as usize) * (height as usize));
94
95        for row in 0..height {
96            for col in 0..width {
97                let upper_y = (row * 2) as usize;
98                let lower_y = (row * 2 + 1) as usize;
99                let x = (col * 3) as usize;
100
101                let (ur, ug, ub) = if upper_y < pixel_height as usize {
102                    let offset = upper_y * stride + x;
103                    if offset + 2 < rgb_data.len() {
104                        (rgb_data[offset], rgb_data[offset + 1], rgb_data[offset + 2])
105                    } else {
106                        (0, 0, 0)
107                    }
108                } else {
109                    (0, 0, 0)
110                };
111
112                let (lr, lg, lb) = if lower_y < pixel_height as usize {
113                    let offset = lower_y * stride + x;
114                    if offset + 2 < rgb_data.len() {
115                        (rgb_data[offset], rgb_data[offset + 1], rgb_data[offset + 2])
116                    } else {
117                        (0, 0, 0)
118                    }
119                } else {
120                    (0, 0, 0)
121                };
122
123                pixels.push((Color::Rgb(ur, ug, ub), Color::Rgb(lr, lg, lb)));
124            }
125        }
126
127        Self {
128            width,
129            height,
130            pixels,
131        }
132    }
133
134    fn empty(width: u32, height: u32) -> Self {
135        Self {
136            width,
137            height,
138            pixels: Vec::new(),
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn from_rgb_rejects_oversized_dimensions() {
149        // 10_000 × 10_000 × 2 = 2×10^8 pixels — well above MAX_IMAGE_PIXELS.
150        // Must not allocate gigabytes; must return an empty image.
151        let img = HalfBlockImage::from_rgb(&[], 10_000, 10_000);
152        assert!(img.pixels.is_empty());
153    }
154
155    #[test]
156    fn from_rgb_rejects_overflowing_height() {
157        let img = HalfBlockImage::from_rgb(&[], 1, u32::MAX);
158        assert!(img.pixels.is_empty());
159    }
160}