Skip to main content

brushkit_preview/
bitmap.rs

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
40/// Both formats use the same polarity — a `Shape.png` is white=stamp coverage
41/// and a `TipBitmap` is 255=full ink — so the pixels are copied unchanged.
42pub 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
73/// Decode an imported brush-tip image (PNG/JPEG bytes) into a grayscale tip
74/// buffer. Alpha becomes intensity when the image has an alpha channel;
75/// otherwise luminance is inverted (dark = opaque), matching Photoshop's
76/// convention.
77pub 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
99/// Box-filter `bitmap` so its larger side is at most `max_side` (>= 1). Returns
100/// a clone when it already fits. Average of the covered source pixels per
101/// destination pixel, the same filter `generate_preview_png` uses.
102pub 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
153/// A 200 px RGBA thumbnail: black ink, alpha from the tip's gray value.
154pub 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}