M1: SVG export keeps a nested frame's fill and stroke inside its parent's clip (fixes #325)

This commit is contained in:
Marco Arnulfo
2026-10-14 19:40:08 +02:00
parent 833f0d2c27
commit ebf6045184
2 changed files with 50 additions and 3 deletions
+11 -3
View File
@@ -86,6 +86,12 @@ fn clip_attr(clip: &Option<String>) -> 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!("<g{clip}>{el}</g>") }
}
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, "<path transform=\"{}\" d=\"{}\" {a}{fr}{paint_clip}/>", matrix(xf), path_d(path));
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", 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, "<path transform=\"{}\" d=\"{}\" {a}{fr}{paint_clip}/>", matrix(xf), path_d(&area));
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(&area));
self.body.push_str(&clipped(&paint_clip, el));
}
}
}
+39
View File
@@ -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("<path d=\"M52 60").unwrap();
let open = svg[..def].rfind("<clipPath id=\"").unwrap() + "<clipPath id=\"".len();
let card_clip = &svg[open..open + svg[open..].find('"').unwrap()];
let wrapped = format!("<g clip-path=\"url(#{card_clip})\">");
for color in ["#1E6F8E", "#AA0000"] {
let at = svg.find(color).unwrap_or_else(|| panic!("{color} missing\n{svg}"));
let path = svg[..at].rfind("<path").unwrap();
assert!(svg[..path].ends_with(&wrapped), "{color} not in the card's clip\n{svg}");
}
}
}