diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 883ceb4e..92d2cb44 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -168,332 +168,4 @@ pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 { } #[cfg(test)] -mod tests { - use super::*; - - fn any_face() -> std::sync::Arc { - FontDb::global().face(DEFAULT_FAMILY, "Regular") - } - - #[test] - fn emoji_clusters_are_recognised() { - let c = |s: &str| s.chars().collect::>(); - // As PowerPoint draws them: emoji-presentation characters (⭐ ✅ ☔ ⌚ ⏩) in colour even - // without a selector, text-presentation ones (♥ ✔ ☎ ⬆ ▶) as text unless U+FE0F asks, and - // keycaps always as text. - for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "🏳\u{FE0F}\u{200D}🌈", "⭐", "✅", "☔", "⌚", "⏩", "🀄"] - { - assert!(is_emoji_cluster(&c(yes)), "{yes:?}"); - } - for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→", "♥", "✔", "☎", "⬆", "▶", "1\u{FE0F}\u{20E3}", "#\u{20E3}"] - { - assert!(!is_emoji_cluster(&c(no)), "{no:?}"); - } - } - - #[test] - fn colour_fonts_are_recognised() { - let db = FontDb::with_font_dirs(Vec::new()); - assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0); - assert!(db.face("DeckCraft Colr Squares", "Regular").is_color()); - assert!(!fontdb::last_resort_face().is_color()); - } - - #[test] - fn last_resort_face_parses() { - let f = fontdb::last_resort_face(); - assert_eq!(f.family, fontdb::LAST_RESORT_FAMILY); - assert!(f.upem > 0.0); - } - - #[test] - fn line_split_follows_the_windows_ascent_and_descent() { - use skrifa::raw::TableProvider; - let f = fontdb::last_resort_face(); - let font = skrifa::FontRef::new(f.data()).unwrap(); - let os2 = font.os2().unwrap(); - let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64); - assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d)); - assert!(f.line_split > 0.5 && f.line_split < 1.0); - } - - #[test] - fn some_face_always_resolves() { - let db = FontDb::with_font_dirs(Vec::new()); - db.set_system_fallback(false); - let f = db.face("No Such Font Anywhere", "Regular"); - assert!(!f.family.is_empty()); - assert!(!shape(&f, "Hello", &[], |c| c).is_empty()); - } - - #[test] - fn filtered_fallback_continues_after_a_rejected_loaded_face() { - let db = FontDb::with_font_dirs(Vec::new()); - db.set_system_fallback(false); - assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec()), 1); - let first = db.fallback_for('A', u32::MAX).unwrap(); - let accepted_family = if first.family == LAST_RESORT_FAMILY { "DeckCraft Legacy" } else { LAST_RESORT_FAMILY }; - let fallback = db.fallback_for_filtered('A', u32::MAX, |f| f.family == accepted_family).unwrap(); - assert_eq!(fallback.family, accepted_family); - assert!(fallback.covers('A')); - assert_ne!(fallback.id(), first.id()); - assert_eq!(db.fallback_for('A', u32::MAX).unwrap().id(), first.id(), "document fallback order is unchanged"); - assert!(db.fallback_for_filtered('A', fallback.id(), |f| f.family == accepted_family).is_none(), "the excluded face stays excluded"); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn filtered_fallback_loads_an_eligible_system_face_and_does_not_cache_policy_misses() { - let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); - let db = FontDb::with_font_dirs(vec![dir]); - let first = db.fallback_for('A', u32::MAX).unwrap(); - assert_ne!(first.family, "DeckCraft Legacy"); - assert!(db.fallback_for_filtered('A', u32::MAX, |_| false).is_none()); - assert!(db.fallback_for('A', u32::MAX).is_some(), "an interface policy miss does not hide document glyphs"); - let accepted = db.fallback_for_filtered('A', u32::MAX, |f| f.family == "DeckCraft Legacy").unwrap(); - assert_eq!(accepted.family, "DeckCraft Legacy"); - assert!(accepted.covers('A')); - } - - #[test] - fn concurrent_lookups_all_find_an_installed_family() { - // Threads resolving an installed family nobody had loaded yet used to fall back to - // another face when a different thread finished loading it first, so the same text was - // laid out in different fonts depending on timing. - let dir = std::env::temp_dir().join(format!("deckcraft-fonts-concurrent-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("Hack-Regular.ttf"), epaint_default_fonts::HACK_REGULAR).unwrap(); - for _ in 0..20 { - let db = FontDb::with_font_dirs(vec![dir.clone()]); - db.set_system_fallback(false); - let start = std::sync::Barrier::new(8); - let got: Vec = std::thread::scope(|s| { - let threads: Vec<_> = (0..8) - .map(|_| { - s.spawn(|| { - start.wait(); - db.face("Hack", "Regular").family.clone() - }) - }) - .collect(); - threads.into_iter().map(|t| t.join().unwrap()).collect() - }); - assert!(got.iter().all(|f| f == "Hack"), "{got:?}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn windows_family_names_find_their_faces() { - // Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's - // typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way. - let check = |db: &FontDb| { - assert!(db.has_family("DeckCraft Legacy Semibold")); - for style in ["Regular", "Bold"] { - let f = db.face("DeckCraft Legacy Semibold", style); - assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}"); - } - assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri"); - // The typographic name still works. - assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold"); - }; - // Loaded directly… - let db = FontDb::with_font_dirs(Vec::new()); - db.set_system_fallback(false); - assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1); - check(&db); - // …and found by the system font scan under its Windows name, then loaded. - #[cfg(not(target_arch = "wasm32"))] - { - let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); - let db = FontDb::with_font_dirs(vec![dir.clone()]); - db.set_system_fallback(false); - check(&db); - // Looking up the Windows name loads the Semibold file only; a later lookup of the - // family's Regular must still load and pick the Regular file, not settle for Semibold. - let db = FontDb::with_font_dirs(vec![dir]); - db.set_system_fallback(false); - assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold"); - let regular = db.face("DeckCraft Legacy", "Regular"); - assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0)); - } - } - - #[test] - fn faces_without_outlines_are_not_cataloged() { - // Rename the Semibold's `glyf` table, as in fonts whose glyphs are bitmaps or are stored in - // Apple's `hvgl` table. The face still maps "A", but its outline is empty. The new tag - // keeps the table directory sorted. - let mut semibold = include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec(); - let tables = u16::from_be_bytes([semibold[4], semibold[5]]) as usize; - let glyf = (0..tables).map(|i| 12 + 16 * i).find(|&r| &semibold[r..r + 4] == b"glyf").unwrap(); - semibold[glyf..glyf + 4].copy_from_slice(b"gxyz"); - let loaded = FontDb::with_font_dirs(Vec::new()); - assert_eq!(loaded.add_font(semibold.clone()), 1); - let face = loaded.face("DeckCraft Legacy", "Semibold"); - assert!(face.covers('A')); - assert!(loaded.outline(&face, face.glyph_for('A')).elements().is_empty()); - // The system font scan lists the Regular, which has outlines, and leaves out the Semibold - // under both of its names. - #[cfg(not(target_arch = "wasm32"))] - { - let dir = std::env::temp_dir().join(format!("deckcraft-fonts-outlines-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("legacy-semibold.ttf"), &semibold).unwrap(); - std::fs::write(dir.join("legacy-regular.ttf"), include_bytes!("../tests/fixtures/legacy-regular.ttf")).unwrap(); - let db = FontDb::with_font_dirs(vec![dir.clone()]); - db.set_system_fallback(false); - assert_eq!(db.styles("DeckCraft Legacy"), ["Regular"]); - assert!(!db.has_family("DeckCraft Legacy Semibold")); - let _ = std::fs::remove_dir_all(&dir); - } - } - - #[test] - fn missing_document_fonts_are_drawn_in_calibri() { - // PowerPoint draws a missing family in Calibri, whatever its panose or the theme says. - let db = FontDb::global(); - for missing in ["Qqq Nonexistent", "Avenir Next Qqq", "", "Arimo Qqq"] { - let f = db.document_face(missing, "Bold"); - assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}"); - } - // PowerPoint's own twins and Windows' substitutes, when the font itself is missing. - for (missing, twin) in - [("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")] - { - if !db.has_family(missing) { - assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}"); - } - } - // Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks. - // So do Office's cloud fonts, which PowerPoint downloads and draws as themselves. - for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] { - assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}"); - } - } - - #[test] - fn office_fonts_have_substitutes() { - assert!(substitutes("Calibri").contains(&"Carlito")); - assert!(substitutes("Cambria").contains(&"Caladea")); - assert!(substitutes("Times New Roman").contains(&"Liberation Serif")); - assert!(substitutes("Aptos").contains(&"Inter")); - assert!(substitutes("zzz").is_empty()); - } - - #[test] - fn shaping_produces_clusters_and_advances() { - let face = any_face(); - let g = shape(&face, "Hello", &[], |c| c); - assert_eq!(g.len(), 5); - assert_eq!(g.iter().map(|g| g.cluster).collect::>(), vec![0, 1, 2, 3, 4]); - assert!(g.iter().all(|g| g.x_advance > 0 && g.gid != 0)); - } - - #[test] - fn mapping_keeps_clusters() { - let face = any_face(); - let g = shape(&face, "ab", &[], |c| c.to_ascii_uppercase()); - assert_eq!(g[0].gid, face.glyph_for('A')); - assert_eq!(g[1].cluster, 1); - } - - #[test] - fn outlines_and_metrics() { - let db = FontDb::global(); - let face = any_face(); - let o = db.outline(&face, face.glyph_for('O')); - assert!(!o.elements().is_empty()); - assert!(face.ascent > 0.0 && face.descent > 0.0); - assert!(feature("abc").is_none()); - assert!(feature("liga").is_some()); - } - - /// When the Latin build input exists, the runtime catalogue carries the expected Latin faces; - /// required builds also assert their presence. - #[test] - fn craft_fonts_latin_when_present() { - let required = option_env!("CRAFT_FONTS_REQUIRED").is_some(); - if !CRAFT_FONTS.iter().any(|f| f.family == "Inter") { - assert!(!required, "CRAFT_FONTS_REQUIRED must embed Inter rather than skip the optional Latin manifest"); - eprintln!("skipped: built without craft-fonts' Latin manifest (set CRAFT_FONTS_DIR)"); - return; - } - let inter = |style: &str| CRAFT_FONTS.iter().find(|f| f.family == "Inter" && f.style == style); - let (regular, bold, variable) = (inter("Regular"), inter("Bold"), inter("Variable")); - if required { - assert!( - variable.is_some() || (regular.is_some() && bold.is_some()), - "CRAFT_FONTS_REQUIRED must embed Inter Regular and Bold (or a Variable face)" - ); - } - let db = FontDb::with_font_dirs(Vec::new()); - db.set_system_fallback(false); - assert_eq!(db.face("Inter", "Regular").family, "Inter"); - assert_eq!(db.face("Aptos", "Regular").family, "Inter"); - if let (Some(regular), Some(bold)) = (regular, bold) { - // The build input carries one entry per face; real static Bold must not be the Regular - // file relabelled in the manifest. - assert_ne!(regular.bytes, bold.bytes, "Inter Bold must be a distinct face file from Regular"); - let (r, b) = (db.face("Inter", "Regular"), db.face("Inter", "Bold")); - assert_eq!((r.family.as_str(), r.style.as_str()), ("Inter", "Regular")); - assert_eq!((b.family.as_str(), b.style.as_str()), ("Inter", "Bold")); - assert_ne!(r.id(), b.id(), "Inter Bold must load as its own face"); - assert!(b.weight > r.weight, "Inter Bold weight {} is not above Regular {}", b.weight, r.weight); - } - if CRAFT_FONTS.iter().any(|f| f.family == "Carlito") { - assert_eq!(db.face("Calibri", "Bold").family, "Carlito"); - } - } - - /// An Embedded OpenType header of `version` around `font`, with the font at the end. - fn wrap_eot(font: &[u8], version: u32) -> Vec { - let mut h = vec![0u8; 82]; - let total = 82 + font.len(); - h[0..4].copy_from_slice(&(total as u32).to_le_bytes()); - h[4..8].copy_from_slice(&(font.len() as u32).to_le_bytes()); - h[8..12].copy_from_slice(&version.to_le_bytes()); - h[34..36].copy_from_slice(&0x504Cu16.to_le_bytes()); - h.extend_from_slice(font); - h - } - - #[test] - fn embedded_open_type_yields_the_font() { - let font = epaint_default_fonts::UBUNTU_LIGHT; - assert_eq!(embedded_sfnt(font), Some(font)); - assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0002_0001)), Some(font)); - assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0001_0000)), Some(font)); - assert!(embedded_sfnt(b"not a font file").is_none()); - assert!(embedded_sfnt(&wrap_eot(b"hello world!!", 0x0002_0002)).is_none()); - let mut truncated = wrap_eot(font, 0x0002_0001); - truncated.truncate(40); - assert!(embedded_sfnt(&truncated).is_none()); - } - - /// A FIFO, a folder and a link loop dressed up as font files: the system scan skips them - /// instead of blocking forever on the FIFO (start-up hung when one sat in `~/.fonts`), and - /// still finds the real font beside them. - #[cfg(unix)] - #[test] - fn the_font_scan_skips_fifos_folders_and_link_loops() { - let dir = std::env::temp_dir().join(format!("deckcraft-fontscan-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("sub")).unwrap(); - std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy-semibold.ttf"), dir.join("sub/real.ttf")).unwrap(); - let fifo = std::process::Command::new("mkfifo").arg(dir.join("trap.ttf")).status().is_ok_and(|s| s.success()); - std::fs::create_dir(dir.join("folder.ttf")).unwrap(); - std::fs::write(dir.join("empty.otf"), b"").unwrap(); - std::os::unix::fs::symlink(&dir, dir.join("sub/loop")).unwrap(); - let (tx, rx) = std::sync::mpsc::channel(); - let scan_dir = dir.clone(); - std::thread::spawn(move || { - let db = FontDb::with_font_dirs(vec![scan_dir]); - let _ = tx.send(db.load_system_fonts()); - }); - let n = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the font scan hung"); - assert!(n >= 1, "the real font was found"); - assert!(fifo || n >= 1); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/fonts/src/tests.rs b/crates/fonts/src/tests.rs new file mode 100644 index 00000000..bb7b726a --- /dev/null +++ b/crates/fonts/src/tests.rs @@ -0,0 +1,326 @@ +use super::*; + +fn any_face() -> std::sync::Arc { + FontDb::global().face(DEFAULT_FAMILY, "Regular") +} + +#[test] +fn emoji_clusters_are_recognised() { + let c = |s: &str| s.chars().collect::>(); + // As PowerPoint draws them: emoji-presentation characters (⭐ ✅ ☔ ⌚ ⏩) in colour even + // without a selector, text-presentation ones (♥ ✔ ☎ ⬆ ▶) as text unless U+FE0F asks, and + // keycaps always as text. + for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "🏳\u{FE0F}\u{200D}🌈", "⭐", "✅", "☔", "⌚", "⏩", "🀄"] + { + assert!(is_emoji_cluster(&c(yes)), "{yes:?}"); + } + for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→", "♥", "✔", "☎", "⬆", "▶", "1\u{FE0F}\u{20E3}", "#\u{20E3}"] { + assert!(!is_emoji_cluster(&c(no)), "{no:?}"); + } +} + +#[test] +fn colour_fonts_are_recognised() { + let db = FontDb::with_font_dirs(Vec::new()); + assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0); + assert!(db.face("DeckCraft Colr Squares", "Regular").is_color()); + assert!(!fontdb::last_resort_face().is_color()); +} + +#[test] +fn last_resort_face_parses() { + let f = fontdb::last_resort_face(); + assert_eq!(f.family, fontdb::LAST_RESORT_FAMILY); + assert!(f.upem > 0.0); +} + +#[test] +fn line_split_follows_the_windows_ascent_and_descent() { + use skrifa::raw::TableProvider; + let f = fontdb::last_resort_face(); + let font = skrifa::FontRef::new(f.data()).unwrap(); + let os2 = font.os2().unwrap(); + let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64); + assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d)); + assert!(f.line_split > 0.5 && f.line_split < 1.0); +} + +#[test] +fn some_face_always_resolves() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let f = db.face("No Such Font Anywhere", "Regular"); + assert!(!f.family.is_empty()); + assert!(!shape(&f, "Hello", &[], |c| c).is_empty()); +} + +#[test] +fn filtered_fallback_continues_after_a_rejected_loaded_face() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec()), 1); + let first = db.fallback_for('A', u32::MAX).unwrap(); + let accepted_family = if first.family == LAST_RESORT_FAMILY { "DeckCraft Legacy" } else { LAST_RESORT_FAMILY }; + let fallback = db.fallback_for_filtered('A', u32::MAX, |f| f.family == accepted_family).unwrap(); + assert_eq!(fallback.family, accepted_family); + assert!(fallback.covers('A')); + assert_ne!(fallback.id(), first.id()); + assert_eq!(db.fallback_for('A', u32::MAX).unwrap().id(), first.id(), "document fallback order is unchanged"); + assert!(db.fallback_for_filtered('A', fallback.id(), |f| f.family == accepted_family).is_none(), "the excluded face stays excluded"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn filtered_fallback_loads_an_eligible_system_face_and_does_not_cache_policy_misses() { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let db = FontDb::with_font_dirs(vec![dir]); + let first = db.fallback_for('A', u32::MAX).unwrap(); + assert_ne!(first.family, "DeckCraft Legacy"); + assert!(db.fallback_for_filtered('A', u32::MAX, |_| false).is_none()); + assert!(db.fallback_for('A', u32::MAX).is_some(), "an interface policy miss does not hide document glyphs"); + let accepted = db.fallback_for_filtered('A', u32::MAX, |f| f.family == "DeckCraft Legacy").unwrap(); + assert_eq!(accepted.family, "DeckCraft Legacy"); + assert!(accepted.covers('A')); +} + +#[test] +fn concurrent_lookups_all_find_an_installed_family() { + // Threads resolving an installed family nobody had loaded yet used to fall back to + // another face when a different thread finished loading it first, so the same text was + // laid out in different fonts depending on timing. + let dir = std::env::temp_dir().join(format!("deckcraft-fonts-concurrent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Hack-Regular.ttf"), epaint_default_fonts::HACK_REGULAR).unwrap(); + for _ in 0..20 { + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + let start = std::sync::Barrier::new(8); + let got: Vec = std::thread::scope(|s| { + let threads: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + start.wait(); + db.face("Hack", "Regular").family.clone() + }) + }) + .collect(); + threads.into_iter().map(|t| t.join().unwrap()).collect() + }); + assert!(got.iter().all(|f| f == "Hack"), "{got:?}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn windows_family_names_find_their_faces() { + // Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's + // typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way. + let check = |db: &FontDb| { + assert!(db.has_family("DeckCraft Legacy Semibold")); + for style in ["Regular", "Bold"] { + let f = db.face("DeckCraft Legacy Semibold", style); + assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}"); + } + assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri"); + // The typographic name still works. + assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold"); + }; + // Loaded directly… + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1); + check(&db); + // …and found by the system font scan under its Windows name, then loaded. + #[cfg(not(target_arch = "wasm32"))] + { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + check(&db); + // Looking up the Windows name loads the Semibold file only; a later lookup of the + // family's Regular must still load and pick the Regular file, not settle for Semibold. + let db = FontDb::with_font_dirs(vec![dir]); + db.set_system_fallback(false); + assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold"); + let regular = db.face("DeckCraft Legacy", "Regular"); + assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0)); + } +} + +#[test] +fn faces_without_outlines_are_not_cataloged() { + // Rename the Semibold's `glyf` table, as in fonts whose glyphs are bitmaps or are stored in + // Apple's `hvgl` table. The face still maps "A", but its outline is empty. The new tag + // keeps the table directory sorted. + let mut semibold = include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec(); + let tables = u16::from_be_bytes([semibold[4], semibold[5]]) as usize; + let glyf = (0..tables).map(|i| 12 + 16 * i).find(|&r| &semibold[r..r + 4] == b"glyf").unwrap(); + semibold[glyf..glyf + 4].copy_from_slice(b"gxyz"); + let loaded = FontDb::with_font_dirs(Vec::new()); + assert_eq!(loaded.add_font(semibold.clone()), 1); + let face = loaded.face("DeckCraft Legacy", "Semibold"); + assert!(face.covers('A')); + assert!(loaded.outline(&face, face.glyph_for('A')).elements().is_empty()); + // The system font scan lists the Regular, which has outlines, and leaves out the Semibold + // under both of its names. + #[cfg(not(target_arch = "wasm32"))] + { + let dir = std::env::temp_dir().join(format!("deckcraft-fonts-outlines-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("legacy-semibold.ttf"), &semibold).unwrap(); + std::fs::write(dir.join("legacy-regular.ttf"), include_bytes!("../tests/fixtures/legacy-regular.ttf")).unwrap(); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + assert_eq!(db.styles("DeckCraft Legacy"), ["Regular"]); + assert!(!db.has_family("DeckCraft Legacy Semibold")); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[test] +fn missing_document_fonts_are_drawn_in_calibri() { + // PowerPoint draws a missing family in Calibri, whatever its panose or the theme says. + let db = FontDb::global(); + for missing in ["Qqq Nonexistent", "Avenir Next Qqq", "", "Arimo Qqq"] { + let f = db.document_face(missing, "Bold"); + assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}"); + } + // PowerPoint's own twins and Windows' substitutes, when the font itself is missing. + for (missing, twin) in + [("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")] + { + if !db.has_family(missing) { + assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}"); + } + } + // Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks. + // So do Office's cloud fonts, which PowerPoint downloads and draws as themselves. + for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] { + assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}"); + } +} + +#[test] +fn office_fonts_have_substitutes() { + assert!(substitutes("Calibri").contains(&"Carlito")); + assert!(substitutes("Cambria").contains(&"Caladea")); + assert!(substitutes("Times New Roman").contains(&"Liberation Serif")); + assert!(substitutes("Aptos").contains(&"Inter")); + assert!(substitutes("zzz").is_empty()); +} + +#[test] +fn shaping_produces_clusters_and_advances() { + let face = any_face(); + let g = shape(&face, "Hello", &[], |c| c); + assert_eq!(g.len(), 5); + assert_eq!(g.iter().map(|g| g.cluster).collect::>(), vec![0, 1, 2, 3, 4]); + assert!(g.iter().all(|g| g.x_advance > 0 && g.gid != 0)); +} + +#[test] +fn mapping_keeps_clusters() { + let face = any_face(); + let g = shape(&face, "ab", &[], |c| c.to_ascii_uppercase()); + assert_eq!(g[0].gid, face.glyph_for('A')); + assert_eq!(g[1].cluster, 1); +} + +#[test] +fn outlines_and_metrics() { + let db = FontDb::global(); + let face = any_face(); + let o = db.outline(&face, face.glyph_for('O')); + assert!(!o.elements().is_empty()); + assert!(face.ascent > 0.0 && face.descent > 0.0); + assert!(feature("abc").is_none()); + assert!(feature("liga").is_some()); +} + +/// When the Latin build input exists, the runtime catalogue carries the expected Latin faces; +/// required builds also assert their presence. +#[test] +fn craft_fonts_latin_when_present() { + let required = option_env!("CRAFT_FONTS_REQUIRED").is_some(); + if !CRAFT_FONTS.iter().any(|f| f.family == "Inter") { + assert!(!required, "CRAFT_FONTS_REQUIRED must embed Inter rather than skip the optional Latin manifest"); + eprintln!("skipped: built without craft-fonts' Latin manifest (set CRAFT_FONTS_DIR)"); + return; + } + let inter = |style: &str| CRAFT_FONTS.iter().find(|f| f.family == "Inter" && f.style == style); + let (regular, bold, variable) = (inter("Regular"), inter("Bold"), inter("Variable")); + if required { + assert!( + variable.is_some() || (regular.is_some() && bold.is_some()), + "CRAFT_FONTS_REQUIRED must embed Inter Regular and Bold (or a Variable face)" + ); + } + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + assert_eq!(db.face("Inter", "Regular").family, "Inter"); + assert_eq!(db.face("Aptos", "Regular").family, "Inter"); + if let (Some(regular), Some(bold)) = (regular, bold) { + // The build input carries one entry per face; real static Bold must not be the Regular + // file relabelled in the manifest. + assert_ne!(regular.bytes, bold.bytes, "Inter Bold must be a distinct face file from Regular"); + let (r, b) = (db.face("Inter", "Regular"), db.face("Inter", "Bold")); + assert_eq!((r.family.as_str(), r.style.as_str()), ("Inter", "Regular")); + assert_eq!((b.family.as_str(), b.style.as_str()), ("Inter", "Bold")); + assert_ne!(r.id(), b.id(), "Inter Bold must load as its own face"); + assert!(b.weight > r.weight, "Inter Bold weight {} is not above Regular {}", b.weight, r.weight); + } + if CRAFT_FONTS.iter().any(|f| f.family == "Carlito") { + assert_eq!(db.face("Calibri", "Bold").family, "Carlito"); + } +} + +/// An Embedded OpenType header of `version` around `font`, with the font at the end. +fn wrap_eot(font: &[u8], version: u32) -> Vec { + let mut h = vec![0u8; 82]; + let total = 82 + font.len(); + h[0..4].copy_from_slice(&(total as u32).to_le_bytes()); + h[4..8].copy_from_slice(&(font.len() as u32).to_le_bytes()); + h[8..12].copy_from_slice(&version.to_le_bytes()); + h[34..36].copy_from_slice(&0x504Cu16.to_le_bytes()); + h.extend_from_slice(font); + h +} + +#[test] +fn embedded_open_type_yields_the_font() { + let font = epaint_default_fonts::UBUNTU_LIGHT; + assert_eq!(embedded_sfnt(font), Some(font)); + assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0002_0001)), Some(font)); + assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0001_0000)), Some(font)); + assert!(embedded_sfnt(b"not a font file").is_none()); + assert!(embedded_sfnt(&wrap_eot(b"hello world!!", 0x0002_0002)).is_none()); + let mut truncated = wrap_eot(font, 0x0002_0001); + truncated.truncate(40); + assert!(embedded_sfnt(&truncated).is_none()); +} + +/// A FIFO, a folder and a link loop dressed up as font files: the system scan skips them +/// instead of blocking forever on the FIFO (start-up hung when one sat in `~/.fonts`), and +/// still finds the real font beside them. +#[cfg(unix)] +#[test] +fn the_font_scan_skips_fifos_folders_and_link_loops() { + let dir = std::env::temp_dir().join(format!("deckcraft-fontscan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy-semibold.ttf"), dir.join("sub/real.ttf")).unwrap(); + let fifo = std::process::Command::new("mkfifo").arg(dir.join("trap.ttf")).status().is_ok_and(|s| s.success()); + std::fs::create_dir(dir.join("folder.ttf")).unwrap(); + std::fs::write(dir.join("empty.otf"), b"").unwrap(); + std::os::unix::fs::symlink(&dir, dir.join("sub/loop")).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let scan_dir = dir.clone(); + std::thread::spawn(move || { + let db = FontDb::with_font_dirs(vec![scan_dir]); + let _ = tx.send(db.load_system_fonts()); + }); + let n = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the font scan hung"); + assert!(n >= 1, "the real font was found"); + assert!(fifo || n >= 1); + let _ = std::fs::remove_dir_all(&dir); +}