use super::*;
use crate::{
core::types::Point2f,
render::color::{scale_premultiplied_rgba, source_over_premultiplied_rgba},
};
const LEGACY_OUTLINE_WIDTH_PX: f32 = 1.0;
const MIN_RECT_EDGE_WIDTH_PX: f32 = 0.1;
impl SkiaRenderer {
pub(super) fn all_finite(values: &[f32]) -> bool {
values.iter().all(|value| value.is_finite())
}
fn finite_runs<T>(points: &[T], finite: impl Fn(&T) -> bool) -> Vec<&[T]> {
let mut runs = Vec::new();
let mut start = 0usize;
for (index, point) in points.iter().enumerate() {
if finite(point) {
continue;
}
if index > start {
runs.push(&points[start..index]);
}
start = index + 1;
}
if points.len() > start {
runs.push(&points[start..]);
}
runs
}
fn reject_non_finite(primitive: &str, dims: &[(&str, f32)]) -> Result<()> {
let Some((name, value)) = dims.iter().copied().find(|&(_, v)| !v.is_finite()) else {
return Ok(());
};
Err(PlottingError::RenderError(format!(
"{primitive} was given a non-finite {name} ({value}). Geometry reached the raster \
backend without being validated; this is an internal invariant failure in the \
renderer, not a limit of the supplied data."
)))
}
fn reject_non_finite_vertices(primitive: &str, vertices: &[(f32, f32)]) -> Result<()> {
let Some((index, &(x, y))) = vertices
.iter()
.enumerate()
.find(|&(_, &(x, y))| !x.is_finite() || !y.is_finite())
else {
return Ok(());
};
Err(PlottingError::RenderError(format!(
"{primitive} was given a non-finite vertex {index} ({x}, {y}). Geometry reached the \
raster backend without being validated; this is an internal invariant failure in \
the renderer, not a limit of the supplied data."
)))
}
pub(super) fn typst_size_pt(&self, size_px: f32) -> f32 {
size_px.max(0.1)
}
pub(super) fn draw_typst_raster(
&mut self,
rendered: &typst_text::TypstRasterOutput,
x: f32,
y: f32,
) {
let logical_w = rendered.width.max(1e-6);
let logical_h = rendered.height.max(1e-6);
let pixel_w = rendered.pixmap.width().max(1) as f32;
let pixel_h = rendered.pixmap.height().max(1) as f32;
let scale_x = (pixel_w / logical_w).max(1e-6);
let scale_y = (pixel_h / logical_h).max(1e-6);
if (scale_x - 1.0).abs() <= 0.02 && (scale_y - 1.0).abs() <= 0.02 {
self.pixmap.draw_pixmap(
x.round() as i32,
y.round() as i32,
rendered.pixmap.as_ref(),
&PixmapPaint::default(),
Transform::identity(),
None,
);
return;
}
let transform = Transform::from_scale(1.0 / scale_x, 1.0 / scale_y).post_translate(x, y);
let paint = PixmapPaint {
quality: FilterQuality::Bilinear,
..PixmapPaint::default()
};
self.pixmap
.draw_pixmap(0, 0, rendered.pixmap.as_ref(), &paint, transform, None);
}
pub fn clear(&mut self) {
let bg_color = self.theme.background.to_tiny_skia_color();
self.pixmap.fill(bg_color);
}
pub fn draw_line(
&mut self,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
color: Color,
width: f32,
style: LineStyle,
) -> Result<()> {
self.draw_line_with_mask(x1, y1, x2, y2, color, width, style, None)
}
pub fn draw_line_clipped(
&mut self,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
color: Color,
width: f32,
style: LineStyle,
clip_rect: (f32, f32, f32, f32),
) -> Result<()> {
let mask = self.get_clip_mask(clip_rect)?;
self.draw_line_with_mask(x1, y1, x2, y2, color, width, style, Some(mask.as_ref()))
}
fn draw_line_with_mask(
&mut self,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
color: Color,
width: f32,
style: LineStyle,
mask: Option<&Mask>,
) -> Result<()> {
if !Self::all_finite(&[x1, y1, x2, y2, width]) {
return Ok(());
}
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
paint.set_color_rgba8(color.r, color.g, color.b, color.a);
let mut stroke = Stroke {
width: width.max(0.1),
..Stroke::default()
};
if let Some(dash_pattern) = self.scaled_dash_pattern(&style) {
stroke.dash = StrokeDash::new(dash_pattern, 0.0);
}
let mut path = PathBuilder::new();
path.move_to(x1, y1);
path.line_to(x2, y2);
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create line path".to_string(),
))?;
self.stroke_path_masked(&path, &paint, &stroke, Transform::identity(), mask)?;
Ok(())
}
pub fn draw_polyline(
&mut self,
points: &[(f32, f32)],
color: Color,
width: f32,
style: LineStyle,
) -> Result<()> {
self.draw_polyline_with_mask(points, color, width, style, None)
}
fn draw_polyline_with_mask(
&mut self,
points: &[(f32, f32)],
color: Color,
width: f32,
style: LineStyle,
mask: Option<&Mask>,
) -> Result<()> {
if !width.is_finite() {
return Ok(());
}
if Self::all_finite_points(points) {
return self.stroke_polyline_run(points, color, width, &style, mask);
}
for run in Self::finite_runs(points, |&(x, y)| x.is_finite() && y.is_finite()) {
self.stroke_polyline_run(run, color, width, &style, mask)?;
}
Ok(())
}
fn all_finite_points(points: &[(f32, f32)]) -> bool {
points.iter().all(|&(x, y)| x.is_finite() && y.is_finite())
}
fn stroke_polyline_run(
&mut self,
points: &[(f32, f32)],
color: Color,
width: f32,
style: &LineStyle,
mask: Option<&Mask>,
) -> Result<()> {
if points.len() < 2 {
return Ok(());
}
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let mut stroke = Stroke {
width: width.max(0.1),
line_cap: LineCap::Round,
line_join: LineJoin::Round,
..Stroke::default()
};
if let Some(dash_pattern) = self.scaled_dash_pattern(style) {
stroke.dash = StrokeDash::new(dash_pattern, 0.0);
}
let mut path = PathBuilder::new();
path.move_to(points[0].0, points[0].1);
for &(x, y) in &points[1..] {
path.line_to(x, y);
}
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create polyline path".to_string(),
))?;
self.stroke_path_masked(&path, &paint, &stroke, Transform::identity(), mask)?;
Ok(())
}
pub fn draw_polyline_clipped(
&mut self,
points: &[(f32, f32)],
color: Color,
width: f32,
style: LineStyle,
clip_rect: (f32, f32, f32, f32), ) -> Result<()> {
let mask = self.get_clip_mask(clip_rect)?;
self.draw_polyline_with_mask(points, color, width, style, Some(mask.as_ref()))
}
pub fn draw_polyline_points_clipped(
&mut self,
points: &[Point2f],
color: Color,
width: f32,
style: LineStyle,
clip_rect: (f32, f32, f32, f32), ) -> Result<()> {
if points.len() < 2 || !width.is_finite() {
return Ok(());
}
let mask = self.get_clip_mask(clip_rect)?;
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let mut stroke = Stroke {
width,
..Stroke::default()
};
if let Some(dash_pattern) = self.scaled_dash_pattern(&style) {
stroke.dash = StrokeDash::new(dash_pattern, 0.0);
}
for run in Self::finite_runs(points, |point| point.x.is_finite() && point.y.is_finite()) {
if run.len() < 2 {
continue;
}
let mut path = PathBuilder::new();
path.move_to(run[0].x, run[0].y);
for point in &run[1..] {
path.line_to(point.x, point.y);
}
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create polyline path".to_string(),
))?;
self.stroke_path_masked(
&path,
&paint,
&stroke,
Transform::identity(),
Some(mask.as_ref()),
)?;
}
Ok(())
}
fn get_clip_mask(&mut self, clip_rect: (f32, f32, f32, f32)) -> Result<Arc<Mask>> {
let key = ClipMaskKey::new(clip_rect);
if let Some(mask) = self.clip_mask_cache.get(&key) {
return Ok(Arc::clone(mask));
}
let mask = Arc::new(self.create_clip_mask(clip_rect)?);
self.clip_mask_cache.insert(key, Arc::clone(&mask));
Ok(mask)
}
fn create_clip_mask(&self, clip_rect: (f32, f32, f32, f32)) -> Result<Mask> {
let mut mask = Mask::new(self.width, self.height).ok_or(PlottingError::RenderError(
"Failed to create clip mask".to_string(),
))?;
let clip_path = {
let mut pb = PathBuilder::new();
let (x, y, w, h) = clip_rect;
pb.move_to(x, y);
pb.line_to(x + w, y);
pb.line_to(x + w, y + h);
pb.line_to(x, y + h);
pb.close();
pb.finish().ok_or(PlottingError::RenderError(
"Failed to create clip path".to_string(),
))?
};
mask.fill_path(&clip_path, FillRule::Winding, true, Transform::identity());
Ok(mask)
}
pub(super) fn fill_path_masked(
&mut self,
path: &tiny_skia::Path,
paint: &Paint,
fill_rule: FillRule,
transform: Transform,
mask: Option<&Mask>,
) -> Result<()> {
self.pixmap
.fill_path(path, paint, fill_rule, transform, mask);
Ok(())
}
pub(super) fn stroke_path_masked(
&mut self,
path: &tiny_skia::Path,
paint: &Paint,
stroke: &Stroke,
transform: Transform,
mask: Option<&Mask>,
) -> Result<()> {
self.pixmap
.stroke_path(path, paint, stroke, transform, mask);
Ok(())
}
pub fn draw_circle(
&mut self,
x: f32,
y: f32,
radius: f32,
color: Color,
filled: bool,
) -> Result<()> {
self.draw_circle_with_mask(x, y, radius, color, filled, None)
}
fn draw_circle_with_mask(
&mut self,
x: f32,
y: f32,
radius: f32,
color: Color,
filled: bool,
mask: Option<&Mask>,
) -> Result<()> {
Self::reject_non_finite("circle", &[("cx", x), ("cy", y), ("radius", radius)])?;
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let path = self
.marker_path(
if filled {
MarkerStyle::Circle
} else {
MarkerStyle::CircleOpen
},
radius * 2.0,
)?
.ok_or(PlottingError::RenderError(
"Failed to create circle path".to_string(),
))?;
let transform = Transform::from_translate(x, y);
self.note_marker_path_cache();
if filled {
self.fill_path_masked(path.as_ref(), &paint, FillRule::Winding, transform, mask)?;
} else {
let stroke = Stroke::default();
self.stroke_path_masked(path.as_ref(), &paint, &stroke, transform, mask)?;
}
Ok(())
}
pub fn draw_rectangle(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
color: Color,
filled: bool,
) -> Result<()> {
self.draw_rectangle_with_mask(x, y, width, height, color, filled, None)
}
pub fn draw_rectangle_clipped(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
color: Color,
filled: bool,
clip_rect: (f32, f32, f32, f32),
) -> Result<()> {
let mask = self.get_clip_mask(clip_rect)?;
self.draw_rectangle_with_mask(x, y, width, height, color, filled, Some(mask.as_ref()))
}
pub fn draw_rectangle_styled(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
fill: Option<Color>,
edge: Option<(Color, f32)>,
) -> Result<()> {
let edge = self.scaled_edge(edge);
self.draw_rectangle_styled_with_mask(x, y, width, height, fill, edge, None)
}
pub fn draw_rectangle_styled_clipped(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
fill: Option<Color>,
edge: Option<(Color, f32)>,
clip_rect: (f32, f32, f32, f32),
) -> Result<()> {
let edge = self.scaled_edge(edge);
let mask = self.get_clip_mask(clip_rect)?;
self.draw_rectangle_styled_with_mask(x, y, width, height, fill, edge, Some(mask.as_ref()))
}
fn scaled_edge(&self, edge: Option<(Color, f32)>) -> Option<(Color, f32)> {
edge.filter(|&(_, width_pt)| width_pt > 0.0)
.map(|(color, width_pt)| (color, self.points_to_pixels(width_pt)))
}
fn draw_rectangle_with_mask(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
color: Color,
filled: bool,
mask: Option<&Mask>,
) -> Result<()> {
let (fill, edge) = if filled {
(Some(color), None)
} else {
(None, Some((color, LEGACY_OUTLINE_WIDTH_PX)))
};
self.draw_rectangle_styled_with_mask(x, y, width, height, fill, edge, mask)
}
fn draw_rectangle_styled_with_mask(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
fill: Option<Color>,
edge: Option<(Color, f32)>,
mask: Option<&Mask>,
) -> Result<()> {
if fill.is_none() && edge.is_none() {
return Ok(());
}
Self::reject_non_finite(
"rectangle",
&[
("x", x),
("y", y),
("width", width),
("height", height),
("edge width", edge.map_or(0.0, |(_, w)| w)),
],
)?;
if width <= 0.0 || height <= 0.0 {
return Ok(());
}
let rect = Rect::from_xywh(x, y, width, height).ok_or(PlottingError::RenderError(
"Invalid rectangle dimensions".to_string(),
))?;
let mut path = PathBuilder::new();
path.push_rect(rect);
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create rectangle path".to_string(),
))?;
if let Some(fill_color) = fill {
let mut fill_paint = Paint::default();
fill_paint.set_color(fill_color.to_tiny_skia_color());
fill_paint.anti_alias = true;
self.fill_path_masked(
&path,
&fill_paint,
FillRule::Winding,
Transform::identity(),
mask,
)?;
}
if let Some((edge_color, edge_width_px)) = edge {
let mut edge_paint = Paint::default();
edge_paint.set_color(edge_color.to_tiny_skia_color());
edge_paint.anti_alias = true;
let stroke = Stroke {
width: edge_width_px.max(MIN_RECT_EDGE_WIDTH_PX),
..Stroke::default()
};
self.stroke_path_masked(&path, &edge_paint, &stroke, Transform::identity(), mask)?;
}
Ok(())
}
pub fn draw_solid_rectangle(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
color: Color,
) -> Result<()> {
Self::reject_non_finite(
"solid rectangle",
&[("x", x), ("y", y), ("width", width), ("height", height)],
)?;
let rect = Rect::from_xywh(x, y, width, height).ok_or(PlottingError::RenderError(
"Invalid rectangle dimensions".to_string(),
))?;
let mut path = PathBuilder::new();
path.push_rect(rect);
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create rectangle path".to_string(),
))?;
let mut fill_paint = Paint::default();
fill_paint.set_color(color.to_tiny_skia_color());
fill_paint.anti_alias = false;
self.pixmap.fill_path(
&path,
&fill_paint,
FillRule::Winding,
Transform::identity(),
None,
);
Ok(())
}
fn rect_bounds(x: f32, y: f32, width: f32, height: f32) -> Option<(f32, f32, f32, f32)> {
let left = x.min(x + width);
let right = x.max(x + width);
let top = y.min(y + height);
let bottom = y.max(y + height);
if !left.is_finite() || !right.is_finite() || !top.is_finite() || !bottom.is_finite() {
return None;
}
if right <= left || bottom <= top {
None
} else {
Some((left, top, right - left, bottom - top))
}
}
fn pixel_aligned_rect_bounds(
x: f32,
y: f32,
width: f32,
height: f32,
) -> Option<(f32, f32, f32, f32)> {
let (left, top, width, height) = Self::rect_bounds(x, y, width, height)?;
let right = (left + width).round();
let bottom = (top + height).round();
let left = left.round();
let top = top.round();
if right <= left || bottom <= top {
None
} else {
Some((left, top, right - left, bottom - top))
}
}
fn draw_composited_solid_rectangle(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
color: Color,
) -> Result<()> {
Self::reject_non_finite(
"solid rectangle",
&[("x", x), ("y", y), ("width", width), ("height", height)],
)?;
let Some((x, y, width, height)) = Self::rect_bounds(x, y, width, height) else {
return Ok(());
};
let rect = Rect::from_xywh(x, y, width, height).ok_or(PlottingError::RenderError(
"Invalid rectangle dimensions".to_string(),
))?;
let mut path = PathBuilder::new();
path.push_rect(rect);
let path = path.finish().ok_or(PlottingError::RenderError(
"Failed to create rectangle path".to_string(),
))?;
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
self.fill_path_masked(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
)?;
Ok(())
}
pub fn draw_pixel_aligned_solid_rectangle(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
color: Color,
) -> Result<()> {
Self::reject_non_finite(
"pixel-aligned rectangle",
&[("x", x), ("y", y), ("width", width), ("height", height)],
)?;
if color.a != u8::MAX {
return self.draw_composited_solid_rectangle(x, y, width, height, color);
}
if let Some((x, y, width, height)) = Self::pixel_aligned_rect_bounds(x, y, width, height) {
let left = x.max(0.0).floor() as u32;
let top = y.max(0.0).floor() as u32;
let right = (x + width).min(self.width as f32).ceil() as u32;
let bottom = (y + height).min(self.height as f32).ceil() as u32;
if left < right && top < bottom {
let fill = color.to_tiny_skia_color().premultiply().to_color_u8();
self.note_pixel_aligned_rect_fill();
let pixels = self.pixmap.pixels_mut();
for py in top..bottom {
let row_start = (py * self.width) as usize;
for px in left..right {
pixels[row_start + px as usize] = fill;
}
}
return Ok(());
}
}
self.draw_composited_solid_rectangle(x, y, width, height, color)
}
pub fn draw_pixel_aligned_rectangle_outline(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
color: Color,
) -> Result<()> {
Self::reject_non_finite(
"pixel-aligned rectangle outline",
&[("x", x), ("y", y), ("width", width), ("height", height)],
)?;
if let Some((x, y, width, height)) = Self::pixel_aligned_rect_bounds(x, y, width, height) {
self.draw_rectangle(x, y, width, height, color, false)?;
}
Ok(())
}
pub fn draw_rounded_rectangle(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
corner_radius: f32,
color: Color,
filled: bool,
) -> Result<()> {
Self::reject_non_finite(
"rounded rectangle",
&[
("x", x),
("y", y),
("width", width),
("height", height),
("corner radius", corner_radius),
],
)?;
let max_radius = (width.min(height) / 2.0).max(0.0);
let radius = corner_radius.min(max_radius);
if radius < 0.1 {
return self.draw_rectangle(x, y, width, height, color, filled);
}
let mut pb = PathBuilder::new();
pb.move_to(x + radius, y);
pb.line_to(x + width - radius, y);
pb.quad_to(x + width, y, x + width, y + radius);
pb.line_to(x + width, y + height - radius);
pb.quad_to(x + width, y + height, x + width - radius, y + height);
pb.line_to(x + radius, y + height);
pb.quad_to(x, y + height, x, y + height - radius);
pb.line_to(x, y + radius);
pb.quad_to(x, y, x + radius, y);
pb.close();
let path = pb.finish().ok_or(PlottingError::RenderError(
"Failed to create rounded rectangle path".to_string(),
))?;
if filled {
let mut fill_paint = Paint::default();
let (r, g, b, a) = color.to_rgba_f32();
let fill_color = tiny_skia::Color::from_rgba(r, g, b, a).ok_or(
PlottingError::RenderError("Invalid color for rounded rectangle fill".to_string()),
)?;
fill_paint.set_color(fill_color);
fill_paint.anti_alias = true;
self.pixmap.fill_path(
&path,
&fill_paint,
FillRule::Winding,
Transform::identity(),
None,
);
} else {
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let stroke = Stroke::default();
self.pixmap
.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
Ok(())
}
pub fn draw_filled_polygon(&mut self, vertices: &[(f32, f32)], color: Color) -> Result<()> {
if vertices.len() < 3 {
return Ok(()); }
Self::reject_non_finite_vertices("filled polygon", vertices)?;
let mut pb = PathBuilder::new();
pb.move_to(vertices[0].0, vertices[0].1);
for &(x, y) in &vertices[1..] {
pb.line_to(x, y);
}
pb.close();
let path = pb.finish().ok_or(PlottingError::RenderError(
"Failed to create polygon path".to_string(),
))?;
let mut paint = Paint::default();
let (r, g, b, a) = color.to_rgba_f32();
let fill_color = tiny_skia::Color::from_rgba(r, g, b, a).ok_or(
PlottingError::RenderError("Invalid polygon color".to_string()),
)?;
paint.set_color(fill_color);
paint.anti_alias = true;
self.pixmap.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
Ok(())
}
pub fn draw_filled_polygon_clipped(
&mut self,
vertices: &[(f32, f32)],
color: Color,
clip_rect: (f32, f32, f32, f32), ) -> Result<()> {
if vertices.len() < 3 {
return Ok(()); }
Self::reject_non_finite_vertices("filled polygon", vertices)?;
Self::reject_non_finite(
"polygon clip rectangle",
&[
("x", clip_rect.0),
("y", clip_rect.1),
("width", clip_rect.2),
("height", clip_rect.3),
],
)?;
let mut mask = Mask::new(self.width, self.height).ok_or(PlottingError::RenderError(
"Failed to create clip mask".to_string(),
))?;
let clip_path = {
let mut pb = PathBuilder::new();
let (x, y, w, h) = clip_rect;
pb.move_to(x, y);
pb.line_to(x + w, y);
pb.line_to(x + w, y + h);
pb.line_to(x, y + h);
pb.close();
pb.finish().ok_or(PlottingError::RenderError(
"Failed to create clip path".to_string(),
))?
};
mask.fill_path(&clip_path, FillRule::Winding, true, Transform::identity());
let mut pb = PathBuilder::new();
pb.move_to(vertices[0].0, vertices[0].1);
for &(x, y) in &vertices[1..] {
pb.line_to(x, y);
}
pb.close();
let path = pb.finish().ok_or(PlottingError::RenderError(
"Failed to create polygon path".to_string(),
))?;
let mut paint = Paint::default();
let (r, g, b, a) = color.to_rgba_f32();
let fill_color = tiny_skia::Color::from_rgba(r, g, b, a).ok_or(
PlottingError::RenderError("Invalid polygon color".to_string()),
)?;
paint.set_color(fill_color);
paint.anti_alias = true;
self.fill_path_masked(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
Some(&mask),
)?;
Ok(())
}
pub fn draw_polygon_outline(
&mut self,
vertices: &[(f32, f32)],
color: Color,
width: f32,
) -> Result<()> {
if vertices.len() < 3 {
return Ok(()); }
Self::reject_non_finite("polygon outline", &[("stroke width", width)])?;
Self::reject_non_finite_vertices("polygon outline", vertices)?;
let mut pb = PathBuilder::new();
pb.move_to(vertices[0].0, vertices[0].1);
for &(x, y) in &vertices[1..] {
pb.line_to(x, y);
}
pb.close();
let path = pb.finish().ok_or(PlottingError::RenderError(
"Failed to create polygon outline path".to_string(),
))?;
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let stroke = Stroke {
width,
..Stroke::default()
};
self.pixmap
.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
Ok(())
}
pub fn draw_marker(
&mut self,
x: f32,
y: f32,
size: f32,
style: MarkerStyle,
color: Color,
) -> Result<()> {
self.draw_marker_with_mask(x, y, size, style, color, None)
}
pub fn draw_marker_styled(
&mut self,
x: f32,
y: f32,
size: f32,
style: MarkerStyle,
color: Color,
edge: Option<(Color, f32)>,
) -> Result<()> {
let edge = self.scaled_edge(edge);
self.draw_marker_styled_with_mask_vector(x, y, size, style, color, edge, None)
}
pub fn draw_marker_clipped(
&mut self,
x: f32,
y: f32,
size: f32,
style: MarkerStyle,
color: Color,
clip_rect: (f32, f32, f32, f32),
) -> Result<()> {
let mask = self.get_clip_mask(clip_rect)?;
self.draw_marker_with_mask(x, y, size, style, color, Some(mask.as_ref()))
}
pub fn draw_marker_styled_clipped(
&mut self,
x: f32,
y: f32,
size: f32,
style: MarkerStyle,
color: Color,
edge: Option<(Color, f32)>,
clip_rect: (f32, f32, f32, f32),
) -> Result<()> {
let edge = self.scaled_edge(edge);
let mask = self.get_clip_mask(clip_rect)?;
self.draw_marker_styled_with_mask_vector(
x,
y,
size,
style,
color,
edge,
Some(mask.as_ref()),
)
}
pub fn draw_markers_clipped(
&mut self,
points: &[Point2f],
size: f32,
style: MarkerStyle,
color: Color,
clip_rect: (f32, f32, f32, f32),
) -> Result<()> {
self.draw_markers_styled_clipped(points, size, style, color, None, clip_rect)
}
pub fn draw_markers_styled_clipped(
&mut self,
points: &[Point2f],
size: f32,
style: MarkerStyle,
color: Color,
edge: Option<(Color, f32)>,
clip_rect: (f32, f32, f32, f32),
) -> Result<()> {
let edge = self.scaled_edge(edge);
if points.is_empty() || !size.is_finite() || size <= 0.0 || (color.a == 0 && edge.is_none())
{
return Ok(());
}
if Self::should_use_marker_sprite_compositor(points.len(), size, style) {
return self
.draw_markers_with_sprite_compositor(points, size, style, color, edge, clip_rect);
}
self.note_marker_sprite_fallback();
let mask = self.get_clip_mask(clip_rect)?;
for point in points {
self.draw_marker_styled_with_mask_vector(
point.x,
point.y,
size,
style,
color,
edge,
Some(mask.as_ref()),
)?;
}
Ok(())
}
fn draw_marker_with_mask(
&mut self,
x: f32,
y: f32,
size: f32,
style: MarkerStyle,
color: Color,
mask: Option<&Mask>,
) -> Result<()> {
self.draw_marker_with_mask_vector(x, y, size, style, color, mask)
}
pub(crate) fn draw_marker_with_mask_vector(
&mut self,
x: f32,
y: f32,
size: f32,
style: MarkerStyle,
color: Color,
mask: Option<&Mask>,
) -> Result<()> {
self.draw_marker_styled_with_mask_vector(x, y, size, style, color, None, mask)
}
pub(crate) fn draw_marker_styled_with_mask_vector(
&mut self,
x: f32,
y: f32,
size: f32,
style: MarkerStyle,
color: Color,
edge: Option<(Color, f32)>,
mask: Option<&Mask>,
) -> Result<()> {
if !Self::all_finite(&[x, y, size]) {
return Ok(());
}
let radius = size * 0.5;
let edge = edge.filter(|_| style.takes_edge());
match style {
MarkerStyle::Circle | MarkerStyle::CircleOpen => {
self.draw_circle_with_mask(x, y, radius, color, style.is_filled(), mask)?;
}
MarkerStyle::Square | MarkerStyle::SquareOpen => {
let half_size = radius;
if style.is_filled() {
self.draw_rectangle_styled_with_mask(
x - half_size,
y - half_size,
size,
size,
Some(color),
edge,
mask,
)?;
return Ok(());
}
self.draw_rectangle_with_mask(
x - half_size,
y - half_size,
size,
size,
color,
false,
mask,
)?;
}
MarkerStyle::Triangle | MarkerStyle::TriangleOpen | MarkerStyle::TriangleDown => {
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let path = self
.marker_path(style, size)?
.ok_or(PlottingError::RenderError(
"Failed to create triangle path".to_string(),
))?;
let transform = Transform::from_translate(x, y);
self.note_marker_path_cache();
if style.is_filled() {
self.fill_path_masked(
path.as_ref(),
&paint,
FillRule::Winding,
transform,
mask,
)?;
} else {
let stroke = Stroke {
width: (size * 0.15).max(1.0),
..Stroke::default()
};
self.stroke_path_masked(path.as_ref(), &paint, &stroke, transform, mask)?;
}
}
MarkerStyle::Diamond | MarkerStyle::DiamondOpen => {
let mut paint = Paint::default();
paint.set_color(color.to_tiny_skia_color());
paint.anti_alias = true;
let path = self
.marker_path(style, size)?
.ok_or(PlottingError::RenderError(
"Failed to create diamond path".to_string(),
))?;
let transform = Transform::from_translate(x, y);
self.note_marker_path_cache();
if style.is_filled() {
self.fill_path_masked(
path.as_ref(),
&paint,
FillRule::Winding,
transform,
mask,
)?;
} else {
let stroke = Stroke {
width: (size * 0.15).max(1.0),
..Stroke::default()
};
self.stroke_path_masked(path.as_ref(), &paint, &stroke, transform, mask)?;
}
}
MarkerStyle::Plus => {
let marker_line_width = (size * 0.25).max(1.0);
self.draw_line_with_mask(
x - radius,
y,
x + radius,
y,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
self.draw_line_with_mask(
x,
y - radius,
x,
y + radius,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
}
MarkerStyle::Cross => {
let marker_line_width = (size * 0.25).max(1.0);
let offset = radius * 0.707; self.draw_line_with_mask(
x - offset,
y - offset,
x + offset,
y + offset,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
self.draw_line_with_mask(
x - offset,
y + offset,
x + offset,
y - offset,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
}
MarkerStyle::Star => {
let marker_line_width = (size * 0.22).max(1.0);
self.draw_line_with_mask(
x - radius,
y,
x + radius,
y,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
self.draw_line_with_mask(
x,
y - radius,
x,
y + radius,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
let offset = radius * 0.707;
self.draw_line_with_mask(
x - offset,
y - offset,
x + offset,
y + offset,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
self.draw_line_with_mask(
x - offset,
y + offset,
x + offset,
y - offset,
color,
marker_line_width,
LineStyle::Solid,
mask,
)?;
}
}
if let Some((edge_color, edge_width_px)) = edge {
self.stroke_marker_outline(x, y, size, style, edge_color, edge_width_px, mask)?;
}
Ok(())
}
fn stroke_marker_outline(
&mut self,
x: f32,
y: f32,
size: f32,
style: MarkerStyle,
edge_color: Color,
edge_width_px: f32,
mask: Option<&Mask>,
) -> Result<()> {
let Some(path) = self.marker_path(style, size)? else {
return Ok(());
};
self.note_marker_path_cache();
let mut paint = Paint::default();
paint.set_color(edge_color.to_tiny_skia_color());
paint.anti_alias = true;
let stroke = Stroke {
width: edge_width_px.max(MIN_RECT_EDGE_WIDTH_PX),
..Stroke::default()
};
self.stroke_path_masked(
path.as_ref(),
&paint,
&stroke,
Transform::from_translate(x, y),
mask,
)
}
fn should_use_marker_sprite_compositor(
point_count: usize,
size: f32,
style: MarkerStyle,
) -> bool {
point_count >= 32
&& size >= 1.0
&& !matches!(
style,
MarkerStyle::Plus
| MarkerStyle::Cross
| MarkerStyle::Star
| MarkerStyle::SquareOpen
| MarkerStyle::TriangleOpen
| MarkerStyle::DiamondOpen
)
}
fn draw_markers_with_sprite_compositor(
&mut self,
points: &[Point2f],
size: f32,
style: MarkerStyle,
color: Color,
edge: Option<(Color, f32)>,
clip_rect: (f32, f32, f32, f32),
) -> Result<()> {
let phase_count = Self::marker_subpixel_phases() as usize;
let mut sprites = vec![None; phase_count * phase_count];
let mask = self.get_clip_mask(clip_rect)?;
let clip_left = clip_rect.0.floor() as i32 - 1;
let clip_top = clip_rect.1.floor() as i32 - 1;
let clip_right = (clip_rect.0 + clip_rect.2).ceil() as i32 + 1;
let clip_bottom = (clip_rect.1 + clip_rect.3).ceil() as i32 + 1;
self.note_marker_sprite_compositor();
for point in points {
if !point.x.is_finite() || !point.y.is_finite() {
continue;
}
let (base_x, phase_x) = Self::quantize_marker_subpixel(point.x);
let (base_y, phase_y) = Self::quantize_marker_subpixel(point.y);
let slot = phase_y as usize * phase_count + phase_x as usize;
let sprite = if let Some(sprite) = &sprites[slot] {
Arc::clone(sprite)
} else {
let sprite = self.marker_sprite(style, size, color, edge, phase_x, phase_y)?;
sprites[slot] = Some(Arc::clone(&sprite));
sprite
};
let dst_x = base_x - sprite.origin_x;
let dst_y = base_y - sprite.origin_y;
if dst_x + sprite.width as i32 <= clip_left
|| dst_x >= clip_right
|| dst_y + sprite.height as i32 <= clip_top
|| dst_y >= clip_bottom
{
continue;
}
if self.can_use_unmasked_marker_scanline_blit(
&sprite,
dst_x,
dst_y,
clip_rect,
0,
0,
self.width as i32,
self.height as i32,
) {
self.note_marker_scanline_blit();
self.blit_marker_sprite_scanlines_unmasked(&sprite, dst_x, dst_y);
} else {
self.blit_marker_sprite_region(
&sprite,
dst_x,
dst_y,
Some(mask.as_ref()),
0,
0,
self.width as i32,
self.height as i32,
);
}
}
Ok(())
}
fn quantize_marker_subpixel(value: f32) -> (i32, u8) {
let phase_count = Self::marker_subpixel_phases() as i32;
let mut base = value.floor() as i32;
let fraction = value - base as f32;
let mut phase = (fraction * phase_count as f32).round() as i32;
if phase >= phase_count {
phase = 0;
base += 1;
}
(base, phase as u8)
}
fn blit_marker_sprite_region(
&mut self,
sprite: &MarkerSprite,
dst_x: i32,
dst_y: i32,
mask: Option<&Mask>,
region_left: i32,
region_top: i32,
region_right: i32,
region_bottom: i32,
) {
let src_width = sprite.width as i32;
let src_height = sprite.height as i32;
let copy_left = dst_x.max(region_left).max(0);
let copy_top = dst_y.max(region_top).max(0);
let copy_right = (dst_x + src_width).min(region_right).min(self.width as i32);
let copy_bottom = (dst_y + src_height)
.min(region_bottom)
.min(self.height as i32);
if copy_left >= copy_right || copy_top >= copy_bottom {
return;
}
let src_offset_x = (copy_left - dst_x) as usize;
let src_offset_y = (copy_top - dst_y) as usize;
let copy_width = (copy_right - copy_left) as usize;
let copy_height = (copy_bottom - copy_top) as usize;
let sprite_stride = sprite.width as usize * 4;
let canvas_stride = self.width as usize * 4;
let mask_stride = self.width as usize;
let mask_data = mask.map(Mask::data);
let dst_data = self.pixmap.data_mut();
for row in 0..copy_height {
let src_row = (src_offset_y + row) * sprite_stride + src_offset_x * 4;
let dst_row = (copy_top as usize + row) * canvas_stride + copy_left as usize * 4;
let mask_row = (copy_top as usize + row) * mask_stride + copy_left as usize;
for col in 0..copy_width {
let src_idx = src_row + col * 4;
let src_a = sprite.pixels[src_idx + 3];
if src_a == 0 {
continue;
}
let dst_idx = dst_row + col * 4;
if let Some(mask_data) = mask_data {
let mask_alpha = mask_data[mask_row + col];
if mask_alpha == 0 {
continue;
}
Self::blend_premultiplied_rgba(
&mut dst_data[dst_idx..dst_idx + 4],
&sprite.pixels[src_idx..src_idx + 4],
mask_alpha,
);
} else {
Self::blend_premultiplied_rgba_unmasked(
&mut dst_data[dst_idx..dst_idx + 4],
&sprite.pixels[src_idx..src_idx + 4],
);
}
}
}
}
fn can_use_unmasked_marker_scanline_blit(
&self,
sprite: &MarkerSprite,
dst_x: i32,
dst_y: i32,
clip_rect: (f32, f32, f32, f32),
region_left: i32,
region_top: i32,
region_right: i32,
region_bottom: i32,
) -> bool {
if sprite.scanlines.is_none() {
return false;
}
let clip_left = clip_rect.0.ceil() as i32;
let clip_top = clip_rect.1.ceil() as i32;
let clip_right = (clip_rect.0 + clip_rect.2).floor() as i32;
let clip_bottom = (clip_rect.1 + clip_rect.3).floor() as i32;
dst_x >= clip_left
&& dst_y >= clip_top
&& dst_x + sprite.width as i32 <= clip_right
&& dst_y + sprite.height as i32 <= clip_bottom
&& dst_x >= region_left
&& dst_y >= region_top
&& dst_x + sprite.width as i32 <= region_right
&& dst_y + sprite.height as i32 <= region_bottom
}
fn blit_marker_sprite_scanlines_unmasked(
&mut self,
sprite: &MarkerSprite,
dst_x: i32,
dst_y: i32,
) {
let Some(scanlines) = sprite.scanlines.as_ref() else {
return;
};
let sprite_stride = sprite.width as usize * 4;
let canvas_stride = self.width as usize * 4;
let dst_data = self.pixmap.data_mut();
for (row_index, scanline) in scanlines.iter().enumerate() {
if scanline.end_x <= scanline.start_x {
continue;
}
let row_y = dst_y as usize + row_index;
let src_row = row_index * sprite_stride;
let dst_row = row_y * canvas_stride;
let start = scanline.start_x as usize;
let end = scanline.end_x as usize;
let opaque_start = scanline.opaque_start_x as usize;
let opaque_end = scanline.opaque_end_x as usize;
let left_partial_end = opaque_start.max(start).min(end);
for col in start..left_partial_end {
let src_idx = src_row + col * 4;
let dst_idx = dst_row + (dst_x as usize + col) * 4;
Self::blend_premultiplied_rgba_unmasked(
&mut dst_data[dst_idx..dst_idx + 4],
&sprite.pixels[src_idx..src_idx + 4],
);
}
if opaque_end > opaque_start {
let src_start = src_row + opaque_start * 4;
let src_end = src_row + opaque_end * 4;
let dst_start = dst_row + (dst_x as usize + opaque_start) * 4;
let dst_end = dst_row + (dst_x as usize + opaque_end) * 4;
dst_data[dst_start..dst_end].copy_from_slice(&sprite.pixels[src_start..src_end]);
}
let right_partial_start = opaque_end.max(start).min(end);
for col in right_partial_start..end {
let src_idx = src_row + col * 4;
let dst_idx = dst_row + (dst_x as usize + col) * 4;
Self::blend_premultiplied_rgba_unmasked(
&mut dst_data[dst_idx..dst_idx + 4],
&sprite.pixels[src_idx..src_idx + 4],
);
}
}
}
fn blend_premultiplied_rgba(dst: &mut [u8], src: &[u8], mask_alpha: u8) {
let src = scale_premultiplied_rgba([src[0], src[1], src[2], src[3]], mask_alpha);
Self::blend_premultiplied_rgba_unmasked(dst, &src);
}
fn blend_premultiplied_rgba_unmasked(dst: &mut [u8], src: &[u8]) {
let blended = source_over_premultiplied_rgba(
[dst[0], dst[1], dst[2], dst[3]],
[src[0], src[1], src[2], src[3]],
);
dst.copy_from_slice(&blended);
}
pub fn draw_grid(
&mut self,
x_ticks: &[f32],
y_ticks: &[f32],
plot_area: Rect,
color: Color,
style: LineStyle,
line_width: f32,
) -> Result<()> {
for &x in x_ticks {
if x >= plot_area.left() && x <= plot_area.right() {
self.draw_line(
x,
plot_area.top(),
x,
plot_area.bottom(),
color,
line_width,
style.clone(),
)?;
}
}
for &y in y_ticks {
if y >= plot_area.top() && y <= plot_area.bottom() {
self.draw_line(
plot_area.left(),
y,
plot_area.right(),
y,
color,
line_width,
style.clone(),
)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn white_canvas(width: u32, height: u32, dpi: f32) -> SkiaRenderer {
let mut renderer = SkiaRenderer::new(width, height, Theme::default()).unwrap();
renderer.set_render_scale(RenderScale::new(dpi));
renderer.pixmap.fill(Color::WHITE.to_tiny_skia_color());
renderer
}
fn pixel(image: &Image, x: u32, y: u32) -> [u8; 4] {
let idx = ((y * image.width + x) * 4) as usize;
[
image.pixels[idx],
image.pixels[idx + 1],
image.pixels[idx + 2],
image.pixels[idx + 3],
]
}
fn left_edge_thickness(image: &Image, y: u32) -> u32 {
let mut thickness = 0;
let mut seen_ink = false;
for x in 0..image.width {
let is_ink = pixel(image, x, y)[0] < 250;
if is_ink {
seen_ink = true;
thickness += 1;
} else if seen_ink {
break;
}
}
thickness
}
#[test]
fn test_draw_rectangle_filled_uses_exact_requested_color() {
let mut renderer = white_canvas(64, 64, 100.0);
let requested = Color::from_rgb(31, 119, 180);
renderer
.draw_rectangle(10.0, 10.0, 40.0, 40.0, requested, true)
.expect("filled rectangle should render");
let image = renderer.into_image();
assert_eq!(
pixel(&image, 30, 30),
[requested.r, requested.g, requested.b, 255],
"a filled rectangle must render the exact requested color, with no 0.85 alpha tint"
);
}
#[test]
fn test_draw_rectangle_filled_has_no_implicit_border() {
let mut renderer = white_canvas(64, 64, 100.0);
let requested = Color::from_rgb(31, 119, 180);
renderer
.draw_rectangle(10.0, 10.0, 40.0, 40.0, requested, true)
.expect("filled rectangle should render");
let image = renderer.into_image();
assert_eq!(
pixel(&image, 11, 30),
pixel(&image, 30, 30),
"the fill must be uniform: no implicit darker edge stroke"
);
}
#[test]
fn test_draw_rectangle_filled_preserves_requested_alpha() {
let mut renderer = white_canvas(64, 64, 100.0);
renderer
.draw_rectangle(10.0, 10.0, 40.0, 40.0, Color::from_rgba(0, 0, 0, 128), true)
.expect("translucent rectangle should render");
let image = renderer.into_image();
let channel = pixel(&image, 30, 30)[0];
assert!(
(126..=128).contains(&channel),
"requested alpha must survive unchanged, got channel {channel}"
);
}
#[test]
fn test_draw_rectangle_styled_fill_only_matches_plain_fill() {
let color = Color::from_rgba(200, 40, 40, 190);
let mut plain = white_canvas(48, 48, 100.0);
plain
.draw_rectangle(8.0, 8.0, 24.0, 24.0, color, true)
.expect("plain fill should render");
let mut styled = white_canvas(48, 48, 100.0);
styled
.draw_rectangle_styled(8.0, 8.0, 24.0, 24.0, Some(color), None)
.expect("styled fill should render");
assert_eq!(
plain.into_image().pixels,
styled.into_image().pixels,
"an edgeless styled rectangle must be identical to a plain filled one"
);
}
#[test]
fn test_draw_rectangle_styled_edge_width_scales_with_dpi() {
let edge = Color::from_rgb(0, 0, 0);
let mut low_dpi = white_canvas(64, 64, 72.0);
low_dpi
.draw_rectangle_styled(16.0, 16.0, 32.0, 32.0, None, Some((edge, 1.0)))
.expect("styled edge should render at 72 dpi");
let mut high_dpi = white_canvas(64, 64, 288.0);
high_dpi
.draw_rectangle_styled(16.0, 16.0, 32.0, 32.0, None, Some((edge, 1.0)))
.expect("styled edge should render at 288 dpi");
let thin = left_edge_thickness(&low_dpi.into_image(), 32);
let thick = left_edge_thickness(&high_dpi.into_image(), 32);
assert!(
(1..=2).contains(&thin),
"1pt at 72 dpi should be a hairline, got {thin}px"
);
assert!(thick >= 4, "1pt at 288 dpi should be ~4px, got {thick}px");
assert!(
thick > thin,
"edge width must scale with DPI: {thin}px at 72 dpi vs {thick}px at 288 dpi"
);
}
fn whole_canvas(width: f32, height: f32) -> (f32, f32, f32, f32) {
(0.0, 0.0, width, height)
}
#[test]
fn test_filled_square_marker_has_no_edge_unless_one_is_asked_for() {
let fill = Color::from_rgb(31, 119, 180);
let mut renderer = white_canvas(64, 64, 100.0);
renderer
.draw_marker_styled_clipped(
32.0,
32.0,
20.0,
MarkerStyle::Square,
fill,
None,
whole_canvas(64.0, 64.0),
)
.expect("square marker should render");
let image = renderer.into_image();
assert_eq!(
pixel(&image, 24, 32),
[fill.r, fill.g, fill.b, 255],
"an edgeless square marker must be a flat fill all the way to its rim"
);
}
#[test]
fn test_filled_square_marker_strokes_a_requested_edge() {
let fill = Color::from_rgb(31, 119, 180);
let edge = Color::from_rgb(255, 0, 0);
let mut renderer = white_canvas(64, 64, 100.0);
renderer
.draw_marker_styled_clipped(
32.0,
32.0,
20.0,
MarkerStyle::Square,
fill,
Some((edge, 3.0)),
whole_canvas(64.0, 64.0),
)
.expect("edged square marker should render");
let image = renderer.into_image();
assert_eq!(
pixel(&image, 22, 32),
[edge.r, edge.g, edge.b, 255],
"the requested edge colour must be stroked on the marker boundary"
);
assert_eq!(
pixel(&image, 32, 32),
[fill.r, fill.g, fill.b, 255],
"the edge must not tint the interior fill"
);
}
#[test]
fn test_filled_circle_marker_strokes_a_requested_edge() {
let fill = Color::from_rgb(31, 119, 180);
let edge = Color::from_rgb(255, 0, 0);
let mut bare = white_canvas(64, 64, 100.0);
bare.draw_marker_styled_clipped(
32.0,
32.0,
20.0,
MarkerStyle::Circle,
fill,
None,
whole_canvas(64.0, 64.0),
)
.expect("circle marker should render");
let mut edged = white_canvas(64, 64, 100.0);
edged
.draw_marker_styled_clipped(
32.0,
32.0,
20.0,
MarkerStyle::Circle,
fill,
Some((edge, 3.0)),
whole_canvas(64.0, 64.0),
)
.expect("edged circle marker should render");
let edged = edged.into_image();
assert_ne!(
bare.into_image().pixels,
edged.pixels,
"a circle marker must honour an edge, not only the square path"
);
assert!(
edged
.pixels
.chunks_exact(4)
.any(|px| px[0] > px[2] && px[0] > 200),
"the edge colour must actually reach the canvas"
);
}
#[test]
fn test_marker_edge_is_ignored_by_line_drawn_styles() {
let color = Color::from_rgb(31, 119, 180);
let mut bare = white_canvas(48, 48, 100.0);
bare.draw_marker_styled_clipped(
24.0,
24.0,
16.0,
MarkerStyle::Plus,
color,
None,
whole_canvas(48.0, 48.0),
)
.expect("plus marker should render");
let mut edged = white_canvas(48, 48, 100.0);
edged
.draw_marker_styled_clipped(
24.0,
24.0,
16.0,
MarkerStyle::Plus,
color,
Some((Color::from_rgb(255, 0, 0), 3.0)),
whole_canvas(48.0, 48.0),
)
.expect("plus marker should render");
assert_eq!(
bare.into_image().pixels,
edged.into_image().pixels,
"a plus has no interior, so an edge request must be a no-op rather than a second stroke"
);
}
#[test]
fn test_marker_edge_width_scales_with_dpi() {
let transparent = Color::from_rgba(0, 0, 0, 0);
let edge = Color::from_rgb(0, 0, 0);
let mut low_dpi = white_canvas(64, 64, 72.0);
low_dpi
.draw_marker_styled_clipped(
32.0,
32.0,
24.0,
MarkerStyle::Square,
transparent,
Some((edge, 1.0)),
whole_canvas(64.0, 64.0),
)
.expect("marker edge should render at 72 dpi");
let mut high_dpi = white_canvas(64, 64, 288.0);
high_dpi
.draw_marker_styled_clipped(
32.0,
32.0,
24.0,
MarkerStyle::Square,
transparent,
Some((edge, 1.0)),
whole_canvas(64.0, 64.0),
)
.expect("marker edge should render at 288 dpi");
let thin = left_edge_thickness(&low_dpi.into_image(), 32);
let thick = left_edge_thickness(&high_dpi.into_image(), 32);
assert!(
(1..=2).contains(&thin),
"1pt at 72 dpi should be a hairline, got {thin}px"
);
assert!(thick >= 4, "1pt at 288 dpi should be ~4px, got {thick}px");
assert!(
thick > thin,
"marker edge width must scale with DPI: {thin}px at 72 dpi vs {thick}px at 288 dpi"
);
}
#[test]
fn test_edged_marker_batch_matches_the_single_marker_path() {
let points: Vec<Point2f> = (0..40)
.map(|i| Point2f::new(8.0 + (i % 10) as f32 * 8.0, 12.0 + (i / 10) as f32 * 16.0))
.collect();
let fill = Color::from_rgb(31, 119, 180);
let edge = Some((Color::from_rgb(255, 0, 0), 1.0));
let clip = whole_canvas(96.0, 80.0);
let mut batched = white_canvas(96, 80, 100.0);
batched
.draw_markers_styled_clipped(&points, 9.0, MarkerStyle::Circle, fill, edge, clip)
.expect("batched edged markers should render");
assert!(
batched.render_diagnostics().used_marker_sprite_compositor,
"asking for an edge must not cost the sprite fast path"
);
let mut singly = white_canvas(96, 80, 100.0);
for point in &points {
singly
.draw_marker_styled_clipped(
point.x,
point.y,
9.0,
MarkerStyle::Circle,
fill,
edge,
clip,
)
.expect("single edged marker should render");
}
let batched = batched.into_image().pixels;
let singly = singly.into_image().pixels;
assert_eq!(batched.len(), singly.len());
let worst = batched
.iter()
.zip(singly.iter())
.map(|(a, b)| a.abs_diff(*b))
.max()
.unwrap_or(0);
assert!(
worst <= 1,
"batched and single-marker rendering must agree once an edge is requested, \
worst channel delta {worst}"
);
}
fn sprite_vs_vector_worst_delta(edge: Option<(Color, f32)>) -> u8 {
let points: Vec<Point2f> = (0..40)
.map(|i| Point2f::new(10.0 + (i % 10) as f32 * 18.7, 15.0 + (i / 10) as f32 * 27.3))
.collect();
let fill = Color::from_rgb(31, 119, 180);
let clip = whole_canvas(200.0, 120.0);
let mut batched = white_canvas(200, 120, 100.0);
batched
.draw_markers_styled_clipped(&points, 8.0, MarkerStyle::Circle, fill, edge, clip)
.expect("batched markers should render");
assert!(batched.render_diagnostics().used_marker_sprite_compositor);
let mut singly = white_canvas(200, 120, 100.0);
for point in &points {
singly
.draw_marker_styled_clipped(
point.x,
point.y,
8.0,
MarkerStyle::Circle,
fill,
edge,
clip,
)
.expect("single marker should render");
}
batched
.into_image()
.pixels
.iter()
.zip(singly.into_image().pixels.iter())
.map(|(a, b)| a.abs_diff(*b))
.max()
.unwrap_or(0)
}
#[test]
fn test_a_marker_rim_does_not_amplify_sprite_phase_quantisation() {
let edgeless = sprite_vs_vector_worst_delta(None);
let edged = sprite_vs_vector_worst_delta(Some((Color::from_rgb(22, 84, 126), 0.8)));
assert!(
edged <= edgeless.max(32),
"an edged batch must not diverge from the vector path more than an \
edgeless one does: {edged} vs {edgeless}"
);
}
#[test]
fn test_marker_sprites_are_exact_on_a_subpixel_phase_boundary() {
let phase = 1.0 / SkiaRenderer::marker_subpixel_phases() as f32;
let points: Vec<Point2f> = (0..40)
.map(|i| {
Point2f::new(
10.0 + (i % 10) as f32 * 18.0 + (i % 10) as f32 * phase,
15.0 + (i / 10) as f32 * 27.0 + (i / 10) as f32 * phase,
)
})
.collect();
let fill = Color::from_rgb(31, 119, 180);
let clip = whole_canvas(200.0, 120.0);
let mut batched = white_canvas(200, 120, 100.0);
batched
.draw_markers_styled_clipped(&points, 8.0, MarkerStyle::Circle, fill, None, clip)
.expect("batched markers should render");
let mut singly = white_canvas(200, 120, 100.0);
for point in &points {
singly
.draw_marker_styled_clipped(
point.x,
point.y,
8.0,
MarkerStyle::Circle,
fill,
None,
clip,
)
.expect("single marker should render");
}
assert_eq!(
batched.into_image().pixels,
singly.into_image().pixels,
"on an exact phase the sprite compositor must be pixel-identical to \
the vector painter"
);
}
#[test]
fn test_wide_marker_rim_survives_the_sprite_border() {
let points: Vec<Point2f> = (0..40)
.map(|i| Point2f::new(20.0 + (i % 8) as f32 * 24.0, 24.0 + (i / 8) as f32 * 28.0))
.collect();
let fill = Color::from_rgb(31, 119, 180);
let edge = Some((Color::from_rgb(255, 0, 0), 10.0));
let clip = whole_canvas(220.0, 170.0);
let mut batched = white_canvas(220, 170, 100.0);
batched
.draw_markers_styled_clipped(&points, 8.0, MarkerStyle::Circle, fill, edge, clip)
.expect("batched fat-rim markers should render");
assert!(batched.render_diagnostics().used_marker_sprite_compositor);
let mut singly = white_canvas(220, 170, 100.0);
for point in &points {
singly
.draw_marker_styled_clipped(
point.x,
point.y,
8.0,
MarkerStyle::Circle,
fill,
edge,
clip,
)
.expect("single fat-rim marker should render");
}
let count_red = |pixels: &[u8]| {
pixels
.chunks_exact(4)
.filter(|px| px[0] > 200 && px[1] < 60 && px[2] < 60)
.count()
};
let batched_red = count_red(&batched.into_image().pixels);
let singly_red = count_red(&singly.into_image().pixels);
assert!(batched_red > 0, "the fat rim must be painted");
assert!(
batched_red.abs_diff(singly_red) * 50 <= singly_red,
"the sprite must carry the whole rim: {batched_red} vs {singly_red}"
);
}
#[test]
fn test_edgeless_marker_batch_still_uses_the_sprite_compositor() {
let points: Vec<Point2f> = (0..40)
.map(|i| Point2f::new(8.0 + (i % 10) as f32 * 8.0, 12.0 + (i / 10) as f32 * 16.0))
.collect();
let clip = whole_canvas(96.0, 80.0);
let mut renderer = white_canvas(96, 80, 100.0);
renderer
.draw_markers_styled_clipped(
&points,
9.0,
MarkerStyle::Circle,
Color::from_rgb(31, 119, 180),
None,
clip,
)
.expect("edgeless markers should render");
assert!(
renderer.render_diagnostics().used_marker_sprite_compositor,
"asking for no edge must not cost the sprite fast path"
);
}
#[test]
fn test_draw_rectangle_styled_without_fill_or_edge_is_a_noop() {
let mut renderer = white_canvas(16, 16, 100.0);
renderer
.draw_rectangle_styled(2.0, 2.0, 8.0, 8.0, None, None)
.expect("empty style should be accepted");
let image = renderer.into_image();
assert!(
image
.pixels
.chunks_exact(4)
.all(|px| px.iter().all(|channel| *channel == 255)),
"a rectangle with neither fill nor edge must not touch the canvas"
);
}
fn has_ink(image: &Image, x: u32, y: u32) -> bool {
pixel(image, x, y)[0] < 250
}
#[test]
fn test_polyline_breaks_at_a_non_finite_vertex_instead_of_losing_the_line() {
let mut renderer = white_canvas(64, 32, 100.0);
let points = [
(4.0, 16.0),
(16.0, 16.0),
(f32::NAN, f32::NAN),
(44.0, 16.0),
(60.0, 16.0),
];
renderer
.draw_polyline(&points, Color::from_rgb(0, 0, 0), 2.0, LineStyle::Solid)
.expect("a non-finite vertex must not fail the whole polyline");
let image = renderer.into_image();
assert!(
has_ink(&image, 10, 16),
"the run before the hole must still be stroked"
);
assert!(
has_ink(&image, 52, 16),
"the run after the hole must still be stroked"
);
assert!(
!has_ink(&image, 30, 16),
"the stroke must break at the hole, not run straight through it"
);
}
#[test]
fn test_clipped_point_polyline_breaks_at_a_non_finite_vertex() {
let mut renderer = white_canvas(64, 32, 100.0);
let points = [
Point2f::new(4.0, 16.0),
Point2f::new(16.0, 16.0),
Point2f::new(f32::NAN, 16.0),
Point2f::new(44.0, 16.0),
Point2f::new(60.0, 16.0),
];
renderer
.draw_polyline_points_clipped(
&points,
Color::from_rgb(0, 0, 0),
2.0,
LineStyle::Solid,
(0.0, 0.0, 64.0, 32.0),
)
.expect("a non-finite vertex must not fail the whole polyline");
let image = renderer.into_image();
assert!(has_ink(&image, 10, 16), "first run must survive");
assert!(has_ink(&image, 52, 16), "second run must survive");
assert!(!has_ink(&image, 30, 16), "the hole must stay a hole");
}
#[test]
fn test_line_with_a_non_finite_endpoint_is_skipped_not_failed() {
let mut renderer = white_canvas(32, 32, 100.0);
renderer
.draw_line(
4.0,
16.0,
f32::NAN,
16.0,
Color::from_rgb(0, 0, 0),
2.0,
LineStyle::Solid,
)
.expect("an unplaceable segment is a gap, not an error");
let image = renderer.into_image();
assert!(
image
.pixels
.chunks_exact(4)
.all(|px| px.iter().all(|channel| *channel == 255)),
"a segment with no endpoint must not paint anything"
);
}
#[test]
fn test_rectangle_with_a_non_finite_height_names_the_dimension() {
let mut renderer = white_canvas(32, 32, 100.0);
let error = renderer
.draw_rectangle(4.0, 4.0, 8.0, f32::NAN, Color::from_rgb(0, 0, 0), true)
.expect_err("a rectangle with no height must not reach tiny-skia");
let message = error.to_string();
assert!(
message.contains("height"),
"the message must name the offending dimension: {message}"
);
assert!(
message.contains("internal invariant"),
"the message must read as an unvalidated-input bug, not a user diagnostic: {message}"
);
assert!(
!message.contains("Invalid rectangle dimensions"),
"the anonymous message must be gone: {message}"
);
}
#[test]
fn test_styled_rectangle_with_a_non_finite_origin_names_the_dimension() {
let mut renderer = white_canvas(32, 32, 100.0);
let error = renderer
.draw_rectangle_styled(
f32::NAN,
4.0,
8.0,
8.0,
Some(Color::from_rgb(0, 0, 0)),
None,
)
.expect_err("a rectangle with no origin must not reach tiny-skia");
assert!(
error.to_string().contains("non-finite x"),
"the message must name x: {error}"
);
}
#[test]
fn test_pixel_aligned_rectangle_refuses_a_non_finite_tile() {
let mut renderer = white_canvas(32, 32, 100.0);
let error = renderer
.draw_pixel_aligned_solid_rectangle(4.0, 4.0, f32::NAN, 8.0, Color::from_rgb(0, 0, 0))
.expect_err("an unvalidated tile must be reported, not silently skipped");
assert!(
error.to_string().contains("non-finite width"),
"the message must name width: {error}"
);
}
#[test]
fn test_marker_batch_skips_unplaceable_points() {
let mut renderer = white_canvas(64, 64, 100.0);
let points: Vec<Point2f> = std::iter::once(Point2f::new(f32::NAN, f32::NAN))
.chain((0..64).map(|i| Point2f::new(32.0, 32.0 + (i % 3) as f32)))
.collect();
renderer
.draw_markers_clipped(
&points,
6.0,
MarkerStyle::Circle,
Color::from_rgb(0, 0, 0),
(0.0, 0.0, 64.0, 64.0),
)
.expect("unplaceable samples are skipped, not fatal");
let image = renderer.into_image();
assert!(
has_ink(&image, 32, 32),
"the placeable markers must still be drawn"
);
assert!(
!has_ink(&image, 0, 0),
"an unplaceable marker must not be painted in the canvas corner"
);
}
#[test]
fn test_single_marker_with_a_non_finite_centre_is_skipped() {
let mut renderer = white_canvas(32, 32, 100.0);
renderer
.draw_marker(
f32::NAN,
16.0,
6.0,
MarkerStyle::Circle,
Color::from_rgb(0, 0, 0),
)
.expect("unplaceable markers are skipped, not fatal");
let image = renderer.into_image();
assert!(
image
.pixels
.chunks_exact(4)
.all(|px| px.iter().all(|channel| *channel == 255)),
"an unplaceable marker must not touch the canvas"
);
}
#[test]
fn test_filled_polygon_refuses_a_non_finite_vertex() {
let mut renderer = white_canvas(32, 32, 100.0);
let error = renderer
.draw_filled_polygon(
&[(4.0, 4.0), (28.0, 4.0), (16.0, f32::NAN)],
Color::from_rgb(0, 0, 0),
)
.expect_err("a polygon with an unplaceable corner must not be filled");
let message = error.to_string();
assert!(
message.contains("vertex 2"),
"the message must name the offending vertex: {message}"
);
assert!(
message.contains("internal invariant"),
"the message must read as an unvalidated-input bug: {message}"
);
}
}