🚧 Fix border style when strokes per side are different

This commit is contained in:
Luis de Dios 2026-09-22 14:11:46 +02:00
parent bc3cb4bddf
commit 13026958ff
4 changed files with 273 additions and 43 deletions

View File

@ -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<Corners>,
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<skia::Path> {
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<skia::Path> = 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<skia::Path> {
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)]

View File

@ -0,0 +1,8 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="140" height="100" viewBox="0 0 140 100">
<path fill="#1040FF" d="M8 0L0 0L0 8L40 8L40 0L8 0ZM50 0L42 0L42 4L50 4L50 0ZM64 0L56 0L56 4L64 4L64 0ZM78 0L70 0L70 4L78 4L78 0ZM92 0L84 0L84 4L92 4L92 0ZM106 0L98 0L98 4L106 4L106 0ZM120 0L112 0L112 4L120 4L120 0ZM140 0L126 0L126 4L128 4L128 8L140 8L140 0ZM40 22L40 14L0 14L0 22L40 22ZM140 22L140 14L128 14L128 22L140 22ZM40 36L40 28L0 28L0 36L40 36ZM140 36L140 28L128 28L128 36L140 36ZM40 50L40 42L0 42L0 50L40 50ZM140 50L140 42L128 42L128 50L140 50ZM40 64L40 56L0 56L0 64L40 64ZM140 64L140 56L128 56L128 64L140 64ZM40 78L40 70L0 70L0 100L40 100L40 98L36 98L36 92L40 92L40 84L36 84L36 78L40 78ZM28 78L22 78L22 84L28 84L28 78ZM28 92L22 92L22 98L28 98L28 92ZM14 98L14 92L8 92L8 98L14 98ZM8 84L14 84L14 78L8 78L8 84ZM140 78L140 70L128 70L128 76L126 76L126 100L140 100L140 98L134 98L134 92L140 92L140 84L134 84L134 78L140 78ZM42 76L50 76L50 100L42 100L42 76ZM56 76L64 76L64 100L56 100L56 76ZM70 76L78 76L78 100L70 100L70 76ZM84 76L92 76L92 100L84 100L84 76ZM98 76L106 76L106 100L98 100L98 76ZM112 76L120 76L120 100L112 100L112 76Z" fill-rule="evenodd"/>
</svg>

View File

@ -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();

View File

@ -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<skia::PathEffect> {
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,