From a34b4011cf138b1520799fa0a593fe0cb7ecec47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 24 Sep 2026 13:20:33 +0200 Subject: [PATCH] :tada: Render different stroke styles per side (#11883) * :tada: Render different stroke styles per side * :wrench: 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 --- render-wasm/src/render/strokes.rs | 431 +++++++++++++++++- render-wasm/src/render/svg/fixtures.rs | 79 +++- ...ct_with_per_side_dashed_center_stroke.snap | 8 + ...ct_with_per_side_dotted_center_stroke.snap | 13 + ...ect_with_per_side_dotted_inner_stroke.snap | 13 + ...ect_with_per_side_dashed_outer_stroke.snap | 13 + ...rect_whose_sides_are_separate_strokes.snap | 31 ++ ...rect_whose_sides_are_separate_strokes.snap | 31 ++ ..._dashed_stroke_skips_zero_width_sides.snap | 8 + render-wasm/src/render/svg/tests.rs | 178 ++++++++ render-wasm/src/render/vector.rs | 2 + render-wasm/src/shapes/strokes.rs | 310 +++++++++++-- 12 files changed, 1064 insertions(+), 53 deletions(-) create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dashed_center_stroke.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dotted_center_stroke.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dotted_inner_stroke.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rounded_rect_with_per_side_dashed_outer_stroke.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__miters_a_rect_whose_sides_are_separate_strokes.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__miters_a_rounded_rect_whose_sides_are_separate_strokes.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__per_side_dashed_stroke_skips_zero_width_sides.snap diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 4b7e2dcc61..527e395c15 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, 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, 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, + 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::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 { + 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"); + } +} diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index b58766fd17..db7759102b 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -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 = 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 = (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| {}) } diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dashed_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dashed_center_stroke.snap new file mode 100644 index 0000000000..1f929033d9 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dashed_center_stroke.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dotted_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dotted_center_stroke.snap new file mode 100644 index 0000000000..35641ab796 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dotted_center_stroke.snap @@ -0,0 +1,13 @@ +--- +source: src/render/svg/tests.rs +expression: with_stable_clip_ids(&svg) +--- + + + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dotted_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dotted_inner_stroke.snap new file mode 100644 index 0000000000..61d7b1dd8d --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_dotted_inner_stroke.snap @@ -0,0 +1,13 @@ +--- +source: src/render/svg/tests.rs +expression: with_stable_clip_ids(&svg) +--- + + + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rounded_rect_with_per_side_dashed_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rounded_rect_with_per_side_dashed_outer_stroke.snap new file mode 100644 index 0000000000..a69d49538c --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rounded_rect_with_per_side_dashed_outer_stroke.snap @@ -0,0 +1,13 @@ +--- +source: src/render/svg/tests.rs +expression: with_stable_clip_ids(&svg) +--- + + + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__miters_a_rect_whose_sides_are_separate_strokes.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__miters_a_rect_whose_sides_are_separate_strokes.snap new file mode 100644 index 0000000000..91738c1f33 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__miters_a_rect_whose_sides_are_separate_strokes.snap @@ -0,0 +1,31 @@ +--- +source: src/render/svg/tests.rs +expression: with_stable_clip_ids(&svg) +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__miters_a_rounded_rect_whose_sides_are_separate_strokes.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__miters_a_rounded_rect_whose_sides_are_separate_strokes.snap new file mode 100644 index 0000000000..5865f55c57 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__miters_a_rounded_rect_whose_sides_are_separate_strokes.snap @@ -0,0 +1,31 @@ +--- +source: src/render/svg/tests.rs +expression: "with_stable_clip_ids(&render(&pool, id))" +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__per_side_dashed_stroke_skips_zero_width_sides.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__per_side_dashed_stroke_skips_zero_width_sides.snap new file mode 100644 index 0000000000..edd5f95a32 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__per_side_dashed_stroke_skips_zero_width_sides.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 3c90779d20..20d139a26b 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -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!(" 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(" 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, pub gap: Option, - // 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) -> 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> { + 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 { + 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 { + 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> { + 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 { + 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 { + 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) }