1use brushkit_abr::TipBitmap;
2use image::{ImageBuffer, Rgba, RgbaImage};
3
4#[derive(Debug, Clone)]
5pub struct GrayscaleBitmap {
6 pub width: u32,
7 pub height: u32,
8 pub data: Vec<u8>,
9}
10
11pub fn to_grayscale(tip: &TipBitmap) -> GrayscaleBitmap {
12 match tip.depth {
13 8 => GrayscaleBitmap {
14 width: tip.width,
15 height: tip.height,
16 data: tip.data.clone(),
17 },
18 16 => {
19 let data: Vec<u8> = tip
20 .data
21 .as_chunks::<2>()
22 .0
23 .iter()
24 .map(|pair| pair[0])
25 .collect();
26 GrayscaleBitmap {
27 width: tip.width,
28 height: tip.height,
29 data,
30 }
31 }
32 _ => GrayscaleBitmap {
33 width: tip.width,
34 height: tip.height,
35 data: tip.data.clone(),
36 },
37 }
38}
39
40pub fn tip_bitmap_of(bitmap: &GrayscaleBitmap) -> TipBitmap {
43 TipBitmap {
44 width: bitmap.width,
45 height: bitmap.height,
46 depth: 8,
47 data: bitmap.data.clone(),
48 }
49}
50
51pub const MAX_IMPORT_DIMENSION: u32 = 16384;
52
53#[derive(Debug)]
54pub enum TipImageError {
55 Decode(String),
56 TooLarge { width: u32, height: u32 },
57}
58
59impl std::fmt::Display for TipImageError {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 match self {
62 TipImageError::Decode(msg) => f.write_str(msg),
63 TipImageError::TooLarge { width, height } => write!(
64 f,
65 "image is {width}x{height}px; the maximum supported brush-tip dimension is {MAX_IMPORT_DIMENSION}px"
66 ),
67 }
68 }
69}
70
71impl std::error::Error for TipImageError {}
72
73pub fn decode_tip_image(bytes: &[u8]) -> Result<GrayscaleBitmap, TipImageError> {
78 use image::GenericImageView;
79
80 let img = image::load_from_memory(bytes).map_err(|e| TipImageError::Decode(e.to_string()))?;
81 let (width, height) = img.dimensions();
82 if width > MAX_IMPORT_DIMENSION || height > MAX_IMPORT_DIMENSION {
83 return Err(TipImageError::TooLarge { width, height });
84 }
85
86 let data: Vec<u8> = if img.color().has_alpha() {
87 img.to_rgba8().pixels().map(|p| p.0[3]).collect()
88 } else {
89 img.to_luma8().pixels().map(|p| 255 - p.0[0]).collect()
90 };
91
92 Ok(GrayscaleBitmap {
93 width,
94 height,
95 data,
96 })
97}
98
99pub fn downsample(bitmap: &GrayscaleBitmap, max_side: u32) -> GrayscaleBitmap {
103 let max_side = max_side.max(1);
104
105 let (tw, th) = if bitmap.width > max_side || bitmap.height > max_side {
106 let scale = f64::min(
107 max_side as f64 / bitmap.width as f64,
108 max_side as f64 / bitmap.height as f64,
109 );
110 let w = ((bitmap.width as f64 * scale).round() as u32).max(1);
111 let h = ((bitmap.height as f64 * scale).round() as u32).max(1);
112 (w, h)
113 } else {
114 return bitmap.clone();
115 };
116
117 let src_w = bitmap.width as f64;
118 let src_h = bitmap.height as f64;
119 let tw_f = tw as f64;
120 let th_f = th as f64;
121
122 let mut data = Vec::with_capacity((tw as usize) * (th as usize));
123 for y in 0..th {
124 let sy0 = (y as f64 * src_h / th_f) as u32;
125 let sy1 = (((y + 1) as f64 * src_h / th_f) as u32).min(bitmap.height);
126 for x in 0..tw {
127 let sx0 = (x as f64 * src_w / tw_f) as u32;
128 let sx1 = (((x + 1) as f64 * src_w / tw_f) as u32).min(bitmap.width);
129
130 let mut sum = 0u32;
131 let mut count = 0u32;
132 for sy in sy0..sy1 {
133 let row_offset = (sy * bitmap.width) as usize;
134 for sx in sx0..sx1 {
135 sum += bitmap.data[row_offset + sx as usize] as u32;
136 count += 1;
137 }
138 }
139
140 #[allow(clippy::manual_checked_ops)]
141 let value = if count > 0 { (sum / count) as u8 } else { 0 };
142 data.push(value);
143 }
144 }
145
146 GrayscaleBitmap {
147 width: tw,
148 height: th,
149 data,
150 }
151}
152
153pub fn generate_preview_png(bitmap: &GrayscaleBitmap) -> Result<Vec<u8>, String> {
155 let small = downsample(bitmap, 200);
156
157 let img: RgbaImage = ImageBuffer::from_fn(small.width, small.height, |x, y| {
158 let alpha = small.data[(y * small.width + x) as usize];
159 Rgba([0, 0, 0, alpha])
160 });
161
162 let mut buf = std::io::Cursor::new(Vec::new());
163 img.write_to(&mut buf, image::ImageFormat::Png)
164 .map_err(|e| format!("thumbnail encoding failed: {e}"))?;
165
166 Ok(buf.into_inner())
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn to_grayscale_16bit_takes_high_byte() {
175 let tip = TipBitmap {
176 width: 2,
177 height: 1,
178 depth: 16,
179 data: vec![0xAB, 0x12, 0xCD, 0x34],
180 };
181 let result = to_grayscale(&tip);
182 assert_eq!(result.width, 2);
183 assert_eq!(result.height, 1);
184 assert_eq!(result.data.len(), 2);
185 assert_eq!(result.data, vec![0xAB, 0xCD], "must keep the high byte");
186 }
187
188 #[test]
189 fn downsample_averages_covered_pixels() {
190 let bitmap = GrayscaleBitmap {
191 width: 4,
192 height: 2,
193 data: vec![0, 255, 0, 255, 0, 255, 0, 255],
194 };
195 let small = downsample(&bitmap, 2);
196 assert_eq!((small.width, small.height), (2, 1));
197 assert_eq!(small.data, vec![127, 127]);
198 }
199
200 #[test]
201 fn downsample_keeps_a_fitting_bitmap() {
202 let bitmap = GrayscaleBitmap {
203 width: 3,
204 height: 2,
205 data: vec![1, 2, 3, 4, 5, 6],
206 };
207 let same = downsample(&bitmap, 8);
208 assert_eq!((same.width, same.height), (3, 2));
209 assert_eq!(same.data, bitmap.data);
210 }
211
212 #[test]
213 fn preview_png_is_200_wide_with_alpha_from_gray() {
214 let bitmap = GrayscaleBitmap {
215 width: 400,
216 height: 100,
217 data: vec![200u8; 400 * 100],
218 };
219 let png = generate_preview_png(&bitmap).unwrap();
220 let img = image::load_from_memory(&png).unwrap().to_rgba8();
221 assert_eq!((img.width(), img.height()), (200, 50));
222 assert!(img.pixels().all(|p| p.0 == [0, 0, 0, 200]));
223 }
224}
225
226#[cfg(test)]
227mod tip_image_tests {
228 use super::*;
229
230 fn rgba_png(width: u32, height: u32, pixels: &[[u8; 4]]) -> Vec<u8> {
231 let img: RgbaImage =
232 ImageBuffer::from_fn(width, height, |x, y| Rgba(pixels[(y * width + x) as usize]));
233 let mut buf = std::io::Cursor::new(Vec::new());
234 img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
235 buf.into_inner()
236 }
237
238 fn luma_png(width: u32, height: u32, luma: &[u8]) -> Vec<u8> {
239 let mut buf = Vec::new();
240 {
241 let mut encoder = png::Encoder::new(&mut buf, width, height);
242 encoder.set_color(png::ColorType::Grayscale);
243 encoder.set_depth(png::BitDepth::Eight);
244 let mut writer = encoder.write_header().unwrap();
245 writer.write_image_data(luma).unwrap();
246 }
247 buf
248 }
249
250 #[test]
251 fn alpha_channel_becomes_intensity() {
252 let png = rgba_png(3, 1, &[[255, 0, 0, 255], [255, 0, 0, 128], [255, 0, 0, 0]]);
253 let bitmap = decode_tip_image(&png).unwrap();
254 assert_eq!((bitmap.width, bitmap.height), (3, 1));
255 assert_eq!(bitmap.data, vec![255, 128, 0]);
256 }
257
258 #[test]
259 fn opaque_image_inverts_luminance() {
260 let png = luma_png(3, 1, &[0, 40, 255]);
261 let bitmap = decode_tip_image(&png).unwrap();
262 assert_eq!((bitmap.width, bitmap.height), (3, 1));
263 assert_eq!(bitmap.data, vec![255, 215, 0]);
264 }
265
266 #[test]
267 fn oversized_image_is_rejected_with_the_ceiling_message() {
268 let over = MAX_IMPORT_DIMENSION + 1;
269 let png = luma_png(over, 1, &vec![0u8; over as usize]);
270 let err = decode_tip_image(&png).expect_err("over-ceiling image must be rejected");
271 assert!(
272 matches!(err, TipImageError::TooLarge { width, height } if width == over && height == 1),
273 "expected TooLarge, got {err:?}"
274 );
275 assert!(
276 err.to_string().starts_with("image is "),
277 "unexpected message: {err}"
278 );
279 }
280
281 #[test]
282 fn garbage_bytes_report_a_decode_error() {
283 let err = decode_tip_image(b"not an image").expect_err("garbage must not decode");
284 assert!(matches!(err, TipImageError::Decode(_)), "got {err:?}");
285 }
286}
287
288#[cfg(test)]
289mod tip_bitmap_of_tests {
290 use super::*;
291
292 #[test]
293 fn tip_bitmap_of_copies_pixels_and_declares_depth_8() {
294 let bitmap = GrayscaleBitmap {
295 width: 3,
296 height: 2,
297 data: vec![0, 17, 255, 128, 1, 0],
298 };
299 let tip = tip_bitmap_of(&bitmap);
300 assert_eq!((tip.width, tip.height), (3, 2));
301 assert_eq!(tip.depth, 8, "GrayscaleBitmap is 8-bit by construction");
302 assert_eq!(tip.data, bitmap.data, "polarity matches, so no conversion");
303 }
304
305 #[test]
306 fn tip_bitmap_of_round_trips_an_8_bit_tip() {
307 let tip = TipBitmap {
308 width: 2,
309 height: 2,
310 depth: 8,
311 data: vec![9, 200, 0, 255],
312 };
313 let back = tip_bitmap_of(&to_grayscale(&tip));
314 assert_eq!((back.width, back.height, back.depth), (2, 2, 8));
315 assert_eq!(back.data, tip.data);
316 }
317}