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 covered_interior(&self, bbox: &Bbox) -> [i64; 4] {
let height = f64::from(self.pixmap.height());
[
bbox.xmin().ceil() as i64,
(height - bbox.ymax()).ceil() as i64,
bbox.xmax().floor() as i64,
(height - bbox.ymin()).floor() as i64,
]
}
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 paint_path(
target: &mut Pixmap,
sk_path: &tiny_skia::Path,
gc: &GraphicsContext,
fill: Option<Rgba>,
dpi: f64,
transform: Transform,
mask: Option<&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));
target.fill_path(sk_path, &paint, FillRule::Winding, transform, mask);
}
if let Some(stroke_color) = gc.stroke {
let width = gc.line_width * dpi / 72.0;
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 = dpi / 72.0;
let array: Vec<f32> = pattern.iter().map(|d| (d * scale) as f32).collect();
StrokeDash::new(array, (offset * scale) as f32)
});
let mut stroke = Stroke {
width: width as f32,
line_cap: to_line_cap(gc.cap),
line_join: to_line_join(gc.join),
dash,
..Stroke::default()
};
if let Some(limit) = gc.miter_limit {
stroke.miter_limit = limit as f32;
}
target.stroke_path(sk_path, &paint, &stroke, transform, mask);
}
}
}
fn is_rectilinear(path: &Path) -> bool {
if path.vertices().len() > 1024 {
return false;
}
let mut pen: Option<[f64; 2]> = None;
for segment in path.iter_segments() {
match segment {
PathSegment::MoveTo(p) => pen = Some(p),
PathSegment::LineTo(p) => {
let diagonal =
pen.is_some_and(|q| (p[0] - q[0]).abs() >= 1e-4 && (p[1] - q[1]).abs() >= 1e-4);
if diagonal {
return false;
}
pen = Some(p);
}
PathSegment::Quad(..) | PathSegment::Cubic(..) => return false,
PathSegment::Close => {}
}
}
true
}
const SPRITE_MIN_POINTS: usize = 10_000;
struct Clip<'a> {
mask: &'a Mask,
interior: [i64; 4],
}
struct MarkerSprite {
layers: Vec<SpriteLayer>,
cx: i64,
cy: i64,
width: i64,
height: i64,
}
struct SpriteLayer {
pixmap: Pixmap,
spans: Vec<(i64, i64)>,
}
impl SpriteLayer {
fn paint(width: u32, height: u32, paint: impl FnOnce(&mut Pixmap)) -> Option<Self> {
let mut pixmap = Pixmap::new(width, height)?;
paint(&mut pixmap);
let spans: Vec<(i64, i64)> = pixmap
.data()
.chunks_exact(width as usize * 4)
.map(|row| {
let inked = |px: &[u8]| px[3] != 0;
let first = row.chunks_exact(4).position(inked);
let last = row.chunks_exact(4).rposition(inked);
match (first, last) {
(Some(a), Some(b)) => (a as i64, b as i64 + 1),
_ => (0, 0),
}
})
.collect();
spans
.iter()
.any(|(a, b)| a < b)
.then_some(Self { pixmap, spans })
}
}
impl MarkerSprite {
fn new(
marker: &tiny_skia::Path,
gc: &GraphicsContext,
fill: Option<Rgba>,
dpi: f64,
) -> Option<Self> {
let bounds = marker.bounds();
let stroke = if gc.stroke.is_some() {
gc.line_width * dpi / 72.0
} else {
0.0
};
let margin = 2.0 * stroke + 2.0;
let left = (f64::from(bounds.left()) - margin).floor();
let top = (f64::from(bounds.top()) - margin).floor();
let right = (f64::from(bounds.right()) + margin).ceil();
let bottom = (f64::from(bounds.bottom()) + margin).ceil();
let (width, height) = ((right - left) as u32 + 2, (bottom - top) as u32 + 2);
let (cx, cy) = (-left, -top);
let shift = Transform::from_translate(cx as f32, cy as f32);
let face_only = GraphicsContext {
stroke: None,
..gc.clone()
};
let layers: Vec<SpriteLayer> = [
SpriteLayer::paint(width, height, |p| {
paint_path(p, marker, &face_only, fill, dpi, shift, None);
}),
SpriteLayer::paint(width, height, |p| {
paint_path(p, marker, gc, None, dpi, shift, None);
}),
]
.into_iter()
.flatten()
.collect();
(!layers.is_empty()).then_some(Self {
layers,
cx: cx as i64,
cy: cy as i64,
width: i64::from(width),
height: i64::from(height),
})
}
fn stamp(&self, target: &mut Pixmap, px: i64, py: i64, clip: Option<&Clip<'_>>) {
let (tw, th) = (i64::from(target.width()), i64::from(target.height()));
let (sw, sh) = (self.width, self.height);
let (ox, oy) = (px - self.cx, py - self.cy);
let mask = clip
.filter(|c| {
let [ix0, iy0, ix1, iy1] = c.interior;
!(ox >= ix0 && oy >= iy0 && ox + sw <= ix1 && oy + sh <= iy1)
})
.map(|c| c.mask.data());
let dst = target.data_mut();
for layer in &self.layers {
let src = layer.pixmap.data();
for row in oy.max(0)..(oy + sh).min(th) {
let (a, b) = layer.spans[(row - oy) as usize];
let (x0, x1) = ((ox + a).max(0), (ox + b).min(tw));
for x in x0..x1 {
let s = (((row - oy) * sw + (x - ox)) * 4) as usize;
let d = ((row * tw + x) * 4) as usize;
let pixel = &src[s..s + 4];
match mask {
Some(cov) => {
let c = u32::from(cov[(row * tw + x) as usize]);
if c != 0 {
let scaled =
[0, 1, 2, 3].map(|k| div255(u32::from(pixel[k]) * c) as u8);
source_over(&mut dst[d..d + 4], &scaled);
}
}
None => source_over(&mut dst[d..d + 4], pixel),
}
}
}
}
}
}
fn source_over(dst: &mut [u8], src: &[u8]) {
match src[3] {
0 => {}
255 => dst.copy_from_slice(src),
alpha => {
let keep = 255 - u32::from(alpha);
for k in 0..4 {
dst[k] = (u32::from(src[k]) + div255(u32::from(dst[k]) * keep)) as u8;
}
}
}
}
fn div255(x: u32) -> u32 {
(x + 255) >> 8
}
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);
paint_path(
&mut self.pixmap,
&sk_path,
gc,
fill,
self.dpi,
Transform::identity(),
mask_ref,
);
}
fn draw_markers(
&mut self,
gc: &GraphicsContext,
marker_path: &Path,
marker_transform: &Affine2D,
path: &Path,
transform: &Affine2D,
fill: Option<Rgba>,
) {
let marker =
marker_path.transformed(&marker_transform.then(&Affine2D::from_scale(1.0, -1.0)));
let snap = gc.snap.unwrap_or_else(|| is_rectilinear(&marker));
let (marker, offset) = if snap {
let edge = self.points_to_pixels(gc.line_width);
let half = if (edge + 0.5).floor() as i64 % 2 == 1 {
0.5
} else {
0.0
};
let snapped = marker
.vertices()
.iter()
.map(|&[x, y]| [(x + 0.5).floor() + half, (y + 0.5).floor() + half])
.collect();
(Path::new(snapped, marker.codes().map(<[_]>::to_vec)), 0.0)
} else {
(marker, 0.5)
};
let marker = marker.transformed(&Affine2D::from_translation(offset, offset));
let device = transform.then(&self.flip_transform());
let origins: Vec<(f64, f64)> = path
.vertices()
.iter()
.map(|&[vx, vy]| device.transform_point((vx, vy)))
.filter(|(x, y)| x.abs() < 1e9 && y.abs() < 1e9)
.map(|(x, y)| ((x + 0.5).floor(), (y + 0.5).floor()))
.collect();
if origins.len() < SPRITE_MIN_POINTS {
let height = f64::from(self.pixmap.height());
for (x, y) in origins {
let place = Affine2D::new(1.0, 0.0, 0.0, -1.0, x, height - y);
self.draw_path(gc, &marker, &place, fill);
}
return;
}
let Some(sk_marker) = Self::build_device_path(&marker, &Affine2D::identity()) else {
return;
};
let Some(sprite) = MarkerSprite::new(&sk_marker, gc, fill, self.dpi) else {
return;
};
self.ensure_clip_mask(gc);
let clip = self.clip_cache.as_ref().map(|(bbox, mask)| Clip {
mask,
interior: self.covered_interior(bbox),
});
for (x, y) in origins {
sprite.stamp(&mut self.pixmap, x as i64, y as i64, clip.as_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);
}
}