use std::fmt;
pub use crate::core::{Affine2D, Path, color::Rgba};
use crate::core::{Bbox, PathSegment};
use crate::render::{CapStyle, GraphicsContext, JoinStyle, Renderer};
use tiny_skia::{
Color, FillRule, IntSize, LineCap, LineJoin, Mask, Paint, PathBuilder, Pixmap, PixmapPaint,
Rect, Stroke, StrokeDash, Transform,
};
#[derive(Debug)]
pub struct PngError(String);
impl fmt::Display for PngError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "PNG encoding failed: {}", self.0)
}
}
impl std::error::Error for PngError {}
#[derive(Debug, Clone)]
pub struct SkiaRenderer {
pixmap: Pixmap,
dpi: f64,
clip_cache: Option<(Bbox, Mask)>,
}
impl SkiaRenderer {
#[must_use]
pub fn new(width_px: u32, height_px: u32, dpi: f64) -> Self {
let pixmap = Pixmap::new(width_px, height_px)
.expect("SkiaRenderer: pixmap dimensions must be non-zero");
Self {
pixmap,
dpi,
clip_cache: None,
}
}
#[must_use]
pub fn dpi(&self) -> f64 {
self.dpi
}
#[must_use]
pub fn pixmap(&self) -> &Pixmap {
&self.pixmap
}
#[must_use]
pub fn into_pixmap(self) -> Pixmap {
self.pixmap
}
pub fn encode_png(&self) -> Result<Vec<u8>, PngError> {
self.pixmap
.encode_png()
.map_err(|e| PngError(e.to_string()))
}
pub fn save_png<P: AsRef<std::path::Path>>(&self, path: P) -> Result<(), PngError> {
self.pixmap
.save_png(path)
.map_err(|e| PngError(e.to_string()))
}
fn flip_transform(&self) -> Affine2D {
let height = f64::from(self.pixmap.height());
Affine2D::from_scale(1.0, -1.0).translate(0.0, height)
}
fn build_device_path(path: &Path, device: &Affine2D) -> Option<tiny_skia::Path> {
let map = |p: [f64; 2]| -> (f32, f32) {
let (x, y) = device.transform_point((p[0], p[1]));
(x as f32, y as f32)
};
let mut pb = PathBuilder::new();
for seg in path.iter_segments() {
match seg {
PathSegment::MoveTo(p) => {
let (x, y) = map(p);
pb.move_to(x, y);
}
PathSegment::LineTo(p) => {
let (x, y) = map(p);
pb.line_to(x, y);
}
PathSegment::Quad(c, e) => {
let (cx, cy) = map(c);
let (ex, ey) = map(e);
pb.quad_to(cx, cy, ex, ey);
}
PathSegment::Cubic(c1, c2, e) => {
let (c1x, c1y) = map(c1);
let (c2x, c2y) = map(c2);
let (ex, ey) = map(e);
pb.cubic_to(c1x, c1y, c2x, c2y, ex, ey);
}
PathSegment::Close => pb.close(),
}
}
pb.finish()
}
fn build_clip_mask(width: u32, height: u32, bbox: &Bbox) -> Option<Mask> {
let pix_h = f64::from(height);
let left = bbox.xmin();
let right = bbox.xmax();
let top = pix_h - bbox.ymax();
let bottom = pix_h - bbox.ymin();
let rect = Rect::from_ltrb(left as f32, top as f32, right as f32, bottom as f32)?;
let mut mask = Mask::new(width, height)?;
let mut pb = PathBuilder::new();
pb.push_rect(rect);
let rect_path = pb.finish()?;
mask.fill_path(&rect_path, FillRule::Winding, true, Transform::identity());
Some(mask)
}
fn ensure_clip_mask(&mut self, gc: &GraphicsContext) {
let Some(bbox) = gc.clip_rect else {
self.clip_cache = None;
return;
};
if matches!(&self.clip_cache, Some((cached, _)) if *cached == bbox) {
return;
}
self.clip_cache = Self::build_clip_mask(self.pixmap.width(), self.pixmap.height(), &bbox)
.map(|mask| (bbox, mask));
}
}
fn to_color(rgba: Rgba, alpha: Option<f64>) -> Color {
let a = alpha.map_or(rgba.a, |g| rgba.a * g);
Color::from_rgba(
rgba.r.clamp(0.0, 1.0) as f32,
rgba.g.clamp(0.0, 1.0) as f32,
rgba.b.clamp(0.0, 1.0) as f32,
a.clamp(0.0, 1.0) as f32,
)
.unwrap_or(Color::TRANSPARENT)
}
fn premultiply_rgba(rgba: &[u8], width: usize, height: usize) -> Vec<u8> {
debug_assert_eq!(rgba.len(), width * height * 4);
let mut out = Vec::with_capacity(rgba.len());
for px in rgba.chunks_exact(4) {
let [r, g, b, a] = [px[0], px[1], px[2], px[3]];
let mul = |c: u8| -> u8 { ((u16::from(c) * u16::from(a) + 127) / 255) as u8 };
out.push(mul(r));
out.push(mul(g));
out.push(mul(b));
out.push(a);
}
out
}
fn to_line_cap(cap: CapStyle) -> LineCap {
match cap {
CapStyle::Butt => LineCap::Butt,
CapStyle::Round => LineCap::Round,
CapStyle::Projecting => LineCap::Square,
}
}
fn to_line_join(join: JoinStyle) -> LineJoin {
match join {
JoinStyle::Miter => LineJoin::Miter,
JoinStyle::Round => LineJoin::Round,
JoinStyle::Bevel => LineJoin::Bevel,
}
}
impl Renderer for SkiaRenderer {
fn draw_path(
&mut self,
gc: &GraphicsContext,
path: &Path,
transform: &Affine2D,
fill: Option<Rgba>,
) {
let device = transform.then(&self.flip_transform());
let Some(sk_path) = Self::build_device_path(path, &device) else {
return;
};
self.ensure_clip_mask(gc);
let mask_ref = self.clip_cache.as_ref().map(|(_, mask)| mask);
let anti_alias = gc.antialiased;
if let Some(face) = fill {
let mut paint = Paint {
anti_alias,
..Paint::default()
};
paint.set_color(to_color(face, gc.alpha));
self.pixmap.fill_path(
&sk_path,
&paint,
FillRule::Winding,
Transform::identity(),
mask_ref,
);
}
if let Some(stroke_color) = gc.stroke {
let width = self.points_to_pixels(gc.line_width);
let bounds = sk_path.bounds();
let has_extent = bounds.width() > 0.0 || bounds.height() > 0.0;
if width > 0.0 && has_extent {
let mut paint = Paint {
anti_alias,
..Paint::default()
};
paint.set_color(to_color(stroke_color, gc.alpha));
let dash = gc.dashes.as_ref().and_then(|(offset, pattern)| {
let scale = self.dpi / 72.0;
let array: Vec<f32> = pattern.iter().map(|d| (d * scale) as f32).collect();
StrokeDash::new(array, (offset * scale) as f32)
});
let stroke = Stroke {
width: width as f32,
line_cap: to_line_cap(gc.cap),
line_join: to_line_join(gc.join),
dash,
..Stroke::default()
};
self.pixmap
.stroke_path(&sk_path, &paint, &stroke, Transform::identity(), mask_ref);
}
}
}
fn canvas_size(&self) -> (f64, f64) {
(
f64::from(self.pixmap.width()),
f64::from(self.pixmap.height()),
)
}
fn points_to_pixels(&self, points: f64) -> f64 {
points * self.dpi / 72.0
}
fn decoration_scale(&self) -> f64 {
self.dpi / 100.0
}
fn flipy(&self) -> bool {
true
}
fn draw_image(
&mut self,
gc: &GraphicsContext,
x: f64,
y: f64,
rgba: &[u8],
width: usize,
height: usize,
) {
if width == 0 || height == 0 || rgba.len() != width * height * 4 {
return;
}
let (Ok(w), Ok(h)) = (u32::try_from(width), u32::try_from(height)) else {
return;
};
let Some(size) = IntSize::from_wh(w, h) else {
return;
};
let premultiplied = premultiply_rgba(rgba, width, height);
let Some(src) = Pixmap::from_vec(premultiplied, size) else {
return;
};
let pix_h = f64::from(self.pixmap.height());
let dest_x = x.round() as i32;
let dest_y = (pix_h - y - height as f64).round() as i32;
let opacity = gc.alpha.map_or(1.0, |a| a.clamp(0.0, 1.0) as f32);
let paint = PixmapPaint {
opacity,
..PixmapPaint::default()
};
self.ensure_clip_mask(gc);
self.pixmap.draw_pixmap(
dest_x,
dest_y,
src.as_ref(),
&paint,
Transform::identity(),
self.clip_cache.as_ref().map(|(_, mask)| mask),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Bbox;
fn pixel(r: &SkiaRenderer, x: u32, y: u32) -> [u8; 4] {
let p = r.pixmap().pixel(x, y).expect("pixel in bounds");
[p.red(), p.green(), p.blue(), p.alpha()]
}
#[test]
fn filled_rectangle_covers_center_not_corner() {
let mut r = SkiaRenderer::new(100, 100, 72.0);
let transform = Affine2D::from_scale(60.0, 60.0).translate(20.0, 20.0);
let gc = GraphicsContext::new();
r.draw_path(&gc, &Path::unit_rectangle(), &transform, Some(Rgba::RED));
assert_eq!(pixel(&r, 50, 50), [255, 0, 0, 255]);
assert_eq!(pixel(&r, 5, 5), [0, 0, 0, 0]);
}
#[test]
fn stroked_polyline_sets_pixels_on_path() {
let mut r = SkiaRenderer::new(100, 100, 72.0);
let line = Path::from_polyline(&[[10.0, 50.0], [90.0, 50.0]]);
let gc = GraphicsContext::new()
.with_stroke(Rgba::BLACK)
.with_line_width(4.0);
r.draw_path(&gc, &line, &Affine2D::identity(), None);
let [_, _, _, a] = pixel(&r, 50, 50);
assert!(a > 0, "stroke should mark the path center, got alpha {a}");
}
#[test]
fn degenerate_strokes_are_ignored() {
let gc = GraphicsContext::new()
.with_stroke(Rgba::BLACK)
.with_line_width(4.0);
for points in [&[[50.0, 50.0]][..], &[[50.0, 50.0], [50.0, 50.0]][..]] {
let mut r = SkiaRenderer::new(100, 100, 72.0);
r.draw_path(
&gc,
&Path::from_polyline(points),
&Affine2D::identity(),
None,
);
assert_eq!(pixel(&r, 50, 50), [0, 0, 0, 0]);
}
}
#[test]
fn zero_width_vertical_line_is_not_treated_as_degenerate() {
let mut r = SkiaRenderer::new(100, 100, 72.0);
let line = Path::from_polyline(&[[50.0, 10.0], [50.0, 90.0]]);
let gc = GraphicsContext::new()
.with_stroke(Rgba::BLACK)
.with_line_width(4.0);
r.draw_path(&gc, &line, &Affine2D::identity(), None);
assert!(pixel(&r, 50, 50)[3] > 0);
}
#[test]
fn clip_rect_limits_drawing() {
let mut r = SkiaRenderer::new(100, 100, 72.0);
let transform = Affine2D::from_scale(100.0, 100.0);
let gc = GraphicsContext {
clip_rect: Some(Bbox::from_extents(40.0, 40.0, 60.0, 60.0)),
..GraphicsContext::new()
};
r.draw_path(&gc, &Path::unit_rectangle(), &transform, Some(Rgba::RED));
assert_eq!(pixel(&r, 50, 50), [255, 0, 0, 255]);
assert_eq!(pixel(&r, 5, 5), [0, 0, 0, 0]);
}
#[test]
fn encode_png_returns_non_empty() {
let mut r = SkiaRenderer::new(16, 16, 72.0);
r.draw_path(
&GraphicsContext::new(),
&Path::unit_rectangle(),
&Affine2D::from_scale(16.0, 16.0),
Some(Rgba::BLUE),
);
assert!(!r.encode_png().expect("encode succeeds").is_empty());
}
fn solid_rgba(width: usize, height: usize, rgba: [u8; 4]) -> Vec<u8> {
rgba.iter()
.copied()
.cycle()
.take(width * height * 4)
.collect()
}
#[test]
fn draw_image_places_solid_block_with_y_flip() {
let mut r = SkiaRenderer::new(100, 100, 72.0);
let img = solid_rgba(10, 10, [255, 0, 0, 255]);
r.draw_image(&GraphicsContext::new(), 20.0, 20.0, &img, 10, 10);
assert_eq!(pixel(&r, 25, 75), [255, 0, 0, 255]);
assert_eq!(pixel(&r, 20, 70), [255, 0, 0, 255]);
assert_eq!(pixel(&r, 29, 79), [255, 0, 0, 255]);
assert_eq!(pixel(&r, 5, 5), [0, 0, 0, 0]);
assert_eq!(pixel(&r, 25, 25), [0, 0, 0, 0]);
assert_eq!(pixel(&r, 50, 50), [0, 0, 0, 0]);
}
#[test]
fn draw_image_half_transparent_blends_over_background() {
let mut r = SkiaRenderer::new(20, 20, 72.0);
let bg = Affine2D::from_scale(20.0, 20.0);
r.draw_path(
&GraphicsContext::new(),
&Path::unit_rectangle(),
&bg,
Some(Rgba::BLUE),
);
let img = solid_rgba(8, 8, [255, 0, 0, 128]);
r.draw_image(&GraphicsContext::new(), 5.0, 5.0, &img, 8, 8);
let [red, green, blue, alpha] = pixel(&r, 8, 10);
assert!(red > 110 && red < 145, "red {red}");
assert_eq!(green, 0);
assert!(blue > 110 && blue < 145, "blue {blue}");
assert_eq!(alpha, 255);
assert_eq!(pixel(&r, 0, 0), [0, 0, 255, 255]);
}
#[test]
fn draw_image_non_square_places_corner() {
let mut r = SkiaRenderer::new(40, 40, 72.0);
let img = solid_rgba(30, 10, [0, 255, 0, 255]);
r.draw_image(&GraphicsContext::new(), 0.0, 0.0, &img, 30, 10);
assert_eq!(pixel(&r, 29, 39), [0, 255, 0, 255]);
assert_eq!(pixel(&r, 0, 30), [0, 255, 0, 255]);
assert_eq!(pixel(&r, 30, 39), [0, 0, 0, 0]);
assert_eq!(pixel(&r, 0, 29), [0, 0, 0, 0]);
}
#[test]
fn draw_image_global_alpha_scales_opacity() {
let mut r = SkiaRenderer::new(20, 20, 72.0);
let img = solid_rgba(6, 6, [255, 0, 0, 255]);
let gc = GraphicsContext::new().with_alpha(0.5);
r.draw_image(&gc, 2.0, 2.0, &img, 6, 6);
let [_, _, _, alpha] = pixel(&r, 4, 14);
assert!((120..=135).contains(&alpha), "alpha {alpha}");
}
#[test]
fn draw_image_ignores_mismatched_buffer() {
let mut r = SkiaRenderer::new(10, 10, 72.0);
let img = vec![255u8; 7]; r.draw_image(&GraphicsContext::new(), 0.0, 0.0, &img, 2, 2);
assert_eq!(pixel(&r, 0, 9), [0, 0, 0, 0]);
}
#[test]
fn capabilities_match_dpi() {
let r = SkiaRenderer::new(10, 20, 144.0);
assert!(r.flipy());
assert_eq!(r.canvas_size(), (10.0, 20.0));
assert_eq!(r.points_to_pixels(3.0), 6.0);
}
}