refactor(text): move src/layout.rs tests to src/layout/tests.rs

This commit is contained in:
aiongg
2026-10-26 21:49:11 +00:00
parent 2e76b5f8b2
commit 7204aeaa69
2 changed files with 368 additions and 369 deletions
+1 -369
View File
@@ -1239,372 +1239,4 @@ pub fn measure(text: &str, style: &TextStyle) -> f32 {
}
#[cfg(test)]
mod tests {
use super::*;
fn st(size: f32) -> TextStyle {
TextStyle { size, ..Default::default() }
}
fn ids(text: &str, s: &TextStyle) -> Vec<u32> {
layout_uncached(text, s, &ParagraphStyle::default()).glyphs.iter().map(|g| g.id).collect()
}
fn ot(f: impl FnOnce(&mut OpenType)) -> TextStyle {
let mut s = st(100.0);
f(&mut s.opentype);
s
}
fn serif() -> TextStyle {
TextStyle { family: "Noto Serif".into(), ..st(100.0) }
}
#[test]
fn bundled_faces_list_their_features() {
let inter = fonts::face(fonts::resolve("Inter", "Regular").face).features();
for t in ["ss01", "dlig", "frac", "tnum", "sups", "ordn"] {
assert!(inter.iter().any(|f| f == t), "{t} in {inter:?}");
}
let serif = fonts::face(fonts::resolve("Noto Serif", "Regular").face).features();
for t in ["smcp", "c2sc", "onum", "lnum"] {
assert!(serif.iter().any(|f| f == t), "{t} in {serif:?}");
}
}
#[test]
fn opentype_features_substitute_glyphs() {
let plain = st(100.0);
// Stylistic sets, fractions, ordinals, figure widths and discretionary ligatures.
assert_ne!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(1, true))), "ss01 alternate digits");
assert_eq!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(20, true))), "Inter has no ss20");
assert_ne!(ids("1/2", &plain), ids("1/2", &ot(|o| o.fractions = true)), "frac");
assert_ne!(ids("1a 2o No", &plain), ids("1a 2o No", &ot(|o| o.ordinals = true)), "ordn");
let tab = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Tabular));
let prop = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Proportional));
assert!((tab - prop).abs() > 1.0, "tabular {tab} vs proportional {prop}");
// Old-style figures (Noto Serif).
let old = TextStyle { opentype: OpenType { figure_style: effectcraft_keyframe::FigureStyle::OldStyle, ..Default::default() }, ..serif() };
assert_ne!(ids("123", &serif()), ids("123", &old), "onum");
// Contextual alternates are on by default; turning them off can only keep or change glyphs.
let no_calt = ot(|o| o.contextual_alternates = false);
assert_eq!(ids("Hello", &plain).len(), ids("Hello", &no_calt).len());
}
#[test]
fn true_small_caps_and_superscripts_with_faux_fallback() {
// Noto Serif has smcp: lowercase letters take their own small-capital glyphs at full size.
let sc = TextStyle { caps: Caps::Small, ..serif() };
let l = layout_uncached("Ab", &sc, &ParagraphStyle::default());
let upper_b = ids("B", &serif())[0];
assert_ne!(l.glyphs[1].id, upper_b, "true small cap glyph, not a scaled capital");
assert_eq!(l.glyphs[1].size, 100.0);
// Inter has no smcp: a capital scaled down.
let faux = layout_uncached("Ab", &TextStyle { caps: Caps::Small, ..st(100.0) }, &ParagraphStyle::default());
assert_eq!(faux.glyphs[1].id, ids("B", &st(100.0))[0]);
assert!((faux.glyphs[1].size - 78.0).abs() < 0.01);
// All Small Caps: capitals become small capitals too (c2sc).
let all = TextStyle { opentype: OpenType { all_small_caps: true, ..Default::default() }, ..serif() };
let a = layout_uncached("AB", &all, &ParagraphStyle::default());
assert_ne!(a.glyphs[0].id, ids("A", &serif())[0]);
// Superscript: Inter's sups figures at full size on the baseline; letters it lacks are
// synthesized (smaller and raised).
let sup = TextStyle { script: Script::Super, ..st(100.0) };
let s2 = layout_uncached("2", &sup, &ParagraphStyle::default());
assert_ne!(s2.glyphs[0].id, ids("2", &st(100.0))[0]);
assert_eq!(s2.glyphs[0].size, 100.0);
assert_eq!(s2.glyphs[0].y, 0.0);
let sq = layout_uncached("q", &sup, &ParagraphStyle::default());
if !feature_substitutes(sq.glyphs[0].face, b"sups", 'q') {
assert!(sq.glyphs[0].size < 60.0 && sq.glyphs[0].y < -20.0, "{:?}", sq.glyphs[0]);
}
let sub = layout_uncached("2", &TextStyle { script: Script::Sub, ..st(100.0) }, &ParagraphStyle::default());
assert_ne!(sub.glyphs[0].id, s2.glyphs[0].id);
}
#[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]);
}
fn rich(text: &str, runs: &[(Range<usize>, TextStyle)], para: ParagraphStyle) -> Layout {
layout_rich_uncached(text, runs, &[para])
}
#[test]
fn mixed_sizes_share_a_baseline_and_take_the_largest_leading() {
let text = "small BIG\nnext";
let runs = [(0..6, st(20.0)), (6..9, st(80.0)), (9..text.len(), st(20.0))];
let l = rich(text, &runs, ParagraphStyle::default());
assert_eq!(l.lines.len(), 2);
let y0 = l.glyphs[0].y;
assert!(l.glyphs[..9].iter().all(|g| (g.y - y0).abs() < 1e-3), "one baseline per line");
assert!(l.glyphs[7].size > l.glyphs[0].size * 3.0);
// Line 2's advance is its own leading (20 px text: 24 px); line 1 is as tall as the big run.
assert!((l.lines[1].baseline - l.lines[0].baseline - 24.0).abs() < 1e-3, "{}", l.lines[1].baseline);
assert!(l.lines[0].ascent > 60.0);
// A big run on the second line pushes it down by its 120% leading.
let text2 = "aa\nBB";
let l2 = rich(text2, &[(0..3, st(20.0)), (3..5, st(80.0))], ParagraphStyle::default());
assert!((l2.lines[1].baseline - 96.0).abs() < 1e-3);
// Explicit leading wins over auto.
let mut fixed = st(20.0);
fixed.leading = Some(50.0);
let l3 = rich("a\nb", &[(0..3, fixed)], ParagraphStyle::default());
assert!((l3.lines[1].baseline - 50.0).abs() < 1e-3);
}
#[test]
fn super_subscript_tsume_and_kerning_metrics() {
let base = st(100.0);
let sup = TextStyle { script: Script::Super, ..base.clone() };
let sub = TextStyle { script: Script::Sub, ..base.clone() };
// A letter the font has no superior / inferior glyph for: synthesized.
let inter = fonts::resolve("Inter", "Regular").face;
let c = "qwkzvjxyQWK".chars().find(|&c| !feature_substitutes(inter, b"sups", c) && !feature_substitutes(inter, b"subs", c)).unwrap();
let text: String = ['x', c, 'y', c].iter().collect();
let l = rich(&text, &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default());
let g = &l.glyphs;
assert!((g[1].size - 58.3).abs() < 0.1);
assert!((g[1].y - (g[0].y - 33.3)).abs() < 0.1, "superscript raised by a third of the size");
assert!((g[3].y - (g[2].y + 33.3)).abs() < 0.1, "subscript lowered");
// Tsume removes side bearings; full tsume is narrower than none.
let w0 = measure("onion", &base);
let w1 = measure("onion", &TextStyle { tsume: 1.0, ..base.clone() });
assert!(w1 < w0 - 5.0, "{w1} vs {w0}");
// Manual kerning: +100/1000 em before each of the 5 later characters of AVAVAV, metrics off.
let nokern = measure("AVAVAV", &TextStyle { kerning: false, ..base.clone() });
let manual = measure("AVAVAV", &TextStyle { manual_kern: Some(100.0), ..base.clone() });
assert!((manual - nokern - 50.0).abs() < 0.5, "{manual} {nokern}");
let optical = measure("AVAVAV", &TextStyle { optical: true, ..base.clone() });
assert!(optical > 0.0 && (optical - nokern).abs() < 100.0);
// Horizontal scale stretches advances.
let wide = measure("onion", &TextStyle { h_scale: 2.0, ..base.clone() });
assert!((wide - 2.0 * w0).abs() < 0.5);
let l = layout("o", &TextStyle { h_scale: 2.0, v_scale: 0.5, ..base }, &ParagraphStyle::default());
assert_eq!((l.glyphs[0].h_scale, l.glyphs[0].v_scale), (2.0, 0.5));
}
#[test]
fn indents_and_paragraph_spacing() {
let s = st(20.0);
let text = "first paragraph here\nsecond one";
let p0 = ParagraphStyle { width: Some(400.0), indent_start: 30.0, indent_first: 15.0, space_after: 10.0, ..Default::default() };
let p1 = ParagraphStyle { width: Some(400.0), indent_end: 50.0, align: Align::Right, space_before: 7.0, ..Default::default() };
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p0.clone(), p1]);
assert!((l.lines[0].x - 45.0).abs() < 1e-3, "left + first-line indent: {}", l.lines[0].x);
assert!((l.lines[1].x + l.lines[1].width - 350.0).abs() < 0.5, "right indent: {}", l.lines[1].x + l.lines[1].width);
assert!((l.lines[1].baseline - l.lines[0].baseline - (24.0 + 10.0 + 7.0)).abs() < 1e-3);
assert_eq!((l.lines[0].para, l.lines[1].para), (0, 1));
assert!(l.lines[0].para_end && l.lines[1].para_end);
// Wrapped lines after the first don't get the first-line indent.
let long = "word ".repeat(30);
let l = layout_rich_uncached(&long, &[(0..long.len(), s.clone())], &[p0]);
assert!(l.lines.len() > 2);
assert!((l.lines[1].x - 30.0).abs() < 1e-3);
assert!(l.lines.iter().all(|ln| ln.x + ln.width <= 400.0 + 0.5));
assert!(!l.lines[0].para_end);
// Point text: a right-aligned paragraph's end indent moves it left of the origin.
let l = layout_rich_uncached("abc", &[(0..3, s)], &[ParagraphStyle { align: Align::Right, indent_end: 12.0, ..Default::default() }]);
assert!((l.lines[0].x + l.lines[0].width + 12.0).abs() < 1e-3);
}
#[test]
fn justify_last_line_modes_and_every_line_composer() {
let text = "the quick brown fox jumps over the lazy dog and keeps running far";
let s = st(30.0);
let p = |last: Align| ParagraphStyle { width: Some(320.0), align: Align::Justify, justify_last: last, ..Default::default() };
let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]);
let n = left.lines.len();
assert!(n >= 3);
assert!((left.lines[0].width - 320.0).abs() < 0.5);
assert!(left.lines[n - 1].width < 320.0 && left.lines[n - 1].x.abs() < 1e-3);
let center = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Center)]);
let lc = &center.lines[n - 1];
assert!((lc.x - (320.0 - lc.width) / 2.0).abs() < 0.5);
let right = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Right)]);
let lr = &right.lines[n - 1];
assert!((lr.x + lr.width - 320.0).abs() < 0.5);
let all = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Justify)]);
assert!((all.lines[n - 1].width - 320.0).abs() < 0.5, "justify all stretches the last line");
// Every-line composer: never worse (summed squared slack) than greedy breaking.
let slack = |l: &Layout| -> f32 { l.lines[..l.lines.len() - 1].iter().map(|ln| (320.0 - ln.width).powi(2)).sum() };
let greedy = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[ParagraphStyle { width: Some(320.0), ..Default::default() }]);
let every = layout_rich_uncached(text, &[(0..text.len(), s)], &[ParagraphStyle { width: Some(320.0), every_line: true, ..Default::default() }]);
assert!(slack(&every) <= slack(&greedy) + 1e-3, "{} vs {}", slack(&every), slack(&greedy));
assert!(every.lines.iter().all(|ln| ln.width <= 320.0 + 0.5));
}
#[test]
fn hanging_punctuation_overhangs_the_box() {
let s = st(40.0);
let text = "\"Hi.\"";
let p = ParagraphStyle { width: Some(400.0), hanging: true, ..Default::default() };
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]);
assert!(l.lines[0].x < -1.0, "opening quote hangs left: {}", l.lines[0].x);
let r = layout_rich_uncached(
text,
&[(0..text.len(), s)],
&[ParagraphStyle { width: Some(400.0), hanging: true, align: Align::Right, ..Default::default() }],
);
assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right");
}
#[test]
fn oversized_layout_requests_are_bounded_without_caching() {
let text = "a".repeat(MAX_LAYOUT_BYTES + 1);
let layout = layout(&text, &TextStyle::default(), &ParagraphStyle::default());
assert_eq!(layout.text_len, text.len());
assert!(layout.lines.is_empty() && layout.glyphs.is_empty());
assert!(layout_rich_uncached("ok", &[], &[]).lines.len() == 1);
}
#[test]
fn rtl_paragraph_carets_run_right_to_left() {
let text = "\u{5d0}\u{5d1}\u{5d2}";
let l = layout_rich_uncached(text, &[(0..text.len(), st(40.0))], &[ParagraphStyle { rtl: Some(true), ..Default::default() }]);
let xs: Vec<f32> = l.lines[0].carets.iter().map(|c| c.1).collect();
assert_eq!(xs.len(), 4);
assert!(xs.windows(2).all(|w| w[1] < w[0]), "{xs:?}");
// Hit testing returns the logical position nearest the point.
assert_eq!(l.hit(xs[1] + 0.5, 0.0), 2);
assert_eq!(l.hit(xs[0] + 50.0, 0.0), 0);
}
/// #1563: text set in a named instance (`Black`) is laid out at the instance's axis values,
/// as if they had been set as variations; axes set on the text itself keep their values.
#[test]
fn named_instance_sets_the_base_variations() {
const FAMILY: &str = "EffectCraft Layout Instance Test";
fonts::add_font_data(crate::sfnt::test_font::variable(FAMILY, &[("Light", 300.0, None), ("Black", 900.0, None)], true));
let st = |style: &str, variations: Vec<(String, f32)>| TextStyle {
family: FAMILY.into(),
style: style.into(),
size: 50.0,
variations,
..Default::default()
};
let coords = |l: &Layout| l.glyphs.iter().map(|g| crate::variable::coords(g.variations).to_vec()).collect::<Vec<_>>();
let black = layout_uncached("Hi", &st("Black", vec![]), &ParagraphStyle::default());
assert!(!black.glyphs.is_empty());
assert!(coords(&black).iter().all(|c| c == &[("wght".to_string(), 900.0)]), "{:?}", coords(&black));
let set = layout_uncached("Hi", &st("Black", vec![("wght".into(), 500.0)]), &ParagraphStyle::default());
assert!(coords(&set).iter().all(|c| c == &[("wght".to_string(), 500.0)]), "{:?}", coords(&set));
// A static family keeps no variations.
let inter = layout_uncached("Hi", &TextStyle::default(), &ParagraphStyle::default());
assert!(inter.glyphs.iter().all(|g| g.variations == 0));
}
}
mod tests;
+367
View File
@@ -0,0 +1,367 @@
use super::*;
fn st(size: f32) -> TextStyle {
TextStyle { size, ..Default::default() }
}
fn ids(text: &str, s: &TextStyle) -> Vec<u32> {
layout_uncached(text, s, &ParagraphStyle::default()).glyphs.iter().map(|g| g.id).collect()
}
fn ot(f: impl FnOnce(&mut OpenType)) -> TextStyle {
let mut s = st(100.0);
f(&mut s.opentype);
s
}
fn serif() -> TextStyle {
TextStyle { family: "Noto Serif".into(), ..st(100.0) }
}
#[test]
fn bundled_faces_list_their_features() {
let inter = fonts::face(fonts::resolve("Inter", "Regular").face).features();
for t in ["ss01", "dlig", "frac", "tnum", "sups", "ordn"] {
assert!(inter.iter().any(|f| f == t), "{t} in {inter:?}");
}
let serif = fonts::face(fonts::resolve("Noto Serif", "Regular").face).features();
for t in ["smcp", "c2sc", "onum", "lnum"] {
assert!(serif.iter().any(|f| f == t), "{t} in {serif:?}");
}
}
#[test]
fn opentype_features_substitute_glyphs() {
let plain = st(100.0);
// Stylistic sets, fractions, ordinals, figure widths and discretionary ligatures.
assert_ne!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(1, true))), "ss01 alternate digits");
assert_eq!(ids("1234567890", &plain), ids("1234567890", &ot(|o| o.set_stylistic_set(20, true))), "Inter has no ss20");
assert_ne!(ids("1/2", &plain), ids("1/2", &ot(|o| o.fractions = true)), "frac");
assert_ne!(ids("1a 2o No", &plain), ids("1a 2o No", &ot(|o| o.ordinals = true)), "ordn");
let tab = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Tabular));
let prop = measure("1111", &ot(|o| o.figure_width = effectcraft_keyframe::FigureWidth::Proportional));
assert!((tab - prop).abs() > 1.0, "tabular {tab} vs proportional {prop}");
// Old-style figures (Noto Serif).
let old = TextStyle { opentype: OpenType { figure_style: effectcraft_keyframe::FigureStyle::OldStyle, ..Default::default() }, ..serif() };
assert_ne!(ids("123", &serif()), ids("123", &old), "onum");
// Contextual alternates are on by default; turning them off can only keep or change glyphs.
let no_calt = ot(|o| o.contextual_alternates = false);
assert_eq!(ids("Hello", &plain).len(), ids("Hello", &no_calt).len());
}
#[test]
fn true_small_caps_and_superscripts_with_faux_fallback() {
// Noto Serif has smcp: lowercase letters take their own small-capital glyphs at full size.
let sc = TextStyle { caps: Caps::Small, ..serif() };
let l = layout_uncached("Ab", &sc, &ParagraphStyle::default());
let upper_b = ids("B", &serif())[0];
assert_ne!(l.glyphs[1].id, upper_b, "true small cap glyph, not a scaled capital");
assert_eq!(l.glyphs[1].size, 100.0);
// Inter has no smcp: a capital scaled down.
let faux = layout_uncached("Ab", &TextStyle { caps: Caps::Small, ..st(100.0) }, &ParagraphStyle::default());
assert_eq!(faux.glyphs[1].id, ids("B", &st(100.0))[0]);
assert!((faux.glyphs[1].size - 78.0).abs() < 0.01);
// All Small Caps: capitals become small capitals too (c2sc).
let all = TextStyle { opentype: OpenType { all_small_caps: true, ..Default::default() }, ..serif() };
let a = layout_uncached("AB", &all, &ParagraphStyle::default());
assert_ne!(a.glyphs[0].id, ids("A", &serif())[0]);
// Superscript: Inter's sups figures at full size on the baseline; letters it lacks are
// synthesized (smaller and raised).
let sup = TextStyle { script: Script::Super, ..st(100.0) };
let s2 = layout_uncached("2", &sup, &ParagraphStyle::default());
assert_ne!(s2.glyphs[0].id, ids("2", &st(100.0))[0]);
assert_eq!(s2.glyphs[0].size, 100.0);
assert_eq!(s2.glyphs[0].y, 0.0);
let sq = layout_uncached("q", &sup, &ParagraphStyle::default());
if !feature_substitutes(sq.glyphs[0].face, b"sups", 'q') {
assert!(sq.glyphs[0].size < 60.0 && sq.glyphs[0].y < -20.0, "{:?}", sq.glyphs[0]);
}
let sub = layout_uncached("2", &TextStyle { script: Script::Sub, ..st(100.0) }, &ParagraphStyle::default());
assert_ne!(sub.glyphs[0].id, s2.glyphs[0].id);
}
#[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]);
}
fn rich(text: &str, runs: &[(Range<usize>, TextStyle)], para: ParagraphStyle) -> Layout {
layout_rich_uncached(text, runs, &[para])
}
#[test]
fn mixed_sizes_share_a_baseline_and_take_the_largest_leading() {
let text = "small BIG\nnext";
let runs = [(0..6, st(20.0)), (6..9, st(80.0)), (9..text.len(), st(20.0))];
let l = rich(text, &runs, ParagraphStyle::default());
assert_eq!(l.lines.len(), 2);
let y0 = l.glyphs[0].y;
assert!(l.glyphs[..9].iter().all(|g| (g.y - y0).abs() < 1e-3), "one baseline per line");
assert!(l.glyphs[7].size > l.glyphs[0].size * 3.0);
// Line 2's advance is its own leading (20 px text: 24 px); line 1 is as tall as the big run.
assert!((l.lines[1].baseline - l.lines[0].baseline - 24.0).abs() < 1e-3, "{}", l.lines[1].baseline);
assert!(l.lines[0].ascent > 60.0);
// A big run on the second line pushes it down by its 120% leading.
let text2 = "aa\nBB";
let l2 = rich(text2, &[(0..3, st(20.0)), (3..5, st(80.0))], ParagraphStyle::default());
assert!((l2.lines[1].baseline - 96.0).abs() < 1e-3);
// Explicit leading wins over auto.
let mut fixed = st(20.0);
fixed.leading = Some(50.0);
let l3 = rich("a\nb", &[(0..3, fixed)], ParagraphStyle::default());
assert!((l3.lines[1].baseline - 50.0).abs() < 1e-3);
}
#[test]
fn super_subscript_tsume_and_kerning_metrics() {
let base = st(100.0);
let sup = TextStyle { script: Script::Super, ..base.clone() };
let sub = TextStyle { script: Script::Sub, ..base.clone() };
// A letter the font has no superior / inferior glyph for: synthesized.
let inter = fonts::resolve("Inter", "Regular").face;
let c = "qwkzvjxyQWK".chars().find(|&c| !feature_substitutes(inter, b"sups", c) && !feature_substitutes(inter, b"subs", c)).unwrap();
let text: String = ['x', c, 'y', c].iter().collect();
let l = rich(&text, &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default());
let g = &l.glyphs;
assert!((g[1].size - 58.3).abs() < 0.1);
assert!((g[1].y - (g[0].y - 33.3)).abs() < 0.1, "superscript raised by a third of the size");
assert!((g[3].y - (g[2].y + 33.3)).abs() < 0.1, "subscript lowered");
// Tsume removes side bearings; full tsume is narrower than none.
let w0 = measure("onion", &base);
let w1 = measure("onion", &TextStyle { tsume: 1.0, ..base.clone() });
assert!(w1 < w0 - 5.0, "{w1} vs {w0}");
// Manual kerning: +100/1000 em before each of the 5 later characters of AVAVAV, metrics off.
let nokern = measure("AVAVAV", &TextStyle { kerning: false, ..base.clone() });
let manual = measure("AVAVAV", &TextStyle { manual_kern: Some(100.0), ..base.clone() });
assert!((manual - nokern - 50.0).abs() < 0.5, "{manual} {nokern}");
let optical = measure("AVAVAV", &TextStyle { optical: true, ..base.clone() });
assert!(optical > 0.0 && (optical - nokern).abs() < 100.0);
// Horizontal scale stretches advances.
let wide = measure("onion", &TextStyle { h_scale: 2.0, ..base.clone() });
assert!((wide - 2.0 * w0).abs() < 0.5);
let l = layout("o", &TextStyle { h_scale: 2.0, v_scale: 0.5, ..base }, &ParagraphStyle::default());
assert_eq!((l.glyphs[0].h_scale, l.glyphs[0].v_scale), (2.0, 0.5));
}
#[test]
fn indents_and_paragraph_spacing() {
let s = st(20.0);
let text = "first paragraph here\nsecond one";
let p0 = ParagraphStyle { width: Some(400.0), indent_start: 30.0, indent_first: 15.0, space_after: 10.0, ..Default::default() };
let p1 = ParagraphStyle { width: Some(400.0), indent_end: 50.0, align: Align::Right, space_before: 7.0, ..Default::default() };
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p0.clone(), p1]);
assert!((l.lines[0].x - 45.0).abs() < 1e-3, "left + first-line indent: {}", l.lines[0].x);
assert!((l.lines[1].x + l.lines[1].width - 350.0).abs() < 0.5, "right indent: {}", l.lines[1].x + l.lines[1].width);
assert!((l.lines[1].baseline - l.lines[0].baseline - (24.0 + 10.0 + 7.0)).abs() < 1e-3);
assert_eq!((l.lines[0].para, l.lines[1].para), (0, 1));
assert!(l.lines[0].para_end && l.lines[1].para_end);
// Wrapped lines after the first don't get the first-line indent.
let long = "word ".repeat(30);
let l = layout_rich_uncached(&long, &[(0..long.len(), s.clone())], &[p0]);
assert!(l.lines.len() > 2);
assert!((l.lines[1].x - 30.0).abs() < 1e-3);
assert!(l.lines.iter().all(|ln| ln.x + ln.width <= 400.0 + 0.5));
assert!(!l.lines[0].para_end);
// Point text: a right-aligned paragraph's end indent moves it left of the origin.
let l = layout_rich_uncached("abc", &[(0..3, s)], &[ParagraphStyle { align: Align::Right, indent_end: 12.0, ..Default::default() }]);
assert!((l.lines[0].x + l.lines[0].width + 12.0).abs() < 1e-3);
}
#[test]
fn justify_last_line_modes_and_every_line_composer() {
let text = "the quick brown fox jumps over the lazy dog and keeps running far";
let s = st(30.0);
let p = |last: Align| ParagraphStyle { width: Some(320.0), align: Align::Justify, justify_last: last, ..Default::default() };
let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]);
let n = left.lines.len();
assert!(n >= 3);
assert!((left.lines[0].width - 320.0).abs() < 0.5);
assert!(left.lines[n - 1].width < 320.0 && left.lines[n - 1].x.abs() < 1e-3);
let center = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Center)]);
let lc = &center.lines[n - 1];
assert!((lc.x - (320.0 - lc.width) / 2.0).abs() < 0.5);
let right = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Right)]);
let lr = &right.lines[n - 1];
assert!((lr.x + lr.width - 320.0).abs() < 0.5);
let all = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Justify)]);
assert!((all.lines[n - 1].width - 320.0).abs() < 0.5, "justify all stretches the last line");
// Every-line composer: never worse (summed squared slack) than greedy breaking.
let slack = |l: &Layout| -> f32 { l.lines[..l.lines.len() - 1].iter().map(|ln| (320.0 - ln.width).powi(2)).sum() };
let greedy = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[ParagraphStyle { width: Some(320.0), ..Default::default() }]);
let every = layout_rich_uncached(text, &[(0..text.len(), s)], &[ParagraphStyle { width: Some(320.0), every_line: true, ..Default::default() }]);
assert!(slack(&every) <= slack(&greedy) + 1e-3, "{} vs {}", slack(&every), slack(&greedy));
assert!(every.lines.iter().all(|ln| ln.width <= 320.0 + 0.5));
}
#[test]
fn hanging_punctuation_overhangs_the_box() {
let s = st(40.0);
let text = "\"Hi.\"";
let p = ParagraphStyle { width: Some(400.0), hanging: true, ..Default::default() };
let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]);
assert!(l.lines[0].x < -1.0, "opening quote hangs left: {}", l.lines[0].x);
let r = layout_rich_uncached(
text,
&[(0..text.len(), s)],
&[ParagraphStyle { width: Some(400.0), hanging: true, align: Align::Right, ..Default::default() }],
);
assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right");
}
#[test]
fn oversized_layout_requests_are_bounded_without_caching() {
let text = "a".repeat(MAX_LAYOUT_BYTES + 1);
let layout = layout(&text, &TextStyle::default(), &ParagraphStyle::default());
assert_eq!(layout.text_len, text.len());
assert!(layout.lines.is_empty() && layout.glyphs.is_empty());
assert!(layout_rich_uncached("ok", &[], &[]).lines.len() == 1);
}
#[test]
fn rtl_paragraph_carets_run_right_to_left() {
let text = "\u{5d0}\u{5d1}\u{5d2}";
let l = layout_rich_uncached(text, &[(0..text.len(), st(40.0))], &[ParagraphStyle { rtl: Some(true), ..Default::default() }]);
let xs: Vec<f32> = l.lines[0].carets.iter().map(|c| c.1).collect();
assert_eq!(xs.len(), 4);
assert!(xs.windows(2).all(|w| w[1] < w[0]), "{xs:?}");
// Hit testing returns the logical position nearest the point.
assert_eq!(l.hit(xs[1] + 0.5, 0.0), 2);
assert_eq!(l.hit(xs[0] + 50.0, 0.0), 0);
}
/// #1563: text set in a named instance (`Black`) is laid out at the instance's axis values,
/// as if they had been set as variations; axes set on the text itself keep their values.
#[test]
fn named_instance_sets_the_base_variations() {
const FAMILY: &str = "EffectCraft Layout Instance Test";
fonts::add_font_data(crate::sfnt::test_font::variable(FAMILY, &[("Light", 300.0, None), ("Black", 900.0, None)], true));
let st = |style: &str, variations: Vec<(String, f32)>| TextStyle {
family: FAMILY.into(),
style: style.into(),
size: 50.0,
variations,
..Default::default()
};
let coords = |l: &Layout| l.glyphs.iter().map(|g| crate::variable::coords(g.variations).to_vec()).collect::<Vec<_>>();
let black = layout_uncached("Hi", &st("Black", vec![]), &ParagraphStyle::default());
assert!(!black.glyphs.is_empty());
assert!(coords(&black).iter().all(|c| c == &[("wght".to_string(), 900.0)]), "{:?}", coords(&black));
let set = layout_uncached("Hi", &st("Black", vec![("wght".into(), 500.0)]), &ParagraphStyle::default());
assert!(coords(&set).iter().all(|c| c == &[("wght".to_string(), 500.0)]), "{:?}", coords(&set));
// A static family keeps no variations.
let inter = layout_uncached("Hi", &TextStyle::default(), &ParagraphStyle::default());
assert!(inter.glyphs.iter().all(|g| g.variations == 0));
}