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 `