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 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
226fn 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}