Skip to main content

brushkit_preview/
sheet.rs

1use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
2
3use crate::GrayscaleBitmap;
4
5static LABEL_FONT_BYTES: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
6
7fn label_font() -> FontRef<'static> {
8    use std::sync::OnceLock;
9    static FONT: OnceLock<FontRef<'static>> = OnceLock::new();
10    FONT.get_or_init(|| {
11        FontRef::try_from_slice(LABEL_FONT_BYTES).expect("embedded Inter font must parse")
12    })
13    .clone()
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum SheetBackground {
18    Transparent,
19    #[default]
20    White,
21    Checker,
22}
23
24pub struct SheetBrush<'a> {
25    pub name: &'a str,
26    pub bitmap: &'a GrayscaleBitmap,
27}
28
29pub struct ContactSheetConfig {
30    pub cell_size: u32,
31    pub columns: Option<u32>,
32    pub padding: u32,
33    pub show_names: bool,
34    /// Draws a size line under each thumbnail. The measurement is the tight
35    /// bounds of ink greater than zero in the stored tip (`{w}×{h}`),
36    /// not the canvas size — a padded square canvas still reports its content
37    /// rectangle. An all-zero tip reads `Empty`.
38    pub show_sizes: bool,
39    pub gap: u32,
40    pub background: SheetBackground,
41}
42
43impl Default for ContactSheetConfig {
44    fn default() -> Self {
45        Self {
46            cell_size: 120,
47            columns: None,
48            padding: 8,
49            show_names: false,
50            show_sizes: true,
51            gap: 8,
52            background: SheetBackground::White,
53        }
54    }
55}
56
57pub fn generate_contact_sheet_png(
58    brushes: &[SheetBrush],
59    config: &ContactSheetConfig,
60) -> Result<Vec<u8>, String> {
61    use image::{ImageBuffer, LumaA};
62
63    if brushes.is_empty() {
64        let img: ImageBuffer<LumaA<u8>, Vec<u8>> = ImageBuffer::from_pixel(1, 1, LumaA([255, 0]));
65        return encode_luma_alpha_png(&img);
66    }
67
68    let cols = config.columns.unwrap_or_else(|| {
69        let count = brushes.len() as u32;
70        count.clamp(1, 10)
71    });
72    let rows = (brushes.len() as u32).div_ceil(cols);
73
74    let margin: u32 = 8;
75    let gap: u32 = config.gap;
76    let actual_cols = (brushes.len() as u32).min(cols);
77    let img_width =
78        2 * margin + actual_cols * config.cell_size + actual_cols.saturating_sub(1) * gap;
79    let img_height = 2 * margin + rows * config.cell_size + rows.saturating_sub(1) * gap;
80
81    let mut canvas: ImageBuffer<LumaA<u8>, Vec<u8>> = match config.background {
82        SheetBackground::Transparent => {
83            ImageBuffer::from_pixel(img_width, img_height, LumaA([255, 0]))
84        }
85        SheetBackground::White => ImageBuffer::from_pixel(img_width, img_height, LumaA([255, 255])),
86        SheetBackground::Checker => {
87            let sq: u32 = 16;
88            let mut data: Vec<u8> = Vec::with_capacity((img_width * img_height * 2) as usize);
89            for y in 0..img_height {
90                for x in 0..img_width {
91                    let is_dim = ((x / sq) + (y / sq)) % 2 == 1;
92                    let v = if is_dim { 232 } else { 255 };
93                    data.push(v);
94                    data.push(255);
95                }
96            }
97            ImageBuffer::from_vec(img_width, img_height, data)
98                .expect("checker buffer length matches dimensions")
99        }
100    };
101
102    let px_size = label_px_size(config.cell_size);
103    let line_h = label_line_height(px_size);
104    let name_line_h = if config.show_names { line_h } else { 0 };
105    let size_line_h = if config.show_sizes { line_h } else { 0 };
106    let label_gap: u32 = if config.show_names && config.show_sizes {
107        (px_size * 0.15).ceil() as u32
108    } else {
109        0
110    };
111    let label_margin_top: u32 = if config.show_names || config.show_sizes {
112        (px_size * 0.45).ceil() as u32
113    } else {
114        0
115    };
116    let label_block_h = name_line_h + label_gap + size_line_h + label_margin_top;
117
118    for (i, brush) in brushes.iter().enumerate() {
119        let col = (i as u32) % cols;
120        let row = (i as u32) / cols;
121        let cell_x = margin + col * (config.cell_size + gap);
122        let cell_y = margin + row * (config.cell_size + gap);
123
124        let available = config.cell_size.saturating_sub(2 * config.padding);
125        if available == 0 {
126            continue;
127        }
128
129        let avail_h = available.saturating_sub(label_block_h);
130        if avail_h == 0 {
131            continue;
132        }
133
134        let bitmap = brush.bitmap;
135
136        let fit_x = available as f64 / bitmap.width as f64;
137        let fit_y = avail_h as f64 / bitmap.height as f64;
138        let fit = fit_x.min(fit_y).min(1.0);
139
140        let thumb_w = ((bitmap.width as f64 * fit).round() as u32).max(1);
141        let thumb_h = ((bitmap.height as f64 * fit).round() as u32).max(1);
142
143        let offset_x = cell_x + config.padding + (available.saturating_sub(thumb_w)) / 2;
144        let offset_y = cell_y + config.padding + (avail_h.saturating_sub(thumb_h)) / 2;
145
146        let src_w = bitmap.width as f64;
147        let src_h = bitmap.height as f64;
148        let tw_f = thumb_w as f64;
149        let th_f = thumb_h as f64;
150
151        for ty in 0..thumb_h {
152            let sy0 = (ty as f64 * src_h / th_f) as u32;
153            let sy1 = (((ty + 1) as f64 * src_h / th_f) as u32).min(bitmap.height);
154            for tx in 0..thumb_w {
155                let sx0 = (tx as f64 * src_w / tw_f) as u32;
156                let sx1 = (((tx + 1) as f64 * src_w / tw_f) as u32).min(bitmap.width);
157
158                let mut sum = 0u32;
159                let mut count = 0u32;
160                for sy in sy0..sy1 {
161                    let row_off = (sy * bitmap.width) as usize;
162                    for sx in sx0..sx1 {
163                        sum += bitmap.data[row_off + sx as usize] as u32;
164                        count += 1;
165                    }
166                }
167                #[allow(clippy::manual_checked_ops)]
168                let ink = if count > 0 { (sum / count) as u8 } else { 0 };
169                if ink == 0 {
170                    continue;
171                }
172
173                let cx = offset_x + tx;
174                let cy = offset_y + ty;
175                if cx >= canvas.width() || cy >= canvas.height() {
176                    continue;
177                }
178
179                let dst = canvas.get_pixel(cx, cy).0;
180                let (dst_l, dst_a) = (dst[0] as u32, dst[1] as u32);
181                let sa = ink as u32;
182                let out_a = sa + dst_a * (255 - sa) / 255;
183                #[allow(clippy::manual_checked_ops)]
184                let out_l = if out_a == 0 {
185                    0
186                } else {
187                    let num = dst_l * dst_a * (255 - sa) / 255;
188                    num / out_a
189                };
190                canvas.put_pixel(cx, cy, LumaA([out_l as u8, out_a as u8]));
191            }
192        }
193
194        let mut label_y = cell_y + config.padding + avail_h + label_margin_top;
195        if config.show_names {
196            let text = fit_label(brush.name, available, px_size);
197            let lw = label_pixel_width(&text, px_size);
198            let lx = cell_x + config.padding + (available.saturating_sub(lw)) / 2;
199            draw_label(&mut canvas, lx as i64, label_y as i64, &text, px_size);
200            label_y += name_line_h + label_gap;
201        }
202        if config.show_sizes {
203            let raw = match ink_bounds(bitmap) {
204                Some((w, h)) => format!("{w}×{h}"),
205                None => "Empty".to_string(),
206            };
207            let text = fit_label(&raw, available, px_size);
208            let lw = label_pixel_width(&text, px_size);
209            let lx = cell_x + config.padding + (available.saturating_sub(lw)) / 2;
210            draw_label(&mut canvas, lx as i64, label_y as i64, &text, px_size);
211        }
212    }
213
214    encode_luma_alpha_png(&canvas)
215}
216
217fn encode_luma_alpha_png(
218    img: &image::ImageBuffer<image::LumaA<u8>, Vec<u8>>,
219) -> Result<Vec<u8>, String> {
220    let mut buf = std::io::Cursor::new(Vec::new());
221    img.write_to(&mut buf, image::ImageFormat::Png)
222        .map_err(|e| format!("preview PNG encoding failed: {e}"))?;
223    Ok(buf.into_inner())
224}
225
226/// Tight axis-aligned bounds of the stored tip's ink, in stored tip pixels.
227///
228/// A pixel counts as ink when its value is greater than zero: value-1 pixels
229/// and detached specks are content. Returns `(width, height)` of that
230/// rectangle, or `None` for an
231/// all-zero, zero-sized or malformed bitmap. Allocation free.
232fn ink_bounds(bitmap: &GrayscaleBitmap) -> Option<(u32, u32)> {
233    let width = bitmap.width as usize;
234    let height = bitmap.height as usize;
235    if width == 0 || height == 0 || bitmap.data.len() != width * height {
236        return None;
237    }
238    let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
239    for y in 0..height {
240        let row = &bitmap.data[y * width..(y + 1) * width];
241        for (x, &v) in row.iter().enumerate() {
242            if v > 0 {
243                x0 = x0.min(x);
244                y0 = y0.min(y);
245                x1 = x1.max(x);
246                y1 = y1.max(y);
247            }
248        }
249    }
250    if x0 == usize::MAX {
251        return None;
252    }
253    Some(((x1 - x0 + 1) as u32, (y1 - y0 + 1) as u32))
254}
255
256const LABEL_COLOR: u8 = 110;
257
258fn label_px_size(cell_size: u32) -> f32 {
259    (cell_size as f32 / 11.0).clamp(11.0, 36.0)
260}
261
262fn label_line_height(px_size: f32) -> u32 {
263    let font = label_font();
264    let scaled = font.as_scaled(PxScale::from(px_size));
265    (scaled.ascent() - scaled.descent() + scaled.line_gap()).ceil() as u32
266}
267
268fn label_pixel_width(text: &str, px_size: f32) -> u32 {
269    let font = label_font();
270    let scaled = font.as_scaled(PxScale::from(px_size));
271    let mut width = 0.0_f32;
272    let mut prev: Option<ab_glyph::GlyphId> = None;
273    for ch in text.chars() {
274        let gid = font.glyph_id(ch);
275        if let Some(pg) = prev {
276            width += scaled.kern(pg, gid);
277        }
278        width += scaled.h_advance(gid);
279        prev = Some(gid);
280    }
281    width.ceil() as u32
282}
283
284fn fit_label(text: &str, max_width: u32, px_size: f32) -> String {
285    if label_pixel_width(text, px_size) <= max_width {
286        return text.to_string();
287    }
288    let ellipsis = "…";
289    let ell_w = label_pixel_width(ellipsis, px_size);
290    if ell_w > max_width {
291        return String::new();
292    }
293    let budget = max_width - ell_w;
294
295    let font = label_font();
296    let scaled = font.as_scaled(PxScale::from(px_size));
297
298    let mut result = String::new();
299    let mut cursor = 0.0_f32;
300    let mut prev: Option<ab_glyph::GlyphId> = None;
301    for ch in text.chars() {
302        let gid = font.glyph_id(ch);
303        let kern = prev.map(|pg| scaled.kern(pg, gid)).unwrap_or(0.0);
304        let advance = scaled.h_advance(gid);
305        if (cursor + kern + advance).ceil() as u32 > budget {
306            break;
307        }
308        cursor += kern + advance;
309        result.push(ch);
310        prev = Some(gid);
311    }
312    while matches!(result.chars().last(), Some(' ') | Some('_')) {
313        result.pop();
314    }
315    format!("{}{}", result, ellipsis)
316}
317
318fn draw_label(
319    canvas: &mut image::ImageBuffer<image::LumaA<u8>, Vec<u8>>,
320    x: i64,
321    y: i64,
322    text: &str,
323    px_size: f32,
324) {
325    use ab_glyph::point;
326    use image::LumaA;
327
328    let font = label_font();
329    let scaled = font.as_scaled(PxScale::from(px_size));
330    let ascent = scaled.ascent();
331
332    let mut cursor_x = x as f32;
333    let mut prev: Option<ab_glyph::GlyphId> = None;
334
335    for ch in text.chars() {
336        let gid = font.glyph_id(ch);
337        if let Some(pg) = prev {
338            cursor_x += scaled.kern(pg, gid);
339        }
340        let glyph =
341            gid.with_scale_and_position(PxScale::from(px_size), point(cursor_x, y as f32 + ascent));
342        if let Some(outlined) = font.outline_glyph(glyph) {
343            let bounds = outlined.px_bounds();
344            let origin_x = bounds.min.x.floor() as i64;
345            let origin_y = bounds.min.y.floor() as i64;
346            outlined.draw(|dx, dy, coverage| {
347                let px = origin_x + dx as i64;
348                let py = origin_y + dy as i64;
349                if px < 0 || py < 0 {
350                    return;
351                }
352                let px = px as u32;
353                let py = py as u32;
354                if px >= canvas.width() || py >= canvas.height() {
355                    return;
356                }
357                let sa = (coverage.clamp(0.0, 1.0) * 255.0) as u32;
358                if sa == 0 {
359                    return;
360                }
361                let dst = canvas.get_pixel(px, py).0;
362                let (dl, da) = (dst[0] as u32, dst[1] as u32);
363                let out_a = sa + da * (255 - sa) / 255;
364                #[allow(clippy::manual_checked_ops)]
365                let out_l = if out_a == 0 {
366                    0
367                } else {
368                    let num = (LABEL_COLOR as u32) * sa + dl * da * (255 - sa) / 255;
369                    num / out_a
370                };
371                canvas.put_pixel(px, py, LumaA([out_l as u8, out_a as u8]));
372            });
373        }
374        cursor_x += scaled.h_advance(gid);
375        prev = Some(gid);
376    }
377}
378
379#[cfg(test)]
380mod contact_sheet_tests {
381    use super::*;
382
383    #[test]
384    fn checker_background_fill_is_stable() {
385        let bitmap = GrayscaleBitmap {
386            width: 4,
387            height: 4,
388            data: vec![0u8; 16],
389        };
390        let brushes = [SheetBrush {
391            name: "x",
392            bitmap: &bitmap,
393        }];
394        let config = ContactSheetConfig {
395            cell_size: 40,
396            columns: Some(1),
397            padding: 4,
398            show_names: false,
399            show_sizes: false,
400            gap: 0,
401            background: SheetBackground::Checker,
402        };
403
404        let png = generate_contact_sheet_png(&brushes, &config).expect("sheet generated");
405        let decoded = image::load_from_memory(&png).expect("decode sheet");
406        let luma_a = decoded.to_luma_alpha8();
407        let (w, h) = luma_a.dimensions();
408
409        let sq: u32 = 16;
410        for y in 0..h.min(sq) {
411            for x in 0..w {
412                let is_dim = ((x / sq) + (y / sq)) % 2 == 1;
413                let v = if is_dim { 232 } else { 255 };
414                assert_eq!(
415                    luma_a.get_pixel(x, y).0,
416                    [v, 255],
417                    "checker pixel at ({x},{y}) drifted"
418                );
419            }
420        }
421    }
422
423    fn bitmap(width: u32, height: u32, fill: &[(u32, u32, u8)]) -> GrayscaleBitmap {
424        let mut data = vec![0u8; (width * height) as usize];
425        for &(x, y, v) in fill {
426            data[(y * width + x) as usize] = v;
427        }
428        GrayscaleBitmap {
429            width,
430            height,
431            data,
432        }
433    }
434
435    fn block(cw: u32, ch: u32, w: u32, h: u32) -> GrayscaleBitmap {
436        let mut data = vec![0u8; (cw * ch) as usize];
437        for y in 0..h {
438            for x in 0..w {
439                data[(y * cw + x) as usize] = 255;
440            }
441        }
442        GrayscaleBitmap {
443            width: cw,
444            height: ch,
445            data,
446        }
447    }
448
449    fn pad_to_square(bitmap: &mut GrayscaleBitmap) {
450        let width = bitmap.width as usize;
451        let height = bitmap.height as usize;
452        if width == height || width == 0 || height == 0 || bitmap.data.len() != width * height {
453            return;
454        }
455        let n = width.max(height);
456        let left = (n - width) / 2;
457        let top = (n - height) / 2;
458
459        let mut out = vec![0u8; n * n];
460        for y in 0..height {
461            let src = &bitmap.data[y * width..(y + 1) * width];
462            let dst_start = (top + y) * n + left;
463            out[dst_start..dst_start + width].copy_from_slice(src);
464        }
465
466        bitmap.width = n as u32;
467        bitmap.height = n as u32;
468        bitmap.data = out;
469    }
470
471    #[test]
472    fn ink_bounds_measures_content_not_canvas() {
473        let mut landscape = block(103, 80, 103, 80);
474        pad_to_square(&mut landscape);
475        assert_eq!((landscape.width, landscape.height), (103, 103));
476        assert_eq!(ink_bounds(&landscape), Some((103, 80)));
477
478        let mut portrait = block(80, 103, 80, 103);
479        pad_to_square(&mut portrait);
480        assert_eq!(ink_bounds(&portrait), Some((80, 103)));
481
482        assert_eq!(ink_bounds(&block(64, 64, 64, 64)), Some((64, 64)));
483    }
484
485    #[test]
486    fn ink_bounds_counts_faint_and_detached_pixels() {
487        assert_eq!(ink_bounds(&bitmap(10, 10, &[(9, 9, 1)])), Some((1, 1)));
488
489        assert_eq!(
490            ink_bounds(&bitmap(10, 10, &[(1, 2, 1), (7, 5, 255)])),
491            Some((7, 4))
492        );
493    }
494
495    #[test]
496    fn ink_bounds_rejects_empty_and_malformed_bitmaps() {
497        assert_eq!(ink_bounds(&bitmap(8, 8, &[])), None, "all-zero tip");
498        assert_eq!(
499            ink_bounds(&GrayscaleBitmap {
500                width: 0,
501                height: 4,
502                data: Vec::new(),
503            }),
504            None,
505            "zero-sized tip"
506        );
507        assert_eq!(
508            ink_bounds(&GrayscaleBitmap {
509                width: 4,
510                height: 4,
511                data: vec![255; 7],
512            }),
513            None,
514            "data length disagrees with dimensions"
515        );
516    }
517
518    #[test]
519    fn longest_realistic_size_label_fits_every_published_cell() {
520        let label = "2500×1667";
521        for cell in [100u32, 120, 200] {
522            let available = cell - 2 * ContactSheetConfig::default().padding;
523            let width = label_pixel_width(label, label_px_size(cell));
524            assert!(
525                width <= available,
526                "{label:?} needs {width}px but cell {cell} offers {available}px"
527            );
528        }
529    }
530
531    fn label_band_count(bitmap: &GrayscaleBitmap, show_sizes: bool) -> usize {
532        let brushes = [SheetBrush { name: "x", bitmap }];
533        let config = ContactSheetConfig {
534            cell_size: 100,
535            columns: Some(1),
536            padding: 8,
537            show_names: false,
538            show_sizes,
539            gap: 8,
540            background: SheetBackground::White,
541        };
542        let png = generate_contact_sheet_png(&brushes, &config).expect("sheet generated");
543        let luma_a = image::load_from_memory(&png)
544            .expect("decode sheet")
545            .to_luma_alpha8();
546        let (w, h) = luma_a.dimensions();
547        let mut bands = 0;
548        let mut in_band = false;
549        for y in 0..h {
550            let has_label = (0..w).any(|x| {
551                let v = luma_a.get_pixel(x, y).0[0];
552                (LABEL_COLOR..255).contains(&v)
553            });
554            if has_label && !in_band {
555                bands += 1;
556            }
557            in_band = has_label;
558        }
559        bands
560    }
561
562    #[test]
563    fn show_sizes_false_draws_no_label_line() {
564        let tip = block(32, 32, 20, 12);
565        assert_eq!(label_band_count(&tip, false), 0, "no label when sizes off");
566        assert_eq!(label_band_count(&tip, true), 1, "one size line when on");
567    }
568}