Merge pull request #7415 from dotCooCoo/fix/warichu-rtl-gap-caret

Type: justification spaces a right-to-left warichu block, and the caret keeps to its block line
This commit is contained in:
BFlat
2026-11-14 21:55:12 +07:00
committed by GitHub
4 changed files with 150 additions and 31 deletions
+71 -17
View File
@@ -544,6 +544,9 @@ struct LineJustification {
fit: LineFit,
start_x: f64,
after: Vec<Option<usize>>,
/// The warichu glyphs drawn inside a block already started, by offset from the line's first
/// glyph ([`blocks_begun`]).
begun: Vec<bool>,
kashidas: Vec<(usize, f64)>,
/// Right-aligned area type: the line's last tab, which takes the text after it to the right
/// edge (the text before it sits at its stops, from the left edge).
@@ -805,14 +808,14 @@ fn visible_after(index: usize, glyph: usize, after: &[Option<usize>], sg: &[SGly
next
}
// The glyph after which a gap between drawn glyphs opens, and the visible glyph that follows it:
// not a held glyph, and never a zero-width format control on either side.
fn gap_pair<'a>(glyph: usize, held: &[usize], after: &[Option<usize>], index: usize, sg: &'a [SGlyph]) -> Option<(&'a SGlyph, &'a SGlyph)> {
// The glyph after which a gap between drawn glyphs opens, and the visible glyph that follows it
// with its index: not a held glyph, and never a zero-width format control on either side.
fn gap_pair<'a>(glyph: usize, held: &[usize], after: &[Option<usize>], index: usize, sg: &'a [SGlyph]) -> Option<(&'a SGlyph, &'a SGlyph, usize)> {
if held.contains(&glyph) {
return None;
}
let a = sg.get(glyph).filter(|a| !is_format_control(a.ch))?;
visible_after(index, glyph, after, sg).and_then(|next| sg.get(next)).map(|b| (a, b))
visible_after(index, glyph, after, sg).and_then(|next| sg.get(next).map(|b| (a, b, next)))
}
// Are `a` and `b` glyphs of the same warichu block? Right to left, the bidi order can draw a
@@ -821,18 +824,61 @@ fn same_warichu_block(a: &SGlyph, b: &SGlyph) -> bool {
a.wari.zip(b.wari).is_some_and(|(first, second)| first.start == second.start)
}
// Is glyph `next` drawn inside a block the line has already started: a tate-chu-yoko glyph after
// its block's first, or a warichu glyph drawn after another glyph of its block (`begun`, by offset
// from the line's first glyph, from [`blocks_begun`])? A warichu block starts at the first of its
// glyphs drawn on the line, which right to left is not the block's first glyph.
fn continues_drawn_block(next: usize, index: usize, begun: &[bool], sg: &[SGlyph]) -> bool {
let Some(b) = sg.get(next) else { return false };
match (b.wari, next.checked_sub(index).and_then(|k| begun.get(k))) {
(Some(_), Some(&begun)) => begun,
_ => b.continues_block(),
}
}
// The warichu glyphs of `line` drawn after another glyph of their block, by offset from the line's
// first glyph. A block the bidi order interleaves with another keeps its start.
fn blocks_begun(line: &LineGeometry, sg: &[SGlyph]) -> Vec<bool> {
let len = line.body.saturating_sub(line.index);
// Each glyph's block, as the offset of the block's first glyph: a block's glyphs are neighbors
// in `sg`, and a block stays on one line.
let mut first: Vec<usize> = Vec::with_capacity(len);
for k in 0..len {
let pair = k.checked_sub(1).and_then(|p| sg.get(line.index + p)).zip(sg.get(line.index + k));
let block = match first.last() {
Some(&f) if pair.is_some_and(|(a, b)| same_warichu_block(a, b)) => f,
_ => k,
};
first.push(block);
}
let mut started = vec![false; len];
let mut begun = vec![false; len];
for &j in &line.drawn {
let Some(k) = j.checked_sub(line.index).filter(|_| sg.get(j).is_some_and(|g| g.wari.is_some())) else { continue };
let Some(&f) = first.get(k) else { continue };
if let (Some(seen), Some(slot)) = (started.get(f).copied(), begun.get_mut(k)) {
*slot = seen;
}
if let Some(seen) = started.get_mut(f) {
*seen = true;
}
}
begun
}
// Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the
// gaps next to a CJK character, not inside a Latin word, a tate-chu-yoko or warichu block or a
// shaped cluster.
pub(super) fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option<usize>], index: usize, sg: &[SGlyph]) -> bool {
gap_pair(glyph, held, after, index, sg).is_some_and(|(a, b)| {
// shaped cluster. `begun` holds the warichu glyphs drawn inside a block already started
// ([`blocks_begun`]).
pub(super) fn is_cjk_gap(glyph: usize, held: &[usize], (after, begun): (&[Option<usize>], &[bool]), index: usize, sg: &[SGlyph]) -> bool {
gap_pair(glyph, held, after, index, sg).is_some_and(|(a, b, next)| {
a.byte != b.byte
&& !a.is_space()
&& !b.is_space()
&& !b.continues_block()
&& !same_warichu_block(a, b)
&& a.ch != '\t'
&& (is_cjk(a.ch) || is_cjk(b.ch))
&& !continues_drawn_block(next, index, begun, sg)
}) && !in_pair(glyph, index, after, sg)
}
@@ -861,13 +907,20 @@ fn in_pair(glyph: usize, index: usize, after: &[Option<usize>], sg: &[SGlyph]) -
// its drawn glyphs: not after a held glyph, nor inside a tate-chu-yoko block (one cell) or a
// warichu block (set as one piece), a shaped cluster, whose glyphs (a letter and its combining marks, a conjunct and its matra) stay
// together, or an inseparable pair. On a line that ends in a drawn hyphen, the gap before the
// hyphen counts too: it follows the line's last visible glyph.
pub(super) fn is_letter_gap(glyph: usize, held: &[usize], after: &[Option<usize>], index: usize, sg: &[SGlyph], hyphen: bool) -> bool {
// hyphen counts too: it follows the line's last visible glyph. `begun` is as for [`is_cjk_gap`].
pub(super) fn is_letter_gap(
glyph: usize,
held: &[usize],
(after, begun): (&[Option<usize>], &[bool]),
index: usize,
sg: &[SGlyph],
hyphen: bool,
) -> bool {
if held.contains(&glyph) || sg.get(glyph).is_none_or(|a| is_format_control(a.ch)) || in_pair(glyph, index, after, sg) {
return false;
}
match gap_pair(glyph, held, after, index, sg) {
Some((a, b)) => a.byte != b.byte && !b.continues_block() && !same_warichu_block(a, b),
Some((a, b, next)) => a.byte != b.byte && !same_warichu_block(a, b) && !continues_drawn_block(next, index, begun, sg),
None => hyphen,
}
}
@@ -974,7 +1027,8 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
*slot = Some(b);
}
}
let cjk_gaps = (line.index..line.body).filter(|&j| free(j) && is_cjk_gap(j, held, &after, line.index, sg)).count();
let begun = blocks_begun(line, sg);
let cjk_gaps = (line.index..line.body).filter(|&j| free(j) && is_cjk_gap(j, held, (&after, &begun), line.index, sg)).count();
// Single Word Justification: a justified line holding one word (no word space) is
// spread between its letters (Full Justify) or aligned. A tab separates two words.
let single_word = justify && fixed.is_empty() && spaces == 0 && cjk_gaps == 0;
@@ -1013,7 +1067,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS;
if pushed_in {
let ems: f64 = (line.index..line.body)
.filter(|&j| free(j) && is_cjk_gap(j, held, &after, line.index, sg) && may_squeeze(&sg[j]))
.filter(|&j| free(j) && is_cjk_gap(j, held, (&after, &begun), line.index, sg) && may_squeeze(&sg[j]))
.map(|j| squeeze_em(&sg[j], cx.vertical))
.sum();
if ems > EPS {
@@ -1044,7 +1098,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
let used: f64 = flex.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum();
let rest = room - width - used;
let hyphen = line.hyphen.is_some();
let gaps = (line.index..line.body).filter(|&j| free(j) && is_letter_gap(j, held, &after, line.index, sg, hyphen)).count();
let gaps = (line.index..line.body).filter(|&j| free(j) && is_letter_gap(j, held, (&after, &begun), line.index, sg, hyphen)).count();
if gaps > 0 && rest > EPS {
per_gap = rest / gaps as f64;
}
@@ -1076,7 +1130,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
_ => line.ax1 + line.opt_r - width,
}
};
LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing, fixed }
LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, begun, kashidas, last_tab, trailing, fixed }
}
/// The advance and leader of tab `glyph_index` of `line`, drawn at `order_index` of
@@ -1203,10 +1257,10 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
advance += justification.fit.letter(&flex) + justification.fit.glyph(&flex);
scale = justification.fit.glyph_scale(&flex);
if justification.per_cjk != 0.0 || justification.squeeze != 0.0 {
if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) {
if is_cjk_gap(glyph_index, &line.held, (&justification.after, &justification.begun), index, sg) {
advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical);
}
} else if is_letter_gap(glyph_index, &line.held, &justification.after, index, sg, line.hyphen.is_some()) {
} else if is_letter_gap(glyph_index, &line.held, (&justification.after, &justification.begun), index, sg, line.hyphen.is_some()) {
advance += justification.per_gap;
}
}
+7 -4
View File
@@ -1173,11 +1173,14 @@ fn justification_adds_no_gap_inside_a_warichu_block_drawn_right_to_left() {
let mut glyphs = synthetic_glyphs(&[(0, 2, 10.0, first), (2, 2, 0.0, second)]);
block(&mut glyphs[0], 0, 0.0);
block(&mut glyphs[1], 0, 10.0);
assert!(!flow::is_letter_gap(1, &[], &after, 0, &glyphs, false), "{first}{second}");
assert!(!flow::is_cjk_gap(1, &[], &after, 0, &glyphs), "{first}{second}");
// Glyph 0 is drawn after glyph 1 of its block.
let begun = [true, false];
assert!(!flow::is_letter_gap(1, &[], (&after, &begun), 0, &glyphs, false), "{first}{second}");
assert!(!flow::is_cjk_gap(1, &[], (&after, &begun), 0, &glyphs), "{first}{second}");
// The same two glyphs as the first glyphs of two blocks.
block(&mut glyphs[1], 2, 0.0);
assert!(flow::is_letter_gap(1, &[], &after, 0, &glyphs, false), "{first}{second}");
assert_eq!(flow::is_cjk_gap(1, &[], &after, 0, &glyphs), first == '漢', "{first}{second}");
let begun = [false, false];
assert!(flow::is_letter_gap(1, &[], (&after, &begun), 0, &glyphs, false), "{first}{second}");
assert_eq!(flow::is_cjk_gap(1, &[], (&after, &begun), 0, &glyphs), first == '漢', "{first}{second}");
}
}
+27 -10
View File
@@ -556,25 +556,32 @@ fn cluster_extent(layout: &TextLayout, glyphs: &[PositionedGlyph], i: usize) ->
/// Caret for byte offset `byte`, as a (top, bottom) segment in text space. In horizontal type, a
/// cluster drawn with several glyphs is measured whole ([`cluster_extent`]). Inside a ligature the
/// caret stands at its caret stops ([`PositionedGlyph::carets`]).
/// caret stands at its caret stops ([`PositionedGlyph::carets`]). Next to a character of a warichu
/// block the caret spans the band of its block line, as its highlight does.
pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) {
let Some(line) = layout.lines.get(layout.line_of(byte)) else {
return (Point::ZERO, Point::ZERO);
};
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
let (pos, angle) = if let Some(i) = glyphs.iter().position(|g| g.byte <= byte && g.byte + g.len > byte) {
let (pos, angle, glyph) = if let Some(i) = glyphs.iter().position(|g| g.byte <= byte && g.byte + g.len > byte) {
let g = &glyphs[i];
let (origin, advance) = match cluster_extent(layout, glyphs, i) {
Some((left, right)) => (Point::new(left, g.origin.y), right - left),
None => (g.origin, g.advance),
};
let off = g.caret_offset(byte, advance);
(origin + dir(g.angle) * if g.rtl { advance - off } else { off }, g.angle)
(origin + dir(g.angle) * if g.rtl { advance - off } else { off }, g.angle, Some(g))
} else if let Some(g) = glyphs.iter().max_by_key(|g| g.byte + g.len) {
(g.origin + dir(g.angle) * if g.rtl { 0.0 } else { g.advance }, g.angle)
(g.origin + dir(g.angle) * if g.rtl { 0.0 } else { g.advance }, g.angle, Some(g))
} else {
(layout.physical_point(Point::new(line.x0, line.baseline)), if layout.vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 })
(layout.physical_point(Point::new(line.x0, line.baseline)), if layout.vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 }, None)
};
if let Some((_, top, bottom)) =
glyph.filter(|g| g.warichu.is_some() && !layout.on_path).and_then(|g| block_band(&warichu_rows(layout, line), line, g))
{
let x = layout.logical_point(pos).x;
return (layout.physical_point(Point::new(x, top)), layout.physical_point(Point::new(x, bottom)));
}
// Up vector in y-down space, rotated with the baseline.
let d = dir(angle);
let up = Vec2::new(d.y, -d.x);
@@ -786,6 +793,20 @@ fn warichu_rows(layout: &TextLayout, l: &LineInfo) -> Vec<(usize, f64, f64)> {
rows
}
/// The band across line `l` (text space, from the top) of the block line that warichu glyph `g` is
/// on, with that block line's index in its block: out to halfway to the block lines next to it,
/// and to the line's top or bottom past the block's outer lines. `rows` are the line's
/// [`warichu_rows`]. None for a glyph of no block.
fn block_band(rows: &[(usize, f64, f64)], l: &LineInfo, g: &PositionedGlyph) -> Option<(usize, f64, f64)> {
let block = g.warichu?;
let lines = rows.get(rows.partition_point(|r| r.0 < block)..rows.partition_point(|r| r.0 <= block)).unwrap_or_default();
let row = lines.partition_point(|r| r.1 < g.warichu_line - 1e-9);
let own = lines.get(row).filter(|r| (r.1 - g.warichu_line).abs() < 1e-9)?;
let top = row.checked_sub(1).and_then(|k| lines.get(k)).map_or(l.baseline - l.ascent, |above| (above.2 + own.2) * 0.5);
let bottom = lines.get(row + 1).map_or(l.baseline + l.descent, |below| (below.2 + own.2) * 0.5);
Some((row, top, bottom))
}
/// A glyph of a warichu block line, for [`selection_quads`]: its block and block line, its extent
/// along the line and its band across it (text space), and whether the selection covers it.
struct BlockPiece {
@@ -829,11 +850,7 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
let mut pieces: Vec<BlockPiece> = Vec::new();
for (i, g) in layout.glyphs.iter().enumerate().take(l.glyph_end).skip(l.glyph_start) {
let Some(block) = g.warichu else { continue };
let lines = rows.get(rows.partition_point(|r| r.0 < block)..rows.partition_point(|r| r.0 <= block)).unwrap_or_default();
let row = lines.partition_point(|r| r.1 < g.warichu_line - 1e-9);
let Some(own) = lines.get(row).filter(|r| (r.1 - g.warichu_line).abs() < 1e-9) else { continue };
let top = row.checked_sub(1).and_then(|k| lines.get(k)).map_or(l.baseline - l.ascent, |above| (above.2 + own.2) * 0.5);
let bottom = lines.get(row + 1).map_or(l.baseline + l.descent, |below| (below.2 + own.2) * 0.5);
let Some((row, top, bottom)) = block_band(&rows, l, g) else { continue };
let x = layout.logical_point(g.origin).x;
let selected = selected(i, g);
let (f0, f1) = if selected { covered(g) } else { (0.0, g.advance) };
+45
View File
@@ -433,6 +433,51 @@ fn blocks_the_bidi_order_interleaves_keep_their_places() {
assert!(c.x < bet.x && bet.x < z.x, "{c:?} {bet:?} {z:?}");
}
/// A justified line spreads its letters with the same gap before a right-to-left block as before a
/// left-to-right one: the block starts at the first of its glyphs the line draws.
#[test]
fn justification_opens_the_same_gap_before_a_right_to_left_block() {
for inside in ["cd", "אב"] {
let at = |justify: Justify| {
let mut t = area("", style(20.0), Rect::new(0.0, 0.0, 200.0, 400.0), justify);
t.runs = vec![TextRun::new("ab", style(20.0)), TextRun::new(inside, marked(20.0)), TextRun::new("z", style(20.0))];
let l = layout(db(), &t);
let x = |byte: usize| l.glyphs.iter().filter(|g| g.byte == byte).map(|g| g.origin.x).fold(f64::INFINITY, f64::min);
let block = l.glyphs.iter().filter(|g| g.warichu.is_some()).map(|g| g.origin.x).fold(f64::INFINITY, f64::min);
(x(0), x(1), block, x(2 + inside.len()))
};
let (a, b, block, z) = at(Justify::Left);
let (a2, b2, block2, z2) = at(Justify::JustifyAll);
let gap = (b2 - a2) - (b - a);
assert!(gap > 1.0, "{inside}: the line is spread: {gap}");
assert!(((block2 - b2) - (block - b) - gap).abs() < 1e-6, "{inside}: the gap before the block: {} {gap}", (block2 - b2) - (block - b));
assert!(((z2 - block2) - (z - block) - gap).abs() < 1e-6, "{inside}: the gap after the block: {} {gap}", (z2 - block2) - (z - block));
}
}
/// The caret next to a character of a block spans the band of the character's block line, not
/// the whole line.
#[test]
fn the_caret_in_a_block_spans_its_block_line() {
let l = layout(db(), &with_block("cdef", marked(20.0)));
let (c, e) = (&l.glyphs[2], &l.glyphs[4]);
let middle = (c.origin.y + e.origin.y) * 0.5;
let line = &l.lines[0];
// "c" and "d", on the upper line.
for byte in [2, 3] {
let (top, bottom) = caret_position(&l, byte);
assert!((top.y - (line.baseline - line.ascent)).abs() < 1e-6 && (bottom.y - middle).abs() < 1e-6, "{byte}: {top:?} {bottom:?} {middle}");
}
// "e" and "f", on the lower line.
for byte in [4, 5] {
let (top, bottom) = caret_position(&l, byte);
assert!((top.y - middle).abs() < 1e-6 && (bottom.y - (line.baseline + line.descent)).abs() < 1e-6, "{byte}: {top:?} {bottom:?} {middle}");
}
// Outside the block the caret spans the line.
let (top, bottom) = caret_position(&l, 0);
assert!((top.y - (line.baseline - line.ascent)).abs() < 1e-6 && (bottom.y - (line.baseline + line.descent)).abs() < 1e-6);
}
/// A click on a block's lower line goes to that line's characters, not to the character of the
/// upper line at the same place along the block.
#[test]