From 490c8030804cd89e9ce4113b32815d2dc69c1816 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:47:27 -0800 Subject: [PATCH] SVG import: type on a closed path wraps around past the path's start Text on a path of one closed subpath that is anchored to start before the path's start point (text-anchor end or middle, right-to-left start, or a negative startOffset) started at the start point, and text offset past the path's end started at its end. It now wraps around the path, as SVG 2's startOffset attribute describes for a single closed subpath (https://www.w3.org/TR/SVG2/text.html#TextPathElementStartOffsetAttribute): text-anchor end at startOffset 0 ends at the start point. Open paths and paths of several subpaths still start the text at the path's start or end. --- crates/svg/src/import/text.rs | 16 ++++-- crates/svg/tests/it/import_text.rs | 78 ++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 1a221a69f1..b8aa55373c 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -1408,10 +1408,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 472d7517e4..fdba7e41cb 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 7e36c7f318..1d924927e7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1658,6 +1658,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 `