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