From 6e2fe9fddf714b71f276a65f10db2445e6e65c35 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 01:06:53 -0800 Subject: [PATCH] SVG: right-to-left text with text-anchor start or end opens at its directional edge SVG 2 defines text-anchor start for right-to-left text as placing the right side of the text at its position, and end as placing the left side there (https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties). Import placed start at the left edge and end at the right edge whatever the direction. - Point type and type on a path swap start and end when the effective direction is rtl. On a path, right-to-left text anchored by start now ends at startOffset and text anchored by end begins there. Vertical type keeps its anchoring. - Each line and each absolutely placed chunk is anchored by the text-anchor and direction of the element holding its first character, a tspan's own included. A later chunk no longer takes the first chunk's anchor. - Each line of point type starts at its own x, also when it has the same anchor as the line before it or another line holds absolutely placed characters. Such lines were drawn at the first line's x. - A test covers letter-spaced right-to-left lines: the layout keeps the tracking of every letter in such a line, so the whole advance sits on its anchor. --- crates/svg/src/import/text.rs | 78 +++++++++------ crates/svg/tests/it/import_text.rs | 152 +++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 200 insertions(+), 31 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 51bcffa41e..f0d50a747d 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -1236,12 +1236,6 @@ impl Ctx { .collect(); let under = stroke_under(&mut cells, label, warnings); - let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.prop(c.1, "text-anchor")).as_deref() { - Some("middle") => 0.5, - Some("end") => 1.0, - _ => 0.0, - }; - let anchor = anchor_of(0); let path = cs.path.and_then(|tp| match self.text_path(xml, tp) { Some(p) => Some((tp, p)), None => { @@ -1250,6 +1244,22 @@ impl Ctx { } }); let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); + // Where the chunk starting at character `i` sits on its anchor: 0 its left (or top) edge, + // 0.5 its middle, 1 its right (or bottom) edge; on a path, its edge toward the path's + // start, its middle or its edge toward the path's end. In horizontal type `start` and + // `end` follow the `direction` of the element holding that character: right-to-left text + // starts at its right edge, and on a path at the edge toward the path's end (type on a + // path lays its glyphs out in visual order from its start). + let anchor_of = |i: usize| { + let Some(&(_, el)) = cs.chars.get(i) else { return 0.0 }; + let a = match self.prop(el, "text-anchor").as_deref() { + Some("middle") => 0.5, + Some("end") => 1.0, + _ => 0.0, + }; + if !vertical && self.prop(el, "direction").is_some_and(|d| d.trim() == "rtl") { 1.0 - a } else { a } + }; + let anchor = anchor_of(0); self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings); let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); let mut breaks: Vec = vec![]; @@ -1268,6 +1278,8 @@ 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 }; obj.xf = Affine::IDENTITY; @@ -1293,14 +1305,14 @@ impl Ctx { obj.xf = Affine::IDENTITY; } else { let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]); - let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, anchor, origin.x, &mut breaks); + let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, origin.x, &mut breaks); obj.para.justify = justify; obj.xf = Affine::translate((finite(left), finite(origin.y))); - // Lines anchored differently (paragraphs aligned differently): each line's paragraph - // takes its alignment, and indents that put it where it was. - if let Some(starts) = starts.filter(|st| st.iter().any(|(i, _)| anchor_of(*i) != anchor)) { - for (k, &(i, ax)) in starts.iter().enumerate() { - let a = anchor_of(i); + // Lines anchored differently or at different x (paragraphs aligned or indented + // differently): each line's paragraph takes its alignment, and indents that put it + // where it was. + if starts.iter().any(|&(_, a, ax)| a != starts[0].1 || ax != starts[0].2) { + for (k, &(_, a, ax)) in starts.iter().enumerate() { let d = finite(ax - left); let mut pa = ParaStyle { justify: justify_of(a), ..ParaStyle::default() }; match pa.justify { @@ -1412,7 +1424,7 @@ fn finite(v: f64) -> f64 { if v.is_finite() { v } else { 0.0 } } -/// The alignment of an SVG text chunk anchored at `anchor` (0 start, 0.5 middle, 1 end). +/// The alignment of an SVG text chunk anchored at `anchor` (0 left edge, 0.5 middle, 1 right edge). fn justify_of(anchor: f64) -> Justify { match anchor { a if a >= 1.0 => Justify::Right, @@ -1428,8 +1440,10 @@ fn leading_tolerance(spacing: f64) -> f64 { } /// Point type: line breaks, baseline shifts and kerning from the positioning lists. Returns the -/// justification, the x of the first line's left (or anchor) edge and, when no character is -/// placed on its own, each line's first character and anchor x. +/// justification, the x of the first line's left (or anchor) edge and each line's first +/// character, alignment (as for [`justify_of`]) and anchor x. `anchor_of` gives where the chunk +/// starting at a character sits on its anchor. When characters are placed on their own, every +/// line is left-aligned and its anchor x is its left edge. #[allow(clippy::too_many_arguments)] fn lines( cells: &mut [Cell], @@ -1437,10 +1451,10 @@ fn lines( y: &[Option], dx: &[f64], dy: &[f64], - anchor: f64, + anchor_of: &dyn Fn(usize) -> f64, x0: f64, breaks: &mut Vec, -) -> (Justify, f64, Option>) { +) -> (Justify, f64, Vec<(usize, f64, f64)>) { let n = cells.len(); let y0 = y[0].unwrap_or(0.0) + dy[0]; let (mut base, mut cur) = (y0, y0); @@ -1482,15 +1496,16 @@ fn lines( } } if targets.is_empty() { - return (justify_of(anchor), x0, Some(lines)); + let starts = lines.iter().map(|&(s, lx)| (s, anchor_of(s), lx)).collect(); + return (justify_of(anchor_of(0)), x0, starts); } // Absolutely placed characters: lay the text out left-aligned and kern each one onto its x. - // SVG anchors every absolutely placed chunk on its own, so with a middle/end anchor each - // chunk's target moves back by its share of the chunk's width. + // SVG anchors every absolutely placed chunk on its own, so a chunk not anchored at its left + // edge moves back by its share of its own width. let line_of = |i: usize| lines.partition_point(|(s, _)| *s <= i) - 1; - let mut left = x0; + let mut line_left: Vec = lines.iter().map(|l| l.1).collect(); let mut wants: Vec<(usize, f64)> = targets.clone(); - if anchor > 0.0 { + if lines.iter().chain(&targets).any(|c| anchor_of(c.0) > 0.0) { let (runs, bytes) = assemble(cells, breaks); let lay = measure(&with_runs(runs.0)); let (pens, ends) = pens(&lay, &bytes); @@ -1506,16 +1521,14 @@ fn lines( _ => 0.0, } }; - let line_left: Vec = lines.iter().map(|&(s, lx)| lx - anchor * width(s)).collect(); - left = line_left[0]; + line_left = lines.iter().map(|&(s, lx)| lx - anchor_of(s) * width(s)).collect(); for w in &mut wants { - w.1 -= anchor * width(w.0) + line_left[line_of(w.0)]; - } - } else { - for w in &mut wants { - w.1 -= lines[line_of(w.0)].1; + w.1 -= anchor_of(w.0) * width(w.0); } } + for w in &mut wants { + w.1 -= line_left[line_of(w.0)]; + } // Kerning changes shaping (pair kerning is off for manually kerned characters): repeat until // every character sits on its x. for _ in 0..4 { @@ -1538,7 +1551,8 @@ fn lines( break; } } - (Justify::Left, left, None) + let starts = lines.iter().zip(&line_left).map(|(&(s, _), &lx)| (s, 0.0, lx)).collect(); + (Justify::Left, line_left[0], starts) } /// Centred and right-aligned lines of point type `obj` where a reader draws them. `text-anchor` @@ -1546,7 +1560,9 @@ fn lines( /// export anchors lines so); VectorCraft aligns a line without that tracking /// ([`vectorcraft_text::LineInfo::trailing`]). Each such line moves back by its share of it: the /// type moves when every line moves alike, otherwise each paragraph's right indent takes the -/// tracking (a line of imported point type is a paragraph). Browsers add that letter spacing +/// tracking (a line of imported point type is a paragraph). A right-to-left paragraph's lines +/// keep all their tracking in the layout (`trailing` is 0), so they stay where they are and their +/// whole advance sits on the anchor. Browsers add that letter spacing /// today; CSS WG issue 1518 and Firefox bugs 1907305 and 1913554 may drop it at a line's end. fn keep_reader_anchors(obj: &mut TextObject) { let share = |j: Justify| match j { diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index b25dc23678..01f541d8d6 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -360,6 +360,158 @@ fn anchored_letter_spaced_lines_stay_where_a_reader_draws_them() { assert!(close(s[0], 200.0 - a[0] / 2.0, 1e-6) && close(s[1], 200.0 - a[1], 1e-6), "{s:?} {a:?}"); } +/// Document-space left and right edges of the advance of each laid-out line. +fn line_edges(t: &TextObject) -> Vec<(f64, f64)> { + let lay = layout(FontDb::global(), t); + lay.lines + .iter() + .map(|l| { + let g = &lay.glyphs[l.glyph_start..l.glyph_end]; + let left = g.iter().map(|g| (t.xf * g.origin).x).fold(f64::INFINITY, f64::min); + let right = g.iter().map(|g| (t.xf * g.origin).x + g.advance).fold(f64::NEG_INFINITY, f64::max); + (left, right) + }) + .collect() +} + +/// SVG 2 §11.10.1.1 (https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties): with +/// `direction: rtl`, `start` puts the right side of the text at its position and `end` puts the +/// left side there; `middle` centers it either way. The direction is the effective one of the element that +/// holds the chunk's first character. +#[test] +fn rtl_point_text_start_and_end_use_directional_edges() { + // (markup, share of the advance left of x = 200) + for (body, share) in [ + (r#"Wave"#, 1.0), + (r#"Wave"#, 1.0), + (r#"Wave"#, 0.5), + (r#"Wave"#, 0.0), + (r#"Wave"#, 1.0), + (r#"Wave"#, 0.0), + (r#"Wave"#, 0.0), + (r#"Wave"#, 0.0), + (r#"Wave"#, 1.0), + ] { + let d = open(body); + let (left, right) = line_edges(only_text(&d))[0]; + let want = 200.0 - share * (right - left); + assert!(close(left, want, 1e-6), "{body}: the advance spans {left}..{right}, so it does not start at {want}"); + } +} + +/// On a path, right-to-left text anchored by `start` ends at `startOffset` and one anchored by +/// `end` begins there (its glyphs run from the path's start in visual order); `middle` centers it. +#[test] +fn rtl_type_on_a_path_starts_at_its_directional_edge() { + // (anchor, direction, share of the advance before the offset) + for (anchor, direction, share) in [("start", "rtl", 1.0), ("middle", "rtl", 0.5), ("end", "rtl", 0.0), ("start", "ltr", 0.0), ("end", "ltr", 1.0)] + { + let d = open(&format!( + r##" + Wave"## + )); + let t = only_text(&d); + assert!(matches!(t.kind, TextKind::OnPath { .. }), "{:?}", t.kind); + let lay = layout(FontDb::global(), t); + let left = lay.glyphs.iter().map(|g| (t.xf * g.origin).x).fold(f64::INFINITY, f64::min); + let width: f64 = lay.glyphs.iter().map(|g| g.advance).sum(); + let want = 200.0 - share * width; + assert!(close(left, want, 0.05), "{anchor} {direction}: the text starts at {left}, not {want}"); + } +} + +/// Letter-spaced right-to-left lines: a line keeps the tracking after each of its characters, and +/// its whole advance sits on its anchor as `start`, `middle` and `end` place it. +#[test] +fn rtl_letter_spaced_lines_stay_on_their_anchor() { + for (anchor, share) in [("start", 1.0), ("middle", 0.5), ("end", 0.0)] { + let d = open(&format!(r#"WAVE"#)); + let t = only_text(&d); + let (left, right) = line_edges(t)[0]; + let want = 200.0 - share * (right - left); + assert!(close(left, want, 1e-6), "{anchor}: the advance spans {left}..{right}, so it does not start at {want}"); + let natural = { + let d = open(&format!(r#"WAVE"#)); + let (l, r) = line_edges(only_text(&d))[0]; + r - l + }; + assert!(close(right - left, natural + 4.0 * 8.0, 1e-6), "{anchor}: the advance {} holds the tracking of all 4 letters", right - left); + } + // A right-to-left line anchored by `start` beside a left-to-right `middle` line: each keeps its own anchor. + let d = open( + r#"WaveLine"#, + ); + let t = only_text(&d); + let e = line_edges(t); + assert_eq!(e.len(), 2, "{e:?}"); + assert!(close(e[0].1, 200.0, 1e-6), "the right-to-left line ends at 200: {e:?}"); + let lay = layout(FontDb::global(), t); + let a: f64 = lay.glyphs[lay.lines[1].glyph_start..lay.lines[1].glyph_end].iter().map(|g| g.advance).sum(); + assert!(close(e[1].0, 200.0 - a / 2.0, 1e-6), "the middle line is centered on 200 with its tracking: {e:?}"); +} + +/// Each line is a chunk anchored by the `text-anchor` and `direction` of its first character. +#[test] +fn rtl_lines_use_each_chunk_first_character_direction_and_anchor() { + let d = open( + r#"WaveLineLastMid"#, + ); + let e = line_edges(only_text(&d)); + assert_eq!(e.len(), 4, "{e:?}"); + assert!(close(e[0].1, 200.0, 1e-6), "a right-to-left start line ends at 200: {e:?}"); + assert!(close(e[1].0, 200.0, 1e-6), "a left-to-right start line starts at 200: {e:?}"); + assert!(close(e[2].0, 200.0, 1e-6), "a right-to-left end line starts at 200: {e:?}"); + assert!(close((e[3].0 + e[3].1) / 2.0, 200.0, 1e-6), "a middle line is centered on 200: {e:?}"); +} + +/// A line starts at its own x, also when it is anchored as the line before it is and when another +/// line holds absolutely placed characters. +#[test] +fn each_line_starts_at_its_own_x() { + let d = open(r#"WaveLine"#); + let e = line_edges(only_text(&d)); + assert!(close(e[0].0, 50.0, 1e-6) && close(e[1].0, 120.0, 1e-6), "{e:?}"); + let d = open(r#"WaveLine"#); + let e = line_edges(only_text(&d)); + assert!(close(e[0].1, 150.0, 1e-6) && close(e[1].1, 100.0, 1e-6), "{e:?}"); + let d = open(r#"abLine"#); + let t = only_text(&d); + let e = line_edges(t); + assert_eq!(e.len(), 2, "{e:?}"); + assert!(close(e[0].0, 50.0, 0.05) && close(e[1].1, 200.0, 0.05), "{e:?}"); + let b = (t.xf * layout(FontDb::global(), t).glyphs[1].origin).x; + assert!(close(b, 100.0, 0.05), "b starts at {b}"); +} + +/// Absolutely placed chunks are anchored on their own, each by the `text-anchor` and `direction` +/// of its first character, whatever the first chunk's anchor. +#[test] +fn absolutely_placed_chunks_use_their_own_rtl_anchor() { + // (markup, share of each chunk's advance left of its x) + for (body, shares) in [ + (r#"ab"#, [0.0, 1.0]), + (r#"ab"#, [0.0, 0.0]), + (r#"ab"#, [0.0, 1.0]), + (r#"ab"#, [0.5, 0.0]), + (r#"ab"#, [0.5, 1.0]), + (r#"ab"#, [0.5, 0.5]), + (r#"ab"#, [1.0, 1.0]), + ] { + let d = open(body); + let t = only_text(&d); + let lay = layout(FontDb::global(), t); + assert_eq!(lay.glyphs.len(), 2, "{body}"); + // Natural advance: the kerning that moves `b` onto its x is added after `a`. + let style = |i: usize| t.runs.iter().flat_map(|r| r.text.chars().map(move |_| &r.style)).nth(i).unwrap(); + let natural = |i: usize| lay.glyphs[i].advance - style(i).kerning.unwrap_or(0.0) / 1000.0 * style(i).size; + for (i, (x, share)) in [80.0, 200.0].into_iter().zip(shares).enumerate() { + let left = (t.xf * lay.glyphs[i].origin).x; + let want = x - share * natural(i); + assert!(close(left, want, 0.05), "{body}: chunk {i} starts at {left}, not {want}"); + } + } +} + #[test] fn a_small_line_gap_in_tiny_type_keeps_its_leading() { // 0.0085 against an auto leading of 0.006 differs by less than 0.01 but is not a whole multiple of it. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 086d4cfc0d..a202baa403 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1526,6 +1526,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A function-based PDF shading (type 1) whose colour changes along one axis only, as a browser writes a repeating linear gradient, comes in as a linear gradient over its domain, each hard colour change two stops at one offset; one whose colour changes in two directions comes in as its average colour, with a warning. Either paints only over the shading's domain. Both came in as a flat grey that wasn't in the file (#3621). Each such shading is read once per import, and all of them within one budget of 200,000 function evaluations, so a file filling thousands of paths with long functions still opens in about a second (it took minutes); past the budget they come in as their average colour, then grey, with a warning. 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 `