From 3239e8b97e80668e28d99ea185545ccb3db1998b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 00:57:45 +0200 Subject: [PATCH] SVG import: follows basic shapes (circle, ellipse, rect, line, polyline, polygon) A pointing at a basic shape imported as point type with "textPath has no usable path", as only was followed. SVG 2 allows any basic shape, and Inkscape writes one whenever text is put on a circle. The shape now becomes its equivalent path as SVG 2 defines it: circles and ellipses start at 3 o'clock and rounded rects after the top-left corner, all clockwise; lines, polylines and polygons follow their points. The shape's transform applies as for a . Shapes that render nothing (zero radius or size) still fall back to point type with the warning. Fixes #1527 --- crates/svg/src/import/text.rs | 85 +++++++++++++++++++++++++++++++-- crates/svg/tests/import_text.rs | 65 +++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 5946665f..23aa3c57 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -468,10 +468,11 @@ impl Ctx { None => { let id = href(tp)?.trim().strip_prefix('#')?; let el = xml.descendants().find(|n| n.is_element() && n.attribute("id") == Some(id))?; - if el.tag_name().name() != "path" { - return None; - } - let mut bp = BezPath::from_svg(el.attribute("d")?).ok()?; + let d = match el.tag_name().name() { + "path" => el.attribute("d")?.to_string(), + _ => self.shape_path(el)?, + }; + let mut bp = BezPath::from_svg(&d).ok()?; bp.apply_affine(parse_transform(el.attribute("transform"))); bp } @@ -483,6 +484,82 @@ impl Ctx { usable.then_some(bp) } + /// The equivalent path of a basic shape (SVG 2 › Basic Shapes), as path data: what a + /// `` that points at it follows. Circles and ellipses start at 3 o'clock and rounded + /// rects at the end of the top-left corner, all running clockwise; lines and polylines follow + /// their points. `None` for other elements and for shapes that render nothing. + fn shape_path(&self, el: XNode) -> Option { + let (vw, vh) = self.viewport; + let len = |a: &str, pct: f64| el.attribute(a).and_then(|v| self.length(v, DEFAULT_FONT_SIZE, pct)); + let x = |a: &str| len(a, vw).unwrap_or(0.0); + let y = |a: &str| len(a, vh).unwrap_or(0.0); + let points = || -> Vec { el.attribute("points").map(|v| list(v).map_while(|t| t.parse().ok()).collect()).unwrap_or_default() }; + let ellipse = |cx: f64, cy: f64, rx: f64, ry: f64| { + (rx > 0.0 && ry > 0.0).then(|| { + format!( + "M{} {} A{rx} {ry} 0 0 1 {cx} {} A{rx} {ry} 0 0 1 {} {cy} A{rx} {ry} 0 0 1 {cx} {} A{rx} {ry} 0 0 1 {} {cy}Z", + cx + rx, + cy, + cy + ry, + cx - rx, + cy - ry, + cx + rx + ) + }) + }; + match el.tag_name().name() { + "circle" => { + let r = len("r", ((vw * vw + vh * vh) / 2.0).sqrt())?; + ellipse(x("cx"), y("cy"), r, r) + } + "ellipse" => { + let (rx, ry) = (len("rx", vw), len("ry", vh)); + let (rx, ry) = (rx.or(ry)?, ry.or(rx)?); + ellipse(x("cx"), y("cy"), rx, ry) + } + "rect" => { + let (x0, y0, w, h) = (x("x"), y("y"), len("width", vw)?, len("height", vh)?); + if w <= 0.0 || h <= 0.0 { + return None; + } + let (rx, ry) = (len("rx", vw).filter(|v| *v > 0.0), len("ry", vh).filter(|v| *v > 0.0)); + let (rx, ry) = (rx.or(ry).unwrap_or(0.0).min(w / 2.0), ry.or(rx).unwrap_or(0.0).min(h / 2.0)); + if rx <= 0.0 || ry <= 0.0 { + return Some(format!("M{x0} {y0} H{} V{} H{x0}Z", x0 + w, y0 + h)); + } + let (x1, y1) = (x0 + w, y0 + h); + Some(format!( + "M{} {y0} H{} A{rx} {ry} 0 0 1 {x1} {} V{} A{rx} {ry} 0 0 1 {} {y1} H{} A{rx} {ry} 0 0 1 {x0} {} V{} A{rx} {ry} 0 0 1 {} {y0}Z", + x0 + rx, + x1 - rx, + y0 + ry, + y1 - ry, + x1 - rx, + x0 + rx, + y1 - ry, + y0 + ry, + x0 + rx + )) + } + "line" => Some(format!("M{} {} L{} {}", x("x1"), y("y1"), x("x2"), y("y2"))), + tag @ ("polyline" | "polygon") => { + let pts = points(); + let mut d = String::new(); + for (i, [x, y]) in pts.as_chunks::<2>().0.iter().enumerate() { + let _ = write!(d, "{}{x} {y} ", if i == 0 { 'M' } else { 'L' }); + } + if d.is_empty() { + return None; + } + if tag == "polygon" { + d.push('Z'); + } + Some(d) + } + _ => None, + } + } + /// Read one ``: the pending text and its `url(#…)` paints. fn build(&self, xml: &roxmltree::Document, t: XNode, label: &str, warnings: &mut Vec) -> Option<(PendingText, Vec)> { let preserve = t.ancestors().filter(|a| a.is_element()).find_map(|a| a.attribute((roxmltree::NS_XML_URI, "space"))) == Some("preserve"); diff --git a/crates/svg/tests/import_text.rs b/crates/svg/tests/import_text.rs index 234eaf12..ce68fd65 100644 --- a/crates/svg/tests/import_text.rs +++ b/crates/svg/tests/import_text.rs @@ -518,3 +518,68 @@ fn gradients_on_scaled_text_stay_in_place() { let (start, end) = (t.xf * geom.start, t.xf * geom.end); assert!(start.distance(Point::new(40.0, 0.0)) < 1e-6 && end.distance(Point::new(240.0, 0.0)) < 1e-6, "{start:?} {end:?}"); } + +/// The start of a type-on-a-path object's path and a point a little way along it, in document +/// space. +fn path_start(t: &TextObject) -> (Point, Point) { + use vectorcraft_geom::kurbo::{ParamCurve, PathEl}; + let TextKind::OnPath { path, .. } = &t.kind else { panic!("{:?}", t.kind) }; + let mut bp = path.to_bezpath(); + bp.apply_affine(t.xf); + let PathEl::MoveTo(p0) = bp.elements()[0] else { panic!("{bp:?}") }; + let next = bp.segments().next().unwrap().eval(0.05); + (p0, next) +} + +#[test] +fn text_path_follows_basic_shapes_as_their_equivalent_paths() { + let (d, w) = import_with_report(&svg(r##" + + + + + + + TEXT ON A CIRCLE + Ellipse + Rounded + Square + Line + Polyline + Polygon"##)) + .unwrap(); + assert!(!w.iter().any(|w| w.contains("textPath")), "{w:?}"); + let t = texts(&d); + assert_eq!(t.len(), 7); + assert!(t.iter().all(|t| matches!(t.kind, TextKind::OnPath { .. })), "{:?}", t.iter().map(|t| &t.kind).collect::>()); + let near = |a: Point, b: Point| (a - b).hypot() < 1e-6; + // Circle: starts at 3 o'clock and runs clockwise (down first, y pointing down), around the circle. + let (p0, next) = path_start(t[0]); + assert!(near(p0, Point::new(250.0, 150.0)) && next.y > p0.y && next.x < p0.x, "{p0:?} {next:?}"); + let TextKind::OnPath { path, .. } = &t[0].kind else { panic!("not on a path") }; + let b = t[0].xf.transform_rect_bbox(path.bounds().unwrap()); + assert!(close(b.x0, 50.0, 1e-6) && close(b.y0, 50.0, 1e-6) && close(b.x1, 250.0, 1e-6) && close(b.y1, 250.0, 1e-6), "{b:?}"); + assert!(path.is_closed()); + // Ellipse, with the shape's own transform. + let (p0, next) = path_start(t[1]); + assert!(near(p0, Point::new(240.0, 150.0)) && next.y > p0.y, "{p0:?} {next:?}"); + // Rounded rect: after the top-left corner, along the top edge to the right. + let (p0, next) = path_start(t[2]); + assert!(near(p0, Point::new(30.0, 30.0)) && next.x > p0.x && close(next.y, 30.0, 1e-9), "{p0:?} {next:?}"); + let (p0, next) = path_start(t[3]); + assert!(near(p0, Point::new(20.0, 30.0)) && next.x > p0.x, "{p0:?} {next:?}"); + // Line, polyline and polygon follow their points. + assert!(near(path_start(t[4]).0, Point::new(10.0, 200.0))); + assert!(near(path_start(t[5]).0, Point::new(10.0, 10.0))); + let TextKind::OnPath { path, .. } = &t[6].kind else { panic!("not on a path") }; + assert!(path.is_closed() && near(path_start(t[6]).0, Point::new(10.0, 10.0))); + assert_eq!(t[0].plain_text(), "TEXT ON A CIRCLE"); +} + +#[test] +fn text_path_to_an_empty_shape_still_falls_back_to_point_type() { + let (d, w) = + import_with_report(&svg(r##"Zero"##)).unwrap(); + assert!(matches!(only_text(&d).kind, TextKind::Point)); + assert!(w.iter().any(|w| w.contains("textPath")), "{w:?}"); +}