SVG import: <textPath> follows basic shapes (circle, ellipse, rect, line, polyline, polygon)

A <textPath href="#id"> pointing at a basic shape imported as point type
with "textPath has no usable path", as only <path> 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 <path>. Shapes
that render nothing (zero radius or size) still fall back to point
type with the warning.

Fixes #1527
This commit is contained in:
Gert Pellin 2026-10-13 00:57:45 +02:00 committed by bflatastic
parent 48d5bd1aee
commit 3239e8b97e
2 changed files with 146 additions and 4 deletions

View File

@ -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
/// `<textPath>` 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<String> {
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<f64> { 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 `<text>`: the pending text and its `url(#…)` paints.
fn build(&self, xml: &roxmltree::Document, t: XNode, label: &str, warnings: &mut Vec<String>) -> Option<(PendingText, Vec<String>)> {
let preserve = t.ancestors().filter(|a| a.is_element()).find_map(|a| a.attribute((roxmltree::NS_XML_URI, "space"))) == Some("preserve");

View File

@ -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##"<circle id="c" cx="150" cy="150" r="100"/>
<ellipse id="e" cx="150" cy="150" rx="80" ry="40" transform="translate(10 0)"/>
<rect id="r" x="20" y="30" width="100" height="60" rx="10"/>
<rect id="s" x="20" y="30" width="100" height="60"/>
<line id="l" x1="10" y1="200" x2="210" y2="200"/>
<polyline id="pl" points="10,10 110,10 110,60"/>
<polygon id="pg" points="10 10, 60 10, 35 50"/>
<text font-size="10"><textPath xlink:href="#c">TEXT ON A CIRCLE</textPath></text>
<text font-size="10"><textPath href="#e">Ellipse</textPath></text>
<text font-size="10"><textPath href="#r">Rounded</textPath></text>
<text font-size="10"><textPath href="#s">Square</textPath></text>
<text font-size="10"><textPath href="#l">Line</textPath></text>
<text font-size="10"><textPath href="#pl">Polyline</textPath></text>
<text font-size="10"><textPath href="#pg">Polygon</textPath></text>"##))
.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::<Vec<_>>());
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##"<circle id="c" cx="5" cy="5" r="0"/><text x="5" y="10"><textPath href="#c">Zero</textPath></text>"##)).unwrap();
assert!(matches!(only_text(&d).kind, TextKind::Point));
assert!(w.iter().any(|w| w.contains("textPath")), "{w:?}");
}