From 2aecd57c28a666c5747896ab78de664c9fbb2eb5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 11:23:49 +0100 Subject: [PATCH] Text: justified RTL point text follows the resolved direction; only the first line decides the vertical baseline Review (CodeRabbit on #1875): with the paragraph direction unset, a right-to-left paragraph resolved by BidiInfo was still aligned as left-to-right justified point text, so it stayed left instead of flush right. eff_align now takes the resolved direction. The box-text first baseline used the font ascent whenever any run of the text was vertical; it now looks only at the runs of the first line. Two regression tests. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index b8e221274..a035bb21b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -654,8 +654,9 @@ fn paragraph( let st = &runs[si].1; first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) }; - let eff_align = |last: bool| -> Align { - if rtl_para && width.is_none() && para.align == Align::Justify { + // `rtl` is the resolved paragraph direction (an unset direction follows the text). + let eff_align = |last: bool, rtl: bool| -> Align { + if rtl && width.is_none() && para.align == Align::Justify { // Right-to-left point text with a justified paragraph sits flush at its start, the // right (After Effects 2026, every justify code; left-to-right not measured). Align::Right @@ -679,7 +680,7 @@ fn paragraph( descent: pm.descent, first: style_first(si), advance: runs[si].1.line_advance(), - align: eff_align(true), + align: eff_align(true, rtl_para), avail: avail_for(true), margins: margins_for(true), underline: None, @@ -861,7 +862,7 @@ fn paragraph( let mut out = Vec::new(); for (li, r) in ranges.into_iter().enumerate() { let last = li + 1 == nlines; - let align = eff_align(last); + let align = eff_align(last, base_rtl); let avail = avail_for(li == 0); let (ce, tw) = trailing_ws(&r); let hang = hang_of(&r, ce); @@ -1084,7 +1085,8 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para let para = para_at(pi); if i == 0 { // Vertical type keeps its first column at the font ascent (not measured in AE). - let vertical = runs.iter().any(|r| r.1.vertical); + let vertical = runs.get(run_at(runs, pl.range.start)).is_some_and(|r| r.1.vertical) + || runs.iter().any(|r| r.1.vertical && r.0.start < pl.range.end && r.0.end > pl.range.start); baseline = if width.is_some() { if vertical { pl.ascent } else { pl.first } } else { 0.0 }; } else { let prev_para = lay.lines.last().map_or(pi, |l| l.para); @@ -1613,6 +1615,33 @@ mod tests { } } + /// The same holds when the direction is unset and the text itself resolves right to left. + #[test] + fn detected_rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() { + let text = "\u{5d0}\u{5d1}\u{5d2} \u{5d3}\u{5d4}"; + let s = st(30.0); + let at = |align: Align| { + let p = ParagraphStyle { align, justify_last: Align::Left, rtl: None, ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let (right, got) = (at(Align::Right), at(Align::Justify)); + assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{got:?} vs right-aligned {right:?}"); + } + + /// Only the first line's own runs decide whether box text starts at the font ascent: a + /// vertical run further down leaves a horizontal first line on its `d`-height baseline. + #[test] + fn a_later_vertical_run_keeps_the_first_line_baseline() { + let text = "Top\nside"; + let horiz = st(40.0); + let vert = TextStyle { vertical: true, ..horiz.clone() }; + let p = ParagraphStyle { width: Some(400.0), ..Default::default() }; + let plain = layout_rich_uncached(text, &[(0..text.len(), horiz.clone())], std::slice::from_ref(&p)); + let mixed = layout_rich_uncached(text, &[(0..4, horiz), (4..text.len(), vert)], &[p]); + assert!((mixed.lines[0].baseline - plain.lines[0].baseline).abs() < 1e-3, "{} vs {}", mixed.lines[0].baseline, plain.lines[0].baseline); + } + #[test] fn oversized_layout_requests_are_bounded_without_caching() { let text = "a".repeat(MAX_LAYOUT_BYTES + 1);