Alejandro Alonso cc5f2f57ad
⬆️ Upgrade skia-safe to 0.153.3 with wasm SIMD (#12148)
Point SKIA_BINARIES_URL at the 0.153.3 simd prebuild and adapt
fonts, path stroke expansion, and gradients to the new API.
2026-10-07 12:08:33 +02:00

1541 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::math::{Matrix, Point, Rect};
use crate::shapes::{
arrow_cap_path, merge_fills, square_cap_path, triangle_cap_path, Corners, Fill, ImageFill,
Path, Shape, Side, Stroke, StrokeCap, StrokeKind, StrokeStyle, SvgAttrs, Type,
};
use skia_safe::{self as skia, ImageFilter, RRect};
use super::{filters, RenderState, SurfaceId};
use crate::error::{Error, Result};
use crate::get_resources;
use crate::render::filters::compose_filters;
use crate::render::{get_dest_rect, get_source_rect};
#[allow(clippy::too_many_arguments)]
pub(super) fn draw_stroke_on_rect(
canvas: &skia::Canvas,
stroke: &Stroke,
rect: &Rect,
corners: &Option<Corners>,
paint: &skia::Paint,
scale: f32,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
miter: Option<[f32; 4]>,
antialias: bool,
) {
let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = paint.clone();
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
// Per-side widths render as a band between an outer and an inner rect.
if let Some(widths) = stroke.per_side_widths() {
draw_per_side_stroke_on_rect(
canvas, stroke, rect, corners, &paint, widths, scale, miter, antialias,
);
return;
}
// Radii as Skia fits them to the shape, so the stroke follows the fill.
let shape_radii = corners.map(|radii| *RRect::new_rect_radii(*rect, &radii).radii_ref());
// By default just draw the rect. Only dotted inner/outer strokes need
// clipping to prevent the dotted pattern from appearing in wrong areas.
let draw_stroke = || match &shape_radii {
Some(radii) => {
let radii = stroke.outer_corners(radii);
let rrect = RRect::new_rect_radii(stroke_rect, &radii);
canvas.draw_rrect(rrect, &paint);
}
None => {
canvas.draw_rect(stroke_rect, &paint);
}
};
// Dotted inner/outer strokes need clipping to prevent the dotted
// pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() {
// Use a neutral layer (no extra paint) so opacity and filters
// come solely from the stroke paint. This avoids applying
// stroke alpha twice for dotted inner/outer strokes.
let layer_rec = skia::canvas::SaveLayerRec::default();
canvas.save_layer(&layer_rec);
match corners {
Some(radii) => {
let rrect = RRect::new_rect_radii(*rect, radii);
canvas.clip_rrect(rrect, clip_op, antialias);
}
None => {
canvas.clip_rect(*rect, clip_op, antialias);
}
}
draw_stroke();
canvas.restore();
} else if stroke.kind == StrokeKind::Inner
&& (stroke.width >= rect.width()
|| stroke.width >= rect.height()
|| inner_corners_collapse(stroke, rect, &shape_radii))
{
// When the inner stroke width exceeds a shape dimension, the inset
// rect goes negative and the stroke overflows outside the shape.
// Same when the inset radii can't follow the shape corners.
// Fall back to the same approach as the SVG renderer: draw with
// doubled width centered on the original shape and clip to it.
canvas.save();
match corners {
Some(radii) => {
let rrect = RRect::new_rect_radii(*rect, radii);
canvas.clip_rrect(rrect, skia::ClipOp::Intersect, antialias);
}
None => {
canvas.clip_rect(*rect, skia::ClipOp::Intersect, antialias);
}
}
let mut inner_paint = paint.clone();
inner_paint.set_stroke_width(stroke.width * 2.0);
match corners {
Some(radii) => {
let rrect = RRect::new_rect_radii(*rect, radii);
canvas.draw_rrect(rrect, &inner_paint);
}
None => {
canvas.draw_rect(*rect, &inner_paint);
}
}
canvas.restore();
} else {
draw_stroke();
}
}
/// Whether the inset radii clamp to zero or get rescaled by Skia to fit.
fn inner_corners_collapse(stroke: &Stroke, rect: &Rect, radii: &Option<Corners>) -> bool {
let Some(radii) = radii else {
return false;
};
if matches!(stroke.style, StrokeStyle::Dotted | StrokeStyle::Dashed) {
return false;
}
let wanted = stroke.outer_corners(radii);
let inset = RRect::new_rect_radii(stroke.outer_rect(rect), &wanted);
radii
.iter()
.zip(wanted.iter().zip(inset.radii_ref()))
.any(|(shape, (want, got))| shape.x > 0.0 && (want.x <= 0.0 || want != got))
}
/// Draws a rect/frame stroke whose sides have different widths as the area
/// between an outer and an inner (rounded) rect, mitered like CSS borders.
/// Dashed/dotted styles cut that band per side, each side fitting a whole
/// number of dashes the way a browser fits a dashed border.
#[allow(clippy::too_many_arguments)]
fn draw_per_side_stroke_on_rect(
canvas: &skia::Canvas,
stroke: &Stroke,
rect: &Rect,
corners: &Option<Corners>,
paint: &skia::Paint,
widths: [f32; 4],
scale: f32,
miter: Option<[f32; 4]>,
antialias: bool,
) {
// Strokes sharing a border box are shaped by the whole box: a side this one
// does not paint still sets the radii its corners curve to, as in CSS.
let band_widths: [f32; 4] = match miter {
Some(profile) => std::array::from_fn(|side| {
if widths[side] > 0.0 {
widths[side]
} else {
profile[side]
}
}),
None => widths,
};
let [top, right, bottom, left] = band_widths;
// Fraction of each side width growing outward / inward from the shape
// boundary, per stroke alignment.
let (out_f, in_f) = match stroke.kind {
StrokeKind::Inner => (0.0, 1.0),
StrokeKind::Center => (0.5, 0.5),
StrokeKind::Outer => (1.0, 0.0),
};
let outer = Rect::from_ltrb(
rect.left - left * out_f,
rect.top - top * out_f,
rect.right + right * out_f,
rect.bottom + bottom * out_f,
);
let inner = Rect::from_ltrb(
rect.left + left * in_f,
rect.top + top * in_f,
rect.right - right * in_f,
rect.bottom - bottom * in_f,
);
// When a side width exceeds the shape dimension the inner rect collapses
// and the whole outer rect is filled, like CSS collapsing borders.
let has_hole = inner.width() > 0.0 && inner.height() > 0.0;
let mut fill_paint = paint.clone();
fill_paint.set_style(skia::PaintStyle::Fill);
fill_paint.set_path_effect(None);
fill_paint.set_anti_alias(antialias);
let mut pb = skia::PathBuilder::new();
let (outer_radii, inner_radii) = match corners {
Some(radii) => {
// Straight (zero-radius) corners stay sharp; rounded ones keep
// their curvature parallel to the shape edge, like CSS borders.
let grow = |radius: f32, delta: f32| {
if radius > 0.0 {
radius + delta
} else {
0.0
}
};
let shrink = |radius: f32, delta: f32| (radius - delta).max(0.0);
// Adjacent side widths per corner, matching the Skia radii order:
// [top-left, top-right, bottom-right, bottom-left].
let side_deltas = [(left, top), (right, top), (right, bottom), (left, bottom)];
let mut outer_radii = *radii;
let mut inner_radii = *radii;
for (i, (dx, dy)) in side_deltas.into_iter().enumerate() {
outer_radii[i].x = grow(outer_radii[i].x, dx * out_f);
outer_radii[i].y = grow(outer_radii[i].y, dy * out_f);
inner_radii[i].x = shrink(inner_radii[i].x, dx * in_f);
inner_radii[i].y = shrink(inner_radii[i].y, dy * in_f);
}
pb.add_rrect(RRect::new_rect_radii(outer, &outer_radii), None, None);
if has_hole {
pb.add_rrect(RRect::new_rect_radii(inner, &inner_radii), None, None);
}
(Some(outer_radii), Some(inner_radii))
}
None => {
pb.add_rect(outer, None, None);
if has_hole {
pb.add_rect(inner, None, None);
}
(None, None)
}
};
let mut band = pb.detach();
band.set_fill_type(skia::PathFillType::EvenOdd);
// Solid is the common case and the whole band is one fill. A collapsed
// band has no sides left to walk, so it fills solid too.
let style = stroke.with_width(stroke.max_width()).style_at_scale(scale);
let pattern = if style == StrokeStyle::Solid || !has_hole {
None
} else {
expand_side_patterns(
stroke,
style,
rect,
&outer,
&inner,
inner_radii,
widths,
antialias,
)
};
// Sides this stroke shares with another per-side stroke meet on a diagonal,
// so each one keeps only its own wedge of the corner.
let wedges = miter.and_then(|profile| side_wedges(&outer, outer_radii, profile, widths));
if pattern.is_none() && wedges.is_none() {
canvas.draw_path(&band, &fill_paint);
return;
}
// A filtered stroke draws inside a filtered layer, so the blur or shadow
// sees the clipped geometry rather than the whole band.
match fill_paint.image_filter() {
Some(filter) => {
fill_paint.set_image_filter(None);
let mut layer_paint = skia::Paint::default();
layer_paint.set_image_filter(filter);
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint);
canvas.save_layer(&layer_rec);
}
None => {
canvas.save();
}
}
if let Some(wedges) = &wedges {
canvas.clip_path(wedges, skia::ClipOp::Intersect, antialias);
}
match &pattern {
Some(pattern) => {
// Square corners need no clip: the side strips tile the band exactly.
// Rounded corners curve past them, and dots straddle the boundary.
let rounded = corners.is_some_and(|radii| radii.iter().any(|r| r.x > 0.0 || r.y > 0.0));
if rounded || style == StrokeStyle::Dotted {
canvas.clip_path(&band, skia::ClipOp::Intersect, antialias);
}
canvas.draw_path(pattern, &fill_paint);
}
None => {
canvas.draw_path(&band, &fill_paint);
}
}
canvas.restore();
}
/// Where a corner's miter line sits at the outer edge and at `depth` into the
/// side. It runs through the centre of curvature, so a corner splits on its arc.
fn miter_offsets(radius: (f32, f32), own: f32, adjacent: f32, depth: f32) -> (f32, f32) {
if own <= 0.0 {
return (0.0, adjacent);
}
// A corner with no neighbour to meet belongs to this side alone.
let (along, across) = if adjacent > 0.0 { radius } else { (0.0, 0.0) };
let slope = adjacent / own;
(along - across * slope, along - (across - depth) * slope)
}
/// The CSS miter wedges for the sides this stroke paints, each narrowing to a
/// diagonal where it meets a neighbour. `None` when it already fills the profile.
fn side_wedges(
outer: &Rect,
outer_radii: Option<Corners>,
profile: [f32; 4],
widths: [f32; 4],
) -> Option<skia::Path> {
if widths == profile {
return None;
}
let [top, right, bottom, left] = profile;
let radii = outer_radii.unwrap_or_default();
let mut pb = skia::PathBuilder::new();
let mut wedged = false;
for side in Side::ALL {
if widths[side.index()] <= 0.0 {
continue;
}
// Each side runs from its own corner to the next one clockwise.
let lead = side.index();
let trail = (lead + 1) % 4;
let (own, ccw, cw) = match side {
Side::Top => (top, left, right),
Side::Right => (right, top, bottom),
Side::Bottom => (bottom, right, left),
Side::Left => (left, bottom, top),
};
// A corner radius is an ellipse: the component along the side sets where
// the miter meets it, the one across it sets how deep the corner runs.
let (lead_radius, trail_radius) = match side {
Side::Top | Side::Bottom => (
(radii[lead].x, radii[lead].y),
(radii[trail].x, radii[trail].y),
),
Side::Right | Side::Left => (
(radii[lead].y, radii[lead].x),
(radii[trail].y, radii[trail].x),
),
};
// A rounded corner bulges past the straight strip, so the wedge reaches
// deeper than the side is thick. The band trims whatever overshoots.
let depth = own + lead_radius.1.max(trail_radius.1);
let (a_out, a_in) = miter_offsets(lead_radius, own, ccw, depth);
let (b_out, b_in) = miter_offsets(trail_radius, own, cw, depth);
let quad = match side {
Side::Top => [
(outer.left + a_out, outer.top),
(outer.right - b_out, outer.top),
(outer.right - b_in, outer.top + depth),
(outer.left + a_in, outer.top + depth),
],
Side::Right => [
(outer.right, outer.top + a_out),
(outer.right, outer.bottom - b_out),
(outer.right - depth, outer.bottom - b_in),
(outer.right - depth, outer.top + a_in),
],
Side::Bottom => [
(outer.right - a_out, outer.bottom),
(outer.left + b_out, outer.bottom),
(outer.left + b_in, outer.bottom - depth),
(outer.right - a_in, outer.bottom - depth),
],
Side::Left => [
(outer.left, outer.bottom - a_out),
(outer.left, outer.top + b_out),
(outer.left + depth, outer.top + b_in),
(outer.left + depth, outer.bottom - a_in),
],
};
pb.add_polygon(&quad.map(Point::from), true);
wedged = true;
}
wedged.then(|| pb.detach())
}
/// Expands each side's dash or dot pattern into the geometry it paints, fitted
/// so the side begins and ends on a dash. `None` when no side produced any.
#[allow(clippy::too_many_arguments)]
fn expand_side_patterns(
stroke: &Stroke,
style: StrokeStyle,
rect: &Rect,
outer: &Rect,
inner: &Rect,
inner_radii: Option<Corners>,
widths: [f32; 4],
antialias: bool,
) -> Option<skia::Path> {
let mut pb = skia::PathBuilder::new();
let mut expanded = false;
let mut stamp_paint = skia::Paint::default();
stamp_paint.set_style(skia::PaintStyle::Stroke);
stamp_paint.set_stroke_cap(skia::paint::Cap::Butt);
stamp_paint.set_anti_alias(antialias);
for side in Side::ALL {
let width = widths[side.index()];
if width <= 0.0 {
continue;
}
let mut side_stroke = stroke.with_width(width);
side_stroke.style = style;
// Where the uniform renderer would stroke this width, so inner/outer
// dots still come out as half circles once the band clips them.
let line = side_stroke.outer_rect(rect);
let (start, end) = match side {
Side::Top => ((outer.left, line.top), (outer.right, line.top)),
Side::Right => ((line.right, outer.top), (line.right, outer.bottom)),
Side::Bottom => ((outer.right, line.bottom), (outer.left, line.bottom)),
Side::Left => ((line.left, outer.bottom), (line.left, outer.top)),
};
let length = match side {
Side::Top | Side::Bottom => outer.width(),
Side::Right | Side::Left => outer.height(),
};
let Some(effect) = side_stroke.path_effect_fitted(length) else {
continue;
};
stamp_paint.set_stroke_width(width);
stamp_paint.set_path_effect(effect);
let line_path = {
let mut line_pb = skia::PathBuilder::new();
line_pb.move_to(start);
line_pb.line_to(end);
line_pb.detach()
};
let mut outline = skia::PathBuilder::new();
if skia::path_utils::fill_path_with_paint(
&line_path,
&stamp_paint,
&mut outline,
None,
None,
) {
pb.add_path(&outline.detach(), None);
expanded = true;
}
}
// A rounded band's corner notches paint solid, as a browser does for uneven
// widths. Dots are discrete and must not weld into a solid corner.
if style != StrokeStyle::Dotted {
if let Some(radii) = inner_radii {
for patch in corner_patches(outer, inner, &radii) {
pb.add_rect(patch, None, None);
expanded = true;
}
}
}
expanded.then(|| pb.detach())
}
/// The corner boxes of a rounded band, from each side's leading outer corner to
/// where that corner's inner arc meets the straight edges.
fn corner_patches(outer: &Rect, inner: &Rect, inner_radii: &Corners) -> Vec<Rect> {
Side::ALL
.into_iter()
.filter_map(|side| {
let radius = inner_radii[side.index()];
if radius.x <= 0.0 && radius.y <= 0.0 {
return None;
}
let (left, right) = match side {
Side::Top | Side::Left => (outer.left, inner.left + radius.x),
Side::Right | Side::Bottom => (inner.right - radius.x, outer.right),
};
let (top, bottom) = match side {
Side::Top | Side::Right => (outer.top, inner.top + radius.y),
Side::Bottom | Side::Left => (inner.bottom - radius.y, outer.bottom),
};
let patch = Rect::from_ltrb(left, top, right, bottom);
(patch.width() > 0.0 && patch.height() > 0.0).then_some(patch)
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub(super) fn draw_stroke_on_circle(
canvas: &skia::Canvas,
stroke: &Stroke,
rect: &Rect,
paint: &skia::Paint,
scale: f32,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
antialias: bool,
) {
let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = paint.clone();
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
// Dotted inner/outer strokes need clipping to prevent the dotted
// pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() {
// Use a neutral layer (no extra paint) so opacity and filters
// come solely from the stroke paint. This avoids applying
// stroke alpha twice for dotted inner/outer strokes.
let layer_rec = skia::canvas::SaveLayerRec::default();
canvas.save_layer(&layer_rec);
let clip_path = {
let mut pb = skia::PathBuilder::new();
pb.add_oval(rect, None, None);
pb.detach()
};
canvas.clip_path(&clip_path, clip_op, antialias);
canvas.draw_oval(stroke_rect, &paint);
canvas.restore();
} else if stroke.kind == StrokeKind::Inner
&& (stroke.width >= rect.width() || stroke.width >= rect.height())
{
// When the inner stroke width exceeds a shape dimension, the inset
// rect goes negative and the stroke overflows outside the shape.
// Fall back to the same approach as the SVG renderer: draw with
// doubled width centered on the original shape and clip to it.
canvas.save();
let clip_path = {
let mut pb = skia::PathBuilder::new();
pb.add_oval(rect, None, None);
pb.detach()
};
canvas.clip_path(&clip_path, skia::ClipOp::Intersect, antialias);
let mut inner_paint = paint.clone();
inner_paint.set_stroke_width(stroke.width * 2.0);
canvas.draw_oval(*rect, &inner_paint);
canvas.restore();
} else {
canvas.draw_oval(stroke_rect, &paint);
}
}
fn draw_outer_stroke_path(
canvas: &skia::Canvas,
path: &skia::Path,
paint: &skia::Paint,
blur: Option<&ImageFilter>,
antialias: bool,
) {
let mut outer_paint = skia::Paint::default();
outer_paint.set_blend_mode(skia::BlendMode::SrcOver);
outer_paint.set_anti_alias(antialias);
if let Some(filter) = blur {
outer_paint.set_image_filter(filter.clone());
}
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&outer_paint);
canvas.save_layer(&layer_rec);
canvas.draw_path(path, paint);
let mut clear_paint = skia::Paint::default();
clear_paint.set_blend_mode(skia::BlendMode::Clear);
clear_paint.set_anti_alias(antialias);
canvas.draw_path(path, &clear_paint);
canvas.restore();
}
// For inner stroke we draw a center stroke (with double width) and clip to the original path (that way the extra outer stroke is removed)
fn draw_inner_stroke_path(
canvas: &skia::Canvas,
path: &skia::Path,
paint: &skia::Paint,
blur: Option<&ImageFilter>,
antialias: bool,
) {
let mut inner_paint = skia::Paint::default();
inner_paint.set_anti_alias(antialias);
if let Some(filter) = blur {
inner_paint.set_image_filter(filter.clone());
}
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&inner_paint);
canvas.save_layer(&layer_rec);
canvas.clip_path(path, skia::ClipOp::Intersect, antialias);
canvas.draw_path(path, paint);
canvas.restore();
}
// For outer stroke we draw a center stroke (with double width) and use another path with blend mode clear to remove the inner stroke added
#[allow(clippy::too_many_arguments)]
fn draw_stroke_on_path(
canvas: &skia::Canvas,
stroke: &Stroke,
path: &Path,
paint: &skia::Paint,
path_transform: Option<&Matrix>,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
svg_attrs: Option<&SvgAttrs>,
antialias: bool,
) {
let is_open = path.is_open();
let mut draw_paint = paint.clone();
let filter = compose_filters(blur, shadow);
draw_paint.set_image_filter(filter);
// Move path_transform from the path geometry to the canvas so the
// stroke width is not distorted by non-uniform shape scaling.
// The path coordinates are already in world space, so we draw the
// raw path on a canvas where the shape transform has been undone:
// canvas * path_transform = View × parents (no shape scale/rotation)
// This matches the SVG renderer, which bakes the transform into path
// coordinates and never sets a transform attribute on the element.
let save_count = canvas.save();
if let Some(pt) = path_transform {
canvas.concat(pt);
}
let skia_path = path.to_skia_path(svg_attrs);
match stroke.render_kind(is_open) {
StrokeKind::Inner => {
draw_inner_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
}
StrokeKind::Center => {
canvas.draw_path(&skia_path, &draw_paint);
}
StrokeKind::Outer => {
draw_outer_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
}
}
handle_stroke_caps(&skia_path, stroke, canvas, is_open, paint, blur, antialias);
canvas.restore_to_count(save_count);
}
fn handle_stroke_cap(
canvas: &skia::Canvas,
cap: StrokeCap,
width: f32,
paint: &mut skia::Paint,
p1: &Point,
p2: &Point,
) {
paint.set_style(skia::PaintStyle::Fill);
match cap {
StrokeCap::LineArrow => {
// We also draw this square cap to fill the gap between the path and the arrow
draw_square_cap(canvas, paint, p1, p2, width, 0.);
paint.set_style(skia::PaintStyle::Stroke);
draw_arrow_cap(canvas, paint, p1, p2, width * 4.);
}
StrokeCap::TriangleArrow => {
draw_triangle_cap(canvas, paint, p1, p2, width * 4.);
}
StrokeCap::SquareMarker => {
draw_square_cap(canvas, paint, p1, p2, width * 4., 0.);
}
StrokeCap::CircleMarker => {
canvas.draw_circle((p1.x, p1.y), width * 2., paint);
}
StrokeCap::DiamondMarker => {
draw_square_cap(canvas, paint, p1, p2, width * 4., 45.);
}
StrokeCap::Round => {
canvas.draw_circle((p1.x, p1.y), width / 2.0, paint);
}
StrokeCap::Square => {
draw_square_cap(canvas, paint, p1, p2, width, 0.);
}
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_stroke_caps(
path: &skia::Path,
stroke: &Stroke,
canvas: &skia::Canvas,
is_open: bool,
paint: &skia::Paint,
blur: Option<&ImageFilter>,
_antialias: bool,
) {
// Closed shapes don't have caps
if !is_open {
return;
}
// When both ends share the same simple line cap, Skia already drew it
// natively via `PaintCap` on the stroke paint, so skip the manual overlay.
if stroke.to_skia_linecap().is_some() {
return;
}
// Curves can have duplicated points, so let's remove consecutive duplicated points
let mut points = path.points().to_vec();
points.dedup();
if let [first_point, .., last_point] = points.as_slice() {
let mut paint_stroke = paint.clone();
paint_stroke.set_path_effect(None);
if let Some(filter) = blur {
paint_stroke.set_image_filter(filter.clone());
}
if let Some(cap) = stroke.cap_start {
handle_stroke_cap(
canvas,
cap,
stroke.width,
&mut paint_stroke,
first_point,
&points[1],
);
}
if let Some(cap) = stroke.cap_end {
handle_stroke_cap(
canvas,
cap,
stroke.width,
&mut paint_stroke,
last_point,
&points[points.len() - 2],
);
}
}
}
fn draw_square_cap(
canvas: &skia::Canvas,
paint: &skia::Paint,
center: &Point,
direction: &Point,
size: f32,
extra_rotation: f32,
) {
canvas.draw_path(
&square_cap_path(center, direction, size, extra_rotation),
paint,
);
}
fn draw_arrow_cap(
canvas: &skia::Canvas,
paint: &skia::Paint,
center: &Point,
direction: &Point,
size: f32,
) {
canvas.draw_path(&arrow_cap_path(center, direction, size), paint);
}
fn draw_triangle_cap(
canvas: &skia::Canvas,
paint: &skia::Paint,
center: &Point,
direction: &Point,
size: f32,
) {
canvas.draw_path(&triangle_cap_path(center, direction, size), paint);
}
fn draw_image_stroke_in_container(
render_state: &mut RenderState,
shape: &Shape,
stroke: &Stroke,
image_fill: &ImageFill,
antialias: bool,
surface_id: SurfaceId,
) -> Result<()> {
let scale = render_state.get_scale();
let lod_stroke;
let stroke = if matches!(shape.shape_type, Type::Path(_) | Type::Bool(_)) {
lod_stroke = stroke.path_lod_at_scale(shape.is_open(), scale);
&lod_stroke
} else {
stroke
};
let Some(image) = get_resources().images.get(&image_fill.id()) else {
return Ok(());
};
let size = image.dimensions();
let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id);
let container = &shape.selrect;
let path_transform = shape.to_path_transform();
let svg_attrs = shape.svg_attrs.as_ref();
// Save canvas and layer state
let mut pb = skia::Paint::default();
pb.set_blend_mode(skia::BlendMode::SrcOver);
pb.set_anti_alias(antialias);
if let Some(filter) = shape.image_filter(1.) {
pb.set_image_filter(filter);
}
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&pb);
canvas.save_layer(&layer_rec);
// Draw the stroke based on the shape type, we are using this stroke as
// a "selector" of the area of the image we want to show.
let outer_rect = stroke.aligned_rect(container, scale);
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias);
draw_stroke_on_rect(
canvas,
stroke,
container,
&shape_type.corners(),
&paint,
scale,
None,
None,
Stroke::per_side_profile(shape.visible_strokes()),
antialias,
);
}
Type::Circle => {
let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias);
draw_stroke_on_circle(
canvas, stroke, container, &paint, scale, None, None, antialias,
);
}
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(p) = shape_type.path() {
canvas.save();
let path = p.to_skia_path(svg_attrs).make_transform(
&path_transform.ok_or(Error::CriticalError("No path transform".to_string()))?,
);
let stroke_kind = stroke.render_kind(p.is_open());
match stroke_kind {
StrokeKind::Inner => {
canvas.clip_path(&path, skia::ClipOp::Intersect, antialias);
}
StrokeKind::Center => {}
StrokeKind::Outer => {
canvas.clip_path(&path, skia::ClipOp::Difference, antialias);
}
}
let is_open = p.is_open();
let paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias);
canvas.draw_path(&path, &paint);
if stroke.render_kind(is_open) == StrokeKind::Outer {
// Small extra inner stroke to overlap with the fill
// and avoid unnecesary artifacts.
let mut thin_paint = paint.clone();
thin_paint.set_stroke_width(1. / scale);
canvas.draw_path(&path, &thin_paint);
}
handle_stroke_caps(
&path,
stroke,
canvas,
is_open,
&paint,
shape.image_filter(1.).as_ref(),
antialias,
);
canvas.restore();
}
}
_ => unreachable!("This shape should not have strokes"),
}
// Draw the image. We are using now the SrcIn blend mode,
// so the rendered piece of image will the area of the
// stroke over the image.
let mut image_paint = skia::Paint::default();
image_paint.set_blend_mode(skia::BlendMode::SrcIn);
image_paint.set_anti_alias(antialias);
if let Some(filter) = shape.image_filter(1.) {
image_paint.set_image_filter(filter);
}
let src_rect = get_source_rect(size, container, image_fill);
let dest_rect = get_dest_rect(container, stroke.delta());
canvas.clip_rect(dest_rect, skia::ClipOp::Intersect, antialias);
canvas.draw_image_rect_with_sampling_options(
image,
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
dest_rect,
get_resources().sampling_options,
&image_paint,
);
// Clear outer stroke for paths if necessary. When adding an outer stroke we need to empty the stroke added too in the inner area.
if let Type::Path(p) = &shape.shape_type {
if stroke.render_kind(p.is_open()) == StrokeKind::Outer {
let path = p.to_skia_path(svg_attrs).make_transform(
&path_transform.ok_or(Error::CriticalError("No path transform".to_string()))?,
);
let mut clear_paint = skia::Paint::default();
clear_paint.set_blend_mode(skia::BlendMode::Clear);
clear_paint.set_anti_alias(antialias);
canvas.draw_path(&path, &clear_paint);
}
}
// Restore canvas state
canvas.restore();
Ok(())
}
/// Renders all strokes for a shape. Merges strokes that share the same
/// geometry (kind, width, style, caps) into a single draw call to avoid
/// anti-aliasing edge bleed between them.
pub fn render(
render_state: &mut RenderState,
shape: &Shape,
strokes: &[&Stroke],
surface_id: Option<SurfaceId>,
antialias: bool,
outset: Option<f32>,
) -> Result<()> {
if strokes.is_empty() {
return Ok(());
}
let has_image_fills = strokes.iter().any(|s| matches!(s.fill, Fill::Image(_)));
let can_merge = !has_image_fills && strokes.len() > 1 && strokes_share_geometry(strokes);
if !can_merge {
// When blur is active, render all strokes into a single offscreen surface
// and apply blur once to the composite. This prevents blur from making
// edges semi-transparent and revealing strokes underneath.
if let Some(image_filter) = shape.image_filter(1.) {
let mut content_bounds = shape.selrect;
// Expand for outset if provided
if let Some(s) = outset.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let max_margin = strokes
.iter()
.map(|s| s.bounds_width(shape.is_open()))
.fold(0.0f32, f32::max);
if max_margin > 0.0 {
content_bounds.inset((-max_margin, -max_margin));
}
let max_cap = strokes
.iter()
.map(|s| s.cap_bounds_margin())
.fold(0.0f32, f32::max);
if max_cap > 0.0 {
content_bounds.inset((-max_cap, -max_cap));
}
let bounds = image_filter.compute_fast_bounds(content_bounds);
let target = surface_id.unwrap_or(SurfaceId::Strokes);
if filters::render_with_filter_surface(
render_state,
bounds,
target,
|state, temp_surface| {
// Use save_layer with the blur filter so it applies once
// to the composite of all strokes, not per-stroke.
let canvas = state.surfaces.canvas(temp_surface);
let mut blur_paint = skia::Paint::default();
blur_paint.set_image_filter(image_filter.clone());
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&blur_paint);
canvas.save_layer(&layer_rec);
for stroke in strokes.iter().rev() {
// bypass_filter=true prevents each stroke from creating
// its own filter surface. The blur on the paint inside
// draw functions is harmless — it composes with the
// layer's filter but the layer filter is the dominant one.
render_single_internal(
state,
shape,
stroke,
Some(temp_surface),
None,
antialias,
true,
true,
outset,
)?;
}
state.surfaces.canvas(temp_surface).restore();
Ok(())
},
)? {
return Ok(());
}
}
// No blur or filter surface unavailable — draw strokes individually.
for stroke in strokes.iter().rev() {
render_single(
render_state,
shape,
stroke,
surface_id,
None,
antialias,
outset,
)?;
}
return Ok(());
}
render_merged(
render_state,
shape,
strokes,
surface_id,
antialias,
false,
outset,
)
}
fn strokes_share_geometry(strokes: &[&Stroke]) -> bool {
strokes.windows(2).all(|pair| {
pair[0].kind == pair[1].kind
&& pair[0].width == pair[1].width
&& pair[0].widths == pair[1].widths
&& pair[0].style == pair[1].style
&& pair[0].cap_start == pair[1].cap_start
&& pair[0].cap_end == pair[1].cap_end
})
}
fn render_merged(
render_state: &mut RenderState,
shape: &Shape,
strokes: &[&Stroke],
surface_id: Option<SurfaceId>,
antialias: bool,
bypass_filter: bool,
outset: Option<f32>,
) -> Result<()> {
let representative = *strokes
.last()
.expect("render_merged expects at least one stroke");
let blur_filter = if bypass_filter {
None
} else {
shape.image_filter(1.)
};
// Handle blur filter
if !bypass_filter {
if let Some(image_filter) = blur_filter.clone() {
let mut content_bounds = shape.selrect;
// Expand for outset if provided
if let Some(s) = outset.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let stroke_margin = representative.bounds_width(shape.is_open());
if stroke_margin > 0.0 {
content_bounds.inset((-stroke_margin, -stroke_margin));
}
let cap_margin = representative.cap_bounds_margin();
if cap_margin > 0.0 {
content_bounds.inset((-cap_margin, -cap_margin));
}
let bounds = image_filter.compute_fast_bounds(content_bounds);
let target = surface_id.unwrap_or(SurfaceId::Strokes);
if filters::render_with_filter_surface(
render_state,
bounds,
target,
|state, temp_surface| {
let blur_filter = image_filter.clone();
state.surfaces.apply_mut(temp_surface as u32, |surface| {
let canvas = surface.canvas();
let mut blur_paint = skia::Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&blur_paint);
canvas.save_layer(&layer_rec);
});
render_merged(
state,
shape,
strokes,
Some(temp_surface),
antialias,
true,
outset,
)?;
state.surfaces.apply_mut(temp_surface as u32, |surface| {
surface.canvas().restore();
});
Ok(())
},
)? {
return Ok(());
}
}
}
// `merge_fills` puts fills[0] on top (each new fill goes under the accumulated shader
// via SrcOver), matching the non-merged path where strokes[0] is drawn last (on top).
let fills: Vec<Fill> = strokes.iter().map(|s| s.fill.clone()).collect();
// Expand selrect if outset is provided
let selrect = if let Some(s) = outset.filter(|&s| s > 0.0) {
let mut r = shape.selrect;
r.outset((s, s));
r
} else {
shape.selrect
};
let merged = merge_fills(&fills, selrect);
let scale = render_state.get_scale();
let target_surface = surface_id.unwrap_or(SurfaceId::Strokes);
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
let svg_attrs = shape.svg_attrs.as_ref();
let path_transform = shape.to_path_transform();
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let mut paint = representative.to_paint(&selrect, svg_attrs, antialias);
paint.set_shader(merged.shader());
draw_stroke_on_rect(
canvas,
representative,
&selrect,
&shape_type.corners(),
&paint,
scale,
None,
blur_filter.as_ref(),
Stroke::per_side_profile(shape.visible_strokes()),
antialias,
);
}
Type::Circle => {
let mut paint = representative.to_paint(&selrect, svg_attrs, antialias);
paint.set_shader(merged.shader());
draw_stroke_on_circle(
canvas,
representative,
&selrect,
&paint,
scale,
None,
blur_filter.as_ref(),
antialias,
);
}
Type::Text(_) => {}
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(path) = shape_type.path() {
let is_open = path.is_open();
let lod_stroke = representative.path_lod_at_scale(is_open, scale);
let mut paint =
lod_stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
paint.set_shader(merged.shader());
draw_stroke_on_path(
canvas,
&lod_stroke,
path,
&paint,
path_transform.as_ref(),
None,
blur_filter.as_ref(),
svg_attrs,
antialias,
);
}
}
_ => unreachable!("This shape should not have strokes"),
}
Ok(())
}
/// Renders a single stroke. Used by the shadow module which needs per-stroke
/// shadow filters.
#[allow(clippy::too_many_arguments)]
pub fn render_single(
render_state: &mut RenderState,
shape: &Shape,
stroke: &Stroke,
surface_id: Option<SurfaceId>,
shadow: Option<&ImageFilter>,
antialias: bool,
outset: Option<f32>,
) -> Result<()> {
render_single_internal(
render_state,
shape,
stroke,
surface_id,
shadow,
antialias,
false,
false,
outset,
)
}
#[allow(clippy::too_many_arguments)]
fn render_single_internal(
render_state: &mut RenderState,
shape: &Shape,
stroke: &Stroke,
surface_id: Option<SurfaceId>,
shadow: Option<&ImageFilter>,
antialias: bool,
bypass_filter: bool,
skip_blur: bool,
outset: Option<f32>,
) -> Result<()> {
if !bypass_filter {
if let Some(image_filter) = shape.image_filter(1.) {
let mut content_bounds = shape.selrect;
// Expand for outset if provided
if let Some(s) = outset.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let stroke_margin = stroke.bounds_width(shape.is_open());
if stroke_margin > 0.0 {
content_bounds.inset((-stroke_margin, -stroke_margin));
}
let cap_margin = stroke.cap_bounds_margin();
if cap_margin > 0.0 {
content_bounds.inset((-cap_margin, -cap_margin));
}
let bounds = image_filter.compute_fast_bounds(content_bounds);
let target = surface_id.unwrap_or(SurfaceId::Strokes);
if filters::render_with_filter_surface(
render_state,
bounds,
target,
|state, temp_surface| {
render_single_internal(
state,
shape,
stroke,
Some(temp_surface),
shadow,
antialias,
true,
true,
outset,
)
},
)? {
return Ok(());
}
}
}
let scale = render_state.get_scale();
let target_surface = surface_id.unwrap_or(SurfaceId::Strokes);
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
let selrect = shape.selrect;
let path_transform = shape.to_path_transform();
let svg_attrs = shape.svg_attrs.as_ref();
let blur = if skip_blur {
None
} else {
shape.image_filter(1.)
};
if !matches!(shape.shape_type, Type::Text(_))
&& shadow.is_none()
&& matches!(stroke.fill, Fill::Image(_))
{
if let Fill::Image(image_fill) = &stroke.fill {
draw_image_stroke_in_container(
render_state,
shape,
stroke,
image_fill,
antialias,
target_surface,
)?;
}
} else {
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let paint = stroke.to_paint(&selrect, svg_attrs, antialias);
draw_stroke_on_rect(
canvas,
stroke,
&selrect,
&shape_type.corners(),
&paint,
scale,
shadow,
blur.as_ref(),
Stroke::per_side_profile(shape.visible_strokes()),
antialias,
);
}
Type::Circle => {
let paint = stroke.to_paint(&selrect, svg_attrs, antialias);
draw_stroke_on_circle(
canvas,
stroke,
&selrect,
&paint,
scale,
shadow,
blur.as_ref(),
antialias,
);
}
Type::Text(_) => {}
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(path) = shape_type.path() {
let is_open = path.is_open();
let lod_stroke = stroke.path_lod_at_scale(is_open, scale);
let stroke = &lod_stroke;
let mut paint =
stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
// Apply outset by increasing stroke width
if let Some(s) = outset.filter(|&s| s > 0.0) {
let current_width = paint.stroke_width();
// Path stroke kinds are built differently:
// - Center uses the stroke width directly.
// - Inner/Outer use a doubled width plus clipping/clearing logic.
// Compensate outset so visual growth is comparable across kinds.
let outset_growth = match stroke.render_kind(is_open) {
StrokeKind::Center => s * 2.0,
StrokeKind::Inner | StrokeKind::Outer => s * 4.0,
};
paint.set_stroke_width(current_width + outset_growth);
}
draw_stroke_on_path(
canvas,
stroke,
path,
&paint,
path_transform.as_ref(),
shadow,
blur.as_ref(),
svg_attrs,
antialias,
);
}
}
_ => unreachable!("This shape should not have strokes"),
}
}
Ok(())
}
// Render text paths (unused)
#[allow(dead_code)]
pub fn render_text_paths(
render_state: &mut RenderState,
shape: &Shape,
stroke: &Stroke,
paths: &Vec<(skia::Path, skia::Paint)>,
surface_id: Option<SurfaceId>,
shadow: Option<&ImageFilter>,
antialias: bool,
) {
let canvas = render_state
.surfaces
.canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::Strokes));
let selrect = &shape.selrect;
let svg_attrs = shape.svg_attrs.as_ref();
let mut paint: skia_safe::Handle<_> =
stroke.to_text_stroked_paint(false, selrect, svg_attrs, antialias);
if let Some(filter) = shadow {
paint.set_image_filter(filter.clone());
}
match stroke.render_kind(false) {
StrokeKind::Inner => {
for (path, _) in paths {
draw_inner_stroke_path(canvas, path, &paint, None, antialias);
}
}
StrokeKind::Center => {
for (path, _) in paths {
canvas.draw_path(path, &paint);
}
}
StrokeKind::Outer => {
for (path, _) in paths {
draw_outer_stroke_path(canvas, path, &paint, None, antialias);
}
}
}
}
#[cfg(test)]
mod per_side_tests {
use super::*;
use crate::shapes::StrokeStyle;
/// Outer-aligned radii for a 220x140 rect, radius 20, widths [10, 20, 15, 5]:
/// each corner grows by its two adjacent widths, so they are wider than tall.
fn outer_radii() -> Corners {
[
skia::Point::new(25.0, 30.0),
skia::Point::new(40.0, 30.0),
skia::Point::new(40.0, 35.0),
skia::Point::new(25.0, 35.0),
]
}
/// Reads a wedge path back as a flat list of points.
fn wedge_points(path: &skia::Path) -> Vec<(f32, f32)> {
path.points().iter().map(|p| (p.x, p.y)).collect()
}
#[test]
fn a_wedge_reads_the_radius_along_the_side_it_runs_on() {
// The outer rect for an outer-aligned stroke: each edge pushed out by
// that side's width.
let outer = Rect::from_ltrb(-5.0, -10.0, 240.0, 155.0);
let profile = [10.0, 20.0, 15.0, 5.0];
// Only the right side is painted, as one stroke of a per-side colour set.
let wedge = side_wedges(&outer, Some(outer_radii()), profile, [0.0, 20.0, 0.0, 0.0])
.expect("the right side is painted, so it gets a wedge");
// The corner ellipses are 40 wide and 30/35 tall, and a vertical side
// reads the tall one, so the wedge meets the top edge at the corner.
assert_eq!(
wedge_points(&wedge),
vec![(240.0, 0.0), (240.0, 150.0), (180.0, 105.0), (180.0, 30.0),]
);
}
/// Whether `point` sits on the infinite line through `a` and `b`.
fn on_line(a: (f32, f32), b: (f32, f32), point: (f32, f32)) -> bool {
let cross = (b.0 - a.0) * (point.1 - a.1) - (b.1 - a.1) * (point.0 - a.0);
cross.abs() < 1e-3
}
#[test]
fn adjacent_wedges_share_one_miter_line() {
let outer = Rect::from_ltrb(-5.0, -10.0, 240.0, 155.0);
let profile = [10.0, 20.0, 15.0, 5.0];
let radii = Some(outer_radii());
let top = wedge_points(
&side_wedges(&outer, radii, profile, [10.0, 0.0, 0.0, 0.0]).expect("top wedge"),
);
let right = wedge_points(
&side_wedges(&outer, radii, profile, [0.0, 20.0, 0.0, 0.0]).expect("right wedge"),
);
// Both edges facing the top-right corner run through its centre of
// curvature, so the two sides abut with neither a gap nor an overlap.
let centre = (240.0 - 40.0, -10.0 + 30.0);
assert!(on_line(top[1], top[2], centre), "top edge missed: {top:?}");
assert!(
on_line(right[0], right[3], centre),
"right edge missed: {right:?}"
);
// And they are the same line, not merely two lines through one point.
assert!(
on_line(top[1], top[2], right[0]),
"not collinear: {right:?}"
);
}
/// Inner-aligned pattern for a 235x175 rect with a nominal 30/30 dash:
/// neither side holds whole periods, so each stretches by its own amount.
fn dashed_pattern() -> skia::Path {
let rect = Rect::from_ltrb(0.0, 0.0, 235.0, 175.0);
let widths = [16.0, 16.0, 16.0, 24.0];
let mut stroke = Stroke::new_inner_stroke(
16.0,
StrokeStyle::Dashed,
None,
None,
Some(30.0),
Some(30.0),
);
stroke.widths = Some(widths);
let inner = Rect::from_ltrb(24.0, 16.0, 235.0 - 16.0, 175.0 - 16.0);
expand_side_patterns(
&stroke,
StrokeStyle::Dashed,
&rect,
&rect,
&inner,
None,
widths,
true,
)
.expect("dashed sides must produce a pattern")
}
#[test]
fn each_side_starts_and_ends_on_a_dash() {
let pattern = dashed_pattern();
// Top side, over x. Sampled clear of the left and right strips so the
// reading is the top side's own pattern.
assert!(pattern.contains((30.0, 8.0)), "top must open with a dash");
assert!(pattern.contains((210.0, 8.0)), "top must close with a dash");
// Right side, over y, at the vertical stroke line (235 - 16 / 2).
assert!(
pattern.contains((227.0, 30.0)),
"right must open with a dash"
);
assert!(
pattern.contains((227.0, 145.0)),
"right must close with a dash"
);
}
#[test]
fn sides_keep_their_own_gaps() {
let pattern = dashed_pattern();
// 235 stretches to 4 dashes of 33.57: the first gap is 33.57..67.14.
assert!(!pattern.contains((50.0, 8.0)), "top gap was filled");
// 175 stretches to 3 dashes of 35: the first gap is 35..70.
assert!(!pattern.contains((227.0, 50.0)), "right gap was filled");
}
}