Merge remote-tracking branch 'origin/main' into pr

# Conflicts:
#	crates/text/src/fonts.rs
#	crates/text/src/layout.rs
This commit is contained in:
midasdf
2026-10-27 22:33:32 +09:00
672 changed files with 60068 additions and 43063 deletions
+3 -1
View File
@@ -150,7 +150,9 @@ impl Reader<'_> {
let mut files: Vec<PathBuf> = rd
.flatten()
.map(|e| e.path())
.filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit())))
.filter(|p| {
p.file_name().and_then(std::ffi::OsStr::to_str).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit()))
})
.collect();
files.sort();
for f in files {
+21 -574
View File
@@ -116,7 +116,7 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec<PathBuf>) {
if depth < 8 {
walk(&p, depth + 1, out);
}
} else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) {
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) {
out.push(p);
}
}
@@ -319,7 +319,7 @@ fn push(db: &mut Db, info: FaceInfo) -> FaceId {
/// added.
pub fn add_source(src: &dyn FontSource) -> usize {
let faces = src.faces();
let mut d = db().write().unwrap_or_else(|e| e.into_inner());
let mut d = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut n = 0;
for f in faces {
let dup = d.faces.iter().any(|g| g.info.family.eq_ignore_ascii_case(&f.family) && g.info.style.eq_ignore_ascii_case(&f.style));
@@ -329,7 +329,7 @@ pub fn add_source(src: &dyn FontSource) -> usize {
}
}
// A new face can belong to one of a script's usual families.
family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).clear();
family_faces_cache().write().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
n
}
@@ -337,9 +337,9 @@ pub fn add_source(src: &dyn FontSource) -> usize {
pub fn add_font_data(data: Vec<u8>) -> Vec<FaceId> {
let data = Arc::new(data);
let names = read_faces_bytes(&data);
let mut d = db().write().unwrap_or_else(|e| e.into_inner());
let mut d = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
let ids = names.into_iter().map(|n| push(&mut d, info_from(n, FaceData::Shared(data.clone()), "user"))).collect();
family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).clear();
family_faces_cache().write().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
ids
}
@@ -353,21 +353,21 @@ pub fn scan_system() -> usize {
if system_scanned() {
return 0;
}
let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner());
let _scan = SCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if system_scanned() {
return 0;
}
let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) };
db().write().unwrap_or_else(|e| e.into_inner()).scanned = true;
db().write().unwrap_or_else(std::sync::PoisonError::into_inner).scanned = true;
n
}
/// Scan the system font folders again, adding fonts installed since the last scan (faces already
/// known are kept, so font ids stay valid). Returns the number of faces added.
pub fn rescan_system() -> usize {
let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner());
let _scan = SCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) };
db().write().unwrap_or_else(|e| e.into_inner()).scanned = true;
db().write().unwrap_or_else(std::sync::PoisonError::into_inner).scanned = true;
n
}
@@ -388,12 +388,12 @@ pub fn scan_system_in_background() {
}
pub fn system_scanned() -> bool {
db().read().unwrap_or_else(|e| e.into_inner()).scanned
db().read().unwrap_or_else(std::sync::PoisonError::into_inner).scanned
}
/// A face by id (panics never: unknown ids give the default face).
pub fn face(id: FaceId) -> Arc<Face> {
let d = db().read().unwrap_or_else(|e| e.into_inner());
let d = db().read().unwrap_or_else(std::sync::PoisonError::into_inner);
// The bundled fonts are compiled in and always parse (`tests::bundled_families_and_resolution` checks them), so
// the database is never empty.
#[allow(clippy::expect_used)]
@@ -402,7 +402,7 @@ pub fn face(id: FaceId) -> Arc<Face> {
/// All faces (bundled first).
pub fn all_faces() -> Vec<Arc<Face>> {
db().read().unwrap_or_else(|e| e.into_inner()).faces.clone()
db().read().unwrap_or_else(std::sync::PoisonError::into_inner).faces.clone()
}
/// Family names in the fonts' own languages: English family → native family (only families
@@ -763,15 +763,15 @@ pub(crate) static TEST_CJK_LOCALE: std::sync::Mutex<()> = std::sync::Mutex::new(
pub fn set_cjk_locale(tag: &str) {
let tag = tag.trim().to_ascii_lowercase();
let requested = if tag.is_empty() || tag == "c" || tag == "posix" { None } else { Some(tag) };
*REQUESTED_CJK_LOCALE.write().unwrap_or_else(|e| e.into_inner()) = requested;
*REQUESTED_CJK_LOCALE.write().unwrap_or_else(std::sync::PoisonError::into_inner) = requested;
// The order of the CJK families follows the language, so a change of language drops the cached
// fallbacks.
fallback_cache().write().unwrap_or_else(|e| e.into_inner()).clear();
family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).clear();
fallback_cache().write().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
family_faces_cache().write().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
}
fn requested_cjk_locale() -> Option<String> {
REQUESTED_CJK_LOCALE.read().unwrap_or_else(|e| e.into_inner()).clone()
REQUESTED_CJK_LOCALE.read().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
}
/// The CJK locale to use: [`set_cjk_locale`] when the host asked for one, otherwise the locale
@@ -1001,7 +1001,7 @@ fn fallback_with_locale(c: char, prefer: FaceId, locale: impl FnOnce() -> CjkLoc
}
}
let key = (c as u32 >> 8, prefer);
if let Some(cached) = fallback_cache().read().unwrap_or_else(|e| e.into_inner()).get(&key).copied() {
if let Some(cached) = fallback_cache().read().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key).copied() {
match cached {
Some(id) if face(id).has_char(c) => return id,
Some(_) => {}
@@ -1012,7 +1012,7 @@ fn fallback_with_locale(c: char, prefer: FaceId, locale: impl FnOnce() -> CjkLoc
scan_system();
}
let found = system_fallback(c, &p.info.style);
fallback_cache().write().unwrap_or_else(|e| e.into_inner()).insert(key, found);
fallback_cache().write().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, found);
found.unwrap_or(prefer)
}
@@ -1020,7 +1020,7 @@ fn fallback_with_locale(c: char, prefer: FaceId, locale: impl FnOnce() -> CjkLoc
/// for `script`, in the order they are tried, each in the style of `prefer`. For CJK, the other CJK
/// languages' families follow in the locale's order.
fn family_faces(script: FallbackScript, prefer: &Face, locale: CjkLocale) -> Arc<Vec<FaceId>> {
if let Some(faces) = family_faces_cache().read().unwrap_or_else(|e| e.into_inner()).get(&(script, prefer.id, locale)) {
if let Some(faces) = family_faces_cache().read().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(script, prefer.id, locale)) {
return faces.clone();
}
let order: Vec<FallbackScript> = match script {
@@ -1036,7 +1036,7 @@ fn family_faces(script: FallbackScript, prefer: &Face, locale: CjkLocale) -> Arc
let style = &prefer.info.style;
let faces: Arc<Vec<FaceId>> =
Arc::new(order.into_iter().flat_map(|s| families_for(s, locale)).filter_map(|fam| resolve_in(fam, style)).map(|r| r.face).collect());
family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).insert((script, prefer.id, locale), faces.clone());
family_faces_cache().write().unwrap_or_else(std::sync::PoisonError::into_inner).insert((script, prefer.id, locale), faces.clone());
faces
}
@@ -1050,557 +1050,4 @@ fn system_fallback(c: char, style: &str) -> Option<FaceId> {
}
#[cfg(test)]
mod tests {
use super::*;
/// Clears the requested CJK locale when a test that set it ends, panic or not.
struct LocaleReset;
impl Drop for LocaleReset {
fn drop(&mut self) {
set_cjk_locale("");
}
}
/// Concurrent language requests must neither choose another context's font nor reuse its
/// cached family order. Synthetic fonts in a child process keep this independent of the host
/// font installation and of other tests that exercise the process-global database/locale.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn cjk_requests_keep_their_language_and_cache_entries() {
const CHILD: &str = "EFFECTCRAFT_TEST_ISOLATED_CJK";
if std::env::var_os(CHILD).is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "fonts::tests::cjk_requests_keep_their_language_and_cache_entries", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
return;
}
let families = [("ja", "Yu Gothic"), ("zh-hans", "Microsoft YaHei"), ("zh-hant", "Microsoft JhengHei"), ("ko", "Malgun Gothic")];
let mut expected = Vec::new();
{
let mut d = db().write().unwrap();
d.scanned = true; // This test process uses only bundled and synthetic fonts.
for (language, family) in families {
let mut data = test_font(family);
let table = data[12..12 + 8 * 16].as_chunks::<16>().0.iter().find(|t| &t[..4] == b"cmap").unwrap();
let cmap = u32::from_be_bytes(table[8..12].try_into().unwrap()) as usize + 12;
// Map 页 to the fixture's triangle glyph instead of its private-use range.
// All four fonts cover the probe, so coverage cannot choose the right language.
for (offset, value) in [(16, '页' as u16), (24, '页' as u16), (30, 2u16.wrapping_sub('页' as u16))] {
data[cmap + offset..cmap + offset + 2].copy_from_slice(&value.to_be_bytes());
}
let data = Arc::new(data);
let names = read_faces_bytes(&data).into_iter().next().unwrap();
let id = push(&mut d, info_from(names, FaceData::Shared(data), "test"));
expected.push((language, id));
}
}
let base = resolve("Inter", "Regular").face;
for &(_, id) in &expected {
assert!(face(id).has_char('页'));
}
// Force the original interleaving: Chinese starts, then Japanese replaces the global
// language before Chinese looks up its probe. The explicit request must still win.
set_cjk_locale("zh-hans");
set_cjk_locale("ja");
assert_eq!(fallback_for('页', base), expected[0].1);
assert_eq!(fallback_for_locale('页', base, "zh-hans"), expected[1].1);
// Do not clear the cache here: different policies must coexist in the same cache.
for _ in 0..3 {
for &(language, id) in &expected {
assert_eq!(fallback_for_locale('页', base, language), id, "{language}");
}
}
let barrier = Arc::new(std::sync::Barrier::new(expected.len()));
let threads: Vec<_> = expected
.into_iter()
.map(|(language, id)| {
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
for _ in 0..128 {
set_cjk_locale(if language == "ja" { "zh-hans" } else { "ja" });
assert_eq!(fallback_for_locale('页', base, language), id, "{language}");
}
})
})
.collect();
for thread in threads {
thread.join().unwrap();
}
}
#[test]
fn extra_font_dirs_come_from_a_path_list() {
assert!(extra_dirs(None).is_empty());
let v = std::env::join_paths(["/a/fonts", "/b/more"]).unwrap_or_default();
assert_eq!(extra_dirs(Some(&v)), vec![PathBuf::from("/a/fonts"), PathBuf::from("/b/more")]);
assert!(extra_dirs(Some(std::ffi::OsStr::new(""))).is_empty());
}
#[test]
fn bundled_families_and_resolution() {
let fams = families();
let inter = fams.iter().find(|(f, _)| f == "Inter").expect("Inter");
for s in ["Regular", "Medium", "SemiBold", "Bold", "Italic"] {
assert!(inter.1.iter().any(|x| x == s), "{s} in {:?}", inter.1);
}
assert!(fams.iter().any(|(f, _)| f == "Noto Serif"));
assert!(fams.iter().any(|(f, _)| f == "JetBrains Mono"));
let b = resolve("inter", "bold");
assert_eq!(face(b.face).info.style, "Bold");
assert!(!b.synth_bold);
let faces = all_faces();
assert!(faces.iter().any(|f| f.info.family == "Noto Serif" && f.info.style == "Regular" && f.info.origin == "bundled"));
// A real installed Bold Italic takes priority. Partial installed families can instead
// supply the nearest weight/slant, so neither synthesis flag is fixed by the OS.
for family in ["Noto Serif", "Inter"] {
let resolved = resolve(family, "Bold Italic");
let selected = face(resolved.face);
assert!(selected.info.family.eq_ignore_ascii_case(family));
assert!(!resolved.missing);
let candidates: Vec<_> = faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(family)).collect();
if let Some(exact) = candidates.iter().find(|f| f.info.style.eq_ignore_ascii_case("Bold Italic")) {
assert_eq!(resolved.face, exact.id, "real style wins for {family}");
assert!(!resolved.synth_bold && !resolved.synth_italic);
} else {
// Italic mismatch dominates any supported font weight distance.
let distance = |f: &Face| u32::from(f.info.weight.abs_diff(700)) + if f.info.italic { 0 } else { 1_000 };
let nearest = candidates.iter().map(|f| distance(f)).min().unwrap();
assert_eq!(distance(&selected), nearest, "closest available style for {family}");
assert_eq!(resolved.synth_bold, selected.info.weight <= 500);
assert_eq!(resolved.synth_italic, !selected.info.italic);
}
}
}
#[test]
fn missing_family_falls_back_to_inter() {
let r = resolve("No Such Font Family 123", "Regular");
assert_eq!(face(r.face).info.family, "Inter");
assert!(r.missing);
}
/// Native: the system scan reads name tables only and resolves families installed there.
#[test]
fn system_scan_finds_fonts() {
let t = std::time::Instant::now();
scan_system(); // (another test may have triggered the scan already)
let n = all_faces().iter().filter(|f| f.info.origin == "system").count();
eprintln!("system fonts: {n} faces in {:?}", t.elapsed());
assert_eq!(scan_system(), 0, "scans once");
if cfg!(target_os = "macos") {
assert!(n > 20, "{n}");
let r = resolve("Helvetica", "Bold");
assert_eq!(face(r.face).info.family, "Helvetica");
let l = crate::layout_text("Helvetica", &crate::TextStyle { family: "Helvetica".into(), size: 30.0, ..Default::default() }, &Default::default());
assert!(!l.missing_font && l.glyphs.iter().all(|g| g.id != 0));
}
}
#[test]
fn postscript_and_full_names_resolve() {
// Bundled Inter: name id 6 is `Inter-Bold`, name id 4 is `Inter Bold`.
let ps = resolve("Inter-Bold", "");
assert_eq!(face(ps.face).info.style, "Bold");
assert!(!ps.missing && !ps.synth_bold);
let full = resolve("inter semibold", "");
assert_eq!(face(full.face).info.style, "SemiBold");
assert!(!full.missing);
// A family name still wins over a name lookup.
assert_eq!(face(resolve("Inter", "Bold").face).info.style, "Bold");
}
/// #1563: a variable font's named instances are styles of its family. `fontStyle = "Black"`,
/// the instance's PostScript name (`font = "Figtree-Black"`) and its full name select the
/// instance's axis values instead of the default instance made bold synthetically, and the
/// font menus list every instance.
#[test]
fn variable_named_instances_resolve_and_list() {
const FAMILY: &str = "EffectCraft Instance Test";
let instances = [
("Light", 300.0, Some("EffectCraftInstanceTest-Light")),
("Regular", 400.0, Some("EffectCraftInstanceTest-Regular")),
("Black", 900.0, Some("EffectCraftInstanceTest-Black")),
("Extra Bold", 800.0, None),
];
assert_eq!(add_font_data(crate::sfnt::test_font::variable(FAMILY, &instances, true)).len(), 1);
let wght = |r: &Resolved| instance_variations(r);
// By style name.
let black = resolve(FAMILY, "Black");
assert!(!black.missing && !black.synth_bold && !black.synth_italic, "{black:?}");
assert_eq!(wght(&black), vec![("wght".to_string(), 900.0)]);
assert_eq!(wght(&resolve(FAMILY, "regular")), vec![("wght".to_string(), 400.0)]);
// The face's own style is its default instance: no axis values to set.
let light = resolve(FAMILY, "Light");
assert_eq!((light.face, light.instance), (black.face, None));
// A style the family lacks takes the nearest instance's weight, no synthetic bold.
let bold = resolve(FAMILY, "Bold");
assert!(!bold.synth_bold, "{bold:?}");
assert_eq!(wght(&bold), vec![("wght".to_string(), 800.0)]);
// By PostScript name, the font's own and the derived one, and by full name.
let ps = resolve("EffectCraftInstanceTest-Black", "Regular");
assert!(!ps.missing && ps.face == black.face, "{ps:?}");
assert_eq!(wght(&ps), vec![("wght".to_string(), 900.0)]);
assert_eq!(wght(&resolve("EffectCraftInstanceTest-ExtraBold", "")), vec![("wght".to_string(), 800.0)]);
assert_eq!(wght(&resolve("effectcraft instance test extra bold", "")), vec![("wght".to_string(), 800.0)]);
// The font menu lists every instance, in weight order.
let fams = families();
let styles = &fams.iter().find(|(f, _)| f == FAMILY).unwrap().1;
assert_eq!(styles, &["Light", "Regular", "Extra Bold", "Black"]);
// Static fonts are untouched.
assert_eq!(resolve("Inter", "Bold").instance, None);
}
/// Axis values set on the text win over the instance's; the others keep the instance's.
#[test]
fn explicit_variations_override_the_instance() {
let base = vec![("wdth".to_string(), 75.0), ("wght".to_string(), 900.0)];
assert_eq!(merge_variations(&base, &[("wght".to_string(), 450.0)]), vec![("wdth".to_string(), 75.0), ("wght".to_string(), 450.0)]);
assert_eq!(merge_variations(&base, &[]), base);
assert_eq!(merge_variations(&[], &[("opsz".to_string(), 12.0)]), vec![("opsz".to_string(), 12.0)]);
}
#[test]
fn scripts_are_classified() {
assert_eq!(script_of('あ'), FallbackScript::Japanese);
assert_eq!(script_of('カ'), FallbackScript::Japanese);
assert_eq!(script_of('한'), FallbackScript::Korean);
assert_eq!(script_of('水'), FallbackScript::Han);
assert_eq!(script_of('、'), FallbackScript::Han);
assert_eq!(script_of('A'), FallbackScript::Han);
assert_eq!(script_of('\u{20000}'), FallbackScript::Han);
assert_eq!(script_of('A'), FallbackScript::Other);
assert_eq!(script_of('م'), FallbackScript::Arabic);
assert_eq!(script_of('\u{fefb}'), FallbackScript::Arabic);
assert_eq!(script_of('\u{5d0}'), FallbackScript::Hebrew);
assert_eq!(script_of('क'), FallbackScript::Indic);
assert_eq!(script_of('த'), FallbackScript::Indic);
assert_eq!(script_of('ก'), FallbackScript::Thai);
assert_eq!(script_of('ა'), FallbackScript::Georgian);
assert_eq!(script_of('\u{1c90}'), FallbackScript::Georgian, "Mtavruli");
assert_eq!(script_of('\u{feff}'), FallbackScript::Other, "the byte order mark is no Arabic letter");
}
/// Native: Arabic, Hebrew, Indic and Thai text in a Latin-only family falls back to the
/// platform's plain text family for its script (#394), not to the first installed face that
/// covers it (on Windows a calligraphic Arabic face sorts first). Skipped per script where no
/// family from its list is installed.
#[test]
fn other_scripts_fall_back_to_a_plain_system_family() {
scan_system();
let inter = resolve("Inter", "Regular").face;
for (c, families) in [('م', ARABIC_FAMILIES), ('א', HEBREW_FAMILIES), ('क', INDIC_FAMILIES), ('த', INDIC_FAMILIES), ('ก', THAI_FAMILIES)] {
let covering = families.iter().any(|n| resolve_in(n, "Regular").is_some_and(|r| face(r.face).has_char(c)));
if !covering {
eprintln!("{c}: no family from the list is installed; skipping");
continue;
}
let f = face(fallback_for(c, inter));
assert!(f.has_char(c), "{c}: {} lacks it", f.info.family);
assert!(families.iter().any(|n| f.info.family.eq_ignore_ascii_case(n)), "{c} → {}", f.info.family);
}
}
/// Native: Mkhedruli in a Latin-only family falls back to a family from the Georgian list,
/// ahead of faces already loaded and of the scan order. Skipped where no family from the list
/// is installed.
#[test]
fn georgian_falls_back_to_a_georgian_system_family() {
scan_system();
let c = 'ა';
if !GEORGIAN_FAMILIES.iter().any(|n| resolve_in(n, "Regular").is_some_and(|r| face(r.face).has_char(c))) {
eprintln!("no Georgian family from the list is installed; skipping");
return;
}
let f = face(fallback_for(c, resolve("Inter", "Regular").face));
assert!(f.has_char(c), "{} lacks it", f.info.family);
assert!(GEORGIAN_FAMILIES.iter().any(|n| f.info.family.eq_ignore_ascii_case(n)), "{c} → {}", f.info.family);
}
/// Native: Japanese text in a Latin-only family falls back to an installed Japanese family
/// (whichever the platform ships) instead of the missing-glyph box, without any layer having
/// loaded a CJK font first. Skipped where no family from the list is installed.
#[test]
fn cjk_falls_back_to_a_system_family() {
// Japanese text must reach the Japanese list even on a Chinese-language host, where the
// ambient locale puts the Chinese families first for the shared ideographs.
let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner());
set_cjk_locale("ja-JP");
let _reset = LocaleReset;
scan_system();
let inter = resolve("Inter", "Bold").face;
let installed = JAPANESE_FAMILIES.iter().any(|f| resolve_in(f, "Regular").is_some());
if !installed {
eprintln!("no Japanese family installed; skipping");
return;
}
for c in ['水', 'あ', 'ア', '。'] {
let id = fallback_for(c, inter);
let f = face(id);
assert!(f.has_char(c), "{c}: {} {} lacks it", f.info.family, f.info.style);
assert!(JAPANESE_FAMILIES.iter().any(|n| f.info.family.eq_ignore_ascii_case(n)), "{c} → {}", f.info.family);
}
// The bold cut of the fallback family is chosen for bold text when the family has one.
let f = face(fallback_for('水', inter));
let (w, _) = style_wants(&f.info.style);
let has_bold = all_faces().iter().any(|g| g.info.family == f.info.family && style_wants(&g.info.style).0 >= 600);
assert!(!has_bold || w >= 600, "{} {}", f.info.family, f.info.style);
// Cached: the second call is a lookup.
assert_eq!(fallback_for('水', inter), f.id);
}
/// A minimal TrueType font built in code (no font file in the repo): U+0020 and U+E000 map to
/// an empty glyph, U+E001 to a triangle.
fn blank_glyph_font() -> Vec<u8> {
test_font("Blank Glyph Test")
}
/// The blank-glyph test font under another family name.
fn test_font(family: &str) -> Vec<u8> {
fn u16s(v: &[u16]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_be_bytes()).collect()
}
let delta_space = 1u16.wrapping_sub(0x20);
let cmap4 = {
let mut t = u16s(&[4, 0, 0, 6, 4, 1, 2]);
t.extend(u16s(&[0x20, 0xE001, 0xFFFF, 0, 0x20, 0xE000, 0xFFFF, delta_space, 0x2001, 1, 0, 0, 0]));
let len = t.len() as u16;
t[2..4].copy_from_slice(&len.to_be_bytes());
t
};
let mut cmap = u16s(&[0, 1, 3, 1, 0, 12]);
cmap.extend(cmap4);
// glyph 2: one contour of three on-curve points (100,0) (500,0) (300,600)
let mut glyf = u16s(&[1, 100, 0, 500, 600, 2, 0]);
glyf.extend([1u8, 1, 1]);
glyf.extend(u16s(&[100, 400, (-200i16) as u16, 0, 0, 600]));
glyf.push(0);
let loca = u16s(&[0, 0, 0, (glyf.len() / 2) as u16]);
let mut head = u16s(&[1, 0, 1, 0, 0, 0, 0x5F0F, 0x3CF5, 0, 1000]);
head.extend([0u8; 16]);
head.extend(u16s(&[100, 0, 500, 600, 0, 8, 2, 0, 0]));
let mut hhea = u16s(&[1, 0, 800, (-200i16) as u16, 0, 600, 0, 0, 500, 1, 0, 0]);
hhea.extend([0u8; 8]);
hhea.extend(u16s(&[0, 3]));
let hmtx = u16s(&[600, 0, 600, 0, 600, 0]);
let maxp = u16s(&[1, 0, 3, 3, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0]);
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
let mut name = u16s(&[0, 2, 30]);
let mut off = 0;
for (id, st) in strings.iter().enumerate() {
name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off]));
off += st.len() as u16;
}
strings.iter().for_each(|st| name.extend(st));
let tables: [(&[u8; 4], Vec<u8>); 8] =
[(b"cmap", cmap), (b"glyf", glyf), (b"head", head), (b"hhea", hhea), (b"hmtx", hmtx), (b"loca", loca), (b"maxp", maxp), (b"name", name)];
let mut out = u16s(&[1, 0, tables.len() as u16, 128, 3, 0]);
let mut offset = 12 + 16 * tables.len();
let mut body = vec![];
for (tag, data) in &tables {
out.extend(*tag);
out.extend([0u8; 4]);
out.extend((offset as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
let padded = data.len().div_ceil(4) * 4;
body.extend(data);
body.resize(body.len() + padded - data.len(), 0);
offset += padded;
}
out.extend(body);
out
}
/// A face that maps a character to an empty glyph doesn't cover it, so fallback skips it
/// (#285); a blank space still counts, and a drawn glyph is found as before.
#[test]
fn an_empty_glyph_does_not_cover_its_character() {
let ids = add_font_data(blank_glyph_font());
let &[id] = ids.as_slice() else { panic!("one face expected: {ids:?}") };
let f = face(id);
assert_eq!(f.info.family, "Blank Glyph Test");
assert!(f.glyph('\u{e000}').is_some(), "the cmap maps it");
assert!(!f.has_char('\u{e000}'), "but its glyph is empty");
assert!(f.has_char('\u{e001}'), "a drawn glyph covers its character");
assert!(f.has_char(' '), "a space is blank by nature");
let inter = resolve("Inter", "Regular").face;
assert_ne!(fallback_for('\u{e000}', inter), id, "fallback never picks the empty glyph");
assert_eq!(fallback_for('\u{e001}', inter), id, "fallback finds the drawn one");
}
/// A Microsoft family that isn't installed renders in its installed metric-compatible
/// substitute instead of Inter, and still reports itself missing (#1677).
#[test]
fn microsoft_families_resolve_to_metric_compatible_substitutes() {
add_font_data(test_font("Carlito"));
add_font_data(test_font("Caladea"));
let calibri = resolve("Calibri", "Regular");
assert_eq!(face(calibri.face).info.family, "Carlito");
assert!(calibri.missing, "the original name stays missing for Find Missing Fonts");
let cambria = resolve("Cambria", "Bold");
assert_eq!(face(cambria.face).info.family, "Caladea");
assert!(cambria.missing);
let unknown = resolve("No Such Family 1677", "Regular");
assert_eq!(face(unknown.face).info.family, DEFAULT_FAMILY);
assert!(unknown.missing);
}
#[test]
fn the_han_order_follows_the_cjk_locale() {
let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner());
// Chinese first for any Chinese tag, whether or not it names a script.
for tag in ["zh", "zh-CN", "zh_Hans", "ZH-Hant-TW.UTF-8", "zh_MO"] {
assert_eq!(han_order_for(tag)[0], FallbackScript::Han, "{tag}");
}
assert_eq!(han_order_for("ko-KR"), [FallbackScript::Korean, FallbackScript::Japanese, FallbackScript::Han]);
// Japanese, and anything else, keeps the Japanese face first for Han text.
for tag in ["ja_JP.UTF-8", "en-US", "de", ""] {
assert_eq!(han_order_for(tag), [FallbackScript::Japanese, FallbackScript::Han, FallbackScript::Korean], "{tag}");
}
// The host's request is what `han_order` reads (the ui test covers the effect on the
// registered fallbacks; this only checks that clearing it returns to the environment).
assert_eq!(requested_cjk_locale(), None, "tests run without a request");
assert_eq!(han_order(), han_order_for(&active_cjk_locale()));
}
#[test]
fn the_chinese_family_list_follows_the_script_the_locale_asks_for() {
let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner());
for tag in ["zh", "zh-CN", "zh_Hans", "zh_CN.UTF-8", "zh-SG"] {
assert_eq!(chinese_families_for(tag), SIMPLIFIED_CHINESE_FAMILIES, "{tag}");
}
for tag in ["zh-Hant", "zh-Hant-TW", "zh_TW", "zh-HK", "zh_MO.UTF-8"] {
assert_eq!(chinese_families_for(tag), TRADITIONAL_CHINESE_FAMILIES, "{tag}");
}
// Each list carries its own script's faces and not the other's, which is the point of
// splitting them: `PingFang SC` covers the same code points as `PingFang TC`, so the
// fallback search would have kept it and drawn Simplified forms in a Traditional interface.
assert!(SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| f.contains("PingFang SC") && !f.contains("Source")));
assert!(!SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| f.contains("TC") || f.contains("JhengHei")));
assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains("PingFang TC")));
assert!(!TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains(" SC") || f.contains("YaHei") || f.contains("WenQuanYi")));
assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains("Heiti TC") || f.contains("Hiragino Sans CNS")));
// The arm the fallback itself walks: Han hands over the list the locale's script picked.
assert_eq!(families_for(FallbackScript::Han, CjkLocale::from_tag(&active_cjk_locale())), chinese_families_for(&active_cjk_locale()));
// Han text is the only thing these lists serve; the Japanese order does not change.
assert_eq!(han_order_for("ja_JP.UTF-8")[0], FallbackScript::Japanese);
}
/// macOS ships no Simplified Chinese PingFang on an English install (macOS 27 has the
/// on-demand PingFang instead): Hiragino Sans GB, Heiti SC and Songti SC are the installed
/// Chinese faces, so the Han list has to name them or Han text falls through to the Japanese
/// face and draws Japanese glyph forms (#453). The system check is skipped where none of them
/// is installed; the list itself is checked everywhere.
#[test]
fn macos_chinese_faces_are_tried_for_han_text() {
for face in ["Hiragino Sans GB", "Heiti SC", "Songti SC"] {
assert!(SIMPLIFIED_CHINESE_FAMILIES.iter().any(|l| l.eq_ignore_ascii_case(face)), "{face} missing from the Han list");
}
assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|l| l.eq_ignore_ascii_case("Songti TC")), "Songti TC missing from the Traditional list");
scan_system();
let installed: Vec<&str> = ["Hiragino Sans GB", "Heiti SC", "Songti SC"].into_iter().filter(|f| resolve_in(f, "Regular").is_some()).collect();
if installed.is_empty() {
eprintln!("no macOS Simplified Chinese face installed; skipping the system check");
return;
}
// Walking the Han branch's own list finds a Chinese face before the Japanese face the
// exhaustive search would have found (Hiragino Sans), and it covers the shared ideographs.
let (family, resolved) =
SIMPLIFIED_CHINESE_FAMILIES.iter().find_map(|f| resolve_in(f, "Regular").map(|r| (*f, r))).expect("an installed Simplified Chinese family");
// An earlier name can win the walk when this Mac has it as well (`PingFang SC` on an older
// install, or Microsoft YaHei / Noto Sans CJK SC through Office or Homebrew). That is just
// as valid a Chinese fallback as the macOS trio; only the Japanese face would be a
// regression. Each installed macOS face is on the list (checked above) and covers the
// shared ideographs.
assert!(!JAPANESE_FAMILIES.iter().any(|j| j.eq_ignore_ascii_case(family)), "{family} is a Japanese family");
for c in ['骨', '直', '日'] {
assert!(face(resolved.face).has_char(c), "{family} cannot draw {c}");
}
for name in installed {
let resolved = resolve_in(name, "Regular").expect("installed above");
for c in ['骨', '直', '日'] {
assert!(face(resolved.face).has_char(c), "{name} cannot draw {c}");
}
}
}
/// #453 under a Chinese interface: Han text in a Latin-only family falls back through the Han
/// list, in the locale's order, to an installed Simplified Chinese family; it must not reach the
/// Japanese face the exhaustive search would pick. The direct family lookup above would not
/// catch a `fallback_for` that skipped `han_order`. Skipped where no Simplified family is
/// installed.
#[test]
fn chinese_han_text_falls_back_to_a_simplified_chinese_family() {
let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner());
scan_system();
if !SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| resolve_in(f, "Regular").is_some()) {
eprintln!("no Simplified Chinese family installed; skipping");
return;
}
set_cjk_locale("zh-CN");
let _reset = LocaleReset;
let inter = resolve("Inter", "Regular").face;
for c in ['骨', '直', '日', '真'] {
let f = face(fallback_for(c, inter));
assert!(f.has_char(c), "{c}: {} {} lacks it", f.info.family, f.info.style);
assert!(
SIMPLIFIED_CHINESE_FAMILIES.iter().any(|n| n.eq_ignore_ascii_case(&f.info.family)),
"{c} → {} is not a Simplified Chinese family",
f.info.family
);
}
}
#[test]
fn the_rendered_order_keeps_the_print_face_first() {
// A composed frame renders with these lists, so they keep the platform's print order: the
// interface prefers the platform's UI faces on its own (see `install_script_fallbacks` in
// the ui crate), and an existing project must not render differently because a window's
// chrome changed.
let pos = |list: &[&str], name: &str| list.iter().position(|f| *f == name).unwrap_or(usize::MAX);
assert!(
pos(JAPANESE_FAMILIES, "Yu Gothic") < pos(JAPANESE_FAMILIES, "Yu Gothic UI"),
"the print face must come before its UI sibling: {JAPANESE_FAMILIES:?}"
);
for name in ["Meiryo UI", "MS UI Gothic"] {
assert!(!JAPANESE_FAMILIES.contains(&name), "{name} belongs to the interface order, not this list");
}
assert_eq!(SIMPLIFIED_CHINESE_FAMILIES[0], "Microsoft YaHei");
assert_eq!(TRADITIONAL_CHINESE_FAMILIES[0], "Microsoft JhengHei");
for name in ["Microsoft YaHei UI", "Microsoft JhengHei UI", "PMingLiU"] {
assert!(
!SIMPLIFIED_CHINESE_FAMILIES.contains(&name) && !TRADITIONAL_CHINESE_FAMILIES.contains(&name),
"{name} belongs to the interface order, not these lists"
);
}
}
#[test]
fn bundled_ui_fonts_cover_the_ukrainian_alphabet() {
const ALPHABET: &str = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя’«»";
for (name, bytes) in
[("Inter Regular", INTER_REGULAR), ("Inter Medium", INTER_MEDIUM), ("Inter SemiBold", INTER_SEMIBOLD), ("JetBrains Mono", JETBRAINS_MONO_REGULAR)]
{
let font = FontRef::new(bytes).unwrap();
for c in ALPHABET.chars() {
assert!(covers(&font, c), "{name} cannot draw {c}");
}
}
}
#[test]
fn metrics_and_coverage() {
let f = face(resolve("Inter", "Regular").face);
let m = f.metrics(100.0);
assert!(m.ascent > 80.0 && m.ascent < 110.0, "{m:?}");
assert!(m.descent > 10.0 && m.descent < 40.0);
assert!(m.cap_height > 60.0 && m.cap_height < 80.0);
assert!(f.has_char('A') && f.has_char('Ж'));
assert!(!f.has_char('\u{5d0}'), "Inter has no Hebrew");
assert!(f.has_feature(b"liga") || f.has_feature(b"calt"));
}
}
mod tests;
+471
View File
@@ -0,0 +1,471 @@
use super::*;
/// Concurrent language requests must neither choose another context's font nor reuse its
/// cached family order. Synthetic fonts in a child process keep this independent of the host
/// font installation and of other tests that exercise the process-global database/locale.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn cjk_requests_keep_their_language_and_cache_entries() {
const CHILD: &str = "EFFECTCRAFT_TEST_ISOLATED_CJK";
if std::env::var_os(CHILD).is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "fonts::tests::cjk_requests_keep_their_language_and_cache_entries", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
return;
}
let families = [("ja", "Yu Gothic"), ("zh-hans", "Microsoft YaHei"), ("zh-hant", "Microsoft JhengHei"), ("ko", "Malgun Gothic")];
let mut expected = Vec::new();
{
let mut d = db().write().unwrap();
d.scanned = true; // This test process uses only bundled and synthetic fonts.
for (language, family) in families {
let mut data = test_font(family);
let table = data[12..12 + 8 * 16].as_chunks::<16>().0.iter().find(|t| &t[..4] == b"cmap").unwrap();
let cmap = u32::from_be_bytes(table[8..12].try_into().unwrap()) as usize + 12;
// Map 页 to the fixture's triangle glyph instead of its private-use range.
// All four fonts cover the probe, so coverage cannot choose the right language.
for (offset, value) in [(16, '页' as u16), (24, '页' as u16), (30, 2u16.wrapping_sub('页' as u16))] {
data[cmap + offset..cmap + offset + 2].copy_from_slice(&value.to_be_bytes());
}
let data = Arc::new(data);
let names = read_faces_bytes(&data).into_iter().next().unwrap();
let id = push(&mut d, info_from(names, FaceData::Shared(data), "test"));
expected.push((language, id));
}
}
let base = resolve("Inter", "Regular").face;
for &(_, id) in &expected {
assert!(face(id).has_char('页'));
}
// Force the original interleaving: Chinese starts, then Japanese replaces the global
// language before Chinese looks up its probe. The explicit request must still win.
set_cjk_locale("zh-hans");
set_cjk_locale("ja");
assert_eq!(fallback_for('页', base), expected[0].1);
assert_eq!(fallback_for_locale('页', base, "zh-hans"), expected[1].1);
// Do not clear the cache here: different policies must coexist in the same cache.
for _ in 0..3 {
for &(language, id) in &expected {
assert_eq!(fallback_for_locale('页', base, language), id, "{language}");
}
}
let barrier = Arc::new(std::sync::Barrier::new(expected.len()));
let threads: Vec<_> = expected
.into_iter()
.map(|(language, id)| {
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
for _ in 0..128 {
set_cjk_locale(if language == "ja" { "zh-hans" } else { "ja" });
assert_eq!(fallback_for_locale('页', base, language), id, "{language}");
}
})
})
.collect();
for thread in threads {
thread.join().unwrap();
}
}
#[test]
fn extra_font_dirs_come_from_a_path_list() {
assert!(extra_dirs(None).is_empty());
let v = std::env::join_paths(["/a/fonts", "/b/more"]).unwrap_or_default();
assert_eq!(extra_dirs(Some(&v)), vec![PathBuf::from("/a/fonts"), PathBuf::from("/b/more")]);
assert!(extra_dirs(Some(std::ffi::OsStr::new(""))).is_empty());
}
#[test]
fn bundled_families_and_resolution() {
let fams = families();
let inter = fams.iter().find(|(f, _)| f == "Inter").expect("Inter");
for s in ["Regular", "Medium", "SemiBold", "Bold", "Italic"] {
assert!(inter.1.iter().any(|x| x == s), "{s} in {:?}", inter.1);
}
assert!(fams.iter().any(|(f, _)| f == "Noto Serif"));
assert!(fams.iter().any(|(f, _)| f == "JetBrains Mono"));
let b = resolve("inter", "bold");
assert_eq!(face(b.face).info.style, "Bold");
assert!(!b.synth_bold);
let faces = all_faces();
assert!(faces.iter().any(|f| f.info.family == "Noto Serif" && f.info.style == "Regular" && f.info.origin == "bundled"));
// A real installed Bold Italic takes priority. Partial installed families can instead
// supply the nearest weight/slant, so neither synthesis flag is fixed by the OS.
for family in ["Noto Serif", "Inter"] {
let resolved = resolve(family, "Bold Italic");
let selected = face(resolved.face);
assert!(selected.info.family.eq_ignore_ascii_case(family));
assert!(!resolved.missing);
let candidates: Vec<_> = faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(family)).collect();
if let Some(exact) = candidates.iter().find(|f| f.info.style.eq_ignore_ascii_case("Bold Italic")) {
assert_eq!(resolved.face, exact.id, "real style wins for {family}");
assert!(!resolved.synth_bold && !resolved.synth_italic);
} else {
// Italic mismatch dominates any supported font weight distance.
let distance = |f: &Face| u32::from(f.info.weight.abs_diff(700)) + if f.info.italic { 0 } else { 1_000 };
let nearest = candidates.iter().map(|f| distance(f)).min().unwrap();
assert_eq!(distance(&selected), nearest, "closest available style for {family}");
assert_eq!(resolved.synth_bold, selected.info.weight <= 500);
assert_eq!(resolved.synth_italic, !selected.info.italic);
}
}
}
#[test]
fn missing_family_falls_back_to_inter() {
let r = resolve("No Such Font Family 123", "Regular");
assert_eq!(face(r.face).info.family, "Inter");
assert!(r.missing);
}
/// Native: the system scan reads name tables only and resolves families installed there.
#[test]
fn system_scan_finds_fonts() {
let t = std::time::Instant::now();
scan_system(); // (another test may have triggered the scan already)
let n = all_faces().iter().filter(|f| f.info.origin == "system").count();
eprintln!("system fonts: {n} faces in {:?}", t.elapsed());
assert_eq!(scan_system(), 0, "scans once");
if cfg!(target_os = "macos") {
assert!(n > 20, "{n}");
let r = resolve("Helvetica", "Bold");
assert_eq!(face(r.face).info.family, "Helvetica");
let l = crate::layout_text("Helvetica", &crate::TextStyle { family: "Helvetica".into(), size: 30.0, ..Default::default() }, &Default::default());
assert!(!l.missing_font && l.glyphs.iter().all(|g| g.id != 0));
}
}
#[test]
fn postscript_and_full_names_resolve() {
// Bundled Inter: name id 6 is `Inter-Bold`, name id 4 is `Inter Bold`.
let ps = resolve("Inter-Bold", "");
assert_eq!(face(ps.face).info.style, "Bold");
assert!(!ps.missing && !ps.synth_bold);
let full = resolve("inter semibold", "");
assert_eq!(face(full.face).info.style, "SemiBold");
assert!(!full.missing);
// A family name still wins over a name lookup.
assert_eq!(face(resolve("Inter", "Bold").face).info.style, "Bold");
}
/// #1563: a variable font's named instances are styles of its family. `fontStyle = "Black"`,
/// the instance's PostScript name (`font = "Figtree-Black"`) and its full name select the
/// instance's axis values instead of the default instance made bold synthetically, and the
/// font menus list every instance.
#[test]
fn variable_named_instances_resolve_and_list() {
const FAMILY: &str = "EffectCraft Instance Test";
let instances = [
("Light", 300.0, Some("EffectCraftInstanceTest-Light")),
("Regular", 400.0, Some("EffectCraftInstanceTest-Regular")),
("Black", 900.0, Some("EffectCraftInstanceTest-Black")),
("Extra Bold", 800.0, None),
];
assert_eq!(add_font_data(crate::sfnt::test_font::variable(FAMILY, &instances, true)).len(), 1);
let wght = |r: &Resolved| instance_variations(r);
// By style name.
let black = resolve(FAMILY, "Black");
assert!(!black.missing && !black.synth_bold && !black.synth_italic, "{black:?}");
assert_eq!(wght(&black), vec![("wght".to_string(), 900.0)]);
assert_eq!(wght(&resolve(FAMILY, "regular")), vec![("wght".to_string(), 400.0)]);
// The face's own style is its default instance: no axis values to set.
let light = resolve(FAMILY, "Light");
assert_eq!((light.face, light.instance), (black.face, None));
// A style the family lacks takes the nearest instance's weight, no synthetic bold.
let bold = resolve(FAMILY, "Bold");
assert!(!bold.synth_bold, "{bold:?}");
assert_eq!(wght(&bold), vec![("wght".to_string(), 800.0)]);
// By PostScript name, the font's own and the derived one, and by full name.
let ps = resolve("EffectCraftInstanceTest-Black", "Regular");
assert!(!ps.missing && ps.face == black.face, "{ps:?}");
assert_eq!(wght(&ps), vec![("wght".to_string(), 900.0)]);
assert_eq!(wght(&resolve("EffectCraftInstanceTest-ExtraBold", "")), vec![("wght".to_string(), 800.0)]);
assert_eq!(wght(&resolve("effectcraft instance test extra bold", "")), vec![("wght".to_string(), 800.0)]);
// The font menu lists every instance, in weight order.
let fams = families();
let styles = &fams.iter().find(|(f, _)| f == FAMILY).unwrap().1;
assert_eq!(styles, &["Light", "Regular", "Extra Bold", "Black"]);
// Static fonts are untouched.
assert_eq!(resolve("Inter", "Bold").instance, None);
}
/// Axis values set on the text win over the instance's; the others keep the instance's.
#[test]
fn explicit_variations_override_the_instance() {
let base = vec![("wdth".to_string(), 75.0), ("wght".to_string(), 900.0)];
assert_eq!(merge_variations(&base, &[("wght".to_string(), 450.0)]), vec![("wdth".to_string(), 75.0), ("wght".to_string(), 450.0)]);
assert_eq!(merge_variations(&base, &[]), base);
assert_eq!(merge_variations(&[], &[("opsz".to_string(), 12.0)]), vec![("opsz".to_string(), 12.0)]);
}
#[test]
fn scripts_are_classified() {
assert_eq!(script_of('あ'), FallbackScript::Japanese);
assert_eq!(script_of('カ'), FallbackScript::Japanese);
assert_eq!(script_of('한'), FallbackScript::Korean);
assert_eq!(script_of('水'), FallbackScript::Han);
assert_eq!(script_of('、'), FallbackScript::Han);
assert_eq!(script_of('A'), FallbackScript::Han);
assert_eq!(script_of('\u{20000}'), FallbackScript::Han);
assert_eq!(script_of('A'), FallbackScript::Other);
assert_eq!(script_of('م'), FallbackScript::Arabic);
assert_eq!(script_of('\u{fefb}'), FallbackScript::Arabic);
assert_eq!(script_of('\u{5d0}'), FallbackScript::Hebrew);
assert_eq!(script_of('क'), FallbackScript::Indic);
assert_eq!(script_of('த'), FallbackScript::Indic);
assert_eq!(script_of('ก'), FallbackScript::Thai);
assert_eq!(script_of('ა'), FallbackScript::Georgian);
assert_eq!(script_of('\u{1c90}'), FallbackScript::Georgian, "Mtavruli");
assert_eq!(script_of('\u{feff}'), FallbackScript::Other, "the byte order mark is no Arabic letter");
}
/// Native: Arabic, Hebrew, Indic and Thai text in a Latin-only family falls back to the
/// platform's plain text family for its script (#394), not to the first installed face that
/// covers it (on Windows a calligraphic Arabic face sorts first). Skipped per script where no
/// family from its list is installed.
#[test]
fn other_scripts_fall_back_to_a_plain_system_family() {
scan_system();
let inter = resolve("Inter", "Regular").face;
for (c, families) in [('م', ARABIC_FAMILIES), ('א', HEBREW_FAMILIES), ('क', INDIC_FAMILIES), ('த', INDIC_FAMILIES), ('ก', THAI_FAMILIES)] {
let covering = families.iter().any(|n| resolve_in(n, "Regular").is_some_and(|r| face(r.face).has_char(c)));
if !covering {
eprintln!("{c}: no family from the list is installed; skipping");
continue;
}
let f = face(fallback_for(c, inter));
assert!(f.has_char(c), "{c}: {} lacks it", f.info.family);
assert!(families.iter().any(|n| f.info.family.eq_ignore_ascii_case(n)), "{c} → {}", f.info.family);
}
}
/// Native: Mkhedruli in a Latin-only family falls back to a family from the Georgian list,
/// ahead of faces already loaded and of the scan order. Skipped where no family from the list
/// is installed.
#[test]
fn georgian_falls_back_to_a_georgian_system_family() {
scan_system();
let c = 'ა';
if !GEORGIAN_FAMILIES.iter().any(|n| resolve_in(n, "Regular").is_some_and(|r| face(r.face).has_char(c))) {
eprintln!("no Georgian family from the list is installed; skipping");
return;
}
let f = face(fallback_for(c, resolve("Inter", "Regular").face));
assert!(f.has_char(c), "{} lacks it", f.info.family);
assert!(GEORGIAN_FAMILIES.iter().any(|n| f.info.family.eq_ignore_ascii_case(n)), "{c} → {}", f.info.family);
}
/// Native: Japanese text in a Latin-only family falls back to an installed Japanese family
/// (whichever the platform ships) instead of the missing-glyph box, without any layer having
/// loaded a CJK font first. Skipped where no family from the list is installed.
#[test]
fn cjk_falls_back_to_a_system_family() {
scan_system();
let inter = resolve("Inter", "Bold").face;
let installed = JAPANESE_FAMILIES.iter().any(|f| resolve_in(f, "Regular").is_some());
if !installed {
eprintln!("no Japanese family installed; skipping");
return;
}
for c in ['水', 'あ', 'ア', '。'] {
let id = fallback_for(c, inter);
let f = face(id);
assert!(f.has_char(c), "{c}: {} {} lacks it", f.info.family, f.info.style);
assert!(JAPANESE_FAMILIES.iter().any(|n| f.info.family.eq_ignore_ascii_case(n)), "{c} → {}", f.info.family);
}
// The bold cut of the fallback family is chosen for bold text when the family has one.
let f = face(fallback_for('水', inter));
let (w, _) = style_wants(&f.info.style);
let has_bold = all_faces().iter().any(|g| g.info.family == f.info.family && style_wants(&g.info.style).0 >= 600);
assert!(!has_bold || w >= 600, "{} {}", f.info.family, f.info.style);
// Cached: the second call is a lookup.
assert_eq!(fallback_for('水', inter), f.id);
}
/// A minimal TrueType font built in code (no font file in the repo): U+0020 and U+E000 map to
/// an empty glyph, U+E001 to a triangle.
fn blank_glyph_font() -> Vec<u8> {
test_font("Blank Glyph Test")
}
/// The blank-glyph test font under another family name.
fn test_font(family: &str) -> Vec<u8> {
fn u16s(v: &[u16]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_be_bytes()).collect()
}
let delta_space = 1u16.wrapping_sub(0x20);
let cmap4 = {
let mut t = u16s(&[4, 0, 0, 6, 4, 1, 2]);
t.extend(u16s(&[0x20, 0xE001, 0xFFFF, 0, 0x20, 0xE000, 0xFFFF, delta_space, 0x2001, 1, 0, 0, 0]));
let len = t.len() as u16;
t[2..4].copy_from_slice(&len.to_be_bytes());
t
};
let mut cmap = u16s(&[0, 1, 3, 1, 0, 12]);
cmap.extend(cmap4);
// glyph 2: one contour of three on-curve points (100,0) (500,0) (300,600)
let mut glyf = u16s(&[1, 100, 0, 500, 600, 2, 0]);
glyf.extend([1u8, 1, 1]);
glyf.extend(u16s(&[100, 400, (-200i16) as u16, 0, 0, 600]));
glyf.push(0);
let loca = u16s(&[0, 0, 0, (glyf.len() / 2) as u16]);
let mut head = u16s(&[1, 0, 1, 0, 0, 0, 0x5F0F, 0x3CF5, 0, 1000]);
head.extend([0u8; 16]);
head.extend(u16s(&[100, 0, 500, 600, 0, 8, 2, 0, 0]));
let mut hhea = u16s(&[1, 0, 800, (-200i16) as u16, 0, 600, 0, 0, 500, 1, 0, 0]);
hhea.extend([0u8; 8]);
hhea.extend(u16s(&[0, 3]));
let hmtx = u16s(&[600, 0, 600, 0, 600, 0]);
let maxp = u16s(&[1, 0, 3, 3, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0]);
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
let mut name = u16s(&[0, 2, 30]);
let mut off = 0;
for (id, st) in strings.iter().enumerate() {
name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off]));
off += st.len() as u16;
}
strings.iter().for_each(|st| name.extend(st));
let tables: [(&[u8; 4], Vec<u8>); 8] =
[(b"cmap", cmap), (b"glyf", glyf), (b"head", head), (b"hhea", hhea), (b"hmtx", hmtx), (b"loca", loca), (b"maxp", maxp), (b"name", name)];
let mut out = u16s(&[1, 0, tables.len() as u16, 128, 3, 0]);
let mut offset = 12 + 16 * tables.len();
let mut body = vec![];
for (tag, data) in &tables {
out.extend(*tag);
out.extend([0u8; 4]);
out.extend((offset as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
let padded = data.len().div_ceil(4) * 4;
body.extend(data);
body.resize(body.len() + padded - data.len(), 0);
offset += padded;
}
out.extend(body);
out
}
/// A face that maps a character to an empty glyph doesn't cover it, so fallback skips it
/// (#285); a blank space still counts, and a drawn glyph is found as before.
#[test]
fn an_empty_glyph_does_not_cover_its_character() {
let ids = add_font_data(blank_glyph_font());
let &[id] = ids.as_slice() else { panic!("one face expected: {ids:?}") };
let f = face(id);
assert_eq!(f.info.family, "Blank Glyph Test");
assert!(f.glyph('\u{e000}').is_some(), "the cmap maps it");
assert!(!f.has_char('\u{e000}'), "but its glyph is empty");
assert!(f.has_char('\u{e001}'), "a drawn glyph covers its character");
assert!(f.has_char(' '), "a space is blank by nature");
let inter = resolve("Inter", "Regular").face;
assert_ne!(fallback_for('\u{e000}', inter), id, "fallback never picks the empty glyph");
assert_eq!(fallback_for('\u{e001}', inter), id, "fallback finds the drawn one");
}
/// A Microsoft family that isn't installed renders in its installed metric-compatible
/// substitute instead of Inter, and still reports itself missing (#1677).
#[test]
fn microsoft_families_resolve_to_metric_compatible_substitutes() {
add_font_data(test_font("Carlito"));
add_font_data(test_font("Caladea"));
let calibri = resolve("Calibri", "Regular");
assert_eq!(face(calibri.face).info.family, "Carlito");
assert!(calibri.missing, "the original name stays missing for Find Missing Fonts");
let cambria = resolve("Cambria", "Bold");
assert_eq!(face(cambria.face).info.family, "Caladea");
assert!(cambria.missing);
let unknown = resolve("No Such Family 1677", "Regular");
assert_eq!(face(unknown.face).info.family, DEFAULT_FAMILY);
assert!(unknown.missing);
}
#[test]
fn the_han_order_follows_the_cjk_locale() {
// Chinese first for any Chinese tag, whether or not it names a script.
for tag in ["zh", "zh-CN", "zh_Hans", "ZH-Hant-TW.UTF-8", "zh_MO"] {
assert_eq!(han_order_for(tag)[0], FallbackScript::Han, "{tag}");
}
assert_eq!(han_order_for("ko-KR"), [FallbackScript::Korean, FallbackScript::Japanese, FallbackScript::Han]);
// Japanese, and anything else, keeps the Japanese face first for Han text.
for tag in ["ja_JP.UTF-8", "en-US", "de", ""] {
assert_eq!(han_order_for(tag), [FallbackScript::Japanese, FallbackScript::Han, FallbackScript::Korean], "{tag}");
}
// The host's request is what `han_order` reads (the ui test covers the effect on the
// registered fallbacks; this only checks that clearing it returns to the environment).
assert_eq!(requested_cjk_locale(), None, "tests run without a request");
assert_eq!(han_order(), han_order_for(&active_cjk_locale()));
}
#[test]
fn the_chinese_family_list_follows_the_script_the_locale_asks_for() {
for tag in ["zh", "zh-CN", "zh_Hans", "zh_CN.UTF-8", "zh-SG"] {
assert_eq!(chinese_families_for(tag), SIMPLIFIED_CHINESE_FAMILIES, "{tag}");
}
for tag in ["zh-Hant", "zh-Hant-TW", "zh_TW", "zh-HK", "zh_MO.UTF-8"] {
assert_eq!(chinese_families_for(tag), TRADITIONAL_CHINESE_FAMILIES, "{tag}");
}
// Each list carries its own script's faces and not the other's, which is the point of
// splitting them: `PingFang SC` covers the same code points as `PingFang TC`, so the
// fallback search would have kept it and drawn Simplified forms in a Traditional interface.
assert!(SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| f.contains("PingFang SC") && !f.contains("Source")));
assert!(!SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| f.contains("TC") || f.contains("JhengHei")));
assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains("PingFang TC")));
assert!(!TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains(" SC") || f.contains("YaHei") || f.contains("WenQuanYi")));
assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains("Heiti TC") || f.contains("Hiragino Sans CNS")));
// The arm the fallback itself walks: Han hands over the list the locale's script picked.
assert_eq!(families_for(FallbackScript::Han, CjkLocale::from_tag(&active_cjk_locale())), chinese_families_for(&active_cjk_locale()));
// Han text is the only thing these lists serve; the Japanese order does not change.
assert_eq!(han_order_for("ja_JP.UTF-8")[0], FallbackScript::Japanese);
}
#[test]
fn the_rendered_order_keeps_the_print_face_first() {
// A composed frame renders with these lists, so they keep the platform's print order: the
// interface prefers the platform's UI faces on its own (see `install_script_fallbacks` in
// the ui crate), and an existing project must not render differently because a window's
// chrome changed.
let pos = |list: &[&str], name: &str| list.iter().position(|f| *f == name).unwrap_or(usize::MAX);
assert!(
pos(JAPANESE_FAMILIES, "Yu Gothic") < pos(JAPANESE_FAMILIES, "Yu Gothic UI"),
"the print face must come before its UI sibling: {JAPANESE_FAMILIES:?}"
);
for name in ["Meiryo UI", "MS UI Gothic"] {
assert!(!JAPANESE_FAMILIES.contains(&name), "{name} belongs to the interface order, not this list");
}
assert_eq!(SIMPLIFIED_CHINESE_FAMILIES[0], "Microsoft YaHei");
assert_eq!(TRADITIONAL_CHINESE_FAMILIES[0], "Microsoft JhengHei");
for name in ["Microsoft YaHei UI", "Microsoft JhengHei UI", "PMingLiU"] {
assert!(
!SIMPLIFIED_CHINESE_FAMILIES.contains(&name) && !TRADITIONAL_CHINESE_FAMILIES.contains(&name),
"{name} belongs to the interface order, not these lists"
);
}
}
#[test]
fn bundled_ui_fonts_cover_the_ukrainian_alphabet() {
const ALPHABET: &str = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя’«»";
for (name, bytes) in
[("Inter Regular", INTER_REGULAR), ("Inter Medium", INTER_MEDIUM), ("Inter SemiBold", INTER_SEMIBOLD), ("JetBrains Mono", JETBRAINS_MONO_REGULAR)]
{
let font = FontRef::new(bytes).unwrap();
for c in ALPHABET.chars() {
assert!(covers(&font, c), "{name} cannot draw {c}");
}
}
}
#[test]
fn metrics_and_coverage() {
let f = face(resolve("Inter", "Regular").face);
let m = f.metrics(100.0);
assert!(m.ascent > 80.0 && m.ascent < 110.0, "{m:?}");
assert!(m.descent > 10.0 && m.descent < 40.0);
assert!(m.cap_height > 60.0 && m.cap_height < 80.0);
assert!(f.has_char('A') && f.has_char('Ж'));
assert!(!f.has_char('\u{5d0}'), "Inter has no Hebrew");
assert!(f.has_feature(b"liga") || f.has_feature(b"calt"));
}
+448 -748
View File
File diff suppressed because it is too large Load Diff
+359
View File
@@ -0,0 +1,359 @@
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));
}
+2 -2
View File
@@ -54,7 +54,7 @@ impl OutlinePen for Pen {
pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
static C: OnceLock<Mutex<HashMap<(FaceId, u32), Option<Arc<BezPath>>>>> = OnceLock::new();
let c = C.get_or_init(Default::default);
if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, gid)) {
if let Some(v) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(face, gid)) {
return v.clone();
}
let f = fonts::face(face);
@@ -64,7 +64,7 @@ pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen).ok()?;
Some(Arc::new(pen.0))
});
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 50_000 {
m.clear();
}
+4 -4
View File
@@ -36,7 +36,7 @@ pub fn intern(coords: &[(String, f32)]) -> u32 {
if key.windows(2).any(|w| w[0].0 == w[1].0) {
return 0;
}
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
let mut t = table().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(id) = t.by_coords.get(&key) {
return *id;
}
@@ -55,7 +55,7 @@ pub fn coords(id: u32) -> Arc<Vec<(String, f32)>> {
if id == 0 {
return Arc::new(Vec::new());
}
table().lock().unwrap_or_else(|e| e.into_inner()).values.get(id as usize - 1).cloned().unwrap_or_default()
table().lock().unwrap_or_else(std::sync::PoisonError::into_inner).values.get(id as usize - 1).cloned().unwrap_or_default()
}
fn table() -> &'static Mutex<Table> {
@@ -109,7 +109,7 @@ pub fn outline_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Opt
let c = C.get_or_init(Default::default);
// Quantise to 1/100 of a unit so animation frames reuse outlines.
let key: Key = (face, gid, coords.iter().map(|(t, v)| (t.clone(), (v * 100.0).round() as i32)).collect());
if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if let Some(v) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return v.clone();
}
let f = fonts::face(face);
@@ -120,7 +120,7 @@ pub fn outline_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Opt
g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::from(&loc)), &mut pen).ok()?;
Some(Arc::new(pen.0))
});
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 20_000 {
m.clear();
}