diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index f0d50a747d..419e73a385 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -1244,6 +1244,7 @@ impl Ctx { } }); let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); + let rtl = self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl"); // 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 @@ -1305,7 +1306,7 @@ 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_of, origin.x, &mut breaks); + let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, rtl, origin.x, &mut breaks); obj.para.justify = justify; obj.xf = Affine::translate((finite(left), finite(origin.y))); // Lines anchored differently or at different x (paragraphs aligned or indented @@ -1331,13 +1332,7 @@ impl Ctx { if !para_styles.is_empty() { obj.set_paragraph_styles(para_styles); } - // SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first - // strong character: pinned (on every paragraph) when that would read as right to left. - if self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") { - obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft)); - } else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) { - obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight)); - } + pin_direction(&mut obj, rtl); keep_reader_anchors(&mut obj); // The placeholder covers the text (laid out when a paint server needs its bounding box). let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default(); @@ -1442,8 +1437,9 @@ 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 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. +/// starting at a character sits on its anchor, and `rtl` whether the text's `direction` is `rtl` +/// ([`pin_direction`]). 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], @@ -1452,6 +1448,7 @@ fn lines( dx: &[f64], dy: &[f64], anchor_of: &dyn Fn(usize) -> f64, + rtl: bool, x0: f64, breaks: &mut Vec, ) -> (Justify, f64, Vec<(usize, f64, f64)>) { @@ -1501,47 +1498,54 @@ fn lines( } // 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 a chunk not anchored at its left - // edge moves back by its share of its own width. + // edge moves back by its share of its own width. A chunk's edges are those of its glyphs as + // they are drawn: the first character of a right-to-left chunk is at its right. let line_of = |i: usize| lines.partition_point(|(s, _)| *s <= i) - 1; + // Chunk starts in order: line starts and absolutely placed characters. + let mut starts: Vec = lines.iter().map(|l| l.0).chain(targets.iter().map(|t| t.0)).collect(); + starts.sort_unstable(); + // The left and right edges of the chunk starting at character `s`, from each character's pen and end. + let edges = |s: usize, pens: &[Option], ends: &[Option]| { + let e = starts.get(starts.partition_point(|&c| c <= s)).copied().unwrap_or(n); + let left = (s..e).filter_map(|j| pens[j]).fold(f64::INFINITY, f64::min); + let right = (s..e).filter_map(|j| ends[j]).fold(f64::NEG_INFINITY, f64::max); + (left.is_finite() && right.is_finite()).then_some((left, right)) + }; let mut line_left: Vec = lines.iter().map(|l| l.1).collect(); - let mut wants: Vec<(usize, f64)> = targets.clone(); - 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); - // Chunk starts in order: line starts and absolutely placed characters. - let mut starts: Vec = lines.iter().map(|l| l.0).chain(targets.iter().map(|t| t.0)).collect(); - starts.sort_unstable(); - let width = |s: usize| { - let k = starts.partition_point(|&c| c <= s); - let e = starts.get(k).copied().unwrap_or(n); - let end = (s..e).filter_map(|j| ends[j]).fold(f64::NEG_INFINITY, f64::max); - match pens[s] { - Some(p) if end.is_finite() => end - p, - _ => 0.0, - } - }; - line_left = lines.iter().map(|&(s, lx)| lx - anchor_of(s) * width(s)).collect(); - for w in &mut wants { - w.1 -= anchor_of(w.0) * width(w.0); - } - } - for w in &mut wants { - w.1 -= line_left[line_of(w.0)]; - } + // Where each absolutely placed character's pen goes, from the left edge of its line's first chunk. + let mut wants: Vec<(usize, f64)> = vec![]; // Kerning changes shaping (pair kerning is off for manually kerned characters): repeat until // every character sits on its x. - for _ in 0..4 { + for round in 0..4 { let (runs, bytes) = assemble(cells, breaks); - let lay = measure(&with_runs(runs.0)); - let (pens, _) = pens(&lay, &bytes); + let mut text = with_runs(runs.0); + pin_direction(&mut text, rtl); + let (pens, ends) = pens(&measure(&text), &bytes); + // The edges of each line's first chunk, found once a round. + let first: Vec> = lines.iter().map(|&(s, _)| edges(s, &pens, &ends)).collect(); + if round == 0 { + // The chunks as they lie before any is moved: each one's left edge on its anchor. + let left_at = |s: usize, x: f64, chunk: Option<(f64, f64)>| chunk.map_or(x, |(left, right)| x - anchor_of(s) * (right - left)); + let first_left: Vec = lines.iter().zip(&first).map(|(&(s, lx), &chunk)| left_at(s, lx, chunk)).collect(); + wants = targets + .iter() + .map(|&(i, x)| { + let chunk = edges(i, &pens, &ends); + // The pen of a right-to-left chunk's first character is right of the chunk's left edge. + let pen = pens[i].zip(chunk).map_or(0.0, |(p, (left, _))| p - left); + (i, left_at(i, x, chunk) + pen - first_left[line_of(i)]) + }) + .collect(); + // A line's layout starts at the line's left edge, and its first chunk's left edge is `left` from there. + line_left = first_left.iter().zip(&first).map(|(&x, chunk)| x - chunk.map_or(0.0, |(left, _)| left)).collect(); + } let (mut line, mut moved, mut worst) = (usize::MAX, 0.0, 0.0f64); for &(i, want) in &wants { let l = line_of(i); if l != line { (line, moved) = (l, 0.0); } - let (Some(p), Some(p0)) = (pens[i], pens[lines[l].0]) else { continue }; + let (Some(p), Some((p0, _))) = (pens[i], first[l]) else { continue }; let d = want - (p - p0 + moved); cells[i - 1].kern += d; moved += d; @@ -1606,6 +1610,17 @@ fn keep_reader_anchors(obj: &mut TextObject) { obj.set_paragraph_styles(styles); } +/// SVG text runs left to right unless `direction: rtl` on the `` says otherwise (`rtl`), +/// whatever its first strong character: pinned (on every paragraph of `obj`) when that would read +/// as right to left. +fn pin_direction(obj: &mut TextObject, rtl: bool) { + if rtl { + obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft)); + } else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) { + obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight)); + } +} + /// A left-aligned point text of `runs` at the origin (for measuring). fn with_runs(runs: Vec) -> TextObject { let mut t = TextObject::point(Point::ZERO, "", CharStyle::default()); diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 01f541d8d6..92747cd05a 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -512,6 +512,45 @@ fn absolutely_placed_chunks_use_their_own_rtl_anchor() { } } +/// A right-to-left chunk of several characters is anchored by its visual edges: its first +/// character is drawn at its right, so the chunk runs left from that character's right edge. +#[test] +fn absolutely_placed_rtl_chunks_of_several_characters_anchor_by_their_visual_edges() { + // (markup, share of the right-to-left chunk's advance left of x = 200) + for (body, share) in [ + (r#"aאב"#, 1.0), + (r#"aאב"#, 0.5), + (r#"aאב"#, 0.0), + (r#"aאב"#, 1.0), + // A digit is not a strong character: the text still runs left to right. + (r#"1אב"#, 0.0), + // The chunk starts a line, and another line holds absolutely placed characters. + (r#"אבab"#, 1.0), + ( + r#"אבab"#, + 0.5, + ), + ( + r#"אבab"#, + 0.0, + ), + ] { + let d = open(body); + let t = only_text(&d); + let lay = layout(FontDb::global(), t); + let chunk: Vec<_> = lay.glyphs.iter().filter(|g| g.rtl).collect(); + assert_eq!(chunk.len(), 2, "{body}"); + assert!(chunk.iter().all(|g| g.advance > 1.0), "{body}: both letters have an advance"); + let left = chunk.iter().map(|g| (t.xf * g.origin).x).fold(f64::INFINITY, f64::min); + let right = chunk.iter().map(|g| (t.xf * g.origin).x + g.advance).fold(f64::NEG_INFINITY, f64::max); + let want = 200.0 - share * (right - left); + assert!(close(left, want, 0.05), "{body}: the chunk spans {left}..{right}, so it does not start at {want}"); + // The first character is the right one of the two. + let first = lay.glyphs.iter().filter(|g| g.rtl).min_by_key(|g| g.byte).map(|g| (t.xf * g.origin).x); + assert!(first.is_some_and(|x| x > left + 1.0), "{body}: the first character starts at {first:?}, the chunk at {left}"); + } +} + #[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 a202baa403..613bbd90d7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1526,7 +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 `