diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs
index 87e100f1b9..f7ba77cea2 100644
--- a/crates/svg/src/import/text.rs
+++ b/crates/svg/src/import/text.rs
@@ -1425,10 +1425,18 @@ impl Ctx {
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];
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
- // Text that would start before the path's beginning starts at it, with all its glyphs
- // on the path (SVG does not draw the glyphs that fall off it).
- let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0));
- obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start, end: None };
+ // On a path of one closed subpath, text that starts before the path's start or past its
+ // end wraps around the path. Otherwise it starts at the path's start or end.
+ let path = PathData::from_bezpath(&bp);
+ let wraps = path.subpaths.len() == 1 && path.is_closed();
+ let wrap = |s: f64| {
+ // `rem_euclid` can round a tiny negative value up to 1.
+ 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| if wraps { wrap(s) } else { s.clamp(0.0, 1.0) });
+ obj.kind = TextKind::OnPath { path, start, end: None };
obj.xf = Affine::IDENTITY;
} else if vertical {
// Type on a vertical path: dy is extra advance, dx shifts across the column.
diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs
index 0bc8c34dfd..084c3172f3 100644
--- a/crates/svg/tests/it/import_text.rs
+++ b/crates/svg/tests/it/import_text.rs
@@ -299,6 +299,84 @@ fn text_path_becomes_type_on_a_path() {
assert_eq!(t[2].para.justify, Justify::Left);
}
+#[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
+ // around the path instead of stopping at its start or end.
+ let d = open(
+ r##"
+ Wave
+ Wave
+ Wave
+ Wave
+ Wave
+ Wave
+
+ Wave
+
+ Wave
+ Wave"##,
+ );
+ let t = texts(&d);
+ assert_eq!(t.len(), 9);
+ let start_of = |t: &TextObject| match t.kind {
+ TextKind::OnPath { start, .. } => start,
+ _ => panic!("{:?}", t.kind),
+ };
+ let width: f64 = layout(FontDb::global(), t[0]).glyphs.iter().map(|g| g.advance).sum();
+ assert!(width > 0.0);
+ // Ending at the path's start: the text starts its width before the end of the loop.
+ assert!(close(start_of(t[0]), 1.0 - width / 400.0, 1e-6), "{} {width}", start_of(t[0]));
+ assert!(close(start_of(t[1]), 0.1, 1e-9), "{}", start_of(t[1]));
+ // Right-to-left text starts at its edge toward the path's end, here the path's start.
+ assert!(close(start_of(t[2]), 1.0 - width / 400.0, 1e-6), "{} {width}", start_of(t[2]));
+ // Centered on the path's start: half the text before it, half after.
+ assert!(close(start_of(t[3]), 1.0 - width / 800.0, 1e-6), "{} {width}", start_of(t[3]));
+ // A negative startOffset, in user units or as a percentage, counts back from the path's end.
+ assert!(close(start_of(t[4]), 0.9, 1e-9), "{}", start_of(t[4]));
+ assert!(close(start_of(t[5]), 0.9, 1e-9), "{}", start_of(t[5]));
+ // A path of two closed subpaths, and an open path, do not wrap: the text starts at the path's
+ // start or end.
+ assert_eq!(start_of(t[6]), 0.0);
+ assert_eq!(start_of(t[7]), 0.0);
+ assert_eq!(start_of(t[8]), 1.0);
+
+ // Laid out, the end-anchored text draws every glyph up the left side and ends at the start
+ // point; the centered text crosses the start point from the left side onto the top.
+ let lay = layout(FontDb::global(), t[0]);
+ assert!(!lay.overflow);
+ assert_eq!(lay.glyphs.len(), 4);
+ for g in &lay.glyphs {
+ let o = t[0].xf * g.origin;
+ assert!(close(o.x, 50.0, 0.05) && o.y > 50.0 && o.y < 150.0, "{o:?}");
+ }
+ let last = lay.glyphs.last().map(|g| t[0].xf * (g.origin + baseline_dir(g.angle) * g.advance));
+ assert!(last.is_some_and(|p| close(p.x, 50.0, 0.05) && close(p.y, 50.0, 0.05)), "{last:?}");
+ let lay = layout(FontDb::global(), t[3]);
+ assert!(!lay.overflow);
+ assert_eq!(lay.glyphs.len(), 4);
+ let first = t[3].xf * lay.glyphs[0].origin;
+ assert!(close(first.x, 50.0, 0.05) && first.y > 50.0, "{first:?}");
+ let last = t[3].xf * lay.glyphs[3].origin;
+ assert!(close(last.y, 50.0, 0.05) && last.x > 50.0, "{last:?}");
+
+ // Text longer than the loop draws its first characters, whatever its anchor.
+ let d = open(
+ r##"
+ Wave Wave Wave"##,
+ );
+ let t = only_text(&d);
+ let lay = layout(FontDb::global(), t);
+ assert!(lay.overflow);
+ assert!(!lay.glyphs.is_empty() && lay.glyphs.len() < 14, "{}", lay.glyphs.len());
+ assert!(lay.glyphs.iter().enumerate().all(|(i, g)| g.byte == i), "{:?}", lay.glyphs.iter().map(|g| g.byte).collect::>());
+}
+
+/// The unit vector along a glyph's baseline.
+fn baseline_dir(angle: f64) -> vectorcraft_geom::Vec2 {
+ vectorcraft_geom::Vec2::new(angle.cos(), angle.sin())
+}
+
#[test]
fn per_character_positions_rotation_and_shifts() {
let d = open(r#"abcd"#);
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index daf36b7f4a..a07d71e7e0 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1663,6 +1663,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
A gap between glyphs of two sizes is measured against the smaller one, so a word set beside a much larger one with no space glyph between them (a browser's margin between two sizes, as in `≤ 4` and a small `hours`) keeps its word space (#3623).
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `