Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 28 kB View raw
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}