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:
parent
48d5bd1aee
commit
3239e8b97e
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue