From e1d35131d3bd7e11c954979f06718a6d596ce0d3 Mon Sep 17 00:00:00 2001 From: Matthew Farrow Date: Fri, 9 Oct 2026 10:05:18 -0400 Subject: [PATCH 1/3] Fonts: find suitcase fonts, whose font data is in the resource fork macOS lists fonts such as the ones Microsoft Office installs in /Library/Fonts/Microsoft (Bookman Old Style, Impact, Georgia, Tahoma, ...) but their files have nothing in the data fork and no font extension: the fonts are `sfnt` resources in the resource fork. The font scan only read data forks of .ttf/.otf/.ttc/.otc files, so those families were never cataloged and a file using one (an EPS naming BookmanOldStyle-Bold) showed the font as missing. The scan now catalogs a file with an empty data fork and a resource fork by the fonts in that fork, and loading reads them the same way. The fork reader (crates/text/src/suitcase.rs) follows Apple's published resource file format, caps what a damaged fork can make it read and never panics. macOS only. Tests: the fork reader on a synthetic fork built around a bundled font, including truncated and garbage forks; a macOS scan of a suitcase file. Co-Authored-By: Claude Sonnet 5.5 --- crates/text/src/fontdb.rs | 42 +++++++-- crates/text/src/lib.rs | 2 + crates/text/src/suitcase.rs | 147 ++++++++++++++++++++++++++++++ crates/text/src/tests_sysfonts.rs | 37 ++++++++ 4 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 crates/text/src/suitcase.rs diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 58841f63..eabf0c02 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -773,16 +773,37 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, FaceStyle)> { .collect() } +/// The font files held by the file at `path`: its contents, or for a suitcase font the fonts in +/// its resource fork ([`crate::suitcase`]). None when it can't be read. +#[cfg(not(target_arch = "wasm32"))] +fn font_files(path: &Path) -> Vec> { + if crate::suitcase::is_suitcase(path) { + return crate::suitcase::read_fonts(path); + } + std::fs::read(path).into_iter().collect() +} + /// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table /// directories and `name` and `fvar` tables: a scan opens hundreds of font files, many of them /// megabytes long. #[cfg(not(target_arch = "wasm32"))] fn file_face_names(path: &Path) -> Vec { - use std::io::{Read, Seek, SeekFrom}; + if crate::suitcase::is_suitcase(path) { + return crate::suitcase::read_fonts(path).into_iter().flat_map(|font| reader_face_names(std::io::Cursor::new(font))).collect(); + } + match std::fs::File::open(path) { + Ok(file) => reader_face_names(file), + Err(_) => vec![], + } +} + +/// [`file_face_names`] for the font file `file` reads. +#[cfg(not(target_arch = "wasm32"))] +fn reader_face_names(mut file: impl std::io::Read + std::io::Seek) -> Vec { + use std::io::SeekFrom; /// Caps on what a (possibly damaged) file can make the scan read. const MAX_FACES: u32 = 256; const MAX_NAME_TABLE: u32 = 1 << 20; - let Ok(mut file) = std::fs::File::open(path) else { return vec![] }; let mut read_at = |offset: u64, len: usize| -> Option> { let mut buf = vec![0; len]; file.seek(SeekFrom::Start(offset)).ok()?; @@ -1328,9 +1349,10 @@ impl FontDb { } for (p, named) in files { // A file named by itself is a font whatever its name (font services keep fonts in - // files without an extension); one of a folder's only with a font's extension. + // files without an extension); one of a folder's only with a font's extension, or + // a suitcase font (whose fonts are in its resource fork, whatever its name). let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); - if !named && !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { + if !named && !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) && !crate::suitcase::is_suitcase(&p) { continue; } for FaceStyle { family, style, keys, weight, italic, traits, .. } in file_face_names(&p) { @@ -1451,7 +1473,7 @@ impl FontDb { paths.dedup(); let mut any = false; for p in paths { - if let Ok(data) = std::fs::read(&p) { + for data in font_files(&p) { any |= self.add_font_from(data, Some(&p)) > 0; } } @@ -1592,10 +1614,12 @@ impl FontDb { if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) { continue; } - let Ok(data) = std::fs::read(&p) else { continue }; - let hit = enumerate_faces(&data).iter().any(|(i, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())); - if hit && self.add_font_from(data, Some(&p)) > 0 && covered(self) { - return true; + for data in font_files(&p) { + let hit = + enumerate_faces(&data).iter().any(|(i, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())); + if hit && self.add_font_from(data, Some(&p)) > 0 && covered(self) { + return true; + } } } self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 740891de..e89a62ce 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -18,6 +18,8 @@ mod fontdb; pub mod hyphen; mod layout; mod shape; +#[cfg(not(target_arch = "wasm32"))] +mod suitcase; #[cfg(any(test, feature = "test-fonts"))] pub mod test_fonts; pub mod thread; diff --git a/crates/text/src/suitcase.rs b/crates/text/src/suitcase.rs new file mode 100644 index 00000000..78c93c5e --- /dev/null +++ b/crates/text/src/suitcase.rs @@ -0,0 +1,147 @@ +//! Suitcase fonts: classic Mac OS font files whose data fork is empty and whose fonts are `sfnt` +//! resources in the resource fork (Microsoft Office installs its fonts this way in +//! `/Library/Fonts/Microsoft`). macOS lists them as installed; a reader of the file's data finds +//! nothing, so the scan reads the resource fork. The fork's layout is Apple's published resource +//! file format (Inside Macintosh: More Macintosh Toolbox, "Resource Manager"). + +use std::path::Path; + +/// Caps on what a (possibly damaged) fork can make the scan read. +const MAX_FORK: u64 = 256 << 20; +const MAX_TYPES: usize = 1 << 12; +const MAX_FONTS: usize = 256; + +fn be16(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(2)?).and_then(|s| s.try_into().ok()).map(|s| u16::from_be_bytes(s) as usize) +} + +fn be32(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(4)?).and_then(|s| s.try_into().ok()).map(|s| u32::from_be_bytes(s) as usize) +} + +/// The font files (`sfnt` resources) of a resource fork, in the order its map lists them. A +/// fork that isn't well formed gives the fonts read before the damage, or none. +pub(crate) fn sfnt_resources(fork: &[u8]) -> Vec<&[u8]> { + let mut fonts = Vec::new(); + let _ = collect(fork, &mut fonts); + fonts +} + +fn collect<'a>(fork: &'a [u8], fonts: &mut Vec<&'a [u8]>) -> Option<()> { + // The header: where the data and the map start. + let data = be32(fork, 0)?; + let map = fork.get(be32(fork, 4)?..)?; + // The map: its type list starts at the offset kept 24 bytes in, and counts from the map. + let types = be16(map, 24)?; + // Counts are stored less one, so a list of none is 0xFFFF. + let n_types = (be16(map, types)? + 1) & 0xFFFF; + for t in 0..n_types.min(MAX_TYPES) { + let at = types + 2 + t * 8; + if map.get(at..at + 4)? != b"sfnt" { + continue; + } + let count = (be16(map, at + 4)? + 1) & 0xFFFF; + // Each reference: its data's offset is the low three bytes of the word 4 bytes in. + let refs = types + be16(map, at + 6)?; + for r in 0..count { + if fonts.len() >= MAX_FONTS { + return Some(()); + } + let start = data.checked_add(be32(map, refs + r * 12 + 4)? & 0x00FF_FFFF)?; + let len = be32(fork, start)?; + fonts.push(fork.get(start + 4..start.checked_add(4)?.checked_add(len)?)?); + } + } + Some(()) +} + +/// The fonts of the suitcase font at `path`, read from its resource fork; none for a file +/// without one, and off macOS, the only place such a fork is a part of a file. +pub(crate) fn read_fonts(path: &Path) -> Vec> { + let Some(fork) = read_fork(path) else { return vec![] }; + sfnt_resources(&fork).into_iter().map(<[u8]>::to_vec).collect() +} + +/// Whether the file at `path` is a suitcase font: nothing in its data fork and something in its +/// resource fork. +pub(crate) fn is_suitcase(path: &Path) -> bool { + std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() == 0) && fork_len(path) > 0 +} + +#[cfg(target_os = "macos")] +fn fork_path(path: &Path) -> std::path::PathBuf { + path.join("..namedfork/rsrc") +} + +#[cfg(target_os = "macos")] +fn fork_len(path: &Path) -> u64 { + std::fs::metadata(fork_path(path)).map_or(0, |m| m.len()) +} + +#[cfg(target_os = "macos")] +fn read_fork(path: &Path) -> Option> { + use std::io::Read; + let mut fork = Vec::new(); + std::fs::File::open(fork_path(path)).ok()?.take(MAX_FORK).read_to_end(&mut fork).ok()?; + Some(fork) +} + +#[cfg(not(target_os = "macos"))] +fn fork_len(_: &Path) -> u64 { + 0 +} + +#[cfg(not(target_os = "macos"))] +fn read_fork(_: &Path) -> Option> { + let _ = MAX_FORK; + None +} + +/// A resource fork holding `fonts` as `sfnt` resources and one other resource (a `vers`), as +/// a font utility writes them. +#[cfg(test)] +pub(crate) fn fork_of(fonts: &[Vec]) -> Vec { + let mut data = Vec::new(); + let mut offsets = Vec::new(); + for body in fonts.iter().map(Vec::as_slice).chain([&b"1.0"[..]]) { + offsets.push(data.len() as u32); + data.extend_from_slice(&(body.len() as u32).to_be_bytes()); + data.extend_from_slice(body); + } + let n = fonts.len(); + // The type list: `vers` (1 resource) then `sfnt`, each entry pointing at its references. + let list = 2 + 2 * 8; + let mut types = Vec::new(); + types.extend_from_slice(&1u16.to_be_bytes()); + types.extend_from_slice(b"vers"); + types.extend_from_slice(&0u16.to_be_bytes()); + types.extend_from_slice(&(list as u16).to_be_bytes()); + types.extend_from_slice(b"sfnt"); + types.extend_from_slice(&((n - 1) as u16).to_be_bytes()); + types.extend_from_slice(&((list + 12) as u16).to_be_bytes()); + let reference = |id: i16, offset: u32| { + let mut r = Vec::new(); + r.extend_from_slice(&id.to_be_bytes()); + r.extend_from_slice(&0xFFFFu16.to_be_bytes()); + r.extend_from_slice(&offset.to_be_bytes()); + r.extend_from_slice(&[0; 4]); + r + }; + types.extend(reference(1, offsets[n])); + for (i, o) in offsets[..n].iter().enumerate() { + types.extend(reference(5000 + i as i16, *o)); + } + // The map: a copy of the header, a handle, attributes, then the offsets of the lists. + let mut map = vec![0; 24]; + map.extend_from_slice(&28u16.to_be_bytes()); + map.extend_from_slice(&((28 + types.len()) as u16).to_be_bytes()); + map.extend(types); + let mut fork = Vec::new(); + for v in [256u32, 256 + data.len() as u32, data.len() as u32, map.len() as u32] { + fork.extend_from_slice(&v.to_be_bytes()); + } + fork.resize(256, 0); + fork.extend(data); + fork.extend(map); + fork +} diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs index 12f49a02..c1c2c5b1 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests_sysfonts.rs @@ -263,3 +263,40 @@ fn fonts_the_platform_lists_outside_the_font_folders_are_cataloged() { 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()); +} + +/// 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"); +} From d35db792dcf7034b88d34e1368edde6f9319f7f5 Mon Sep 17 00:00:00 2001 From: Matthew Farrow Date: Fri, 9 Oct 2026 10:29:11 -0400 Subject: [PATCH 2/3] PDF tests: the system-fonts test checks the first installed family that stays live text The test took the first installed family that isn't bundled and expected its PDF text to stay live. The PDF doesn't embed the font, so the page draws a stand-in and text stays live only where the installed font's glyphs match it. With suitcase fonts now found, the first family on a Mac is a condensed display face whose glyphs don't, so the importer rightly kept outlines and the test failed. Which family comes first depends on the machine's fonts: the test now tries up to 200 families and checks the first that stays live. Co-Authored-By: Claude Sonnet 5.5 --- crates/pdf/tests/system_fonts.rs | 41 ++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/crates/pdf/tests/system_fonts.rs b/crates/pdf/tests/system_fonts.rs index 262c4bde..b3eaa7bd 100644 --- a/crates/pdf/tests/system_fonts.rs +++ b/crates/pdf/tests/system_fonts.rs @@ -14,21 +14,36 @@ fn a_fresh_session_resolves_pdf_fonts_to_installed_families() { let probe = FontDb::with_font_dirs(system_font_dirs()); probe.load_system_fonts(); let installed = probe.families(); - // A family whose PDF name is its name without the spaces (as "TimesNewRoman"). - let Some(family) = installed.iter().find(|f| !bundled.contains(f) && f.contains(' ') && f.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ')) - else { + // Families whose PDF name is their name without the spaces (as "TimesNewRoman"). The font + // isn't embedded, so the page draws a stand-in; text stays live only where the installed + // font's glyphs match it, as a plain sans-serif's do and a condensed display face's don't: + // which family that is depends on the machine's fonts, so the first of them to do is checked. + let candidates: Vec<&String> = installed + .iter() + .filter(|f| !bundled.contains(f) && f.contains(' ') && f.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ')) + .take(200) + .collect(); + if candidates.is_empty() { eprintln!("no system fonts installed: nothing to check"); return; + } + let import = |family: &str| { + let resources = format!("/Font << /F1 << /Type /Font /Subtype /TrueType /BaseFont /{} >> >>", family.replace(' ', "")); + let page = PdfPage { resources, ..PdfPage::new(100.0, 100.0, "BT /F1 12 Tf 10 30 Td (Hi) Tj ET") }; + let r = import_with_report(&pdf_with(&[page], &[], None), &ImportOptions::default()).unwrap(); + let mut families = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + families.extend(t.runs.iter().map(|r| r.style.font_family.clone())); + } + }); + (families, r.warnings) }; - let resources = format!("/Font << /F1 << /Type /Font /Subtype /TrueType /BaseFont /{} >> >>", family.replace(' ', "")); - let page = PdfPage { resources, ..PdfPage::new(100.0, 100.0, "BT /F1 12 Tf 10 30 Td (Hi) Tj ET") }; - let r = import_with_report(&pdf_with(&[page], &[], None), &ImportOptions::default()).unwrap(); - let mut families = vec![]; - r.document.walk(|n| { - if let NodeKind::Text(t) = &n.kind { - families.extend(t.runs.iter().map(|r| r.style.font_family.clone())); - } + let found = candidates.iter().find_map(|family| { + let (families, warnings) = import(family); + (!families.is_empty()).then_some((family, families, warnings)) }); - assert_eq!(families, std::slice::from_ref(family)); - assert!(!r.warnings.iter().any(|w| w.contains(family.as_str())), "{:?}", r.warnings); + let (family, families, warnings) = found.expect("one of the installed families stays live text"); + assert_eq!(families, std::slice::from_ref(*family)); + assert!(!warnings.iter().any(|w| w.contains(family.as_str())), "{warnings:?}"); } From be9a879e5111ef2ad133b442a1d379d85bac9086 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 21:11:39 +0700 Subject: [PATCH 3/3] Fonts: cap the total a suitcase fork can make the scan copy; no fork look-up off macOS References can share data, so a damaged fork could make the scan copy one large resource up to 256 times: cap the total copied like the fork itself. Off macOS no file has a fork, so the scan doesn't look at each font file twice. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/suitcase.rs | 12 ++++++++---- crates/text/src/tests_sysfonts.rs | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/text/src/suitcase.rs b/crates/text/src/suitcase.rs index 78c93c5e..0de80f2a 100644 --- a/crates/text/src/suitcase.rs +++ b/crates/text/src/suitcase.rs @@ -35,6 +35,7 @@ fn collect<'a>(fork: &'a [u8], fonts: &mut Vec<&'a [u8]>) -> Option<()> { let types = be16(map, 24)?; // Counts are stored less one, so a list of none is 0xFFFF. let n_types = (be16(map, types)? + 1) & 0xFFFF; + let mut total = 0usize; for t in 0..n_types.min(MAX_TYPES) { let at = types + 2 + t * 8; if map.get(at..at + 4)? != b"sfnt" { @@ -44,11 +45,13 @@ fn collect<'a>(fork: &'a [u8], fonts: &mut Vec<&'a [u8]>) -> Option<()> { // Each reference: its data's offset is the low three bytes of the word 4 bytes in. let refs = types + be16(map, at + 6)?; for r in 0..count { - if fonts.len() >= MAX_FONTS { - return Some(()); - } let start = data.checked_add(be32(map, refs + r * 12 + 4)? & 0x00FF_FFFF)?; let len = be32(fork, start)?; + // References can share data: cap the total too, as every font is copied out. + total = total.checked_add(len)?; + if fonts.len() >= MAX_FONTS || total as u64 > MAX_FORK { + return Some(()); + } fonts.push(fork.get(start + 4..start.checked_add(4)?.checked_add(len)?)?); } } @@ -65,7 +68,8 @@ pub(crate) fn read_fonts(path: &Path) -> Vec> { /// Whether the file at `path` is a suitcase font: nothing in its data fork and something in its /// resource fork. pub(crate) fn is_suitcase(path: &Path) -> bool { - std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() == 0) && fork_len(path) > 0 + // Off macOS no file has a fork: don't look at every font file twice. + cfg!(target_os = "macos") && std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() == 0) && fork_len(path) > 0 } #[cfg(target_os = "macos")] diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs index c1c2c5b1..26288442 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests_sysfonts.rs @@ -274,6 +274,8 @@ fn a_resource_fork_gives_up_its_font_files() { 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