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:?}"); } 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..0de80f2a --- /dev/null +++ b/crates/text/src/suitcase.rs @@ -0,0 +1,151 @@ +//! 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; + 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" { + 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 { + 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)?)?); + } + } + 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 { + // 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")] +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..26288442 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests_sysfonts.rs @@ -263,3 +263,42 @@ 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()); + // 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"); +}