diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs index 8531ccd67d..effb050fbe 100644 --- a/crates/ui-egui/src/system_fonts.rs +++ b/crates/ui-egui/src/system_fonts.rs @@ -119,21 +119,53 @@ fn script_fallback(candidates: &[PathBuf], probe: char) -> Option> candidates.iter().find_map(|path| read(path, probe, MAX_BYTES)) } +/// macOS folders that hold Apple's own families, system folder first. On macOS 26.6 (Apple M5 +/// Max) `Kohinoor.ttc` is in `/System/Library/Fonts` while the Sangam MN faces are under +/// `Supplemental/`, so looking one family up in both folders stops a move by Apple from silently +/// dropping it — which is what happened to `Supplemental/Kohinoor.ttc`, listed by the code but +/// not present on that OS. `/Library/Fonts` is deliberately not listed here: it holds +/// compatibility symlinks into `Supplemental/`, and the one face wanted from it is named in full +/// below. On that machine each family below lives in exactly one of the two folders, so their +/// order is a deterministic choice rather than a tie-break that was ever exercised. +/// +/// Only [`macos_devanagari_candidates`] uses this today: in the Telugu, Sinhala and Tamil lists each +/// family is named once, in a single folder (some of those lists also name `/Library/Fonts` for the +/// Unicode face). Their first choices all exist on macOS 26.6, so nothing is dropping there now — the +/// same two-folder expansion would fit them, and is left for a change that tests it. +const MACOS_FONT_DIRS: [&str; 2] = ["/System/Library/Fonts", "/System/Library/Fonts/Supplemental"]; + +/// The Devanagari families this list tries, best first. Spelled as the files are on disk — +/// `Devanagari Sangam MN.ttc` has spaces, like the other Sangam MN families below. Apple ships at +/// least one more Devanagari-capable collection on macOS 26.6 (`Supplemental/ITFDevanagari.ttc`, +/// 10 faces), and this list does not try it. +const MACOS_DEVANAGARI_FAMILIES: &[&str] = &["DevanagariMT.ttc", "Devanagari Sangam MN.ttc", "Kohinoor.ttc"]; + +/// `families`, best first, each looked for in every folder of `dirs`. Split out from +/// [`macos_candidates`] so the macOS layout can be tested on a machine with no Apple fonts. +fn macos_families_in(dirs: &[&str], families: &[&str]) -> Vec { + families.iter().flat_map(|family| dirs.iter().map(move |dir| Path::new(dir).join(family))).collect() +} + +/// [`macos_families_in`] against the folders Apple actually uses. +fn macos_candidates(families: &[&str]) -> Vec { + macos_families_in(&MACOS_FONT_DIRS, families) +} + +/// The macOS Devanagari list: Apple's installed families, then the last-resort Unicode face. +/// `Arial Unicode.ttf` is listed from `/Library/Fonts`, where it is a symlink into +/// `Supplemental/` on current macOS, and is read only when no family above covers the script. +fn macos_devanagari_candidates() -> Vec { + let mut list = macos_candidates(MACOS_DEVANAGARI_FAMILIES); + list.push(PathBuf::from("/Library/Fonts/Arial Unicode.ttf")); + list +} + /// Well-known locations of Devanagari (Hindi) faces, best first. fn devanagari_candidates() -> Vec { if cfg!(windows) { ["Nirmala.ttf", "NirmalaB.ttf", "mangal.ttf", "aparaj.ttf", "utsah.ttf"].iter().map(|f| windows_fonts().join(f)).collect() } else if cfg!(target_os = "macos") { - [ - "/System/Library/Fonts/Supplemental/DevanagariMT.ttc", - "/System/Library/Fonts/Supplemental/DevanagariSangamMN.ttc", - "/System/Library/Fonts/Supplemental/Kohinoor.ttc", - "/System/Library/Fonts/Kohinoor.ttc", - "/Library/Fonts/Arial Unicode.ttf", - ] - .iter() - .map(PathBuf::from) - .collect() + macos_devanagari_candidates() } else { unix_fonts(&[ "google-noto/NotoSansDevanagariUI-Regular.ttf", @@ -431,8 +463,41 @@ mod tests { } } + /// `script_fallback` honours the documented installed-font switch, under which it returns + /// nothing before order is ever consulted — so both loader tests and the macOS oracle skip when + /// it is off, the way `tests/fonts.rs` skips for the same reason. Without this the suite goes red in exactly the + /// mode `cargo xtask screenshots` uses. + fn installed_faces_switched_off() -> bool { + std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") + } + + #[test] + fn the_first_readable_candidate_wins_not_the_last() { + // List order *is* the preference, so a loader that scanned back-to-front would pick a + // different face and nobody would notice. Two bundled faces both cover 'A' and differ in + // size, so the winner is identifiable by length alone. + if installed_faces_switched_off() { + eprintln!("skipping: PDFCRAFT_SYSTEM_FONTS=0 turns the loader off before order matters"); + return; + } + let dir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts")); + let (inter, mono) = (dir.join("Inter-Regular.ttf"), dir.join("JetBrainsMono-Regular.ttf")); + let size = |p: &Path| std::fs::metadata(p).expect("bundled face").len(); + assert_ne!(size(&inter), size(&mono), "the two fixtures must be tellable apart"); + let first = script_fallback(&[inter.clone(), mono.clone()], 'A').expect("Inter covers 'A'"); + assert_eq!(first.font.len() as u64, size(&inter), "the first readable candidate must win"); + let reversed = script_fallback(&[mono.clone(), inter], 'A').expect("JetBrains Mono covers 'A'"); + assert_eq!(reversed.font.len() as u64, size(&mono), "reversing the list must reverse the pick"); + } + #[test] fn a_script_fallback_is_the_first_candidate_with_the_probe() { + // This one calls `script_fallback` too, so it fails under the documented switch that + // published screenshots use — skip with a reason, as tests/fonts.rs does. + if installed_faces_switched_off() { + eprintln!("skipping: PDFCRAFT_SYSTEM_FONTS=0 turns the loader off"); + return; + } let inter = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf")); let candidates = [PathBuf::from("definitely/not/here.ttf"), inter]; assert!(script_fallback(&candidates, 'A').is_some()); @@ -461,4 +526,134 @@ mod tests { } } } + + /// Retune tripwires, deliberately separate from `the_macos_devanagari_list_is_apples_layout` below: these assert that three + /// constants still hold the values this module's contract depends on. They cannot fail on their + /// own — they exist so that editing one is a decision someone has to make twice. Each was + /// checked by deleting the pin and watching the matching regression go green again: raising + /// `MAX_BYTES` above Arial Unicode's ~23 MB, swapping the Devanagari probe for Arabic `U+0627` + /// (which that face also covers, so every other test stays green), and cutting `MAX_FACES` to 1. + /// The oracle in the macOS test below also depends on all three, and it is conditional on + /// installation — so on CI these are the only guard. + #[test] + fn the_load_constants_are_the_documented_contract() { + assert_eq!(MAX_BYTES, 32 << 20, "the non-Han installed-face size cap is this module's contract (Arial Unicode is ~23 MB)"); + assert_eq!(DEVANAGARI_PROBE, '\u{0915}', "Devanagari ka is what the Hindi candidates are probed for, not another script's letter"); + assert_eq!(MAX_FACES, 16, "how deep into a .ttc collection the loader looks; the macOS oracle trusts this depth"); + } + + /// The macOS Devanagari list, exactly: families best first, each tried in the system folder + /// before `Supplemental/`. This pins the spellings Apple uses on disk and the order that + /// decides which face wins. A missing space (`DevanagariSangamMN.ttc`), swapping the two + /// folders, or walking folder-major instead of family-major all fail here — and they fail on + /// every OS, because this test reads no filesystem. + #[test] + fn the_macos_devanagari_list_is_apples_layout() { + assert_eq!( + macos_devanagari_candidates(), + vec![ + PathBuf::from("/System/Library/Fonts/DevanagariMT.ttc"), + PathBuf::from("/System/Library/Fonts/Supplemental/DevanagariMT.ttc"), + PathBuf::from("/System/Library/Fonts/Devanagari Sangam MN.ttc"), + PathBuf::from("/System/Library/Fonts/Supplemental/Devanagari Sangam MN.ttc"), + PathBuf::from("/System/Library/Fonts/Kohinoor.ttc"), + PathBuf::from("/System/Library/Fonts/Supplemental/Kohinoor.ttc"), + PathBuf::from("/Library/Fonts/Arial Unicode.ttf"), + ] + ); + // Pin what the application actually consults, not just the helper that builds it. + #[cfg(target_os = "macos")] + assert_eq!(devanagari_candidates(), macos_devanagari_candidates(), "the app must consult the list this test pins"); + } + + #[test] + fn a_family_whose_name_has_spaces_is_still_found() { + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0); + let root = std::env::temp_dir().join(format!("pdfcraft-macos-fonts-{}-{nanos}", std::process::id())); + let supplemental = root.join("Supplemental"); + std::fs::create_dir_all(&supplemental).expect("build a temp macOS-shaped font tree"); + let dirs = [root.to_string_lossy().to_string(), supplemental.to_string_lossy().to_string()]; + let names: Vec<&str> = dirs.iter().map(|d| d.as_str()).collect(); + let inter: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let spaced = "Devanagari Sangam MN.ttc"; + std::fs::write(supplemental.join(spaced), inter).expect("place a face at the spaced path"); + let found = macos_families_in(&names, &[spaced]).iter().any(|p| read(p, 'A', MAX_BYTES).is_some()); + let _ = std::fs::remove_dir_all(&root); + assert!(found, "a family named with spaces did not resolve under {dirs:?}"); + } + + /// On a Mac, where Apple's fonts are installed: every family folder that really exists is + /// reachable from the candidate list, and something in the list covers Devanagari. A family + /// Apple no longer ships is reported rather than failed — this list exists precisely for the case + /// where one of them is missing, with one exception: if *nothing* here covers Devanagari the test + /// fails loudly, because a machine with no face to check cannot validate the list at all. That + /// leniency is not a guess: Apple's "Fonts included with macOS Tahoe" page (support.apple.com/122869) + /// titles its list "Installed or downloadable fonts" and says most install automatically while + /// others download on demand via Font Book — without marking which is which, so no entry in these + /// lists can be assumed present on a given Mac. CI has no macOS runner (`.github/workflows/ci.yml` + /// keeps the org's Mac slots for release builds), so this runs on contributors' and + /// maintainers' machines. + #[cfg(target_os = "macos")] + #[test] + fn every_installed_devanagari_face_is_reachable_on_this_mac() { + if installed_faces_switched_off() { + eprintln!("skipping on this machine: PDFCRAFT_SYSTEM_FONTS=0 turns installed faces off"); + return; + } + let list = devanagari_candidates(); + for family in MACOS_DEVANAGARI_FAMILIES { + // Same rule as the last resort below: an entry that exists counts, even a link whose + // target has gone — "present but unresolved" is precisely the dead-path defect this change + // is about, and filtering on `is_file()` would quietly reclassify it as "not installed". + // Absence is tolerated; a broken entry is not. + let present: Vec = + MACOS_FONT_DIRS.iter().map(|dir| Path::new(dir).join(family)).filter(|p| std::fs::symlink_metadata(p).is_ok()).collect(); + if present.is_empty() { + eprintln!("{family}: not installed on this machine, so the list offers nothing that exists — fine, and reported only"); + continue; + } + for path in &present { + assert!( + path.is_file(), + "{family}: {path:?} exists but does not resolve — its target was moved or removed, so this candidate path has gone dead" + ); + assert!(list.contains(path), "{path:?} is installed here but the candidate list skips it: {list:?}"); + } + } + assert!(script_fallback(&devanagari_candidates(), DEVANAGARI_PROBE).is_some(), "no installed face on this Mac covers Devanagari"); + // And the function callers use, not just its inputs: asserting *some* face loads is not + // enough, because both lists end at the same last-resort Unicode face and would rescue a + // mix-up. The app-facing function must land on the same bytes, and the same face within + // them, as this list produces. + let direct = script_fallback(&devanagari_candidates(), DEVANAGARI_PROBE).expect("the list above resolves"); + let cached = devanagari_fallback().expect("devanagari_fallback() resolves where these faces are installed"); + assert_eq!(cached.font.len(), direct.font.len(), "devanagari_fallback() must load the face this list points at, not another script's"); + assert_eq!(cached.index, direct.index, "and the same face within that file"); + // The advertised last resort is the biggest file on the list and reaches its target through + // a compatibility symlink in `/Library/Fonts`, a folder installers and users may modify — so + // it is optional in the same sense the families above are, and a Mac without it must not go + // red for an unrelated reason. But `is_file()` follows symlinks: a link whose target has + // moved reads as "no file", which is exactly the dead-path defect this change exists to + // catch. So gate on the entry existing (lstat), then require that it resolves, then check + // size cap and coverage. `MAX_BYTES` is what the loader applies to non-Han scripts; the path + // comes from the list itself rather than being written out again, so this oracle cannot + // drift from what production offers. + let last_resort = list.last().expect("the macOS Devanagari list always ends at the Unicode face").clone(); + assert!( + last_resort.to_string_lossy().ends_with("Arial Unicode.ttf"), + "this block checks the Unicode last resort, so the list must still end at it: {last_resort:?}" + ); + if std::fs::symlink_metadata(&last_resort).is_ok() { + assert!( + last_resort.is_file(), + "{last_resort:?} exists but does not resolve — its target was moved or removed, so this path has gone dead" + ); + assert!( + read(&last_resort, DEVANAGARI_PROBE, MAX_BYTES).is_some(), + "the last-resort Unicode face is installed here but did not resolve and load under MAX_BYTES" + ); + } else { + eprintln!("{last_resort:?} is not installed on this machine — optional to the loader, so nothing to check"); + } + } }