From 77e4dceb9d7e88f4e9bc4540d4dd4428e8101560 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 14:04:05 -0800 Subject: [PATCH] Type on a path: overflowing right-to-left type shows the longest start that fits Right-to-left type on a path that overflows searched for the start of its text that fits with a binary search, which assumed that a longer start never fits once a shorter one does not. A letter tracked to a negative advance breaks that: with steps of 6, 6, -3 and 6 points in a 10 point span, the first three letters fit while the first two do not, and the search showed only the first letter. - Without hanging punctuation, and with no spacing or on a path of one straight segment, every start is measured in O(n log n): each glyph keeps its place in the whole text's visual order, except the trailing whitespace and controls that UAX #9 rule L1 sets to the paragraph's level. - With hanging punctuation, or spacing on a curve, when a letter has a negative advance, a start is a candidate only when its glyphs, in its own visual order and each at the least step it can take, reach no farther than the path's length. This lower bound holds with negative advances. - At most 32 candidates are arranged from the longest. When none of them fits, a binary search over the rest shows a start that fits, which is not always the longest. - With hanging punctuation, or spacing on a curve, text with no negative advance keeps the binary search. Right-aligned and centered type on a curve with Spacing was placed using the width its glyphs have at the start bracket, while the steps were measured where the line was placed, so a line that fits could end past the end bracket and lose glyphs. It is now placed using the width of the steps it has where it is placed. --- crates/text/src/layout.rs | 2 + crates/text/src/layout/on_path.rs | 362 +++++++++++++++++++++++++++--- crates/text/src/tests.rs | 23 ++ crates/text/src/tests/bidi.rs | 167 ++++++++++++++ docs/target-app-parity.md | 1 + 5 files changed, 527 insertions(+), 28 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 6dbbb6108d..e42bd2044e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -42,6 +42,8 @@ use breaking::{break_line, candidates}; pub use driver::{decorations, drawn, fit_first_line, fit_source_lines, layout, layout_with, scaled}; #[cfg(test)] pub(crate) use driver::{fit_passes, layout_faces_per_call}; +#[cfg(test)] +pub(crate) use on_path::{layout_trying_each_start, path_arrangements}; use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths}; #[cfg(test)] diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index de0272cb44..d119f5d780 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -7,6 +7,7 @@ use super::{ SGlyph, Shape, TextObject, Vec2, style_metrics, }; use crate::fontdb::PathAlignHeights; +use unicode_bidi::{BidiClass, BidiInfo}; /// The distance along type on a path each glyph takes: its advance measured `spacing` points above /// the path (Type on a Path Options › Spacing), so glyphs close up round the outside of a curve and @@ -132,6 +133,253 @@ fn bend_outline(outline: &BezPath, pre: Affine, ap: &ArcPath, bend: &GlyphBend) .then_some(out) } +/// Glyphs in a row, each taking its step: how far the row takes the line (`sum`), and how far past +/// where its first glyph starts the farthest of their ends is (`far`). +#[derive(Clone, Copy)] +struct Reach { + sum: f64, + far: f64, +} + +impl Reach { + /// No glyphs. + const NONE: Self = Self { sum: 0.0, far: f64::NEG_INFINITY }; + + /// One glyph. + fn step(step: f64) -> Self { + Self { sum: step, far: step } + } + + /// The glyphs of `self` followed by those of `next`. + fn then(self, next: Self) -> Self { + Self { sum: self.sum + next.sum, far: self.far.max(self.sum + next.far) } + } +} + +/// The reach of the glyphs at some of `n` positions in a row (a segment tree): a glyph is put at +/// its position in O(log n), and the reach of the glyphs in a range of positions is found in O(log n). +struct Reaches { + size: usize, + nodes: Vec, +} + +impl Reaches { + fn new(n: usize) -> Self { + let size = n.next_power_of_two(); + Self { size, nodes: vec![Reach::NONE; 2 * size] } + } + + fn node(&self, k: usize) -> Reach { + self.nodes.get(k).copied().unwrap_or(Reach::NONE) + } + + /// Puts a glyph that takes `step` at position `at`. + fn put(&mut self, at: usize, step: f64) { + let mut k = self.size + at; + let Some(leaf) = self.nodes.get_mut(k) else { return }; + *leaf = Reach::step(step); + while k > 1 { + k /= 2; + let joined = self.node(2 * k).then(self.node(2 * k + 1)); + if let Some(node) = self.nodes.get_mut(k) { + *node = joined; + } + } + } + + /// The reach of the glyphs put at positions `range`, in order. + fn of(&self, range: Range) -> Reach { + let (mut left, mut right) = (Reach::NONE, Reach::NONE); + let (mut a, mut b) = (range.start.min(self.size) + self.size, range.end.min(self.size) + self.size); + while a < b { + if a % 2 == 1 { + left = left.then(self.node(a)); + a += 1; + } + if b % 2 == 1 { + b -= 1; + right = self.node(b).then(right); + } + (a, b) = (a / 2, b / 2); + } + left.then(right) + } +} + +/// Whether a glyph whose character has bidi class `class` goes to its paragraph's level when the +/// line ends with it and the characters after it like it (UAX #9 L1): whitespace and isolate +/// controls, and the embedding controls and boundary neutrals, which `unicode_bidi` treats the same. +fn trails(class: BidiClass) -> bool { + use unicode_bidi::BidiClass::{BN, FSI, LRE, LRI, LRO, PDF, PDI, RLE, RLI, RLO, WS}; + matches!(class, WS | FSI | LRI | RLI | PDI | RLE | LRE | RLO | LRO | PDF | BN) +} + +/// Measures every start of the text in reading order, shortest first, in O(n log n) for n glyphs: +/// `visit(end, reach)` gets the byte each start ends at (a glyph's byte; the whole text is not +/// visited) and the reach of its glyphs in its own visual order when each glyph `g` takes `step(g)`. +/// +/// The visual order of a start of a paragraph keeps the order its glyphs have in the whole +/// paragraph (UAX #9 L2 reorders by the levels between two glyphs only), except for the glyphs +/// that L1 sets to the paragraph's level at the end of the start: those come after the rest in a +/// left-to-right paragraph and before it, last first, in a right-to-left one. The glyphs are put at +/// their positions in the whole text's visual order one start after another. +fn each_start(shaped: &[(Range, Option>, Vec)], step: impl Fn(&SGlyph) -> f64, mut visit: impl FnMut(usize, Reach)) { + let total = shaped.iter().map(|(_, _, glyphs)| glyphs.len()).sum(); + let mut reaches = Reaches::new(total); + let mut last_byte = None; + let mut para_at = 0; + for (pr, bidi, glyphs) in shaped { + // Where each glyph of the paragraph is in the whole text's visual order. + let mut at = vec![para_at; glyphs.len()]; + for (v, i) in visual_order(bidi.as_ref(), pr.start, pr.clone(), glyphs).into_iter().enumerate() { + if let Some(p) = at.get_mut(i) { + *p = para_at + v; + } + } + let para_rtl = bidi.as_ref().and_then(|b| b.paragraphs.first()).map(|p| p.level.is_rtl()); + // The glyphs at the end of the start so far that L1 sets to the paragraph's level, their + // reach in visual order, and the reach of the cluster being added to them. + let mut held: Vec<(usize, f64)> = Vec::new(); + let (mut trailing, mut cluster) = (Reach::NONE, Reach::NONE); + let mut prev: Option<&SGlyph> = None; + for (g, &p) in glyphs.iter().zip(&at) { + // A cluster is a glyph and those after it at its byte (a list marker's apart). + let joins = prev.is_some_and(|q| q.byte == g.byte && (q.len == 0) == (g.len == 0)); + if !joins { + trailing = match para_rtl { + Some(true) => cluster.then(trailing), + _ => trailing.then(cluster), + }; + cluster = Reach::NONE; + } + if last_byte != Some(g.byte) { + // The start that ends before this glyph. + let reach = match para_rtl { + _ if held.is_empty() => reaches.of(0..total), + Some(true) => reaches.of(0..para_at).then(trailing).then(reaches.of(para_at..total)), + _ => reaches.of(0..total).then(trailing), + }; + visit(g.byte, reach); + last_byte = Some(g.byte); + } + let s = step(g); + // Every character of the glyph's cluster is one that trails. + let from = g.byte.saturating_sub(pr.start); + let classes = bidi.as_ref().and_then(|b| b.original_classes.get(from..from + g.len)); + if para_rtl.is_some() && g.len > 0 && classes.is_some_and(|c| c.iter().copied().all(trails)) { + held.push((p, s)); + cluster = cluster.then(Reach::step(s)); + } else { + for (q, s) in held.drain(..) { + reaches.put(q, s); + } + (trailing, cluster) = (Reach::NONE, Reach::NONE); + reaches.put(p, s); + } + prev = Some(g); + } + // The whole paragraph is in its own visual order. + for (q, s) in held.drain(..) { + reaches.put(q, s); + } + para_at += glyphs.len(); + } +} + +/// The end (a byte) of the longest start of the text, in reading order, whose glyphs fit when each +/// takes its advance: `fits(w, far)` says whether glyphs in visual order that take `w` along the +/// path, the farthest of their ends `far` past where the first starts, fit. `first` is the first +/// glyph's byte, where nothing is laid out. A start can fit where a shorter one doesn't, after a +/// glyph that takes the line back, so every start is measured ([`each_start`]). +fn longest_fitting_start(shaped: &[(Range, Option>, Vec)], first: usize, fits: impl Fn(f64, f64) -> bool) -> usize { + let mut longest = first; + each_start( + shaped, + |g| g.adv, + |byte, reach| { + if fits(reach.sum, reach.far) { + longest = byte; + } + }, + ); + longest +} + +/// The ends (bytes) of the starts of the text, in reading order, shortest first, that can fit on a +/// path of length `len` with `spacing`: those whose glyphs, in their own visual order, reach at most +/// `len` past where the first starts when each takes the least step it can. A glyph's step is at +/// least a quarter of its advance with spacing, and its advance otherwise ([`path_steps`]); a line +/// starts no earlier than its span with the hang at its start, and every glyph that fits ends within +/// that span with both hangs, which is no longer than the path. The start before the first glyph, +/// where nothing is laid out, is always one of them. +fn starts_that_can_fit(shaped: &[(Range, Option>, Vec)], spacing: f64, len: f64) -> Vec { + let least = |g: &SGlyph| if spacing != 0.0 && g.adv > 1e-9 { g.adv * 0.25 } else { g.adv }; + let mut starts = Vec::new(); + each_start(shaped, least, |byte, reach| { + // A little room for sums added in another order than where the glyphs are placed. + if reach.far <= len + 1e-5 || reach.far.is_nan() { + starts.push(byte); + } + }); + starts +} + +/// The most starts of overflowing right-to-left type on a path that are arranged one by one, from +/// the longest, when a glyph takes the line back ([`longest_start_tried`]). +const PATH_START_TRIES: usize = 32; + +/// The longest of `starts` (ends of starts, shortest first, the first of which fits) for which +/// `fits` holds, arranging at most `tries` of them one by one, from the longest. When none of those +/// fits, a binary search among the rest finds one that fits with O(log n) more arrangements; as a +/// longer start can fit where a shorter one doesn't, that one is not always the longest that fits. +/// `None` when there are no starts. +fn longest_start_tried(starts: &[usize], tries: usize, mut fits: impl FnMut(usize) -> bool) -> Option { + let rest = starts.len().saturating_sub(tries); + if let Some(&byte) = starts.get(rest..).unwrap_or_default().iter().rev().find(|&&byte| fits(byte)) { + return Some(byte); + } + // `starts[fit]` fits; `starts[over]` was tried or doesn't. + let (mut fit, mut over) = (0, rest); + while over > fit + 1 { + let mid = fit + (over - fit) / 2; + if starts.get(mid).is_some_and(|&byte| fits(byte)) { + fit = mid; + } else { + over = mid; + } + } + starts.get(fit).copied() +} + +#[cfg(test)] +thread_local! { + /// How many times type on a path arranged glyphs on its path on this thread ([`path_arrangements`]). + static ARRANGEMENTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times type on a path arranged glyphs on its path on this thread. +#[cfg(test)] +pub(crate) fn path_arrangements() -> usize { + ARRANGEMENTS.get() +} + +#[cfg(test)] +thread_local! { + /// Set while [`layout_trying_each_start`] lays text out. + static EACH_START: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// [`layout`](super::layout) arranging every start of overflowing right-to-left type on a path in +/// turn, from the longest, until one fits: the reference the search for the longest start is +/// compared with. +#[cfg(test)] +pub(crate) fn layout_trying_each_start(db: &crate::fontdb::FontDb, t: &TextObject) -> crate::TextLayout { + EACH_START.set(true); + let l = super::layout(db, t); + EACH_START.set(false); + l +} + /// Lay type on a path out along `path` (text space), between its brackets. pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &PathData) { cx.out.on_path = true; @@ -247,29 +495,33 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, }; (l, r, hung_r) }; + // Where a line that takes `w` along the path starts, aligned in the span with the hangs. + let align = |w: f64, (l, r, _): (f64, f64, usize)| { + let (lo, room) = (s_start - l, avail + l + r); + match para.justify { + Justify::Auto if rtl => lo + (room - w).max(0.0), + Justify::Center | Justify::JustifyCenter => lo + ((room - w) * 0.5).max(0.0), + Justify::Right | Justify::JustifyRight => lo + (room - w).max(0.0), + _ => lo, + } + }; // The glyphs `sg` arranged on the path: their steps, where the first starts, and the hangs. let arrange = |sg: &[SGlyph]| { + #[cfg(test)] + ARRANGEMENTS.set(ARRANGEMENTS.get() + 1); let mut steps = path_steps(&ap, sg, s_start, spacing); - let w: f64 = steps.iter().sum(); - let place = |(l, r, _): (f64, f64, usize)| { - let (lo, room) = (s_start - l, avail + l + r); - match para.justify { - Justify::Auto if rtl => lo + (room - w).max(0.0), - Justify::Center | Justify::JustifyCenter => lo + ((room - w) * 0.5).max(0.0), - Justify::Right | Justify::JustifyRight => lo + (room - w).max(0.0), - _ => lo, - } - }; + // Where the line starts when its glyphs take `steps`. + let place = |steps: &[f64], hung| align(steps.iter().sum(), hung); let mut hung = hangs(sg, &steps); - let mut s0 = place(hung); + let mut s0 = place(&steps, hung); if spacing != 0.0 && s0 != s_start { // Spaced from where the alignment puts them, the curve under them is another. That - // changes the hanging glyphs' steps, so the line is placed again from the new steps - // until it stays where it is (at most 8 times). + // changes the glyphs' steps, so what the line takes and what hangs, and the line is + // placed again from the new steps until it stays where it is (at most 8 times). steps = path_steps(&ap, sg, s0, spacing); for _ in 0..8 { hung = hangs(sg, &steps); - let moved = place(hung); + let moved = place(&steps, hung); if (moved - s0).abs() < 1e-9 { break; } @@ -303,23 +555,47 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, // Right-to-left text that overflows keeps the start of its text, not what comes first in visual // order: the longest start, in reading order, whose glyphs fit in visual order is laid out. if shaped.iter().any(|(_, bidi, _)| bidi.is_some()) && fitting(&steps, s0, hung) < sg.len() { - let mut starts: Vec = shaped.iter().flat_map(|(_, _, glyphs)| glyphs.iter().map(|g| g.byte)).collect(); - starts.dedup(); - // The text before `starts[fit]` fits; before `starts[over]` (the whole text at the end) it - // doesn't. - let (mut fit, mut over) = (0, starts.len()); - while over - fit > 1 { - let mid = (fit + over) / 2; - let sg = visual(starts.get(mid).copied().unwrap_or(text_len)); + // Whether the text before byte `cut` fits, arranged as it would be laid out. + let fits_before = |cut: usize| { + let sg = visual(cut); let (steps, s0, hung) = arrange(&sg); - if fitting(&steps, s0, hung) == sg.len() { - fit = mid; - } else { - over = mid; + fitting(&steps, s0, hung) == sg.len() + }; + // The byte at which each start of the text ends, in reading order. + let mut starts: Vec = shaped.iter().flat_map(|(_, _, glyphs)| glyphs).map(|g| g.byte).collect(); + starts.dedup(); + let first = starts.first().copied().unwrap_or(text_len); + // On one straight segment the path doesn't turn, so spacing changes no step. + let straight = matches!(path.to_bezpath().elements(), [PathEl::MoveTo(_), PathEl::LineTo(_)]); + #[cfg(test)] + let each_start = EACH_START.get(); + #[cfg(not(test))] + let each_start = false; + fits = if each_start { + starts.iter().rev().copied().find(|&byte| fits_before(byte)).unwrap_or(first) + } else if (spacing == 0.0 || straight) && !hanging { + // Each glyph takes its advance wherever the line is placed. + longest_fitting_start(&shaped, first, |w, far| align(w, (0.0, 0.0, 0)) - s_start + far <= avail + 1e-6) + } else if sg.iter().all(|g| g.adv >= 0.0) { + // No glyph takes the line back: a start fits when a longer one does. The text before + // `starts[fit]` fits; before `starts[over]` (the whole text at the end) it doesn't. + let (mut fit, mut over) = (0, starts.len()); + while over - fit > 1 { + let mid = (fit + over) / 2; + if fits_before(starts.get(mid).copied().unwrap_or(text_len)) { + fit = mid; + } else { + over = mid; + } } - } + starts.get(fit).copied().unwrap_or(text_len) + } else { + // A glyph that takes the line back can make a start fit where a shorter one doesn't: + // the starts that can fit the path however their glyphs are spaced are tried from the + // longest, at most `PATH_START_TRIES` of them before a binary search over the rest. + longest_start_tried(&starts_that_can_fit(&shaped, spacing, ap.len()), PATH_START_TRIES, &fits_before).unwrap_or(first) + }; cx.out.overflow = true; - fits = starts.get(fit).copied().unwrap_or(text_len); sg = visual(fits); (steps, s0, hung) = arrange(&sg); } @@ -432,3 +708,33 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, region: 0, }); } + +#[cfg(test)] +mod tests { + use super::longest_start_tried; + + /// The longest start that fits is found among the ones tried; past the tries, a binary search + /// finds one that fits, with as many more tries as halvings of the rest. + #[test] + fn starts_are_tried_from_the_longest_then_searched() { + let starts: Vec = (0..1000).collect(); + // Every start up to 600 fits, and so does 990. + let fits = |byte: usize| byte <= 600 || byte == 990; + let mut tried = 0; + let found = longest_start_tried(&starts, 32, |byte| { + tried += 1; + fits(byte) + }); + assert_eq!((found, tried), (Some(990), 10)); + tried = 0; + let found = longest_start_tried(&starts, 8, |byte| { + tried += 1; + fits(byte) + }); + assert_eq!(found, Some(600)); + assert!(tried <= 8 + 10, "{tried} tries"); + // With fewer starts than tries, each is tried. + assert_eq!(longest_start_tried(&starts[..5], 32, |byte| byte == 0), Some(0)); + assert_eq!(longest_start_tried(&[], 32, |_| true), None); + } +} diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8b1b2bc7e3..e7e4f33ff6 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -835,6 +835,29 @@ fn on_path_circle_and_overflow() { assert!(l.overflow && l.glyphs.len() < t.plain_text().len()); } +/// Type on a curve with Spacing, aligned right or centered, is placed by what its glyphs take where +/// it ends up, which differs from what they take at the start bracket: text that fits shows whole. +#[test] +fn on_path_aligned_spaced_text_that_fits_shows_whole() { + let circle = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), 200.0).to_path(0.1)); + for text in ["Hi", "Around"] { + for justify in [Justify::Right, Justify::Center] { + for spacing in [20.0, -20.0] { + for (start, end) in [(0.0, Some(0.05)), (0.3, Some(0.4)), (0.0, None)] { + let mut t = point(text, style(12.0)); + t.para.justify = justify; + t.path_spacing = spacing; + t.kind = TextKind::OnPath { path: circle.clone(), start, end }; + let l = layout(db(), &t); + let case = format!("{text} {justify:?} {spacing} {start} {end:?}"); + assert!(!l.overflow, "{case}"); + assert_eq!(l.glyphs.len(), text.len(), "{case}"); + } + } + } + } +} + #[test] fn fallback_font_per_character() { let primary = db().face("Source Sans 3", "Regular").unwrap(); diff --git a/crates/text/src/tests/bidi.rs b/crates/text/src/tests/bidi.rs index 431ec9e568..f02db481ab 100644 --- a/crates/text/src/tests/bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -698,3 +698,170 @@ fn mixed_type_on_a_path_hangs_its_final_period() { assert!(l.glyphs.iter().any(|g| g.byte == s.len() - 1), "the period shows"); assert!(!l.overflow, "all of it fits"); } + +/// `n` Hebrew letters on `path` from `start` to `end` (fractions of its length), one run each, the +/// letter at `i` tracked so that it takes `steps[i % steps.len()]` points: what each takes with no +/// tracking is measured first, and what each takes when tracked is checked. +fn stepped_hebrew(db: &FontDb, n: usize, steps: &[f64], path: &BezPath, (start, end): (f64, Option)) -> TextObject { + let letters = ['א', 'ב', 'ג', 'ד']; + let style = CharStyle::default(); + let mut t = TextObject::point(Point::ZERO, "", style.clone()); + t.runs = (0..n).map(|i| TextRun { text: letters[i % letters.len()].to_string(), style: style.clone(), inline: None }).collect(); + let long = kurbo::Line::new((0.0, 0.0), (100_000.0, 0.0)).to_path(0.1); + t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&long), start: 0.0, end: None }; + let advances = |t: &TextObject| { + let l = layout(db, t); + assert!(!l.overflow); + let mut a: Vec<(usize, f64)> = l.glyphs.iter().map(|g| (g.byte, g.advance)).collect(); + a.sort_by_key(|&(byte, _)| byte); + assert_eq!(a.len(), n, "one glyph per letter"); + a.into_iter().map(|(_, adv)| adv).collect::>() + }; + let plain = advances(&t); + for (i, (run, adv)) in t.runs.iter_mut().zip(&plain).enumerate() { + run.style.tracking = (steps[i % steps.len()] - adv) / style.size * 1000.0; + assert!((-1000.0..=10000.0).contains(&run.style.tracking), "tracking {} is in the range commands allow", run.style.tracking); + } + for (i, adv) in advances(&t).into_iter().enumerate() { + assert!((adv - steps[i % steps.len()]).abs() < 1e-9, "letter {i} takes {adv}"); + } + t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(path), start, end }; + t +} + +/// Right-to-left type on a path that overflows shows the longest start of its text whose glyphs +/// fit, even where a shorter start doesn't fit: a letter tracked to take the line back (a negative +/// step) lets a longer start fit than the one before it. +#[test] +fn overflowing_rtl_type_on_a_path_shows_the_longest_start_that_fits() { + use vectorcraft_doc::Justify; + let db = FontDb::with_font_dirs(vec![]); + // The span runs from x = 0 to x = 10, with path on both sides of it. + let line = kurbo::Line::new((-100.0, 0.0), (100.0, 0.0)).to_path(0.1); + let t = stepped_hebrew(&db, 4, &[6.0, 6.0, -3.0, 6.0], &line, (0.5, Some(0.55))); + // Letters 0 and 1 take 12 points, more than the span holds; letters 0 to 2 take 9 and fit: + // in visual order letter 2 (-3), then 1 and 0 (6 each). All four take 15. + for (justify, s0) in [(Justify::Right, 1.0), (Justify::Auto, 1.0), (Justify::Center, 0.5), (Justify::Left, 0.0)] { + let mut t = t.clone(); + t.para.justify = justify; + let l = layout(&db, &t); + assert!(l.overflow, "{justify:?}"); + assert_eq!(l.lines.first().map(|line| line.end), Some(6), "{justify:?}: the first three letters show"); + assert_eq!(l.glyphs.len(), 3, "{justify:?}"); + let x = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.origin.x); + for (byte, at) in [(4, s0), (2, s0 - 3.0), (0, s0 + 3.0)] { + assert!(x(byte).is_some_and(|x| (x - at).abs() < 1e-6), "{justify:?}: letter at byte {byte} at {:?}, not {at}", x(byte)); + } + } +} + +/// The longest start that fits is the one found by arranging every start in turn, in mixed text +/// whose letters take the line forward and back: with spaces and controls at the end of a start +/// (UAX #9 L1), isolates, embeddings, several paragraphs and either base direction. +#[test] +fn the_longest_rtl_start_on_a_path_is_the_one_each_start_gives() { + use vectorcraft_doc::Justify; + let db = FontDb::with_font_dirs(vec![]); + let texts = [ + "abc אבג de שלום ", + "שלום עולם, hello world 123 שלום ", + "a \u{2067}אב \u{2069} cd אב \u{2066}x ", + "אב גד\nef גה \nשלום ab ", + "\u{202B}abc \u{202C} אב \u{200B} cd\u{0301} ", + ]; + let tracking = [-900.0, 300.0, -400.0, 0.0, 800.0, -1000.0, 150.0]; + let line = kurbo::Line::new((0.0, 0.0), (200.0, 0.0)).to_path(0.1); + let mut overflowed = 0; + for s in texts { + let style = CharStyle::default(); + let mut t = TextObject::point(Point::ZERO, "", style.clone()); + t.runs = s + .chars() + .enumerate() + .map(|(i, c)| TextRun { text: c.to_string(), style: CharStyle { tracking: tracking[i % tracking.len()], ..style.clone() }, inline: None }) + .collect(); + for direction in [None, Some(ParaDirection::RightToLeft), Some(ParaDirection::LeftToRight)] { + for justify in [Justify::Right, Justify::Center, Justify::Left, Justify::Auto] { + for end in (1..=25).map(|i| f64::from(i) * 0.02) { + t.para.direction = direction; + t.para.justify = justify; + t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: Some(end) }; + let (found, each) = (layout(&db, &t), crate::layout::layout_trying_each_start(&db, &t)); + let case = format!("{s:?} {direction:?} {justify:?} {end}"); + overflowed += usize::from(found.overflow); + assert_eq!(found.overflow, each.overflow, "{case}"); + assert_eq!(found.lines.first().map(|l| l.end), each.lines.first().map(|l| l.end), "{case}"); + let placed = |l: &TextLayout| l.glyphs.iter().map(|g| (g.byte, g.origin.x)).collect::>(); + assert_eq!(placed(&found), placed(&each), "{case}"); + } + } + } + } + assert!(overflowed > 500, "{overflowed} of the layouts overflow"); +} + +/// Finding the longest start that fits in a long right-to-left text whose letters take the line +/// back does not arrange the glyphs again for every start, nor for a search over them. +#[test] +fn the_longest_rtl_start_on_a_path_is_found_without_arranging_each_start() { + use vectorcraft_doc::Justify; + let db = FontDb::with_font_dirs(vec![]); + // Every 4 letters take 15 points, but 2 letters into a group the line is 3 points longer than + // after 3: with 940 points, 251 letters (939 points) fit and 250 (942) don't. + let line = kurbo::Line::new((0.0, 0.0), (940.0, 0.0)).to_path(0.1); + let t = stepped_hebrew(&db, 2000, &[6.0, 6.0, -3.0, 6.0], &line, (0.0, None)); + let mut cases = Vec::new(); + for justify in [Justify::Right, Justify::Center, Justify::Left] { + let mut t = t.clone(); + t.para.justify = justify; + cases.push((format!("{justify:?}"), t.clone(), 2)); + // Spacing on a straight path changes no step. + t.path_spacing = 5.0; + cases.push((format!("{justify:?} spaced"), t.clone(), 2)); + // Hanging punctuation: starts are arranged one by one, from the longest that could fit. + t.path_spacing = 0.0; + t.para.roman_hanging = true; + cases.push((format!("{justify:?} hanging"), t, 3)); + } + for (case, t, most) in cases { + let before = crate::layout::path_arrangements(); + let l = layout(&db, &t); + let arrangements = crate::layout::path_arrangements() - before; + assert!(l.overflow, "{case}"); + assert_eq!(l.lines.first().map(|line| line.end), Some(251 * 'א'.len_utf8()), "{case}: 251 letters show"); + assert_eq!(l.glyphs.len(), 251, "{case}"); + assert!(arrangements <= most, "{case}: {arrangements} arrangements"); + } +} + +/// Long right-to-left type on a curved, spaced path with hanging punctuation, whose letters take the +/// line forward and back in turn, is arranged a bounded number of times however long the text is, +/// and the start it shows fits. +#[test] +fn rtl_type_on_a_curve_whose_letters_take_the_line_back_is_arranged_a_bounded_number_of_times() { + use vectorcraft_doc::Justify; + let db = FontDb::with_font_dirs(vec![]); + let n = 600; + let circle = kurbo::Circle::new((0.0, 0.0), 200.0).to_path(0.1); + let t = stepped_hebrew(&db, n, &[6.0, -4.0], &circle, (0.0, Some(0.05))); + for justify in [Justify::Right, Justify::Left] { + for spacing in [20.0, -20.0] { + let mut t = t.clone(); + t.para.justify = justify; + t.para.roman_hanging = true; + t.path_spacing = spacing; + let case = format!("{justify:?} spacing {spacing}"); + let before = crate::layout::path_arrangements(); + let l = layout(&db, &t); + let arrangements = crate::layout::path_arrangements() - before; + let end = l.lines.first().map_or(0, |line| line.end); + // The whole text, at most 32 starts one by one, a binary search over 600, and the start shown. + assert!(arrangements <= 44, "{case}: {arrangements} arrangements, overflow {}, end {end}", l.overflow); + assert!(l.overflow, "{case}"); + assert!(end > 0, "{case}: some letters show"); + assert_eq!(l.glyphs.len(), end / 'א'.len_utf8(), "{case}: every letter of the start shows"); + let each = crate::layout::layout_trying_each_start(&db, &t); + assert!(end <= each.lines.first().map_or(0, |line| line.end), "{case}: no start longer than the longest that fits"); + } + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e7c1b28726..d44eac5e1c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1313,6 +1313,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. + Type on a path with right-to-left text that does not fit shows the longest start of its text whose glyphs fit, also where tracking gives a letter a negative advance and a shorter start does not fit; the rest overflows or flows to the next threaded object (VectorCraft's own rule). Without hanging punctuation, and on a straight path or with no spacing, every start is measured without arranging it on the path again. With hanging punctuation, or spacing on a curve, when a letter has a negative advance, at most 32 of the starts that could fit are arranged one by one from the longest; when none of them fits, a binary search over the rest shows a start that fits, which is not always the longest (`overflowing_rtl_type_on_a_path_shows_the_longest_start_that_fits`, `the_longest_rtl_start_on_a_path_is_found_without_arranging_each_start`, `the_longest_rtl_start_on_a_path_is_the_one_each_start_gives` and `rtl_type_on_a_curve_whose_letters_take_the_line_back_is_arranged_a_bounded_number_of_times` in `crates/text/src/tests/bidi.rs`). Right-aligned and centered type on a curve with Spacing is placed by what its glyphs take where the line ends up, so text that fits is not cut off at the end bracket (`on_path_aligned_spaced_text_that_fits_shows_whole` in `crates/text/src/tests.rs`). The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. The Paragraph panel menu's Bunri-Kinshi (`text.setFormat {bunriKinshi}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) keeps inseparable pairs on one line in each paragraph that has it: two em dashes, two ellipses, two two-dot leaders and the two halves of a vertical kana repeat mark (the pairs of W3C JLREQ's class cl-08, VectorCraft's set). Both composers and vertical type follow it, and justification adds no space inside a pair. It works with a Kinsoku Set only, is off by default and is saved only when on; editing the set of pairs is not available. The Paragraph panel menu's Korean Line Break Anywhere (`text.setFormat {koreanLineBreakAnywhere}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) sets for each paragraph whether a line may break between any two Hangul syllables. When it is off, a word that contains Hangul breaks only at its spaces and after punctuation, or where the word is wider than the line, and takes no hyphen. New type has it off; paragraphs saved before the option read as on, so their lines do not move.