mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 12:38:58 -06:00
Merge PR #3598: M14: find Apple's Devanagari faces in the folders current macOS uses
This commit is contained in:
@@ -119,21 +119,53 @@ fn script_fallback(candidates: &[PathBuf], probe: char) -> Option<Arc<FontData>>
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> =
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user