diff --git a/Cargo.lock b/Cargo.lock index 4e6e381a..0168c5cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1459,6 +1459,7 @@ dependencies = [ "harfrust 0.13.3", "log", "skrifa 0.47.0", + "unicode-bidi", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f1b0c48b..515d0d0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index b8e05125..3985465c 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@

Type that sets beautifully

- 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.

Prototype and hand off

diff --git a/ROADMAP.md b/ROADMAP.md index b83761e4..f501ca52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index a184861a..44a4af9f 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -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 } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 4ea08beb..3b4b5cae 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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, + pub(crate) rtl_para: Vec, +} + +impl Bidi { + /// None when the text has nothing right to left (the common case costs one scan). + pub(crate) fn of(text: &str) -> Option { + 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 { + let mut order: Vec = (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, box_height: Option) .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 = 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, box_height: Option) }; 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, box_height: Option) 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, box_height: Option) } 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, box_height: Option) 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 = if bidi.is_some() { + let para_level = u8::from(rtl_line); + let levels: Vec = 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, box_height: Option) 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, box_height: Option) 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) { 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, 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, 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 { + 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(); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ff5e0beb..883b1c90 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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 = 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)); +}