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:
commit
0a91fd9b44
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue