//! 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, /// Base direction: None = from the first strong character. pub rtl: Option, } fn hf(h: &mut impl Hasher, v: f32) { v.to_bits().hash(h); } impl Hash for TextStyle { fn hash(&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(&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, 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, /// 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, pub lines: Vec, /// 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 = 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, rtl: bool, glyphs: Vec, } 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 { 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, glyphs: Vec, 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 { 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 = 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| -> f32 { char_adv[r].iter().sum() }; let trailing_ws = |r: &Range| -> (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> = Vec::new(); match para.width { None => ranges.push(0..text.len()), Some(maxw) => { let mut start = 0usize; let mut last_ok: Option = 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 = HashMap::new(); for run in runs { let rtl = levels[run.start].is_rtl(); let mut idx: Vec = (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 = extents.keys().copied().collect(); cluster_starts.sort_unstable(); let char_bytes: Vec = 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 = 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)>> { static C: OnceLock)>>> = 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 { 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 = 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 = l.lines[0].carets.iter().map(|c| c.0).collect(); assert_eq!(stops, vec![0, 1, 2, 3, 4, 5, 6]); let xs: Vec = 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]); } }