vectorcraft/crates/text/src/tests_sysfonts.rs

187 lines
8.5 KiB
Rust

//! Installed system fonts: found by family name by every lookup, whatever ran before it (#130).
use std::path::{Path, PathBuf};
use std::sync::Arc;
use super::*;
const FAMILY: &str = "Sysfont Sans3";
/// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name).
fn renamed(file: &str) -> Vec<u8> {
renamed_to(file, FAMILY)
}
/// A bundled Source Sans 3 file renamed `family`, which is as long as the original name.
fn renamed_to(file: &str, family: &str) -> Vec<u8> {
assert_eq!(family.len(), "Source Sans 3".len());
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
let mut i = 0;
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
data[i + at..i + at + to.len()].copy_from_slice(&to);
i += at + to.len();
}
}
data
}
/// The fonts in `faces` as one collection (TTC) file.
fn collection(faces: &[Vec<u8>]) -> Vec<u8> {
let mut out = b"ttcf".to_vec();
out.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
out.extend_from_slice(&(faces.len() as u32).to_be_bytes());
let mut base = 12 + 4 * faces.len();
for f in faces {
out.extend_from_slice(&(base as u32).to_be_bytes());
base += f.len();
}
for f in faces {
// Table offsets count from the start of the collection.
let start = out.len() as u32;
let mut f = f.clone();
let tables = u16::from_be_bytes([f[4], f[5]]) as usize;
for r in 0..tables {
let at = 12 + r * 16 + 8;
let off = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) + start;
f[at..at + 4].copy_from_slice(&off.to_be_bytes());
}
out.extend_from_slice(&f);
}
out
}
/// A font folder (with a subfolder, a damaged font and a file that isn't a font) holding
/// [`FAMILY`] Regular and Bold.
fn font_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Sub")).unwrap();
std::fs::write(dir.join("Sysfont-Regular.ttf"), renamed("SourceSans3-Regular.ttf")).unwrap();
std::fs::write(dir.join("Sub/Sysfont-Bold.TTF"), renamed("SourceSans3-Bold.ttf")).unwrap();
std::fs::write(dir.join("damaged.ttf"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap();
std::fs::write(dir.join("readme.txt"), FAMILY).unwrap();
dir
}
fn has(list: &[String], family: &str) -> bool {
list.iter().any(|f| f == family)
}
#[test]
fn every_lookup_by_name_finds_installed_fonts_in_a_fresh_database() {
let dir = font_dir("fresh");
// Each lookup is the first thing a new database is asked. A folder listed twice is read once.
let db = || FontDb::with_font_dirs(vec![dir.clone(), dir.join("Sub/..")]);
let f = db().face("sysfont sans3", "Bold").unwrap();
assert_eq!((f.family.as_str(), f.style.as_str()), (FAMILY, "Bold"));
assert_eq!(f.path().map(std::fs::canonicalize).unwrap().unwrap(), std::fs::canonicalize(dir.join("Sub/Sysfont-Bold.TTF")).unwrap());
assert!(db().has_family(FAMILY));
assert!(has(&db().families(), FAMILY));
assert_eq!(db().styles(FAMILY), ["Regular", "Bold"]);
assert_eq!(db().load_system_fonts(), 2);
assert_eq!(db().find_family("SysfontSans3").as_deref(), Some(FAMILY), "a PostScript-style name");
// Missing fonts stay missing.
let db = db();
assert!(!db.has_family("No Such Font"));
assert_eq!(db.face("No Such Font", "Regular").unwrap().family, FALLBACK_FAMILY);
}
#[test]
fn the_scan_reads_collections_and_loads_fonts_only_when_used() {
let dir = font_dir("collection");
std::fs::remove_file(dir.join("Sysfont-Regular.ttf")).unwrap();
std::fs::remove_file(dir.join("Sub/Sysfont-Bold.TTF")).unwrap();
std::fs::write(dir.join("Sysfont.ttc"), collection(&[renamed("SourceSans3-Regular.ttf"), renamed("SourceSans3-Bold.ttf")])).unwrap();
let db = FontDb::with_font_dirs(vec![dir.clone()]);
assert_eq!(db.styles(FAMILY), ["Regular", "Bold"]);
// Cataloged, not loaded.
assert!(!db.is_loaded(FAMILY));
let bold = db.face(FAMILY, "Bold").unwrap();
assert_eq!((bold.style.as_str(), bold.face_index()), ("Bold", 1));
assert_eq!(db.find(FAMILY, "Regular").unwrap().face_index(), 0, "the collection's faces load together");
}
#[test]
fn rescanning_finds_fonts_installed_since() {
let dir = font_dir("rescan");
let later = dir.join("Later");
let db = FontDb::with_font_dirs(vec![later.clone()]);
assert!(!db.has_family(FAMILY));
let (generation, families) = (db.generation(), db.family_list());
std::fs::create_dir_all(&later).unwrap();
std::fs::copy(dir.join("Sysfont-Regular.ttf"), later.join("Sysfont-Regular.ttf")).unwrap();
assert!(!db.has_family(FAMILY), "the folders are scanned once, not on every miss");
assert_eq!(db.load_system_fonts(), 1);
assert!(db.has_family(FAMILY) && has(&db.family_list(), FAMILY) && !has(&families, FAMILY));
assert_ne!(db.generation(), generation);
}
#[test]
fn a_background_scan_serves_the_first_lookup() {
let dir = font_dir("background");
let db: &'static FontDb = Box::leak(Box::new(FontDb::with_font_dirs(vec![dir])));
db.scan_in_background();
// Waits for the scan when it is still running.
assert!(has(&db.families(), FAMILY));
assert_eq!(db.load_system_fonts(), 2, "a rescan catalogs the two faces again");
}
#[test]
fn the_family_list_is_shared_until_fonts_change() {
let db = FontDb::with_font_dirs(vec![]);
let a = db.family_list();
assert!(Arc::ptr_eq(&a, &db.family_list()));
assert!(db.add_font(renamed("SourceSans3-Regular.ttf")) > 0);
let b = db.family_list();
assert!(!Arc::ptr_eq(&a, &b) && has(&b, FAMILY) && !has(&a, FAMILY));
}
#[test]
fn font_lists_leave_out_hidden_system_families_which_still_resolve() {
// macOS names the faces it keeps for its own interface with a leading "." (".SF NS").
const HIDDEN: &str = ".Sysfont Sans";
const DOTTED: &str = "Sysfont.Sans3";
let dir = font_dir("hidden");
std::fs::write(dir.join("Hidden.ttf"), renamed_to("SourceSans3-Regular.ttf", HIDDEN)).unwrap();
std::fs::write(dir.join("Dotted.ttf"), renamed_to("SourceSans3-Regular.ttf", DOTTED)).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
let listed = db.menu_family_list();
assert!(has(&listed, FAMILY) && has(&listed, FALLBACK_FAMILY));
assert!(!has(&listed, HIDDEN), "a leading dot hides a family: {listed:?}");
assert!(has(&listed, DOTTED), "a dot elsewhere doesn't");
assert_eq!(listed.len() + 1, db.family_list().len(), "only the hidden family is left out");
assert!(Arc::ptr_eq(&listed, &db.menu_family_list()), "shared until the fonts change");
// Documents and fallbacks that name it still find it.
assert!(has(&db.families(), HIDDEN) && db.has_family(HIDDEN));
assert_eq!(db.find_family(HIDDEN).as_deref(), Some(HIDDEN));
assert_eq!(db.face(HIDDEN, "Regular").unwrap().family, HIDDEN);
// Loading it changed the fonts: the list is built again, still without it.
let again = db.menu_family_list();
assert!(!Arc::ptr_eq(&listed, &again) && *listed == *again);
}
#[test]
fn installed_styles_of_a_loaded_family_load_when_asked_for() {
let dir = font_dir("styles");
let db = FontDb::with_font_dirs(vec![dir]);
// The family is loaded with only its Regular face (as when a bundled family is also installed).
assert_eq!(db.add_font(renamed("SourceSans3-Regular.ttf")), 1);
assert_eq!(db.face(FAMILY, "Bold").unwrap().style, "Bold");
// A style nobody has still gets the closest one.
assert_eq!(db.face(FAMILY, "Black").unwrap().style, "Bold");
}
/// Documents name fonts by PostScript name, and families can hold hyphens: the installed face of
/// that exact name gives the family and style, however the name splits.
#[test]
fn installed_faces_are_found_by_postscript_name() {
let dir = font_dir("postscript");
let db = FontDb::with_font_dirs(vec![dir]);
// The renamed Source Sans 3 files keep their PostScript names.
assert_eq!(db.by_postscript_name("SourceSans3-Bold"), Some((FAMILY.to_string(), "Bold".to_string())));
assert_eq!(db.by_postscript_name("sourcesans3-regular"), Some((FAMILY.to_string(), "Regular".to_string())), "any case");
assert_eq!(db.by_postscript_name("Rounded-X-Mplus-1c-black"), None);
}