vectorcraft/crates/text/src/tests_sysfonts.rs

754 lines
37 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.

//! 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::*;
use crate::fontdb::fonts_outside;
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 fonts_without_outlines_the_engine_draws_are_left_out() {
let dir = font_dir("outlines");
// The Bold's TrueType outlines under another tag, as in fonts that keep their outlines in
// Apple's `hvgl` table or hold bitmaps only. The tag keeps the table directory sorted.
let bold = dir.join("Sub/Sysfont-Bold.TTF");
let mut data = std::fs::read(&bold).unwrap();
let tables = u16::from_be_bytes([data[4], data[5]]) as usize;
let rec = (0..tables).map(|i| 12 + 16 * i).find(|&r| &data[r..r + 4] == b"glyf").unwrap();
data[rec..rec + 4].copy_from_slice(b"gxyz");
std::fs::write(&bold, data).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
assert_eq!(db.styles(FAMILY), ["Regular"]);
assert_eq!(db.load_system_fonts(), 1);
}
/// A character no font covered is looked for again once a rescan finds a font that has it (#992).
#[test]
fn rescanning_retries_characters_no_font_covered() {
// A private-use character none of the bundled fonts has.
const C: char = '\u{F0000}';
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-retry", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let db = FontDb::with_font_dirs(vec![dir.clone()]);
assert!(db.face_covering(C).is_none());
// Source Sans 3 Regular renamed, its character map replaced by one holding only `C`.
let base = named(&[(3, EN, 1, "Retry Sans"), (3, EN, 2, "Regular"), (3, EN, 6, "RetrySans-Regular")]);
let font = skrifa::FontRef::new(&base).unwrap();
let mut builder = write_fonts::FontBuilder::new();
builder.add_raw(write_fonts::types::Tag::new(b"cmap"), crate::embed::cmap(&[(C as u32, 1)]).unwrap());
for r in font.table_directory.table_records() {
let tag = write_fonts::types::Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag()).unwrap().as_bytes());
}
}
std::fs::write(dir.join("Retry.ttf"), builder.build()).unwrap();
assert!(db.face_covering(C).is_none(), "the miss is remembered until the fonts are scanned again");
assert_eq!(db.load_system_fonts(), 1);
assert_eq!(db.face_covering(C).unwrap().family, "Retry Sans");
}
#[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);
}
/// Windows lists fonts installed as shortcuts, or by programs into their own folders, by their
/// files' full paths: those files are scanned too (#443).
#[test]
fn a_font_file_named_by_itself_is_scanned() {
let dir = font_dir("file");
let db = FontDb::with_font_dirs(vec![dir.join("Sub/Sysfont-Bold.TTF"), dir.join("No Such Font.ttf")]);
assert_eq!(db.styles(FAMILY), ["Bold"]);
assert_eq!(db.load_system_fonts(), 1);
}
#[test]
fn registered_fonts_outside_the_font_folders_are_their_full_paths() {
let dir = std::env::temp_dir().join("Fonts");
let elsewhere = std::env::temp_dir().join("Downloads").join("Montserrat-Regular.ttf");
let registered = [
// A file in the Windows font folder, by name.
"arial.ttf".into(),
// A file in a font folder (ignoring case).
dir.join("Lato-Regular.ttf").to_string_lossy().to_uppercase(),
elsewhere.to_string_lossy().into_owned(),
elsewhere.to_string_lossy().into_owned(),
];
assert_eq!(fonts_outside(registered, &[dir]), [elsewhere]);
}
/// Fonts installed while the app runs are found when it comes back to the front, without
/// scanning every time (#443).
#[test]
fn the_database_tells_when_fonts_were_installed_or_removed_since_its_scan() {
let dir = font_dir("changed");
let db = FontDb::with_font_dirs(vec![dir.clone(), dir.join("Later")]);
assert!(!db.installed_fonts_changed(), "nothing to compare with before the first scan");
assert!(!db.has_family("Other Sans 33"));
assert!(!db.installed_fonts_changed());
// Past the file system's clock tick, so the folders' times differ from the scan's.
let tick = || std::thread::sleep(std::time::Duration::from_millis(50));
tick();
std::fs::write(dir.join("Sub/Other.ttf"), renamed_to("SourceSans3-Regular.ttf", "Other Sans 33")).unwrap();
assert!(db.installed_fonts_changed(), "a font installed into a subfolder");
assert_eq!(db.load_system_fonts(), 3);
assert!(db.has_family("Other Sans 33") && !db.installed_fonts_changed());
tick();
std::fs::create_dir_all(dir.join("Later")).unwrap();
assert!(db.installed_fonts_changed(), "a font folder that didn't exist");
db.load_system_fonts();
tick();
std::fs::remove_file(dir.join("Sysfont-Regular.ttf")).unwrap();
assert!(db.installed_fonts_changed(), "a font removed");
assert_eq!(db.load_system_fonts(), 2);
assert_eq!(db.styles(FAMILY), ["Bold"]);
}
/// A font a font service loads from a file without an extension, outside the font folders.
fn service_font() -> PathBuf {
std::env::temp_dir().join(format!("vc-sysfonts-{}-service", std::process::id())).join(".29457")
}
/// The fonts a font service (Adobe Fonts on Windows, through DirectWrite) loads in place are
/// cataloged: named by the platform's lister, read whatever their file is called (#579).
#[test]
fn fonts_the_platform_lists_outside_the_font_folders_are_cataloged() {
let file = service_font();
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, renamed_to("SourceSans3-Regular.ttf", "Service Sans3")).unwrap();
// The lister's files outside the font folders are scanned with them, each once (a collection
// lists its file once per face).
fn lister() -> Vec<String> {
vec![service_font().to_string_lossy().into_owned(); 2]
}
set_platform_font_files(lister);
let dirs = system_font_dirs();
assert_eq!(dirs.iter().filter(|d| **d == file).count(), 1, "{dirs:?}");
// A file named by itself is read without a font's extension, one in a folder isn't.
let db = FontDb::with_font_dirs(vec![file.clone()]);
assert_eq!(db.styles("Service Sans3"), ["Regular"]);
assert!(!FontDb::with_font_dirs(vec![file.parent().unwrap().to_path_buf()]).has_family("Service Sans3"));
}
#[test]
fn a_resource_fork_gives_up_its_font_files() {
let (regular, bold) = (renamed("SourceSans3-Regular.ttf"), renamed("SourceSans3-Bold.ttf"));
let fork = crate::suitcase::fork_of(&[regular.clone(), bold.clone()]);
assert_eq!(crate::suitcase::sfnt_resources(&fork), [regular.as_slice(), bold.as_slice()]);
// Damage keeps what was read before it, and never panics.
for cut in [0, 3, 16, 300, fork.len() - 1] {
let _ = crate::suitcase::sfnt_resources(&fork[..cut]);
}
assert!(crate::suitcase::sfnt_resources(&[0xFF; 600]).is_empty());
// A fork can't make the scan read more than so many fonts.
assert_eq!(crate::suitcase::sfnt_resources(&crate::suitcase::fork_of(&vec![b"font".to_vec(); 300])).len(), 256);
}
/// Office's fonts: a file with nothing in it but a resource fork holding the fonts, named
/// without an extension. Only macOS keeps a resource fork in a file.
#[cfg(target_os = "macos")]
#[test]
fn the_scan_reads_suitcase_fonts() {
let dir = font_dir("suitcase");
std::fs::remove_file(dir.join("Sysfont-Regular.ttf")).unwrap();
std::fs::remove_file(dir.join("Sub/Sysfont-Bold.TTF")).unwrap();
let suitcase = dir.join("Sysfont Sans3");
std::fs::write(&suitcase, b"").unwrap();
std::fs::write(
suitcase.join("..namedfork/rsrc"),
crate::suitcase::fork_of(&[renamed("SourceSans3-Regular.ttf"), renamed("SourceSans3-Bold.ttf")]),
)
.unwrap();
// A file with nothing in either fork is no font.
std::fs::write(dir.join("empty"), b"").unwrap();
let db = FontDb::with_font_dirs(vec![dir.clone()]);
assert_eq!(db.styles(FAMILY), ["Regular", "Bold"]);
let bold = db.face(FAMILY, "Bold").unwrap();
assert_eq!((bold.family.as_str(), bold.style.as_str()), (FAMILY, "Bold"));
assert_eq!(bold.path(), Some(suitcase.as_path()));
assert_eq!(db.find_family("SysfontSans3").as_deref(), Some(FAMILY), "by a PostScript-style name, as an EPS names it");
}
/// The bundled Source Sans 3 Regular with a `name` table of `records` (platform, language, name id,
/// text): Windows Unicode (UTF-16) or Mac Roman (ASCII here).
fn named(records: &[(u16, u16, u16, &str)]) -> Vec<u8> {
let mut records = records.to_vec();
records.sort_by_key(|r| (r.0, r.1, r.2));
let (mut name, mut strings) = (vec![], vec![]);
let n = records.len() as u16;
for v in [0, n, 6 + 12 * n] {
name.extend_from_slice(&v.to_be_bytes());
}
for &(platform, lang, id, s) in &records {
let bytes: Vec<u8> = if platform == 3 { s.encode_utf16().flat_map(u16::to_be_bytes).collect() } else { s.as_bytes().to_vec() };
let encoding = if platform == 3 { 1 } else { 0 };
for v in [platform, encoding, lang, id, bytes.len() as u16, strings.len() as u16] {
name.extend_from_slice(&v.to_be_bytes());
}
strings.extend(bytes);
}
name.extend(strings);
let base = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf")).unwrap();
let font = skrifa::FontRef::new(&base).unwrap();
let mut builder = write_fonts::FontBuilder::new();
builder.add_raw(write_fonts::types::Tag::new(b"name"), name);
for r in font.table_directory.table_records() {
let tag = write_fonts::types::Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag()).unwrap().as_bytes());
}
}
builder.build()
}
const EN: u16 = 0x0409;
const JA: u16 = 0x0411;
/// Copying a file a search offers into a folder the scan reads makes the font resolve exactly from
/// that file, and a file it doesn't offer doesn't: the search matches fonts as `resolve` finds
/// them. Each case resolves the font, copies the file into a scanned folder, rescans and resolves
/// again, as the app does.
#[test]
fn wanted_fonts_match_as_resolve_finds_them() {
let one = named(&[(3, EN, 1, "Parity One"), (3, EN, 2, "Regular"), (3, EN, 6, "ParityOne-Regular")]);
let two = named(&[
(3, EN, 1, "Parity Two"),
(3, EN, 2, "Regular"),
(3, EN, 6, "ParityTwo-Regular"),
(3, EN, 16, "Parity Two"),
(3, JA, 16, "パリティ二"),
(3, EN, 17, "Regular"),
]);
// Laid out like Hiragino Sans W3: a legacy family per weight, paired with its legacy style, and
// a PostScript name that reads as the legacy family.
let three = |postscript: &str| {
named(&[
(1, 0, 1, "Parity Three"),
(1, 0, 2, "W3"),
(3, EN, 1, "Parity Three W3"),
(3, EN, 2, "Regular"),
(3, EN, 6, postscript),
(3, EN, 16, "Parity Three"),
(3, EN, 17, "W3"),
(3, JA, 1, "パリティ三 W3"),
(3, JA, 2, "Regular"),
(3, JA, 16, "パリティ三"),
(3, JA, 17, "W3"),
])
};
let (three, three_ps) = (three("ParityThree-W3"), three("PThree-W3"));
// A PostScript name that is the family's name.
let findme = named(&[(3, EN, 1, "Findme"), (3, EN, 2, "Regular"), (3, EN, 6, "Findme")]);
let psdiff = named(&[(3, EN, 1, "Psdiff Sans"), (3, EN, 2, "Regular"), (3, EN, 6, "PsdiffSans-Regular")]);
let variable = crate::test_fonts::variable_font().unwrap();
let four = |style: &str, ja_style: &str| {
named(&[
(3, EN, 1, "Parity Four"),
(3, EN, 2, style),
(3, EN, 6, &format!("ParityFour-{style}")),
(3, EN, 16, "Parity Four"),
(3, EN, 17, style),
(3, JA, 16, "パリティ四"),
(3, JA, 17, ja_style),
])
};
let (four_regular, four_bold) = (four("Regular", "標準"), four("Bold", "太字"));
let five = named(&[(3, EN, 1, "Parity Five"), (3, EN, 2, "Bold"), (3, EN, 6, "ParityFive-Bold"), (3, EN, 17, "Bold"), (3, JA, 17, "太字")]);
// (family, style, Parity Four Regular installed, candidate file, provides it)
let cases: Vec<(&str, &str, bool, &[u8], bool)> = vec![
("Parity One", "Regular", false, &one, true),
("parity one", "REGULAR", false, &one, true),
("Parity One", "Bold", false, &one, false),
("Parity One", "Regular", false, &four_bold, false),
("パリティ二", "Regular", false, &two, true),
("パリティ二", "Bold", false, &two, false),
("Parity Three W3", "Regular", false, &three_ps, true),
("Parity Three W3", "Bold", false, &three_ps, false),
("PThree-W3", "Bold", false, &three_ps, true),
// The legacy family reads as the PostScript name, which names the W3 face.
("Parity Three W3", "Bold", false, &three, true),
("パリティ三", "W3", false, &three, true),
("パリティ三", "Bold", false, &three, false),
("Findme", "Bold", false, &findme, false),
("Find-me", "Regular", false, &findme, true),
// A family's name goes before a PostScript name that reads the same.
("Find-me", "Bold", false, &findme, false),
("PsdiffSans-Regular", "Bold", false, &psdiff, true),
("Varitest Sans", "SemiBold", false, &variable, true),
("Varitest Sans", "Normal", false, &variable, true),
("VaritestSans-Bold", "Regular", false, &variable, true),
("Varitest Sans", "Black", false, &variable, false),
("Parity Five", "太字", false, &five, true),
("Parity Four", "Bold", true, &four_bold, true),
("パリティ四", "Bold", true, &four_bold, true),
("Parity Four", "太字", true, &four_bold, false),
("Parity Four", "Bold", true, &one, false),
];
for (i, (family, style, installed, candidate, provides)) in cases.into_iter().enumerate() {
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-wanted-{i}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (base, new) = (dir.join("base"), dir.join("new"));
std::fs::create_dir_all(&base).unwrap();
std::fs::create_dir_all(&new).unwrap();
if installed {
std::fs::write(base.join("ParityFour-Regular.ttf"), &four_regular).unwrap();
}
let db = FontDb::with_font_dirs(vec![base, new.clone()]);
let (face, m) = db.resolve(family, style).unwrap();
assert_eq!(m, if installed { FontMatch::Style } else { FontMatch::Missing }, "case {i}: {family} {style}");
let wanted = WantedFont { family: family.into(), style: style.into(), installed: (m == FontMatch::Style).then(|| face.family.clone()) };
let file = new.join("Candidate.ttf");
std::fs::write(&file, candidate).unwrap();
db.load_system_fonts();
let (face, m) = db.resolve(family, style).unwrap();
let exact = m == FontMatch::Exact && face.path().map(|p| std::fs::canonicalize(p).unwrap()) == Some(std::fs::canonicalize(&file).unwrap());
assert_eq!(exact, provides, "case {i}: resolve gave {face:?} {m:?} for {family} {style}");
assert_eq!(WantedFonts::new(&[wanted]).provided_by(&file) == [0], provides, "case {i}: the search, for {family} {style}");
let _ = std::fs::remove_dir_all(&dir);
}
}
/// A search reads only so much of a file: of a collection whose header gives 300 faces it reads
/// the first 64, and a cap on the bytes read ends it early.
#[test]
fn a_search_reads_at_most_its_share_of_a_font_file() {
use crate::fontdb::file_face_names_within;
let face = renamed("SourceSans3-Regular.ttf");
let tables = u16::from_be_bytes([face[4], face[5]]) as usize;
let len = |tag: &[u8]| {
let r = (0..tables).map(|i| 12 + 16 * i).find(|&r| &face[r..r + 4] == tag).unwrap();
u64::from(u32::from_be_bytes(face[r + 12..r + 16].try_into().unwrap()))
};
// What one face costs: its table directory and its name and OS/2 tables.
let per_face = 12 + 16 * tables as u64 + len(b"name") + len(b"OS/2");
let n = 300;
let base = 12 + 4 * n;
let mut ttc = b"ttcf".to_vec();
ttc.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
ttc.extend_from_slice(&(n as u32).to_be_bytes());
for _ in 0..n {
ttc.extend_from_slice(&(base as u32).to_be_bytes());
}
// Every face is the one font, whose table offsets count from the start of the collection.
let mut f = face.clone();
for r in 0..tables {
let at = 12 + r * 16 + 8;
let off = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) + base as u32;
f[at..at + 4].copy_from_slice(&off.to_be_bytes());
}
ttc.extend(f);
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-many", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("Many.ttc");
std::fs::write(&file, ttc).unwrap();
assert_eq!(file_face_names_within(&file, 256, u64::MAX).len(), 256);
assert_eq!(file_face_names_within(&file, 64, u64::MAX).len(), 64);
// The header, 64 offsets and three faces.
assert_eq!(file_face_names_within(&file, 64, 12 + 4 * 64 + 3 * per_face).len(), 3);
let wanted = WantedFonts::new(&[WantedFont { family: FAMILY.into(), style: "Regular".into(), installed: None }]);
assert_eq!(wanted.provided_by(&file), [0]);
assert!(wanted.provided_by(&dir.join("No Such.ttf")).is_empty());
}
/// A search reads a suitcase font only when its whole resource fork fits in the 8 MB a search reads
/// of one file.
#[cfg(target_os = "macos")]
#[test]
fn a_search_reads_a_suitcase_font_only_within_its_share() {
use crate::fontdb::file_face_names_within;
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-big-suitcase", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let suitcase = dir.join("Sysfont Sans3");
std::fs::write(&suitcase, b"").unwrap();
let fork = crate::suitcase::fork_of(&[renamed("SourceSans3-Regular.ttf")]);
let len = fork.len() as u64;
std::fs::write(suitcase.join("..namedfork/rsrc"), &fork).unwrap();
assert_eq!(file_face_names_within(&suitcase, 64, len).len(), 1);
assert!(file_face_names_within(&suitcase, 64, len - 1).is_empty());
let wanted = WantedFonts::new(&[WantedFont { family: FAMILY.into(), style: "Regular".into(), installed: None }]);
assert_eq!(wanted.provided_by(&suitcase), [0]);
// Past the 8 MB a search reads of one file.
let mut big = fork;
big.resize((8 << 20) + 1, 0);
std::fs::write(suitcase.join("..namedfork/rsrc"), big).unwrap();
assert!(wanted.provided_by(&suitcase).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn font_files_are_told_by_their_extension() {
for name in ["a/B.TTF", "c.otf", "d.Ttc", "e.otc"] {
assert!(is_font_file(Path::new(name)), "{name}");
}
for name in ["f.ttf.txt", "ttf", ".29457", "g.woff2", "h.fon"] {
assert!(!is_font_file(Path::new(name)), "{name}");
}
}
/// VectorCraft's own Fonts folder joins the scan once set, even before it exists; only the first
/// folder set counts.
#[test]
fn the_app_font_folder_is_read_by_the_scan() {
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-app-fonts", std::process::id()));
set_app_font_dir(dir.clone());
set_app_font_dir(dir.join("Other"));
assert_eq!(app_font_dir(), Some(dir.as_path()));
let dirs = system_font_dirs();
assert_eq!(dirs.iter().filter(|d| **d == dir).count(), 1, "{dirs:?}");
assert!(!dirs.contains(&dir.join("Other")));
}
/// #394 Preferences › Type › Show Font Names in English: off, menus label the family in its
/// native language; on (default), the English name. Documents still resolve by either name.
#[test]
fn family_display_name_follows_show_font_names_in_english() {
let bytes = named(&[
(3, EN, 1, "Hiragino Sans"),
(3, EN, 2, "Regular"),
(3, EN, 6, "HiraginoSans-Regular"),
(3, EN, 16, "Hiragino Sans"),
(3, JA, 16, "ヒラギノ角ゴシック"),
(3, EN, 17, "Regular"),
]);
let db = FontDb::with_font_dirs(vec![]);
assert!(db.add_font(bytes) > 0);
assert_eq!(db.family_display_name("Hiragino Sans", true), "Hiragino Sans");
assert_eq!(db.family_display_name("Hiragino Sans", false), "ヒラギノ角ゴシック");
assert_eq!(db.family_display_name("ヒラギノ角ゴシック", false), "ヒラギノ角ゴシック");
assert_eq!(db.family_display_name("Source Sans 3", false), "Source Sans 3");
let face = db.face("ヒラギノ角ゴシック", "Regular").unwrap();
assert_eq!(face.family, "Hiragino Sans");
}
/// Thai typed in a font without Thai takes an installed font made for Thai (Leelawadee UI,
/// Thonburi, Noto Sans Thai…), whatever other face covering Thai loaded first; Latin beside it keeps
/// the font chosen. Skipped where none of those families is installed.
#[test]
fn thai_falls_back_to_a_font_made_for_thai() {
let db = FontDb::global();
let Some(thai) = crate::fontdb::THAI_FAMILIES.iter().find(|f| db.has_family(f)) else {
eprintln!("skipped: no Thai font installed");
return;
};
let st = vectorcraft_doc::CharStyle { size: 20.0, ..Default::default() };
let l = layout(db, &TextObject::point(kurbo::Point::ZERO, "Thai ภาษาไทย", st));
let family = |g: &PositionedGlyph| db.face_by_id(g.font_id).map(|f| f.family.clone()).unwrap_or_default();
let (latin, thai_glyphs) = l.glyphs.split_at(5);
assert!(latin.iter().all(|g| family(g) == FALLBACK_FAMILY), "{:?}", latin.iter().map(family).collect::<Vec<_>>());
let first = crate::fontdb::THAI_FAMILIES.iter().find(|f| thai_glyphs.iter().all(|g| family(g).eq_ignore_ascii_case(f)));
assert!(first.is_some(), "Thai drawn in {:?}; {thai} is installed", thai_glyphs.iter().map(family).collect::<Vec<_>>());
assert!(thai_glyphs.iter().all(|g| g.gid != 0), "no .notdef");
}
/// #1258: full names can differ from the PostScript name, and name one particular face.
#[test]
fn full_names_resolve_faces_and_missing_font_searches() {
let data = named(&[
(3, EN, 1, "Alias Script"),
(3, EN, 2, "Italic"),
(3, EN, 4, "Alias Script Italic"),
(3, JA, 4, "例の書体 斜体"),
(3, EN, 6, "AliasScriptI"),
]);
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-full-name", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("Alias.ttf");
std::fs::write(&file, &data).unwrap();
for loaded in [false, true] {
let db = FontDb::with_font_dirs(vec![dir.clone()]);
if loaded {
assert!(db.add_font(data.clone()) > 0);
}
for name in ["Alias Script Italic", "Alias-Script-Italic", "ALIAS SCRIPT ITALIC", "例の書体 斜体", "AliasScriptI"] {
for requested_style in ["Regular", "Bold"] {
let (face, matched) = db.resolve(name, requested_style).unwrap();
assert_eq!((face.family.as_str(), face.style.as_str(), matched), ("Alias Script", "Italic", FontMatch::Exact));
let wanted = WantedFonts::new(&[WantedFont { family: name.into(), style: requested_style.into(), installed: None }]);
assert_eq!(wanted.provided_by(&file), [0], "search for {name} {requested_style}");
}
}
// Full names are face aliases; the API for actual PostScript names stays precise.
assert!(db.find_postscript("Alias Script Italic").is_none());
assert!(db.by_postscript_name("Alias Script Italic").is_none());
assert_eq!(db.find_postscript("AliasScriptI").unwrap().style, "Italic");
}
let _ = std::fs::remove_dir_all(dir);
}
/// A real family's name keeps the requested style even if another face uses it as a full name.
#[test]
fn a_full_name_does_not_override_an_installed_family() {
let db = FontDb::with_font_dirs(vec![]);
assert!(
db.add_font(named(&[(3, EN, 1, "Alias Script"), (3, EN, 2, "Italic"), (3, EN, 4, "Alias Script Italic"), (3, EN, 6, "AliasScriptI")])) > 0
);
assert!(db.add_font(named(&[(3, EN, 1, "Alias Script Italic"), (3, EN, 2, "Regular"), (3, EN, 6, "OtherAlias-Regular")])) > 0);
let (face, matched) = db.resolve("Alias Script Italic", "Regular").unwrap();
assert_eq!((face.family.as_str(), face.style.as_str(), matched), ("Alias Script Italic", "Regular", FontMatch::Exact));
}
/// When type is set in a font that isn't installed, layout searches the loaded faces' names for the
/// font's name once until fonts are added.
#[test]
fn a_missing_fonts_name_is_looked_up_once_until_fonts_are_added() {
use crate::fontdb::alias_lists;
let db = FontDb::with_font_dirs(vec![]);
// One lookup lists the names of every loaded face.
let before = alias_lists();
assert_eq!(db.canonical("Gone Sans", "Bold"), ("Gone Sans".to_string(), "Bold".to_string()));
let one_lookup = alias_lists() - before;
let style = vectorcraft_doc::CharStyle { font_family: "Gone Sans".into(), ..Default::default() };
let t = TextObject::point(kurbo::Point::ZERO, &vec!["Set in a font that is not installed."; 20].join("\n"), style);
let before = alias_lists();
for _ in 0..2 {
assert_eq!(layout(&db, &t).lines.len(), 20);
}
assert_eq!(alias_lists() - before, one_lookup, "two layouts of 20 paragraphs list the faces' names once");
// A font added since has the name as its full name.
assert_eq!(db.add_font(named(&[(3, EN, 1, "Found Sans"), (3, EN, 2, "Bold"), (3, EN, 4, "Gone Sans"), (3, EN, 6, "FoundSans-Bold")])), 1);
let l = layout(&db, &t);
assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| db.face_by_id(g.font_id).is_some_and(|f| f.family == "Found Sans")));
}
/// Until an installed font has a name, `canonical` returns the name and the style asked for each
/// time; once a rescan finds the font, the name gives the font's family.
#[test]
fn a_name_asked_for_before_its_font_is_installed_finds_it_after_a_rescan() {
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-asked-before", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let db = FontDb::with_font_dirs(vec![dir.clone()]);
let answer = |family: &str, style: &str| (family.to_string(), style.to_string());
for _ in 0..2 {
for style in ["Regular", "Bold"] {
assert_eq!(db.canonical("Later Three W3", style), answer("Later Three W3", style));
assert_eq!(db.canonical("後の三", style), answer("後の三", style));
}
}
// The name table is laid out like Hiragino Sans W3's, with a legacy family paired with its
// legacy style.
let three = named(&[
(1, 0, 1, "Later Three"),
(1, 0, 2, "W3"),
(3, EN, 1, "Later Three W3"),
(3, EN, 2, "Regular"),
(3, EN, 6, "LThree-W3"),
(3, EN, 16, "Later Three"),
(3, EN, 17, "W3"),
(3, JA, 16, "後の三"),
(3, JA, 17, "W3"),
]);
std::fs::write(dir.join("LaterThree.ttf"), three).unwrap();
assert_eq!(db.load_system_fonts(), 1);
for _ in 0..2 {
assert_eq!(db.canonical("Later Three W3", "Regular"), answer("Later Three", "W3"));
assert_eq!(db.canonical("Later Three W3", "Bold"), answer("Later Three", "Bold"));
assert_eq!(db.canonical("後の三", "Bold"), answer("Later Three", "Bold"));
}
let _ = std::fs::remove_dir_all(dir);
}
/// Times the layout of a story of 1,000 paragraphs set in a font that is not installed, with 0, 100
/// and 400 fonts added to the bundled ones. Run with
/// `cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`.
#[test]
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
fn missing_font_story_layout_timing() {
let text = vec!["Type set in a font that is not installed on this computer."; 1000].join("\n");
let style = vectorcraft_doc::CharStyle { font_family: "No Such Font".into(), ..Default::default() };
let t = TextObject::point(kurbo::Point::ZERO, &text, style);
for extra in [0, 100, 400] {
let db = FontDb::with_font_dirs(vec![]);
for i in 0..extra {
let (family, full, postscript) = (format!("Loaded Sans {i}"), format!("Loaded Sans {i} Regular"), format!("LoadedSans{i}-Regular"));
assert_eq!(db.add_font(named(&[(3, EN, 1, &family), (3, EN, 2, "Regular"), (3, EN, 4, &full), (3, EN, 6, &postscript)])), 1);
}
let mut ms = vec![];
for _ in 0..7 {
let start = std::time::Instant::now();
let l = layout(&db, &t);
ms.push(start.elapsed().as_secs_f64() * 1000.0);
assert_eq!(l.lines.len(), 1000);
}
ms.sort_by(f64::total_cmp);
println!("{extra} fonts added, {} families loaded: median {:.1} ms; samples {ms:.1?}", db.family_list().len(), ms[3]);
}
}