Fonts: a folder search finds .suit suitcases and skips font files over 256 MB

Add Fonts adds only files a folder search found.

- macOS declares .suit as the extension of a font suitcase
  (com.apple.font-suitcase), and the font scan reads a suitcase
  whatever its name. A search considered only suitcases without an
  extension. It now also considers files whose extension is .suit, in
  any letter case, and reads one only when it is a suitcase font.
- Add Fonts copies at most 256 MB a file. A search also read larger
  font files, and a search that found one for a font could end before
  it found a smaller copy. A search now skips font files over 256 MB.

Docs: docs/mcp.md.
This commit is contained in:
dotCooCoo authored and bflatastic committed 2026-10-10 08:42:32 +07:00
1 parent 7b7e10e82e
commit 11cf254a07
3 files changed
+42 -11

No files matched your search

+9 -7
View File
@@ -40,8 +40,8 @@ const GENERIC_FAMILIES: &[&str] = &[
const MAX_PATH: usize = 4096;
/// The most files one `text.addFontFiles` copies: as many as a search keeps.
const MAX_ADD_FILES: usize = 1_000;
/// The largest font file copied, and the most bytes one `text.addFontFiles` copies.
#[cfg(not(target_arch = "wasm32"))]
/// The largest font file a search reads and `text.addFontFiles` copies, and the most bytes one
/// `text.addFontFiles` copies.
pub(crate) const MAX_FONT_FILE: u64 = 256 << 20;
#[cfg(not(target_arch = "wasm32"))]
const MAX_ADD_BYTES: u64 = 1 << 30;
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Find Font Files",
[],
None,
"{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files and, on macOS, of suitcase fonts (files without an extension whose fonts are in their resource fork), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
"{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
always,
find
),
@@ -192,12 +192,14 @@ struct FontFiles(WantedFonts);
impl Visitor for FontFiles {
fn wants(&self, name: &str) -> bool {
let path = Path::new(name);
// A suitcase font (macOS) often has no extension.
vectorcraft_text::is_font_file(path) || cfg!(target_os = "macos") && path.extension().is_none()
// A suitcase font (macOS) has no extension, or `.suit`.
vectorcraft_text::is_font_file(path) || cfg!(target_os = "macos") && path.extension().is_none_or(|e| e.eq_ignore_ascii_case("suit"))
}
fn worth_reading(&self, path: &Path) -> bool {
// A file without an extension is read only when it is a suitcase font.
path.extension().is_some() || vectorcraft_text::is_suitcase(path)
// A search reads font files and suitcase fonts of at most [`MAX_FONT_FILE`] bytes, the files
// `text.addFontFiles` copies.
(vectorcraft_text::is_font_file(path) || vectorcraft_text::is_suitcase(path))
&& std::fs::metadata(path).is_ok_and(|m| m.len() <= MAX_FONT_FILE)
}
fn items(&self, path: &Path) -> Vec<usize> {
self.0.provided_by(path)
+30 -2
View File
@@ -356,8 +356,28 @@ fn only_font_files_are_copied() {
let _ = std::fs::remove_dir_all(&root);
}
/// A suitcase font: a file without an extension, empty but for the fonts in its resource fork,
/// which only macOS keeps. A search finds it, and Add Fonts copies it with its fork.
/// A search reads no font file larger than Add Fonts copies, and finds a smaller copy of the font.
#[test]
fn a_search_skips_font_files_too_large_to_add() {
let root = folder("too-large");
let (big, small) = (root.join("big/Sizeme.otf"), root.join("small/Sizeme.otf"));
let font = vectorcraft_testkit::fonts::renamed("Sizeme Sans 3");
for path in [&big, &small] {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, &font).unwrap();
}
// One byte past the limit, without writing it: the file system leaves the rest unallocated.
std::fs::File::options().write(true).open(&big).unwrap().set_len(MAX_FONT_FILE + 1).unwrap();
let mut s = session_for(&root);
// A font no file has keeps the search going through every folder.
s.execute("text.findFontFiles", &json!({"folder": root, "fonts": [{"family": "Sizeme Sans 3"}, {"family": "No Such Sans"}]})).unwrap();
let r = finished(&mut s);
assert_eq!(r["fonts"][0]["files"], json!([small]), "{r}");
let _ = std::fs::remove_dir_all(&root);
}
/// A suitcase font: a file without an extension or with `.suit`, empty but for the fonts in its
/// resource fork, which only macOS keeps. A search finds it, and Add Fonts copies it with its fork.
#[cfg(target_os = "macos")]
#[test]
fn suitcase_fonts_are_found_and_copied_with_their_fork() {
@@ -380,6 +400,14 @@ fn suitcase_fonts_are_found_and_copied_with_their_fork() {
s.execute("text.findFontFiles", &json!({"folder": src, "fonts": [{"family": "Suitme Sans 3"}]})).unwrap();
let r = finished(&mut s);
assert_eq!(r["fonts"][0]["files"], json!([found]), "{r}");
// A suitcase with the `.suit` extension is found too, and a `.suit` file that isn't one is left out.
let suit = src.join("Suited.SUIT");
suitcase(&suit, "Suited Sans 3");
std::fs::write(src.join("Plain.suit"), b"text").unwrap();
// A font no file has keeps the search going through every folder.
s.execute("text.findFontFiles", &json!({"folder": src, "fonts": [{"family": "Suited Sans 3"}, {"family": "No Such Sans"}]})).unwrap();
let r = finished(&mut s);
assert_eq!((r["fonts"][0]["files"].clone(), r["searched"]["fontFiles"].as_u64()), (json!([suit]), Some(2)), "{r}");
let r = copy_into(std::slice::from_ref(&found), &dest);
assert_eq!(r.copied, [(found.clone(), dest.join("Suitme"))]);
assert!(vectorcraft_text::is_suitcase(&dest.join("Suitme")));
+3 -2
View File
@@ -2593,11 +2593,12 @@ returns the last search's state with the files found so far for each font: `sear
`stopped`: `stop`, `time` or `limit`) or `failed` (with `error`); before the first search it returns
`{state: "idle", fonts: []}`, without an `id`. `{stop: true}` stops the search. A search reads the table directories
and the `name`, `fvar` and `OS/2` tables of `.ttf`, `.otf`, `.ttc` and `.otc` files and, on macOS, of suitcase fonts
(files without an extension whose fonts are in their resource fork), and it lists a file for a font only when adding
(extensionless or `.suit` files whose fonts are in their resource fork), and it lists a file for a font only when adding
the file makes the font resolve exactly as `text.fonts` resolves it. It ends after `maxSeconds` (60 by default, 1 to
600), at its limits on the entries listed (10,000,000), the folders waiting to be listed (1,000,000), the font files
read (10,000) and the files kept (1,000), or once every font has a file. Folders more than 64 levels below the picked
one are skipped and counted in `skipped`. A search does not read a suitcase font whose resource fork is larger than 8 MB.
one are skipped and counted in `skipped`. A search does not read a font file larger than 256 MB, which
`text.addFontFiles` does not copy, or a suitcase font whose resource fork is larger than 8 MB.
A search does not enter app and media library packages (`.app`, `.bundle`, `.framework`, `.photoslibrary` and the like),
folders named `Program Files` (also `Program Files (x86)` and `Program Files (Arm)`), `ProgramData`, `$Recycle.Bin` or