mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
Signs such as U+2316 and U+2912 are in no text face. The fallback list had the macOS and Windows symbol fonts (Apple Symbols, Segoe UI Symbol) but nothing a Linux install has, so they drew as empty boxes there even with Noto Sans Symbols 2 or Noto Sans Math installed. Adds Noto Sans Symbols, Noto Sans Symbols 2 (and the Symbols2 spelling some packages use) and Noto Sans Math after them.
279 lines
13 KiB
Rust
279 lines
13 KiB
Rust
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() }).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(®ular), 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");
|
|
}
|
|
|
|
/// 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() };
|
|
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"]);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|