Merge pull request #762 from DisasterAreaDesigns/suitcase-fonts

Fonts: find suitcase fonts, whose font data is in the resource fork
This commit is contained in:
BFlat 2026-10-09 21:49:07 +07:00 committed by GitHub
commit 0a91fd9b44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 253 additions and 22 deletions

View File

@ -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:?}");
}

View File

@ -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<Vec<u8>> {
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<FaceStyle> {
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<FaceStyle> {
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<Vec<u8>> {
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);

View File

@ -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;

151
crates/text/src/suitcase.rs Normal file
View File

@ -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<usize> {
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<usize> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<u8>]) -> Vec<u8> {
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
}

View File

@ -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");
}