From 0d75df4da874bae9c8853827a7c18a6c234e8e08 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 11:56:37 +0700 Subject: [PATCH] M4.41: SVG import keeps symbols, patterns and blur/shadow filters live with imports as a symbol (named after data-name or its id) with one instance per ; a that shows the symbol differently (inherited paint, a viewport that cuts it) or that the canvas would paint differently under its transform stays plain art. feGaussianBlur, feDropShadow and the usual shadow chains (offset, blur, flood or colour matrix, composite, merge) become Gaussian Blur, Drop Shadow, Outer/Inner Glow and Feather effects; other filters are still reported. Export records the symbol name and the shadow/glow blend mode so export -> import keeps them. keeps importing as a pattern swatch (now tested). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/tests_svgedit.rs | 5 +- crates/engine/tests/import_fuzz.rs | 46 +++ crates/svg/Cargo.toml | 3 +- crates/svg/src/export.rs | 21 +- crates/svg/src/import.rs | 388 +++++++++++++----- crates/svg/src/import/fx.rs | 247 +++++++++++ crates/svg/src/lib.rs | 12 +- crates/svg/tests/import_live.rs | 251 +++++++++++ crates/svg/tests/symbols_layers.rs | 10 +- docs/mcp.md | 7 + 10 files changed, 874 insertions(+), 116 deletions(-) create mode 100644 crates/svg/src/import/fx.rs create mode 100644 crates/svg/tests/import_live.rs diff --git a/crates/engine/src/cmd/fileio/tests_svgedit.rs b/crates/engine/src/cmd/fileio/tests_svgedit.rs index be91967e9..06f988846 100644 --- a/crates/engine/src/cmd/fileio/tests_svgedit.rs +++ b/crates/engine/src/cmd/fileio/tests_svgedit.rs @@ -78,7 +78,10 @@ fn an_svg_edited_elsewhere_opens_as_plain_svg_with_a_warning() { let r = open(&mut s, "edited.svg", &edited); assert_eq!(r["warnings"][0], load::EDITING_STALE, "{r}"); let d = &s.doc().unwrap().doc; - assert!(d.symbols.is_empty() && d.patterns.iter().all(|p| p.name != "Dots"), "plain import: no symbols, no named pattern"); + // (SVG import keeps symbols since M4.41, so the roughen's result tells the plain import.) + let mut effects = 0; + d.walk(|n| effects += n.appearance.effects.len()); + assert!(effects == 0 && d.patterns.iter().all(|p| p.name != "Dots"), "plain import: the roughen as its result, no named pattern"); // Unreadable editing data (the hash only covers the markup around it) falls back too. let damaged = text.replacen(" impl Strategy { "text-anchor", "side", "path", + // Filters, spreads, hidden objects and linked images. + "spreadMethod", + "display", + "filter", + "clip-path", + "stdDeviation", + "flood-color", + "flood-opacity", + "in", + "in2", + "result", + "operator", + "values", + "tableValues", + "type", + "preserveAspectRatio", + "overflow", + "data-vc-blend", ]); let value = prop_oneof![ arb_num(), @@ -138,6 +156,23 @@ fn arb_svg_attr() -> impl Strategy { prop::collection::vec(arb_num(), 0..4).prop_map(|v| format!("lab({})", v.join(" "))), prop::sample::select(vec!["noclip invert", "invert", "noclip", "alpha", "luminance", "url(#e)", "#e", "#zz", "none", "currentColor"]) .prop_map(str::to_string), + prop::sample::select(vec![ + "reflect", + "repeat", + "SourceAlpha", + "SourceGraphic", + "e", + "over", + "in", + "table", + "1 0", + "multiply", + "img/x%2.png", + "file:///C:/nope.png", + "https://x/y.png", + "data:image/svg+xml;base64,PHN2Zy8+", + ]) + .prop_map(str::to_string), ]; (names, value).prop_map(|(n, v)| format!(" {n}=\"{v}\"")) } @@ -168,6 +203,17 @@ fn arb_svg_element(depth: u32) -> BoxedStrategy { "svg", "marker", "a", + "filter", + "feGaussianBlur", + "feOffset", + "feFlood", + "feComposite", + "feMerge", + "feMergeNode", + "feColorMatrix", + "feDropShadow", + "feComponentTransfer", + "feFuncA", ]); let attrs = || prop::collection::vec(arb_svg_attr(), 0..6).prop_map(|v| v.concat()); let leaf = (tag.clone(), attrs(), "[a-z ]{0,6}").prop_map(|(t, a, txt)| format!("<{t} id=\"e\"{a} href=\"#e\">{txt}")); diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 9411daefe..aa1935916 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -19,6 +19,8 @@ kurbo = { workspace = true } usvg = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } +# Live effect params read from filters. +serde_json = { workspace = true } # SVGZ (gzip, pure-Rust backend: builds for wasm). flate2 = { workspace = true } image = { workspace = true } @@ -30,4 +32,3 @@ resvg = { version = "0.45", default-features = false } vectorcraft-testkit = { workspace = true } proptest = { workspace = true } image = { workspace = true } -serde_json = { workspace = true } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 273458f5d..036ec098e 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -204,7 +204,7 @@ struct SymbolDef { /// canvas transforms the art's geometry alone: stroke weights, effects, patterns and unlinked /// masks stay put). #[derive(Clone, Copy)] -struct Reuse { +pub(crate) struct Reuse { /// Translations (no pattern paints or unlinked masks). moves: bool, /// Rotations and reflections (nor effects or brushes). @@ -227,7 +227,7 @@ impl Reuse { const NONE: Self = Self { moves: false, turns: false, scales: false }; /// What a def of symbol art `art` stands for. - fn of(doc: &Document, art: &Node) -> Self { + pub(crate) fn of(doc: &Document, art: &Node) -> Self { let mut r = Self { moves: true, turns: true, scales: true }; r.scan(doc, art, 0); r @@ -277,7 +277,7 @@ impl Reuse { } /// Can the def stand for an instance with transform `xf`? - fn allows(self, xf: Affine) -> bool { + pub(crate) fn allows(self, xf: Affine) -> bool { const EPS: f64 = 1e-9; let [a, b, c, d, _, _] = xf.as_coeffs(); if (a - 1.0).abs() < EPS && b.abs() < EPS && c.abs() < EPS && (d - 1.0).abs() < EPS { @@ -1250,7 +1250,9 @@ impl Writer<'_> { let art = self.detached(3, |w| w.node(&sym.art)); self.symbol_nest -= 1; (self.xf, self.instance_xf, self.knockout, self.names, self.anonymous) = saved; - self.def(1, &format!("")); + // The symbol's name, when the id had to differ from it, comes back on import. + let name = if id == sym.name { String::new() } else { format!(" data-name=\"{}\"", xml_escape(&sym.name)) }; + self.def(1, &format!("")); self.defs.push_str(&art); self.def(1, ""); if let Some(def) = self.symbols.get_mut(&sym.name) { @@ -1326,7 +1328,16 @@ impl Writer<'_> { ), RasterFx::GaussianBlur { radius } => format!("", sd(*radius)), }; - self.def(1, &format!("{body}")); + // Filters composite normally: a shadow's or glow's other blend mode is recorded for import. + let mode = match f { + RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. } + if *mode != BlendMode::Normal => + { + format!(" {}=\"{}\"", crate::import::BLEND, blend_css(*mode)) + } + _ => String::new(), + }; + self.def(1, &format!("{body}")); self.line(&format!("")); self.depth += 1; } diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 79c7530e0..188d2d051 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -13,16 +13,19 @@ use vectorcraft_doc::{ }; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes}; +use crate::export::Reuse; use crate::{SvgError, fnv1a}; use css::XNode; use text::TextSlots; +pub(crate) use fx::BLEND; + mod css; +mod fx; mod text; pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { - let (linked, links) = link_ids(svg); - let (svg, hidden) = hidden_groups(&linked); + let (svg, found) = prepass(svg); let svg = svg.as_ref(); let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) .map_err(|e| SvgError::Parse(e.to_string()))?; @@ -39,12 +42,15 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { doc, warnings, mask_flags: mask_flags(&xml), - links, + links: found.links, slots, patterns: HashMap::new(), midpoints: midpoint_stops(&xml), labels: labels(&xml), - hidden, + hidden: found.hidden, + uses: found.uses, + symbols: HashMap::new(), + blends: fx::blends(&xml), }; // usvg wraps everything in an id-less group carrying the viewBox transform when needed. @@ -61,7 +67,7 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { // Top-level `` elements become layers (as in the reference app); otherwise all art goes // into "Layer 1". Top-level text joins the layer below it (the first layer if none is). - let is_layer = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !im.links.contains_key(g.id()) && is_plain(g) && im.text_slot(g).is_none()); + let is_layer = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !made_up(g.id()) && !im.links.contains_key(g.id()) && !im.uses.contains_key(g.id()) && is_plain(g) && im.text_slot(g).is_none()); let is_text = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if im.text_slot(g).is_some()); let layer_mode = top.children().iter().any(|c| is_layer(&im, c)) && top.children().iter().all(|c| is_layer(&im, c) || is_text(&im, c)); if layer_mode { @@ -148,7 +154,7 @@ struct Importer { warnings: Vec, /// Opacity-mask options our export wrote, by mask id (see [`mask_flags`]). mask_flags: HashMap, - /// The URL of each ``'s group, by its id ([`link_ids`]). + /// The URL of each ``'s group, by its id ([`prepass`]). links: HashMap, /// Texts read from the XML, by placeholder ([`text`]). slots: TextSlots, @@ -158,8 +164,46 @@ struct Importer { midpoints: HashMap>>, /// Object names by element id (see [`labels`]). labels: HashMap, - /// Ids of the hidden groups shown for usvg ([`hidden_groups`]). + /// Ids of the hidden groups shown for usvg ([`prepass`]). hidden: HashSet, + /// The `` id each `` of a symbol shows, by the ``'s id ([`prepass`]). + uses: HashMap, + /// The symbols made so far, by `` id. + symbols: HashMap, + /// Blend modes of the shadows and glows our export wrote, by filter id ([`fx::blends`]). + blends: HashMap, +} + +/// A `` made a symbol: its name, its art with no object ids (what each `` has to show +/// to be an instance of it) and the instance transforms the art can stand for. +struct SymbolMade { + name: String, + art: Node, + reuse: Reuse, +} + +/// `n` with every object id (its descendants' and its mask art's too) zeroed, to compare art. +fn without_ids(n: &Node) -> Node { + let mut n = n.clone(); + n.id = vectorcraft_doc::NodeId(0); + if let Some(m) = n.mask.as_deref_mut() { + m.art = Arc::new(without_ids(&m.art)); + } + for c in n.children_mut().into_iter().flatten() { + *c = Arc::new(without_ids(c)); + } + n +} + +/// `base`, or `base 2`, `base 3`… : the first that isn't `taken`. +fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String { + let mut name = base.to_string(); + let mut k = 2; + while taken(&name) { + name = format!("{base} {k}"); + k += 1; + } + name } /// The stops our export added for midpoints (`data-vc-midpoint`), by gradient id: for each stop, @@ -209,81 +253,125 @@ fn mask_flags(xml: &roxmltree::Document) -> HashMap { xml.descendants().filter(|n| n.tag_name().name() == "mask" && n.has_attribute(attr)).filter_map(flags).collect() } -/// Prefix of the ids [`link_ids`] gives the `` elements that have none. -const LINK_ID: &str = "vectorcraft-link-"; +/// Prefix of the ids [`prepass`] gives the elements it has to find again that have none. +const MADE_UP_ID: &str = "vectorcraft-id-"; + +/// Is `id` one [`prepass`] made up (not a name)? +fn made_up(id: &str) -> bool { + id.starts_with(MADE_UP_ID) +} /// The URL an `` element links to. pub(super) fn href<'a>(a: XNode<'a, '_>) -> Option<&'a str> { a.attribute("href").or_else(|| a.attribute(("http://www.w3.org/1999/xlink", "href"))).filter(|h| !h.is_empty()) } -/// usvg reads `` as a plain group and drops the URL. So that the group can be found, every -/// link without an id gets one ([`LINK_ID`]…); returns the SVG with those ids and the URLs by id. -fn link_ids(svg: &str) -> (std::borrow::Cow<'_, str>, HashMap) { - let mut links = HashMap::new(); - if !svg.contains("`'s group, by its id. + links: HashMap, + /// Ids of the hidden groups shown for usvg: they come back hidden. + hidden: HashSet, + /// The `` id each `` of a symbol shows, by the ``'s id. + uses: HashMap, +} + +/// Changes to the SVG text: `(range, replacement)`, an empty range inserting. +#[derive(Default)] +struct Edits { + list: Vec<(std::ops::Range, String)>, + /// The ids made up so far, by element. + ids: HashMap, +} + +impl Edits { + fn insert(&mut self, at: usize, text: String) { + self.list.push((at..at, text)); + } + + /// The id of element `n` (in `svg`), made up and written in when it has none. + fn id(&mut self, svg: &str, n: XNode) -> String { + if let Some(id) = n.attribute("id").filter(|id| !id.is_empty()) { + return id.to_string(); + } + if let Some(id) = self.ids.get(&n.id()) { + return id.clone(); + } + let id = format!("{MADE_UP_ID}{}", self.ids.len()); + self.set(svg, n, "id", &id); + self.ids.insert(n.id(), id.clone()); + id + } + + /// Set attribute `name` of element `n` (in `svg`) to `value` (XML-escaped already). + fn set(&mut self, svg: &str, n: XNode, name: &str, value: &str) { + match n.attributes().find(|a| a.name() == name && a.namespace().is_none()) { + Some(a) => self.list.push((a.range_value(), value.to_string())), + None => self.insert(tag_name_end(svg, n), format!(" {name}=\"{value}\"")), + } + } + + /// `svg` with the edits made; overlapping ones and ranges off character boundaries are + /// skipped. + fn apply(mut self, svg: &str) -> Cow<'_, str> { + if self.list.is_empty() { + return svg.into(); + } + // Stable: inserts at one place keep their order. + self.list.sort_by_key(|(r, _)| (r.start, r.end)); + let mut out = String::with_capacity(svg.len() + self.list.iter().map(|(_, t)| t.len()).sum::()); + let mut last = 0; + for (r, text) in self.list { + let (Some(part), Some(_)) = (svg.get(last..r.start), svg.get(r.clone())) else { continue }; + out.push_str(part); + out.push_str(&text); + last = r.end; + } + out.push_str(svg.get(last..).unwrap_or("")); + out.into() + } +} + +/// usvg reads `` and `` as plain groups and drops what isn't displayed. Before it +/// runs, the SVG is changed so that what we keep can be found: +/// * every `` and every `` of a `` gets an id when it has none +/// ([`MADE_UP_ID`]…), recorded with its URL or symbol; +/// * hidden layers (as Save writes them: `display: none` on a top-level ``, or on such a +/// group inside groups) are shown and recorded, to come back hidden; a group something links to +/// (a `` template) stays as it is. +fn prepass(svg: &str) -> (Cow<'_, str>, Found) { + let mut found = Found::default(); + if ![" id.to_string(), - None => { - let id = format!("{LINK_ID}{}", inserts.len()); - inserts.push((tag_name_end(svg, a), format!(" id=\"{id}\""))); - id - } - }; - links.insert(id, url.to_string()); + let mut edits = Edits::default(); + let elements = || xml.descendants().filter(|n| n.is_element()); + for a in elements().filter(|n| n.tag_name().name() == "a") { + if let Some(url) = href(a) { + found.links.insert(edits.id(svg, a), url.to_string()); + } } - (insert(svg, inserts), links) + let symbols: HashSet<&str> = elements().filter(|n| n.tag_name().name() == "symbol").filter_map(|n| n.attribute("id")).collect(); + for u in elements().filter(|n| n.tag_name().name() == "use") { + if let Some(sym) = href(u).and_then(|h| h.strip_prefix('#')).filter(|s| symbols.contains(s)) { + found.uses.insert(edits.id(svg, u), sym.to_string()); + } + } + if svg.contains("display") { + hidden_groups(svg, &xml, &mut edits, &mut found.hidden); + } + (edits.apply(svg), found) } -/// Where the start tag of element `n` ends its name (where an attribute can go). -fn tag_name_end(svg: &str, n: XNode) -> usize { - let start = n.range().start; - svg.get(start + 1..).and_then(|s| s.find(|c: char| c.is_whitespace() || c == '/' || c == '>')).map_or(start + 2, |i| start + 1 + i) -} - -/// `svg` with each `(offset, text)` of `inserts` (in offset order) inserted; offsets that are out -/// of order or not on a character boundary are skipped. -fn insert(svg: &str, inserts: Vec<(usize, String)>) -> Cow<'_, str> { - if inserts.is_empty() { - return svg.into(); - } - let mut out = String::with_capacity(svg.len() + inserts.iter().map(|(_, t)| t.len()).sum::()); - let mut last = 0; - for (at, text) in inserts { - let Some(part) = svg.get(last..at) else { continue }; - out.push_str(part); - out.push_str(&text); - last = at; - } - out.push_str(svg.get(last..).unwrap_or("")); - out.into() -} - -/// usvg drops what isn't displayed, but hidden layers (as Save writes them: `display: none` on a -/// top-level ``, or on such a group inside groups) come back hidden. Those groups are shown -/// for usvg; returns the SVG so changed and their ids. A group something links to (a `` -/// template) stays as it is. -fn hidden_groups(svg: &str) -> (Cow<'_, str>, HashSet) { - let mut ids = HashSet::new(); - if !svg.contains("display") { - return (svg.into(), ids); - } - let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else { - return (svg.into(), ids); - }; - let css = css::Styles::new(&xml); +/// Show the hidden `` layers for usvg, recording their ids. +fn hidden_groups(svg: &str, xml: &roxmltree::Document, edits: &mut Edits, ids: &mut HashSet) { + let css = css::Styles::new(xml); let linked: HashSet<&str> = xml.descendants().filter_map(href).filter_map(|h| h.strip_prefix('#')).collect(); let group = |n: &XNode| n.is_element() && n.tag_name().name() == "g" && n.attribute("id").is_none_or(|id| !linked.contains(id)); - let mut inserts = vec![]; let mut groups: Vec = xml.root_element().children().filter(group).collect(); while let Some(g) = groups.pop() { groups.extend(g.children().filter(group)); @@ -293,13 +381,17 @@ fn hidden_groups(svg: &str) -> (Cow<'_, str>, HashSet) { } ids.insert(id.to_string()); // A `style` declaration wins over the attribute and style sheet rules. - inserts.push(match g.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) { - Some(a) => (a.range_value().end, ";display:inline".to_string()), - None => (tag_name_end(svg, g), " style=\"display:inline\"".to_string()), - }); + match g.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) { + Some(a) => edits.insert(a.range_value().end, ";display:inline".to_string()), + None => edits.insert(tag_name_end(svg, g), " style=\"display:inline\"".to_string()), + } } - inserts.sort_by_key(|(at, _)| *at); - (insert(svg, inserts), ids) +} + +/// Where the start tag of element `n` ends its name (where an attribute can go). +fn tag_name_end(svg: &str, n: XNode) -> usize { + let start = n.range().start; + svg.get(start + 1..).and_then(|s| s.find(|c: char| c.is_whitespace() || c == '/' || c == '>')).map_or(start + 2, |i| start + 1 + i) } /// Make `n` link to `url`, unless it links somewhere already (an inner link wins). @@ -373,6 +465,37 @@ fn bezpath(p: &usvg::tiny_skia_path::Path, m: Affine) -> BezPath { bp } +/// The shapes of clip path `cp` on an element whose user space `ts` maps to the document. +fn clip_shapes(cp: &usvg::ClipPath, ts: Affine) -> Vec<(BezPath, FillRule)> { + fn collect(g: &usvg::Group, m: Affine, out: &mut Vec<(BezPath, FillRule)>) { + for c in g.children() { + match c { + usvg::Node::Group(g) => collect(g, m * aff(g.transform()), out), + usvg::Node::Path(p) => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())), + _ => {} + } + } + } + let mut shapes = Vec::new(); + collect(cp.root(), ts * aff(cp.transform()), &mut shapes); + shapes +} + +/// Does clip path `cp` (on an element whose user space `ts` maps to the document) leave `art`, +/// placed by `xf`, whole? (Only its bounds are checked: the box must be inside a clip shape.) +fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) -> bool { + let Some(b) = art.visual_bounds() else { return false }; + if cp.clip_path().is_some() { + return false; + } + // Art touching the clip's edge isn't cut by it. + let e = 1e-6 * (b.width() + b.height()).max(1e-9); + let b = b.inflate(-e, -e); + let shapes = clip_shapes(cp, ts); + let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]; + corners.iter().all(|c| shapes.iter().any(|(s, _)| kurbo::Shape::contains(s, xf * *c))) +} + fn rule(r: usvg::FillRule) -> FillRule { match r { usvg::FillRule::NonZero => FillRule::NonZero, @@ -392,13 +515,17 @@ impl Importer { } fn node(&mut self, n: &usvg::Node, acc: Affine) -> Option { - match n { + let mut out = match n { usvg::Node::Group(g) => self.group(g, acc), usvg::Node::Path(p) => self.path(p, acc), usvg::Node::Image(i) => self.image(i, acc), // Only present when usvg had fonts; text is read from the XML instead. usvg::Node::Text(_) => None, + }?; + if self.hidden.contains(n.id()) { + out.visible = false; } + Some(out) } /// A gradient's stops, midpoints restored. @@ -411,10 +538,11 @@ impl Importer { self.labels.get(id).map_or(id, String::as_str) } + /// A new object named after element `id` (unless it has none or a made-up one). fn named(&mut self, id: &str, kind: NodeKind) -> Node { let nid = self.doc.alloc_id(); let mut n = Node::new(nid, kind); - if !id.is_empty() { + if !id.is_empty() && !made_up(id) { n.name = Some(self.name_of(id).to_string()); } n @@ -422,13 +550,66 @@ impl Importer { fn group(&mut self, g: &usvg::Group, acc: Affine) -> Option { let url = self.links.get(g.id()).cloned(); - let mut n = self.group_node(g, acc)?; + let mut n = match self.instance(g, acc) { + Some(n) => n, + None => self.group_node(g, acc)?, + }; if let Some(url) = url { link(&mut n, &url); } - if self.hidden.contains(g.id()) { - n.visible = false; + Some(n) + } + + /// The group of a `` of a `` → an instance of the symbol made from it (on its + /// first ``). `None` when the group has to be plain art: it shows the symbol differently + /// (properties inherited from the ``), or the canvas would paint the instance differently + /// under its transform (see [`Reuse`]), or its viewport cuts the art, or it filters. + fn instance(&mut self, g: &usvg::Group, acc: Affine) -> Option { + let sym = self.uses.get(g.id())?.clone(); + if !g.filters().is_empty() { + return None; } + // usvg puts the art in a group moved by the ``'s position and the symbol's viewBox. + let ts = acc * aff(g.transform()); + let (mut xf, mut inner) = (ts, g); + while let [usvg::Node::Group(s)] = inner.children() + && s.id().is_empty() + && is_plain(s) + && self.text_slot(s).is_none() + { + xf *= aff(s.transform()); + inner = s; + } + let mut kids = self.children(inner, Affine::IDENTITY); + let art = match kids.len() { + 0 => return None, + 1 => Arc::unwrap_or_clone(kids.pop()?), + _ => self.named("", NodeKind::Group { children: kids, clip: false }), + }; + // usvg clips a `` of a symbol to its viewport: that only counts when it cuts the art. + let whole = g.clip_path().is_none_or(|cp| uncut(cp, ts, &art, xf)); + let name = match self.symbols.get(&sym) { + Some(made) if whole && made.art == without_ids(&art) && made.reuse.allows(xf) => made.name.clone(), + Some(_) => return None, + None => { + let name = unique_name(self.name_of(&sym), |n| self.doc.symbols.iter().any(|s| s.name == n)); + let reuse = Reuse::of(&self.doc, &art); + let made = SymbolMade { name: name.clone(), art: without_ids(&art), reuse }; + self.doc.symbols.push(vectorcraft_doc::Symbol { name: name.clone(), art: Arc::new(art) }); + self.symbols.insert(sym, made); + if !whole || !reuse.allows(xf) { + return None; + } + name + } + }; + let label = if made_up(g.id()) { "a symbol instance".to_string() } else { format!("'{}'", g.id()) }; + let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label)); + let mut n = self.named(g.id(), NodeKind::SymbolInstance { symbol: name, xf }); + n.opacity = g.opacity().get(); + n.blend = blend(g.blend_mode()); + n.isolate = g.isolate(); + n.mask = mask; Some(n) } @@ -478,22 +659,33 @@ impl Importer { return None; } let is_text = text.is_some(); - // The made-up ids of links aren't names. - let id = if is_text || g.id().starts_with(LINK_ID) { "" } else { g.id() }; + // Made-up ids aren't names. + let id = if is_text || made_up(g.id()) { "" } else { g.id() }; let label = match (&text, id) { (Some(t), _) => t.first().and_then(|n| n.name.as_deref()).map_or_else(|| "a text".to_string(), |n| format!("text '{n}'")), (None, "") => "a group".to_string(), (None, id) => format!("'{id}'"), }; let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label)); - if !g.filters().is_empty() { - self.warn(format!("filter on {label} ignored")); - } + // Blurs, shadows and glows become live effects (see [`fx`]). + let effects = match g.filters() { + [] => vec![], + filters => fx::effects(filters, ts, &self.blends).unwrap_or_else(|| { + self.warn(format!("filter on {label} ignored")); + vec![] + }), + }; let mut children = match text { Some(t) => t, None => self.children(g, ts), }; let mut n = if let Some(cp) = g.clip_path() { + // SVG filters before it clips: the effects go on the art inside the clip. + if !effects.is_empty() { + let mut inner = self.named("", NodeKind::Group { children, clip: false }); + inner.appearance.effects = effects.clone(); + children = vec![Arc::new(inner)]; + } if cp.clip_path().is_some() { self.warn(format!("nested clip path on {label} approximated by its outer clip")); } @@ -506,10 +698,12 @@ impl Importer { return None; } // An id-less wrapper around a single object (usvg adds these for opacity/transform on - // shapes): fold its opacity and blend into the object. A text takes its own mask too. + // shapes): fold its opacity, blend and effects into the object (unless the object + // blends or masks before the effects). A text takes its own mask too. if (is_text || id.is_empty() && mask.is_none()) && children.len() == 1 && (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal) + && (effects.is_empty() || children[0].blend == BlendMode::Normal && children[0].mask.is_none()) && let Some(only) = children.pop() { let mut c = Arc::unwrap_or_clone(only); @@ -521,9 +715,12 @@ impl Importer { if mask.is_some() { c.mask = mask; } + c.appearance.effects.extend(effects); return Some(c); } - self.named(id, NodeKind::Group { children, clip: false }) + let mut n = self.named(id, NodeKind::Group { children, clip: false }); + n.appearance.effects = effects; + n }; n.opacity = g.opacity().get(); n.blend = blend(g.blend_mode()); @@ -567,17 +764,7 @@ impl Importer { } fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option { - fn collect(g: &usvg::Group, m: Affine, out: &mut Vec<(BezPath, FillRule)>) { - for c in g.children() { - match c { - usvg::Node::Group(g) => collect(g, m * aff(g.transform()), out), - usvg::Node::Path(p) => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())), - _ => {} - } - } - } - let mut shapes = Vec::new(); - collect(cp.root(), ts * aff(cp.transform()), &mut shapes); + let shapes = clip_shapes(cp, ts); if shapes.is_empty() { return None; } @@ -664,12 +851,7 @@ impl Importer { children.extend(content); let art = self.named("", NodeKind::Group { children, clip: true }); let base = if pt.id().is_empty() { "Pattern" } else { pt.id() }; - let mut name = base.to_string(); - let mut k = 2; - while self.doc.pattern(&name).is_some() { - name = format!("{base} {k}"); - k += 1; - } + let name = unique_name(base, |n| self.doc.pattern(n).is_some()); let mut def = PatternDef::new(&name, vec![Arc::new(art)]); def.tile = tile; self.doc.patterns.push(def); diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs new file mode 100644 index 000000000..9e2d55a20 --- /dev/null +++ b/crates/svg/src/import/fx.rs @@ -0,0 +1,247 @@ +//! SVG filters → live effects. A filter is read by evaluating its primitives on symbols (the +//! source, its silhouette blurred, moved or inverted, a colour flood, a coloured silhouette…), +//! and the result recognised as one of the effects the canvas draws: Gaussian Blur, Drop Shadow, +//! Outer Glow, Inner Glow or Feather. That covers `feGaussianBlur`, `feDropShadow`, the usual +//! shadow chains (offset, blur, flood or colour matrix, composite, merge) and what our export +//! writes, so export → import keeps the effects. + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::json; +use usvg::filter::{ColorMatrixKind, CompositeOperator, Filter, Input, Kind, TransferFunction}; +use usvg::roxmltree; +use vectorcraft_color::Color; +use vectorcraft_doc::Effect; +use vectorcraft_geom::{Affine, Vec2}; + +/// Attribute our export writes on the filter of a shadow or glow whose blend mode isn't normal +/// (filters composite normally; the mode comes back on import). +pub(crate) const BLEND: &str = "data-vc-blend"; + +/// The blend modes our export recorded ([`BLEND`]), by filter id. +pub(super) fn blends(xml: &roxmltree::Document) -> HashMap { + if !xml.input_text().contains(BLEND) { + return HashMap::new(); + } + xml.descendants() + .filter(|n| n.tag_name().name() == "filter") + .filter_map(|n| Some((n.attribute("id")?.to_string(), n.attribute(BLEND)?.to_string()))) + .collect() +} + +/// The object's alpha, blurred (σ), moved and maybe inverted. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Shape { + sd: f64, + dx: f64, + dy: f64, + moved: bool, + inverted: bool, +} + +impl Shape { + /// The silhouette as it is. + fn plain(self) -> bool { + self == Shape::default() + } +} + +/// What a filter primitive's result is, as far as the effects go. +#[derive(Clone, Debug)] +enum V { + /// `SourceGraphic`. + Graphic, + /// The source blurred (σ). + Blurred(f64), + /// A black silhouette. + Alpha(Shape), + Flood(Color, f32), + /// A silhouette painted a colour and opacity; `clipped` to the object's own shape. + Paint { + shape: Shape, + color: Color, + opacity: f32, + clipped: bool, + }, + /// The source inside its own blurred silhouette (σ). + Feathered(f64), + /// Layers, bottom first. + Stack(Vec), +} + +impl V { + fn layers(self) -> Vec { + match self { + V::Stack(l) => l, + v => vec![v], + } + } + + /// Blurred by σ `sd` (blurs add up as variances). + fn blur(self, sd: f64) -> Option { + let add = |a: f64| (a * a + sd * sd).sqrt(); + Some(match self { + v if sd <= 0.0 => v, + V::Graphic => V::Blurred(sd), + V::Blurred(a) => V::Blurred(add(a)), + V::Alpha(s) => V::Alpha(Shape { sd: add(s.sd), ..s }), + V::Paint { shape, color, opacity, clipped: false } => { + V::Paint { shape: Shape { sd: add(shape.sd), ..shape }, color, opacity, clipped: false } + } + _ => return None, + }) + } + + fn offset(self, dx: f64, dy: f64) -> Option { + let moved = |s: Shape| Shape { dx: s.dx + dx, dy: s.dy + dy, moved: true, ..s }; + Some(match self { + V::Alpha(s) => V::Alpha(moved(s)), + V::Paint { shape, color, opacity, clipped: false } => V::Paint { shape: moved(shape), color, opacity, clipped: false }, + v @ (V::Graphic | V::Blurred(_)) if dx == 0.0 && dy == 0.0 => v, + _ => return None, + }) + } + + /// `self` in the alpha of `mask` (`feComposite operator="in"`). + fn inside(self, mask: V) -> Option { + // The source's alpha is its silhouette. + let mask = match mask { + V::Graphic => V::Alpha(Shape::default()), + m => m, + }; + Some(match (self, mask) { + (V::Flood(color, opacity), V::Alpha(shape)) => V::Paint { shape, color, opacity, clipped: false }, + (V::Paint { shape, color, opacity, .. }, V::Alpha(s)) if s.plain() => V::Paint { shape, color, opacity, clipped: true }, + (V::Graphic, V::Alpha(s)) if !s.moved && !s.inverted => V::Feathered(s.sd), + (f @ V::Feathered(_), V::Alpha(s)) if s.plain() => f, + _ => return None, + }) + } + + /// A silhouette (or the blurred source, by its alpha) painted by a colour matrix that ignores + /// the colour channels: the matrix's constant colour, at its alpha scale. + fn color_matrix(self, m: &[f32]) -> Option { + let shape = match self { + V::Alpha(s) => s, + V::Blurred(sd) => Shape { sd, ..Shape::default() }, + _ => return None, + }; + let row = |r: usize| m.get(r * 5..r * 5 + 5); + let (Some(r), Some(g), Some(b), Some(a)) = (row(0), row(1), row(2), row(3)) else { return None }; + let colour_only = [r, g, b].iter().all(|row| row.iter().take(4).all(|v| *v == 0.0)); + let alpha_only = a.iter().take(3).all(|v| *v == 0.0) && a.get(4) == Some(&0.0); + let (Some(cr), Some(cg), Some(cb), Some(k)) = (r.get(4), g.get(4), b.get(4), a.get(3)) else { return None }; + (colour_only && alpha_only).then(|| V::Paint { + shape, + color: Color::rgb(cr.clamp(0.0, 1.0), cg.clamp(0.0, 1.0), cb.clamp(0.0, 1.0)), + opacity: k.clamp(0.0, 1.0), + clipped: false, + }) + } +} + +/// σ of a blur with standard deviations `x`, `y` (their mean: the effects blur evenly). +fn sd(x: f32, y: f32) -> f64 { + (x as f64 + y as f64) / 2.0 +} + +fn color(c: usvg::Color) -> Color { + Color::rgb8(c.red, c.green, c.blue) +} + +/// Evaluate one filter's primitives; `None` when one does something the effects can't. +fn eval(f: &Filter) -> Option { + let mut results: HashMap<&str, V> = HashMap::new(); + let mut last = V::Graphic; + for p in f.primitives() { + let input = |i: &Input| -> Option { + match i { + Input::SourceGraphic => Some(V::Graphic), + Input::SourceAlpha => Some(V::Alpha(Shape::default())), + Input::Reference(r) => results.get(r.as_str()).cloned(), + } + }; + let v = match p.kind() { + Kind::GaussianBlur(b) => input(b.input())?.blur(sd(b.std_dev_x().get(), b.std_dev_y().get()))?, + Kind::Offset(o) => input(o.input())?.offset(o.dx() as f64, o.dy() as f64)?, + Kind::Flood(fl) => V::Flood(color(fl.color()), fl.opacity().get()), + Kind::Composite(c) => { + let (a, b) = (input(c.input1())?, input(c.input2())?); + match c.operator() { + CompositeOperator::In => a.inside(b)?, + CompositeOperator::Over => V::Stack(b.layers().into_iter().chain(a.layers()).collect()), + _ => return None, + } + } + Kind::Merge(m) => V::Stack(m.inputs().iter().map(input).collect::>>()?.into_iter().flat_map(V::layers).collect()), + Kind::ColorMatrix(c) => match c.kind() { + ColorMatrixKind::Matrix(m) => input(c.input())?.color_matrix(m)?, + _ => return None, + }, + Kind::ComponentTransfer(c) => { + let identity = [c.func_r(), c.func_g(), c.func_b()].iter().all(|f| matches!(f, TransferFunction::Identity)); + let invert = match c.func_a() { + TransferFunction::Table(t) => t.as_slice() == [1.0, 0.0], + TransferFunction::Linear { slope, intercept } => *slope == -1.0 && *intercept == 1.0, + _ => false, + }; + match input(c.input())? { + V::Alpha(s) if identity && invert && s.sd == 0.0 => V::Alpha(Shape { inverted: !s.inverted, ..s }), + _ => return None, + } + } + Kind::DropShadow(d) if matches!(input(d.input())?, V::Graphic) => { + let shape = + Shape { sd: sd(d.std_dev_x().get(), d.std_dev_y().get()), dx: d.dx() as f64, dy: d.dy() as f64, moved: true, inverted: false }; + V::Stack(vec![V::Paint { shape, color: color(d.color()), opacity: d.opacity().get(), clipped: false }, V::Graphic]) + } + _ => return None, + }; + results.insert(p.result(), v.clone()); + last = v; + } + Some(last) +} + +/// The live effects `filters` (in the order they apply) stand for, in user space mapped to the +/// document by `ts`; `blends`: the modes our export recorded ([`blends`]). `None` when one of +/// them isn't an effect the canvas draws. +pub(super) fn effects(filters: &[Arc], ts: Affine, blends: &HashMap) -> Option> { + let k = ts.determinant().abs().sqrt(); + let [a, b, c, d, _, _] = ts.as_coeffs(); + let along = |dx: f64, dy: f64| Vec2::new(a * dx + c * dy, b * dx + d * dy); + let num = |v: f64| (v * 1000.0).round() / 1000.0; + let mut out = vec![]; + for f in filters { + let blur = |sd: f64| num(2.0 * sd * k); + let mode = blends.get(f.id()).map_or("normal", String::as_str); + let paint = |c: Color, o: f32| (c.to_hex(), num(o as f64 * 100.0)); + let (id, params) = match eval(f)? { + V::Graphic => continue, + V::Blurred(sd) => ("blur.gaussian", json!({ "radius": blur(sd) })), + V::Feathered(sd) => ("stylize.feather", json!({ "radius": blur(sd) })), + V::Stack(layers) => match layers.as_slice() { + [V::Paint { shape, color, opacity, clipped: false }, V::Graphic] if !shape.inverted => { + let (color, opacity) = paint(*color, *opacity); + if shape.moved { + let v = along(shape.dx, shape.dy); + let p = json!({ "x": num(v.x), "y": num(v.y), "blur": blur(shape.sd), "color": color, "opacity": opacity, "mode": mode }); + ("stylize.dropShadow", p) + } else { + ("stylize.outerGlow", json!({ "blur": blur(shape.sd), "color": color, "opacity": opacity, "mode": mode })) + } + } + [V::Graphic, V::Paint { shape, color, opacity, clipped: true }] if !shape.moved => { + let (color, opacity) = paint(*color, *opacity); + let source = if shape.inverted { "edge" } else { "center" }; + ("stylize.innerGlow", json!({ "blur": blur(shape.sd), "color": color, "opacity": opacity, "mode": mode, "source": source })) + } + _ => return None, + }, + _ => return None, + }; + out.push(Effect { id: id.into(), params, visible: true }); + } + Some(out) +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index e8b28ee65..d6f5f51b2 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -36,13 +36,19 @@ //! unit of the root `width` (pixels when it has none). //! * `` imports as a luminance opacity mask; a mask we exported keeps its options and art //! (its `data-vectorcraft-mask="noclip invert"` lists the options that differ from clipping and -//! not inverted). Filters are ignored (reported as warnings); nested clip paths use the outer -//! clip only. +//! not inverted). Nested clip paths use the outer clip only. +//! * Filters that are a Gaussian blur, a drop shadow (`feDropShadow` or the usual chains of offset, +//! blur, flood or colour matrix, composite and merge), a glow or a feather (as we export them) +//! become those live effects; other filters are ignored (reported as warnings). //! * `` becomes a pattern swatch (its content clipped to the tile) painted with the //! pattern's placement. +//! * `` with `` becomes a symbol and its instances. A `` that shows the symbol +//! differently (inherited paint, a viewport that cuts it) or that the canvas would paint +//! differently (see the export's symbol rules) becomes plain art, as do ``s of other +//! elements. //! * A `` layer (top-level, or inside one) that isn't displayed (`display: none`) comes back //! as a hidden layer or group, unless a `` refers to it. Other undisplayed elements are left -//! out. `` instances (and ``s) become plain art. +//! out. //! * Text lines after the first start at the first line's x; text in a clip path is ignored; //! absolute positions inside type on a path are ignored; vertical text sets every glyph sideways. //! diff --git a/crates/svg/tests/import_live.rs b/crates/svg/tests/import_live.rs new file mode 100644 index 000000000..4acd4662f --- /dev/null +++ b/crates/svg/tests/import_live.rs @@ -0,0 +1,251 @@ +//! SVG import keeps what the canvas edits live: `` becomes a pattern swatch, `` +//! with `` a symbol and its instances, blurs and drop-shadow chains live effects; and +//! export → import keeps them. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeKind}; +use vectorcraft_effects::RasterFx; +use vectorcraft_geom::{Point, Rect, shapes}; +use vectorcraft_svg::{ExportOptions, export, import, import_with_report}; +use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard}; + +fn svg(body: &str) -> String { + format!(r#"{body}"#) +} + +fn open(body: &str) -> (Document, Vec) { + import_with_report(&svg(body)).unwrap() +} + +/// All non-layer nodes in paint order. +fn art(d: &Document) -> Vec<&Node> { + let mut v = Vec::new(); + d.walk(|n| { + if !n.is_layer() { + v.push(n) + } + }); + v +} + +/// The SVG rendered by resvg on white at 1 px/pt. +fn resvg_render(svg: &str, w: u32, h: u32) -> Image { + let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse"); + let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap(); + pm.fill(resvg::tiny_skia::Color::WHITE); + resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut()); + Image { width: w, height: h, rgba: pm.data().to_vec() } +} + +/// The effects of the only object. +fn effects(d: &Document) -> Vec { + let a = art(d); + assert_eq!(a.len(), 1, "{a:?}"); + a[0].appearance.effects.clone() +} + +#[test] +fn a_checker_pattern_becomes_a_pattern_swatch() { + let (d, w) = open( + r##" + + + "##, + ); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(d.patterns.len(), 1); + let p = &d.patterns[0]; + assert_eq!((p.name.as_str(), p.tile), ("Checker", Rect::new(0.0, 0.0, 20.0, 20.0))); + assert!(matches!(art(&d)[0].appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Checker")); + assert_similar( + &render_artboard(&d), + &resvg_render( + &svg( + r##""##, + ), + 200, + 120, + ), + 24.0, + 0.01, + ); +} + +const SYMBOL: &str = r##""##; + +#[test] +fn two_uses_give_one_symbol_and_two_instances() { + let body = format!(r##"{SYMBOL}"##); + let (d, w) = open(&body); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(d.symbols.len(), 1); + assert_eq!(d.symbols[0].name, "Gold Star"); + assert!(matches!(&d.symbols[0].art.kind, NodeKind::Group { children, .. } if children.len() == 2)); + let instances: Vec<_> = art(&d) + .into_iter() + .filter_map(|n| match &n.kind { + NodeKind::SymbolInstance { symbol, xf } => Some((symbol.clone(), *xf)), + _ => None, + }) + .collect(); + assert_eq!(instances.len(), 2, "{:?}", art(&d)); + assert!(instances.iter().all(|(s, _)| s == "Gold Star")); + assert!((instances[0].1 * Point::ORIGIN).distance(Point::new(20.0, 30.0)) < 1e-9); + assert!((instances[1].1 * Point::ORIGIN).distance(Point::new(100.0, 50.0)) < 1e-9); + assert_similar(&render_artboard(&d), &resvg_render(&svg(&body), 200, 120), 24.0, 0.003); +} + +#[test] +fn uses_the_symbol_cant_stand_for_stay_art() { + // Inherited paint: the second use shows the symbol in another colour. Scaled: the canvas keeps + // stroke weights as they are. + let body = r##" + + "##; + let (d, _) = open(body); + assert_eq!(d.symbols.len(), 1); + let a = art(&d); + let instances = a.iter().filter(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).count(); + assert_eq!(instances, 2, "the red ones not scaled: {a:?}"); + assert_similar(&render_artboard(&d), &resvg_render(&svg(body), 200, 120), 24.0, 0.003); +} + +#[test] +fn symbols_round_trip_through_export() { + let (d, _) = open(&format!(r##"{SYMBOL}"##)); + let back = import(&export(&d, &ExportOptions::default())).unwrap(); + assert_eq!(back.symbols.len(), 1); + assert_eq!(back.symbols[0].name, "Gold Star"); + assert_eq!(art(&back).iter().filter(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).count(), 2); + assert_similar(&render_artboard(&d), &render_artboard(&back), 8.0, 0.001); +} + +#[test] +fn a_gaussian_blur_becomes_the_effect_with_its_radius() { + let (d, w) = open( + r##""##, + ); + assert!(w.is_empty(), "{w:?}"); + let fx = effects(&d); + assert_eq!(fx.len(), 1); + assert_eq!((fx[0].id.as_str(), &fx[0].params), ("blur.gaussian", &json!({"radius": 6.0}))); + // In a scaled group, the radius scales too. + let (d, _) = open( + r##""##, + ); + assert_eq!(effects(&d)[0].params["radius"], json!(12.0)); +} + +/// The one drop shadow of the only object: (dx, dy, blur, colour, opacity %). +fn shadow(d: &Document) -> (f64, f64, f64, Color, f32) { + let fx = vectorcraft_effects::raster_effects(&effects(d)); + match fx.as_slice() { + [RasterFx::DropShadow { dx, dy, blur, color, opacity, mode }] => { + assert_eq!(*mode, BlendMode::Normal, "filters composite normally"); + (*dx, *dy, *blur, *color, (opacity * 100.0).round()) + } + other => panic!("{other:?}"), + } +} + +#[test] +fn drop_shadow_chains_become_drop_shadows() { + let rect = r##""##; + let chains = [ + // feDropShadow. + r##""##, + // Offset, blur, flood, composite, merge. + r##""##, + // Flood in the source, blur, offset, the source over it. + r##""##, + // A colour matrix paints the blurred, moved silhouette. + r##""##, + ]; + for chain in chains { + let (d, w) = open(&format!(r#"{chain}{rect}"#)); + assert!(w.is_empty(), "{chain}: {w:?}"); + let (dx, dy, blur, color, opacity) = shadow(&d); + assert_eq!((dx, dy, blur, opacity), (4.0, 5.0, 4.0, 50.0), "{chain}"); + assert_eq!(color.to_hex(), "#ff0000", "{chain}"); + } +} + +#[test] +fn other_filters_are_reported() { + let (d, w) = + open(r##""##); + assert!(w.iter().any(|w| w.contains("filter")), "{w:?}"); + assert!(effects(&d).is_empty()); +} + +#[test] +fn a_filter_and_a_clip_on_one_object_filter_first() { + let body = r##" + "##; + let (d, w) = open(body); + assert!(w.is_empty(), "{w:?}"); + let a = art(&d); + assert!(matches!(a[0].kind, NodeKind::Group { clip: true, .. }) && a[0].appearance.effects.is_empty()); + assert!(a.iter().any(|n| n.appearance.effects.iter().any(|e| e.id == "blur.gaussian"))); + assert_similar(&render_artboard(&d), &resvg_render(&svg(body), 200, 120), 40.0, 0.02); +} + +#[test] +fn exported_effects_import_as_the_same_effects() { + let fx = |id: &str, p: serde_json::Value| Effect { id: id.into(), params: p, visible: true }; + let cases = [ + vec![fx("stylize.dropShadow", json!({"x": 7.0, "y": -3.0, "blur": 5.0, "opacity": 60.0, "color": "#203040"}))], + vec![fx("stylize.outerGlow", json!({"blur": 8.0, "color": "#ffcc00", "opacity": 80.0}))], + vec![fx("stylize.innerGlow", json!({"blur": 6.0, "color": "#ffffff", "source": "edge", "mode": "normal"}))], + vec![fx("stylize.innerGlow", json!({"blur": 6.0, "color": "#00ff00", "source": "center", "mode": "multiply"}))], + vec![fx("stylize.feather", json!({"radius": 4.0}))], + // Stacked: the first innermost. + vec![fx("blur.gaussian", json!({"radius": 3.0})), fx("stylize.dropShadow", json!({"x": 2.0, "y": 2.0, "blur": 1.0}))], + ]; + for effects in cases { + let mut d = Document::new(200.0, 120.0); + let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(50.0, 30.0, 110.0, 90.0)), Appearance::default_art()); + n.appearance.effects = effects.clone(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let (back, w) = import_with_report(&export(&d, &ExportOptions::default())).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let got = vectorcraft_effects::raster_effects(&art(&back)[0].appearance.effects); + assert_eq!(got, vectorcraft_effects::raster_effects(&effects), "{effects:?}"); + } +} + +#[test] +fn symbols_and_effects_export_import_export_is_stable() { + // A doc with a symbol instance carrying a shadow: export → import → export is stable. + let mut d = Document::new(200.0, 120.0); + let dot = Node::path( + d.alloc_id(), + shapes::ellipse(Rect::new(0.0, 0.0, 20.0, 20.0)), + Appearance::basic(Paint::solid(Color::rgb8(200, 30, 30)), Paint::None, 0.0), + ); + d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(dot) }); + let l = d.layers[0].id; + for x in [10.0, 60.0] { + let id = d.alloc_id(); + d.insert( + Some(l), + usize::MAX, + Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf: vectorcraft_geom::Affine::translate((x, 40.0)) }), + ) + .unwrap(); + } + let mut blurred = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(120.0, 30.0, 180.0, 90.0)), Appearance::default_art()); + blurred.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: json!({"x": 3.0, "y": 3.0, "blur": 2.0}), visible: true }); + d.insert(Some(l), usize::MAX, blurred).unwrap(); + let opts = ExportOptions { object_ids: vectorcraft_svg::ObjectIds::Minimal, ..Default::default() }; + let first = export(&d, &opts); + let second = export(&import(&first).unwrap(), &opts); + let strip = |s: &str| s.lines().filter(|l| !l.contains("")).collect::<Vec<_>>().join("\n"); + assert_eq!(strip(&first), strip(&second)); +} diff --git a/crates/svg/tests/symbols_layers.rs b/crates/svg/tests/symbols_layers.rs index 17f2d2162..42ad14073 100644 --- a/crates/svg/tests/symbols_layers.rs +++ b/crates/svg/tests/symbols_layers.rs @@ -52,12 +52,16 @@ fn three_instances_give_one_symbol_and_three_uses() { // The art is written once. assert_eq!(svg.matches("<path").count(), 1, "{svg}"); assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 24.0, 0.002); - // Read back: the three squares are where the instances are. + // Read back (since SVG import keeps symbols: as the symbol and three instances): the three + // squares are where the instances are. let back = import(&svg).unwrap(); + assert_eq!(back.symbols.len(), 1); + assert_eq!(back.symbols[0].name, "My Mark", "the name comes back"); + let art = back.symbols[0].art.path_data().unwrap().bounds().unwrap(); let mut boxes = vec![]; back.walk(|n| { - if let Some(p) = n.path_data() { - boxes.push(p.bounds().unwrap()); + if let NodeKind::SymbolInstance { xf, .. } = &n.kind { + boxes.push(xf.transform_rect_bbox(art)); } }); assert_eq!(boxes.len(), 3, "{boxes:?}"); diff --git a/docs/mcp.md b/docs/mcp.md index af93f92ea..ad23e22c7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -149,6 +149,13 @@ of the file, so Save asks for a new name instead of writing it back. `file.documentColorMode {mode, convert?, intent?}` switches the document colour mode, converting its colours through the colour settings (`object.convertDocumentColorMode` is an alias kept for older scripts). +SVG import keeps what the canvas edits live. `<pattern>` becomes a pattern swatch. A `<symbol>` with `<use>` becomes a +symbol (named after its `data-name`, else its id) with an instance per `<use>`; a `<use>` that shows it differently +(paint inherited from the `<use>`, a viewport that cuts the art) or that the canvas would draw otherwise (a scaled +symbol with strokes) stays art. `feGaussianBlur`, `feDropShadow` and the usual shadow chains (offset, blur, flood or +colour matrix, composite, merge) become Gaussian Blur, Drop Shadow, Outer Glow, Inner Glow and Feather effects; other +filters are listed in `warnings`. + ## Resources | URI | Content |