Another project
1use std::io::Cursor;
2
3use bone_text::{FontFace, FontWeight, ShapeRequest, ShapedLine, Shaper, load_font};
4use swash::{
5 FontRef,
6 scale::{Render, ScaleContext, Source, image::Image},
7 zeno::Format,
8};
9
10use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
11use crate::strings::StringTable;
12use crate::theme::{Border, Color, SurfaceLevel, Theme};
13use crate::widgets::{ConvexPoly, HorizontalAlign, PaintPrim, PolyPath, WidgetPaint, lower_paint};
14
15const VECTOR_AA_PX: f32 = 0.5;
16
17const MARK_FONT_SCALE: f32 = 0.7;
18
19#[derive(Debug, thiserror::Error)]
20pub enum PngError {
21 #[error("png encode failed: {0}")]
22 Encode(String),
23 #[error("png decode failed: {0}")]
24 Decode(String),
25 #[error("png dimensions {got_w}x{got_h} differ from expected {want_w}x{want_h}")]
26 Dimensions {
27 got_w: CanvasPx,
28 got_h: CanvasPx,
29 want_w: CanvasPx,
30 want_h: CanvasPx,
31 },
32 #[error("png decoded buffer size {got} differs from expected {want}")]
33 Length { got: usize, want: usize },
34}
35
36#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
37pub struct CanvasPx(u32);
38
39impl CanvasPx {
40 #[must_use]
41 pub const fn new(value: u32) -> Self {
42 Self(value)
43 }
44
45 #[must_use]
46 pub const fn value(self) -> u32 {
47 self.0
48 }
49}
50
51impl core::fmt::Display for CanvasPx {
52 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
53 self.0.fmt(f)
54 }
55}
56
57#[derive(Copy, Clone, Debug, PartialEq, Eq)]
58pub struct CanvasSize {
59 pub width: CanvasPx,
60 pub height: CanvasPx,
61}
62
63impl CanvasSize {
64 #[must_use]
65 pub const fn new(width: CanvasPx, height: CanvasPx) -> Self {
66 Self { width, height }
67 }
68
69 #[must_use]
70 pub const fn pixel_count(self) -> usize {
71 (self.width.value() as usize) * (self.height.value() as usize)
72 }
73}
74
75#[derive(Clone, Debug)]
76struct Canvas {
77 size: CanvasSize,
78 pixels: Vec<[f32; 4]>,
79}
80
81impl Canvas {
82 fn new(size: CanvasSize, clear: Color) -> Self {
83 let fill = clear.linear_rgba_premul();
84 Self {
85 size,
86 pixels: vec![fill; size.pixel_count()],
87 }
88 }
89
90 fn fill_rect(&mut self, rect: LayoutRect, color: Color) {
91 let bounds = self.bounds(rect);
92 let src = color.linear_rgba_premul();
93 composite_band(&mut self.pixels, self.size.width.value(), bounds, src);
94 }
95
96 fn outline_rect(&mut self, rect: LayoutRect, color: Color, thickness: f32) {
97 let outer = self.bounds(rect);
98 if outer.is_empty() {
99 return;
100 }
101 #[allow(
102 clippy::cast_possible_truncation,
103 clippy::cast_sign_loss,
104 reason = "stroke widths are small non-negative pixel counts"
105 )]
106 let t = thickness.max(1.0).round() as u32;
107 let h = outer.y1 - outer.y0;
108 let w = outer.x1 - outer.x0;
109 let top_h = t.min(h);
110 let bot_h = t.min(h - top_h);
111 let left_w = t.min(w);
112 let right_w = t.min(w - left_w);
113 let mid_y0 = outer.y0 + top_h;
114 let mid_y1 = outer.y1 - bot_h;
115 let src = color.linear_rgba_premul();
116 let bands = [
117 PixelBounds {
118 x0: outer.x0,
119 y0: outer.y0,
120 x1: outer.x1,
121 y1: mid_y0,
122 },
123 PixelBounds {
124 x0: outer.x0,
125 y0: mid_y1,
126 x1: outer.x1,
127 y1: outer.y1,
128 },
129 PixelBounds {
130 x0: outer.x0,
131 y0: mid_y0,
132 x1: outer.x0 + left_w,
133 y1: mid_y1,
134 },
135 PixelBounds {
136 x0: outer.x1 - right_w,
137 y0: mid_y0,
138 x1: outer.x1,
139 y1: mid_y1,
140 },
141 ];
142 bands.iter().for_each(|band| {
143 composite_band(&mut self.pixels, self.size.width.value(), *band, src);
144 });
145 }
146
147 fn bounds(&self, rect: LayoutRect) -> PixelBounds {
148 let x0 = clamp_axis(rect.min_x(), self.size.width);
149 let y0 = clamp_axis(rect.min_y(), self.size.height);
150 let x1 = clamp_axis(rect.max_x(), self.size.width);
151 let y1 = clamp_axis(rect.max_y(), self.size.height);
152 PixelBounds {
153 x0,
154 y0,
155 x1: x1.max(x0),
156 y1: y1.max(y0),
157 }
158 }
159
160 fn expanded_bounds(&self, rect: LayoutRect, pad: f32) -> PixelBounds {
161 let grown = LayoutRect::new(
162 LayoutPos::new(
163 LayoutPx::saturating(rect.min_x().value() - pad),
164 LayoutPx::saturating(rect.min_y().value() - pad),
165 ),
166 LayoutSize::new(
167 LayoutPx::saturating_nonneg(rect.size.width.value() + 2.0 * pad),
168 LayoutPx::saturating_nonneg(rect.size.height.value() + 2.0 * pad),
169 ),
170 );
171 self.bounds(grown)
172 }
173
174 fn fill_convex(&mut self, poly: &ConvexPoly, fill: Color, border: Option<Border>) {
175 let planes = poly.edge_planes();
176 let bounds = self.expanded_bounds(poly.bounds(), VECTOR_AA_PX);
177 if bounds.is_empty() {
178 return;
179 }
180 let fill_premul = fill.linear_rgba_premul();
181 let (border_premul, border_width) = match border {
182 Some(b) => (b.color.linear_rgba_premul(), b.width.value_px()),
183 None => ([0.0; 4], 0.0),
184 };
185 let stride = self.size.width.value();
186 (bounds.y0..bounds.y1).for_each(|y| {
187 let py = pixel_center(y);
188 (bounds.x0..bounds.x1).for_each(|x| {
189 let sd = convex_signed_distance(&planes, pixel_center(x), py);
190 let cov_outer = coverage(sd);
191 if cov_outer <= 0.0 {
192 return;
193 }
194 let cov_inner = if border_width > 0.0 {
195 coverage(sd + border_width)
196 } else {
197 cov_outer
198 };
199 let src = blend_fill_border(
200 fill_premul,
201 cov_inner,
202 border_premul,
203 (cov_outer - cov_inner).max(0.0),
204 );
205 composite_pixel(&mut self.pixels, stride, x, y, src);
206 });
207 });
208 }
209
210 fn stroke_path(&mut self, path: &PolyPath, width: f32, color: Color) {
211 let half = width.max(1.0) * 0.5;
212 let bounds = self.expanded_bounds(path.bounds(), half + VECTOR_AA_PX);
213 if bounds.is_empty() {
214 return;
215 }
216 let span_w = (bounds.x1 - bounds.x0) as usize;
217 let span_h = (bounds.y1 - bounds.y0) as usize;
218 let mut mask = vec![0.0_f32; span_w * span_h];
219 path.segments().for_each(|(a, b)| {
220 accumulate_segment(&mut mask, bounds, span_w, a, b, half);
221 });
222 let premul = color.linear_rgba_premul();
223 let stride = self.size.width.value();
224 (bounds.y0..bounds.y1).for_each(|y| {
225 let row = (y - bounds.y0) as usize;
226 (bounds.x0..bounds.x1).for_each(|x| {
227 let cov = mask[row * span_w + (x - bounds.x0) as usize];
228 if cov <= 0.0 {
229 return;
230 }
231 let src = [
232 premul[0] * cov,
233 premul[1] * cov,
234 premul[2] * cov,
235 premul[3] * cov,
236 ];
237 composite_pixel(&mut self.pixels, stride, x, y, src);
238 });
239 });
240 }
241
242 fn into_srgb_rgba8(self) -> Vec<u8> {
243 self.pixels
244 .iter()
245 .flat_map(|p| pixel_to_srgb_u8(*p))
246 .collect()
247 }
248}
249
250#[derive(Copy, Clone, Debug, PartialEq, Eq)]
251struct PixelBounds {
252 x0: u32,
253 y0: u32,
254 x1: u32,
255 y1: u32,
256}
257
258impl PixelBounds {
259 fn is_empty(self) -> bool {
260 self.x0 >= self.x1 || self.y0 >= self.y1
261 }
262}
263
264fn clamp_axis(value: LayoutPx, limit: CanvasPx) -> u32 {
265 let raw = value.value();
266 #[allow(
267 clippy::cast_precision_loss,
268 reason = "canvas dimensions far below f32 precision boundary"
269 )]
270 let bounded = raw.clamp(0.0, limit.value() as f32);
271 #[allow(
272 clippy::cast_possible_truncation,
273 clippy::cast_sign_loss,
274 reason = "value clamped to [0, limit] before cast"
275 )]
276 let pixel = bounded.round() as u32;
277 pixel
278}
279
280fn composite_band(pixels: &mut [[f32; 4]], stride: u32, bounds: PixelBounds, src: [f32; 4]) {
281 if bounds.is_empty() {
282 return;
283 }
284 let inv_alpha = 1.0 - src[3];
285 (bounds.y0..bounds.y1).for_each(|y| {
286 let row_offset = (y as usize) * (stride as usize);
287 (bounds.x0..bounds.x1).for_each(|x| {
288 let idx = row_offset + (x as usize);
289 let dst = pixels[idx];
290 pixels[idx] = [
291 src[0] + dst[0] * inv_alpha,
292 src[1] + dst[1] * inv_alpha,
293 src[2] + dst[2] * inv_alpha,
294 src[3] + dst[3] * inv_alpha,
295 ];
296 });
297 });
298}
299
300#[allow(
301 clippy::cast_precision_loss,
302 reason = "pixel coordinates stay far below the f32 mantissa limit"
303)]
304fn pixel_center(coord: u32) -> f32 {
305 coord as f32 + 0.5
306}
307
308fn coverage(signed_distance: f32) -> f32 {
309 (0.5 - signed_distance / VECTOR_AA_PX).clamp(0.0, 1.0)
310}
311
312fn convex_signed_distance(planes: &[[f32; 3]], x: f32, y: f32) -> f32 {
313 planes
314 .iter()
315 .map(|[nx, ny, d]| nx * x + ny * y - d)
316 .fold(f32::NEG_INFINITY, f32::max)
317}
318
319fn blend_fill_border(
320 fill_premul: [f32; 4],
321 fill_cov: f32,
322 border_premul: [f32; 4],
323 border_cov: f32,
324) -> [f32; 4] {
325 let border = [
326 border_premul[0] * border_cov,
327 border_premul[1] * border_cov,
328 border_premul[2] * border_cov,
329 border_premul[3] * border_cov,
330 ];
331 let inv = 1.0 - border[3];
332 [
333 border[0] + fill_premul[0] * fill_cov * inv,
334 border[1] + fill_premul[1] * fill_cov * inv,
335 border[2] + fill_premul[2] * fill_cov * inv,
336 border[3] + fill_premul[3] * fill_cov * inv,
337 ]
338}
339
340fn composite_pixel(pixels: &mut [[f32; 4]], stride: u32, x: u32, y: u32, src: [f32; 4]) {
341 let idx = (y as usize) * (stride as usize) + (x as usize);
342 let dst = pixels[idx];
343 let inv_alpha = 1.0 - src[3];
344 pixels[idx] = [
345 src[0] + dst[0] * inv_alpha,
346 src[1] + dst[1] * inv_alpha,
347 src[2] + dst[2] * inv_alpha,
348 src[3] + dst[3] * inv_alpha,
349 ];
350}
351
352fn accumulate_segment(
353 mask: &mut [f32],
354 bounds: PixelBounds,
355 span_w: usize,
356 a: LayoutPos,
357 b: LayoutPos,
358 half: f32,
359) {
360 let (ax, ay) = (a.x.value(), a.y.value());
361 let (bx, by) = (b.x.value(), b.y.value());
362 (bounds.y0..bounds.y1).for_each(|y| {
363 let row = (y - bounds.y0) as usize;
364 let py = pixel_center(y);
365 (bounds.x0..bounds.x1).for_each(|x| {
366 let cov = coverage(dist_point_segment(pixel_center(x), py, ax, ay, bx, by) - half);
367 if cov > 0.0 {
368 let idx = row * span_w + (x - bounds.x0) as usize;
369 mask[idx] = mask[idx].max(cov);
370 }
371 });
372 });
373}
374
375fn dist_point_segment(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
376 let (dx, dy) = (bx - ax, by - ay);
377 let len2 = dx * dx + dy * dy;
378 let t = if len2 <= f32::EPSILON {
379 0.0
380 } else {
381 (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0)
382 };
383 (px - (ax + t * dx)).hypot(py - (ay + t * dy))
384}
385
386fn pixel_to_srgb_u8(premul: [f32; 4]) -> [u8; 4] {
387 let alpha = premul[3].clamp(0.0, 1.0);
388 if alpha <= 0.0 {
389 return [0, 0, 0, 0];
390 }
391 let inv = 1.0 / alpha;
392 let to_u8 = |c: f32| {
393 let unpremul = (c * inv).clamp(0.0, 1.0);
394 unit_to_byte(linear_to_srgb(unpremul))
395 };
396 [
397 to_u8(premul[0]),
398 to_u8(premul[1]),
399 to_u8(premul[2]),
400 unit_to_byte(alpha),
401 ]
402}
403
404fn unit_to_byte(unit: f32) -> u8 {
405 #[allow(
406 clippy::cast_possible_truncation,
407 clippy::cast_sign_loss,
408 reason = "value clamped to [0, 255] before cast"
409 )]
410 let byte = (unit * 255.0).round().clamp(0.0, 255.0) as u8;
411 byte
412}
413
414fn linear_to_srgb(linear: f32) -> f32 {
415 if linear <= 0.003_130_8 {
416 12.92 * linear
417 } else {
418 1.055 * linear.powf(1.0 / 2.4) - 0.055
419 }
420}
421
422#[must_use]
423pub fn rasterize(
424 theme: &Theme,
425 paint: &[WidgetPaint],
426 size: CanvasSize,
427 strings: &StringTable,
428) -> Vec<u8> {
429 let mut canvas = Canvas::new(size, theme.colors.surface(SurfaceLevel::L0));
430 let mut painter = GlyphPainter::new();
431 paint
432 .iter()
433 .for_each(|p| draw(&mut canvas, p, theme, strings, &mut painter));
434 canvas.into_srgb_rgba8()
435}
436
437fn draw(
438 canvas: &mut Canvas,
439 paint: &WidgetPaint,
440 theme: &Theme,
441 strings: &StringTable,
442 painter: &mut GlyphPainter,
443) {
444 match paint {
445 WidgetPaint::Label {
446 rect,
447 text,
448 color,
449 role,
450 } => {
451 painter.paint_text(
452 canvas,
453 *rect,
454 text.resolve(strings),
455 TextStyle {
456 font_size_px: role.size.as_px_f32(),
457 face: role.face,
458 weight: role.weight,
459 color: *color,
460 align: HorizontalAlign::Center,
461 },
462 );
463 }
464 WidgetPaint::AlignedLabel {
465 rect,
466 text,
467 color,
468 role,
469 align,
470 } => {
471 painter.paint_text(
472 canvas,
473 *rect,
474 text.resolve(strings),
475 TextStyle {
476 font_size_px: role.size.as_px_f32(),
477 face: role.face,
478 weight: role.weight,
479 color: *color,
480 align: *align,
481 },
482 );
483 }
484 WidgetPaint::Mark { rect, kind, color } => {
485 painter.paint_text(
486 canvas,
487 *rect,
488 kind.glyph(),
489 TextStyle {
490 font_size_px: rect.size.height.value() * MARK_FONT_SCALE,
491 face: FontFace::Sans,
492 weight: FontWeight::Regular,
493 color: *color,
494 align: HorizontalAlign::Center,
495 },
496 );
497 }
498 WidgetPaint::ConvexFill { poly, fill, border } => {
499 canvas.fill_convex(poly, *fill, *border);
500 }
501 WidgetPaint::Stroke { path, width, color } => {
502 canvas.stroke_path(path, width.value_px(), *color);
503 }
504 WidgetPaint::Popup { paints } => {
505 paints
506 .iter()
507 .for_each(|inner| draw(canvas, inner, theme, strings, painter));
508 }
509 other => {
510 let prim = lower_paint(theme, other);
511 draw_prim(canvas, &prim);
512 }
513 }
514}
515
516fn draw_prim(canvas: &mut Canvas, prim: &PaintPrim) {
517 if prim.fill.alpha() > 0.0 {
518 canvas.fill_rect(prim.rect, prim.fill);
519 }
520 if let Some(b) = prim.border {
521 canvas.outline_rect(prim.rect, b.color, b.width.value_px());
522 }
523}
524
525struct GlyphPainter {
526 shaper: Shaper,
527 scale_ctx: ScaleContext,
528 sans: FontRef<'static>,
529 mono: FontRef<'static>,
530}
531
532impl GlyphPainter {
533 fn new() -> Self {
534 Self {
535 shaper: Shaper::new(),
536 scale_ctx: ScaleContext::new(),
537 sans: load_font(FontFace::Sans),
538 mono: load_font(FontFace::Mono),
539 }
540 }
541
542 fn font_for(&self, face: FontFace) -> &FontRef<'static> {
543 match face {
544 FontFace::Sans => &self.sans,
545 FontFace::Mono => &self.mono,
546 }
547 }
548
549 fn paint_text(&mut self, canvas: &mut Canvas, rect: LayoutRect, text: &str, style: TextStyle) {
550 if text.is_empty() || style.font_size_px <= 0.0 {
551 return;
552 }
553 let layout = self.shaper.shape(
554 text,
555 ShapeRequest {
556 face: style.face,
557 size_px: style.font_size_px,
558 weight: style.weight,
559 line_height_px: 0.0,
560 letter_spacing_px: 0.0,
561 max_width: None,
562 },
563 );
564 let metrics = self
565 .font_for(style.face)
566 .metrics(&[])
567 .scale(style.font_size_px);
568 let visible_advance = layout
569 .lines
570 .first()
571 .map_or(0.0, ShapedLine::visible_advance_px);
572 let rx = rect.origin.x.value();
573 let ry = rect.origin.y.value();
574 let rw = rect.size.width.value();
575 let rh = rect.size.height.value();
576 let start_x = match style.align {
577 HorizontalAlign::Center => rx + ((rw - visible_advance) * 0.5).max(0.0),
578 HorizontalAlign::Start => rx,
579 HorizontalAlign::End => rx + (rw - visible_advance).max(0.0),
580 };
581 let baseline_y = ry + (rh + metrics.cap_height) * 0.5;
582 let font = *self.font_for(style.face);
583 let mut scaler = self
584 .scale_ctx
585 .builder(font)
586 .size(style.font_size_px)
587 .hint(false)
588 .build();
589 layout
590 .lines
591 .iter()
592 .flat_map(|l| l.runs.iter())
593 .for_each(|run| {
594 run.glyphs.iter().for_each(|g| {
595 let Ok(glyph_id_u16) = u16::try_from(g.id.raw()) else {
596 return;
597 };
598 let mut image = Image::new();
599 let rendered = Render::new(&[Source::Outline])
600 .format(Format::Alpha)
601 .render_into(&mut scaler, glyph_id_u16, &mut image);
602 if !rendered {
603 return;
604 }
605 composite_alpha_mask(
606 canvas,
607 &image,
608 start_x + run.origin_x_px + g.x_px,
609 baseline_y,
610 style.color,
611 );
612 });
613 });
614 }
615}
616
617#[derive(Copy, Clone)]
618struct TextStyle {
619 font_size_px: f32,
620 face: FontFace,
621 weight: FontWeight,
622 color: Color,
623 align: HorizontalAlign,
624}
625
626fn composite_alpha_mask(
627 canvas: &mut Canvas,
628 image: &Image,
629 origin_x_px: f32,
630 baseline_y_px: f32,
631 color: Color,
632) {
633 let placement = image.placement;
634 let mask_w = placement.width;
635 let mask_h = placement.height;
636 if mask_w == 0 || mask_h == 0 || image.data.len() < (mask_w as usize) * (mask_h as usize) {
637 return;
638 }
639 #[allow(
640 clippy::cast_precision_loss,
641 reason = "glyph placement offsets stay well within f32 mantissa precision"
642 )]
643 let base_horiz = origin_x_px + placement.left as f32;
644 #[allow(
645 clippy::cast_precision_loss,
646 reason = "glyph placement offsets stay well within f32 mantissa precision"
647 )]
648 let base_vert = baseline_y_px - placement.top as f32;
649 let canvas_w = canvas.size.width.value();
650 let canvas_h = canvas.size.height.value();
651 let stride = canvas_w as usize;
652 let color_premul = color.linear_rgba_premul();
653 (0..mask_h).for_each(|py| {
654 let Some(dst_y) = pixel_index(base_vert, py, canvas_h) else {
655 return;
656 };
657 let row = (py as usize) * (mask_w as usize);
658 (0..mask_w).for_each(|px| {
659 let Some(dst_x) = pixel_index(base_horiz, px, canvas_w) else {
660 return;
661 };
662 let mask = f32::from(image.data[row + (px as usize)]) / 255.0;
663 if mask <= 0.0 {
664 return;
665 }
666 let src = [
667 color_premul[0] * mask,
668 color_premul[1] * mask,
669 color_premul[2] * mask,
670 color_premul[3] * mask,
671 ];
672 let inv_alpha = 1.0 - src[3];
673 let idx = (dst_y as usize) * stride + (dst_x as usize);
674 let dst = canvas.pixels[idx];
675 canvas.pixels[idx] = [
676 src[0] + dst[0] * inv_alpha,
677 src[1] + dst[1] * inv_alpha,
678 src[2] + dst[2] * inv_alpha,
679 src[3] + dst[3] * inv_alpha,
680 ];
681 });
682 });
683}
684
685#[allow(
686 clippy::cast_precision_loss,
687 clippy::cast_possible_truncation,
688 clippy::cast_sign_loss,
689 reason = "glyph mask offsets stay within f32 mantissa precision and the rounded coordinate is guarded against negatives and the canvas limit"
690)]
691fn pixel_index(base: f32, offset: u32, limit: u32) -> Option<u32> {
692 let pos = (base + offset as f32).round();
693 if !pos.is_finite() || pos < 0.0 {
694 return None;
695 }
696 let value = pos as u32;
697 (value < limit).then_some(value)
698}
699
700pub fn encode_png(rgba: &[u8], size: CanvasSize) -> Result<Vec<u8>, PngError> {
701 let expected = size.pixel_count() * 4;
702 if rgba.len() != expected {
703 return Err(PngError::Length {
704 got: rgba.len(),
705 want: expected,
706 });
707 }
708 let mut out = Vec::new();
709 {
710 let mut encoder = png::Encoder::new(&mut out, size.width.value(), size.height.value());
711 encoder.set_color(png::ColorType::Rgba);
712 encoder.set_depth(png::BitDepth::Eight);
713 encoder.set_compression(png::Compression::Best);
714 let mut writer = encoder
715 .write_header()
716 .map_err(|e| PngError::Encode(e.to_string()))?;
717 writer
718 .write_image_data(rgba)
719 .map_err(|e| PngError::Encode(e.to_string()))?;
720 }
721 Ok(out)
722}
723
724pub fn decode_png(bytes: &[u8], size: CanvasSize) -> Result<Vec<u8>, PngError> {
725 let decoder = png::Decoder::new(Cursor::new(bytes));
726 let mut reader = decoder
727 .read_info()
728 .map_err(|e| PngError::Decode(e.to_string()))?;
729 let info = reader.info();
730 if info.width != size.width.value() || info.height != size.height.value() {
731 return Err(PngError::Dimensions {
732 got_w: CanvasPx::new(info.width),
733 got_h: CanvasPx::new(info.height),
734 want_w: size.width,
735 want_h: size.height,
736 });
737 }
738 let mut buf = vec![0_u8; reader.output_buffer_size()];
739 let frame = reader
740 .next_frame(&mut buf)
741 .map_err(|e| PngError::Decode(e.to_string()))?;
742 if frame.color_type != png::ColorType::Rgba {
743 return Err(PngError::Decode(format!(
744 "expected rgba color type, got {:?}",
745 frame.color_type,
746 )));
747 }
748 let rgba = buf[..frame.buffer_size()].to_vec();
749 let expected = size.pixel_count() * 4;
750 if rgba.len() != expected {
751 return Err(PngError::Length {
752 got: rgba.len(),
753 want: expected,
754 });
755 }
756 Ok(rgba)
757}
758
759#[cfg(test)]
760mod tests {
761 use super::{Canvas, CanvasPx, CanvasSize, rasterize};
762 use crate::gallery::{GALLERY_CANVAS, GalleryState, render};
763 use crate::strings::StringTable;
764 use crate::theme::Theme;
765 use std::sync::Arc;
766
767 #[test]
768 fn rasterizer_emits_canvas_sized_buffer() {
769 let mut state = GalleryState::new();
770 let paint = render(Arc::new(Theme::light()), &mut state);
771 let pixels = rasterize(
772 &Theme::light(),
773 &paint,
774 GALLERY_CANVAS,
775 StringTable::empty(),
776 );
777 assert_eq!(pixels.len(), GALLERY_CANVAS.pixel_count() * 4);
778 }
779
780 #[test]
781 fn light_and_dark_buffers_differ() {
782 let render_for = |theme: Theme| {
783 let mut state = GalleryState::new();
784 let paint = render(Arc::new(theme.clone()), &mut state);
785 rasterize(&theme, &paint, GALLERY_CANVAS, StringTable::empty())
786 };
787 let light = render_for(Theme::light());
788 let dark = render_for(Theme::dark());
789 assert_ne!(light, dark, "themes must produce distinct rasterizations");
790 }
791
792 #[test]
793 fn outline_rect_corners_render_once_under_translucent_color() {
794 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
795 use crate::theme::Color;
796 let size = CanvasSize::new(CanvasPx::new(8), CanvasPx::new(8));
797 let bg = Color::TRANSPARENT.with_alpha(1.0);
798 let stroke = Theme::light().colors.focus_ring().with_alpha(0.5);
799 let mut canvas = Canvas::new(size, bg);
800 canvas.outline_rect(
801 LayoutRect::new(
802 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
803 LayoutSize::new(LayoutPx::new(8.0), LayoutPx::new(8.0)),
804 ),
805 stroke,
806 1.0,
807 );
808 let bytes = canvas.into_srgb_rgba8();
809 let pixel = |x: usize, y: usize| {
810 let i = (y * size.width.value() as usize + x) * 4;
811 [bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]
812 };
813 assert_eq!(
814 pixel(0, 0),
815 pixel(3, 0),
816 "corner pixel must match a non-corner top-edge pixel; double-paint detected",
817 );
818 }
819
820 #[test]
821 fn convex_fill_and_stroke_paint_inside_their_shapes() {
822 use crate::layout::{LayoutPos, LayoutPx};
823 use crate::theme::{Step12, StrokeWidth};
824 use crate::widgets::{ConvexPoly, PolyPath, WidgetPaint};
825 let theme = Theme::light();
826 let size = CanvasSize::new(CanvasPx::new(32), CanvasPx::new(32));
827 let ink = theme.colors.accent.step(Step12::SOLID);
828 let bg = rasterize(&theme, &[], size, StringTable::empty());
829 let rgb = |buf: &[u8], x: usize, y: usize| {
830 let i = (y * 32 + x) * 4;
831 [buf[i], buf[i + 1], buf[i + 2]]
832 };
833 let Some(poly) = ConvexPoly::new(vec![
834 LayoutPos::new(LayoutPx::new(8.0), LayoutPx::new(8.0)),
835 LayoutPos::new(LayoutPx::new(24.0), LayoutPx::new(8.0)),
836 LayoutPos::new(LayoutPx::new(24.0), LayoutPx::new(24.0)),
837 LayoutPos::new(LayoutPx::new(8.0), LayoutPx::new(24.0)),
838 ]) else {
839 panic!("the square is convex");
840 };
841 let filled = rasterize(
842 &theme,
843 &[WidgetPaint::ConvexFill {
844 poly,
845 fill: ink,
846 border: None,
847 }],
848 size,
849 StringTable::empty(),
850 );
851 assert_ne!(
852 rgb(&filled, 16, 16),
853 rgb(&bg, 16, 16),
854 "interior is painted"
855 );
856 assert_eq!(
857 rgb(&filled, 1, 1),
858 rgb(&bg, 1, 1),
859 "far corner is untouched"
860 );
861
862 let Some(path) = PolyPath::open(vec![
863 LayoutPos::new(LayoutPx::new(4.0), LayoutPx::new(16.0)),
864 LayoutPos::new(LayoutPx::new(28.0), LayoutPx::new(16.0)),
865 ]) else {
866 panic!("two points form a path");
867 };
868 let stroked = rasterize(
869 &theme,
870 &[WidgetPaint::Stroke {
871 path,
872 width: StrokeWidth::px(3.0),
873 color: ink,
874 }],
875 size,
876 StringTable::empty(),
877 );
878 assert_ne!(
879 rgb(&stroked, 16, 16),
880 rgb(&bg, 16, 16),
881 "stroke covers the line"
882 );
883 assert_eq!(
884 rgb(&stroked, 16, 3),
885 rgb(&bg, 16, 3),
886 "stroke stays off the line"
887 );
888 }
889}