Files
figcraft/crates/text/src/fontdb_tests.rs
T

443 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
fn pending(styles: &[&str]) -> FontDb {
let mut db = FontDb::default();
let entries =
styles.iter().map(|s| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: (*s).into(), postscript: None }).collect();
db.pending.insert("cjk ui".into(), ("CJK UI".into(), entries));
db
}
fn left(db: &FontDb) -> Vec<String> {
db.pending.get("cjk ui").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect()).unwrap_or_default()
}
#[test]
fn find_one_reads_only_the_nearest_style() {
// The files don't exist: what matters is which entry is tried, and that the rest stay pending.
let mut db = pending(&["Bold", "Light", "Regular", "Italic"]);
assert!(db.find_one("CJK UI", "Regular").is_none());
assert_eq!(left(&db), ["Bold", "Light", "Italic"]);
assert!(db.has_family("cjk ui"), "the family is still offered");
// No exact name: the nearest weight, upright before italic.
let mut db = pending(&["W6", "Italic", "W3", "Bold"]);
db.find_one("CJK UI", "Regular");
assert_eq!(left(&db), ["W6", "Italic", "Bold"]);
// `find` reads the whole family.
db.find("CJK UI", "Regular");
assert!(left(&db).is_empty() && !db.has_family("cjk ui"));
assert_eq!(pending_weight("W3"), 300);
assert_eq!(pending_weight("SemiBold"), 600);
assert_eq!(pending_weight("W12"), 400);
}
#[test]
fn system_fonts_are_scanned_once() {
// Racing callers (the desktop's background scan, on-demand lookups, tests) share one walk.
let threads: Vec<_> = (0..4).map(|_| std::thread::spawn(scan_system_fonts)).collect();
for t in threads {
t.join().unwrap();
}
scan_system_fonts_on_demand();
let _ = db();
scan_system_fonts();
let walked = !system_fonts_opted_out();
assert_eq!(SCANS.load(Ordering::Relaxed), usize::from(walked));
assert_eq!(system_fonts_scanned(), walked);
}
#[test]
fn the_scan_reads_names_like_a_whole_file_read() {
// Every installed font file (none on CI): the partial read finds the same faces and names.
let mut files = Vec::new();
for dir in system_font_dirs() {
let mut stack = vec![dir];
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.is_file()
&& p.extension().is_some_and(|x| ["ttf", "otf", "ttc", "otc"].contains(&x.to_ascii_lowercase().to_str().unwrap_or("")))
{
files.push(p);
}
}
}
}
for p in files {
let Ok(whole) = std::fs::read(&p) else { continue };
assert_eq!(faces_in(&index_bytes(&p, whole.len()).unwrap()), faces_in(&whole), "{}", p.display());
}
}
#[test]
fn macos_font_assets_are_found_in_the_asset_store() {
// A store laid out like macOS's: fonts downloaded on demand sit in `<id>.asset/AssetData`
// folders under `com_apple_MobileAsset_Font*`; other assets are never walked. Untested on a Mac.
struct TempDir(std::path::PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let tmp = TempDir(std::env::temp_dir().join(format!("figcraft-font-assets-{}", std::process::id())));
let store = tmp.0.join("AssetsV2");
let fonts = store.join("com_apple_MobileAsset_Font7");
let data = |kind: &std::path::Path| kind.join("0f1e2d.asset").join("AssetData");
for dir in [data(&fonts), data(&store.join("com_apple_MobileAsset_VoiceServices"))] {
std::fs::create_dir_all(dir).unwrap();
}
// A file under a font-like name is listed but walks to nothing.
std::fs::write(store.join("com_apple_MobileAsset_Font7.plist"), b"not a folder").unwrap();
let found = font_asset_dirs(&store);
assert!(found.contains(&fonts) && !found.iter().any(|d| d.ends_with("com_apple_MobileAsset_VoiceServices")), "{found:?}");
assert!(font_asset_dirs(&tmp.0.join("missing")).is_empty());
// With an installed font to copy in (none on CI), the scan finds it only in the font folder.
let installed = system_font_dirs()
.into_iter()
.find_map(|d| std::fs::read_dir(d).into_iter().flatten().flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "ttf")));
let Some(font) = installed else { return };
for kind in ["com_apple_MobileAsset_Font7", "com_apple_MobileAsset_VoiceServices"] {
std::fs::copy(&font, data(&store.join(kind)).join("Font.ttf")).unwrap();
}
let mut pending = BTreeMap::new();
for dir in found {
scan_dir(&dir, 0, &mut pending);
}
let paths: Vec<_> = pending.values().flat_map(|(_, p)| p.iter().map(|p| p.path.clone())).collect();
assert!(!paths.is_empty() && paths.iter().all(|p| p.starts_with(&fonts)), "{paths:?}");
}
#[cfg(unix)]
#[test]
fn the_scan_skips_named_pipes() {
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const INPUT: &str = "FIGCRAFT_TEST_PIPE_FONT_DIR";
if let Some(dir) = std::env::var_os(INPUT) {
let mut pending = BTreeMap::new();
scan_dir(std::path::Path::new(&dir), 0, &mut pending);
assert!(pending.is_empty());
std::fs::write(std::path::Path::new(&dir).join("complete"), b"scanned").unwrap();
return;
}
struct TempDir(std::path::PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let path = std::env::temp_dir().join(format!("figcraft-font-pipe-{}", std::process::id()));
std::fs::create_dir(&path).unwrap();
let dir = TempDir(path);
// POSIX mkfifo is only a test fixture helper, never a runtime font dependency.
let status = match Command::new("mkfifo").arg(dir.0.join("pipe.ttf")).status() {
Ok(status) => status,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
eprintln!("skipping named-pipe fixture: POSIX mkfifo is unavailable");
return;
}
Err(error) => panic!("cannot create named-pipe fixture: {error}"),
};
assert!(status.success(), "mkfifo failed");
std::os::unix::fs::symlink("pipe.ttf", dir.0.join("linked.otf")).unwrap();
std::os::unix::fs::symlink("missing.ttf", dir.0.join("broken.ttf")).unwrap();
// A regression must fail without leaving a blocked test thread behind. Run only this
// test in a child, then kill and reap it if opening the pipe blocks the scanner.
struct Child(std::process::Child);
impl Drop for Child {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
let module = module_path!().split_once("::").unwrap().1;
let mut child = Child(
Command::new(std::env::current_exe().unwrap())
.args(["--exact", &format!("{module}::the_scan_skips_named_pipes"), "--nocapture"])
.env(INPUT, &dir.0)
.stdout(Stdio::null())
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some(status) = child.0.try_wait().unwrap() {
assert!(status.success(), "font-scan child failed: {status}");
break;
}
assert!(Instant::now() < deadline, "font scan blocked on a named pipe");
// std::process has no portable timed wait; a bounded poll keeps the regression finite.
std::thread::sleep(Duration::from_millis(10));
}
// An exact-filter typo otherwise makes libtest report success after running zero tests.
assert_eq!(std::fs::read(dir.0.join("complete")).unwrap(), b"scanned");
}
#[test]
fn fallback_faces_follow_the_runs_style() {
// A bold run's missing characters come from each fallback family's face nearest Bold, not its
// Regular, so Hangul or kana in a bold Latin run come out bold too. Same families, same order.
scan_system_fonts();
let mut w = db().write().unwrap();
let regular = w.fallbacks();
let bold = w.fallbacks_for("Bold");
let families = |v: &[Arc<Face>]| v.iter().map(|f| f.family.clone()).collect::<Vec<_>>();
assert_eq!(families(&regular), families(&bold));
for (r, b) in regular.iter().zip(&bold) {
assert!(b.weight >= r.weight, "{}: {} is lighter than {}", b.family, b.style, r.style);
}
}
#[test]
fn every_platform_has_a_symbol_font_in_the_fallbacks() {
// Symbols such as ⌖ (U+2316) and ⤒ (U+2912) are in no text face (#1156): macOS has Apple
// Symbols, Windows Segoe UI Symbol, and Linux the Noto symbol and math fonts (fontconfig names
// "Noto Sans Symbols 2"; some packages name it "Noto Sans Symbols2").
for family in ["Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Sans Symbols2", "Noto Sans Math"] {
assert!(FALLBACK_FAMILIES.contains(&family), "{family} is a fallback");
}
// Text faces come first: a symbol font only draws what they lack.
let at = |f: &str| FALLBACK_FAMILIES.iter().position(|x| *x == f).unwrap();
assert!(at("Noto Sans") < at("Noto Sans Symbols") && at("DejaVu Sans") < at("Noto Sans Math"));
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn user_font_dirs_follow_xdg_data_home() {
use std::ffi::OsString;
use std::path::PathBuf;
let dirs = |home: Option<&str>, data: Option<&str>| unix_user_font_dirs(home.map(OsString::from), data.map(OsString::from));
let paths = |items: &[&str]| items.iter().map(PathBuf::from).collect::<Vec<_>>();
assert_eq!(dirs(Some("/home/user"), Some("/custom user data")), paths(&["/home/user/.fonts", "/custom user data/fonts"]));
for data in [None, Some(""), Some("relative/data")] {
assert_eq!(dirs(Some("/home/user"), data), paths(&["/home/user/.fonts", "/home/user/.local/share/fonts"]));
}
assert_eq!(dirs(None, Some("/custom-data")), paths(&["/custom-data/fonts"]));
assert_eq!(dirs(None, None), paths(&[]));
assert_eq!(dirs(None, Some("relative")), paths(&[]));
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn user_font_dirs_preserve_non_utf8_paths() {
use std::ffi::OsString;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let dirs = unix_user_font_dirs(Some(OsString::from_vec(b"/home/\xff".to_vec())), Some(OsString::from_vec(b"/data/\xfe".to_vec())));
assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts");
assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts");
}
#[test]
fn the_scan_reads_font_files_without_an_extension() {
// #298: Adobe Fonts keeps the fonts it activates in hidden files named by number. Any installed
// font stands in for one (none on CI); a file that isn't a font is left alone.
let Some(font) = system_font_dirs().into_iter().find_map(|d| {
std::fs::read_dir(d)
.ok()?
.flatten()
.map(|e| e.path())
.find(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("ttf") || x.eq_ignore_ascii_case("otf")))
}) else {
eprintln!("skipping: no installed font to stand in for an activated one");
return;
};
let dir = std::env::temp_dir().join(format!("figcraft-font-bare-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".r")).unwrap();
std::fs::copy(&font, dir.join(".r/.12345")).unwrap();
std::fs::write(dir.join(".r/entitlements"), b"<xml/>").unwrap();
let mut found = BTreeMap::new();
scan_dir(&dir, 0, &mut found);
let _ = std::fs::remove_dir_all(&dir);
let families: Vec<&String> = found.values().map(|(f, _)| f).collect();
assert_eq!(found.values().map(|(_, v)| v.len()).sum::<usize>(), faces_in(&std::fs::read(&font).unwrap()).len(), "{families:?}");
assert!(!families.is_empty(), "{} is read as a font", font.display());
assert!(found.values().flat_map(|(_, v)| v).all(|p| p.path.ends_with(".r/.12345")));
#[cfg(target_os = "macos")]
assert!(system_font_dirs().iter().any(|d| d.ends_with("Adobe/CoreSync/plugins/livetype")), "Adobe Fonts' folder is scanned");
}
/// A rescan offers new families and the styles installed since, and never a file twice.
#[test]
fn a_rescan_offers_fonts_installed_since() {
let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into(), postscript: None };
let mut db = pending(&["Regular"]);
let found = |fams: &[(&str, &[&str])]| -> BTreeMap<String, (String, Vec<Pending>)> {
fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect()
};
// A new family is added; a pending one takes the walk's files (a new style); both changed.
let r = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])]));
assert_eq!(r.added, ["Brand Sans"]);
assert_eq!(r.changed, ["Brand Sans", "CJK UI"]);
assert!(db.has_family("brand sans"));
assert_eq!(left(&db), ["Regular", "Bold"]);
// A loaded family keeps its faces and gets only the files it hasn't read, once.
db.families.insert("brand sans".into(), ("Brand Sans".into(), Vec::new()));
db.pending.remove("brand sans");
db.loaded_files.insert(("no-such-dir/Regular.ttf".into(), 0));
let same = db.merge_rescan(found(&[("Brand Sans", &["Regular"])]));
assert_eq!(same, Rescan::default());
assert!(!db.pending.contains_key("brand sans"));
let bold = db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])]));
assert_eq!((bold.added.len(), bold.changed), (0, vec!["Brand Sans".to_string()]));
let waiting = |db: &FontDb| db.pending.get("brand sans").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect::<Vec<_>>()).unwrap_or_default();
assert_eq!(waiting(&db), ["Black"]);
assert_eq!(db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])), Rescan::default());
assert_eq!(waiting(&db), ["Black"]);
}
/// #1246: the first scan offers the installed styles an embedded family lacks (release builds embed
/// Poppins Regular, Italic and Bold; an installed SemiBold and Medium went unseen). Embedded styles win.
#[test]
fn the_scan_offers_installed_styles_an_embedded_family_lacks() {
// Any embedded or installed font stands in for the embedded face (none without craft-fonts on CI).
let stand_in = CRAFT_FONTS.first().map(|f| f.bytes.to_vec()).or_else(|| {
let mut installed = BTreeMap::new();
for dir in system_font_dirs() {
scan_dir(&dir, 0, &mut installed);
}
installed.values().flat_map(|(_, v)| v).find_map(|p| std::fs::read(&p.path).ok())
});
let Some(mut face) = stand_in.and_then(|b| Face::from_bytes(FontBytes::Shared(Arc::new(b)), 0, Some("Brand Sans"), Some("Regular"))) else {
eprintln!("skipping: no font to stand in for an embedded one");
return;
};
// A variable stand-in's named instances aren't this family's styles.
face.instances.clear();
let mut db = FontDb::default();
db.add(face);
let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into(), postscript: None };
let found =
|family: &str, styles: &[&str]| BTreeMap::from([(family.to_lowercase(), (family.to_string(), styles.iter().map(|s| entry(s)).collect()))]);
db.merge_scan(found("Brand Sans", &["Regular"]));
assert!(!db.pending.contains_key("brand sans"), "an embedded style isn't read from disk again");
db.merge_scan(found("Brand Sans", &["Regular", "SemiBold", "Medium"]));
let waiting: Vec<String> = db.pending.get("brand sans").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect()).unwrap_or_default();
assert_eq!(waiting, ["SemiBold", "Medium"]);
assert_eq!(db.families(), ["Brand Sans"]);
db.merge_scan(found("CJK UI", &["Regular", "Bold"]));
assert_eq!(left(&db), ["Regular", "Bold"]);
}
/// Font file bytes add their faces once; junk adds nothing.
#[test]
fn font_bytes_load_into_the_db() {
assert!(load_font_bytes(b"not a font".to_vec()).is_empty());
assert!(load_font_bytes(Vec::new()).is_empty());
// With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added,
// and loading the same file again returns it without adding its faces twice.
if let Some(f) = CRAFT_FONTS.first() {
let families = load_font_bytes(f.bytes.to_vec());
assert_eq!(families.len(), 1);
assert!(families.iter().all(|f| has_family(f)));
let faces = db().read().unwrap().face_count();
assert_eq!(load_font_bytes(f.bytes.to_vec()), families);
assert_eq!(db().read().unwrap().face_count(), faces);
}
}
/// Apple's system font answers to the names designs use for it (SF Pro, SF UI Text, …), unless a
/// font of that name is installed.
#[test]
fn apple_system_font_names_find_the_system_font() {
let mut db = FontDb::default();
db.pending.insert(
"system font".into(),
("System Font".into(), vec![Pending { path: "no-such-dir/SFNS.ttf".into(), index: 0, style: "Regular".into(), postscript: None }]),
);
for name in ["SF Pro", "SF Pro Text", "SF Pro Display", "SF UI Text", "SF UI Display", ".SF NS Text", "San Francisco"] {
assert!(db.has_family(name), "{name}");
}
assert!(!db.has_family("SF Mono"));
db.pending.insert("sf pro".into(), ("SF Pro".into(), Vec::new()));
assert_eq!(db.key("SF Pro"), "sf pro", "an installed SF Pro is itself");
assert_eq!(db.key("SF UI Text"), "system font");
}
#[test]
fn hidden_apple_families_take_public_names() {
assert_eq!(public_family(".SF Compact").as_deref(), Some("SF Compact"));
assert_eq!(public_family(".SF Compact Rounded").as_deref(), Some("SF Compact Rounded"));
assert_eq!(public_family(".SF NS Rounded").as_deref(), Some("SF Pro Rounded"));
assert_eq!(public_family(".Keyboard"), None);
assert_eq!(public_family("Inter").as_deref(), Some("Inter"));
}
/// A named style keeps its own optical size whatever the text size (existing documents render as
/// before); a custom `opsz` value (what a `.sketch` import sets) wins. Needs a variable font with an
/// optical size axis and named instances: macOS's or Windows' system font.
#[test]
fn named_styles_keep_their_optical_size() {
let Some(bytes) = ["/System/Library/Fonts/SFNS.ttf", "C:/Windows/Fonts/SegUIVar.ttf"].iter().find_map(|p| std::fs::read(p).ok()) else {
return;
};
let face = Face::from_bytes(FontBytes::Shared(std::sync::Arc::new(bytes)), 0, None, None).unwrap();
let opsz = face.axes.iter().position(|a| a.tag == "opsz").unwrap();
let inst = face.instances.first().unwrap();
let mut style = figcraft_doc::TextStyle { font_style: inst.name.clone(), ..Default::default() };
for size in [1.0, 12.0, 40.0, 200.0] {
style.font_size = size;
assert_eq!(face.axis_values(&style)[opsz], inst.coords[opsz], "{} at {size}", inst.name);
}
style.variations.insert("opsz".into(), 1e9);
assert_eq!(face.axis_values(&style)[opsz], face.axes[opsz].max);
}
/// The tracking a font's `trak` table gives at a size (Apple's fonts carry one; the platform text
/// engine applies it on its own), in pixels, interpolated between the sizes the table lists.
#[test]
fn trak_tracking_by_size() {
let Ok(bytes) = std::fs::read("/System/Library/Fonts/SFNS.ttf") else { return };
let face = Face::from_bytes(FontBytes::Shared(std::sync::Arc::new(bytes)), 0, None, None).unwrap();
// Track 0 of SF: −12 units at 13 pt, 0 at 12 pt, −40 at 16 pt (2048 units per em).
assert!((face.tracking(13.0) - (-12.0 * 13.0 / 2048.0)).abs() < 1e-9, "{}", face.tracking(13.0));
assert!(face.tracking(12.0).abs() < 1e-9);
assert!((face.tracking(12.5) - (-6.0 * 12.5 / 2048.0)).abs() < 1e-9);
// Past the table's ends: its last values.
assert!(face.tracking(500.0).is_finite());
let plain = crate::fontdb::db().write().unwrap().default_face();
if let Some(f) = plain.filter(|f| f.family != "System Font") {
let _ = f.tracking(12.0);
}
}
/// A face is found by its PostScript name (ignoring case), loaded or only scanned: the family and
/// style it is offered under.
#[test]
fn faces_by_postscript_name() {
let mut db = FontDb::default();
db.pending.insert(
"brand sans".into(),
(
"Brand Sans".into(),
vec![Pending { path: "no-such-dir/b.ttf".into(), index: 0, style: "Bold".into(), postscript: Some("BrandSans-Bold".into()) }],
),
);
assert_eq!(db.postscript_face("brandsans-bold"), Some(("Brand Sans".to_string(), "Bold".to_string())));
assert_eq!(db.postscript_face("BrandSans-Light"), None);
if let Some(f) = CRAFT_FONTS.first()
&& let Some(face) = Face::from_bytes(FontBytes::Static(f.bytes), 0, Some(f.family), Some(f.style))
&& let Some(ps) = face.postscript.clone()
{
db.add(face);
assert_eq!(db.postscript_face(&ps), Some((f.family.to_string(), f.style.to_string())));
}
assert!(postscript_face("").is_none());
}
#[test]
fn every_platform_has_a_thai_font_in_the_fallbacks() {
// Thai typed into a Latin layer (new text starts in Inter) is drawn by the first fallback that
// has it: Noto Sans Thai (craft-fonts, Linux), Leelawadee UI or Tahoma (Windows), Thonburi
// (macOS). They come after the Latin faces, which go on drawing Latin text.
let at = |family: &str| FALLBACK_FAMILIES.iter().position(|f| *f == family);
let latin = at("Segoe UI").unwrap();
for thai in ["Noto Sans Thai", "Leelawadee UI", "Tahoma", "Thonburi"] {
assert!(at(thai).is_some_and(|i| i > latin), "{thai}");
}
}