//! usvg tree → document conversion. use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::str::FromStr; use std::sync::Arc; use usvg::roxmltree; use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{ Appearance, AppearanceItem, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, PatternDef, StrokeLayer, Unit, }; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes}; use crate::export::Reuse; 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; mod vector_effect; pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec), 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 root_name = root_layer_name(xml.root_element()); let mut warnings = found.warnings; let (src, slots) = text::prepare(svg, &xml, units.dpi, &mut warnings); 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); doc.units = units.unit; let mut im = Importer { doc, warnings, mask_flags: mask_flags(&xml), links: found.links, slots, patterns: HashMap::new(), midpoints: midpoint_stops(&xml), labels: labels(&xml), hidden: found.hidden, uses: found.uses, symbols: HashMap::new(), blends: fx::blends(&xml), files, non_scaling: found.non_scaling, data: found.data, sublayers: found.sublayers, sublayer_groups: HashSet::new(), screen: (kx * ky).sqrt(), }; 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(); let mut base = Affine::scale_non_uniform(kx, ky) * aff(top.transform()); if let Some(g) = im.wrapper(top) { base *= aff(g.transform()); top = g; } // Top-level `` elements become layers (as in the reference app), named from their // labels (`data-name`…) else their ids; otherwise all art goes into "Layer 1". A clip path on // one (as on an Inkscape layer, or our clipping layers) makes it a clipping layer. 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() && !made_up(g.id()) && !im.links.contains_key(g.id()) && !im.uses.contains_key(g.id()) && only_clips(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 = |im: &Importer, top: &usvg::Group| { top.children().iter().any(|c| is_layer(im, c)) && top.children().iter().all(|c| is_layer(im, c) || is_text(im, c)) }; // The layers may sit in more wrappers (a transform on all the art, a ``'s group). if !layer_mode(&im, top) { let (mut inner, mut ts) = (top, base); while let Some(g) = im.wrapper(inner) { ts *= aff(g.transform()); inner = g; } if layer_mode(&im, inner) { (top, base) = (inner, ts); } } if layer_mode(&im, top) { im.doc.layers.clear(); let mut loose = vec![]; for c in top.children() { let usvg::Node::Group(g) = c else { continue }; if !is_layer(&im, c) { let n: Vec<_> = im.node(c, base).map(Arc::new).into_iter().collect(); match im.doc.layers.pop() { Some(mut l) => { im.add_unclipped(Arc::make_mut(&mut l), usize::MAX, n); im.doc.layers.push(l); } None => loose.extend(n), } continue; } let ts = base * aff(g.transform()); let name = im.name_of(g.id()).to_string(); let mut art = im.children(g, ts); // A clip path with no shape shows nothing of the layer: it stays, empty. let clip = match g.clip_path() { Some(cp) => { art = im.clipped(cp, ts, art, &format!("layer '{name}'")).unwrap_or_default(); !art.is_empty() } None => false, }; let id = im.doc.alloc_id(); let preset = (im.doc.layers.len() % vectorcraft_doc::LAYER_COLORS.len()) as u8; let mut l = Node::layer(id, &name, LayerColor::Preset(preset)); l.set_clips(clip); if let Some(ch) = l.children_mut() { *ch = art; } im.add_unclipped(&mut l, 0, std::mem::take(&mut loose)); l.visible = !im.hidden.contains(g.id()); im.keep_data(g.id(), &mut l); im.doc.layers.push(Arc::new(l)); } } else { // An export of a one-layer document names the layer on the root element, its art (groups // among it) loose inside: the one layer takes that name. let children = im.children(top, base); if let Some(l) = im.doc.layers.first_mut() { let l = Arc::make_mut(l); if let Some(ch) = l.children_mut() { *ch = children; } if root_name.is_some() { l.name = root_name; } } } if !im.sublayer_groups.is_empty() { for l in &mut im.doc.layers { promote_sublayers(Arc::make_mut(l), &im.sublayer_groups); } } Ok((im.doc, im.warnings)) } /// What marks a nested `` as a sublayer rather than a group: our export writes it on the layers /// it writes inside layers. (Inkscape's `inkscape:groupmode="layer"` counts too.) pub(crate) const SUBLAYER: &str = "data-vc-layer"; /// The Inkscape namespace (its layers and their labels). const INKSCAPE: &str = "http://www.inkscape.org/namespaces/inkscape"; /// The layer name on root element `root` (``, or its `data-name`), as exports of /// one-layer documents write it. An Inkscape root's id (`svg8`) is one Inkscape made up, not a name. fn root_layer_name(root: XNode) -> Option { let id = root.attribute("id").filter(|_| root.attribute((INKSCAPE, "version")).is_none()); root.attribute("data-name").or(id).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) } /// Is element `g` a `` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or /// the label beside it) is its name. fn marked_layer(g: XNode) -> bool { g.tag_name().name() == "g" && g.attribute("id").is_some_and(|id| !id.is_empty()) && (g.has_attribute(SUBLAYER) || g.attribute((INKSCAPE, "groupmode")) == Some("layer")) } /// Turn the groups made of elements marked as layers (`marked`, see [`marked_layer`]) right inside /// layer `l` into its sublayers, in its colour, and theirs in turn. A marked group anywhere else /// (in a group, a mask, a symbol) stays a group: only layers hold layers. fn promote_sublayers(l: &mut Node, marked: &HashSet) { let NodeKind::Layer { color, children, .. } = &mut l.kind else { return }; let color = *color; for c in children.iter_mut().filter(|c| marked.contains(&c.id)) { let c = Arc::make_mut(c); let NodeKind::Group { children, clip } = &mut c.kind else { continue }; let mut sub = Node::layer(c.id, "", color); sub.set_clips(*clip); if let Some(ch) = sub.children_mut() { *ch = std::mem::take(children); } c.kind = sub.kind; promote_sublayers(c, marked); } } /// SVG's initial `font-size` (`medium`) in user units. pub(super) const DEFAULT_FONT_SIZE: f64 = 12.0; /// How lengths become points, from the root ``'s `width` / `height`. /// /// A pixel (and a unitless user unit) is a point, as we export, and absolute lengths keep their /// physical size at 72 pt per inch: `font-size="12pt"` is 12 pt, `1in` is 72 pt. A root size in /// absolute units (`width="210mm"`) is that physical size, and its user units are CSS pixels of it /// (96 per inch, 0.75 pt each), so a drawing without a `viewBox` keeps its proportions. struct RootUnits { /// Pixels per inch usvg (and the text pass) convert absolute lengths at. dpi: f64, /// Points per user unit of the root along x and y. k: (f64, f64), /// Document units: those of the root `width` (pixels when it has none). unit: Unit, } impl RootUnits { fn of(root: XNode) -> Self { let unit = |name: &str| root.attribute(name).and_then(|v| svgtypes::Length::from_str(v.trim()).ok()).map(|l| l.unit); use svgtypes::LengthUnit as U; let physical = |u: Option| matches!(u, Some(U::In | U::Cm | U::Mm | U::Pt | U::Pc)); let (w, h) = (unit("width"), unit("height")); let k = |u| if physical(u) { PT_PER_IN / CSS_PX_PER_IN } else { 1.0 }; let dpi = if physical(w) || physical(h) { CSS_PX_PER_IN } else { PT_PER_IN }; let unit = match w { Some(U::Mm) => Unit::Millimeters, Some(U::Cm) => Unit::Centimeters, Some(U::In) => Unit::Inches, Some(U::Pt) => Unit::Points, Some(U::Pc) => Unit::Picas, _ => Unit::Pixels, }; Self { dpi, k: (k(w), k(h)), unit } } } /// How many clip paths deep (``) 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. const CSS_PX_PER_IN: f64 = 96.0; struct Importer { doc: Document, 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 ([`prepass`]). links: HashMap, /// Texts read from the XML, by placeholder ([`text`]). slots: TextSlots, /// Pattern swatch made for each usvg pattern. patterns: HashMap, /// Midpoint stops by gradient id (see [`midpoint_stops`]). midpoints: HashMap>>, /// Object names by element id (see [`labels`]). labels: HashMap, /// Ids of the undisplayed objects 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, /// The files `` elements link to. files: files::Files, /// Ids of the shapes whose strokes don't scale ([`vector_effect`]). non_scaling: HashSet, /// The `data-*` attributes of elements, by id ([`user_data`]). data: HashMap>, /// Ids of the `` elements marked as layers ([`marked_layer`]). sublayers: HashSet, /// The groups made of them, made sublayers where they sit in a layer ([`promote_sublayers`]). sublayer_groups: HashSet, /// Points per screen pixel (the root's user unit before its `viewBox`): what a non-scaling /// stroke's width is measured in. screen: f64, } /// 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, /// the midpoint it stands for. fn midpoint_stops(xml: &roxmltree::Document) -> HashMap>> { xml.descendants() .filter(|n| matches!(n.tag_name().name(), "linearGradient" | "radialGradient")) .filter_map(|g| { let id = g.attribute("id")?; let marks: Vec> = g .children() .filter(|c| c.tag_name().name() == "stop") .map(|c| c.attribute("data-vc-midpoint").and_then(|v| v.parse().ok())) .collect(); marks.iter().any(Option::is_some).then(|| (id.to_string(), marks)) }) .collect() } /// A gradient's stops, with the midpoint stops `marks` names folded back into the midpoints of /// the stops before them. fn gradient_stops(g: &usvg::BaseGradient, marks: Option<&[Option]>) -> Vec { let marks = marks.filter(|m| m.len() == g.stops().len()); let mut out: Vec = Vec::with_capacity(g.stops().len()); for (i, s) in g.stops().iter().enumerate() { if let (Some(mid), Some(prev)) = (marks.and_then(|m| m.get(i).copied().flatten()), out.last_mut()) { prev.midpoint = mid.clamp(0.0, 1.0); continue; } let c = s.color(); out.push(GradientStop { opacity: s.opacity().get(), ..GradientStop::new(s.offset().get(), Color::rgb8(c.red, c.green, c.blue)) }); } out } /// The options [`crate::export::MASK_FLAGS`] records on exported `` elements, by mask id: /// (clip, invert). fn mask_flags(xml: &roxmltree::Document) -> HashMap { let attr = crate::export::MASK_FLAGS; if !xml.input_text().contains(attr) { return HashMap::new(); } let flags = |n: roxmltree::Node| { let has = |f: &str| n.attribute(attr).is_some_and(|v| v.split_whitespace().any(|x| x == f)); Some((n.attribute("id")?.to_string(), (!has("noclip"), has("invert")))) }; xml.descendants().filter(|n| n.tag_name().name() == "mask" && n.has_attribute(attr)).filter_map(flags).collect() } /// 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()) } /// What usvg would lose, found in the XML before it runs ([`prepass`]). #[derive(Default)] struct Found { /// The URL of each ``'s group, by its id. links: HashMap, /// Ids of the undisplayed objects shown for usvg: they come back hidden. hidden: HashSet, /// The `` id each `` of a symbol shows, by the ``'s id. uses: HashMap, /// The files `` elements link to. files: files::Files, /// Ids of the shapes whose strokes don't scale ([`vector_effect`]). non_scaling: HashSet, /// The `data-*` attributes of elements, by id ([`user_data`]). data: HashMap>, /// Ids of the `` elements marked as layers ([`marked_layer`]). sublayers: HashSet, warnings: Vec, } /// Changes to the SVG text: `(range, replacement)`, an empty range inserting. #[derive(Default)] pub(super) 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). 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}\"")), } } /// `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, 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 `` and every `` of a `` gets an id when it has none /// ([`MADE_UP_ID`]…), recorded with its URL or symbol; /// * undisplayed objects (`display: none`, as Save writes hidden layers) are shown and recorded, /// to come back hidden; one something links to (a `` template) stays as it is; /// * shapes with a non-scaling stroke get an id when they have none, recorded ([`vector_effect`]); /// * linked files are read ([`files`]). fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { let mut found = Found::default(); // (`:` for prefixed elements such as ``.) if ![" = 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()); } } let (display, non_scaling) = (svg.contains("display"), svg.contains(vector_effect::NON_SCALING)); if display || non_scaling { let css = css::Styles::new(&xml); if display { hidden_objects(svg, &xml, &css, &mut edits, &mut found.hidden); } if non_scaling { vector_effect::find(svg, &xml, &css, &mut edits, &mut found.non_scaling, &mut found.warnings); } } found.files = files::Files::read(svg, &xml, opts, &mut edits, &mut found.warnings); (edits.apply(svg), found) } /// 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 `` (and the like) for usvg, recording their ids. The /// objects in a `` count too (some apps wrap all their layers in one). fn hidden_objects(svg: &str, xml: &roxmltree::Document, css: &css::Styles, edits: &mut Edits, ids: &mut HashSet) { let linked: HashSet<&str> = xml.descendants().filter_map(href).filter_map(|h| h.strip_prefix('#')).collect(); let mut todo: Vec = xml.root_element().children().filter(XNode::is_element).collect(); while let Some(n) = todo.pop() { let tag = n.tag_name().name(); if n.attribute("id").is_some_and(|id| linked.contains(id)) { continue; } if matches!(tag, "g" | "a" | "switch") { todo.extend(n.children().filter(XNode::is_element)); } if !OBJECTS.contains(&tag) || 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 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, n), " style=\"display:inline\"".to_string()), } } } /// 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) } /// The elements that become objects, whose `data-*` attributes are kept. const OBJECT_ELEMENTS: &[&str] = &["g", "a", "use", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image"]; /// The most `data-*` attributes kept per element (the file is untrusted). const MAX_DATA: usize = 64; /// The `data-*` attributes of element `e`, in order, without the `data-` (ours, `data-name` and /// `data-vc-*`, aside). fn user_data(e: XNode) -> Vec<(String, String)> { e.attributes() .filter(|a| a.namespace().is_none()) .filter_map(|a| Some((a.name().strip_prefix("data-")?, a.value()))) .filter(|(k, _)| !k.is_empty() && *k != "name" && !k.starts_with("vc-")) .take(MAX_DATA) .map(|(k, v)| (k.to_string(), v.to_string())) .collect() } /// Make `n` link to `url`, unless it links somewhere already (an inner link wins). fn link(n: &mut Node, url: &str) { if n.url().is_none() { n.edit_attrs(|a| a.url = url.to_string()); } } /// The names apps keep beside element ids, by id: `data-name`, `inkscape:label`, `serif:id` or /// `aria-label` (the first one present). fn labels(xml: &roxmltree::Document) -> HashMap { const SERIF: &str = "http://www.serif.com/"; xml.descendants() .filter_map(|n| { let id = n.attribute("id").filter(|id| !id.is_empty())?; let name = n .attribute("data-name") .or_else(|| n.attribute((INKSCAPE, "label"))) .or_else(|| n.attribute((SERIF, "id"))) .or_else(|| n.attribute("aria-label"))?; Some((id.to_string(), name.to_string())) }) .collect() } fn aff(t: usvg::Transform) -> Affine { Affine::new([t.sx as f64, t.ky as f64, t.kx as f64, t.sy as f64, t.tx as f64, t.ty as f64]) } fn is_plain(g: &usvg::Group) -> bool { g.clip_path().is_none() && only_clips(g) } /// Does group `g` leave its art as it is, but for a clip path (no mask, filter, opacity or blend)? fn only_clips(g: &usvg::Group) -> bool { g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal } fn blend(b: usvg::BlendMode) -> BlendMode { use usvg::BlendMode as B; match b { B::Normal => BlendMode::Normal, B::Multiply => BlendMode::Multiply, B::Screen => BlendMode::Screen, B::Overlay => BlendMode::Overlay, B::Darken => BlendMode::Darken, B::Lighten => BlendMode::Lighten, B::ColorDodge => BlendMode::ColorDodge, B::ColorBurn => BlendMode::ColorBurn, B::HardLight => BlendMode::HardLight, B::SoftLight => BlendMode::SoftLight, B::Difference => BlendMode::Difference, B::Exclusion => BlendMode::Exclusion, B::Hue => BlendMode::Hue, B::Saturation => BlendMode::Saturation, B::Color => BlendMode::Color, B::Luminosity => BlendMode::Luminosity, } } fn bezpath(p: &usvg::tiny_skia_path::Path, m: Affine) -> BezPath { use usvg::tiny_skia_path::PathSegment as S; let pt = |p: usvg::tiny_skia_path::Point| m * Point::new(p.x as f64, p.y as f64); let mut bp = BezPath::new(); for s in p.segments() { match s { S::MoveTo(p) => bp.move_to(pt(p)), S::LineTo(p) => bp.line_to(pt(p)), S::QuadTo(a, p) => bp.quad_to(pt(a), pt(p)), S::CubicTo(a, b, p) => bp.curve_to(pt(a), pt(b), pt(p)), S::Close => bp.close_path(), } } 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, usvg::FillRule::EvenOdd => FillRule::EvenOdd, } } impl Importer { fn warn(&mut self, s: String) { if !self.warnings.contains(&s) { self.warnings.push(s); } } /// The child of `g` when it's all `g` holds and only wraps art: an id-less group that leaves /// it as it is (but for a transform) and isn't a text. fn wrapper<'g>(&self, g: &'g usvg::Group) -> Option<&'g usvg::Group> { match g.children() { [usvg::Node::Group(w)] if w.id().is_empty() && is_plain(w) && self.text_slot(w).is_none() => Some(w), _ => None, } } fn children(&mut self, g: &usvg::Group, acc: Affine) -> Vec> { g.children().iter().filter_map(|c| self.node(c, acc)).map(Arc::new).collect() } fn node(&mut self, n: &usvg::Node, acc: Affine) -> Option { 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; } self.keep_data(n.id(), &mut out); Some(out) } /// Give `n` the `data-*` attributes of element `id`, unless it has data already (what an /// inner element made of it keeps). fn keep_data(&self, id: &str, n: &mut Node) { if let Some(data) = self.data.get(id).filter(|_| n.attrs.as_ref().is_none_or(|a| a.data.is_empty())) { n.edit_attrs(|a| a.data = data.clone()); } } /// A gradient's stops, midpoints restored. fn stops(&self, g: &usvg::BaseGradient) -> Vec { gradient_stops(g, self.midpoints.get(g.id()).map(Vec::as_slice)) } /// The object name for an element id: its label (see [`labels`]), else the id itself. fn name_of<'a>(&'a self, id: &'a str) -> &'a str { 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() && !made_up(id) { n.name = Some(self.name_of(id).to_string()); } n } fn group(&mut self, g: &usvg::Group, acc: Affine) -> Option { let url = self.links.get(g.id()).cloned(); 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); } 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) } /// The text a placeholder group stands for (see [`text`]) and the index of its main path. fn text_slot(&self, g: &usvg::Group) -> Option<(usize, usize)> { g.children().iter().enumerate().find_map(|(k, c)| match c { usvg::Node::Path(p) => self.slots.find(p).map(|i| (i, k)), _ => None, }) } /// A text placeholder group → its text object (nothing when the text is hidden). fn text(&mut self, g: &usvg::Group, ts: Affine) -> Option>> { let (i, main) = self.text_slot(g)?; let kids = g.children(); if !matches!(kids.get(main), Some(usvg::Node::Path(p)) if p.is_visible()) { return Some(vec![]); } let text::PendingText { name, mut obj, servers, paints, non_scaling, under, .. } = self.slots.texts.get(i)?.clone(); // The scale the type draws at becomes its size (last, below): text space grows by `grow`. let xf = ts * obj.xf; let grow = text::size_scale(&obj, xf); // usvg resolved the `url(#…)` paints (the paths after the main one) in the element's user // space; runs paint in text space. let to_text = Affine::scale(grow) * obj.xf.inverse(); let slot_paint = |im: &mut Self, k: usize, m: Affine| match kids.get(main + 1 + k) { Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| { let area = kurbo::Shape::bounding_box(&bezpath(p.data(), m)); im.paint(f.paint(), m, Some(area)) }), _ => Paint::None, }; let resolved: Vec = (0..paints).map(|k| slot_paint(self, k, to_text)).collect(); let server = |k: usize| resolved.get(k).cloned().unwrap_or(Paint::None); for (run, (fill, stroke)) in obj.runs.iter_mut().zip(servers) { if let Some(k) = fill { run.style.fill = server(k); } if let Some(k) = stroke { run.style.stroke = server(k); } } obj.xf = xf; if non_scaling && !vector_effect::unscale_text(&mut obj, self.screen) { let label = if name.is_empty() || made_up(&name) { "a text".to_string() } else { format!("text '{name}'") }; self.warn(format!("non-scaling stroke on {label} approximated: the text is stretched or skewed")); } // A stroke under the characters is the object's own: it paints in the document. let under = under.map(|(mut sl, k)| { if let Some(k) = k { sl.paint = slot_paint(self, k, ts); } let mut ap = Appearance { items: vec![AppearanceItem::Stroke(sl)], ..Default::default() }; ap.scale_strokes(if non_scaling { self.screen } else { obj.xf.determinant().abs().sqrt() }); ap.set_contents_at(1); ap }); text::fold_scale(&mut obj, grow); let mut n = self.named(&name, NodeKind::Text(Box::new(obj))); if let Some(ap) = under { n.appearance = ap; } Some(vec![Arc::new(n)]) } fn group_node(&mut self, g: &usvg::Group, acc: Affine) -> Option { let ts = acc * aff(g.transform()); // A text placeholder becomes its text, named after the element (the group stays unnamed). let text = self.text(g, ts); if text.as_ref().is_some_and(Vec::is_empty) { return None; } let is_text = text.is_some(); // 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)); // 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)]; } let children = self.clipped(cp, ts, children, &label)?; self.named(id, NodeKind::Group { children, clip: true }) } else { if children.is_empty() { return None; } // An id-less wrapper around a single object (usvg adds these for opacity/transform on // 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); c.opacity *= g.opacity().get(); if g.blend_mode() != usvg::BlendMode::Normal { c.blend = blend(g.blend_mode()); } c.isolate |= g.isolate(); if mask.is_some() { c.mask = mask; } c.appearance.effects.extend(effects); return Some(c); } 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()); n.isolate = g.isolate(); n.mask = mask; if !is_text && self.sublayers.contains(g.id()) { self.sublayer_groups.insert(n.id); } Some(n) } /// `` → opacity mask (luminance; alpha masks are approximated by their luminance). A /// mask our export wrote gets its options back ([`mask_flags`]) and its art without what /// stands for them: the inverting filter group and the backdrop rectangle. fn opacity_mask(&mut self, m: &usvg::Mask, ts: Affine, label: &str) -> Option> { if m.kind() == usvg::MaskType::Alpha { self.warn(format!("alpha mask on {label} imported as a luminance opacity mask")); } if m.mask().is_some() { self.warn(format!("nested mask on {label} ignored")); } let (clip, invert) = self.mask_flags.get(m.id()).copied().unwrap_or((true, false)); let (mut nodes, mut ts) = (m.root().children(), ts); if invert && let [usvg::Node::Group(g)] = nodes && !g.filters().is_empty() { (nodes, ts) = (g.children(), ts * aff(g.transform())); } if (!clip || invert) && let [usvg::Node::Path(_), rest @ ..] = nodes { nodes = rest; } let mut children: Vec> = nodes.iter().filter_map(|c| self.node(c, ts)).map(Arc::new).collect(); let art = match children.len() { 0 => return None, 1 => Arc::unwrap_or_clone(children.pop()?), _ => self.named("", NodeKind::Group { children, clip: false }), }; let mut mask = vectorcraft_doc::OpacityMask::new(art, clip); mask.invert = invert; Some(Box::new(mask)) } /// `children` clipped by `cp` (on an element whose user space `ts` maps to the document): the /// children of a clip group or clipping layer, its clipping path first. A clip path clipped in /// turn (``) nests one clip group in another, so the clips intersect. /// `None` when the clip path has no shape (nothing shows). fn clipped(&mut self, cp: &usvg::ClipPath, ts: Affine, mut children: Vec>, label: &str) -> Option>> { 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 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); Some(ch) } /// Put `art` in layer `l` at `index` (at most its end) unclipped: a clipping mask on the layer /// becomes a clip group in it, holding the art it clipped. fn add_unclipped(&mut self, l: &mut Node, index: usize, art: Vec>) { if art.is_empty() { return; } if let NodeKind::Layer { children, clip: clip @ true, .. } = &mut l.kind { *clip = false; let clipped = std::mem::take(children); children.push(Arc::new(self.named("", NodeKind::Group { children: clipped, clip: true }))); } if let Some(ch) = l.children_mut() { let i = index.min(ch.len()); ch.splice(i..i, art); } } fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option { let shapes = clip_shapes(cp, ts); if shapes.is_empty() { return None; } let r = shapes[0].1; let mut subs = Vec::new(); for (bp, _) in &shapes { subs.extend(PathData::from_bezpath(bp).subpaths); } let id = cp.id().to_string(); if subs.len() == 1 { let path = PathData::new(subs); return Some(self.named(&id, NodeKind::Path { path, rule: r, live: None, clipping: true, guide: false })); } let children = subs .into_iter() .map(|sp| { let nid = self.doc.alloc_id(); Arc::new(Node::new(nid, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false })) }) .collect(); Some(self.named(&id, NodeKind::Compound { children, rule: r })) } /// 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) -> 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) => { 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, }; let base: &usvg::BaseGradient = lg; (GradientKind::Linear, base, geom) } usvg::Paint::RadialGradient(rg) => { if rg.fr().get() > 1e-4 { self.warn(format!("gradient '{}': focal radius ignored", rg.id())); } let c = Point::new(rg.cx() as f64, rg.cy() as f64); 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))); 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())), } } Paint::Gradient(Box::new(GradientPaint { gradient: Gradient { kind, stops }, geom: Some(geom), angle: geom.angle_deg(), swatch: None, freeform: None, })) } /// `` → a pattern swatch painted with the pattern's tile placement. The swatch art is /// the pattern content clipped to the tile, as SVG draws it. fn pattern(&mut self, pt: &Arc, m: Affine) -> Paint { let r = pt.rect(); let xf = m * aff(pt.transform()) * Affine::translate((r.x() as f64, r.y() as f64)); let key = Arc::as_ptr(pt) as usize; if let Some(name) = self.patterns.get(&key) { return Paint::Pattern { pattern: name.clone(), xf }; } let tile = Rect::new(0.0, 0.0, r.width() as f64, r.height() as f64); let content = self.children(pt.root(), aff(pt.root().transform())); if content.is_empty() { return Paint::None; } let clip = self.named("", NodeKind::Path { path: shapes::rectangle(tile), rule: FillRule::NonZero, live: None, clipping: true, guide: false }); let mut children = vec![Arc::new(clip)]; children.extend(content); let art = self.named("", NodeKind::Group { children, clip: true }); let base = if pt.id().is_empty() { "Pattern" } else { pt.id() }; 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); self.patterns.insert(key, name.clone()); Paint::Pattern { pattern: name, xf } } /// 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) -> (Appearance, FillRule) { let mut fill = None; let mut r = FillRule::NonZero; if let Some(f) = p.fill() { let paint = self.paint(f.paint(), m, area); let mut fl = FillLayer::new(paint); fl.opacity = f.opacity().get(); r = rule(f.rule()); fill = Some(AppearanceItem::Fill(fl)); } let mut stroke = None; if let Some(s) = p.stroke() { // A non-scaling stroke is as wide on screen whatever `m` does: the path takes `m`, so a // stroke of that width in the document is exact. let scale = if self.non_scaling.contains(p.id()) { self.screen } else { m.determinant().abs().sqrt() }; // 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() { usvg::LineCap::Butt => LineCap::Butt, usvg::LineCap::Round => LineCap::Round, usvg::LineCap::Square => LineCap::Square, }; sl.join = match s.linejoin() { usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => LineJoin::Miter, usvg::LineJoin::Round => LineJoin::Round, usvg::LineJoin::Bevel => LineJoin::Bevel, }; sl.miter_limit = s.miterlimit().get() as f64; if let Some(d) = s.dasharray() { sl.dash = Some(Dash { pattern: d.iter().map(|v| *v as f64 * scale).collect(), offset: s.dashoffset() as f64 * scale, align_corners: false, }); } stroke = Some(AppearanceItem::Stroke(sl)); } let items = match p.paint_order() { usvg::PaintOrder::FillAndStroke => [fill, stroke], usvg::PaintOrder::StrokeAndFill => [stroke, fill], }; (Appearance { items: items.into_iter().flatten().collect(), ..Default::default() }, r) } fn path(&mut self, p: &usvg::Path, acc: Affine) -> Option { if !p.is_visible() { return None; } let path = PathData::from_bezpath(&bezpath(p.data(), acc)); if path.is_empty() { return None; } 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 .subpaths .into_iter() .map(|sp| { let id = self.doc.alloc_id(); let mut c = Node::new(id, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false }); c.appearance = appearance.clone(); Arc::new(c) }) .collect(); self.named(p.id(), NodeKind::Compound { children, rule: r }) } else { self.named(p.id(), NodeKind::Path { path, rule: r, live: None, clipping: false, guide: false }) }; n.appearance = appearance; Some(n) } fn image(&mut self, i: &usvg::Image, acc: Affine) -> Option { if !i.is_visible() { return None; } let (bytes, mime) = match i.kind() { usvg::ImageKind::JPEG(d) => (d, "image/jpeg"), usvg::ImageKind::PNG(d) => (d, "image/png"), usvg::ImageKind::GIF(d) => (d, "image/gif"), usvg::ImageKind::WEBP(d) => (d, "image/webp"), 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[..])) .with_guessed_format() .ok() .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)); // 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, placement: Default::default() }))) } /// 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 { 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), std::mem::take(&mut self.non_scaling), std::mem::take(&mut self.data), ); let art = self.group_node(tree.root(), acc); (self.links, self.hidden, self.uses, self.labels, self.non_scaling, self.data) = outer; art } }