M4: right-to-left and mixed-direction text (Unicode bidi) (#34)

Text runs get bidi embedding levels from the Unicode bidi algorithm
(unicode-bidi, MIT/Apache-2.0). Right-to-left runs are shaped right to
left, kept in logical order for line breaking and placed in visual order
per line (rule L2, trailing spaces at the paragraph level). Glyphs and
lines know their direction, so the caret, clicks and selection
highlights follow right-to-left runs. Text without right-to-left
characters skips the analysis.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 05:12:14 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 1c6f04bf62
commit 632c5ffc16
7 changed files with 204 additions and 17 deletions
Generated
+1
View File
@@ -1459,6 +1459,7 @@ dependencies = [
"harfrust 0.13.3",
"log",
"skrifa 0.47.0",
"unicode-bidi",
]
[[package]]
+1
View File
@@ -43,6 +43,7 @@ usvg = "0.48"
krilla = "0.8"
skrifa = "0.47"
harfrust = "0.13"
unicode-bidi = "0.3"
linesweeper = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
sha2 = "0.10"
+1 -1
View File
@@ -98,7 +98,7 @@
<tr>
<td width="50%" valign="top">
<h3>Type that sets beautifully</h3>
Text edited in place on the canvas (with inline IME composition), mixed styles within a paragraph, auto width and auto height, line height, letter and paragraph spacing, decoration, case, lists, OpenType features, variable font axes and text styles, shaped with HarfRust and the open fonts from craft-fonts plus your system fonts.
Text edited in place on the canvas (with inline IME composition), mixed styles within a paragraph, auto width and auto height, line height, letter and paragraph spacing, decoration, case, lists, OpenType features, variable font axes and text styles, right-to-left and mixed-direction text (Arabic, Hebrew) with the Unicode bidi algorithm, shaped with HarfRust and the open fonts from craft-fonts plus your system fonts.
</td>
<td width="50%" valign="top">
<h3>Prototype and hand off</h3>
+1 -1
View File
@@ -144,7 +144,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Effects | 4 | 70% | glass/texture fidelity, shadow spread on non-rects exactness | 4–7 |
| Frames, groups, sections, masks | 5 | 75% | hiding section contents UI, section-level prototype/dev affordances | 2–4 |
| Auto layout & constraints | 9 | 65% | nested hug/fill edge cases, drag-reorder across nested auto layout frames | 4–7 |
| Text | 8 | 60% | right-to-left/bidi, text style picker depth, axis hover preview | 8–12 |
| Text | 8 | 65% | visual caret movement in right-to-left runs, right-to-left layer names in the panels, text style picker depth, axis hover preview | 6–9 |
| Components & variants | 9 | 60% | libraries, slots, nested override edge cases | 10–15 |
| Styles & variables | 5 | 72% | binding effects, line height and letter spacing, variable groups in the editor sidebar | 3–5 |
| Prototyping | 6 | 70% | scrolling inside device frames, prototype preview pane in the editor | 3–5 |
+1
View File
@@ -13,4 +13,5 @@ figcraft-geom = { workspace = true }
figcraft-doc = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
unicode-bidi = { workspace = true }
log = { workspace = true }
+142 -15
View File
@@ -1,4 +1,5 @@
//! Text layout: shaping styled spans, fallback fonts, line breaking, alignment and decorations.
//! Text layout: shaping styled spans, fallback fonts, bidirectional text, line breaking,
//! alignment and decorations.
use std::sync::Arc;
@@ -24,6 +25,8 @@ pub struct Glyph {
pub char_end: usize,
/// Index into [`TextLayout::styles`].
pub style: usize,
/// Part of a right-to-left run: its first char sits at the glyph's right edge.
pub rtl: bool,
}
impl Glyph {
@@ -50,6 +53,8 @@ pub struct Line {
pub x: f64,
/// The line ends a paragraph (hard break or end of text).
pub para_end: bool,
/// The paragraph runs right to left (its first strong character is right-to-left).
pub rtl: bool,
}
/// A decoration line (underline/strikethrough) to paint with the style's fills.
@@ -91,6 +96,8 @@ struct Item {
char_end: usize,
style: usize,
ch: char,
/// Bidi embedding level (odd = right to left).
level: u8,
}
impl Item {
@@ -102,6 +109,64 @@ impl Item {
}
}
/// Bidi embedding levels per char, and whether each char's paragraph runs right to left.
pub(crate) struct Bidi {
pub(crate) levels: Vec<u8>,
pub(crate) rtl_para: Vec<bool>,
}
impl Bidi {
/// None when the text has nothing right to left (the common case costs one scan).
pub(crate) fn of(text: &str) -> Option<Bidi> {
if !text.chars().any(is_rtl_char) {
return None;
}
let info = unicode_bidi::BidiInfo::new(text, None);
let mut paras = info.paragraphs.iter().peekable();
let n = text.chars().count();
let (mut levels, mut rtl_para) = (Vec::with_capacity(n), Vec::with_capacity(n));
for (b, _) in text.char_indices() {
while paras.peek().is_some_and(|p| p.range.end <= b) {
paras.next();
}
levels.push(info.levels.get(b).map(|l| l.number()).unwrap_or(0));
rtl_para.push(paras.peek().is_some_and(|p| p.level.is_rtl()));
}
Some(Bidi { levels, rtl_para })
}
}
/// Characters of right-to-left scripts and the explicit right-to-left marks.
fn is_rtl_char(c: char) -> bool {
matches!(c as u32, 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF | 0x10800..=0x10FFF | 0x1E800..=0x1EFFF | 0x200F | 0x202B | 0x202E | 0x2067)
}
/// Visual order of a line's items from their levels (rule L2: from the highest level down to the
/// lowest odd one, reverse every run at that level or above).
pub(crate) fn visual_order(levels: &[u8]) -> Vec<usize> {
let mut order: Vec<usize> = (0..levels.len()).collect();
let Some(lowest_odd) = levels.iter().copied().filter(|l| l % 2 == 1).min() else { return order };
let highest = levels.iter().copied().max().unwrap_or(0);
let level = |order: &[usize], i: usize| order.get(i).and_then(|k| levels.get(*k)).copied().unwrap_or(0);
for lvl in (lowest_odd..=highest).rev() {
let mut i = 0;
while i < order.len() {
if level(&order, i) < lvl {
i += 1;
continue;
}
let start = i;
while i < order.len() && level(&order, i) >= lvl {
i += 1;
}
if let Some(run) = order.get_mut(start..i) {
run.reverse();
}
}
}
order
}
fn is_cjk(c: char) -> bool {
matches!(c as u32, 0x3040..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF)
}
@@ -139,7 +204,10 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
.filter_map(|(k, on)| <[u8; 4]>::try_from(k.as_bytes()).ok().map(|t| Feature::new(Tag::new(&t), u32::from(*on), ..)))
.collect();
// Shape each span, split by paragraph, falling back per char.
// Shape each span, split by paragraph, bidi run and fallback face.
let bidi = Bidi::of(&text.characters);
let level_at = |i: usize| bidi.as_ref().and_then(|b| b.levels.get(i)).copied().unwrap_or(0);
let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false);
let mut items: Vec<Item> = Vec::with_capacity(chars.len());
for (si, (start, end, _)) in spans.iter().enumerate() {
let st_idx = span_style.get(si).copied().unwrap_or(0);
@@ -158,8 +226,12 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
};
let first = chars.get(seg_start).copied().unwrap_or(' ');
let face = face_for(first);
let level = level_at(seg_start);
let mut seg_end = seg_start + 1;
while seg_end < *end && chars.get(seg_end).is_some_and(|c| *c != '\n' && first != '\n' && face_for(*c).id == face.id) {
while seg_end < *end
&& chars.get(seg_end).is_some_and(|c| *c != '\n' && first != '\n' && face_for(*c).id == face.id)
&& level_at(seg_end) == level
{
seg_end += 1;
}
if first == '\n' {
@@ -175,9 +247,10 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
char_end: seg_start + 1,
style: st_idx,
ch: '\n',
level,
});
} else {
let seg = Segment { start: seg_start, end: seg_end, style, size, spacing, st_idx };
let seg = Segment { start: seg_start, end: seg_end, style, size, spacing, st_idx, level };
shape_segment(&chars, &seg, &face, &features, &mut items);
}
seg_start = seg_end;
@@ -288,6 +361,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum();
let line_indent = marker_width + if para_start_line { indent } else { 0.0 };
let rtl_line = rtl_para_at(items.get(*s).map(|it| it.char_start).unwrap_or(0));
let gstart = out.glyphs.len();
let mut pen = line_indent;
// Justify: extra space per gap.
@@ -328,12 +402,23 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
char_start: at,
char_end: at,
style: st_idx,
rtl: false,
});
mx += adv;
}
}
}
for it in items.get(*s..*e).unwrap_or(&[]) {
// Right-to-left runs are placed in visual order; trailing spaces take the paragraph's
// direction (rule L1).
let line_items = items.get(*s..*e).unwrap_or(&[]);
let order: Vec<usize> = if bidi.is_some() {
let para_level = u8::from(rtl_line);
let levels: Vec<u8> = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect();
visual_order(&levels)
} else {
(0..line_items.len()).collect()
};
for it in order.iter().filter_map(|k| line_items.get(*k)) {
if it.ch == '\n' {
continue;
}
@@ -350,6 +435,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
char_start: it.char_start,
char_end: it.char_end,
style: it.style,
rtl: it.level % 2 == 1,
});
}
pen += it.adv;
@@ -370,6 +456,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
width: width + line_indent,
x: 0.0,
para_end: *para_end,
rtl: rtl_line,
});
y += lh;
para_start_line = *para_end;
@@ -510,6 +597,7 @@ fn add_ellipsis(out: &mut TextLayout, wrap: Option<f64>) {
char_start: last.char_end,
char_end: last.char_end,
style: last.style,
rtl: false,
});
if let Some(l) = out.lines.last_mut() {
l.glyph_end = out.glyphs.len();
@@ -529,6 +617,8 @@ struct Segment<'a> {
size: f64,
spacing: f64,
st_idx: usize,
/// Bidi level (odd: shaped right to left).
level: u8,
}
/// Synthesised small caps: lower-case letters drawn as capitals at this fraction of the size.
@@ -586,12 +676,18 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Fe
for (u, src, _) in &mapped {
buf.add(*u, *src as u32);
}
buf.set_direction(Direction::LeftToRight);
let rtl = seg.level % 2 == 1;
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
buf.guess_segment_properties();
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
}
// Right-to-left glyphs come out in visual order; items are kept in logical order (lines
// put runs back in visual order).
if rtl {
raw.reverse();
}
} else {
for (u, src, _) in &mapped {
let gid = face.glyph_for(*u);
@@ -620,6 +716,7 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Fe
char_end: cend,
style: seg.st_idx,
ch,
level: seg.level,
});
}
}
@@ -646,6 +743,7 @@ impl TextLayout {
}
if index >= g.char_start && index < g.char_end {
let frac = (index - g.char_start) as f64 / (g.char_end - g.char_start) as f64;
let frac = if g.rtl { 1.0 - frac } else { frac };
return (g.x + g.advance * frac, li);
}
}
@@ -655,14 +753,14 @@ impl TextLayout {
(self.lines.last().map(|l| self.line_end_x(l)).unwrap_or(0.0), li)
}
/// Where a line's text ends: its right edge, or its left edge for a right-to-left paragraph.
fn line_end_x(&self, line: &Line) -> f64 {
self.glyphs
.get(line.glyph_start..line.glyph_end)
.unwrap_or(&[])
.iter()
.filter(|g| g.char_end > g.char_start)
.map(|g| g.x + g.advance)
.fold(line.x, f64::max)
let glyphs = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start);
if line.rtl {
glyphs.map(|g| g.x).fold(f64::INFINITY, f64::min).min(line.x + line.width)
} else {
glyphs.map(|g| g.x + g.advance).fold(line.x, f64::max)
}
}
/// The char index nearest to `(x, y)` (layout coordinates).
@@ -678,11 +776,14 @@ impl TextLayout {
continue;
}
if x < g.x + g.advance / 2.0 {
return g.char_start;
// A right-to-left glyph's left half is after it.
return if g.rtl { g.char_end } else { g.char_start };
}
}
let last_line = li + 1 == self.lines.len();
if line.para_end && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end }
let end = if line.para_end && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end };
// Right of a right-to-left line is its start.
if line.rtl { line.char_start } else { end }
}
/// Selection rectangles for chars `start..end`.
@@ -693,6 +794,11 @@ impl TextLayout {
if b <= line.char_start || a > line.char_end || (a == line.char_end && li + 1 < self.lines.len()) {
continue;
}
let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
if line.rtl || gl.iter().any(|g| g.rtl) {
out.extend(Self::mixed_selection(gl, a, b, line));
continue;
}
let (x0, _) = self.caret(a.max(line.char_start));
let x1 = if b >= line.char_end {
self.line_end_x(line) + if line.para_end && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
@@ -704,6 +810,27 @@ impl TextLayout {
out
}
/// Selection rectangles on a line with right-to-left runs: the selected part of each glyph
/// (they're in visual order), merged where they touch.
fn mixed_selection(gl: &[Glyph], a: usize, b: usize, line: &Line) -> Vec<Rect> {
let mut spans: Vec<(f64, f64)> = Vec::new();
for g in gl.iter().filter(|g| g.char_end > g.char_start) {
let (ga, gb) = (g.char_start.max(a), g.char_end.min(b));
if ga >= gb {
continue;
}
let n = (g.char_end - g.char_start) as f64;
let (f0, f1) = ((ga - g.char_start) as f64 / n, (gb - g.char_start) as f64 / n);
let (x0, x1) =
if g.rtl { (g.x + g.advance * (1.0 - f1), g.x + g.advance * (1.0 - f0)) } else { (g.x + g.advance * f0, g.x + g.advance * f1) };
match spans.last_mut() {
Some(last) if (x0 - last.1).abs() < 0.5 => last.1 = last.1.max(x1),
_ => spans.push((x0, x1)),
}
}
spans.into_iter().map(|(x0, x1)| Rect::new(x0, line.top, x1.max(x0 + 1.0), line.top + line.height)).collect()
}
/// All glyph outlines as one path (for outline/flatten and boolean ops).
pub fn to_path(&self) -> BezPath {
let mut p = BezPath::new();
+57
View File
@@ -327,3 +327,60 @@ fn static_fonts_have_no_axes() {
assert!(face.axis_values(&st).is_empty());
assert!(face.coords(&[700.0]).is_empty());
}
#[test]
fn bidi_visual_order_follows_levels() {
assert_eq!(crate::layout::visual_order(&[0, 0, 1, 1, 1, 0]), vec![0, 1, 4, 3, 2, 5]);
// A left-to-right run inside a right-to-left paragraph keeps its order.
assert_eq!(crate::layout::visual_order(&[1, 1, 2, 2, 1]), vec![4, 2, 3, 1, 0]);
assert_eq!(crate::layout::visual_order(&[0, 0, 0]), vec![0, 1, 2]);
assert!(crate::layout::visual_order(&[]).is_empty());
// Plain left-to-right text skips the analysis.
assert!(crate::layout::Bidi::of("Hello, world 123").is_none());
let b = crate::layout::Bidi::of("abc \u{5D0}\u{5D1}\u{5D2}\n\u{5D3} x").unwrap();
assert_eq!(b.levels.get(..4), Some(&[0, 0, 0, 0][..]));
assert!(b.levels.get(4..7).unwrap().iter().all(|l| l % 2 == 1));
// Paragraph direction comes from each paragraph's first strong char.
assert!(!b.rtl_para[0] && b.rtl_para[8]);
}
/// The x of the glyph for char `i`.
fn glyph_x(l: &TextLayout, i: usize) -> f64 {
l.glyphs.iter().find(|g| g.char_start == i && g.char_end > i).map(|g| g.x).unwrap()
}
#[test]
fn bidi_runs_lay_out_right_to_left() {
if !have_fonts() {
eprintln!("no fonts; skipping");
return;
}
// Hebrew inside an English line: its first letter is right of its last.
let mut t = TextData::new("abc \u{5D0}\u{5D1}\u{5D2}");
t.style.font_size = 20.0;
let l = layout(&t, None, None);
assert!(!l.lines[0].rtl);
assert!(glyph_x(&l, 2) < glyph_x(&l, 6), "Hebrew after the English");
assert!(glyph_x(&l, 4) > glyph_x(&l, 6), "א right of ג");
assert!(l.glyphs.iter().filter(|g| g.char_start >= 4).all(|g| g.rtl));
// Glyphs are stored left to right and don't overlap.
let xs: Vec<f64> = l.glyphs.iter().map(|g| g.x).collect();
assert!(xs.windows(2).all(|w| w[0] <= w[1] + 1e-6), "{xs:?}");
// A Hebrew paragraph with English inside: English sits on the left, in reading order.
let t = TextData::new("\u{5D0}\u{5D1}\u{5D2} abc");
let l = layout(&t, None, None);
assert!(l.lines[0].rtl);
assert!(glyph_x(&l, 4) < glyph_x(&l, 5) && glyph_x(&l, 6) < glyph_x(&l, 2));
assert!(glyph_x(&l, 0) > glyph_x(&l, 2));
// The caret starts at the right and ends at the left; clicks map back.
let right = l.glyphs.iter().map(|g| g.x + g.advance).fold(0.0, f64::max);
assert!((l.caret(0).0 - right).abs() < 1e-6, "{} vs {right}", l.caret(0).0);
assert!(l.caret(7).0 < glyph_x(&l, 2));
assert_eq!(l.hit(right + 50.0, 5.0), 0);
assert_eq!(l.hit(-50.0, 5.0), 4, "left of the English run: before 'a'");
// Selecting the Hebrew word highlights its glyphs only.
let r = l.selection_rects(0, 3);
assert_eq!(r.len(), 1);
assert!(r[0].x0 >= glyph_x(&l, 2) - 1e-6 && r[0].x1 <= right + 1e-6 && r[0].x0 > glyph_x(&l, 6));
}