diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index 8d9aa9f12b..b5e2107e75 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -130,12 +130,23 @@ fn rides_on(p: &TextGlyph, m: &TextGlyph) -> bool { && m.rect[0] <= p.rect[2] + reach } -/// Glyph `i` continues the run of glyph `i - 1`: when it is a combining mark on that glyph, it joins its cluster. +/// How far, as a fraction of its letter's height, a combining mark's em box may sit above or below the letter's and +/// still ride on it. A tone mark lifted over an upper vowel can be drawn more than half an em higher, past where a +/// glyph of its own would start a new line. +const MARK_RISE: f32 = 1.0; + +/// Whether glyph `m`, drawn right after glyph `p` (a letter, or a mark riding on letter `a`), rides on that cluster: +/// on `p` itself, or, for a mark stacked on another one, on its letter `a`. +fn rides_on_cluster(glyphs: &[&TextGlyph], p: usize, a: usize, m: &TextGlyph) -> bool { + glyphs.get(p).is_some_and(|pg| rides_on(pg, m)) || (a != p && glyphs.get(a).is_some_and(|ag| rides_on(ag, m))) +} + +/// Glyph `i` continues the run of glyph `i - 1`: when it is a combining mark on that glyph's cluster, it joins it. fn attach_mark(glyphs: &[&TextGlyph], anchor: &mut [usize], i: usize) { let Some(p) = i.checked_sub(1) else { return }; - if let (Some(pg), Some(mg)) = (glyphs.get(p), glyphs.get(i)) - && rides_on(pg, mg) - && let Some(&a) = anchor.get(p) + let Some(&a) = anchor.get(p) else { return }; + if let Some(mg) = glyphs.get(i) + && rides_on_cluster(glyphs, p, a, mg) && let Some(slot) = anchor.get_mut(i) { *slot = a; @@ -221,7 +232,13 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { let g = glyphs[i].rect; let hi = height(i); let cont = segs.last().is_some_and(|s| { - let Some(p) = s.first.end.checked_sub(1) else { return false }; + let Some(last) = s.first.end.checked_sub(1) else { return false }; + // A glyph after a combining mark continues from the mark's letter, and a mark on that letter's cluster + // stays on its line even when drawn raised or lowered by up to an em (`MARK_RISE`). + let p = anchor.get(last).copied().unwrap_or(last); + if rides_on_cluster(&glyphs, last, p, glyphs[i]) && (cy(i) - cy(p)).abs() < height(p) * MARK_RISE { + return true; + } let ph = height(p).min(hi); let sh = s.h.min(hi); let gap = g[0] - glyphs[p].rect[2]; diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index 0a9c91de24..c8bf5a5d68 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -161,8 +161,9 @@ pub(super) struct Scratch { } /// Fills `sc.cluster_gap` with the gap left of each glyph of `sc.placed`, between clusters: a combining mark riding on a -/// glyph has none (no word ever starts at it), and its cluster ends at the base's right edge or the furthest mark start. -/// A zero-advance mark's box is no measure of where the pen went (`draw_glyph` widens it to its outline or half an em). +/// glyph has none (no word ever starts at it), and its cluster ends at the base's right edge. A zero-advance mark's box +/// is no measure of where the pen went (`draw_glyph` widens it to its outline or half an em), and its start is only +/// the base's advance moved by a mark-positioning offset. pub(super) fn measure_cluster_gaps(glyphs: &[&TextGlyph], sc: &mut Scratch) { sc.cluster_gap.clear(); let mut right = f32::NEG_INFINITY; @@ -172,7 +173,6 @@ pub(super) fn measure_cluster_gaps(glyphs: &[&TextGlyph], sc: &mut Scratch) { continue; }; if anchor_of(&sc.anchor, c) != c { - right = right.max(g.rect[0]); sc.cluster_gap.push(None); } else { sc.cluster_gap.push((w > 0).then_some(g.rect[0] - right)); @@ -304,14 +304,15 @@ pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, } else { collide(glyphs, height, s.h, sc); } - // Each mark goes before the first placed glyph whose x is greater than its own. Marks are visited in x order, and - // that position never moves back, so one pass over `placed` puts all of them in. - sc.marks.sort_by(|a, b| x0(*a).total_cmp(&x0(*b))); + // Each mark goes before the first placed glyph whose x is greater than its own, or its letter's when it is a + // combining mark riding on one. Marks are visited in that order, and that position never moves back, so one pass + // over `placed` puts all of them in. + sc.marks.sort_by(|a, b| by_cluster(glyphs, &sc.anchor, *a, *b)); if !sc.marks.is_empty() { sc.merged.clear(); let mut next = 0; for &m in &sc.marks { - while next < sc.placed.len() && x0(anchor_of(&sc.anchor, sc.placed[next])) <= x0(m) { + while next < sc.placed.len() && x0(anchor_of(&sc.anchor, sc.placed[next])) <= x0(anchor_of(&sc.anchor, m)) { sc.merged.push(sc.placed[next]); next += 1; } diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index b5dd72bb9f..0014f79870 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -2064,3 +2064,33 @@ fn a_mark_moves_with_its_letter_in_a_line_drawn_backwards() { let v = vec![g("a", 12.0, 10.0, 18.0), g("b", 18.0, 10.0, 24.0), g("c", 24.0, 10.0, 30.0), g("\u{301}", 13.0, 10.0, 18.0)]; assert_eq!(layout(v).plain_text(), "a\u{301}bc"); } + +/// A second mark stacked on a letter rides on the letter, not only on the mark before it: a mark drawn back over its +/// base, then a tone mark a hair past the base's advance, both stay before the next letter. +#[test] +fn a_stacked_mark_rides_on_its_base_letter() { + let v = vec![g("ก", 12.0, 10.0, 18.0), g("\u{e34}", 10.0, 10.0, 15.0), g("\u{e48}", 19.5, 10.0, 24.5), g("ข", 18.0, 10.0, 24.0)]; + assert_eq!(layout(v).plain_text(), "ก\u{e34}\u{e48}ข"); +} + +/// A mark drawn raised by more than half an em over its letter (a tone mark lifted above an upper vowel) stays on the +/// letter's line and before the next letter. +#[test] +fn a_raised_mark_stays_with_its_letter() { + let v = vec![g("ก", 12.0, 10.0, 18.0), g("\u{e48}", 18.4, 16.0, 23.4), g("ข", 18.0, 10.0, 24.0), g("ค", 24.0, 10.0, 30.0)]; + assert_eq!(layout(v).plain_text(), "ก\u{e48}ขค"); +} + +/// A word gap after a mark is measured from its letter's advance: a mark drawn a hair past the pen does not narrow it. +#[test] +fn a_word_gap_after_a_mark_is_measured_from_its_letter() { + let v = vec![g("a", 0.0, 10.0, 6.0), g("b", 6.0, 10.0, 12.0), g("\u{301}", 12.4, 10.0, 17.4), g("c", 13.8, 10.0, 19.8), g("d", 19.8, 10.0, 25.8)]; + assert_eq!(layout(v).plain_text(), "ab\u{301} cd"); +} + +/// In a run squeezed to no width, a mark still follows its letter, even when it is drawn past the next letter. +#[test] +fn a_mark_in_a_zero_width_run_follows_its_letter() { + let v = vec![g("a", 10.0, 10.0, 10.0), g("\u{301}", 10.6, 10.0, 10.6), g("b", 10.3, 10.0, 10.3)]; + assert_eq!(layout(v).plain_text(), "a\u{301}b"); +}