diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index bbe72063e1..cbffbfc348 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}; @@ -1466,7 +1466,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 @@ -1599,7 +1604,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], @@ -1611,7 +1617,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); @@ -1654,7 +1660,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 @@ -1664,59 +1670,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` @@ -1875,13 +1944,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 d55e8b930e..816e17f15d 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}"#) @@ -808,6 +808,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 a42cf53eab..cd0f70d19a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1692,6 +1692,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 `