Files
effectcraft/crates/text/src/layout.rs
T

724 lines
26 KiB
Rust

//! Paragraph layout: font fallback, bidi (UAX #9), shaping (OpenType via harfrust: kerning,
//! ligatures, mark positioning, complex scripts), line breaking (UAX #14), alignment, leading,
//! tracking, baseline shift, all caps / small caps, underline. Results are cached.
//!
//! Coordinates are pixels, y down. For **point text** (`ParagraphStyle::width == None`) the
//! origin is the alignment point on the first baseline: x = 0 is the left edge (left / justify),
//! the centre (centre) or the right edge (right). For **area text** (`width == Some(w)`) the
//! origin is the top-left of the text box and lines wrap at `w`.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::ops::Range;
use std::sync::{Arc, Mutex, OnceLock};
use harfrust::{Direction, Feature, Tag, UnicodeBuffer};
use unicode_bidi::{BidiInfo, Level};
use crate::fonts::{self, FaceId, Resolved};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Caps {
#[default]
Normal,
All,
Small,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Align {
#[default]
Left,
Center,
Right,
Justify,
}
/// Character formatting.
#[derive(Clone, Debug, PartialEq)]
pub struct TextStyle {
pub family: String,
pub style: String,
/// Font size in pixels.
pub size: f32,
/// Tracking in 1/1000 em (added after every cluster).
pub tracking: f32,
/// Metric kerning (`kern`).
pub kerning: bool,
/// Standard ligatures (`liga`, `clig`).
pub ligatures: bool,
/// Baseline shift in pixels (positive = up).
pub baseline_shift: f32,
pub faux_bold: bool,
pub faux_italic: bool,
pub caps: Caps,
pub underline: bool,
}
impl Default for TextStyle {
fn default() -> Self {
Self {
family: fonts::DEFAULT_FAMILY.into(),
style: "Regular".into(),
size: 100.0,
tracking: 0.0,
kerning: true,
ligatures: true,
baseline_shift: 0.0,
faux_bold: false,
faux_italic: false,
caps: Caps::Normal,
underline: false,
}
}
}
/// Paragraph formatting.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ParagraphStyle {
pub align: Align,
/// Extra line spacing in pixels added to the font's natural line height (Leading).
pub leading: f32,
/// Wrap width (area text); None = point text.
pub width: Option<f32>,
/// Base direction: None = from the first strong character.
pub rtl: Option<bool>,
}
fn hf(h: &mut impl Hasher, v: f32) {
v.to_bits().hash(h);
}
impl Hash for TextStyle {
fn hash<H: Hasher>(&self, h: &mut H) {
self.family.hash(h);
self.style.hash(h);
hf(h, self.size);
hf(h, self.tracking);
hf(h, self.baseline_shift);
(self.kerning, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline).hash(h);
}
}
impl Hash for ParagraphStyle {
fn hash<H: Hasher>(&self, h: &mut H) {
self.align.hash(h);
hf(h, self.leading);
self.width.map(f32::to_bits).hash(h);
self.rtl.hash(h);
}
}
/// A positioned glyph. `(x, y)` is the glyph origin on its baseline.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Glyph {
pub face: FaceId,
pub id: u32,
pub x: f32,
pub y: f32,
pub size: f32,
/// Byte offset of the cluster in the source text.
pub cluster: usize,
pub synth_bold: bool,
pub synth_italic: bool,
}
/// One laid-out line.
#[derive(Clone, Debug, PartialEq)]
pub struct Line {
/// Byte range in the source text (without the terminating newline).
pub range: Range<usize>,
pub baseline: f32,
/// Left edge and width of the line's content (trailing spaces excluded).
pub x: f32,
pub width: f32,
pub ascent: f32,
pub descent: f32,
pub glyphs: Range<usize>,
/// Caret stops `(byte offset, x)` for every character boundary in the line, by byte offset.
pub carets: Vec<(usize, f32)>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Layout {
pub glyphs: Vec<Glyph>,
pub lines: Vec<Line>,
/// Underline rectangles `[x0, y0, x1, y1]`.
pub underlines: Vec<[f32; 4]>,
/// Logical bounds `[x0, y0, x1, y1]` (line boxes; area text spans the box width).
pub bounds: [f32; 4],
/// The face requested was missing (substituted).
pub missing_font: bool,
pub text_len: usize,
}
impl Layout {
/// Line index for a caret at byte `pos`.
pub fn line_of(&self, pos: usize) -> usize {
let mut li = 0;
for (i, l) in self.lines.iter().enumerate() {
if l.range.start <= pos {
li = i;
}
}
li
}
/// Caret position `(x, baseline, line)` for byte offset `pos`.
pub fn caret(&self, pos: usize) -> (f32, f32, usize) {
let li = self.line_of(pos);
let Some(l) = self.lines.get(li) else { return (0.0, 0.0, 0) };
let x = l.carets.iter().min_by_key(|(b, _)| b.abs_diff(pos)).map_or(l.x, |c| c.1);
(x, l.baseline, li)
}
/// Nearest caret byte offset to point `(x, y)`.
pub fn hit(&self, x: f32, y: f32) -> usize {
let Some(l) = self.lines.iter().min_by(|a, b| line_dist(a, y).total_cmp(&line_dist(b, y))) else {
return 0;
};
l.carets.iter().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0)
}
/// Selection highlight rectangles `[x0, y0, x1, y1]` for the byte range `a..b`.
pub fn selection_rects(&self, a: usize, b: usize) -> Vec<[f32; 4]> {
let (a, b) = (a.min(b), a.max(b));
let mut out = Vec::new();
for l in &self.lines {
let s = a.max(l.range.start);
let e = b.min(l.range.end);
if s > e || (s == e && !(a < l.range.start && b > l.range.end)) {
continue;
}
let xs: Vec<f32> = l.carets.iter().filter(|(p, _)| *p >= s && *p <= e).map(|c| c.1).collect();
if xs.len() < 2 && b <= l.range.end {
continue;
}
let x0 = xs.iter().copied().fold(f32::MAX, f32::min);
let mut x1 = xs.iter().copied().fold(f32::MIN, f32::max);
if b > l.range.end {
x1 += l.ascent * 0.25; // selected newline
}
out.push([x0.min(x1), l.baseline - l.ascent, x1, l.baseline + l.descent]);
}
out
}
}
fn line_dist(l: &Line, y: f32) -> f32 {
let (t, b) = (l.baseline - l.ascent, l.baseline + l.descent);
if y < t {
t - y
} else if y > b {
y - b
} else {
0.0
}
}
struct ShapedGlyph {
face: FaceId,
id: u32,
cluster: usize,
adv: f32,
dx: f32,
dy: f32,
size: f32,
}
struct Item {
range: Range<usize>,
rtl: bool,
glyphs: Vec<ShapedGlyph>,
}
fn upper_single(c: char) -> char {
let mut u = c.to_uppercase();
match (u.next(), u.next()) {
(Some(x), None) => x,
_ => c,
}
}
const SMALL_CAPS_SCALE: f32 = 0.78;
fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle) -> Vec<ShapedGlyph> {
let f = fonts::face(face);
let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() };
let shaper = data.shaper(&font).build();
let mut buf = UnicodeBuffer::new();
for &(b, _, sc) in chars {
buf.add(if sc == '\t' { ' ' } else { sc }, b as u32);
}
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
buf.guess_segment_properties();
let mut feats = Vec::new();
if !style.kerning {
feats.push(Feature::new(Tag::new(b"kern"), 0, ..));
}
if !style.ligatures {
feats.push(Feature::new(Tag::new(b"liga"), 0, ..));
feats.push(Feature::new(Tag::new(b"clig"), 0, ..));
}
let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats));
let k = size / f.units_per_em();
let _ = text;
out.glyph_infos()
.iter()
.zip(out.glyph_positions())
.map(|(i, p)| ShapedGlyph {
face,
id: i.glyph_id,
cluster: i.cluster as usize,
adv: p.x_advance as f32 * k,
dx: p.x_offset as f32 * k,
dy: p.y_offset as f32 * k,
size,
})
.collect()
}
struct ParaLine {
range: Range<usize>,
glyphs: Vec<Glyph>,
carets: Vec<(usize, f32)>,
content: f32,
left_trim: f32,
ascent: f32,
descent: f32,
}
/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.
fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, primary: Resolved) -> Vec<ParaLine> {
let pm = fonts::face(primary.face).metrics(style.size);
if text.is_empty() {
return vec![ParaLine {
range: base..base,
glyphs: vec![],
carets: vec![(base, 0.0)],
content: 0.0,
left_trim: 0.0,
ascent: pm.ascent,
descent: pm.descent,
}];
}
let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() });
let bidi = BidiInfo::new(text, default_level);
let pinfo = &bidi.paragraphs[0];
let base_rtl = pinfo.level.is_rtl();
// per char: (byte, char, shaped char), face, level, small
let chars: Vec<(usize, char, char, FaceId, bool, bool)> = text
.char_indices()
.map(|(b, c)| {
let (sc, small) = match style.caps {
Caps::Normal => (c, false),
Caps::All => (upper_single(c), false),
Caps::Small if c.is_lowercase() => (upper_single(c), true),
Caps::Small => (c, false),
};
let face = if sc.is_whitespace() { primary.face } else { fonts::fallback_for(sc, primary.face) };
(b, c, sc, face, bidi.levels[b].is_rtl(), small)
})
.collect();
// items: runs of equal (face, rtl, small)
let mut items: Vec<Item> = Vec::new();
let mut i = 0;
while i < chars.len() {
let (face, rtl, small) = (chars[i].3, chars[i].4, chars[i].5);
let mut j = i + 1;
while j < chars.len() && chars[j].3 == face && chars[j].4 == rtl && chars[j].5 == small {
j += 1;
}
let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect();
let size = if small { style.size * SMALL_CAPS_SCALE } else { style.size };
let mut glyphs = shape_item(text, &sub, rtl, face, size, style);
// tracking after each cluster
if style.tracking != 0.0 {
let t = style.tracking * style.size / 1000.0;
for k in 0..glyphs.len() {
if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster {
glyphs[k].adv += t;
}
}
}
let end = if j < chars.len() { chars[j].0 } else { text.len() };
items.push(Item { range: chars[i].0..end, rtl, glyphs });
i = j;
}
// advance per char byte (cluster start)
let mut char_adv = vec![0.0f32; text.len() + 1];
for it in &items {
for g in &it.glyphs {
char_adv[g.cluster.min(text.len())] += g.adv;
}
}
let range_w = |r: Range<usize>| -> f32 { char_adv[r].iter().sum() };
let trailing_ws = |r: &Range<usize>| -> (usize, f32) {
let mut end = r.end;
let mut w = 0.0;
for (b, c) in text[r.clone()].char_indices().rev() {
if !c.is_whitespace() {
break;
}
end = r.start + b;
w += char_adv[r.start + b];
}
(end, w)
};
// line breaking
let mut ranges: Vec<Range<usize>> = Vec::new();
match para.width {
None => ranges.push(0..text.len()),
Some(maxw) => {
let mut start = 0usize;
let mut last_ok: Option<usize> = None;
for (p, _) in unicode_linebreak::linebreaks(text) {
loop {
let r = start..p;
let (ce, _) = trailing_ws(&r);
let w = range_w(start..ce);
if w <= maxw || last_ok.is_none() {
last_ok = Some(p);
break;
}
let b = last_ok.take().expect("checked");
ranges.push(start..b);
start = b;
}
}
ranges.push(start..text.len());
ranges.retain(|r| !r.is_empty());
if ranges.is_empty() {
ranges.push(0..text.len());
}
}
}
let nlines = ranges.len();
let mut out = Vec::new();
for (li, r) in ranges.into_iter().enumerate() {
let (ce, tw) = trailing_ws(&r);
let content = range_w(r.start..ce);
// justification
let mut space_extra = 0.0;
if para.align == Align::Justify
&& li + 1 < nlines
&& let Some(maxw) = para.width
{
let spaces = text[r.start..ce].chars().filter(|c| *c == ' ').count();
if spaces > 0 {
space_extra = ((maxw - content) / spaces as f32).max(0.0);
}
}
let (levels, runs) = bidi.visual_runs(pinfo, r.clone());
let mut pen = 0.0f32;
let mut glyphs = Vec::new();
let mut extents: HashMap<usize, (f32, f32, bool)> = HashMap::new();
for run in runs {
let rtl = levels[run.start].is_rtl();
let mut idx: Vec<usize> = (0..items.len()).filter(|&k| items[k].range.start < run.end && items[k].range.end > run.start).collect();
if rtl {
idx.reverse();
}
for k in idx {
for g in items[k].glyphs.iter().filter(|g| run.contains(&g.cluster)) {
let x0 = pen;
glyphs.push(Glyph {
face: g.face,
id: g.id,
x: pen + g.dx,
y: -g.dy - style.baseline_shift,
size: g.size,
cluster: base + g.cluster,
synth_bold: primary.synth_bold || style.faux_bold,
synth_italic: primary.synth_italic || style.faux_italic,
});
pen += g.adv;
if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce {
pen += space_extra;
}
let e = extents.entry(g.cluster).or_insert((x0, pen, items[k].rtl));
e.0 = e.0.min(x0);
e.1 = e.1.max(pen);
}
}
}
// caret stops for each char boundary in the line
let mut carets = Vec::new();
let mut cluster_starts: Vec<usize> = extents.keys().copied().collect();
cluster_starts.sort_unstable();
let char_bytes: Vec<usize> = text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect();
for (ci, &cs) in cluster_starts.iter().enumerate() {
let (x0, x1, rtl) = extents[&cs];
let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end);
let members: Vec<usize> = char_bytes.iter().copied().filter(|b| *b >= cs && *b < next).collect();
let n = members.len().max(1) as f32;
for (mi, b) in members.iter().enumerate() {
let f = mi as f32 / n;
let x = if rtl { x1 - (x1 - x0) * f } else { x0 + (x1 - x0) * f };
carets.push((base + b, x));
}
}
// end of line caret
let end_x = match chars.iter().rev().find(|c| c.0 < r.end && c.0 >= r.start) {
Some(c) => {
let cs = cluster_starts.iter().rev().find(|s| **s <= c.0).copied();
match cs.and_then(|s| extents.get(&s)) {
Some(&(x0, x1, rtl)) => {
if rtl {
x0
} else {
x1
}
}
None => pen,
}
}
None => 0.0,
};
carets.push((base + r.end, end_x));
carets.sort_by_key(|c| c.0);
carets.dedup_by_key(|c| c.0);
let mut asc = pm.ascent;
let mut desc = pm.descent;
for g in &glyphs {
if g.face != primary.face {
let m = fonts::face(g.face).metrics(g.size);
asc = asc.max(m.ascent);
desc = desc.max(m.descent);
}
}
let content_w = if space_extra > 0.0 { para.width.unwrap_or(content) } else { content };
out.push(ParaLine {
range: base + r.start..base + r.end,
glyphs,
carets,
content: content_w,
left_trim: if base_rtl { tw } else { 0.0 },
ascent: asc,
descent: desc,
});
}
out
}
/// Lay out `text` (paragraphs separated by `\n`).
pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Layout {
let primary = fonts::resolve(&style.family, &style.style);
let pm = fonts::face(primary.face).metrics(style.size);
let line_h = (pm.ascent + pm.descent + pm.line_gap).max(style.size * 0.5) + para.leading;
let mut lay = Layout { missing_font: primary.missing, text_len: text.len(), ..Default::default() };
let mut base = 0usize;
let mut plines = Vec::new();
for p in text.split('\n') {
let p_clean = p.strip_suffix('\r').unwrap_or(p);
plines.extend(paragraph(p_clean, base, style, para, primary));
base += p.len() + 1;
}
let first_baseline = if para.width.is_some() { pm.ascent } else { 0.0 };
for (i, pl) in plines.into_iter().enumerate() {
let baseline = first_baseline + line_h * i as f32;
let shift = match (para.width, para.align) {
(None, Align::Left | Align::Justify) => 0.0,
(None, Align::Center) => -pl.content / 2.0,
(None, Align::Right) => -pl.content,
(Some(_), Align::Left | Align::Justify) => 0.0,
(Some(w), Align::Center) => (w - pl.content) / 2.0,
(Some(w), Align::Right) => w - pl.content,
} - pl.left_trim;
let g0 = lay.glyphs.len();
lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| {
g.x += shift;
g.y += baseline;
g
}));
let x = shift + pl.left_trim;
if style.underline && pl.content > 0.0 {
lay.underlines.push([x, baseline + pm.underline_pos, x + pl.content, baseline + pm.underline_pos + pm.underline_thickness]);
}
lay.lines.push(Line {
range: pl.range,
baseline,
x,
width: pl.content,
ascent: pl.ascent,
descent: pl.descent,
glyphs: g0..lay.glyphs.len(),
carets: pl.carets.into_iter().map(|(b, cx)| (b, cx + shift)).collect(),
});
}
let (mut x0, mut x1) = (f32::MAX, f32::MIN);
for l in &lay.lines {
x0 = x0.min(l.x);
x1 = x1.max(l.x + l.width);
}
if let Some(w) = para.width {
x0 = 0.0;
x1 = x1.max(w);
}
let first = &lay.lines[0];
let last = lay.lines.last().expect("at least one line");
lay.bounds = [x0, first.baseline - first.ascent, x1.max(x0), last.baseline + last.descent];
lay
}
fn cache() -> &'static Mutex<HashMap<u64, (u64, Arc<Layout>)>> {
static C: OnceLock<Mutex<HashMap<u64, (u64, Arc<Layout>)>>> = OnceLock::new();
C.get_or_init(Default::default)
}
/// Lay out `text` (cached by text and styles).
pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc<Layout> {
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
style.hash(&mut h);
para.hash(&mut h);
let key = h.finish();
static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if let Some(e) = cache().lock().unwrap_or_else(|e| e.into_inner()).get_mut(&key) {
e.0 = now;
return e.1.clone();
}
let l = Arc::new(layout_uncached(text, style, para));
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
if c.len() >= 512 {
// evict the oldest quarter
let mut ages: Vec<u64> = c.values().map(|v| v.0).collect();
ages.sort_unstable();
let cut = ages[ages.len() / 4];
c.retain(|_, v| v.0 > cut);
}
c.insert(key, (now, l.clone()));
l
}
/// Width of a single line of `text` (no wrapping).
pub fn measure(text: &str, style: &TextStyle) -> f32 {
let l = layout(text, style, &ParagraphStyle::default());
l.lines.iter().map(|l| l.width).fold(0.0, f32::max)
}
#[cfg(test)]
mod tests {
use super::*;
fn st(size: f32) -> TextStyle {
TextStyle { size, ..Default::default() }
}
#[test]
fn kerning_and_ligatures_change_advances() {
let kern = measure("AVAVAV", &st(100.0));
let nokern = measure("AVAVAV", &TextStyle { kerning: false, ..st(100.0) });
assert!(kern < nokern - 5.0, "kerning tightens AV: {kern} vs {nokern}");
// tracking adds 1/1000 em per cluster
let tracked = measure("AVAVAV", &TextStyle { tracking: 100.0, ..st(100.0) });
assert!((tracked - kern - 6.0 * 10.0).abs() < 0.5, "{tracked} {kern}");
}
#[test]
fn point_text_alignment() {
let l = layout("Hello", &st(50.0), &ParagraphStyle { align: Align::Center, ..Default::default() });
let line = &l.lines[0];
assert!((line.x + line.width / 2.0).abs() < 0.01);
let r = layout("Hello", &st(50.0), &ParagraphStyle { align: Align::Right, ..Default::default() });
assert!((r.lines[0].x + r.lines[0].width).abs() < 0.01);
assert_eq!(l.glyphs.len(), 5);
assert!(l.bounds[1] < -30.0 && l.bounds[3] > 5.0, "{:?}", l.bounds);
}
#[test]
fn wraps_area_text_and_justifies() {
let p = ParagraphStyle { width: Some(300.0), align: Align::Justify, ..Default::default() };
let l = layout("the quick brown fox jumps over the lazy dog again and again", &st(40.0), &p);
assert!(l.lines.len() >= 3, "{}", l.lines.len());
for line in &l.lines[..l.lines.len() - 1] {
assert!((line.width - 300.0).abs() < 0.5, "justified to the box: {}", line.width);
}
// the lines tile the text
assert_eq!(l.lines[0].range.start, 0);
for w in l.lines.windows(2) {
assert_eq!(w[0].range.end, w[1].range.start);
assert!(w[1].baseline > w[0].baseline);
}
let left = layout("the quick brown fox jumps over the lazy dog", &st(40.0), &ParagraphStyle { width: Some(300.0), ..Default::default() });
assert!(left.lines.iter().all(|l| l.width <= 300.0 + 0.01));
}
#[test]
fn newlines_make_paragraphs_and_leading_adds() {
let l = layout("one\ntwo\n\nfour", &st(30.0), &ParagraphStyle::default());
assert_eq!(l.lines.len(), 4);
assert_eq!(l.lines[1].range, 4..7);
assert_eq!(l.lines[2].range, 8..8);
let gap = l.lines[1].baseline - l.lines[0].baseline;
let l2 = layout("one\ntwo", &st(30.0), &ParagraphStyle { leading: 10.0, ..Default::default() });
assert!((l2.lines[1].baseline - l2.lines[0].baseline - gap - 10.0).abs() < 1e-3);
}
#[test]
fn carets_and_hit_testing() {
let l = layout("abc\nde", &st(40.0), &ParagraphStyle::default());
let (x0, _, li0) = l.caret(0);
let (x1, _, _) = l.caret(1);
let (x3, _, _) = l.caret(3);
assert!(x0.abs() < 1e-3 && x1 > x0 && x3 > x1);
assert_eq!(li0, 0);
let (_, b4, li4) = l.caret(4);
assert_eq!(li4, 1);
assert!(b4 > 0.0);
assert_eq!(l.hit(x1 + 1.0, 0.0), 1);
assert_eq!(l.hit(1000.0, b4), 6);
let rects = l.selection_rects(1, 5);
assert_eq!(rects.len(), 2);
}
#[test]
fn bidi_reorders_rtl_runs() {
// Hebrew letters are laid out right-to-left: the first logical letter is rightmost.
let l = layout("ab \u{5d0}\u{5d1}\u{5d2} cd", &st(40.0), &ParagraphStyle::default());
let x_of = |byte: usize| l.glyphs.iter().find(|g| g.cluster == byte).map(|g| g.x).unwrap();
let alef = "ab ".len();
let gimel = alef + 4;
assert!(x_of(alef) > x_of(gimel), "alef right of gimel");
assert!(x_of(0) < x_of(gimel) && x_of(alef) < x_of("ab \u{5d0}\u{5d1}\u{5d2} ".len()));
}
#[test]
fn caps_and_small_caps() {
let s = st(50.0);
let up = measure("HELLO", &s);
let all = measure("hello", &TextStyle { caps: Caps::All, ..s.clone() });
assert!((up - all).abs() < 0.01);
let small = measure("hello", &TextStyle { caps: Caps::Small, ..s.clone() });
assert!(small < up * 0.9 && small > up * 0.6, "{small} {up}");
let l = layout("Hi", &TextStyle { baseline_shift: 10.0, underline: true, ..s }, &ParagraphStyle::default());
assert!((l.glyphs[0].y + 10.0).abs() < 1e-3);
assert_eq!(l.underlines.len(), 1);
}
#[test]
fn ligature_caret_interpolates() {
// Inter has an "fi"-like ligature only via calt in some versions; use "ffi" which most
// fonts ligate. Whatever shaping does, every char boundary has a caret.
let l = layout("office", &st(40.0), &ParagraphStyle::default());
let stops: Vec<usize> = l.lines[0].carets.iter().map(|c| c.0).collect();
assert_eq!(stops, vec![0, 1, 2, 3, 4, 5, 6]);
let xs: Vec<f32> = l.lines[0].carets.iter().map(|c| c.1).collect();
assert!(xs.windows(2).all(|w| w[1] >= w[0]), "{xs:?}");
}
#[test]
fn fallback_font_for_missing_glyphs() {
// Inter lacks Devanagari/Hebrew; Noto Serif lacks Hebrew too → glyphs still produced (notdef)
let l = layout("A\u{3b1}", &TextStyle { family: "Noto Serif".into(), ..st(40.0) }, &ParagraphStyle::default());
assert_eq!(l.glyphs.len(), 2);
assert!(l.glyphs.iter().all(|g| g.id != 0));
}
#[test]
fn empty_text_has_a_caret() {
let l = layout("", &st(40.0), &ParagraphStyle::default());
assert_eq!(l.lines.len(), 1);
assert_eq!(l.caret(0).0, 0.0);
assert!(l.bounds[3] > l.bounds[1]);
}
}