SVG: circles and ellipses import as four-anchor ellipses

usvg turns each <circle> and <ellipse> into arcs and then into a number
of cubics that depends on f32 rounding: an ellipse that export wrote as
<ellipse> could come back with eight anchors instead of the four it was
written from. The renderer flattens the two outlines differently, so a
round trip of a flat filled ellipse redrew 158 edge pixels by up to 43
levels, and prop_svg::plain_art_survives_visually failed on main.

Import now records the ids of <circle> and <ellipse> elements in the
prepass and rebuilds each as the ellipse VectorCraft draws: four smooth
anchors on the axes, starting at the right as SVG defines, with handles
KAPPA of the radii long, then transformed as before.

Tests: prop_svg::filled_ellipse_on_the_artboard_edge_survives_visually
(the failing case) and basic_shapes::circles_and_ellipses_import_as_the_ellipse_drawn
fail without the change.
This commit is contained in:
dotCooCoo
2026-11-02 02:49:36 -08:00
parent 4ee0ef231a
commit 60e1600caa
7 changed files with 139 additions and 4 deletions
+3
View File
@@ -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<String>,
/// The groups made of them, made sublayers where they sit in a layer ([`promote_sublayers`]).
sublayer_groups: HashSet<NodeId>,
/// Ids of the `<circle>` and `<ellipse>` elements ([`prepass`]).
ellipses: HashSet<String>,
/// Points per screen pixel (the root's user unit before its `viewBox`): what a non-scaling
/// stroke's width is measured in.
screen: f64,
+44 -1
View File
@@ -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,47 @@ pub(super) fn bezpath(p: &usvg::tiny_skia_path::Path, m: Affine) -> BezPath {
bp
}
/// The path usvg makes of a `<circle>` or `<ellipse>` (`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 `<ellipse>`. `None` when `p` is not such an outline.
pub(super) fn drawn_ellipse(p: &usvg::tiny_skia_path::Path) -> Option<PathData> {
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: the tolerance follows the size of the numbers.
let tol = 1e-5 * (c.x.abs() + c.y.abs() + rx + ry).max(1.0);
let axes = [Point::new(x1, c.y), Point::new(c.x, y1), Point::new(x0, c.y), Point::new(c.x, y0)];
let on_ellipse = |q: &Point| (((q.x - c.x) / rx).powi(2) + ((q.y - c.y) / ry).powi(2) - 1.0).abs() <= 1e-3;
if !closed
|| rx <= tol
|| ry <= tol
|| 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)>) {
+6 -3
View File
@@ -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,
+12
View File
@@ -28,6 +28,9 @@ pub(super) struct Found {
pub(super) scripts: Vec<String>,
/// Ids of the `<g>` elements marked as layers ([`marked_layer`]).
pub(super) sublayers: HashSet<String>,
/// Ids of the `<circle>` and `<ellipse>` elements, which come back as the ellipse VectorCraft
/// draws ([`drawn_ellipse`](super::convert::drawn_ellipse)).
pub(super) ellipses: HashSet<String>,
pub(super) warnings: Vec<String>,
}
@@ -115,6 +118,8 @@ impl Edits {
/// * objects with event attributes get an id when they have none, recorded with them
/// ([`user_events`]), and the `<script>` links are recorded ([`linked_scripts`]);
/// * so do objects with attributes in other applications' namespaces ([`foreign`]);
/// * so do `<circle>` and `<ellipse>` elements, recorded: usvg makes their arcs into a varying
/// number of curves, and they come back as the ellipse VectorCraft draws;
/// * linked files are read ([`files`]);
/// * percentage lengths in a `<symbol>` with a `viewBox` become numbers ([`symbol_units`]);
/// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]).
@@ -129,10 +134,12 @@ pub(super) fn prepass<'s>(svg: &'s str, markers: &str, xml: &roxmltree::Document
let not = markers.contains(":not(");
let events = SVG_EVENTS.iter().any(|e| markers.contains(e));
let foreign_attrs = markers.contains("xmlns:") && has_foreign_attributes(xml);
let ellipses = ["<circle", ":circle", "<ellipse", ":ellipse"].iter().any(|t| markers.contains(t));
if !css_transform
&& !not
&& !events
&& !foreign_attrs
&& !ellipses
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode", "script"]
.iter()
.any(|t| markers.contains(t))
@@ -170,6 +177,11 @@ pub(super) fn prepass<'s>(svg: &'s str, markers: &str, xml: &roxmltree::Document
}
}
}
if ellipses {
for e in elements().filter(|e| matches!(e.tag_name().name(), "circle" | "ellipse")) {
found.ellipses.insert(edits.id(svg, e));
}
}
if markers.contains("script") {
found.scripts = linked_scripts(xml, &mut found.warnings);
}
+45
View File
@@ -134,3 +134,48 @@ fn shape_elements_reimport_with_the_same_geometry() {
assert!(!far, "{a:?} came back as {b:?}");
}
}
/// The paths of the objects in `d`, in order.
fn paths(d: &Document) -> Vec<PathData> {
let mut v = Vec::new();
d.walk(|n| v.extend(n.path_data().cloned()));
v
}
#[test]
fn circles_and_ellipses_import_as_the_ellipse_drawn() {
// What export writes comes back with the anchors and handles it was written from.
let drawn = [shapes::ellipse(Rect::new(10.0, 80.0, 50.0, 120.0)), shapes::ellipse(Rect::new(60.0, 80.0, 140.0, 120.0))];
let d = doc(drawn.iter().map(|p| (p.clone(), false)).collect());
let back = paths(&import(&export(&d, &ExportOptions::default())).unwrap());
assert_eq!(back.len(), 2);
for (want, got) in drawn.iter().zip(&back) {
let [w, g] = [want, got].map(|p| &p.subpaths[0].anchors);
assert!(got.subpaths.len() == 1 && got.subpaths[0].closed && g.len() == 4, "{got:?}");
// The same four anchors, starting on the right as an SVG ellipse does.
for a in g {
let same = w.iter().any(|b| a.p.distance(b.p) < 1e-4 && a.h_in.distance(b.h_in) < 1e-4 && a.h_out.distance(b.h_out) < 1e-4);
assert!(same, "{a:?} is not one of {w:?}");
}
assert!((g[0].p.x - want.bounds().unwrap().x1).abs() < 1e-4, "{g:?}");
}
// From another app, with a transform: four anchors on the axes, transformed.
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<circle cx="50" cy="50" r="40" transform="translate(10 20)"/>
<ellipse cx="100" cy="100" rx="60" ry="20" transform="rotate(30 100 100)"/>
<ellipse cx="36.255" cy="154.118" rx="36.255" ry="5.421"/></svg>"#;
let back = paths(&import(svg).unwrap());
assert_eq!(back.len(), 3);
let circle = &back[0].subpaths[0].anchors;
let want: Vec<Point> = [(100.0, 70.0), (60.0, 110.0), (20.0, 70.0), (60.0, 30.0)].map(|(x, y)| Point::new(x, y)).into();
assert_eq!(circle.len(), 4);
for (a, w) in circle.iter().zip(&want) {
assert!(a.p.distance(*w) < 1e-4, "{circle:?}");
}
let turned = &back[1].subpaths[0].anchors;
let r = Affine::rotate_about(30f64.to_radians(), Point::new(100.0, 100.0));
assert!(turned.len() == 4 && turned[0].p.distance(r * Point::new(160.0, 100.0)) < 1e-3, "{turned:?}");
// However many curves the arcs of a flat ellipse make.
let flat = &back[2].subpaths[0].anchors;
assert!(flat.len() == 4 && flat[0].p.distance(Point::new(72.51, 154.118)) < 1e-3, "{flat:?}");
}
+28
View File
@@ -92,6 +92,34 @@ proptest! {
}
}
#[test]
fn filled_ellipse_on_the_artboard_edge_survives_visually() {
use vectorcraft_geom::Point;
use vectorcraft_geom::path::{Anchor, AnchorKind, SubPath};
let a = |p: (f64, f64), i: (f64, f64), o: (f64, f64)| Anchor {
p: Point::new(p.0, p.1),
h_in: Point::new(i.0, i.1),
h_out: Point::new(o.0, o.1),
kind: AnchorKind::Smooth,
};
let path = PathData::new(vec![SubPath::new(
vec![
a((0.0, 154.1183786634012), (0.0, 157.11240210742653), (0.0, 151.12435521937584)),
a((36.254761132097215, 148.69721965992943), (16.231809450081727, 148.69721965992943), (56.277712814112704, 148.69721965992943)),
a((72.50952226419443, 154.1183786634012), (72.50952226419443, 151.12435521937584), (72.50952226419443, 157.11240210742653)),
a((36.254761132097215, 159.53953766687295), (56.277712814112704, 159.53953766687295), (16.231809450081727, 159.53953766687295)),
],
true,
)]);
let mut b = DocBuilder::new(200.0, 200.0);
b.path(path, Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0), |n| n.opacity = 0.983_042_96);
let d = b.build();
let svg = export(&d, &ExportOptions::default());
assert!(svg.contains("<ellipse "), "{svg}");
let back = import(&svg).unwrap();
assert_similar(&render_artboard(&d), &render_artboard(&back), 6.0, 0.002);
}
#[test]
fn rich_fixture_exports_with_all_options() {
let s = fixtures::rich_session();
+1
View File
@@ -1084,6 +1084,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does.
SVG export (Save As, Export As, Export for Screens, Asset Export and the clipboard share the writer) writes simple shapes as SVG basic shapes, as the reference app does (an ellipse is an `ellipse` tag until an anchor is added, [forum](https://community.adobe.com/t5/illustrator-discussions/trouble-exporting-shape-with-points-to-svg/td-p/10720325); rectangles come out as `<rect>` and `<polygon>`, [forum](https://community.adobe.com/t5/illustrator-discussions/force-svg-export-using-all-lt-rect-gt/td-p/11282378)): an axis-aligned rectangle as `<rect>`, a circle or ellipse drawn with four standard arcs as `<circle>`/`<ellipse>`, a straight line as `<line>`, and other straight-segment paths as `<polygon>`/`<polyline>`, at the written precision (#4652). A rectangle or ellipse with a dashed stroke, whose dashes would start elsewhere, a turned ellipse, curves and compound paths stay `<path>`. `crates/svg/tests/it/basic_shapes.rs` checks each element, that the picture renders as the canvas does and that the geometry reimports.
SVG import reads each `<circle>` and `<ellipse>` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `<ellipse>` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`).
SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`).
In a `.ai` or EPS opened from its editing data, type has its text object's opacity and blend mode, whether it is made from the file's text document or taken from the page. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning.
`.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`).