🎉 Render different stroke styles per side (#11883)

* 🎉 Render different stroke styles per side

* 🔧 Stabilise per-side stroke snapshots against clip id churn

SVG clip ids come from a counter that lives for the whole test
process, so a snapshot holding one depends on how many clips other
tests emitted first. The masked-group export tests that landed on
develop shifted that count and broke two per-side snapshots, whose
only diff was the id.

Route the remaining per-side snapshots through with_stable_clip_ids,
which renumbers ids in order of appearance. No snapshot in the suite
now carries a generated id.

AI-assisted-by: claude-opus-5
This commit is contained in:
Belén Albeza 2026-09-24 13:20:33 +02:00 committed by GitHub
parent acd146f6f4
commit a34b4011cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1064 additions and 53 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, Side, Stroke, StrokeCap, StrokeKind, StrokeStyle, SvgAttrs, Type,
};
use skia_safe::{self as skia, ImageFilter, RRect};
@ -22,6 +22,7 @@ pub(super) fn draw_stroke_on_rect(
scale: f32,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
miter: Option<[f32; 4]>,
antialias: bool,
) {
let stroke_rect = stroke.aligned_rect(rect, scale);
@ -33,7 +34,9 @@ pub(super) fn draw_stroke_on_rect(
// 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, antialias);
draw_per_side_stroke_on_rect(
canvas, stroke, rect, corners, &paint, widths, scale, miter, antialias,
);
return;
}
@ -105,8 +108,8 @@ 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.
/// 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,
@ -115,9 +118,23 @@ fn draw_per_side_stroke_on_rect(
corners: &Option<Corners>,
paint: &skia::Paint,
widths: [f32; 4],
scale: f32,
miter: Option<[f32; 4]>,
antialias: bool,
) {
let [top, right, bottom, left] = widths;
// 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.
@ -149,7 +166,7 @@ fn draw_per_side_stroke_on_rect(
fill_paint.set_anti_alias(antialias);
let mut pb = skia::PathBuilder::new();
match corners {
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.
@ -176,18 +193,279 @@ fn draw_per_side_stroke_on_rect(
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();
}
}
let mut path = pb.detach();
path.set_fill_type(skia::PathFillType::EvenOdd);
canvas.draw_path(&path, &fill_paint);
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::Path::default();
if skia::path_utils::fill_path_with_paint(
&line_path,
&stamp_paint,
&mut outline,
None,
None,
) {
pb.add_path(&outline);
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)]
@ -526,6 +804,7 @@ fn draw_image_stroke_in_container(
scale,
None,
None,
Stroke::per_side_profile(shape.visible_strokes()),
antialias,
);
}
@ -845,6 +1124,7 @@ fn render_merged(
scale,
None,
blur_filter.as_ref(),
Stroke::per_side_profile(shape.visible_strokes()),
antialias,
);
}
@ -1006,6 +1286,7 @@ fn render_single_internal(
scale,
shadow,
blur.as_ref(),
Stroke::per_side_profile(shape.visible_strokes()),
antialias,
);
}
@ -1103,3 +1384,135 @@ pub fn render_text_paths(
}
}
}
#[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");
}
}

View File

@ -7,9 +7,9 @@ use skia_safe as skia;
use crate::globals::TestRenderResourcesGuard;
use crate::render::{FontStore, RenderResources};
use crate::shapes::{
Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, Paragraph, Path, Rect, Segment,
SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, TextDirection, TextSpan,
Type,
make_corners, Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, Paragraph, Path,
Rect, Segment, SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent,
TextDirection, TextSpan, Type,
};
use crate::state::ShapesPool;
use crate::utils::uuid_from_u32_quartet;
@ -368,17 +368,32 @@ pub(super) fn add_text_with_fills(
shape.set_shape_type(Type::Text(content));
}
/// Adds a rectangle with a single solid stroke (no fill).
/// Adds a rectangle with a single stroke and no fill.
pub(super) fn add_stroked_rect(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
bounds: (f32, f32, f32, f32),
stroke: Stroke,
) {
add_stroked_rect_with_radius(pool, id, parent, bounds, 0.0, stroke);
}
/// Adds a rectangle with a single stroke, no fill, and every corner rounded to
/// `radius`. A radius of zero leaves the corners square.
pub(super) fn add_stroked_rect_with_radius(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
radius: f32,
stroke: Stroke,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Rect(Rect::default()));
shape.set_shape_type(Type::Rect(Rect {
corners: make_corners((radius, radius, radius, radius)),
}));
shape.set_selrect(l, t, r, b);
shape.set_fills(vec![]);
shape.add_stroke(stroke);
@ -402,6 +417,32 @@ pub(super) fn add_empty_fill_closed_path(
add_path_with_fills(pool, id, parent, (l, t, r, b), segments, vec![]);
}
/// Adds a rectangle carrying one stroke per side, each with its own colour, as
/// a design gets per-side colours today. `radius` of zero leaves corners square.
pub(super) fn add_rect_with_per_side_strokes(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
radius: f32,
sides: [(f32, skia::Color); 4],
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Rect(Rect {
corners: make_corners((radius, radius, radius, radius)),
}));
shape.set_selrect(l, t, r, b);
shape.set_fills(vec![]);
for (index, (width, color)) in sides.into_iter().enumerate() {
let mut widths = [0.0; 4];
widths[index] = width;
let mut stroke = solid_stroke(StrokeKind::Inner, width, color);
stroke.widths = Some(widths);
shape.add_stroke(stroke);
}
}
/// Adds a closed rectangular path with a single solid stroke (no fill).
pub(super) fn add_stroked_closed_path(
pool: &mut ShapesPool,
@ -518,6 +559,34 @@ pub(super) fn add_image_text(
);
}
/// Clip ids come from a process-wide counter, so a snapshot holding them would
/// depend on what else ran first. Renumbers them in order of appearance.
pub(super) fn with_stable_clip_ids(svg: &str) -> String {
const PREFIX: &str = "f0_cl_";
let mut ids: Vec<String> = Vec::new();
let mut rest = svg;
while let Some(at) = rest.find(PREFIX) {
let tail = &rest[at + PREFIX.len()..];
let end = tail
.find(|c: char| !c.is_ascii_hexdigit())
.unwrap_or(tail.len());
let id = format!("{PREFIX}{}", &tail[..end]);
if !ids.contains(&id) {
ids.push(id);
}
rest = &tail[end..];
}
// Longest first, so `f0_cl_3` never eats the head of `f0_cl_3a`.
let mut order: Vec<usize> = (0..ids.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse(ids[i].len()));
let mut out = svg.to_string();
for i in order {
out = out.replace(&ids[i], &format!("clip{i}"));
}
out
}
pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
render_with(pool, root, |_resources| {})
}

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="280" height="220" viewBox="0 0 280 220">
<path fill="#1040FF" transform="translate(20 20)" d="M-20 -2L-6 -2L-6 2L-20 2M8 -2L22 -2L22 2L8 2M36 -2L50 -2L50 2L36 2M64 -2L78 -2L78 2L64 2M92 -2L106 -2L106 2L92 2M120 -2L134 -2L134 2L120 2M148 -2L162 -2L162 2L148 2M176 -2L190 -2L190 2L176 2M204 -2L218 -2L218 2L204 2M232 -2L246 -2L246 2L232 2M246 -2L246 19.555553L234 19.555553L234 -2M246 41.111107L246 62.666656L234 62.666656L234 41.111107M246 84.222214L246 105.77777L234 105.77777L234 84.222214M246 127.33331L246 148.88887L234 148.88887L234 127.33331M246 170.44443L246 191.99998L234 191.99998L234 170.44443M246 192L208 192L208 168L246 168M170 192L132 192L132 168L170 168M94 192L56 192L56 168L94 168M18 192L-20 192L-20 168L18 168M-19.999998 192L-19.999998 127.33334L19.999998 127.33334L19.999998 192M-19.999998 62.666687L-19.999998 -1.9999847L19.999998 -1.9999847L19.999998 62.666687"/>
</svg>

View File

@ -0,0 +1,31 @@
---
source: src/render/svg/tests.rs
expression: with_stable_clip_ids(&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="220" height="140" viewBox="0 0 220 140">
<clipPath id="clip0">
<path d="M0 140L0 0L5 10L5 125L0 140Z"/>
</clipPath>
<g clip-path="url(#clip0)">
<path fill="#F59E0B" d="M0 0L220 0L220 140L0 140L0 0ZM5 10L200 10L200 125L5 125L5 10Z" fill-rule="evenodd"/>
</g>
<clipPath id="clip1">
<path d="M220 140L0 140L5 125L200 125L220 140Z"/>
</clipPath>
<g clip-path="url(#clip1)">
<path fill="#2563EB" d="M0 0L220 0L220 140L0 140L0 0ZM5 10L200 10L200 125L5 125L5 10Z" fill-rule="evenodd"/>
</g>
<clipPath id="clip2">
<path d="M220 0L220 140L200 125L200 10L220 0Z"/>
</clipPath>
<g clip-path="url(#clip2)">
<path fill="#059669" d="M0 0L220 0L220 140L0 140L0 0ZM5 10L200 10L200 125L5 125L5 10Z" fill-rule="evenodd"/>
</g>
<clipPath id="clip3">
<path d="M0 0L220 0L200 10L5 10L0 0Z"/>
</clipPath>
<g clip-path="url(#clip3)">
<path fill="#E11D48" d="M0 0L220 0L220 140L0 140L0 0ZM5 10L200 10L200 125L5 125L5 10Z" fill-rule="evenodd"/>
</g>
</svg>

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="240" height="180" viewBox="0 0 240 180">
<path fill="#1040FF" d="M0 0L26.666668 0L26.666668 16L0 16M53.333336 0L80 0L80 16L53.333336 16M106.66667 0L133.33334 0L133.33334 16L106.66667 16M160 0L186.66667 0L186.66667 16L160 16M213.33334 0L240 0L240 16L213.33334 16M240 180L213.33333 180L213.33333 164L240 164M186.66666 180L160 180L160 164L186.66666 164M133.33333 180L106.66666 180L106.66666 164L133.33333 164M80 180L53.333328 180L53.333328 164L80 164M26.666656 180L0 180L0 164L26.666656 164"/>
</svg>

View File

@ -3164,3 +3164,181 @@ fn exports_closed_path_with_dotted_outer_image_stroke() {
assert_evenodd_stroke_clip(&svg);
insta::assert_snapshot!(svg);
}
/// A per-side dash or dot pattern is drawn as its own filled path, so the
/// exported SVG carries far more than the two contours of a solid band.
fn assert_per_side_pattern(svg: &str, fill: &str) {
let needle = format!("<path fill=\"{fill}\"");
let from = svg
.find(&needle)
.unwrap_or_else(|| panic!("no stroke path filled with {fill}: {svg}"));
let d_start = svg[from..].find(" d=\"").expect("stroke path has no d") + from + 4;
let d_end = svg[d_start..].find('"').expect("unterminated d") + d_start;
assert!(
svg[d_start..d_end].matches('M').count() > 2,
"pattern must leave more than the two band contours: {svg}"
);
}
#[test]
fn exports_rect_with_per_side_dashed_center_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let mut stroke = dashed_stroke(
StrokeKind::Center,
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, 240.0, 180.0), stroke);
let svg = render(&pool, id);
assert_per_side_pattern(&svg, "#1040FF");
insta::assert_snapshot!(with_stable_clip_ids(&svg));
}
#[test]
fn exports_rect_with_per_side_dotted_center_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let mut stroke = dotted_stroke(
StrokeKind::Center,
16.0,
skia::Color::from_rgb(0x10, 0x40, 0xff),
);
stroke.widths = Some([6.0, 16.0, 6.0, 16.0]);
add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 240.0, 180.0), stroke);
let svg = render(&pool, id);
assert_per_side_pattern(&svg, "#1040FF");
insta::assert_snapshot!(with_stable_clip_ids(&svg));
}
#[test]
fn exports_rect_with_per_side_dotted_inner_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
// Inner dots have radius = width against an advance of width + 5, so they
// only stay discrete below width 5.
let mut stroke = dotted_stroke(
StrokeKind::Inner,
4.0,
skia::Color::from_rgb(0x10, 0x40, 0xff),
);
stroke.widths = Some([2.0, 4.0, 2.0, 4.0]);
add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 60.0, 40.0), stroke);
let svg = render(&pool, id);
assert_per_side_pattern(&svg, "#1040FF");
insta::assert_snapshot!(with_stable_clip_ids(&svg));
}
#[test]
fn exports_rounded_rect_with_per_side_dashed_outer_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let mut stroke = dashed_stroke(
StrokeKind::Outer,
12.0,
skia::Color::from_rgb(0x10, 0x40, 0xff),
);
stroke.widths = Some([6.0, 12.0, 18.0, 24.0]);
add_stroked_rect_with_radius(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 240.0, 180.0),
24.0,
stroke,
);
let svg = render(&pool, id);
assert_per_side_pattern(&svg, "#1040FF");
insta::assert_snapshot!(with_stable_clip_ids(&svg));
}
#[test]
fn per_side_dashed_stroke_skips_zero_width_sides() {
let mut pool = ShapesPool::new();
let id = uid(1);
let mut stroke = dashed_stroke(
StrokeKind::Inner,
16.0,
skia::Color::from_rgb(0x10, 0x40, 0xff),
);
// Only the top and bottom sides are drawn.
stroke.widths = Some([16.0, 0.0, 16.0, 0.0]);
add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 240.0, 180.0), stroke);
let svg = render(&pool, id);
assert_per_side_pattern(&svg, "#1040FF");
insta::assert_snapshot!(with_stable_clip_ids(&svg));
}
/// Widths and colours for a rect whose four sides are four separate strokes.
fn per_side_colour_sides() -> [(f32, skia::Color); 4] {
[
(10.0, skia::Color::from_rgb(0xe1, 0x1d, 0x48)),
(20.0, skia::Color::from_rgb(0x05, 0x96, 0x69)),
(15.0, skia::Color::from_rgb(0x25, 0x63, 0xeb)),
(5.0, skia::Color::from_rgb(0xf5, 0x9e, 0x0b)),
]
}
#[test]
fn miters_a_rect_whose_sides_are_separate_strokes() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_rect_with_per_side_strokes(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 220.0, 140.0),
0.0,
per_side_colour_sides(),
);
let svg = render(&pool, id);
// Each side is clipped to its wedge, so every stroke carries a clip path.
assert_eq!(
svg.matches("<clipPath").count(),
4,
"each side needs its own miter clip: {svg}"
);
insta::assert_snapshot!(with_stable_clip_ids(&svg));
}
#[test]
fn miters_a_rounded_rect_whose_sides_are_separate_strokes() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_rect_with_per_side_strokes(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 220.0, 140.0),
20.0,
per_side_colour_sides(),
);
insta::assert_snapshot!(with_stable_clip_ids(&render(&pool, id)));
}
#[test]
fn a_lone_per_side_stroke_is_not_mitered() {
let mut pool = ShapesPool::new();
let id = uid(1);
let mut stroke = solid_stroke(
StrokeKind::Inner,
10.0,
skia::Color::from_rgb(0x10, 0x40, 0xff),
);
stroke.widths = Some([10.0, 20.0, 15.0, 5.0]);
add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 220.0, 140.0), stroke);
let svg = render(&pool, id);
assert!(
!svg.contains("<clipPath"),
"one stroke has nothing to miter against, so it stays a plain band: {svg}"
);
}

View File

@ -1225,6 +1225,7 @@ pub(super) fn paint_svg_stroke_silhouette(
scale,
None,
None,
Stroke::per_side_profile(shape.visible_strokes()),
true,
);
return true;
@ -1280,6 +1281,7 @@ fn draw_stroke_geometry(canvas: &Canvas, scale: f32, shape: &Shape, stroke: &Str
scale,
None,
None,
Stroke::per_side_profile(shape.visible_strokes()),
true,
);
}

View File

@ -34,6 +34,24 @@ pub enum StrokeCap {
Square,
}
/// A rect side, in the order `Stroke::widths` stores them. Side `n` runs
/// clockwise from corner `n` in `Corners` order, sharing its index.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Top,
Right,
Bottom,
Left,
}
impl Side {
pub const ALL: [Side; 4] = [Side::Top, Side::Right, Side::Bottom, Side::Left];
pub fn index(self) -> usize {
self as usize
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StrokeKind {
Inner,
@ -53,8 +71,8 @@ pub struct Stroke {
// default `width + 10` pattern to keep existing designs visually identical.
pub dash: Option<f32>,
pub gap: Option<f32>,
// Per-side widths [top, right, bottom, left] for rects and frames.
// `None` means the uniform `width` applies to all sides.
// Per-side widths in `Side` order. `None` means the uniform `width` applies
// to all sides.
pub widths: Option<[f32; 4]>,
}
@ -147,6 +165,23 @@ impl Stroke {
}
}
/// The widest each side reaches across a shape's per-side strokes, which is
/// the border box they miter against. `None` when at most one stroke needs it.
pub fn per_side_profile<'a>(strokes: impl Iterator<Item = &'a Stroke>) -> Option<[f32; 4]> {
let mut profile = [0.0f32; 4];
let mut per_side_strokes = 0;
for stroke in strokes {
let Some(widths) = stroke.per_side_widths() else {
continue;
};
per_side_strokes += 1;
for (side, width) in profile.iter_mut().zip(widths) {
*side = side.max(width);
}
}
(per_side_strokes > 1).then_some(profile)
}
pub fn bounds_width(&self, is_open: bool) -> f32 {
match self.render_kind(is_open) {
StrokeKind::Inner => 0.,
@ -355,48 +390,120 @@ 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
}
/// On/off run lengths of this stroke's dash pattern; `None` for solid and
/// dotted. The defaults are width-derived, so each side gets its own.
pub fn dash_pattern(&self) -> Option<Vec<f32>> {
match self.style {
StrokeStyle::Dashed => {
let dash = self.dash.unwrap_or(self.width + 10.);
let gap = self.gap.unwrap_or(self.width + 10.);
Some(vec![dash, gap])
}
StrokeStyle::Mixed => Some(vec![
self.width + 5.,
self.width + 5.,
self.width + 1.,
self.width + 5.,
]),
_ => None,
}
}
/// Circle radius and centre-to-centre spacing of a dotted stroke; `None`
/// for every other style.
pub fn dot_pattern(&self) -> Option<(f32, f32)> {
if self.style != StrokeStyle::Dotted {
return None;
}
let radius = match self.kind {
StrokeKind::Inner | StrokeKind::Outer => self.width,
StrokeKind::Center => self.width / 2.0,
};
Some((radius, self.width + 5.0))
}
/// The dash/dot `PathEffect` for this stroke's style.
pub fn path_effect(&self) -> Option<skia::PathEffect> {
match self.style {
StrokeStyle::Solid => None,
StrokeStyle::Dotted => {
let (radius, advance) = self.dot_pattern()?;
Self::dot_effect(radius, advance)
}
_ => skia::PathEffect::dash(&self.dash_pattern()?, 0.),
}
}
/// The dash/dot `PathEffect` stretched so a run of `length` begins and ends
/// on a dash, as a browser fits a border. Runs too short come out solid.
pub fn path_effect_fitted(&self, length: f32) -> Option<skia::PathEffect> {
if length <= 0.0 {
return self.path_effect();
}
match self.style {
StrokeStyle::Solid => None,
StrokeStyle::Dotted => {
let (radius, _) = self.dot_pattern()?;
Self::dot_effect(radius, self.fitted_dot_advance(length)?)
}
_ => skia::PathEffect::dash(&self.fitted_dash_pattern(length)?, 0.),
}
}
/// The dash pattern scaled so `length` holds a whole number of dashes and
/// both ends land on one, instead of being cut wherever the run stops.
pub fn fitted_dash_pattern(&self, length: f32) -> Option<Vec<f32>> {
let pattern = self.dash_pattern()?;
let period: f32 = pattern.iter().sum();
let first = pattern[0];
if length <= 0.0 || period <= 0.0 || first <= 0.0 {
return Some(pattern);
}
// Whole periods that fit before the closing dash.
let repeats = ((length - first) / period).round().max(0.0);
let scale = length / (repeats * period + first);
Some(pattern.iter().map(|run| run * scale).collect())
}
/// Dot spacing scaled so a dot lands on both ends of a `length` run.
pub fn fitted_dot_advance(&self, length: f32) -> Option<f32> {
let (_, advance) = self.dot_pattern()?;
if length <= 0.0 || advance <= 0.0 {
return Some(advance);
}
Some(length / (length / advance).round().max(1.0))
}
fn dot_effect(radius: f32, advance: f32) -> Option<skia::PathEffect> {
let circle_path = {
let mut pb = skia::PathBuilder::new();
pb.add_circle((0.0, 0.0), radius, None);
pb.detach()
};
skia::PathEffect::path_1d(
&circle_path,
advance,
0.0,
skia::path_1d_path_effect::Style::Translate,
)
}
/// The same stroke narrowed to a single side's width, so that side's
/// geometry and width-derived dash pattern come out of the shared style.
pub fn with_width(&self, width: f32) -> Stroke {
Stroke {
width,
widths: None,
..self.clone()
}
}
pub fn to_stroked_paint(
&self,
is_open: bool,
@ -529,6 +636,34 @@ mod tests {
stroke
}
#[test]
fn per_side_profile_needs_two_strokes_to_miter_against() {
let one = [stroke_with_widths(Some([10.0, 0.0, 0.0, 0.0]))];
assert_eq!(Stroke::per_side_profile(one.iter()), None);
// A uniform stroke has no side of its own, so it never contributes.
let uniform = [
stroke_with_widths(Some([10.0, 0.0, 0.0, 0.0])),
stroke_with_widths(Some([4.0, 4.0, 4.0, 4.0])),
stroke_with_widths(None),
];
assert_eq!(Stroke::per_side_profile(uniform.iter()), None);
}
#[test]
fn per_side_profile_takes_the_widest_side_of_each() {
let strokes = [
stroke_with_widths(Some([10.0, 0.0, 0.0, 0.0])),
stroke_with_widths(Some([0.0, 20.0, 0.0, 0.0])),
stroke_with_widths(Some([0.0, 0.0, 15.0, 5.0])),
stroke_with_widths(Some([6.0, 0.0, 0.0, 0.0])),
];
assert_eq!(
Stroke::per_side_profile(strokes.iter()),
Some([10.0, 20.0, 15.0, 5.0])
);
}
#[test]
fn max_width_falls_back_to_uniform_width() {
let stroke = stroke_with_widths(None);
@ -561,6 +696,103 @@ mod tests {
assert_eq!(stroke.width, 4.0);
}
#[test]
fn with_width_keeps_style_and_drops_per_side_widths() {
let mut stroke = stroke_with_widths(Some([1.0, 2.0, 3.0, 4.0]));
stroke.style = StrokeStyle::Dashed;
stroke.dash = Some(7.0);
let side = stroke.with_width(3.0);
assert_eq!(side.width, 3.0);
assert_eq!(side.widths, None);
assert_eq!(side.style, StrokeStyle::Dashed);
assert_eq!(side.dash, Some(7.0));
assert_eq!(side.kind, stroke.kind);
}
#[test]
fn dash_pattern_follows_side_width() {
let mut stroke = solid_center(4.0);
assert_eq!(stroke.dash_pattern(), None);
stroke.style = StrokeStyle::Dashed;
// Defaults are width-relative, so each side gets its own pattern.
assert_eq!(stroke.dash_pattern(), Some(vec![14.0, 14.0]));
assert_eq!(
stroke.with_width(10.0).dash_pattern(),
Some(vec![20.0, 20.0])
);
// An explicit dash/gap is shared by every side.
stroke.dash = Some(6.0);
stroke.gap = Some(2.0);
assert_eq!(stroke.dash_pattern(), Some(vec![6.0, 2.0]));
assert_eq!(stroke.with_width(10.0).dash_pattern(), Some(vec![6.0, 2.0]));
stroke.style = StrokeStyle::Mixed;
assert_eq!(stroke.dash_pattern(), Some(vec![9.0, 9.0, 5.0, 9.0]));
}
#[test]
fn fitted_dash_pattern_ends_on_a_dash() {
let mut stroke = solid_center(10.0);
stroke.style = StrokeStyle::Dashed;
stroke.dash = Some(30.0);
stroke.gap = Some(30.0);
// 235 holds 4 dashes and 3 gaps once stretched; both ends are a dash.
let fitted = stroke.fitted_dash_pattern(235.0).unwrap();
assert!((4.0 * fitted[0] + 3.0 * fitted[1] - 235.0).abs() < 1e-3);
// 175 only fits 3, so the sides stretch by different amounts.
let fitted = stroke.fitted_dash_pattern(175.0).unwrap();
assert!((3.0 * fitted[0] + 2.0 * fitted[1] - 175.0).abs() < 1e-3);
// A side too short for a whole period becomes one dash, as in CSS.
let fitted = stroke.fitted_dash_pattern(40.0).unwrap();
assert!((fitted[0] - 40.0).abs() < 1e-3);
// Mixed keeps its four-run shape and still closes on its first run.
stroke.style = StrokeStyle::Mixed;
stroke.dash = None;
stroke.gap = None;
let fitted = stroke.fitted_dash_pattern(200.0).unwrap();
let period: f32 = fitted.iter().sum();
let repeats = ((200.0 - fitted[0]) / period).round();
assert!((repeats * period + fitted[0] - 200.0).abs() < 1e-3);
}
#[test]
fn fitted_dot_advance_lands_on_both_ends() {
let mut stroke = solid_center(8.0);
stroke.style = StrokeStyle::Dotted;
// Nominal advance is 13; 200 / 13 rounds to 15 steps of 13.33.
let advance = stroke.fitted_dot_advance(200.0).unwrap();
assert!((200.0 / advance - 15.0).abs() < 1e-3);
// Shorter than one advance still yields a single step, never zero.
assert!(stroke.fitted_dot_advance(4.0).unwrap() > 0.0);
}
#[test]
fn dot_pattern_halves_the_radius_only_when_centered() {
let mut stroke = solid_center(8.0);
assert_eq!(stroke.dot_pattern(), None);
stroke.style = StrokeStyle::Dotted;
assert_eq!(stroke.dot_pattern(), Some((4.0, 13.0)));
stroke.kind = StrokeKind::Inner;
assert_eq!(stroke.dot_pattern(), Some((8.0, 13.0)));
assert_eq!(stroke.with_width(2.0).dot_pattern(), Some((2.0, 7.0)));
}
#[test]
fn path_effect_is_none_only_for_solid() {
let mut stroke = solid_center(4.0);
assert!(stroke.path_effect().is_none());
for style in [StrokeStyle::Dashed, StrokeStyle::Dotted, StrokeStyle::Mixed] {
stroke.style = style;
assert!(stroke.path_effect().is_some(), "{style:?} needs an effect");
}
}
fn solid_center(width: f32) -> Stroke {
Stroke::new_center_stroke(width, StrokeStyle::Solid, None, None, None, None)
}