From 9762e124fcd7da4c1796807ed1188cfb262471c7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 21:02:59 -0800 Subject: [PATCH] SVG import: textPath startOffset follows the path's pathLength A startOffset given as a length now counts in the units of the referenced element's pathLength, so pathLength="100" on a path 400 long puts startOffset="50" at 200. A percentage stays a share of the computed length. A pathLength of 0 keeps an offset of 0 at the start and puts any other offset past the end, and an offset too large to scale starts at the path's end instead of its start. https://www.w3.org/TR/SVG2/text.html#TextLayoutAlgorithm https://www.w3.org/TR/SVG2/paths.html#PathLengthAttribute --- crates/svg/src/import/text.rs | 51 ++++++++++++++++++++++++----- crates/svg/src/import/text/tests.rs | 5 +++ crates/svg/tests/it/import_text.rs | 32 ++++++++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 5d700f9c5a..ef3934c2d6 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -758,7 +758,15 @@ struct Ctx { /// The path each `` read so far follows ([`Self::text_path`]); `None` where it has /// no usable one. The styles of a `` instance share it, so each path is found and parsed /// once however many `` elements show its text. - paths: Rc>>>>, + paths: Rc>>>>, +} + +/// The path a `` follows ([`Ctx::text_path`]). +struct FollowedPath { + path: BezPath, + /// The `pathLength` of the element the `` points at, 0 or greater; `None` when the + /// `` has its own `path` or the element has no such value. + author_length: Option, } #[cfg(test)] @@ -1172,7 +1180,7 @@ impl Ctx { /// The path a `` 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> { + fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option> { 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 `` `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 `` `tp` follows. - fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { + fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { #[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::().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 = vec![]; let mut para_styles: Vec = 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 { diff --git a/crates/svg/src/import/text/tests.rs b/crates/svg/src/import/text/tests.rs index 1508997dcc..a6bcf07265 100644 --- a/crates/svg/src/import/text/tests.rs +++ b/crates/svg/src/import/text/tests.rs @@ -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##"Path{uses}"##)); assert_eq!((texts, reads), (51, 1)); + // The path's pathLength is read with it. + let (texts, reads, _) = prepared(&format!( + r##"Path{uses}"## + )); + assert_eq!((texts, reads), (51, 1)); } diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 72b476eb24..fa91f8d162 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -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##" + + + + Half + Quarter + Huge + Zero + Fifty + Past"##, + ); + 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