diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a5ae82ad..9f0b9f83 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -86,6 +86,12 @@ fn clip_attr(clip: &Option) -> String { clip.as_ref().map(|c| format!(" clip-path=\"url(#{c})\"")).unwrap_or_default() } +/// `el` in a group with `clip` (a ` clip-path` attribute, or nothing). A clip path is in page +/// coordinates, and on an element with a `transform` it would be read after that transform. +fn clipped(clip: &str, el: String) -> String { + if clip.is_empty() { el } else { format!("{el}") } +} + fn num(v: f64) -> String { if !v.is_finite() { return "0".into(); @@ -433,7 +439,8 @@ impl W<'_> { let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; for p in &n.fills { if let Some(a) = self.paint_attr(p, w, h) { - let _ = write!(self.body, "", matrix(xf), path_d(path)); + let el = format!("", matrix(xf), path_d(path)); + self.body.push_str(&clipped(&paint_clip, el)); } } } @@ -505,7 +512,7 @@ impl W<'_> { if n.has_visible_stroke() && let Some(simple) = self.opts.simplify_stroke.then(|| self.simple_stroke(n, xf)).flatten() { - self.body.push_str(&simple); + self.body.push_str(&clipped(&paint_clip, simple)); } else if n.has_visible_stroke() { let geom = match &n.kind { NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.as_ref().map(|(p, _)| p.clone()), @@ -517,7 +524,8 @@ impl W<'_> { let fr = if even { " fill-rule=\"evenodd\"" } else { "" }; for p in &n.strokes { if let Some(a) = self.paint_attr(p, w, h) { - let _ = write!(self.body, "", matrix(xf), path_d(&area)); + let el = format!("", matrix(xf), path_d(&area)); + self.body.push_str(&clipped(&paint_clip, el)); } } } diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 6c5bff97..360b95ca 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -43,3 +43,42 @@ fn import_gradient_and_groups() { assert!(kinds.contains(&"GROUP")); assert!(kinds.contains(&"RECTANGLE") && kinds.contains(&"ELLIPSE"), "{kinds:?}"); } + +/// #325: a frame with children inside a clipping frame keeps its own paint inside the clip. The +/// clip path is in page coordinates, so it must sit on a group around the transformed path, never +/// on the path itself (where it would be read after the path's transform, offset by it). +#[test] +fn nested_frame_paint_is_clipped_in_page_space() { + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let page = d.create(p, NodeKind::Frame, "Page", 0.0, 0.0, 400.0, 300.0).unwrap(); + d.get_mut(page).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())]; + let card = d.create(page, NodeKind::Frame, "Card", 40.0, 60.0, 300.0, 120.0).unwrap(); + let c = d.get_mut(card).unwrap(); + c.fills = vec![Paint::solid(Color::from_hex("DDDDDD").unwrap())]; + c.corners.radii = [12.0; 4]; + c.clip = true; + let badge = d.create(card, NodeKind::Frame, "Badge", 200.0, 40.0, 80.0, 40.0).unwrap(); + let b = d.get_mut(badge).unwrap(); + b.fills = vec![Paint::solid(Color::from_hex("1E6F8E").unwrap())]; + b.strokes = vec![Paint::solid(Color::from_hex("AA0000").unwrap())]; + b.stroke.weight = 2.0; + let dot = d.create(badge, NodeKind::Rectangle, "Dot", 10.0, 10.0, 20.0, 20.0).unwrap(); + d.get_mut(dot).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())]; + for simplify_stroke in [true, false] { + let svg = export_svg(&d, &[page], &SvgOptions { simplify_stroke, ..SvgOptions::default() }).unwrap(); + for tag in svg.split('<').filter(|t| t.contains(" clip-path=")) { + assert!(!tag.contains(" transform="), "clip-path on a transformed element: <{tag}\n{svg}"); + } + // The card's clip: its rounded outline starts after the top-left radius. + let def = svg.find(""); + for color in ["#1E6F8E", "#AA0000"] { + let at = svg.find(color).unwrap_or_else(|| panic!("{color} missing\n{svg}")); + let path = svg[..at].rfind("