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, 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) -> 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, 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, profile: [f32; 4], widths: [f32; 4], ) -> Option { 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, widths: [f32; 4], antialias: bool, ) -> Option { 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 { 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, antialias: bool, outset: Option, ) -> 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, antialias: bool, bypass_filter: bool, outset: Option, ) -> 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 = 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, shadow: Option<&ImageFilter>, antialias: bool, outset: Option, ) -> 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, shadow: Option<&ImageFilter>, antialias: bool, bypass_filter: bool, skip_blur: bool, outset: Option, ) -> 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, 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"); } }