Merge pull request #7749 from dotCooCoo/fix/svg-textpath-pathlength

SVG import: textPath startOffset follows the path's pathLength
This commit is contained in:
BFlat
2026-11-15 13:49:33 +07:00
committed by GitHub
3 changed files with 79 additions and 9 deletions
+42 -9
View File
@@ -758,7 +758,15 @@ struct Ctx {
/// The path each `<textPath>` read so far follows ([`Self::text_path`]); `None` where it has
/// no usable one. The styles of a `<use>` instance share it, so each path is found and parsed
/// once however many `<use>` elements show its text.
paths: Rc<RefCell<HashMap<roxmltree::NodeId, Option<Rc<BezPath>>>>>,
paths: Rc<RefCell<HashMap<roxmltree::NodeId, Option<Rc<FollowedPath>>>>>,
}
/// The path a `<textPath>` follows ([`Ctx::text_path`]).
struct FollowedPath {
path: BezPath,
/// The `pathLength` of the element the `<textPath>` points at, 0 or greater; `None` when the
/// `<textPath>` has its own `path` or the element has no such value.
author_length: Option<f64>,
}
#[cfg(test)]
@@ -1172,7 +1180,7 @@ impl Ctx {
/// The path a `<textPath>` follows, in the text's user space. It is read from the file once
/// ([`Self::paths`]).
fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<Rc<BezPath>> {
fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<Rc<FollowedPath>> {
if let Some(known) = self.paths.borrow().get(&tp.id()) {
return known.clone();
}
@@ -1184,13 +1192,14 @@ impl Ctx {
/// How many elements the path of `<textPath>` `tp` has, when a text built so far follows it
/// ([`Self::text_path`]); 0 for any other node.
fn path_elements(&self, tp: XNode) -> usize {
self.paths.borrow().get(&tp.id()).and_then(|p| p.as_deref()).map_or(0, |p| p.elements().len())
self.paths.borrow().get(&tp.id()).and_then(|p| p.as_deref()).map_or(0, |p| p.path.elements().len())
}
/// Find and parse the path `<textPath>` `tp` follows.
fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<BezPath> {
fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<FollowedPath> {
#[cfg(test)]
TEXT_PATH_READS.set(TEXT_PATH_READS.get() + 1);
let mut author_length = None;
let mut bp = match tp.attribute("path") {
Some(d) => BezPath::from_svg(d).ok()?,
None => {
@@ -1202,6 +1211,8 @@ impl Ctx {
};
let mut bp = BezPath::from_svg(&d).ok()?;
bp.apply_affine(parse_transform(el.attribute("transform")));
author_length =
el.attribute("pathLength").and_then(|v| v.trim().parse::<f64>().ok()).filter(|l| l.is_finite() && *l >= 0.0).map(f64::abs);
bp
}
};
@@ -1209,7 +1220,7 @@ impl Ctx {
bp = bp.reverse_subpaths();
}
let usable = bp.segments().next().is_some();
usable.then_some(bp)
usable.then_some(FollowedPath { path: bp, author_length })
}
/// The equivalent path of a basic shape (SVG 2 › Basic Shapes), as path data: what a
@@ -1458,7 +1469,8 @@ impl Ctx {
let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default());
let mut breaks: Vec<Break> = vec![];
let mut para_styles: Vec<ParaStyle> = vec![];
if let Some((tp, bp)) = path {
if let Some((tp, followed)) = path {
let bp = &followed.path;
// Along the path: dx is extra advance, dy shifts off the path.
let mut off = 0.0;
for i in 0..n {
@@ -1470,13 +1482,34 @@ impl Ctx {
}
let len: f64 = bp.segments().map(|s| s.arclen(1e-4)).sum();
let em = self.font_size(tp);
let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0];
// `startOffset` as a share of the path's length. A percentage is of the path's computed
// length. Any other length is in the units of the referenced element's `pathLength`
// when it has one, and a `pathLength` of 0 puts any offset other than 0 infinitely far
// along (SVG 2 › Paths › The pathLength attribute). An offset infinitely far along
// starts as far past the path's end, or before its start, as type may go.
let share = tp
.attribute("startOffset")
.and_then(|v| {
let o = self.length(v, em, len)?;
let share = match followed.author_length {
Some(p) if !v.trim().ends_with('%') => {
if o == 0.0 {
0.0
} else {
o / p
}
}
_ => o / len,
};
Some(if share.is_infinite() { vectorcraft_doc::PATH_LEAD_MAX.copysign(share) } else { share })
})
.unwrap_or(0.0);
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
// On a path of one closed subpath, text that starts before the path's start or past its
// end wraps around the path. On an open path text may start before the path's start,
// and its characters before the start are not drawn; text that starts past the end
// starts at the end. Other paths start it at the path's start or end.
let path = PathData::from_bezpath(&bp);
let path = PathData::from_bezpath(bp);
let wraps = path.subpaths.len() == 1 && path.is_closed();
let open = !path.is_closed();
let wrap = |s: f64| {
@@ -1484,7 +1517,7 @@ impl Ctx {
let s = s.rem_euclid(1.0);
if s < 1.0 { s } else { 0.0 }
};
let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| {
let start = Some(share + (dx[0] - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| {
if wraps {
wrap(s)
} else if open {
+5
View File
@@ -21,4 +21,9 @@ fn uses_of_a_text_on_a_path_read_the_path_once() {
// A path with no usable data is not read again either.
let (texts, reads, _) = prepared(&format!(r##"<defs><text id="t"><textPath href="#none">Path</textPath></text></defs>{uses}"##));
assert_eq!((texts, reads), (51, 1));
// The path's pathLength is read with it.
let (texts, reads, _) = prepared(&format!(
r##"<defs><path id="p" d="M0 20L150 20" pathLength="10"/><text id="t"><textPath href="#p" startOffset="5">Path</textPath></text></defs>{uses}"##
));
assert_eq!((texts, reads), (51, 1));
}
+32
View File
@@ -299,6 +299,38 @@ fn text_path_becomes_type_on_a_path() {
assert_eq!(t[2].para.justify, Justify::Left);
}
#[test]
fn text_path_start_offset_is_scaled_by_the_path_length() {
// A path 400 long whose pathLength is 100: a startOffset of 50 lies 200 along it. A
// percentage stays a share of the computed length. Huge values of both still give their
// ratio. With a pathLength of 0, an offset of 0 stays at the start and any other offset lies
// past the end, as does an offset too large to scale on a short path.
let d = open(
r##"<path id="p" d="M0 100 H400" pathLength="100" fill="none" stroke="black"/>
<path id="z" d="M0 200 H400" pathLength="0" fill="none" stroke="black"/>
<path id="h" d="M0 300 H400" pathLength="1e308" fill="none" stroke="black"/>
<path id="s" d="M0 400 H0.1" pathLength="1e-10" fill="none" stroke="black"/>
<text font-size="10"><textPath href="#p" startOffset="50">Half</textPath></text>
<text font-size="10"><textPath href="#p" startOffset="25%">Quarter</textPath></text>
<text font-size="10"><textPath href="#h" startOffset="5e307">Huge</textPath></text>
<text font-size="10"><textPath href="#z" startOffset="0">Zero</textPath></text>
<text font-size="10"><textPath href="#z" startOffset="50">Fifty</textPath></text>
<text font-size="10"><textPath href="#s" startOffset="1e308">Past</textPath></text>"##,
);
let t = texts(&d);
assert_eq!(t.len(), 6);
let start_of = |t: &TextObject| match t.kind {
TextKind::OnPath { start, .. } => start,
_ => panic!("{:?}", t.kind),
};
assert!(close(start_of(t[0]), 0.5, 1e-9), "{}", start_of(t[0]));
assert!(close(start_of(t[1]), 0.25, 1e-9), "{}", start_of(t[1]));
assert!(close(start_of(t[2]), 0.5, 1e-9), "{}", start_of(t[2]));
assert_eq!(start_of(t[3]), 0.0);
assert_eq!(start_of(t[4]), 1.0);
assert_eq!(start_of(t[5]), 1.0);
}
#[test]
fn text_on_a_closed_path_wraps_past_the_path_start() {
// A closed path 400 long: text anchored before its start, or offset past its end, wraps