mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 11:26:50 -06:00
M1: SVG export keeps a nested frame's fill and stroke inside its parent's clip (fixes #325)
This commit is contained in:
+11
-3
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user