From 1c973dc14170d2d3daacf626439dc75f4a307016 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:36:33 -0800 Subject: [PATCH] SVG import: absolutely placed characters in right-to-left text land on their x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An absolutely placed character starts its own text chunk, anchored on its own x (SVG 1.1 text chunks). In a right-to-left line the import kerned the character before each placed one as if kerning moved the placed character right, but a right-to-left glyph takes its kerning at its left, so the correction moved the wrong glyph. In , אב on a second line put the right edge of א at 393.48 instead of 100. The placement now works in drawing order: each kerning is a cut after its character (before it when it runs right to left), each gap between two placed neighbors takes one cut, and the line's left edge is measured again every round. Corrections are divided by the character's horizontal scale, so text stretched with lengthAdjust="spacingAndGlyphs" lands on its x too. A left-to-right character drawn between two placed right-to-left ones has no kerning slot before the next one, which then keeps its place in the line; the import warns about it. --- crates/svg/src/import/text.rs | 166 +++++++++++++++++++++++------ crates/svg/tests/it/import_text.rs | 110 ++++++++++++++++++- docs/target-app-parity.md | 1 + 3 files changed, 241 insertions(+), 36 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 1a221a69f1..6b1ce0c063 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -40,7 +40,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, ParaDirection, ParaStyle, StrokeLayer, TextKind, TextObject, TextRun}; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect}; -use vectorcraft_text::{FontDb, TextLayout}; +use vectorcraft_text::{FontDb, PositionedGlyph, TextLayout, spacing_scale}; use super::css::{Styles, XNode}; use super::{DEFAULT_FONT_SIZE, PT_PER_IN, href, vector_effect}; @@ -1435,7 +1435,12 @@ 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, &pin, origin.x, &mut breaks); + let (justify, left, starts, unplaced) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, &pin, origin.x, &mut breaks); + if unplaced > 0 { + warnings.push(format!( + "{label}: {unplaced} absolutely placed character(s) between characters of the other direction keep their place in the line" + )); + } obj.para.justify = justify; obj.xf = Affine::translate((finite(left), finite(origin.y))); // Lines anchored differently or at different x (paragraphs aligned or indented @@ -1568,7 +1573,8 @@ fn leading_tolerance(spacing: f64) -> f64 { /// character, alignment (as for [`justify_of`]) and anchor x. `anchor_of` gives where the chunk /// starting at a character sits on its anchor, and `pin` sets the paragraph directions of the /// text ([`pin_direction`]). When characters are placed on their own, every line is left-aligned -/// and its anchor x is its left edge. +/// and its anchor x is its left edge. Also returns how many absolutely placed characters kerning +/// cannot put on their x. #[allow(clippy::too_many_arguments)] fn lines( cells: &mut [Cell], @@ -1580,7 +1586,7 @@ fn lines( pin: &dyn Fn(&mut TextObject), x0: f64, breaks: &mut Vec, -) -> (Justify, f64, Vec<(usize, f64, f64)>) { +) -> (Justify, f64, Vec<(usize, f64, f64)>, usize) { let n = cells.len(); let y0 = y[0].unwrap_or(0.0) + dy[0]; let (mut base, mut cur) = (y0, y0); @@ -1623,7 +1629,7 @@ fn lines( } if targets.is_empty() { let starts = lines.iter().map(|&(s, lx)| (s, anchor_of(s), lx)).collect(); - return (justify_of(anchor_of(0)), x0, starts); + return (justify_of(anchor_of(0)), x0, starts, 0); } // 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 @@ -1633,59 +1639,122 @@ fn lines( // 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]| { + // The left and right edges of the chunk starting at character `s`, and the character drawn at its left. + let edges = |s: usize, pens: &[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 left, mut right, mut at) = (f64::INFINITY, f64::NEG_INFINITY, s); + for (j, p) in pens.iter().enumerate().take(e).skip(s) { + let Some(p) = p else { continue }; + if p.start < left { + (left, at) = (p.start, j); + } + right = right.max(p.end); + } + (left.is_finite() && right.is_finite()).then_some((left, right, at)) }; let mut line_left: Vec = lines.iter().map(|l| l.1).collect(); + // Where each line's first chunk's left edge goes. + let mut first_left: Vec = vec![]; // 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![]; + // Absolutely placed characters drawn right next to a placed neighbor with no kerning between + // them (a left-to-right character between right-to-left ones): they stay where the layout puts them. + let mut unplaced = 0; // Kerning changes shaping (pair kerning is off for manually kerned characters): repeat until // every character sits on its x. for round in 0..4 { let (runs, bytes) = assemble(cells, breaks); let mut text = with_runs(runs.0); pin(&mut text); - let (pens, ends) = pens(&measure(&text), &bytes); + let pens = pens(&measure(&text), &bytes, cells); // The edges of each line's first chunk, found once a round. - let first: Vec> = lines.iter().map(|&(s, _)| edges(s, &pens, &ends)).collect(); + let first: Vec> = lines.iter().map(|&(s, _)| edges(s, &pens)).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(); + let left_at = |s: usize, x: f64, chunk: Option<(f64, f64, usize)>| chunk.map_or(x, |(left, right, _)| x - anchor_of(s) * (right - left)); + first_left = 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); + let chunk = edges(i, &pens); // 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); + let pen = pens[i].zip(chunk).map_or(0.0, |(p, (left, ..))| p.start - 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); + // A line's layout starts at the line's left edge, and its first chunk's left edge is `left` + // from there (right-to-left kerning can move that chunk). + line_left = first_left.iter().zip(&first).map(|(&x, chunk)| x - chunk.map_or(0.0, |(left, ..)| left)).collect(); + // Kerning a character widens its advance to the right: the glyphs drawn right of it move + // right, and so does a right-to-left glyph's own left edge (its kerning is at its left). In + // drawing order the kerning before an absolutely placed character is a cut after the + // character before it (before that character when it runs right to left), and what is drawn + // after the cut moves. Places in drawing order are doubled (from 2) to put cuts between them. + // Each gap between two placed characters drawn one after the other takes the kerning of one + // cut in it, so placing a character moves none placed before it. Kerning is drawn at the + // character's horizontal scale, so each correction is divided by it. + let mut worst = 0.0f64; + let mut from = 0; + unplaced = 0; + while from < wants.len() { + let l = line_of(wants[from].0); + let to = from + wants[from..].partition_point(|&(i, _)| line_of(i) == l); + let on_line = &wants[from..to]; + from = to; + let Some((p0, _, at)) = first[l] else { continue }; + let Some(home) = pens[at].map(|p| 2 * p.rank + 2) else { continue }; + // A character drawn as part of the glyph before it (a mark) is kerned where that glyph is. + // The kerning stays on the character just before the placed one: a mark whose kerning + // differs from its base's is drawn as a glyph of its own, and kerning the base would put + // the space between the base and its mark. + // Each search stops at the nearest earlier character with a glyph, at the latest the + // placed character before it, so the searches of a line read each character once. + let mut cuts: Vec<(usize, usize)> = on_line + .iter() + .filter(|&&(i, _)| pens[i].is_some()) + .filter_map(|&(i, _)| (lines[l].0..i).rev().find_map(|j| pens[j]).map(|k| (2 * k.rank + if k.rtl { 1 } else { 3 }, i - 1))) + .collect(); + cuts.sort_unstable(); + let mut points: Vec<(usize, f64, f64)> = + on_line.iter().filter_map(|&(i, want)| pens[i].map(|p| (2 * p.rank + 2, p.start, want))).collect(); + points.sort_by_key(|p| p.0); + // The character kerned at the first cut between places `a` and `b`. + let cut = |a: usize, b: usize| cuts.get(cuts.partition_point(|c| c.0 <= a)).filter(|c| c.0 < b).map(|c| c.1); + let split = points.partition_point(|p| p.0 < home); + // Drawn right of the line's first chunk: kerning moves the character away from it. + let (mut prev, mut moved) = (home, 0.0); + for &(place, start, want) in &points[split..] { + if let Some(k) = cut(prev, place) { + let d = want - (start - p0 + moved); + cells[k].kern += d / spacing_scale(&cells[k].style, false); + moved += d; + worst = worst.max(d.abs()); + } else { + unplaced += 1; + } + prev = place; + } + // Drawn left of it: kerning moves the chunk (and what lies between) away from the character. + (prev, moved) = (home, 0.0); + for &(place, start, want) in points[..split].iter().rev() { + if let Some(k) = cut(place, prev) { + let d = start - p0 - moved - want; + cells[k].kern += d / spacing_scale(&cells[k].style, false); + moved += d; + worst = worst.max(d.abs()); + } else { + unplaced += 1; + } + prev = place; } - let (Some(p), Some((p0, _))) = (pens[i], first[l]) else { continue }; - let d = want - (p - p0 + moved); - cells[i - 1].kern += d; - moved += d; - worst = worst.max(d.abs()); } if worst < 0.01 { break; } } let starts = lines.iter().zip(&line_left).map(|(&(s, _), &lx)| (s, 0.0, lx)).collect(); - (Justify::Left, line_left[0], starts) + (Justify::Left, line_left[0], starts, unplaced) } /// Centred and right-aligned lines of point type `obj` where a reader draws them. `text-anchor` @@ -1794,13 +1863,40 @@ fn advance(t: &TextObject) -> f64 { measure(t).glyphs.iter().map(|g| g.advance).sum() } -/// Pen x (start) and end x of the glyph that starts at each character's byte offset. -fn pens(lay: &TextLayout, bytes: &[usize]) -> (Vec>, Vec>) { - let mut at: HashMap = HashMap::with_capacity(lay.glyphs.len()); - for g in &lay.glyphs { - at.entry(g.byte).or_insert((g.origin.x, g.origin.x + g.advance)); +/// Where the glyph that starts a character is drawn on a line of point type. +#[derive(Clone, Copy)] +struct Pen { + /// Its left edge. Manual kerning on a right-to-left glyph is at its left, and the glyph is + /// drawn right of it: its left edge is past that kerning. + start: f64, + /// The right edge of its advance. + end: f64, + /// Its place in the order its line is drawn in, from the left. + rank: usize, + rtl: bool, +} + +/// The [`Pen`] of the glyph that starts at each character's byte offset (`bytes`, parallel to `cells`). +fn pens(lay: &TextLayout, bytes: &[usize], cells: &[Cell]) -> Vec> { + // A line's glyphs are laid out in the order they are drawn. + let mut at: HashMap = HashMap::with_capacity(lay.glyphs.len()); + for (rank, g) in lay.glyphs.iter().enumerate() { + at.entry(g.byte).or_insert((rank, g)); } - bytes.iter().map(|b| at.get(b).map_or((None, None), |&(s, e)| (Some(s), Some(e)))).unzip() + bytes + .iter() + .zip(cells) + .map(|(b, c)| { + let &(rank, g) = at.get(b)?; + let mut start = g.origin.x; + if g.rtl { + let st = c.final_style(); + let kern = st.kerning.unwrap_or(0.0) / 1000.0 * st.size * spacing_scale(&st, false); + start += finite(kern); + } + Some(Pen { start, end: g.origin.x + g.advance, rank, rtl: g.rtl }) + }) + .collect() } type Key = (Option, Option); diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 472d7517e4..b4cc379506 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -5,7 +5,7 @@ use vectorcraft_color::{GradientKind, Paint}; use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject}; use vectorcraft_geom::{Point, Rect}; use vectorcraft_svg::{import, import_with_report}; -use vectorcraft_text::{FontDb, layout}; +use vectorcraft_text::{FontDb, layout, spacing_scale}; fn svg(body: &str) -> String { format!(r#"{body}"#) @@ -719,6 +719,114 @@ fn absolutely_placed_rtl_chunks_of_several_characters_anchor_by_their_visual_edg } } +/// Document-space left and right edges of the natural advance of the characters in `bytes` (the +/// manual kerning that places the next chunk left out: it is at a glyph's right when the glyph runs +/// left to right and at its left when it runs right to left). +fn chunk_edges(t: &TextObject, bytes: std::ops::Range) -> (f64, f64) { + let lay = layout(FontDb::global(), t); + let mut starts = vec![]; + let mut at = 0; + for r in &t.runs { + starts.push((at, &r.style)); + at += r.text.len(); + } + let style = |byte: usize| starts.iter().rev().find(|(s, _)| *s <= byte).map(|(_, st)| *st).unwrap(); + let (mut left, mut right) = (f64::INFINITY, f64::NEG_INFINITY); + for g in lay.glyphs.iter().filter(|g| bytes.contains(&g.byte)) { + let st = style(g.byte); + let kern = st.kerning.unwrap_or(0.0) / 1000.0 * st.size * spacing_scale(st, false); + let x = (t.xf * g.origin).x; + let (l, r) = if g.rtl { (x + kern, x + g.advance) } else { (x, x + g.advance - kern) }; + (left, right) = (left.min(l), right.max(r)); + } + assert!(left.is_finite(), "no glyph in {bytes:?}"); + (left, right) +} + +/// SVG 1.1 §10.4 and SVG 2 §11.10.1.1 (https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties): +/// every absolutely placed character starts a chunk anchored on its own x, also in right-to-left +/// text whose characters run right to left, on any line. +#[test] +fn absolutely_placed_right_to_left_characters_sit_on_their_own_x() { + // (markup, share of each chunk's advance left of its x, each chunk's bytes and x) + let two_lines = |anchor: &str| { + format!( + r#"abאב"# + ) + }; + type Chunks = Vec<(std::ops::Range, f64)>; + let cases: Vec<(String, f64, Chunks)> = vec![ + (two_lines("start"), 1.0, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]), + (two_lines("middle"), 0.5, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]), + (two_lines("end"), 0.0, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]), + (r#"אב"#.into(), 1.0, vec![(0..2, 100.0), (2..4, 160.0)]), + (r#"אב"#.into(), 0.0, vec![(0..2, 100.0), (2..4, 160.0)]), + // Three lines, the characters of the last out of order and overlapping. + ( + r#"אבגדהוז"#.into(), + 1.0, + vec![(0..4, 300.0), (5..7, 60.0), (7..9, 120.0), (10..12, 200.0), (12..14, 90.0), (14..16, 205.0)], + ), + // Left-to-right characters among right-to-left ones on the same line. + (r#"אbc"#.into(), 1.0, vec![(0..2, 100.0), (2..3, 160.0), (3..4, 220.0)]), + // Chunks of several characters. + ( + r#"abאבגד"#.into(), + 1.0, + vec![(0..2, 20.0), (3..7, 100.0), (7..11, 250.0)], + ), + ( + r#"abאבגד"#.into(), + 0.5, + vec![(0..2, 20.0), (3..7, 100.0), (7..11, 250.0)], + ), + // A placed character after a letter with a combining mark. + ( + r#"אָב"#.into(), + 1.0, + vec![(4..6, 160.0)], + ), + (r#"éb"#.into(), 0.0, vec![(3..4, 160.0)]), + // Characters stretched by `lengthAdjust`, whose kerning is drawn at their scale. + ( + r#"abאב"#.into(), + 1.0, + vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)], + ), + // Left to right, the right-to-left characters still each start at their own x. + ( + r#"abאב"#.into(), + 0.0, + vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)], + ), + ( + r#"abcd"#.into(), + 0.0, + vec![(0..2, 20.0), (3..4, 100.0), (4..5, 160.0)], + ), + ]; + let unplaced = |w: &[String]| w.iter().any(|w| w.contains("absolutely placed")); + for (body, share, chunks) in cases { + let (d, w) = import_with_report(&svg(&body)).unwrap(); + assert!(!unplaced(&w), "{body}: {w:?}"); + let t = only_text(&d); + if body.contains("lengthAdjust") { + assert!(t.runs.iter().any(|r| r.style.h_scale < 99.0), "{body}: {:?}", t.runs); + } + for (bytes, x) in chunks { + let (left, right) = chunk_edges(t, bytes.clone()); + let want = x - share * (right - left); + assert!(close(left, want, 0.05), "{body}: the chunk {bytes:?} spans {left}..{right}, so it does not start at {want}"); + } + } + // A left-to-right character between right-to-left ones is drawn right next to the one after + // it, with no kerning between them: that one keeps its place in the line, and a warning says so. + let (d, w) = import_with_report(&svg(r#"אbג"#)).unwrap(); + assert!(unplaced(&w), "{w:?}"); + let (left, right) = chunk_edges(only_text(&d), 2..3); + assert!(close(right, 160.0, 0.05), "b spans {left}..{right}"); +} + #[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 7e36c7f318..fd11033247 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 `