From 13026958ff82374601892b1e1d1451d5f2ebceec Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Tue, 22 Sep 2026 14:11:46 +0200 Subject: [PATCH] :construction: Fix border style when strokes per side are different --- render-wasm/src/render/strokes.rs | 152 +++++++++++++++++- ...ect_with_per_side_dashed_inner_stroke.snap | 8 + render-wasm/src/render/svg/tests.rs | 73 +++++++++ render-wasm/src/shapes/strokes.rs | 83 +++++----- 4 files changed, 273 insertions(+), 43 deletions(-) create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dashed_inner_stroke.snap diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 4b7e2dcc61..9f895721aa 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -2,7 +2,7 @@ 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, Stroke, StrokeCap, StrokeKind, SvgAttrs, Type, + Path, Shape, Stroke, StrokeCap, StrokeKind, StrokeStyle, SvgAttrs, Type, }; use skia_safe::{self as skia, ImageFilter, RRect}; @@ -105,8 +105,11 @@ pub(super) fn draw_stroke_on_rect( /// 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. -/// The band is filled with the stroke fill; dashed/dotted patterns are not -/// supported per side and render solid. +/// +/// Solid strokes fill that band directly. Dashed/dotted/mixed strokes are +/// expanded into filled geometry (a band cannot carry a `PathEffect`, and +/// `SkSVGDevice` drops path effects), then clipped to the band so every side +/// keeps its own thickness. #[allow(clippy::too_many_arguments)] fn draw_per_side_stroke_on_rect( canvas: &skia::Canvas, @@ -148,6 +151,54 @@ fn draw_per_side_stroke_on_rect( fill_paint.set_path_effect(None); fill_paint.set_anti_alias(antialias); + let band = per_side_band_path(corners, outer, inner, has_hole, out_f, in_f, widths); + + // A collapsed band fills the whole outer rect; there is no border ring to + // dash, so keep the solid fill. + if stroke.style == StrokeStyle::Solid || !has_hole { + canvas.draw_path(&band, &fill_paint); + return; + } + + // Sharp (unrounded) bands are exactly the union of the four side strips, + // so the pattern needs no boolean trim. Rounded corners curve the band + // beyond the straight strips, so intersect to trim the overflow. + let rounded = matches!(corners, Some(radii) if radii.iter().any(|c| c.x > 0.0 || c.y > 0.0)); + + let pattern = per_side_pattern_path(stroke, rect, outer, widths, in_f - out_f, antialias).map( + |pattern| { + if rounded { + pattern + .op(&band, skia::PathOp::Intersect) + .unwrap_or(pattern) + } else { + pattern + } + }, + ); + + match pattern { + Some(pattern) => canvas.draw_path(&pattern, &fill_paint), + // Boolean/geometry failure: fall back to the solid band rather than + // dropping the stroke entirely. + None => canvas.draw_path(&band, &fill_paint), + }; +} + +/// Even-odd path of the solid per-side band: the outer rect minus the inner +/// rect, with corner radii grown/shrunk like CSS borders. +#[allow(clippy::too_many_arguments)] +fn per_side_band_path( + corners: &Option, + outer: Rect, + inner: Rect, + has_hole: bool, + out_f: f32, + in_f: f32, + widths: [f32; 4], +) -> skia::Path { + let [top, right, bottom, left] = widths; + let mut pb = skia::PathBuilder::new(); match corners { Some(radii) => { @@ -187,7 +238,100 @@ fn draw_per_side_stroke_on_rect( let mut path = pb.detach(); path.set_fill_type(skia::PathFillType::EvenOdd); - canvas.draw_path(&path, &fill_paint); + path +} + +/// Filled dash/dot geometry for a per-side stroke. +/// +/// Strokes each side's centerline with that side's own thickness (so thin and +/// thick sides get proportional patterns) and unions the four sides, keeping +/// overlapping corners from doubling the fill alpha. The result spans the same +/// region as the solid band, minus the gaps between dashes/dots. +fn per_side_pattern_path( + stroke: &Stroke, + rect: &Rect, + outer: Rect, + widths: [f32; 4], + inward: f32, + antialias: bool, +) -> Option { + let [top, right, bottom, left] = widths; + + // Centerline of each side band. `inward > 0` shifts towards the shape + // center (Inner alignment); `inward < 0` shifts outward (Outer). + let cl = rect.left + left * inward / 2.0; + let ct = rect.top + top * inward / 2.0; + let cr = rect.right - right * inward / 2.0; + let cb = rect.bottom - bottom * inward / 2.0; + + let sides = [ + (Point::new(outer.left, ct), Point::new(outer.right, ct), top), + ( + Point::new(cr, outer.top), + Point::new(cr, outer.bottom), + right, + ), + ( + Point::new(outer.left, cb), + Point::new(outer.right, cb), + bottom, + ), + ( + Point::new(cl, outer.top), + Point::new(cl, outer.bottom), + left, + ), + ]; + + let mut acc: Option = None; + for (start, end, thickness) in sides { + if thickness <= 0.0 { + continue; + } + let Some(outline) = per_side_side_outline(stroke, start, end, thickness, antialias) else { + continue; + }; + acc = Some(match acc { + Some(path) => path.op(&outline, skia::PathOp::Union).unwrap_or(path), + None => outline, + }); + } + acc +} + +/// Expands one side's centerline into its filled dash/dot region. +/// +/// Uses `Center` kind on a synthetic stroke whose width is the side thickness +/// so the pattern sizes itself from that side (dot radius = thickness / 2, +/// default dash = thickness + gap). The paint carries no shader: only the +/// geometry is used, and the caller fills it with the real stroke paint. +fn per_side_side_outline( + stroke: &Stroke, + start: Point, + end: Point, + thickness: f32, + antialias: bool, +) -> Option { + let mut side = stroke.clone(); + side.kind = StrokeKind::Center; + side.width = thickness; + side.widths = None; + + let mut paint = skia::Paint::default(); + paint.set_style(skia::PaintStyle::Stroke); + paint.set_stroke_width(thickness); + paint.set_anti_alias(antialias); + paint.set_stroke_cap(skia::paint::Cap::Butt); + paint.set_path_effect(side.path_effect()); + + let mut pb = skia::PathBuilder::new(); + pb.move_to(start); + pb.line_to(end); + let line = pb.detach(); + + let mut outline = skia::Path::default(); + skia::path_utils::fill_path_with_paint(&line, &paint, &mut outline, None, None) + .then_some(outline) } #[allow(clippy::too_many_arguments)] diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dashed_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dashed_inner_stroke.snap new file mode 100644 index 0000000000..45718f4771 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dashed_inner_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 1ecdc699c0..61716556df 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -1367,6 +1367,79 @@ fn exports_rect_with_per_side_solid_inner_stroke() { insta::assert_snapshot!(svg); } +#[test] +fn exports_rect_with_per_side_dashed_inner_stroke() { + // Regression for #10841: per-side strokes used to ignore the border style + // and always render solid. + let mut pool = ShapesPool::new(); + let id = uid(1); + let mut stroke = dashed_stroke( + StrokeKind::Inner, + 20.0, + skia::Color::from_rgb(0x10, 0x40, 0xff), + ); + stroke.widths = Some([4.0, 12.0, 24.0, 40.0]); // top, right, bottom, left + stroke.dash = Some(8.0); + stroke.gap = Some(6.0); + add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 140.0, 100.0), stroke); + + let svg = render(&pool, id); + // The solid band is a single even-odd ring (two subpaths); a dashed stroke + // expands into one subpath per dash. + assert!( + svg.matches('M').count() > 10, + "dashed per-side stroke must expand to several dash segments: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_per_side_dotted_inner_stroke() { + // #10841: dotted must also survive per-side rendering. + let mut pool = ShapesPool::new(); + let id = uid(1); + let mut stroke = dotted_stroke( + StrokeKind::Inner, + 20.0, + skia::Color::from_rgb(0x10, 0x40, 0xff), + ); + stroke.widths = Some([4.0, 12.0, 24.0, 40.0]); // top, right, bottom, left + add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 140.0, 100.0), stroke); + + let svg = render(&pool, id); + assert!( + svg.matches('M').count() > 10, + "dotted per-side stroke must expand to several dot segments: {svg}" + ); + assert!( + svg.contains('Q'), + "dotted per-side stroke must expand to round dots, not a solid band: {svg}" + ); +} + +#[test] +fn exports_rect_with_only_top_side_dashed_stroke() { + // Exact #10841 reproduction: add per-side strokes, keep only the top side, + // then pick a border style. The zero-width sides must be skipped. + let mut pool = ShapesPool::new(); + let id = uid(1); + let mut stroke = dashed_stroke( + StrokeKind::Inner, + 8.0, + skia::Color::from_rgb(0x10, 0x40, 0xff), + ); + stroke.widths = Some([8.0, 0.0, 0.0, 0.0]); // top only + stroke.dash = Some(8.0); + stroke.gap = Some(6.0); + add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 80.0, 40.0), stroke); + + let svg = render(&pool, id); + assert!( + svg.matches('M').count() > 3, + "a single dashed side must expand to several dash segments: {svg}" + ); +} + #[test] fn exports_rect_with_solid_center_stroke() { let mut pool = ShapesPool::new(); diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs index 2a30b57e77..22cbf25cc6 100644 --- a/render-wasm/src/shapes/strokes.rs +++ b/render-wasm/src/shapes/strokes.rs @@ -354,49 +354,54 @@ impl Stroke { } } - if self.style != StrokeStyle::Solid { - let path_effect = match self.style { - StrokeStyle::Dotted => { - let width = match self.kind { - StrokeKind::Inner => self.width, - StrokeKind::Center => self.width / 2.0, - StrokeKind::Outer => self.width, - }; - let circle_path = { - let mut pb = skia::PathBuilder::new(); - pb.add_circle((0.0, 0.0), width, None); - pb.detach() - }; - let advance = self.width + 5.0; - skia::PathEffect::path_1d( - &circle_path, - advance, - 0.0, - skia::path_1d_path_effect::Style::Translate, - ) - } - StrokeStyle::Dashed => { - let dash = self.dash.unwrap_or(self.width + 10.); - let gap = self.gap.unwrap_or(self.width + 10.); - skia::PathEffect::dash(&[dash, gap], 0.) - } - StrokeStyle::Mixed => skia::PathEffect::dash( - &[ - self.width + 5., - self.width + 5., - self.width + 1., - self.width + 5., - ], - 0., - ), - _ => None, - }; - paint.set_path_effect(path_effect); - } + paint.set_path_effect(self.path_effect()); paint } + /// Dash/dot `PathEffect` for the stroke style, or `None` for solid. + /// + /// Dotted sizes its stamp from `kind` (Center dots are half the stroke + /// width); dashed/mixed derive their default pattern from the stroke width. + pub fn path_effect(&self) -> Option { + match self.style { + StrokeStyle::Solid => None, + StrokeStyle::Dotted => { + let radius = match self.kind { + StrokeKind::Inner => self.width, + StrokeKind::Center => self.width / 2.0, + StrokeKind::Outer => self.width, + }; + let circle_path = { + let mut pb = skia::PathBuilder::new(); + pb.add_circle((0.0, 0.0), radius, None); + pb.detach() + }; + let advance = self.width + 5.0; + skia::PathEffect::path_1d( + &circle_path, + advance, + 0.0, + skia::path_1d_path_effect::Style::Translate, + ) + } + StrokeStyle::Dashed => { + let dash = self.dash.unwrap_or(self.width + 10.); + let gap = self.gap.unwrap_or(self.width + 10.); + skia::PathEffect::dash(&[dash, gap], 0.) + } + StrokeStyle::Mixed => skia::PathEffect::dash( + &[ + self.width + 5., + self.width + 5., + self.width + 1., + self.width + 5., + ], + 0., + ), + } + } + pub fn to_stroked_paint( &self, is_open: bool,