Files
figcraft/crates/text/src/tests.rs
T
BFlatandClaude Opus 5.5 b7910c81f8 M4: text depth: inline IME composition, variable font axes, range-aware typography polish (#8)
- Inline IME composition: egui Preedit events become a composition held in the text edit
  session (`TextEdit.preedit`), shown inline at the caret with an underline and the IME cursor,
  reflowing the text; the document only gets the committed text (`text.edit.insert`). Composing
  over a selection replaces it; moving the caret or ending the edit drops an uncommitted
  composition. The IME candidate window follows the cursor inside the composition.
  New command `text.edit.preedit {text, cursor?}`; `text.edit.selection` reports it.
- Variable fonts: faces read their fvar axes (tag, name, range, default, hidden) and named
  instances. Named instances are offered as styles and resolve in `find`; a style name with no
  instance implies weight/italic/slant on the axes and optical size follows the font size.
  Per-run custom axis values (`TextStyle.variations`, serde default, omitted when empty) are applied
  in HarfRust shaping (ShaperInstance) and skrifa outlines/advances (cached per coordinates).
  PDF export outlines non-default instances instead of embedding the default one.
- Commands/props: `fontVariations` / `variationSettings` props (whole layer or start..end),
  `text.setVariations {variations, replace?}` (merges; edited range while editing),
  `text.fonts {family, style?, axes: true}`; `node.get` and copy/paste properties carry the axes.
  Picking a named style (Bold/Italic toggles, weight steps, fontStyle/fontWeight) drops custom
  wght/ital/slnt values and keeps the others.
- Type settings ▸ Variable tab for variable fonts: a slider (default tick, double-click to reset)
  and a field per visible axis, "Mixed" across differing styles, live drag as one undo step,
  "Reset to style". Long style/family names stay on one line with an ellipsis and a tooltip.
- Tests: variable font shaping/outlines/clamping/instances (skipped without a variable font),
  serde backward compatibility, preedit lifecycle and undo, variation commands/props/undo/junk;
  junk sweeps extended with variation and preedit params.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:07:41 +07:00

330 lines
12 KiB
Rust

use super::*;
use figcraft_doc::{TextAlignH, TextData, TextDecoration};
fn have_fonts() -> bool {
db().write().unwrap().default_face().is_some() || {
scan_system_fonts();
db().write().unwrap().default_face().is_some()
}
}
#[test]
fn lays_out_single_line() {
if !have_fonts() {
eprintln!("no fonts; skipping");
return;
}
let mut t = TextData::new("Hello");
t.style.font_size = 20.0;
let l = layout(&t, None, None);
assert_eq!(l.lines.len(), 1);
assert!(l.glyphs.len() >= 5);
assert!(l.width > 30.0 && l.width < 100.0, "{}", l.width);
assert!(l.height > 20.0 && l.height < 30.0, "{}", l.height);
// Caret moves right.
assert!(l.caret(3).0 > l.caret(1).0);
assert_eq!(l.hit(-5.0, 5.0), 0);
assert_eq!(l.hit(1000.0, 5.0), 5);
}
#[test]
fn newline_and_wrap() {
if !have_fonts() {
return;
}
let mut t = TextData::new("one two three four five six\nseven");
t.auto_resize = figcraft_doc::TextAutoResize::Height;
let l = layout(&t, Some(60.0), None);
assert!(l.lines.len() >= 3, "{}", l.lines.len());
assert!(l.lines.iter().all(|ln| ln.width <= 60.0 + 1e-6 || ln.glyph_end - ln.glyph_start <= 1));
// Auto width ignores the box.
t.auto_resize = figcraft_doc::TextAutoResize::WidthAndHeight;
assert_eq!(layout(&t, Some(60.0), None).lines.len(), 2);
}
#[test]
fn alignment_and_decoration() {
if !have_fonts() {
return;
}
let mut t = TextData::new("Hi");
t.auto_resize = figcraft_doc::TextAutoResize::None;
t.align_h = TextAlignH::Right;
t.style.decoration = TextDecoration::Underline;
let l = layout(&t, Some(200.0), None);
let last = l.glyphs.last().unwrap();
assert!((last.x + last.advance - 200.0).abs() < 0.5);
assert_eq!(l.decorations.len(), 1);
}
#[test]
fn empty_and_hostile_inputs() {
if !have_fonts() {
return;
}
let mut t = TextData::new("");
let l = layout(&t, None, None);
assert_eq!(l.lines.len(), 1);
assert!(l.height > 0.0);
t.characters = "\n\n".into();
assert_eq!(layout(&t, None, None).lines.len(), 3);
t.style.font_size = f64::NAN;
t.characters = "x".into();
let _ = layout(&t, Some(f64::NAN), Some(-1.0));
t.runs.push(figcraft_doc::StyleRun { start: 5, end: 2, style: Default::default() });
let _ = layout(&t, None, None);
let mut emoji = TextData::new("a😀b 漢字 ß");
emoji.style.case = figcraft_doc::TextCase::Upper;
let l = layout(&emoji, None, None);
assert!(!l.glyphs.is_empty());
}
#[test]
fn truncate() {
if !have_fonts() {
return;
}
let mut t = TextData::new("a very long line of text that will not fit at all");
t.auto_resize = figcraft_doc::TextAutoResize::Truncate;
let l = layout(&t, Some(80.0), Some(16.0));
assert!(l.truncated);
assert_eq!(l.lines.len(), 1);
}
#[test]
fn glyph_paths_have_area() {
if !have_fonts() {
return;
}
use figcraft_geom::Shape;
let t = TextData::new("O");
let l = layout(&t, None, None);
let p = l.to_path();
assert!(p.area().abs() > 1.0);
}
fn inter() -> Option<std::sync::Arc<Face>> {
if !have_fonts() {
return None;
}
db().write().unwrap().find("Inter", "Regular").filter(|f| f.family == "Inter")
}
#[test]
fn opentype_features_change_shaping() {
let Some(face) = inter() else {
eprintln!("Inter not embedded; skipping");
return;
};
let mut t = TextData::new("1111 0");
t.style.font_size = 20.0;
let base = layout(&t, None, None);
if face.has_feature(b"tnum") {
t.features.insert("tnum".into(), true);
let tab = layout(&t, None, None);
assert!((tab.width - base.width).abs() > 0.01, "tnum should change digit widths");
t.features.clear();
}
if face.has_feature(b"zero") {
t.features.insert("zero".into(), true);
let z = layout(&t, None, None);
let zero_gid = |l: &TextLayout| l.glyphs.iter().find(|g| g.char_start == 5).map(|g| g.gid);
assert_ne!(zero_gid(&z), zero_gid(&base), "zero should pick the slashed zero");
}
// Invalid tags are ignored, never a crash.
t.features.insert("bogus-tag".into(), true);
t.features.insert("".into(), false);
let _ = layout(&t, None, None);
}
#[test]
fn small_caps_native_or_synthesised() {
let Some(face) = inter() else { return };
let mut t = TextData::new("Ab");
t.style.font_size = 20.0;
let orig = layout(&t, None, None);
t.style.case = figcraft_doc::TextCase::SmallCaps;
let sc = layout(&t, None, None);
let b = |l: &TextLayout| l.glyphs.iter().find(|g| g.char_start == 1).cloned().unwrap();
let (ob, sb) = (b(&orig), b(&sc));
assert_ne!(ob.gid, sb.gid, "the lower-case b becomes a small capital");
if !face.has_feature(b"smcp") {
// Synthesised: a capital B drawn smaller than the A.
assert_eq!(sb.gid, face.glyph_for('B'));
assert!(sb.size < 20.0);
}
// Upper-case letters are untouched.
assert_eq!(sc.glyphs.iter().find(|g| g.char_start == 0).unwrap().gid, face.glyph_for('A'));
}
#[test]
fn title_case_across_runs() {
let Some(face) = inter() else { return };
let mut t = TextData::new("hello world");
t.style.case = figcraft_doc::TextCase::Title;
// "hel" bold, "lo world" regular: the word continues across the run boundary.
t.restyle(0, 3, |s| s.font_style = "Bold".into());
let l = layout(&t, None, None);
let gid_at = |i: usize| l.glyphs.iter().find(|g| g.char_start == i).map(|g| g.gid);
assert_eq!(gid_at(3), Some(face.glyph_for('l')), "mid-word char after a run boundary stays lower-case");
assert_eq!(gid_at(6), Some(face.glyph_for('W')));
}
#[test]
fn lists_hang_and_number() {
if !have_fonts() {
return;
}
let mut t = TextData::new("one two three four five six seven\nb");
t.auto_resize = figcraft_doc::TextAutoResize::Height;
t.list = figcraft_doc::ListStyle::Bulleted;
t.paragraph_indent = 30.0;
let l = layout(&t, Some(90.0), None);
assert!(l.lines.len() >= 3);
let first_text = |li: usize| {
let ln = &l.lines[li];
l.glyphs[ln.glyph_start..ln.glyph_end].iter().find(|g| g.char_end > g.char_start).map(|g| g.x).unwrap()
};
// Wrapped lines hang at the same x as the first line's text (paragraph indent is ignored in lists).
assert!((first_text(0) - first_text(1)).abs() < 0.01);
assert!(first_text(0) > 0.0);
// The bullet sits left of the text, and carets never land on it.
let ln = &l.lines[0];
let marker = l.glyphs[ln.glyph_start..ln.glyph_end].iter().find(|g| g.char_end == g.char_start).unwrap();
assert!(marker.x < first_text(0));
assert!(l.caret(0).0 >= first_text(0) - 0.01);
// Numbered: ten paragraphs, the text column clears "10.".
let mut n = TextData::new((1..=10).map(|i| format!("p{i}")).collect::<Vec<_>>().join("\n"));
n.list = figcraft_doc::ListStyle::Numbered;
let l = layout(&n, None, None);
assert_eq!(l.lines.len(), 10);
let last = &l.lines[9];
let gl = &l.glyphs[last.glyph_start..last.glyph_end];
let marker_end = gl.iter().filter(|g| g.char_end == g.char_start).map(|g| g.x + g.advance).fold(0.0, f64::max);
let text_x = gl.iter().find(|g| g.char_end > g.char_start).unwrap().x;
assert!(marker_end < text_x, "{marker_end} < {text_x}");
}
#[test]
fn paragraph_indent_first_line_only() {
if !have_fonts() {
return;
}
let mut t = TextData::new("aaa bbb ccc ddd eee fff ggg");
t.auto_resize = figcraft_doc::TextAutoResize::Height;
t.paragraph_indent = 20.0;
let l = layout(&t, Some(70.0), None);
assert!(l.lines.len() >= 2);
let x0 = |li: usize| l.glyphs[l.lines[li].glyph_start].x;
assert!((x0(0) - 20.0).abs() < 0.5, "{}", x0(0));
assert!(x0(1) < 1.0);
}
#[test]
fn truncate_max_lines_with_ellipsis() {
let Some(face) = inter() else { return };
let mut t = TextData::new("first line\nsecond line\nthird line");
t.auto_resize = figcraft_doc::TextAutoResize::Truncate;
t.max_lines = 2;
let l = layout(&t, Some(300.0), Some(1000.0));
assert!(l.truncated);
assert_eq!(l.lines.len(), 2);
let last = l.glyphs.last().unwrap();
assert_eq!(last.gid, face.glyph_for('…'));
// max_lines is ignored unless truncating.
t.auto_resize = figcraft_doc::TextAutoResize::None;
assert_eq!(layout(&t, Some(300.0), Some(1000.0)).lines.len(), 3);
}
#[test]
fn vertical_alignment_in_fixed_box() {
if !have_fonts() {
return;
}
let mut t = TextData::new("Hi");
t.auto_resize = figcraft_doc::TextAutoResize::None;
t.align_v = figcraft_doc::TextAlignV::Bottom;
let mut n = figcraft_doc::Node::new(figcraft_doc::NodeId(1), figcraft_doc::NodeKind::Text(t.clone()), "t", 100.0, 100.0);
let (l, dy) = layout_node(&n).unwrap();
assert!((dy + l.height - 100.0).abs() < 1e-6);
t.align_v = figcraft_doc::TextAlignV::Middle;
n.kind = figcraft_doc::NodeKind::Text(t.clone());
let (l, dy) = layout_node(&n).unwrap();
assert!((dy - (100.0 - l.height) / 2.0).abs() < 1e-6);
// Auto height ignores vertical alignment.
t.auto_resize = figcraft_doc::TextAutoResize::Height;
n.kind = figcraft_doc::NodeKind::Text(t);
assert_eq!(layout_node(&n).unwrap().1, 0.0);
}
/// A variable font with a usable weight axis, if one is installed (craft-fonts or the system).
fn variable_face() -> Option<std::sync::Arc<Face>> {
if !have_fonts() {
return None;
}
scan_system_fonts();
["Inter", "Bahnschrift", "Segoe UI Variable", "Roboto Flex", "Noto Sans", "Cantarell", "Source Sans 3", "Open Sans", "Roboto"]
.into_iter()
.filter_map(|fam| find_face(fam, "Regular").filter(|f| f.family.eq_ignore_ascii_case(fam)))
.find(|f| f.axis("wght").is_some_and(|a| a.max - a.min >= 200.0))
}
#[test]
fn variable_font_axes_change_shaping_and_outlines() {
let Some(face) = variable_face() else {
eprintln!("no variable font installed; skipping");
return;
};
let wght = face.axis("wght").cloned().unwrap();
let wi = face.axes.iter().position(|a| a.tag == "wght").unwrap();
let mut t = TextData::new("Hamburgefonts");
t.style.font_family = face.family.clone();
t.style.font_style = face.style.clone();
t.style.font_size = 40.0;
t.style.variations.insert("wght".into(), wght.min);
let light = layout(&t, None, None);
t.style.variations.insert("wght".into(), wght.max);
let heavy = layout(&t, None, None);
assert!(heavy.glyphs.iter().filter(|g| g.face.id == face.id).all(|g| !g.coords.is_empty()), "glyphs carry the instance");
let first = |l: &TextLayout| l.glyphs.first().map(|g| g.path().elements().to_vec());
assert_ne!(first(&light), first(&heavy), "the weight axis changes the outlines");
assert!(heavy.width >= light.width, "heavier is not narrower: {} < {}", heavy.width, light.width);
// Values clamp to the axis; unknown axes and junk are ignored.
t.style.variations.insert("wght".into(), 1e9);
t.style.variations.insert("ZZZZ".into(), 5.0);
t.style.variations.insert("wdth".into(), f64::NAN);
let v = face.axis_values(&t.style);
assert_eq!(v.len(), face.axes.len());
assert_eq!(v.get(wi).copied(), Some(wght.max));
assert!(v.iter().all(|x| x.is_finite()));
let _ = layout(&t, None, None);
// Without custom values, the style name implies the weight (or picks the named instance).
t.style.variations.clear();
t.style.font_style = "Bold".into();
let v = face.axis_values(&t.style);
let want = face.instance("Bold").and_then(|i| i.coords.get(wi).copied()).unwrap_or_else(|| wght.clamp(700.0));
assert_eq!(v.get(wi).copied(), Some(want));
// Named instances are offered as styles.
if face.instances.len() > 1 {
assert!(family_styles(&face.family).len() >= 2);
}
// The default instance needs no coordinates.
assert!(face.coords(&face.axes.iter().map(|a| a.default).collect::<Vec<_>>()).is_empty());
}
#[test]
fn static_fonts_have_no_axes() {
if !have_fonts() {
return;
}
let Some(face) = db().write().unwrap().default_face() else { return };
if face.is_variable() {
return;
}
let mut st = figcraft_doc::TextStyle::default();
st.variations.insert("wght".into(), 900.0);
assert!(face.axis_values(&st).is_empty());
assert!(face.coords(&[700.0]).is_empty());
}