diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 18f4f088d..0201c591e 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -161,6 +161,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul foreign: found.foreign, sublayers: found.sublayers, sublayer_groups: HashSet::new(), + ellipses: found.ellipses, screen: k, converted_nodes: 1, max_nodes, @@ -318,6 +319,8 @@ struct Importer { sublayers: HashSet, /// The groups made of them, made sublayers where they sit in a layer ([`promote_sublayers`]). sublayer_groups: HashSet, + /// Ids of the `` and `` elements ([`prepass`]). + ellipses: 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, diff --git a/crates/svg/src/import/convert.rs b/crates/svg/src/import/convert.rs index 40c2cf577..2a777b342 100644 --- a/crates/svg/src/import/convert.rs +++ b/crates/svg/src/import/convert.rs @@ -1,4 +1,6 @@ -use super::{Affine, Arc, BezPath, BlendMode, Color, FillRule, GradientStop, HashMap, Node, Point, XNode, roxmltree}; +use super::{Affine, Arc, BezPath, BlendMode, Color, FillRule, GradientStop, HashMap, Node, PathData, Point, XNode, roxmltree}; +use vectorcraft_geom::path::{Anchor, AnchorKind, SubPath}; +use vectorcraft_geom::shapes::KAPPA; /// `n` with every object id (its descendants' and its mask art's too) zeroed, to compare art. pub(super) fn without_ids(n: &Node) -> Node { @@ -135,6 +137,50 @@ pub(super) fn bezpath(p: &usvg::tiny_skia_path::Path, m: Affine) -> BezPath { bp } +/// The path usvg makes of a `` or `` (`p`: one closed outline of arcs from the +/// right end of the horizontal axis round through the bottom) as the ellipse VectorCraft draws: four +/// smooth anchors on the axes in the same order, each quarter a cubic with handles [`KAPPA`] of the +/// radii long, the ellipse export writes as ``. `None` when `p` is not such an outline. +pub(super) fn drawn_ellipse(p: &usvg::tiny_skia_path::Path) -> Option { + use usvg::tiny_skia_path::PathSegment as S; + let mut ends = vec![]; + let mut closed = false; + for s in p.segments() { + match s { + S::MoveTo(q) if ends.is_empty() => ends.push(Point::new(q.x as f64, q.y as f64)), + S::LineTo(q) | S::QuadTo(_, q) | S::CubicTo(_, _, q) if !ends.is_empty() && !closed => ends.push(Point::new(q.x as f64, q.y as f64)), + S::Close if !closed => closed = true, + _ => return None, + } + } + let (x0, x1) = ends.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.x), hi.max(q.x))); + let (y0, y1) = ends.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y))); + let (c, rx, ry) = (Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0), (x1 - x0) / 2.0, (y1 - y0) / 2.0); + // usvg works in f32: points match within a few of its steps at the size of the coordinates, and + // a radius of fewer than two steps does not survive the conversion. + let step = f64::from(f32::EPSILON) * [x0, x1, y0, y1].iter().fold(1.0f64, |m, v| m.max(v.abs())); + let tol = 16.0 * step; + let axes = [Point::new(x1, c.y), Point::new(c.x, y1), Point::new(x0, c.y), Point::new(c.x, y0)]; + let off = 1e-3 + 4.0 * tol / rx.min(ry); + let on_ellipse = |q: &Point| (((q.x - c.x) / rx).powi(2) + ((q.y - c.y) / ry).powi(2) - 1.0).abs() <= off; + if !closed + || rx < 2.0 * step + || ry < 2.0 * step + || ends.first().is_none_or(|s| s.distance(axes[0]) > tol) + || !axes.iter().all(|a| ends.iter().any(|q| q.distance(*a) <= tol)) + || !ends.iter().all(on_ellipse) + { + return None; + } + let anchors = (0..4) + .map(|i| { + let (p, next, prev) = (axes[i], axes[(i + 1) % 4], axes[(i + 3) % 4]); + Anchor { p, h_in: p + (prev - c) * KAPPA, h_out: p + (next - c) * KAPPA, kind: AnchorKind::Smooth } + }) + .collect(); + Some(PathData::single(SubPath::new(anchors, true))) +} + /// The shapes of clip path `cp` on an element whose user space `ts` maps to the document. pub(super) fn clip_shapes(cp: &usvg::ClipPath, ts: Affine) -> Vec<(BezPath, FillRule)> { fn collect(g: &usvg::Group, m: Affine, out: &mut Vec<(BezPath, FillRule)>) { diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 0e0b8127e..eeb0a4804 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -1,4 +1,4 @@ -use super::convert::{aff, bezpath, blend, clip_shapes, gradient_stops, is_plain, made_up, rule, uncut, unique_name, without_ids}; +use super::convert::{aff, bezpath, blend, clip_shapes, drawn_ellipse, gradient_stops, is_plain, made_up, rule, uncut, unique_name, without_ids}; use super::layers::filter_names; use super::prepass::link; use super::{ @@ -601,7 +601,8 @@ impl Importer { if !p.is_visible() { return None; } - let path = PathData::from_bezpath(&bezpath(p.data(), acc)); + let ellipse = if self.ellipses.contains(p.id()) { drawn_ellipse(p.data()) } else { None }; + let path = ellipse.map_or_else(|| PathData::from_bezpath(&bezpath(p.data(), acc)), |e| e.transformed(acc)); if path.is_empty() { return None; } @@ -683,9 +684,10 @@ impl Importer { std::mem::take(&mut self.non_scaling), std::mem::take(&mut self.data), std::mem::take(&mut self.events), + std::mem::take(&mut self.ellipses), ); let art = self.group_node(tree.root(), acc); - (self.links, self.hidden, self.uses, self.labels, self.non_scaling, self.data, self.events) = outer; + (self.links, self.hidden, self.uses, self.labels, self.non_scaling, self.data, self.events, self.ellipses) = outer; art } } @@ -720,6 +722,7 @@ mod tests { foreign: HashMap::new(), sublayers: HashSet::new(), sublayer_groups: HashSet::new(), + ellipses: HashSet::new(), screen: 1.0, converted_nodes: 0, max_nodes: 1000, diff --git a/crates/svg/src/import/prepass.rs b/crates/svg/src/import/prepass.rs index 8018138dc..245392fda 100644 --- a/crates/svg/src/import/prepass.rs +++ b/crates/svg/src/import/prepass.rs @@ -28,6 +28,9 @@ pub(super) struct Found { pub(super) scripts: Vec, /// Ids of the `` elements marked as layers ([`marked_layer`]). pub(super) sublayers: HashSet, + /// Ids of the `` and `` elements, which come back as the ellipse VectorCraft + /// draws ([`drawn_ellipse`](super::convert::drawn_ellipse)). + pub(super) ellipses: HashSet, pub(super) warnings: Vec, } @@ -37,6 +40,11 @@ pub(crate) struct Edits { list: Vec<(std::ops::Range, String)>, /// The ids made up so far, by element. ids: HashMap, + /// The numbers `n` of the `{MADE_UP_ID}n` the file names (as an element's id, in a style + /// sheet's `#id` selector or in a `#id` link to no element), read when the first id is made up. + taken: Option>, + /// The number the next made-up id is tried with: the numbers below it are made up or taken. + next: usize, /// Declarations added to elements' `style`s, by element: (where they go, whether the element /// has a `style` attribute, the declarations, XML-escaped). styles: HashMap, @@ -47,7 +55,8 @@ impl Edits { self.list.push((at..at, text)); } - /// The id of element `n` (in `svg`), made up and written in when it has none. + /// The id of element `n` (in `svg`), made up and written in when it has none. A made-up id is + /// one the file does not name ([`named_made_up_ids`]). pub(super) 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(); @@ -55,7 +64,12 @@ impl Edits { if let Some(id) = self.ids.get(&n.id()) { return id.clone(); } - let id = format!("{MADE_UP_ID}{}", self.ids.len()); + let taken = self.taken.get_or_insert_with(|| named_made_up_ids(n.document())); + while taken.contains(&self.next) { + self.next += 1; + } + let id = format!("{MADE_UP_ID}{}", self.next); + self.next += 1; self.set(svg, n, "id", &id); self.ids.insert(n.id(), id.clone()); id @@ -105,6 +119,24 @@ impl Edits { } } +/// The number after each [`MADE_UP_ID`] followed by digits in the attribute values and text of +/// `xml`, read with their character references decoded (numbers too large for a `usize` left out). +fn named_made_up_ids(xml: &roxmltree::Document) -> HashSet { + fn numbers(s: &str) -> impl Iterator + '_ { + s.match_indices(MADE_UP_ID).filter_map(|(at, _)| { + let rest = s.get(at + MADE_UP_ID.len()..)?; + let digits = rest.bytes().take_while(u8::is_ascii_digit).count(); + rest.get(..digits)?.parse().ok() + }) + } + let mut taken = HashSet::new(); + for n in xml.descendants() { + taken.extend(n.attributes().flat_map(|a| numbers(a.value()))); + taken.extend(n.text().filter(|_| n.is_text()).into_iter().flat_map(numbers)); + } + taken +} + /// 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 @@ -115,6 +147,9 @@ impl Edits { /// * objects with event attributes get an id when they have none, recorded with them /// ([`user_events`]), and the `