mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 06:27:11 -06:00
Merge remote-tracking branch 'origin/main' into pr
# Conflicts: # crates/text/src/fonts.rs # crates/text/src/layout.rs
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 = ¢er.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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user