From 0d75df4da874bae9c8853827a7c18a6c234e8e08 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 11:56:37 +0700 Subject: [PATCH 1/2] 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 be91967e..06f98884 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 9411daef..aa193591 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 273458f5..036ec098 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 79c7530e..188d2d05 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 00000000..9e2d55a2 --- /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 e8b28ee6..d6f5f51b 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 00000000..4acd4662 --- /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 17f2d216..42ad1407 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 af93f92e..ad23e22c 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 | From 303fc3cbcafa9421e355cfc3d033cec7c482605d Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Mon, 5 Oct 2026 12:00:35 +0700 Subject: [PATCH 2/2] M4.42: SVG import reads linked images from the SVG's folder, intersects nested clips, keeps hidden objects and expands reflected gradients document.open {path} (and File > Place) find an SVG's relative <image> links in its folder: raster files stay linked images (LinkInfo with size, time and hash), SVG files and data: SVGs import as vector art, and a file that can't be read becomes a placeholder in its box that keeps the link, with a warning and a missingLinks row (Update Links / Relink read it later). The SVG library itself reads no file unless given a reader. Nested clip paths become clip groups in clip groups; undisplayed objects (display:none) come back hidden; spreadMethod reflect and repeat are expanded into stops over the painted area (matching resvg). Midpoint stops and focal points keep folding back as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/clipboard.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 23 +- crates/engine/src/cmd/fileio/mod.rs | 5 +- .../engine/src/cmd/fileio/tests_svgimport.rs | 104 ++++++++ crates/svg/src/import.rs | 211 ++++++++++----- crates/svg/src/import/files.rs | 247 ++++++++++++++++++ crates/svg/src/import/spread.rs | 162 ++++++++++++ crates/svg/src/lib.rs | 35 ++- crates/svg/tests/import_details.rs | 183 +++++++++++++ crates/svg/tests/import_text.rs | 12 +- docs/mcp.md | 5 +- 11 files changed, 910 insertions(+), 79 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_svgimport.rs create mode 100644 crates/svg/src/import/files.rs create mode 100644 crates/svg/src/import/spread.rs create mode 100644 crates/svg/tests/import_details.rs diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 12e05c8a..21bfcb80 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -188,7 +188,7 @@ fn import_svg(s: &mut Session, p: &Value) -> Result<Value> { } (None, None) => return Err(bad(C, "give svg, or dataBase64 of an SVG or SVGZ file")), }; - let src = vectorcraft_svg::import(&svg).map_err(|e| bad(C, e.to_string()))?; + let (src, _) = super::fileio::import_svg(&svg, None).map_err(|e| bad(C, e.to_string()))?; let clip = Clipboard::from_document(&src); if clip.is_empty() { return Err(bad(C, "the SVG has no drawable objects")); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index cdba10f5..efec2464 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -8,7 +8,7 @@ use vectorcraft_geom::Affine; use super::super::*; use super::pdfimport::LoadOptions; -use super::{Format, SAVE_FORMATS, format, format_for_name, read_file}; +use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, read_file}; use crate::EngineError; /// A file read into a document, with its format and non-fatal import notes. @@ -102,7 +102,8 @@ fn restore_or_import( Ok((doc, warnings, false)) } -/// Read a file of any readable format (`name`: its file name or path, for the extension and title). +/// Read a file of any readable format (`name`: its file name or path, for the extension and title; +/// a path's folder is where an SVG's relative image links are found). pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> { load_with(name, bytes, &LoadOptions::default()) } @@ -129,7 +130,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> "svg" | "svgz" => { let text = vectorcraft_svg::text_of(bytes).map_err(err)?; let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data))); - restore_or_import(editing, || vectorcraft_svg::import_with_report(&text).map_err(err))? + // Relative links are found in the SVG's folder (when `name` is a path). + let folder = std::path::Path::new(name).parent().map(|f| f.to_string_lossy()).filter(|f| !f.is_empty()).map(|f| absolute_path(&f)); + restore_or_import(editing, || import_svg(&text, folder.as_deref()))? } "pdf" | "ai" | "ait" => { // Saved with Preserve Editing: the document it carries, unless some pages are picked. @@ -150,6 +153,20 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> Ok(Loaded { doc, format, warnings, restored, converted }) } +/// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's +/// own): rasters stay linked, SVG files become art, missing ones placeholders (see +/// [`vectorcraft_svg::ImportOptions`]). +pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec<String>)> { + let read = |path: &str| { + // Only files (not folders or devices) are read. + file_stamp(path)?; + let bytes = read_file(path).ok()?; + let link = crate::cmd::links::link_info(path, &bytes); + Some((bytes, link)) + }; + vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err) +} + /// Open a file's bytes as the new active document (what `document.open` does) → /// `{index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks}`. The /// document keeps `path` (and its format, for Save) when Save can write that format back: not a diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 06439831..2f75c915 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -35,6 +35,7 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, enc pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub use export::export_source; use load::err; +pub(crate) use load::import_svg; pub(crate) use load::source; pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image}; pub use pdfimport::{LoadOptions, page_document}; @@ -51,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> { "Open Document", [], None, - "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update)", + "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), @@ -656,3 +657,5 @@ mod tests_text; #[cfg(test)] mod tests_dxf; +#[cfg(test)] +mod tests_svgimport; diff --git a/crates/engine/src/cmd/fileio/tests_svgimport.rs b/crates/engine/src/cmd/fileio/tests_svgimport.rs new file mode 100644 index 00000000..46fc2f88 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_svgimport.rs @@ -0,0 +1,104 @@ +//! `document.open` of an SVG whose images link to files: relative links are found in the SVG's +//! folder and stay linked (Links reports them), missing ones warn and are listed in +//! `missingLinks` with a placeholder; File → Place reads them the same way. + +use std::path::PathBuf; + +use serde_json::json; +use vectorcraft_doc::{ImageObject, NodeKind}; + +use super::*; + +/// A fresh folder for one test (removed when dropped). +struct Folder(PathBuf); + +impl Folder { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("vectorcraft-svgimport-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("img")).unwrap(); + Self(dir) + } + fn file(&self, name: &str) -> String { + self.0.join(name).to_string_lossy().into_owned() + } +} + +impl Drop for Folder { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn png(w: u32, h: u32) -> Vec<u8> { + let mut out = vec![]; + image::RgbaImage::from_pixel(w, h, image::Rgba([230, 20, 20, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out +} + +/// An SVG linking to `img/photo.png` (here) and `img/gone.png` (missing). +fn write_svg(dir: &Folder) -> String { + std::fs::write(dir.file("img/photo.png"), png(300, 200)).unwrap(); + let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300"> + <image xlink:href="img/photo.png" x="10" y="10" width="150" height="100"/> + <image href="img/gone.png" x="200" y="10" width="100" height="100"/></svg>"#; + let path = dir.file("art.svg"); + std::fs::write(&path, svg).unwrap(); + path +} + +fn images(s: &Session) -> Vec<ImageObject> { + let mut out = vec![]; + s.documents()[s.active_index().unwrap()].doc.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + out.push(im.clone()); + } + }); + out +} + +#[test] +fn opening_an_svg_links_its_images_from_its_folder() { + let dir = Folder::new("open"); + let path = write_svg(&dir); + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"path": path})).unwrap(); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("gone.png")), "{r}"); + let missing = r["missingLinks"].as_array().unwrap(); + assert_eq!(missing.len(), 1, "{r}"); + assert_eq!(missing[0]["name"], "gone.png"); + assert_eq!(r["modifiedLinks"], json!([])); + let ims = images(&s); + assert_eq!(ims.len(), 2); + let photo = ims[0].link.as_ref().unwrap(); + assert_eq!(PathBuf::from(&photo.path), PathBuf::from(dir.file("img/photo.png"))); + assert!(photo.modified.is_some() && photo.hash.is_some(), "{photo:?}"); + assert_eq!((ims[0].width, ims[0].height), (300, 200)); + // Links reports the found file as it is, the missing one as missing. + let c = s.execute("links.check", &json!({})).unwrap(); + assert_eq!((c["missing"].as_u64(), c["modified"].as_u64()), (Some(1), Some(0)), "{c}"); + // Once the file is there, Update Links reads it into the placeholder's box. + std::fs::write(dir.file("img/gone.png"), png(50, 50)).unwrap(); + let r = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(r["updated"].as_array().unwrap().len(), 1, "{r}"); + let gone = &images(&s)[1]; + assert_eq!((gone.width, gone.height), (50, 50)); + let b = gone.xf.transform_rect_bbox(vectorcraft_geom::Rect::new(0.0, 0.0, 50.0, 50.0)); + assert!((b.x0 - 200.0).abs() < 1e-6 && (b.width() - 100.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn placing_an_svg_reads_its_linked_images() { + let dir = Folder::new("place"); + let path = write_svg(&dir); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("file.place", &json!({"path": path})).unwrap(); + let ims = images(&s); + assert_eq!(ims.len(), 2); + assert!(ims.iter().all(|im| im.link.is_some())); + let doc = &s.documents()[s.active_index().unwrap()].doc; + assert!(!doc.images[&ims[0].key].is_proxy(), "the file's pixels"); +} diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 188d2d05..1c5e367f 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -14,26 +14,36 @@ use vectorcraft_doc::{ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes}; use crate::export::Reuse; -use crate::{SvgError, fnv1a}; +use crate::{ImportOptions, SvgError, fnv1a}; use css::XNode; use text::TextSlots; pub(crate) use fx::BLEND; mod css; +mod files; mod fx; +mod spread; mod text; -pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> { - let (svg, found) = prepass(svg); +pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), SvgError> { + let (svg, found) = prepass(svg, opts); 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()))?; let units = RootUnits::of(xml.root_element()); - let mut warnings = Vec::new(); + let mut warnings = found.warnings; let (src, slots) = text::prepare(svg, &xml, units.dpi, &mut warnings); - let opt = usvg::Options { dpi: units.dpi as f32, font_size: DEFAULT_FONT_SIZE as f32, ..usvg::Options::default() }; - let tree = usvg::Tree::from_str(&src, &opt).map_err(|e| SvgError::Parse(e.to_string()))?; + let files = found.files; + let tree = { + let opt = usvg::Options { + dpi: units.dpi as f32, + font_size: DEFAULT_FONT_SIZE as f32, + image_href_resolver: files.resolver(), + ..usvg::Options::default() + }; + usvg::Tree::from_str(&src, &opt).map_err(|e| SvgError::Parse(e.to_string()))? + }; let size = tree.size(); let (kx, ky) = units.k; let mut doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky); @@ -51,7 +61,11 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> { uses: found.uses, symbols: HashMap::new(), blends: fx::blends(&xml), + files, }; + if im.files.nested_text() { + im.warn("text inside an SVG image isn't imported".into()); + } // usvg wraps everything in an id-less group carrying the viewBox transform when needed. let mut top = tree.root(); @@ -144,6 +158,9 @@ impl RootUnits { } } +/// How many clip paths deep (`<clipPath clip-path>`) an object's clip is read. +const MAX_CLIP_NEST: usize = 16; + /// Points per inch. pub(super) const PT_PER_IN: f64 = 72.0; /// CSS pixels per inch. @@ -164,7 +181,7 @@ struct Importer { midpoints: HashMap<String, Vec<Option<f32>>>, /// Object names by element id (see [`labels`]). labels: HashMap<String, String>, - /// Ids of the hidden groups shown for usvg ([`prepass`]). + /// Ids of the undisplayed objects shown for usvg ([`prepass`]). hidden: HashSet<String>, /// The `<symbol>` id each `<use>` of a symbol shows, by the `<use>`'s id ([`prepass`]). uses: HashMap<String, String>, @@ -172,6 +189,8 @@ struct Importer { symbols: HashMap<String, SymbolMade>, /// Blend modes of the shadows and glows our export wrote, by filter id ([`fx::blends`]). blends: HashMap<String, String>, + /// The files `<image>` elements link to. + files: files::Files, } /// A `<symbol>` made a symbol: its name, its art with no object ids (what each `<use>` has to show @@ -271,15 +290,18 @@ pub(super) fn href<'a>(a: XNode<'a, '_>) -> Option<&'a str> { struct Found { /// The URL of each `<a href>`'s group, by its id. links: HashMap<String, String>, - /// Ids of the hidden groups shown for usvg: they come back hidden. + /// Ids of the undisplayed objects shown for usvg: they come back hidden. hidden: HashSet<String>, /// The `<symbol>` id each `<use>` of a symbol shows, by the `<use>`'s id. uses: HashMap<String, String>, + /// The files `<image>` elements link to. + files: files::Files, + warnings: Vec<String>, } /// Changes to the SVG text: `(range, replacement)`, an empty range inserting. #[derive(Default)] -struct Edits { +pub(super) struct Edits { list: Vec<(std::ops::Range<usize>, String)>, /// The ids made up so far, by element. ids: HashMap<roxmltree::NodeId, String>, @@ -305,7 +327,7 @@ impl Edits { } /// 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) { + pub(super) 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}\"")), @@ -333,16 +355,17 @@ impl Edits { } } -/// usvg reads `<a href>` and `<use>` as plain groups and drops what isn't displayed. Before it -/// runs, the SVG is changed so that what we keep can be found: +/// usvg reads `<a href>` and `<use>` as plain groups, drops what isn't displayed and reads linked +/// files on its own. Before it runs, the SVG is changed so that what we keep can be found: /// * every `<a href>` and every `<use>` of a `<symbol>` 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 `<g id>`, or on such a -/// group inside groups) are shown and recorded, to come back hidden; a group something links to -/// (a `<use>` template) stays as it is. -fn prepass(svg: &str) -> (Cow<'_, str>, Found) { +/// * undisplayed objects (`display: none`, as Save writes hidden layers) are shown and recorded, +/// to come back hidden; one something links to (a `<use>` template) stays as it is; +/// * linked files are read ([`files`]). +fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { let mut found = Found::default(); - if !["<a", "display", "<use"].iter().any(|t| svg.contains(t)) { + // (`:` for prefixed elements such as `<svg:image>`.) + if !["<a", "display", "<use", ":use", "<image", ":image"].iter().any(|t| svg.contains(t)) { return (svg.into(), found); } let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else { @@ -362,28 +385,36 @@ fn prepass(svg: &str) -> (Cow<'_, str>, Found) { } } if svg.contains("display") { - hidden_groups(svg, &xml, &mut edits, &mut found.hidden); + hidden_objects(svg, &xml, &mut edits, &mut found.hidden); } + found.files = files::Files::read(svg, &xml, opts, &mut edits, &mut found.warnings); (edits.apply(svg), found) } -/// Show the hidden `<g id>` layers for usvg, recording their ids. -fn hidden_groups(svg: &str, xml: &roxmltree::Document, edits: &mut Edits, ids: &mut HashSet<String>) { +/// The elements that are objects (what [`hidden_objects`] shows). +const OBJECTS: &[&str] = &["g", "a", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text", "use"]; + +/// Show the undisplayed objects outside `<defs>` (and the like) for usvg, recording their ids. +fn hidden_objects(svg: &str, xml: &roxmltree::Document, edits: &mut Edits, ids: &mut HashSet<String>) { 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 groups: Vec<XNode> = xml.root_element().children().filter(group).collect(); - while let Some(g) = groups.pop() { - groups.extend(g.children().filter(group)); - let Some(id) = g.attribute("id").filter(|id| !id.is_empty()) else { continue }; - if css.own(g, "display").as_deref() != Some("none") { + let mut todo: Vec<XNode> = xml.root_element().children().filter(XNode::is_element).collect(); + while let Some(n) = todo.pop() { + let tag = n.tag_name().name(); + if !OBJECTS.contains(&tag) || n.attribute("id").is_some_and(|id| linked.contains(id)) { continue; } - ids.insert(id.to_string()); + if matches!(tag, "g" | "a") { + todo.extend(n.children().filter(XNode::is_element)); + } + if css.own(n, "display").as_deref() != Some("none") { + continue; + } + ids.insert(edits.id(svg, n)); // A `style` declaration wins over the attribute and style sheet rules. - match g.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) { + match n.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()), + None => edits.insert(tag_name_end(svg, n), " style=\"display:inline\"".to_string()), } } } @@ -634,7 +665,10 @@ impl Importer { let to_text = obj.xf.inverse(); let resolved: Vec<Paint> = (0..paints) .map(|k| match kids.get(main + 1 + k) { - Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| self.paint(f.paint(), to_text)), + Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| { + let area = kurbo::Shape::bounding_box(&bezpath(p.data(), to_text)); + self.paint(f.paint(), to_text, Some(area)) + }), _ => Paint::None, }) .collect(); @@ -686,11 +720,25 @@ impl Importer { 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")); + // A clip path clipped in turn (`<clipPath clip-path>`): one clip group in another, so + // the clips intersect. + let mut clips = vec![self.clip_node(cp, ts)?]; + let mut next = cp.clip_path(); + while let Some(c) = next { + if clips.len() > MAX_CLIP_NEST { + self.warn(format!("clip paths nested more than {MAX_CLIP_NEST} deep on {label}: the deeper ones are ignored")); + break; + } + clips.push(self.clip_node(c, ts)?); + next = c.clip_path(); } - let clip = self.clip_node(cp, ts)?; - let mut ch = vec![Arc::new(clip)]; + let outer = clips.pop()?; + for clip in clips { + let mut ch = vec![Arc::new(clip)]; + ch.extend(children); + children = vec![Arc::new(self.named("", NodeKind::Group { children: ch, clip: true }))]; + } + let mut ch = vec![Arc::new(outer)]; ch.extend(children); self.named(id, NodeKind::Group { children: ch, clip: true }) } else { @@ -788,35 +836,23 @@ impl Importer { Some(self.named(&id, NodeKind::Compound { children, rule: r })) } - fn paint(&mut self, p: &usvg::Paint, m: Affine) -> Paint { - let gp = |kind, stops, geom: GradientGeom| { - Paint::Gradient(Box::new(GradientPaint { - gradient: Gradient { kind, stops }, - geom: Some(geom), - angle: geom.angle_deg(), - swatch: None, - freeform: None, - })) - }; - match p { - usvg::Paint::Color(c) => Paint::solid(Color::rgb8(c.red, c.green, c.blue)), + /// A usvg paint mapped by `m`, for art covering `area` (after `m`: where a reflected or + /// repeated gradient has to reach). + fn paint(&mut self, p: &usvg::Paint, m: Affine, area: Option<Rect>) -> Paint { + let (kind, base, mut geom) = match p { + usvg::Paint::Color(c) => return Paint::solid(Color::rgb8(c.red, c.green, c.blue)), + usvg::Paint::Pattern(pt) => return self.pattern(pt, m), usvg::Paint::LinearGradient(lg) => { - if lg.spread_method() != usvg::SpreadMethod::Pad { - self.warn(format!("gradient '{}': spreadMethod approximated as pad", lg.id())); - } - let mut geom = GradientGeom { + let geom = GradientGeom { start: Point::new(lg.x1() as f64, lg.y1() as f64), end: Point::new(lg.x2() as f64, lg.y2() as f64), aspect: 1.0, focal: None, }; - geom.transform(m * aff(lg.transform()), GradientKind::Linear); - gp(GradientKind::Linear, self.stops(lg), geom) + let base: &usvg::BaseGradient = lg; + (GradientKind::Linear, base, geom) } usvg::Paint::RadialGradient(rg) => { - if rg.spread_method() != usvg::SpreadMethod::Pad { - self.warn(format!("gradient '{}': spreadMethod approximated as pad", rg.id())); - } if rg.fr().get() > 1e-4 { self.warn(format!("gradient '{}': focal radius ignored", rg.id())); } @@ -824,11 +860,33 @@ impl Importer { let mut geom = GradientGeom { start: c, end: c + Vec2::new(rg.r().get() as f64, 0.0), aspect: 1.0, focal: None }; // A focal point outside the circle is pulled inside it. geom.set_focal(Some(Point::new(rg.fx() as f64, rg.fy() as f64))); - geom.transform(m * aff(rg.transform()), GradientKind::Radial); - gp(GradientKind::Radial, self.stops(rg), geom) + let base: &usvg::BaseGradient = rg; + (GradientKind::Radial, base, geom) + } + }; + geom.transform(m * aff(base.transform()), kind); + let mut stops = self.stops(base); + let reflect = match base.spread_method() { + usvg::SpreadMethod::Pad => None, + usvg::SpreadMethod::Reflect => Some(true), + usvg::SpreadMethod::Repeat => Some(false), + }; + if let Some(reflect) = reflect { + match area.map(|a| spread::expand(kind, &mut geom, &mut stops, reflect, a)) { + Some(spread::Expanded::Whole) => {} + Some(spread::Expanded::Capped) => { + self.warn(format!("gradient '{}': spreadMethod repeats more than {} times; it pads after that", base.id(), spread::MAX_PERIODS)) + } + None => self.warn(format!("gradient '{}': spreadMethod approximated as pad", base.id())), } - usvg::Paint::Pattern(pt) => self.pattern(pt, m), } + Paint::Gradient(Box::new(GradientPaint { + gradient: Gradient { kind, stops }, + geom: Some(geom), + angle: geom.angle_deg(), + swatch: None, + freeform: None, + })) } /// `<pattern>` → a pattern swatch painted with the pattern's tile placement. The swatch art is @@ -859,11 +917,12 @@ impl Importer { Paint::Pattern { pattern: name, xf } } - fn appearance(&mut self, p: &usvg::Path, m: Affine) -> (Appearance, FillRule) { + /// The fill and stroke of `p` mapped by `m`; `area`: the bounds of the path after `m`. + fn appearance(&mut self, p: &usvg::Path, m: Affine, area: Option<Rect>) -> (Appearance, FillRule) { let mut fill = None; let mut r = FillRule::NonZero; if let Some(f) = p.fill() { - let paint = self.paint(f.paint(), m); + let paint = self.paint(f.paint(), m, area); let mut fl = FillLayer::new(paint); fl.opacity = f.opacity().get(); r = rule(f.rule()); @@ -872,7 +931,9 @@ impl Importer { let mut stroke = None; if let Some(s) = p.stroke() { let scale = m.determinant().abs().sqrt(); - let paint = self.paint(s.paint(), m); + // The stroke reaches past the path (miters further: a generous margin). + let reach = s.width().get() as f64 * scale * (s.miterlimit().get() as f64).max(1.0); + let paint = self.paint(s.paint(), m, area.map(|a| a.inflate(reach, reach))); let mut sl = StrokeLayer::new(paint, s.width().get() as f64 * scale); sl.opacity = s.opacity().get(); sl.cap = match s.linecap() { @@ -910,7 +971,7 @@ impl Importer { if path.is_empty() { return None; } - let (appearance, r) = self.appearance(p, acc); + let (appearance, r) = self.appearance(p, acc, path.bounds()); let mut n = if path.subpaths.len() > 1 { // Multi-subpath SVG paths are compound paths (as in Illustrator). let children = path @@ -940,10 +1001,7 @@ impl Importer { usvg::ImageKind::PNG(d) => (d, "image/png"), usvg::ImageKind::GIF(d) => (d, "image/gif"), usvg::ImageKind::WEBP(d) => (d, "image/webp"), - usvg::ImageKind::SVG(_) => { - self.warn("embedded SVG image skipped".into()); - return None; - } + usvg::ImageKind::SVG(tree) => return self.svg_image(tree, acc), }; let size = i.size(); let (pw, ph) = image::ImageReader::new(std::io::Cursor::new(&bytes[..])) @@ -952,8 +1010,25 @@ impl Importer { .and_then(|r| r.into_dimensions().ok()) .unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32)); let key = format!("img-{:016x}", fnv1a(bytes)); - self.doc.images.entry(key.clone()).or_insert_with(|| ImageBlob::new(mime, bytes.to_vec())); + // A linked file keeps a preview; a placeholder is all a missing one has (its preview). + let linked = self.files.linked(i.kind()); + let missing = linked.is_some_and(|l| l.missing); + let link = linked.map(|l| l.link.clone()); + self.doc.images.entry(key.clone()).or_insert_with(|| { + let blob = ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: missing.then(|| bytes.clone()) }; + if link.is_some() && !missing { blob.with_proxy() } else { blob } + }); let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64); - Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link: None }))) + Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link }))) + } + + /// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size. + /// Its ids are its own: they mean nothing in the SVG around it. + fn svg_image(&mut self, tree: &usvg::Tree, acc: Affine) -> Option<Node> { + let outer = + (std::mem::take(&mut self.links), std::mem::take(&mut self.hidden), std::mem::take(&mut self.uses), std::mem::take(&mut self.labels)); + let art = self.group_node(tree.root(), acc); + (self.links, self.hidden, self.uses, self.labels) = outer; + art } } diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs new file mode 100644 index 00000000..bbe33d84 --- /dev/null +++ b/crates/svg/src/import/files.rs @@ -0,0 +1,247 @@ +//! Files `<image>` elements link to (`href="photo.png"`, `file:///…`): read before usvg runs, +//! through [`ImportOptions::read`], relative paths from [`ImportOptions::folder`]. A raster file +//! stays a linked image ([`LinkInfo`]); an SVG file becomes vector art. One that can't be read +//! (missing, remote, not an image) becomes a placeholder that keeps the link and its place, so it +//! can be relinked, and a warning. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use usvg::{ImageHrefResolver, ImageKind, roxmltree}; +use vectorcraft_doc::LinkInfo; + +use super::{Edits, href}; +use crate::ImportOptions; + +/// The files an SVG's images link to. +#[derive(Default)] +pub(in crate::import) struct Files { + /// What usvg gets for each linked href. + sources: HashMap<String, Source>, + /// The link of each linked raster or placeholder, by the address of the bytes usvg got. + links: HashMap<usize, Linked>, + /// An SVG image had text (which isn't imported). + text: AtomicBool, +} + +enum Source { + /// A raster file of the kind usvg reads it as. + Raster(fn(Arc<Vec<u8>>) -> ImageKind, Arc<Vec<u8>>), + /// An SVG file, parsed by usvg as an image. + Svg(Arc<Vec<u8>>), +} + +/// A linked raster image (or the placeholder of a missing one). +pub(in crate::import) struct Linked { + pub link: LinkInfo, + /// The file wasn't read: the bytes are a placeholder. + pub missing: bool, +} + +/// The side of the placeholder image, in pixels. +const MARKER: u32 = 64; + +/// The placeholder of a missing linked image: a light tile, crossed out (drawn here). +fn marker() -> Option<Vec<u8>> { + let last = MARKER - 1; + let img = image::RgbaImage::from_fn(MARKER, MARKER, |x, y| { + let edge = x == 0 || y == 0 || x == last || y == last; + let cross = x.abs_diff(y) <= 1 || (x + y).abs_diff(last) <= 1; + image::Rgba(if edge || cross { [200, 60, 60, 255] } else { [236, 236, 236, 255] }) + }); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(png) +} + +/// `s` with `%XX` escapes decoded (an href is a URL). +fn percent_decoded(s: &str) -> String { + if !s.contains('%') { + return s.to_string(); + } + let mut out = Vec::with_capacity(s.len()); + let mut rest = s.as_bytes(); + while let Some((&b, tail)) = rest.split_first() { + let hex = tail.get(..2).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(v) if b == b'%' => { + out.push(v); + rest = tail.get(2..).unwrap_or_default(); + } + _ => { + out.push(b); + rest = tail; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Is `p` an absolute path here or on another system (`/…`, `C:/…`, `C:\…`)? +fn is_absolute(p: &str) -> bool { + Path::new(p).is_absolute() || p.starts_with('/') || matches!(p.as_bytes(), [d, b':', b'/' | b'\\', ..] if d.is_ascii_alphabetic()) +} + +/// Where href `h` points (a path, made absolute from `folder`, or a URL) and whether it is a +/// file that can be read. +fn target(h: &str, folder: Option<&str>) -> (String, bool) { + let h = percent_decoded(h.trim()); + let path = match h.strip_prefix("file://") { + Some(rest) => { + let rest = rest.strip_prefix("localhost").unwrap_or(rest); + // `file:///C:/art.png` → `C:/art.png`. + match rest.as_bytes() { + [b'/', d, b':', ..] if d.is_ascii_alphabetic() => rest.get(1..).unwrap_or(rest).to_string(), + _ => rest.to_string(), + } + } + None if h.contains("://") => return (h, false), + None => h, + }; + if is_absolute(&path) { + return (path, true); + } + match folder { + Some(f) => (Path::new(f).join(&path).to_string_lossy().into_owned(), true), + None => (path, false), + } +} + +/// The address of image bytes, to find their link again. +fn key(bytes: &Arc<Vec<u8>>) -> usize { + Arc::as_ptr(bytes) as usize +} + +impl Files { + /// Read the files the `<image>` elements of `xml` (the text `svg`) link to. Placeholders + /// stretch to the image's box (`preserveAspectRatio="none"`), which the file fills once + /// relinked. + pub(in crate::import) fn read(svg: &str, xml: &roxmltree::Document, opts: &ImportOptions, edits: &mut Edits, warnings: &mut Vec<String>) -> Self { + let mut files = Files::default(); + let mut placeholder = None; + for n in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "image") { + let Some(h) = href(n).filter(|h| !h.trim_start().starts_with("data:")) else { continue }; + if !files.sources.contains_key(h) { + let (path, local) = target(h, opts.folder); + let read = if local { opts.read.and_then(|read| read(&path)) } else { None }; + let source = match read { + Some((bytes, link)) => files.source(bytes, link).ok_or("is not an image VectorCraft reads"), + None => Err("not found"), + }; + let source = source.or_else(|why| { + warnings.push(format!("linked image '{h}' {why}: a placeholder keeps its place until it is relinked")); + let bytes = Arc::new(placeholder.get_or_insert_with(marker).clone().ok_or(why)?); + files.links.insert(key(&bytes), Linked { link: LinkInfo::new(path), missing: true }); + Ok::<_, &str>(Source::Raster(ImageKind::PNG, bytes)) + }); + let Ok(source) = source else { continue }; + files.sources.insert(h.to_string(), source); + } + if files.placeholder(h) { + edits.set(svg, n, "preserveAspectRatio", "none"); + } + } + files + } + + /// What usvg gets for a file read (`bytes`, linked by `link`): a raster usvg reads (others are + /// stored as PNG) or an SVG; `None` for anything else. + fn source(&mut self, bytes: Vec<u8>, link: LinkInfo) -> Option<Source> { + use image::ImageFormat as F; + let Ok(format) = image::guess_format(&bytes) else { + let svg = crate::text_of(&bytes).is_ok_and(|t| t.contains("<svg")); + return svg.then(|| Source::Svg(Arc::new(bytes))); + }; + let kind = match format { + F::Png => ImageKind::PNG, + F::Jpeg => ImageKind::JPEG, + F::Gif => ImageKind::GIF, + F::WebP => ImageKind::WEBP, + _ => { + let img = image::load_from_memory_with_format(&bytes, format).ok()?; + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), F::Png).ok()?; + let png = Arc::new(png); + self.links.insert(key(&png), Linked { link, missing: false }); + return Some(Source::Raster(ImageKind::PNG, png)); + } + }; + let bytes = Arc::new(bytes); + self.links.insert(key(&bytes), Linked { link, missing: false }); + Some(Source::Raster(kind, bytes)) + } + + /// Does href `h` show a placeholder? + fn placeholder(&self, h: &str) -> bool { + matches!(self.sources.get(h), Some(Source::Raster(_, b)) if self.links.get(&key(b)).is_some_and(|l| l.missing)) + } + + /// The link of the image whose data usvg got as `kind`. + pub(in crate::import) fn linked(&self, kind: &ImageKind) -> Option<&Linked> { + match kind { + ImageKind::PNG(b) | ImageKind::JPEG(b) | ImageKind::GIF(b) | ImageKind::WEBP(b) => self.links.get(&key(b)), + ImageKind::SVG(_) => None, + } + } + + /// Did an SVG image have text (which isn't imported)? + pub(in crate::import) fn nested_text(&self) -> bool { + self.text.load(Ordering::Relaxed) + } + + fn note_text(&self, svg: &[u8]) { + if svg.windows(5).any(|w| w == b"<text") { + self.text.store(true, Ordering::Relaxed); + } + } + + /// usvg's image resolver: data URLs as usvg reads them, other hrefs only from the files read. + pub(in crate::import) fn resolver(&self) -> ImageHrefResolver<'_> { + let data = ImageHrefResolver::default_data_resolver(); + ImageHrefResolver { + resolve_data: Box::new(move |mime, bytes, opts| { + if mime == "image/svg+xml" { + self.note_text(&bytes); + } + data(mime, bytes, opts) + }), + resolve_string: Box::new(move |h, opts| match self.sources.get(h).or_else(|| self.sources.get(h.trim()))? { + Source::Raster(kind, bytes) => Some(kind(bytes.clone())), + Source::Svg(bytes) => { + self.note_text(bytes); + usvg::Tree::from_data_nested(bytes, opts).ok().map(ImageKind::SVG) + } + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hrefs_resolve_against_the_folder() { + let sep = std::path::MAIN_SEPARATOR; + assert_eq!(target("photo%20one.png", Some("/art")), (format!("/art{sep}photo one.png"), true)); + assert_eq!(target("photo.png", None), ("photo.png".into(), false)); + assert_eq!(target("/abs/photo.png", Some("/art")), ("/abs/photo.png".into(), true)); + assert_eq!(target("file:///C:/art/a.png", None), ("C:/art/a.png".into(), true)); + assert_eq!(target("file:///home/a.png", None), ("/home/a.png".into(), true)); + assert_eq!(target("https://example.com/a.png", Some("/art")), ("https://example.com/a.png".into(), false)); + } + + #[test] + fn percent_escapes_decode() { + assert_eq!(percent_decoded("a%20b%2"), "a b%2"); + assert_eq!(percent_decoded("%E2%82%AC"), "€"); + } + + #[test] + fn the_placeholder_is_a_png() { + let png = marker().unwrap(); + assert_eq!(image::load_from_memory(&png).unwrap().width(), MARKER); + } +} diff --git a/crates/svg/src/import/spread.rs b/crates/svg/src/import/spread.rs new file mode 100644 index 00000000..b88ecf3e --- /dev/null +++ b/crates/svg/src/import/spread.rs @@ -0,0 +1,162 @@ +//! `spreadMethod="reflect"` and `"repeat"`: gradients only pad past their ends, so the stops are +//! repeated (every other period mirrored, for reflect) as many times as the painted area needs, +//! and the gradient stretched over those periods. + +use vectorcraft_color::{GradientGeom, GradientKind, GradientStop}; +use vectorcraft_geom::{Point, Rect}; + +/// Most periods a gradient is expanded to. +pub(super) const MAX_PERIODS: usize = 64; + +/// What [`expand`] did. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Expanded { + /// Every period the area shows. + Whole, + /// [`MAX_PERIODS`] of them: the gradient pads past those. + Capped, +} + +/// Expand `stops` and `geom` (document space) so the gradient paints `area` as the spread method +/// does: `reflect` mirrors every other period, else they repeat. +pub(super) fn expand(kind: GradientKind, geom: &mut GradientGeom, stops: &mut Vec<GradientStop>, reflect: bool, area: Rect) -> Expanded { + let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)]; + let ts = corners.map(|p| geom.param_at(kind, p)); + if ts.iter().any(|t| !t.is_finite()) || stops.is_empty() { + return Expanded::Whole; + } + let radial = kind == GradientKind::Radial; + // The parameter's range over the area: a corner holds its extremes (radial levels are nested + // convex rings from the focal point, where the parameter is 0). + let lo = if radial { 0.0 } else { ts.iter().copied().fold(f64::INFINITY, f64::min).floor() }; + let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max).ceil().max(lo + 1.0); + if lo >= 0.0 && hi <= 1.0 { + return Expanded::Whole; + } + let capped = hi - lo > MAX_PERIODS as f64; + // Capped, the periods kept start no further back than half of them. + let lo = if capped { lo.max(-(MAX_PERIODS as f64) / 2.0) } else { lo }; + let n = (hi - lo).min(MAX_PERIODS as f64); + let period = whole_period(stops); + let mut out = Vec::with_capacity(period.len() * n as usize); + for j in 0..n as usize { + let mirrored = reflect && (lo as i64 + j as i64).rem_euclid(2) == 1; + let one = if mirrored { mirror(&period) } else { period.clone() }; + out.extend(one.into_iter().map(|s| GradientStop { offset: ((j as f64 + s.offset as f64) / n) as f32, ..s })); + } + *stops = out; + let u = geom.end - geom.start; + if radial { + // Level t of an off-centre radial is the extent ellipse scaled by t about the line from the + // focal point to the centre; level n·t' of the new one: the centre moves away from the + // focal point n times as far, and the radius grows n times. + let Some(frame) = geom.unit_frame() else { return Expanded::Whole }; + let f = geom.focal.map_or(Point::ORIGIN, |f| frame.inverse() * f); + geom.start = frame * (f.to_vec2() * (1.0 - n)).to_point(); + geom.end = geom.start + u * n; + } else { + let start = geom.start; + geom.start = start + u * lo; + geom.end = start + u * (lo + n); + } + if capped { Expanded::Capped } else { Expanded::Whole } +} + +/// The stops of one period, from offset 0 to 1: the end colours held out to the ends. +fn whole_period(stops: &[GradientStop]) -> Vec<GradientStop> { + let mut out = stops.to_vec(); + if let Some(first) = out.first().filter(|s| s.offset > 0.0).cloned() { + out.insert(0, GradientStop { offset: 0.0, ..first }); + } + if let Some(last) = out.last_mut() { + // The last stop's midpoint leads nowhere; the copy at 1 takes over. + if last.offset < 1.0 { + let end = GradientStop { offset: 1.0, ..last.clone() }; + out.push(end); + } + } + out +} + +/// A period mirrored: the stops in reverse, each midpoint measured from the other side. +fn mirror(period: &[GradientStop]) -> Vec<GradientStop> { + let rev: Vec<&GradientStop> = period.iter().rev().collect(); + rev.iter() + .enumerate() + .map(|(i, s)| GradientStop { + offset: 1.0 - s.offset, + // The midpoint from this stop to the next one is the mirror of the one the next stop + // had towards this one. + midpoint: rev.get(i + 1).map_or(0.5, |next| 1.0 - next.midpoint), + ..(*s).clone() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_color::Color; + + fn stops() -> Vec<GradientStop> { + let mut a = GradientStop::new(0.0, Color::BLACK); + a.midpoint = 0.3; + vec![a, GradientStop::new(1.0, Color::WHITE)] + } + + #[test] + fn a_linear_gradient_repeats_over_the_area() { + let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; + let mut s = stops(); + let r = expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(-5.0, 0.0, 25.0, 10.0)); + assert_eq!(r, Expanded::Whole); + // From -10 to 30: four periods. + assert_eq!((geom.start, geom.end), (Point::new(-10.0, 0.0), Point::new(30.0, 0.0))); + let offsets: Vec<f32> = s.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0]); + assert!(s.iter().step_by(2).all(|s| s.color == Color::BLACK && (s.midpoint - 0.3).abs() < 1e-6)); + } + + #[test] + fn reflect_mirrors_every_other_period_and_its_midpoints() { + let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; + let mut s = stops(); + expand(GradientKind::Linear, &mut geom, &mut s, true, Rect::new(0.0, 0.0, 20.0, 10.0)); + let colours: Vec<Color> = s.iter().map(|s| s.color).collect(); + assert_eq!(colours, [Color::BLACK, Color::WHITE, Color::WHITE, Color::BLACK]); + // Black → white with the midpoint at 0.3 mirrors to white → black with it at 0.7. + assert!((s[2].midpoint - 0.7).abs() < 1e-6, "{s:?}"); + } + + #[test] + fn stops_inside_the_period_hold_their_colours_to_its_ends() { + let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; + let mut s = vec![GradientStop::new(0.25, Color::BLACK), GradientStop::new(0.75, Color::WHITE)]; + expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 20.0, 10.0)); + let offsets: Vec<f32> = s.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.125, 0.375, 0.5, 0.5, 0.625, 0.875, 1.0]); + } + + #[test] + fn a_radial_gradient_grows_about_its_focal_point() { + let c = Point::new(50.0, 50.0); + let mut geom = GradientGeom { start: c, end: Point::new(60.0, 50.0), aspect: 1.0, focal: Some(Point::new(55.0, 50.0)) }; + let before = geom.param_at(GradientKind::Radial, Point::new(72.0, 61.0)); + let mut s = stops(); + expand(GradientKind::Radial, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 100.0, 100.0)); + let n = s.len() / 2; + assert!(n >= 5, "{n}"); + assert_eq!(geom.focal, Some(Point::new(55.0, 50.0))); + // Every point keeps its level: the new parameter is the old one over n. + let after = geom.param_at(GradientKind::Radial, Point::new(72.0, 61.0)); + assert!((after * n as f64 - before).abs() < 1e-9, "{before} {after}"); + } + + #[test] + fn far_repeats_are_capped() { + let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None }; + let mut s = stops(); + assert_eq!(expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 100.0, 1.0)), Expanded::Capped); + assert_eq!(s.len(), 2 * MAX_PERIODS); + } +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index d6f5f51b..f367555e 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -36,7 +36,7 @@ //! unit of the root `width` (pixels when it has none). //! * `<mask>` 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). Nested clip paths use the outer clip only. +//! not inverted). Nested clip paths (`<clipPath clip-path>`) are clip groups in clip groups. //! * 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). @@ -47,8 +47,12 @@ //! differently (see the export's symbol rules) becomes plain art, as do `<use>`s of other //! elements. //! * A `<g id>` layer (top-level, or inside one) that isn't displayed (`display: none`) comes back -//! as a hidden layer or group, unless a `<use>` refers to it. Other undisplayed elements are left -//! out. +//! as a hidden layer; other undisplayed objects as hidden objects, unless a `<use>` refers to +//! them. +//! * `spreadMethod` reflect and repeat are expanded into stops over the painted area (at most +//! 64 periods). +//! * `<image>` files are read through [`ImportOptions`]: rasters stay linked, SVG files (and SVG +//! `data:` URLs) become art (without their text), missing ones a placeholder keeping the link. //! * 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. //! @@ -336,8 +340,31 @@ pub fn import(svg: &str) -> Result<Document, SvgError> { } /// Import an SVG document, also returning warnings about unsupported or approximated features. +/// Linked files (`<image href="photo.png">`) aren't read: see [`import_with`]. pub fn import_with_report(svg: &str) -> Result<(Document, Vec<String>), SvgError> { - import::import(svg) + import_with(svg, &ImportOptions::default()) +} + +/// Import an SVG document reading the files it links to as `opts` says, also returning warnings. +pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), SvgError> { + import::import(svg, opts) +} + +/// Reads a linked file: its bytes and the link to it (absolute path, size, time and hash), or +/// `None` when there is no readable file at the (absolute) path. +pub type ReadFile<'a> = &'a dyn Fn(&str) -> Option<(Vec<u8>, vectorcraft_doc::LinkInfo)>; + +/// How [`import_with`] reads the files an SVG's `<image>` elements link to. +/// +/// A raster file stays linked ([`vectorcraft_doc::ImageObject::link`]); an SVG file imports as +/// vector art. A file that can't be read imports as a placeholder that keeps the link (and a +/// warning), so it can be relinked. +#[derive(Clone, Copy, Default)] +pub struct ImportOptions<'a> { + /// The folder relative links are found in: the SVG file's own. Without it they can't be read. + pub folder: Option<&'a str>, + /// Reads a linked file. Without it no file is read. + pub read: Option<ReadFile<'a>>, } /// FNV-1a: a stable content hash (image keys, unique id prefixes). diff --git a/crates/svg/tests/import_details.rs b/crates/svg/tests/import_details.rs new file mode 100644 index 00000000..13031e05 --- /dev/null +++ b/crates/svg/tests/import_details.rs @@ -0,0 +1,183 @@ +//! SVG import details: linked images read from the SVG's folder and kept linked (a placeholder +//! and a warning when missing), SVG images as vector art, nested clips, undisplayed objects as +//! hidden objects, and reflected or repeated gradients. +// 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::path::{Path, PathBuf}; + +use vectorcraft_doc::{Document, ImageObject, LinkInfo, Node, NodeKind}; +use vectorcraft_geom::Rect; +use vectorcraft_svg::{ImportOptions, import_with, import_with_report}; +use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard}; + +fn svg(body: &str) -> String { + format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="120">{body}</svg>"#) +} + +/// 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 +} + +fn images(d: &Document) -> Vec<(&Node, &ImageObject)> { + art(d) + .into_iter() + .filter_map(|n| match &n.kind { + NodeKind::Image(im) => Some((n, im)), + _ => None, + }) + .collect() +} + +/// 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() } +} + +fn png(w: u32, h: u32) -> Vec<u8> { + let mut out = vec![]; + image::RgbaImage::from_pixel(w, h, image::Rgba([10, 200, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out +} + +/// A fresh folder for one test's files. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-svg-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("img")).unwrap(); + dir +} + +/// Reads files as the app does (the link knows the size and hash). +fn read(path: &str) -> Option<(Vec<u8>, LinkInfo)> { + let bytes = std::fs::read(path).ok()?; + let link = LinkInfo { size: Some(bytes.len() as u64), hash: Some(vectorcraft_doc::links::hash_bytes(&bytes)), ..LinkInfo::new(path) }; + Some((bytes, link)) +} + +fn open_in(dir: &Path, body: &str) -> (Document, Vec<String>) { + let folder = dir.to_string_lossy(); + import_with(&svg(body), &ImportOptions { folder: Some(&folder), read: Some(&read) }).unwrap() +} + +#[test] +fn a_linked_png_resolves_against_the_svg_folder_and_stays_linked() { + let dir = folder("linked"); + let bytes = png(40, 20); + std::fs::write(dir.join("img").join("photo one.png"), &bytes).unwrap(); + let (d, w) = open_in(&dir, r#"<image href="img/photo%20one.png" x="10" y="20" width="80" height="40"/>"#); + assert!(w.is_empty(), "{w:?}"); + let [(_, im)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; + let link = im.link.as_ref().unwrap(); + assert_eq!(Path::new(&link.path), dir.join("img").join("photo one.png")); + assert_eq!(link.hash, Some(vectorcraft_doc::links::hash_bytes(&bytes))); + assert_eq!((im.width, im.height), (40, 20)); + assert_eq!(*d.images[&im.key].bytes, bytes, "the file's pixels"); + let b = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, 40.0, 20.0)); + assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.width() - 80.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn a_missing_link_warns_and_keeps_a_placeholder_in_its_box() { + let dir = folder("missing"); + let (d, w) = open_in(&dir, r#"<image href="img/gone.png" x="10" y="20" width="100" height="50"/>"#); + assert!(w.iter().any(|w| w.contains("gone.png") && w.contains("not found")), "{w:?}"); + let [(_, im)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; + assert_eq!(Path::new(&im.link.as_ref().unwrap().path), dir.join("img").join("gone.png")); + assert!(d.images[&im.key].is_proxy(), "a placeholder, not the file"); + // Stretched to the image's box, whatever its aspect. + let b = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)); + assert!((b.x0 - 10.0).abs() < 1e-6 && (b.width() - 100.0).abs() < 1e-6 && (b.height() - 50.0).abs() < 1e-6, "{b:?}"); + // Without a reader nothing is read: the same placeholder. + let (d, w) = import_with_report(&svg(r#"<image href="/abs/photo.png" width="10" height="10"/>"#)).unwrap(); + assert_eq!(images(&d)[0].1.link.as_ref().unwrap().path, "/abs/photo.png"); + assert!(!w.is_empty()); +} + +#[test] +fn svg_images_import_as_vector_art() { + let dir = folder("svgimage"); + let inner = + r##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10" viewBox="0 0 20 10"><rect width="10" height="10" fill="#f00"/></svg>"##; + std::fs::write(dir.join("img").join("logo.svg"), inner).unwrap(); + let data = format!("data:image/svg+xml;base64,{}", vectorcraft_svg::base64_encode(inner.as_bytes())); + let body = + format!(r#"<image href="img/logo.svg" x="10" y="10" width="40" height="20"/><image href="{data}" x="100" y="10" width="40" height="20"/>"#); + let (d, w) = open_in(&dir, &body); + assert!(w.is_empty(), "{w:?}"); + assert!(images(&d).is_empty()); + let mut boxes: Vec<Rect> = art(&d).iter().filter(|n| n.path_data().is_some()).filter_map(|n| n.geometric_bounds()).collect(); + boxes.sort_by(|a, b| a.x0.total_cmp(&b.x0)); + assert_eq!(boxes.len(), 2, "{:?}", art(&d)); + assert!((boxes[0].x0 - 10.0).abs() < 1e-6 && (boxes[0].width() - 20.0).abs() < 1e-6, "{boxes:?}"); + assert!((boxes[1].x0 - 100.0).abs() < 1e-6 && (boxes[1].height() - 20.0).abs() < 1e-6, "{boxes:?}"); +} + +#[test] +fn nested_clips_intersect() { + let body = r##"<defs><clipPath id="inner"><rect x="60" y="0" width="200" height="200"/></clipPath> + <clipPath id="outer" clip-path="url(#inner)"><circle cx="70" cy="60" r="40"/></clipPath></defs> + <rect width="200" height="120" fill="#06c" clip-path="url(#outer)"/>"##; + let (d, w) = import_with_report(&svg(body)).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let a = art(&d); + assert!(matches!(&a[0].kind, NodeKind::Group { clip: true, children } if matches!(children[1].kind, NodeKind::Group { clip: true, .. }))); + let mine = render_artboard(&d); + assert_similar(&mine, &resvg_render(&svg(body), 200, 120), 24.0, 0.003); + // The left half of the circle is cut. + assert_eq!(mine.over_white(45, 60), [255, 255, 255]); + assert_ne!(mine.over_white(90, 60), [255, 255, 255]); +} + +#[test] +fn undisplayed_objects_import_hidden() { + let body = r##"<rect width="10" height="10" display="none"/><g style="display:none"><circle cx="50" cy="50" r="5"/></g> + <path id="p" d="M0 0H5V5Z" style="display: none"/><image width="4" height="4" display="none" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=="/> + <defs><rect id="t" width="3" height="3" display="none"/></defs><rect x="20" width="10" height="10"/>"##; + let (d, w) = import_with_report(&svg(body)).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let top = d.layers[0].children().unwrap(); + let hidden: Vec<bool> = top.iter().map(|n| !n.visible).collect(); + assert_eq!(hidden, [true, true, true, true, false], "{top:?}"); + // An id-less group of one object is that object. + assert!(top[1].path_data().is_some()); + assert!(matches!(top[3].kind, NodeKind::Image(_))); + assert_eq!(top[2].name.as_deref(), Some("p"), "named after its id"); + assert!(top[0].name.is_none() && top[1].name.is_none(), "made-up ids aren't names"); +} + +#[test] +fn reflect_and_repeat_match_resvg() { + for (spread, grad) in [ + ( + "reflect", + r##"<linearGradient id="g" x1="40" y1="0" x2="70" y2="0" gradientUnits="userSpaceOnUse" spreadMethod="reflect"><stop offset="0.2" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient>"##, + ), + ( + "repeat", + r##"<linearGradient id="g" x1="0.3" y1="0" x2="0.5" y2="0.3" spreadMethod="repeat"><stop offset="0" stop-color="#fc0"/><stop offset="0.5" stop-color="#063"/><stop offset="1" stop-color="#fff"/></linearGradient>"##, + ), + ( + "radial", + r##"<radialGradient id="g" cx="100" cy="60" r="25" fx="90" fy="55" gradientUnits="userSpaceOnUse" spreadMethod="reflect"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#0cf"/></radialGradient>"##, + ), + ] { + let body = format!(r#"<defs>{grad}</defs><rect x="10" y="10" width="180" height="100" fill="url(#g)"/>"#); + let (d, w) = import_with_report(&svg(&body)).unwrap(); + assert!(w.is_empty(), "{spread}: {w:?}"); + assert_similar(&render_artboard(&d), &resvg_render(&svg(&body), 200, 120), 24.0, 0.01); + } +} diff --git a/crates/svg/tests/import_text.rs b/crates/svg/tests/import_text.rs index c7fdb9f6..fed7138c 100644 --- a/crates/svg/tests/import_text.rs +++ b/crates/svg/tests/import_text.rs @@ -123,7 +123,17 @@ fn hidden_text_is_skipped_and_use_instances_are_kept() { r##"<text y="10" display="none">gone</text><g visibility="hidden"><text y="20">hidden</text></g> <defs><text id="t" y="30">Used</text></defs><use href="#t" x="10"/><use xlink:href="#t" x="50"/>"##, ); - let t = texts(&d); + // An undisplayed text comes back as a hidden object (since SVG import keeps hidden objects). + let hidden: Vec<&Node> = art(&d).into_iter().filter(|n| !n.visible).collect(); + assert!(matches!(hidden.as_slice(), [n] if matches!(&n.kind, NodeKind::Text(t) if t.plain_text() == "gone")), "{hidden:?}"); + let t: Vec<&TextObject> = art(&d) + .into_iter() + .filter(|n| n.visible) + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(&**t), + _ => None, + }) + .collect(); assert_eq!(t.len(), 2); assert!(t.iter().all(|t| t.plain_text() == "Used")); assert!(t[0].xf.translation().to_point().distance(Point::new(10.0, 30.0)) < 1e-9); diff --git a/docs/mcp.md b/docs/mcp.md index ad23e22c..f9148568 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -154,7 +154,10 @@ symbol (named after its `data-name`, else its id) with an instance per `<use>`; (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`. +filters are listed in `warnings`. Undisplayed objects (`display:none`) come back hidden, nested clip paths intersect, +and reflected or repeated gradients (`spreadMethod`) are expanded into stops. `<image>` files (relative links are +found in the SVG's folder) stay linked images (see Linked images); SVG files and `data:` SVGs become art; a file that +can't be read becomes a placeholder in its box, named in `warnings` and `missingLinks`. ## Resources