//! EffectCraft text engine. //! //! - [`fonts`], [`sfnt`], [`layout`]: font database, shaping (harfrust), bidi, line breaking and //! paragraph layout (shared design with FilmCraft's text engine). //! - This module turns a layer's [`TextDoc`] into per-character glyph outlines (Bezier paths) with //! character / word / line indices, which is what text animators and selectors work on. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod fonts; pub mod layout; pub mod path_text; pub mod selectors; pub mod sfnt; pub mod variable; pub use kurbo; use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; use effectcraft_keyframe::{BaselineOption, CharStyle, Composer, Direction, Justify, Kerning, ParaStyle, TextDoc}; pub use fonts::{FaceId, Resolved, families, resolve}; use kurbo::{Affine, BezPath, Point}; pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, Script, TextStyle, layout as layout_text, layout_rich, measure}; use skrifa::instance::{LocationRef, Size}; use skrifa::outline::{DrawSettings, OutlinePen}; use skrifa::{GlyphId, MetadataProvider}; pub(crate) struct Pen(pub(crate) BezPath); impl OutlinePen for Pen { fn move_to(&mut self, x: f32, y: f32) { self.0.move_to((x as f64, y as f64)); } fn line_to(&mut self, x: f32, y: f32) { self.0.line_to((x as f64, y as f64)); } fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { self.0.quad_to((cx0 as f64, cy0 as f64), (x as f64, y as f64)); } fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { self.0.curve_to((cx0 as f64, cy0 as f64), (cx1 as f64, cy1 as f64), (x as f64, y as f64)); } fn close(&mut self) { self.0.close_path(); } } /// Glyph outline in font units (y up), cached. pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option> { static C: OnceLock>>>> = OnceLock::new(); let c = C.get_or_init(Default::default); if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, gid)) { return v.clone(); } let f = fonts::face(face); let v = f.font().and_then(|font| { let g = font.outline_glyphs().get(GlyphId::new(gid))?; let mut pen = Pen(BezPath::new()); g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen).ok()?; Some(Arc::new(pen.0)) }); let mut m = c.lock().unwrap_or_else(|e| e.into_inner()); if m.len() > 50_000 { m.clear(); } m.insert((face, gid), v.clone()); v } /// The outline of a laid-out glyph in pixels, origin on its baseline (y down), with its /// horizontal / vertical scale and faux italic. pub fn glyph_outline(g: &Glyph) -> BezPath { let units = if g.variations == 0 { outline_units(g.face, g.id) } else { variable::outline_units_at(g.face, g.id, &variable::coords(g.variations)) }; let Some(units) = units else { return BezPath::new() }; glyph_affine(g) * (*units).clone() } /// Font units → the glyph's outline in pixels (size, horizontal / vertical scale, faux italic). pub fn glyph_affine(g: &Glyph) -> Affine { let k = g.size as f64 / fonts::face(g.face).units_per_em() as f64; let slant = if g.synth_italic { 0.21 } else { 0.0 }; let (hs, vs) = (g.h_scale as f64, g.v_scale as f64); Affine::new([k * hs, 0.0, slant * k * vs * hs, -k * vs, 0.0, 0.0]) } /// One character of laid-out text. #[derive(Clone, Debug)] pub struct CharGlyph { /// Outline relative to `origin`. pub path: BezPath, /// Baseline origin in layer space. pub origin: Point, pub advance: f64, /// Indices for selectors (characters, characters excluding spaces, words, lines). pub char_index: usize, pub char_index_no_space: usize, pub word_index: usize, pub line_index: usize, pub is_space: bool, /// The source character (after All Caps). pub ch: char, pub synth_bold: bool, /// The glyph's drawn size (superscript / small caps are smaller than the run's size). pub size: f64, /// Index into [`TextLayout::styles`]. pub run: usize, /// Face and glyph id, and font units → `path` (for variable-font redraws). pub face: FaceId, pub gid: u32, pub outline_xf: Affine, /// The character's variable font axis values (its style's Variable Font Axes). pub variations: Vec<(String, f32)>, } #[derive(Clone, Debug, Default)] pub struct TextLayout { pub glyphs: Vec, pub chars: usize, pub chars_no_space: usize, pub words: usize, pub lines: usize, /// Bounds in layer space (x0, y0, x1, y1). pub bounds: [f64; 4], /// Line origins (layer space) `[x, baseline, width, height]`. pub line_boxes: Vec<[f64; 4]>, /// The character style of each run (glyphs refer to them by index). pub styles: Vec, /// The paragraph layout (layout space) and its map into layer space. pub layout: Arc, pub to_layer: Affine, /// Byte offset of every character, plus the text length. pub byte_of_char: Vec, pub vertical: bool, } /// A character style → [`TextStyle`]. pub fn text_style(s: &CharStyle) -> TextStyle { TextStyle { family: s.font.clone(), style: s.style.clone(), size: s.size as f32, tracking: s.tracking as f32, kerning: true, optical: s.kerning == Kerning::Optical, manual_kern: if let Kerning::Manual(v) = s.kerning { Some(v as f32) } else { None }, ligatures: s.ligatures, baseline_shift: s.baseline_shift as f32, faux_bold: s.faux_bold, faux_italic: s.faux_italic, caps: if s.all_caps { Caps::All } else if s.small_caps { Caps::Small } else { Caps::Normal }, underline: false, h_scale: (s.h_scale / 100.0) as f32, v_scale: (s.v_scale / 100.0) as f32, tsume: (s.tsume / 100.0) as f32, script: match s.baseline { BaselineOption::Normal => Script::Normal, BaselineOption::Superscript => Script::Super, BaselineOption::Subscript => Script::Sub, }, leading: s.leading.map(|l| l as f32), opentype: s.opentype, variations: s.variations.clone(), vertical: false, } } /// Paragraph settings → [`ParagraphStyle`] (wrap `width` for paragraph text). pub fn paragraph_style(p: &ParaStyle, width: Option) -> ParagraphStyle { let (align, justify_last) = match p.justify { Justify::Left => (Align::Left, Align::Left), Justify::Center => (Align::Center, Align::Center), Justify::Right => (Align::Right, Align::Right), Justify::JustifyLastLeft => (Align::Justify, Align::Left), Justify::JustifyLastCenter => (Align::Justify, Align::Center), Justify::JustifyLastRight => (Align::Justify, Align::Right), Justify::JustifyAll => (Align::Justify, Align::Justify), }; ParagraphStyle { align, justify_last, leading: 0.0, width, // The chosen direction is the paragraph's base direction (a left-to-right paragraph // starting with Arabic or Hebrew still runs left to right, as in After Effects). rtl: Some(p.direction == Direction::Rtl), indent_start: p.indent_left as f32, indent_end: p.indent_right as f32, indent_first: p.indent_first as f32, space_before: p.space_before as f32, space_after: p.space_after as f32, every_line: p.composer == Composer::EveryLine, hanging: p.hanging_punctuation, } } /// Characters set upright in vertical type: CJK ideographs, kana, hangul, full-width forms and /// CJK punctuation. Everything else (Roman) turns on its side unless asked to stay upright. pub fn is_cjk(c: char) -> bool { matches!(c as u32, 0x1100..=0x11FF | 0x2E80..=0x303F | 0x3040..=0x30FF | 0x3100..=0x31FF | 0x3200..=0x4DBF | 0x4E00..=0x9FFF | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xF900..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFF00..=0xFFEF | 0x20000..=0x3FFFF) } /// Vertical type: the horizontal layout turned a quarter clockwise (lines become columns /// running right to left), characters kept upright. const ROT90: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); /// Lay out a Source Text value in layer space. Point text: the origin is the start of the first /// baseline (left), its centre (centre) or end (right). Paragraph text fills `box_size` from /// `box_pos`. Vertical text runs down from the origin (point) or the box's top-right corner. pub fn layout_doc(doc: &TextDoc) -> TextLayout { let text = doc.text.as_str(); let byte_of_char: Vec = text.char_indices().map(|(b, _)| b).chain(std::iter::once(text.len())).collect(); let nchars = byte_of_char.len() - 1; let mut runs = Vec::new(); let mut styles = Vec::new(); let mut ci = 0; for r in doc.runs() { let a = byte_of_char[ci.min(nchars)]; ci += r.len; let b = byte_of_char[ci.min(nchars)]; let mut runtime_style = text_style(&r.style); runtime_style.vertical = doc.vertical && !r.style.tate_chu_yoko; runs.push((a..b, runtime_style)); styles.push(r.style); } let width = doc.box_size.map(|b| if doc.vertical { b[1] } else { b[0] } as f32); let paras: Vec = doc.paras().iter().map(|p| paragraph_style(p, width)).collect(); let lay = layout::layout_rich(text, &runs, ¶s); let to_layer = match (doc.vertical, doc.box_size) { (false, Some(_)) => Affine::translate((doc.box_pos[0], doc.box_pos[1])), (false, None) => Affine::IDENTITY, (true, Some(b)) => Affine::translate((doc.box_pos[0] + b[0], doc.box_pos[1])) * ROT90, (true, None) => ROT90, }; let mut out = TextLayout { lines: lay.lines.len(), to_layer, vertical: doc.vertical, ..Default::default() }; // Map byte clusters → char/word indices. let mut char_of_byte = vec![0usize; text.len() + 1]; let mut word_of_char = Vec::new(); let mut nospace_of_char = Vec::new(); let mut words = 0usize; let mut in_word = false; let mut nospace = 0usize; for (ci, (b, ch)) in text.char_indices().enumerate() { for k in b..b + ch.len_utf8() { char_of_byte[k] = ci; } let space = ch.is_whitespace(); if !space && !in_word { words += 1; } in_word = !space; word_of_char.push(words.saturating_sub(1)); nospace_of_char.push(nospace); if !space { nospace += 1; } } char_of_byte[text.len()] = nchars; out.chars = nchars; out.chars_no_space = nospace; out.words = words; let mut b = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; for (li, line) in lay.lines.iter().enumerate() { let o = to_layer * Point::new(line.x as f64, line.baseline as f64); out.line_boxes.push([o.x, o.y, line.width as f64, (line.ascent + line.descent) as f64]); // Vertical type: Tate-Chu-Yoko groups (consecutive glyphs of tcy runs) take one em of // the column; everything after them moves up by the rest of their width. let tcy = |gi: usize| doc.vertical && styles.get(lay.glyphs[gi].run).is_some_and(|s| s.tate_chu_yoko); let mut shift = 0.0f64; let mut group: Option<(f64, f64, f64)> = None; // (start x, width, em) in layout space let line_glyphs: Vec = line.glyphs.clone().collect(); for (k, gi) in line_glyphs.iter().copied().enumerate() { let g = &lay.glyphs[gi]; let ci = char_of_byte.get(g.cluster).copied().unwrap_or(0); let src = text[g.cluster..].chars().next().unwrap_or(' '); let st = styles.get(g.run); let ch = if st.is_some_and(|s| s.all_caps) { src.to_uppercase().next().unwrap_or(src) } else { src }; let mut path = glyph_outline(g); let mut outline_xf = glyph_affine(g); let next_x = lay.glyphs.get(gi + 1).filter(|_| line.glyphs.contains(&(gi + 1))).map(|n| n.x).unwrap_or(line.x + line.width); let advance = (next_x - g.x) as f64; let mut origin = to_layer * Point::new(g.x as f64 - shift, g.y as f64); if doc.vertical && tcy(gi) { // Start of a group: measure it. if group.is_none() { let mut w = 0.0; for &gj in line_glyphs[k..].iter().take_while(|&&gj| tcy(gj)) { let nx = lay.glyphs.get(gj + 1).filter(|_| line.glyphs.contains(&(gj + 1))).map(|n| n.x).unwrap_or(line.x + line.width); w += (nx - lay.glyphs[gj].x) as f64; } group = Some((g.x as f64, w, g.size as f64)); } let (x0, w, em) = group.unwrap_or((g.x as f64, advance, g.size as f64)); // Upright and horizontal, centred across the column in an em-high cell. let centre = to_layer * Point::new(x0 - shift + em / 2.0, g.y as f64 - 0.35 * em); origin = Point::new(centre.x - w / 2.0 + (g.x as f64 - x0), centre.y + 0.35 * em); if line_glyphs.get(k + 1).is_none_or(|&n| !tcy(n)) { shift += w - em; group = None; } } else if doc.vertical { let upright = is_cjk(src) || st.is_some_and(|s| s.vertical_roman_upright); if upright { // Anchor vertical forms using their own vertical origin and horizontal // advance, not the next glyph's pen (which includes tracking/justification). let font = fonts::face(g.face); let metrics = is_cjk(src).then(|| font.vertical_origin(g.id, g.size)).flatten(); let t = if let Some((width, top)) = metrics { Affine::translate((0.35 * g.size as f64 - width * g.h_scale as f64 / 2.0, top * g.v_scale as f64)) } else { let c = Point::new(advance / 2.0, -0.35 * g.size as f64); let rc = ROT90 * c; Affine::translate((rc.x - c.x, rc.y - c.y)) }; path = t * path; outline_xf = t * outline_xf; } else { // Roman characters turn on their side with the column. path = ROT90 * path; outline_xf = ROT90 * outline_xf; } } if let Some(r) = (!path.elements().is_empty()).then(|| kurbo::Shape::bounding_box(&path)) { b[0] = b[0].min(origin.x + r.x0); b[1] = b[1].min(origin.y + r.y0); b[2] = b[2].max(origin.x + r.x1); b[3] = b[3].max(origin.y + r.y1); } out.glyphs.push(CharGlyph { path, origin, advance, char_index: ci, char_index_no_space: nospace_of_char.get(ci).copied().unwrap_or(0), word_index: word_of_char.get(ci).copied().unwrap_or(0), line_index: li, is_space: src.is_whitespace(), ch, synth_bold: g.synth_bold, size: g.size as f64, run: g.run, face: g.face, gid: g.id, outline_xf, variations: variable::coords(g.variations).to_vec(), }); } } if b[0].is_finite() { out.bounds = b; } out.styles = styles; out.byte_of_char = byte_of_char; out.layout = lay; out } impl TextLayout { /// Character index of byte offset `b`. pub fn char_of_byte(&self, b: usize) -> usize { self.byte_of_char.partition_point(|x| *x < b).min(self.chars) } fn byte(&self, ci: usize) -> usize { self.byte_of_char.get(ci.min(self.chars)).copied().unwrap_or(0) } /// Layout line holding caret `ci`. pub fn line_of_char(&self, ci: usize) -> usize { self.layout.line_of(self.byte(ci)) } /// The caret at character boundary `ci` as a segment (top, bottom) in layer space. pub fn caret(&self, ci: usize) -> (Point, Point) { let (x, base, li) = self.layout.caret(self.byte(ci)); let (asc, desc) = self.layout.lines.get(li).map_or((10.0, 3.0), |l| (l.ascent, l.descent)); (self.to_layer * Point::new(x as f64, (base - asc) as f64), self.to_layer * Point::new(x as f64, (base + desc) as f64)) } /// The caret's position along its line (layout space), for up / down movement. pub fn caret_x(&self, ci: usize) -> f64 { self.layout.caret(self.byte(ci)).0 as f64 } /// The character boundary nearest a layer-space point. pub fn hit(&self, p: Point) -> usize { let q = self.to_layer.inverse() * p; self.char_of_byte(self.layout.hit(q.x as f32, q.y as f32)) } /// The caret one line up (`dir` < 0) or down from `ci`, keeping the line position `goal`. /// Past the first / last line it goes to the start / end of the text. pub fn move_vertical(&self, ci: usize, dir: i32, goal: Option) -> (usize, f64) { let goal = goal.unwrap_or_else(|| self.caret_x(ci)); let li = self.line_of_char(ci) as i64 + dir as i64; if li < 0 { return (0, goal); } match self.layout.lines.get(li as usize) { Some(l) => (self.char_of_byte(self.layout.hit_line(l, goal as f32)), goal), None => (self.chars, goal), } } /// Start and end carets of the visual line holding `ci`. pub fn line_span(&self, ci: usize) -> (usize, usize) { let li = self.line_of_char(ci); match self.layout.lines.get(li) { Some(l) => (self.char_of_byte(l.range.start), self.char_of_byte(self.layout.line_end(l))), None => (0, self.chars), } } /// Selection highlight quads (layer space) for characters `a..b`. pub fn selection_quads(&self, a: usize, b: usize) -> Vec<[Point; 4]> { self.layout .selection_rects(self.byte(a.min(b)), self.byte(a.max(b))) .into_iter() .map(|r| { let m = |x: f32, y: f32| self.to_layer * Point::new(x as f64, y as f64); [m(r[0], r[1]), m(r[2], r[1]), m(r[2], r[3]), m(r[0], r[3])] }) .collect() } } /// Outline (origin on the baseline) and advance of a single character in a character style: /// used by Character Offset / Character Value substitutions. pub fn char_glyph_style(s: &CharStyle, ch: char) -> (BezPath, f64) { let style = text_style(s); let lay = layout_text(&ch.to_string(), &style, &ParagraphStyle::default()); let Some(g) = lay.glyphs.first() else { return (BezPath::new(), 0.0) }; let adv = lay.lines.first().map(|l| l.width as f64).unwrap_or(0.0); (Affine::translate((-(g.x as f64), 0.0)) * glyph_outline(g), adv) } /// [`char_glyph_style`] in a Source Text's base style. pub fn char_glyph(doc: &TextDoc, ch: char) -> (BezPath, f64) { char_glyph_style(&doc.base_style(), ch) } #[cfg(test)] mod tests { use super::*; #[test] fn layout_counts_and_bounds() { let doc = TextDoc { text: "Hello big world".into(), size: 100.0, ..Default::default() }; let l = layout_doc(&doc); assert_eq!(l.chars, 15); assert_eq!(l.words, 3); assert_eq!(l.chars_no_space, 13); assert!(l.bounds[2] > 500.0, "{:?}", l.bounds); // Baseline at y = 0: caps rise above it. assert!(l.bounds[1] < -60.0 && l.bounds[3] < 30.0, "{:?}", l.bounds); assert!(l.glyphs.iter().filter(|g| !g.is_space).all(|g| !g.path.elements().is_empty())); } #[test] fn styled_runs_carets_and_hit_testing() { let mut doc = TextDoc { text: "Hello big\nworld".into(), size: 40.0, ..Default::default() }; doc.apply_style(6..9, |s| { s.size = 80.0; s.fill = [1.0, 0.0, 0.0, 1.0]; }); let l = layout_doc(&doc); let g = l.glyphs.iter().find(|g| g.char_index == 7).unwrap(); assert_eq!(l.styles[g.run].size, 80.0); assert_eq!(l.styles[g.run].fill, [1.0, 0.0, 0.0, 1.0]); assert_eq!(g.size, 80.0); let small = l.glyphs.iter().find(|g| g.char_index == 1).unwrap(); assert_eq!(small.origin.y, g.origin.y, "one baseline for mixed sizes"); // Line 2 sits one 40 px auto leading (48 px) below line 1. let w = l.glyphs.iter().find(|g| g.char_index == 10).unwrap(); assert!((w.origin.y - g.origin.y - 48.0).abs() < 1e-6); // Every caret hit-tests back to itself; carets advance along each line. for ci in 0..=doc.char_len() { let (a, b) = l.caret(ci); assert!(b.y > a.y); assert_eq!(l.hit(a.midpoint(b)), ci, "caret {ci}"); } assert!(l.caret(8).0.x > l.caret(7).0.x); // Up / down keep the line position; line spans and selections. let (down, goal) = l.move_vertical(2, 1, None); assert_eq!(l.line_of_char(down), 1); assert!((goal - l.caret_x(2)).abs() < 1e-6); assert_eq!(l.move_vertical(down, -1, Some(goal)).0, 2); assert_eq!(l.move_vertical(2, -1, None).0, 0); assert_eq!(l.move_vertical(12, 1, None).0, 15); assert_eq!(l.line_span(3), (0, 9)); assert_eq!(l.line_span(12), (10, 15)); let q = l.selection_quads(3, 12); assert_eq!(q.len(), 2); assert_eq!(l.char_of_byte(doc.byte_of(12)), 12); } #[test] fn paragraph_box_offset_and_vertical_type() { let doc = TextDoc { text: "ab cd".into(), size: 20.0, box_size: Some([200.0, 100.0]), box_pos: [-100.0, -50.0], ..Default::default() }; let l = layout_doc(&doc); let (a, _) = l.caret(0); assert!((a.x + 100.0).abs() < 1e-6 && (a.y + 50.0).abs() < 1.0, "{a:?}"); // Vertical: characters run down a column, carets are horizontal. let v = layout_doc(&TextDoc { text: "abc\nde".into(), size: 20.0, vertical: true, ..Default::default() }); let ys: Vec = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect(); assert!(ys.windows(2).all(|w| w[1] > w[0]), "{ys:?}"); let col2 = v.glyphs.iter().find(|g| g.line_index == 1).unwrap(); assert!(col2.origin.x < v.glyphs[0].origin.x - 10.0, "next column to the left"); let (p0, p1) = v.caret(1); assert!((p0.y - p1.y).abs() < 1e-6 && (p0.x - p1.x).abs() > 5.0); assert_eq!(v.hit(p0.midpoint(p1)), 1); } #[test] fn vertical_roman_rotates_and_tate_chu_yoko_sets_upright_groups() { let wide = |g: &CharGlyph| { let r = kurbo::Shape::bounding_box(&g.path); (r.width(), r.height()) }; // "H" sideways in a vertical column: wider than tall (it is taller than wide upright). let doc = TextDoc { text: "HI".into(), size: 40.0, vertical: true, ..Default::default() }; let v = layout_doc(&doc); let (w, h) = wide(&v.glyphs[0]); assert!(w > h, "rotated: {w}×{h}"); // Standard Vertical Roman Alignment keeps it upright. let mut up = doc.clone(); up.apply_style_all(|s| s.vertical_roman_upright = true); let (w, h) = wide(&layout_doc(&up).glyphs[0]); assert!(h > w, "upright: {w}×{h}"); // CJK stays upright. let cjk = layout_doc(&TextDoc { text: "日本".into(), size: 40.0, vertical: true, ..Default::default() }); assert!(cjk.glyphs.iter().all(|g| g.origin.x.is_finite())); // Tate-Chu-Yoko on "12" in "A12B": the digits sit side by side on one row of the column // (same y, increasing x) and the group takes one em of the column. let mut t = TextDoc { text: "A12B".into(), size: 40.0, vertical: true, ..Default::default() }; let plain = layout_doc(&t); t.apply_style(1..3, |s| s.tate_chu_yoko = true); let tcy = layout_doc(&t); let (one, two) = (&tcy.glyphs[1], &tcy.glyphs[2]); assert!((one.origin.y - two.origin.y).abs() < 1e-6, "{:?} {:?}", one.origin, two.origin); assert!(two.origin.x > one.origin.x + 5.0); let (w, h) = wide(one); assert!(h > w, "digits upright"); let w = one.advance + two.advance; let moved = tcy.glyphs[3].origin.y - plain.glyphs[3].origin.y; // (Within kerning, which no longer applies across the run boundaries.) assert!((moved - (40.0 - w)).abs() < 2.5, "the group takes one em: moved {moved}, width {w}"); // The group is centred across the column (x of the column centre line). let mid = (one.origin.x + two.origin.x + two.advance) / 2.0; assert!((mid - plain.glyphs[0].origin.x - 0.35 * 40.0).abs() < 3.0, "{mid}"); } #[test] fn ltr_paragraph_direction_is_forced() { // Arabic first: Left-to-Right keeps a left-to-right base (the Latin word stays left). let text = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627} abc"; let ltr = layout_doc(&TextDoc { text: text.into(), size: 30.0, ..Default::default() }); let mut r = TextDoc { text: text.into(), size: 30.0, ..Default::default() }; r.direction = effectcraft_keyframe::text_doc::Direction::Rtl; let rtl = layout_doc(&r); let x_of = |l: &TextLayout, c: char| l.glyphs.iter().find(|g| g.ch == c).map(|g| g.origin.x).unwrap(); let first_ar = '\u{0645}'; assert!(x_of(<r, 'a') > x_of(<r, first_ar), "LTR: Arabic run first, then abc"); assert!(x_of(&rtl, 'a') < x_of(&rtl, first_ar), "RTL: abc on the left"); } #[test] fn centered_point_text_straddles_origin() { let doc = TextDoc { text: "CENTER".into(), size: 80.0, justify: Justify::Center, ..Default::default() }; let l = layout_doc(&doc); assert!(l.bounds[0] < -100.0 && l.bounds[2] > 100.0, "{:?}", l.bounds); assert!((l.bounds[0] + l.bounds[2]).abs() < 20.0); } }