From 06a4ec194a987efba01e57bdafeb9281f3d5b31a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 19:09:33 +0000 Subject: [PATCH] craft-font-catalog, craft-font-store and craft-font-manager: installed fonts, the font store and the Font Manager panel --- ATTRIBUTION.md | 1 + Cargo.lock | 85 +- Cargo.toml | 3 + README.md | 13 + crates/font-catalog/Cargo.toml | 31 + crates/font-catalog/README.md | 83 ++ crates/font-catalog/examples/fonts.rs | 51 + crates/font-catalog/src/catalog.rs | 778 ++++++++++++++ crates/font-catalog/src/env.rs | 235 +++++ crates/font-catalog/src/fontconfig.rs | 193 ++++ crates/font-catalog/src/lib.rs | 37 + crates/font-catalog/src/registry.rs | 30 + crates/font-catalog/src/scan.rs | 461 ++++++++ crates/font-catalog/src/suitcase.rs | 102 ++ crates/font-catalog/src/system.rs | 122 +++ crates/font-catalog/src/tests.rs | 500 +++++++++ crates/font-manager/Cargo.toml | 41 + crates/font-manager/README.md | 95 ++ crates/font-manager/src/filter.rs | 97 ++ crates/font-manager/src/index.rs | 70 ++ crates/font-manager/src/installed.rs | 38 + crates/font-manager/src/lib.rs | 23 + crates/font-manager/src/panel/app.rs | 713 +++++++++++++ crates/font-manager/src/panel/browse.rs | 673 ++++++++++++ crates/font-manager/src/panel/catalogs/ar.tsv | 113 ++ crates/font-manager/src/panel/catalogs/ja.tsv | 113 ++ crates/font-manager/src/panel/details.rs | 532 ++++++++++ crates/font-manager/src/panel/faces.rs | 96 ++ crates/font-manager/src/panel/i18n.rs | 111 ++ crates/font-manager/src/panel/jobs.rs | 175 ++++ crates/font-manager/src/panel/look.rs | 111 ++ crates/font-manager/src/panel/mod.rs | 18 + crates/font-manager/src/panel/samples.rs | 108 ++ crates/font-manager/src/panel/sources.rs | 155 +++ crates/font-manager/src/panel/widgets.rs | 482 +++++++++ crates/font-manager/src/store.rs | 502 +++++++++ crates/font-manager/tests/flows.rs | 462 ++++++++ crates/font-store/Cargo.toml | 35 + crates/font-store/README.md | 122 +++ .../index/indexes/google-fonts.json.gz | Bin 0 -> 596893 bytes crates/font-store/index/indexes/noto.json.gz | Bin 0 -> 18192 bytes crates/font-store/index/sources.json | 4 + crates/font-store/src/http.rs | 201 ++++ crates/font-store/src/index.rs | 667 ++++++++++++ crates/font-store/src/lib.rs | 49 + crates/font-store/src/pin.rs | 38 + crates/font-store/src/source.rs | 488 +++++++++ crates/font-store/src/sources.rs | 148 +++ crates/font-store/src/store.rs | 905 ++++++++++++++++ crates/font-store/src/tests.rs | 986 ++++++++++++++++++ crates/font-store/src/verify.rs | 65 ++ 51 files changed, 11156 insertions(+), 5 deletions(-) create mode 100644 crates/font-catalog/Cargo.toml create mode 100644 crates/font-catalog/README.md create mode 100644 crates/font-catalog/examples/fonts.rs create mode 100644 crates/font-catalog/src/catalog.rs create mode 100644 crates/font-catalog/src/env.rs create mode 100644 crates/font-catalog/src/fontconfig.rs create mode 100644 crates/font-catalog/src/lib.rs create mode 100644 crates/font-catalog/src/registry.rs create mode 100644 crates/font-catalog/src/scan.rs create mode 100644 crates/font-catalog/src/suitcase.rs create mode 100644 crates/font-catalog/src/system.rs create mode 100644 crates/font-catalog/src/tests.rs create mode 100644 crates/font-manager/Cargo.toml create mode 100644 crates/font-manager/README.md create mode 100644 crates/font-manager/src/filter.rs create mode 100644 crates/font-manager/src/index.rs create mode 100644 crates/font-manager/src/installed.rs create mode 100644 crates/font-manager/src/lib.rs create mode 100644 crates/font-manager/src/panel/app.rs create mode 100644 crates/font-manager/src/panel/browse.rs create mode 100644 crates/font-manager/src/panel/catalogs/ar.tsv create mode 100644 crates/font-manager/src/panel/catalogs/ja.tsv create mode 100644 crates/font-manager/src/panel/details.rs create mode 100644 crates/font-manager/src/panel/faces.rs create mode 100644 crates/font-manager/src/panel/i18n.rs create mode 100644 crates/font-manager/src/panel/jobs.rs create mode 100644 crates/font-manager/src/panel/look.rs create mode 100644 crates/font-manager/src/panel/mod.rs create mode 100644 crates/font-manager/src/panel/samples.rs create mode 100644 crates/font-manager/src/panel/sources.rs create mode 100644 crates/font-manager/src/panel/widgets.rs create mode 100644 crates/font-manager/src/store.rs create mode 100644 crates/font-manager/tests/flows.rs create mode 100644 crates/font-store/Cargo.toml create mode 100644 crates/font-store/README.md create mode 100644 crates/font-store/index/indexes/google-fonts.json.gz create mode 100644 crates/font-store/index/indexes/noto.json.gz create mode 100644 crates/font-store/index/sources.json create mode 100644 crates/font-store/src/http.rs create mode 100644 crates/font-store/src/index.rs create mode 100644 crates/font-store/src/lib.rs create mode 100644 crates/font-store/src/pin.rs create mode 100644 crates/font-store/src/source.rs create mode 100644 crates/font-store/src/sources.rs create mode 100644 crates/font-store/src/store.rs create mode 100644 crates/font-store/src/tests.rs create mode 100644 crates/font-store/src/verify.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 85fd3078..a29711ee 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -8,6 +8,7 @@ author, source and license. Library code and original assets are under the repos |---|---|---|---| | `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft contributors; generated by `craft_cms::synth::coated_cmyk` | [PhotoCraft `crates/cms`](https://github.com/storytold/photocraft/tree/b3222ee8e7e6bcb4799c37f904c595f20f9baf5e/crates/cms) | CC0-1.0 | | `crates/cms/profiles/craft-coated-fogra39.icc` | craft-libs contributors; generated by `crates/cms/tests/fogra39.rs` from the FOGRA39 data below | this repository | CC0-1.0 | +| `crates/font-store/index/google-fonts-index.json.gz` | google-fonts-index contributors; generated from the METADATA.pb files of [google/fonts](https://github.com/google/fonts) at `45e3d5269e4a17d823f6d84470bc084dc5ae46f6` | [aiongg/google-fonts-index](https://github.com/aiongg/google-fonts-index/tree/8d52c53eaa40f17e7441f8234389cdbed196eb7b) | Apache-2.0 | | `crates/cms/tests/data/FOGRA39.txt` (unmodified) | Fogra | [Fogra characterisation data](https://fogra.org/en/downloads/work-tools/characterisation-data), `FOGRA39_to_FOGRA60_v2.zip` | Fogra terms: free use and redistribution, unmodified, with Fogra identified as the source; "FOGRA39" identifies the reference data only and implies no certification, approval or endorsement by Fogra (full text in [NOTICE](NOTICE)) | | `crates/i18n/data/collation.postcard` | Unicode, Inc. (ICU 78.1 collation data, CLDR 48.2.1); generated by `crates/i18n/data/generate-collation.sh` | [ICU](https://github.com/unicode-org/icu/releases/tag/release-78.1rc) | Unicode-3.0 | | `crates/text/assets/hyphen/en-us.dic` | Grady Ward (Moby Hyphenator II), converted by DesignCraft | [Project Gutenberg eBook #3204](https://www.gutenberg.org/ebooks/3204) | Public domain | diff --git a/Cargo.lock b/Cargo.lock index 868b4335..47ef6f3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -779,6 +779,50 @@ dependencies = [ name = "craft-docpath" version = "0.1.0" +[[package]] +name = "craft-font-catalog" +version = "0.1.0" +dependencies = [ + "fontique", + "skrifa 0.47.0", + "thiserror 2.0.21", + "windows-registry", + "write-fonts 0.54.1", +] + +[[package]] +name = "craft-font-manager" +version = "0.1.0" +dependencies = [ + "craft-font-catalog", + "craft-font-store", + "craft-i18n", + "craft-ui", + "egui", + "egui_kittest", + "egui_system_font_provider", + "epaint_default_fonts", + "image", + "log", + "serde", +] + +[[package]] +name = "craft-font-store" +version = "0.1.0" +dependencies = [ + "craft-atomic", + "craft-font-catalog", + "flate2", + "serde", + "serde_json", + "sha2", + "skrifa 0.47.0", + "thiserror 2.0.21", + "ureq", + "write-fonts 0.54.1", +] + [[package]] name = "craft-fonts-build" version = "0.1.0" @@ -1399,6 +1443,20 @@ dependencies = [ "wgpu", ] +[[package]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +dependencies = [ + "epaint", + "fontique", + "log", + "poll-promise", + "skrifa 0.44.0", + "sys-locale", + "unicode-script", +] + [[package]] name = "either" version = "1.19.0" @@ -3812,6 +3870,16 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "poll-promise" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6a58fecbf9da8965bcdb20ce4fd29788d1acee68ddbb64f0ba1b81bccdb7df" +dependencies = [ + "document-features", + "static_assertions", +] + [[package]] name = "polling" version = "3.11.0" @@ -5691,6 +5759,7 @@ dependencies = [ "rustls-platform-verifier", "ureq-proto", "utf8-zero", + "webpki-roots", ] [[package]] @@ -6075,6 +6144,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -6600,6 +6678,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55716cfa7e8bcdb90289e07dfac478ccb2e3ec0f9a604fe682c7a03a7984d705" dependencies = [ "font-types 0.12.7", + "indexmap", + "kurbo 0.13.1", "log", "read-fonts 0.45.1", ] @@ -7037,8 +7117,3 @@ source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f9 name = "egui_inspection" version = "0.37.0" source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" - -[[patch.unused]] -name = "egui_system_font_provider" -version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 40473a05..95ba61b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ thiserror = "2" craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } craft-docpath = { path = "crates/docpath" } +craft-font-catalog = { path = "crates/font-catalog" } +craft-font-manager = { path = "crates/font-manager" } +craft-font-store = { path = "crates/font-store" } craft-fonts-build = { path = "crates/fonts-build" } craft-i18n = { path = "crates/i18n" } craft-jpeg = { path = "crates/jpeg" } diff --git a/README.md b/README.md index c49e452a..c283c36c 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,19 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` and `` entries of the fontconfig configuration with its includes and the user's own. Inside a Flatpak sandbox, also the host's folders from `/run/host/font-dirs.xml` and from the host's configuration under `/run/host/etc/fonts`. +- Haiku: the system's and the user's `data/fonts` folders. + +The font service covers fonts outside those folders: + +- macOS: CoreText's available fonts, which include fonts that Adobe Fonts, RightFont and other font managers activate from their own folders. +- Windows: DirectWrite's system font collection, which includes per-user fonts and the fonts that font services such as Adobe Fonts load at run time. The registry adds fonts that programs register by full path. +- Linux and FreeBSD: fontconfig, loaded at run time (`fontconfig-dlopen`), so a system without it still runs. + +`FontEnv` holds the environment all of this depends on (`HOME`, the XDG variables, `FONTCONFIG_FILE`, `WINDIR`, `LOCALAPPDATA`, `CRAFT_FONT_STORE`, a file system root), so tests can describe any system without touching the process environment. + +## The font store + +`font_store_dir` is the one per-user folder that the suite's font store installs fonts into and every app's catalog reads. No other program reads it. + +| Platform | Folder | +|---|---| +| macOS | `~/Library/Application Support/Craft Fonts` | +| Windows | `%LOCALAPPDATA%\Craft Fonts` | +| Linux, BSDs, Haiku | `$XDG_DATA_HOME/craft-fonts` (`~/.local/share/craft-fonts`) | + +`CRAFT_FONT_STORE` replaces it, for portable installs and tests. + +## Scanning + +The scan reads each file's table directory and its `name`, `OS/2` and `fvar` tables, at most 1 MiB per table, never the whole file. Faces without outline tables (`glyf`, `CFF `, `CFF2`, `VARC`) are left out. Files in folders count when they have a font extension (`.ttf`, `.otf`, `.ttc`, `.otc`); files a font service or lister names count whatever their name. Mac suitcase fonts are read from their resource fork. Damaged files give no faces, never a panic. + +Each face (`FaceInfo`) has its family and style, legacy family and style, family names in other languages, full name, PostScript name, weight, width, italic, a named instance's axis settings, `fsType`, the OS/2 code page bits, its file and index, and its source. Each named instance of a variable font is a face of its own. + +## Lookup + +- `families()` and `styles(family)`. +- `family(name)` and `resolve(name, style)` accept any name of a family: its own name ignoring ASCII case, its names in other languages and its legacy names ignoring case and anything but letters and digits. `resolve` also accepts a legacy family with its legacy style (`Roboto Light` / `Regular`), a full name, and a PostScript name. +- Within a family, `resolve` matches the style by name (any of its names). Otherwise it picks the nearest weight, with italic differing counting as 1000, then the nearest width. It returns `Match::Exact` or `Match::NearestStyle`, so the app decides about substitutes. +- `by_postscript_name(name)`. + +## Loading and threads + +- The first scan runs on first use, or on a background thread after `scan_in_background`. Lookups wait for it. +- Lookups read an immutable snapshot, so a rescan never blocks them. +- `load_family` reads a family's files once. Other threads asking for the same family wait and share the result; other families load at the same time. Faces of one file share its bytes (`Arc<[u8]>`). +- `add_bytes` and `add_file` add fonts from documents, downloads or the app's bundle, within `Limits`. Added faces win over installed faces of the same family and style. `remove_added` removes a source's faces, for example when a document closes. + +## Changes + +- `rescan` scans again, reading only new and changed files. +- `installed_fonts_changed` compares the modification times of every folder the scan read (and the folders of files a font service named) and, on Windows, the registry's font list. It costs a few hundred `stat` calls, not a scan. +- `rescan_if_changed` does both; apps call it when their window is activated. +- `watch(interval, on_change)` polls on a background thread. +- `generation` counts the catalog's changes. + +## One enumeration for the UI and documents + +With the `system` feature, `system::collection()` returns the process's fontique `Collection`: the system's fonts and the font store's. The catalog lists its files, and craft-ui's font fallback can query the same collection instead of building its own, so the system's fonts are enumerated once per process. A rescan builds a new collection; `system::generation()` tells holders of an older clone to take a new one. + +## Try it + +```sh +cargo run -p craft-font-catalog --example fonts # counts, folders, faces outside them +cargo run -p craft-font-catalog --example fonts -- Arial # a family's faces +cargo run -p craft-font-catalog --example fonts -- --watch # report fonts installed or removed +``` diff --git a/crates/font-catalog/examples/fonts.rs b/crates/font-catalog/examples/fonts.rs new file mode 100644 index 00000000..772ad515 --- /dev/null +++ b/crates/font-catalog/examples/fonts.rs @@ -0,0 +1,51 @@ +//! Lists what the catalog finds on this computer, to check discovery by hand. +//! +//! ```text +//! cargo run -p craft-font-catalog --example fonts # counts, folders, font store +//! cargo run -p craft-font-catalog --example fonts -- Arial # a family's faces +//! cargo run -p craft-font-catalog --example fonts -- --watch # report fonts installed or removed +//! ``` + +use std::time::{Duration, Instant}; + +use craft_font_catalog::{Catalog, font_dirs, font_store_dir}; + +fn main() { + let catalog = Catalog::global(); + let args: Vec = std::env::args().skip(1).collect(); + let start = Instant::now(); + let families = catalog.families(); + let faces = catalog.faces(); + println!("{} families, {} faces, first scan {:?}", families.len(), faces.len(), start.elapsed()); + println!("font store: {:?}", font_store_dir(catalog.env())); + for dir in font_dirs(catalog.env()).iter().filter(|d| d.is_dir()) { + println!("folder: {}", dir.display()); + } + let outside: Vec<_> = + faces.iter().filter(|f| f.path.as_ref().is_some_and(|p| !font_dirs(catalog.env()).iter().any(|d| p.starts_with(d)))).collect(); + println!("{} faces outside the font folders (font service, store, registry):", outside.len()); + for f in outside.iter().take(40) { + println!(" {} {} ({:?}) {}", f.family, f.style, f.source, f.path.as_deref().map(|p| p.display().to_string()).unwrap_or_default()); + } + match args.first().map(String::as_str) { + Some("--watch") => { + let _watch = catalog.watch(Duration::from_secs(2), |c| println!("changed: {} families", c.families().len())); + println!("watching; install or remove a font, Ctrl+C to stop"); + loop { + std::thread::sleep(Duration::from_secs(3600)); + } + } + Some(name) => { + for f in catalog.styles(name) { + println!( + "{} / {}: weight {} width {} italic {} ps {:?} {:?} #{} {:?}", + f.family, f.style, f.weight, f.width, f.italic, f.postscript_name, f.path, f.index, f.source + ); + } + if let Some((f, m)) = catalog.resolve(name, args.get(1).map_or("Regular", String::as_str)) { + println!("resolve: {} {} ({m:?})", f.family, f.style); + } + } + None => {} + } +} diff --git a/crates/font-catalog/src/catalog.rs b/crates/font-catalog/src/catalog.rs new file mode 100644 index 00000000..c20753fb --- /dev/null +++ b/crates/font-catalog/src/catalog.rs @@ -0,0 +1,778 @@ +//! The catalog: every installed face, found by any of its names, loaded once. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, RwLock, Weak}; +use std::time::{Duration, SystemTime}; + +use crate::scan::{self, Face, FaceInfo, Source, norm, style_italic, style_weight, style_width}; +use crate::{FontEnv, Os, font_dirs, font_store_dir}; + +/// Lists font files that the platform's font service knows outside the font folders. Called by +/// every scan. +pub type Lister = Arc Vec + Send + Sync>; + +/// How deep the scan goes below a font folder. +const MAX_DEPTH: usize = 12; +/// The most folder entries one scan looks at. +const MAX_ENTRIES: usize = 200_000; +/// The most bytes the scan reads from one file (its tables, or a suitcase's resource fork). +const MAX_SCAN_READ: u64 = 256 << 20; +/// The largest font file loaded. +const MAX_LOAD: u64 = 512 << 20; + +/// Why a font file or font data wasn't added or loaded. +#[derive(Debug, thiserror::Error)] +pub enum FontFileError { + /// Reading the file failed. + #[error("the font file can't be read: {0}")] + Io(#[from] std::io::Error), + /// The file is larger than the limit. + #[error("the font file is {size} bytes, more than the {max} allowed")] + TooLarge { + /// The file's size. + size: u64, + /// The limit. + max: u64, + }, + /// No face in it could be read. + #[error("not a font file, or a damaged one")] + NotAFont, + /// Adding it would pass the limit on added faces. + #[error("more than {max} added font faces")] + TooManyFaces { + /// The limit. + max: usize, + }, + /// The face is no longer in the catalog (its data was removed). + #[error("the font is no longer available")] + Gone, +} + +/// Limits on fonts added from files or bytes ([`Catalog::add_bytes`], [`Catalog::add_file`]), +/// which may come from documents or downloads. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + /// The largest file accepted. + pub max_file_bytes: u64, + /// The most faces read from one file (a collection). + pub max_faces_per_file: u32, + /// The most added faces the catalog holds in all. + pub max_added_faces: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { max_file_bytes: 256 << 20, max_faces_per_file: 256, max_added_faces: 1000 } + } +} + +/// How [`Catalog::resolve`] matched. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Match { + /// The family and style asked for, by any of their names. + Exact, + /// The family, in the style nearest to the one asked for. + NearestStyle, +} + +/// A face's data: the whole file (or a suitcase font's resource) and the face's index in it. +#[derive(Clone, Debug)] +pub struct FontData { + /// The file's bytes, shared by every face loaded from it. + pub bytes: Arc<[u8]>, + /// The index to pass to a font parser. + pub index: u32, +} + +/// A loaded face ([`Catalog::load_family`]). +#[derive(Clone, Debug)] +pub struct LoadedFace { + /// The face. + pub info: FaceInfo, + /// Its data. + pub data: FontData, +} + +/// A face the scan can't find again, as an alias records it. +#[derive(Clone, Debug)] +enum Alias { + /// The family of this face. + Family(usize), + /// A legacy family name: this face, under this legacy style. + Legacy(usize, String), + /// A full or PostScript name: this face. + Face(usize), +} + +/// One state of the catalog: what lookups read while a scan builds the next one. +#[derive(Default)] +struct Index { + faces: Vec, + infos: Arc<[FaceInfo]>, + families: Arc<[String]>, + by_family: HashMap>, + aliases: HashMap>, + postscript: HashMap, +} + +impl Index { + /// Added faces first: a document's or the app's own face wins over an installed face of the + /// same family and style. + fn build(added: &[Face], scanned: &[Face]) -> Self { + let mut faces: Vec = Vec::new(); + let mut seen = HashSet::new(); + for f in added.iter().chain(scanned) { + let variations: Vec<(u32, u32)> = f.info.variations.iter().map(|(t, v)| (u32::from_be_bytes(*t), v.to_bits())).collect(); + let key = (f.info.family.to_ascii_lowercase(), norm(&f.info.style), f.keys.postscript.first().cloned(), variations); + if seen.insert(key) { + faces.push(f.clone()); + } + } + let mut by_family: HashMap> = HashMap::new(); + let mut aliases: HashMap> = HashMap::new(); + let mut postscript: HashMap = HashMap::new(); + let mut families: Vec = Vec::new(); + for (i, f) in faces.iter().enumerate() { + let entry = by_family.entry(f.info.family.to_ascii_lowercase()).or_default(); + if entry.is_empty() { + families.push(f.info.family.clone()); + } + entry.push(i); + for k in &f.keys.families { + let list = aliases.entry(k.clone()).or_default(); + if !list + .iter() + .any(|a| matches!(a, Alias::Family(j) if faces.get(*j).is_some_and(|g| g.info.family.eq_ignore_ascii_case(&f.info.family)))) + { + list.push(Alias::Family(i)); + } + } + for (family, style) in &f.keys.legacy { + aliases.entry(family.clone()).or_default().push(Alias::Legacy(i, style.clone())); + } + for k in f.keys.full_names.iter().chain(&f.keys.postscript) { + aliases.entry(k.clone()).or_default().push(Alias::Face(i)); + } + for k in &f.keys.postscript { + postscript.entry(k.clone()).or_insert(i); + } + } + // Narrow to wide, light to heavy, upright first. The sort is stable, so of two faces alike + // the added one stays first and wins a tie in `pick_style`. + for list in by_family.values_mut() { + list.sort_by(|a, b| { + let (Some(a), Some(b)) = (faces.get(*a), faces.get(*b)) else { return std::cmp::Ordering::Equal }; + (a.info.width.total_cmp(&b.info.width)).then(a.info.weight.total_cmp(&b.info.weight)).then(a.info.italic.cmp(&b.info.italic)) + }); + } + families.sort_by_cached_key(|f| (f.to_lowercase(), f.clone())); + let infos: Arc<[FaceInfo]> = faces.iter().map(|f| f.info.clone()).collect(); + Self { faces, infos, families: families.into(), by_family, aliases, postscript } + } + + fn face(&self, i: usize) -> Option<&Face> { + self.faces.get(i) + } + + /// The family `name` names: a family name ignoring ASCII case, else any family name of a face + /// (another language, legacy) ignoring case and everything but letters and digits. + fn family_of(&self, name: &str) -> Option<&[usize]> { + if let Some(faces) = self.by_family.get(&name.to_ascii_lowercase()) { + return Some(faces); + } + let aliases = self.aliases.get(&norm(name))?; + let i = aliases.iter().find_map(|a| match a { + Alias::Family(i) => Some(*i), + _ => None, + })?; + self.by_family.get(&self.face(i)?.info.family.to_ascii_lowercase()).map(Vec::as_slice) + } + + fn resolve(&self, name: &str, style: &str) -> Option<(usize, Match)> { + if let Some(faces) = self.family_of(name) { + return self.pick_style(faces, style); + } + let aliases = self.aliases.get(&norm(name))?; + let legacy: Vec<(usize, &str)> = aliases + .iter() + .filter_map(|a| match a { + Alias::Legacy(i, s) => Some((*i, s.as_str())), + _ => None, + }) + .collect(); + if !legacy.is_empty() { + let ns = style_key(style); + if let Some((i, _)) = legacy.iter().find(|(_, s)| *s == ns) { + return Some((*i, Match::Exact)); + } + let faces: Vec = legacy.iter().map(|(i, _)| *i).collect(); + return self.pick_style(&faces, style).map(|(i, _)| (i, Match::NearestStyle)); + } + aliases.iter().find_map(|a| match a { + Alias::Face(i) => Some((*i, Match::Exact)), + _ => None, + }) + } + + /// The face of `faces` (one family) in `style`: by its style name, then by any other name of + /// its style, else the nearest weight with italic differing costing 1000, then the nearest + /// width; the first face wins a tie. + fn pick_style(&self, faces: &[usize], style: &str) -> Option<(usize, Match)> { + let ns = style_key(style); + let cands: Vec<(usize, &Face)> = faces.iter().filter_map(|i| Some((*i, self.face(*i)?))).collect(); + if let Some((i, _)) = cands.iter().find(|(_, f)| norm(&f.info.style) == ns) { + return Some((*i, Match::Exact)); + } + if let Some((i, _)) = cands.iter().find(|(_, f)| f.keys.styles.contains(&ns)) { + return Some((*i, Match::Exact)); + } + let (tw, ti, twd) = (style_weight(style), style_italic(style), style_width(style)); + let score = |f: &Face| ((f.info.weight - tw).abs() + if f.info.italic != ti { 1000.0 } else { 0.0 }, (f.info.width - twd).abs()); + cands + .iter() + .min_by(|(_, a), (_, b)| { + let (sa, sb) = (score(a), score(b)); + sa.0.total_cmp(&sb.0).then(sa.1.total_cmp(&sb.1)) + }) + .map(|(i, _)| (*i, Match::NearestStyle)) + } +} + +/// A style name as compared: normalized, with no style meaning `Regular`. +fn style_key(style: &str) -> String { + let ns = norm(style); + if ns.is_empty() { "regular".into() } else { ns } +} + +/// What the catalog reads besides the platform's font folders. +struct Config { + app_dirs: Vec, + listers: Vec, + font_service: bool, +} + +/// A file found by a scan, remembered so a rescan reads only files that changed. +struct Cached { + len: u64, + modified: Option, + faces: Vec, +} + +#[derive(Default)] +struct ScanState { + files: HashMap, +} + +/// What a change check compares with. +#[derive(Default)] +struct Stamp { + app_dirs: Vec, + /// Every folder the scan listed, and the folders holding the files listers named, with their + /// modification times (`None`: missing). + dirs: Vec<(PathBuf, Option)>, + /// The fonts registered with Windows. + registered: Vec, +} + +#[derive(Default)] +struct Added { + faces: Vec, + blobs: HashMap>, + next_blob: u64, +} + +/// A family's faces, loaded once ([`Catalog::load_family`]). +type FamilyLoad = Arc>>; + +/// The installed fonts: the platform's font folders, the files its font service knows, the font +/// store and the app's own folders, plus faces added from files or bytes. +/// +/// The first scan runs on first use or on a background thread ([`Catalog::scan_in_background`]); +/// lookups wait for it. Lookups read an immutable snapshot, so they never wait for a rescan. +/// Each family's files load once ([`Catalog::load_family`]): other threads asking for the same +/// family wait for that load, other families load at the same time. +pub struct Catalog { + env: FontEnv, + config: RwLock, + index: RwLock>, + ready: OnceLock<()>, + scan: Mutex, + stamp: Mutex, + generation: AtomicU64, + added: Mutex, + loaded: Mutex>, + files: Mutex>>, +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn modified(path: &Path) -> Option { + std::fs::metadata(path).and_then(|m| m.modified()).ok() +} + +impl Catalog { + /// A catalog of the fonts installed on the system `env` describes, the font store, and the + /// files the platform's font service lists (with the `system` feature). + pub fn new(env: FontEnv) -> Self { + Self { + env, + config: RwLock::new(Config { app_dirs: Vec::new(), listers: Vec::new(), font_service: cfg!(feature = "system") }), + index: RwLock::new(Arc::new(Index::default())), + ready: OnceLock::new(), + scan: Mutex::new(ScanState::default()), + stamp: Mutex::new(Stamp::default()), + generation: AtomicU64::new(0), + added: Mutex::new(Added::default()), + loaded: Mutex::new(HashMap::new()), + files: Mutex::new(HashMap::new()), + } + } + + /// The process's catalog, for [`FontEnv::from_process`]. + pub fn global() -> &'static Arc { + static GLOBAL: OnceLock> = OnceLock::new(); + GLOBAL.get_or_init(|| Arc::new(Catalog::new(FontEnv::from_process()))) + } + + /// Whether scans ask the platform's font service (CoreText, DirectWrite, fontconfig) for the + /// font files it knows: on by default with the `system` feature, which provides it. + #[must_use] + pub fn with_font_service(self, on: bool) -> Self { + self.write_config().font_service = on && cfg!(feature = "system"); + self + } + + /// Have every scan also read the files `lister` names (any file name; a folder is read with + /// its subfolders), as fonts of the system. + #[must_use] + pub fn with_lister(self, lister: Lister) -> Self { + self.write_config().listers.push(lister); + self + } + + /// The app's own font folders (its Fonts folder, Office's, folders the user picked), read + /// with their subfolders from the next scan on. A change shows in + /// [`Catalog::installed_fonts_changed`]. + pub fn set_app_dirs(&self, dirs: Vec) { + self.write_config().app_dirs = dirs; + } + + fn write_config(&self) -> std::sync::RwLockWriteGuard<'_, Config> { + self.config.write().unwrap_or_else(PoisonError::into_inner) + } + + fn read_config(&self) -> std::sync::RwLockReadGuard<'_, Config> { + self.config.read().unwrap_or_else(PoisonError::into_inner) + } + + /// The environment the catalog was made for. + pub fn env(&self) -> &FontEnv { + &self.env + } + + /// Start the first scan on a background thread, so the first lookup doesn't wait for all of + /// it. Does nothing once it has run, or when no thread can be started (the first lookup + /// then scans). + pub fn scan_in_background(self: &Arc) { + if self.ready.get().is_none() { + let me = Arc::clone(self); + let _ = std::thread::Builder::new().name("font-catalog-scan".into()).spawn(move || me.ensure()); + } + } + + fn ensure(&self) { + self.ready.get_or_init(|| { + self.scan_now(false); + }); + } + + /// Whether the first scan has finished: lookups won't wait. + pub fn is_ready(&self) -> bool { + self.ready.get().is_some() + } + + /// Scan again, for fonts installed or removed since; only new and changed files are read. + /// Families loaded before load again on their next [`Catalog::load_family`]. Returns the + /// number of faces cataloged. + pub fn rescan(&self) -> usize { + let mut first = false; + self.ready.get_or_init(|| { + first = true; + self.scan_now(false); + }); + if !first { + self.scan_now(true); + } + self.snapshot().faces.len() + } + + /// [`Catalog::rescan`] when [`Catalog::installed_fonts_changed`] says so, for an app to call + /// when its window is activated. Returns whether it rescanned. + pub fn rescan_if_changed(&self) -> bool { + let changed = self.installed_fonts_changed(); + if changed { + self.rescan(); + } + changed + } + + /// Whether fonts were installed or removed since the last scan: a folder the scan read (or + /// the folder of a file the font service named) changed, a missing one appeared, the app's + /// folders changed, or on Windows the registry's font list changed. It looks at folders, + /// not files, so it costs far less than a scan. `false` before the first scan. + /// + /// Fonts a font service registers from a folder no scan has seen are found by the next + /// [`Catalog::rescan`] only. + pub fn installed_fonts_changed(&self) -> bool { + if !self.is_ready() { + return false; + } + let stamp = lock(&self.stamp); + if stamp.app_dirs != self.read_config().app_dirs { + return true; + } + if stamp.dirs.iter().any(|(p, t)| modified(p) != *t) { + return true; + } + self.read_config().font_service && self.env.os == Os::Windows && stamp.registered != crate::registry::registered_font_files() + } + + /// Poll for installed and removed fonts every `interval` on a background thread: when + /// [`Catalog::installed_fonts_changed`], rescan and call `on_change`. Stops when the returned + /// [`Watch`] or the catalog is dropped. + pub fn watch(self: &Arc, interval: Duration, on_change: impl Fn(&Catalog) + Send + 'static) -> Watch { + let stop = Arc::new(AtomicBool::new(false)); + let (me, flag) = (Arc::downgrade(self), Arc::clone(&stop)); + let _ = std::thread::Builder::new().name("font-catalog-watch".into()).spawn(move || { + loop { + std::thread::sleep(interval); + if flag.load(Ordering::Relaxed) { + return; + } + let Some(catalog) = me.upgrade() else { return }; + if catalog.rescan_if_changed() { + on_change(&catalog); + } + } + }); + Watch { stop } + } + + /// Counts the catalog's changes: each scan, and each face added or removed. + pub fn generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + + fn snapshot(&self) -> Arc { + self.ensure(); + Arc::clone(&self.index.read().unwrap_or_else(PoisonError::into_inner)) + } + + fn publish(&self, index: Index) { + *self.index.write().unwrap_or_else(PoisonError::into_inner) = Arc::new(index); + lock(&self.loaded).clear(); + self.generation.fetch_add(1, Ordering::AcqRel); + } + + /// The family names, sorted ignoring case. + pub fn families(&self) -> Arc<[String]> { + Arc::clone(&self.snapshot().families) + } + + /// Every face, added faces first. + pub fn faces(&self) -> Arc<[FaceInfo]> { + Arc::clone(&self.snapshot().infos) + } + + /// The family `name` names: its own name ignoring ASCII case, or any other of its names + /// (another language, a legacy name) ignoring case and everything but letters and digits. + pub fn family(&self, name: &str) -> Option { + let index = self.snapshot(); + let i = *index.family_of(name)?.first()?; + index.face(i).map(|f| f.info.family.clone()) + } + + /// The faces of the family `name` names ([`Catalog::family`]). + pub fn styles(&self, family: &str) -> Vec { + let index = self.snapshot(); + index.family_of(family).unwrap_or_default().iter().filter_map(|i| index.face(*i)).map(|f| f.info.clone()).collect() + } + + /// The face for `name` in `style`, and how it matched. `name` is a family name (any of its + /// names, as [`Catalog::family`]), a legacy family name (`Roboto Light`, matched with its + /// legacy style), a full name or a PostScript name (which name one face; `style` is then + /// ignored). Within the family, `style` matches by name; otherwise the nearest weight wins, + /// italic differing counting as 1000, then the nearest width. An empty style is `Regular`. + /// `None` when nothing has that name. + pub fn resolve(&self, name: &str, style: &str) -> Option<(FaceInfo, Match)> { + let index = self.snapshot(); + let (i, matched) = index.resolve(name, style)?; + Some((index.face(i)?.info.clone(), matched)) + } + + /// The face with this PostScript name (a named instance's too), ignoring case and everything + /// but letters and digits. + pub fn by_postscript_name(&self, name: &str) -> Option { + let index = self.snapshot(); + let i = *index.postscript.get(&norm(name))?; + index.face(i).map(|f| f.info.clone()) + } + + /// The data of `face`, read from its file (shared with other faces of the file still loaded). + pub fn load(&self, face: &FaceInfo) -> Result { + if let Some(blob) = face.blob { + let bytes = lock(&self.added).blobs.get(&blob).cloned().ok_or(FontFileError::Gone)?; + return Ok(FontData { bytes, index: face.index }); + } + let path = face.path.as_deref().ok_or(FontFileError::Gone)?; + if crate::suitcase::is_suitcase(path) { + let font = crate::suitcase::read_fonts(path).into_iter().nth(face.index as usize).ok_or(FontFileError::NotAFont)?; + return Ok(FontData { bytes: font.into(), index: 0 }); + } + Ok(FontData { bytes: self.file_bytes(path)?, index: face.index }) + } + + fn file_bytes(&self, path: &Path) -> Result, FontFileError> { + if let Some(bytes) = lock(&self.files).get(path).and_then(Weak::upgrade) { + return Ok(bytes); + } + let size = std::fs::metadata(path)?.len(); + if size > MAX_LOAD { + return Err(FontFileError::TooLarge { size, max: MAX_LOAD }); + } + let bytes: Arc<[u8]> = std::fs::read(path)?.into(); + let mut files = lock(&self.files); + files.retain(|_, w| w.strong_count() > 0); + files.insert(path.to_path_buf(), Arc::downgrade(&bytes)); + Ok(bytes) + } + + /// Every face of the family `family` names ([`Catalog::family`]) with its data. The first + /// call reads the family's files; calls for the same family on other threads wait for it and + /// share the result, while other families load at the same time. Faces whose files can't be + /// read are left out. After a rescan, the next call reads the files again. + pub fn load_family(&self, family: &str) -> Arc<[LoadedFace]> { + let Some(name) = self.family(family) else { return Arc::from([]) }; + let cell = Arc::clone(lock(&self.loaded).entry(name.to_ascii_lowercase()).or_default()); + Arc::clone( + cell.get_or_init(|| self.styles(&name).into_iter().filter_map(|info| Some(LoadedFace { data: self.load(&info).ok()?, info })).collect()), + ) + } + + /// Add the faces of a font file's `bytes` (a font or a collection) for this session, as + /// `source`. Returns the faces added. + pub fn add_bytes(&self, bytes: Vec, source: Source, limits: &Limits) -> Result, FontFileError> { + let size = bytes.len() as u64; + if size > limits.max_file_bytes { + return Err(FontFileError::TooLarge { size, max: limits.max_file_bytes }); + } + let mut faces = scan::data_faces(&bytes, limits.max_faces_per_file, None, source); + if faces.is_empty() { + return Err(FontFileError::NotAFont); + } + self.ensure(); + let mut added = lock(&self.added); + if added.faces.len() + faces.len() > limits.max_added_faces { + return Err(FontFileError::TooManyFaces { max: limits.max_added_faces }); + } + let blob = added.next_blob; + added.next_blob += 1; + added.blobs.insert(blob, bytes.into()); + for f in &mut faces { + f.info.blob = Some(blob); + } + Ok(self.push_added(added, faces)) + } + + /// Add the faces of the font file at `path` for this session, as `source`, read in place. + /// Returns the faces added. + pub fn add_file(&self, path: &Path, source: Source, limits: &Limits) -> Result, FontFileError> { + let size = std::fs::metadata(path)?.len(); + if size > limits.max_file_bytes { + return Err(FontFileError::TooLarge { size, max: limits.max_file_bytes }); + } + let mut faces = scan::file_faces(path, limits.max_file_bytes, source); + let mut fonts: Vec = faces.iter().map(|f| f.info.index).collect(); + fonts.dedup(); + if let Some(&keep) = fonts.get(limits.max_faces_per_file as usize) { + faces.retain(|f| f.info.index < keep); + } + if faces.is_empty() { + return Err(FontFileError::NotAFont); + } + self.ensure(); + let added = lock(&self.added); + if added.faces.len() + faces.len() > limits.max_added_faces { + return Err(FontFileError::TooManyFaces { max: limits.max_added_faces }); + } + Ok(self.push_added(added, faces)) + } + + fn push_added(&self, mut added: MutexGuard<'_, Added>, faces: Vec) -> Vec { + let infos = faces.iter().map(|f| f.info.clone()).collect(); + added.faces.extend(faces); + let scanned = self.scanned(); + self.publish(Index::build(&added.faces, &scanned)); + infos + } + + /// Remove every added face of `source` (a closed document's fonts). Returns how many. + pub fn remove_added(&self, source: Source) -> usize { + let mut added = lock(&self.added); + let before = added.faces.len(); + added.faces.retain(|f| f.info.source != source); + let blobs: HashSet = added.faces.iter().filter_map(|f| f.info.blob).collect(); + added.blobs.retain(|b, _| blobs.contains(b)); + let removed = before - added.faces.len(); + if removed > 0 { + let scanned = self.scanned(); + self.publish(Index::build(&added.faces, &scanned)); + } + removed + } + + /// The faces the last scan found. + fn scanned(&self) -> Vec { + let scan = lock(&self.scan); + let mut faces: Vec<(&PathBuf, &Cached)> = scan.files.iter().collect(); + faces.sort_by(|a, b| a.0.cmp(b.0)); + faces.into_iter().flat_map(|(_, c)| c.faces.iter().cloned()).collect() + } + + fn scan_now(&self, refresh: bool) { + let mut state = lock(&self.scan); + let (app_dirs, listers, service) = { + let c = self.read_config(); + (c.app_dirs.clone(), c.listers.clone(), c.font_service) + }; + let ignore_case = self.env.os == Os::Windows; + let mut roots: Vec<(PathBuf, Source)> = font_dirs(&self.env).into_iter().map(|d| (d, Source::System)).collect(); + roots.extend(font_store_dir(&self.env).map(|d| (d, Source::Store))); + roots.extend(app_dirs.iter().map(|d| (d.clone(), Source::App))); + + let mut named: Vec = Vec::new(); + let mut registered = Vec::new(); + if service { + named.extend(crate::system::font_files(refresh)); + if self.env.os == Os::Windows { + registered = crate::registry::registered_font_files(); + named.extend(registered.iter().cloned()); + } + } + for lister in &listers { + named.extend(lister()); + } + + let mut walk = Walk { ignore_case, seen_dirs: HashSet::new(), seen_files: HashSet::new(), entries: 0, dirs: Vec::new(), files: Vec::new() }; + for (root, source) in &roots { + walk.dir(root, *source, 0); + } + let mut parents = HashSet::new(); + for file in named { + if file.is_dir() { + walk.dir(&file, Source::System, 0); + continue; + } + if let Some(parent) = file.parent() + && parents.insert(parent.to_path_buf()) + { + walk.dirs.push((parent.to_path_buf(), modified(parent))); + } + walk.file(file, Source::System, true); + } + + let mut files = HashMap::new(); + for (path, source, by_name) in walk.files { + let Ok(meta) = std::fs::metadata(&path) else { continue }; + let (len, mtime) = (meta.len(), meta.modified().ok()); + let cached = state.files.remove(&path).filter(|c| c.len == len && c.modified == mtime); + let mut faces = match cached { + Some(c) => c.faces, + None if by_name || scan::is_font_file(&path) || crate::suitcase::is_suitcase(&path) => scan::file_faces(&path, MAX_SCAN_READ, source), + None => continue, + }; + for f in &mut faces { + f.info.source = source; + } + files.insert(path, Cached { len, modified: mtime, faces }); + } + state.files = files; + drop(state); + + *lock(&self.stamp) = Stamp { app_dirs, dirs: walk.dirs, registered }; + let added = lock(&self.added); + let scanned = self.scanned(); + self.publish(Index::build(&added.faces, &scanned)); + } +} + +/// Stops a [`Catalog::watch`] when dropped. +#[must_use = "dropping a Watch stops it"] +pub struct Watch { + stop: Arc, +} + +impl Drop for Watch { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + } +} + +/// The files of one scan: each folder and file once, however links lead back to it. +struct Walk { + ignore_case: bool, + seen_dirs: HashSet, + seen_files: HashSet, + entries: usize, + dirs: Vec<(PathBuf, Option)>, + /// Each file with where it was found and whether a lister named it. + files: Vec<(PathBuf, Source, bool)>, +} + +impl Walk { + fn key(&self, path: &Path) -> PathBuf { + let real = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + if self.ignore_case { PathBuf::from(real.to_string_lossy().to_lowercase()) } else { real } + } + + fn dir(&mut self, dir: &Path, source: Source, depth: usize) { + let key = self.key(dir); + if !self.seen_dirs.insert(key) { + return; + } + // Stamped before it is listed: a font installed meanwhile changes it after this. + self.dirs.push((dir.to_path_buf(), modified(dir))); + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let mut paths: Vec<(PathBuf, bool)> = Vec::new(); + for e in entries.flatten() { + self.entries += 1; + if self.entries > MAX_ENTRIES { + break; + } + let path = e.path(); + let is_dir = e.file_type().is_ok_and(|t| t.is_dir() || (t.is_symlink() && path.is_dir())); + paths.push((path, is_dir)); + } + paths.sort(); + for (path, is_dir) in paths { + if is_dir { + if depth < MAX_DEPTH { + self.dir(&path, source, depth + 1); + } + } else { + self.file(path, source, false); + } + } + } + + fn file(&mut self, path: PathBuf, source: Source, by_name: bool) { + let key = self.key(&path); + if self.seen_files.insert(key) { + self.files.push((path, source, by_name)); + } + } +} diff --git a/crates/font-catalog/src/env.rs b/crates/font-catalog/src/env.rs new file mode 100644 index 00000000..08a84bdd --- /dev/null +++ b/crates/font-catalog/src/env.rs @@ -0,0 +1,235 @@ +//! Where fonts are installed: the platform's font folders and the suite's font store, worked out +//! from a [`FontEnv`] value rather than the process environment, so tests can describe any system. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +/// The platform family whose font folders apply. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Os { + /// macOS. + MacOs, + /// Windows. + Windows, + /// Linux and the BSDs: XDG folders, fontconfig and Flatpak. + #[default] + Unix, + /// Haiku. + Haiku, + /// The web, Android, iOS and anything else without font folders to scan. + None, +} + +impl Os { + /// The platform this build runs on. + pub fn current() -> Self { + if cfg!(target_arch = "wasm32") || cfg!(target_os = "android") || cfg!(target_os = "ios") { + Self::None + } else if cfg!(target_os = "macos") { + Self::MacOs + } else if cfg!(windows) { + Self::Windows + } else if cfg!(target_os = "haiku") { + Self::Haiku + } else { + Self::Unix + } + } +} + +/// The environment the font folders depend on. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FontEnv { + /// Which platform's folders to list. + pub os: Os, + /// The file system root that fixed system folders (`/usr/share/fonts`, `/run/host/fonts`, + /// `/System/Library/Fonts`, `/etc/fonts/fonts.conf`) and absolute fontconfig `` entries + /// are under: `/` on a real system, a temporary folder in tests. + pub root: PathBuf, + /// `HOME` (`USERPROFILE` on Windows). + pub home: Option, + /// `XDG_DATA_HOME`. + pub xdg_data_home: Option, + /// `XDG_DATA_DIRS`. + pub xdg_data_dirs: Option, + /// `XDG_CONFIG_HOME`. + pub xdg_config_home: Option, + /// `FONTCONFIG_FILE`: the main fontconfig file instead of `/etc/fonts/fonts.conf`. + pub fontconfig_file: Option, + /// Inside a Flatpak sandbox (`/.flatpak-info` exists): the host's fontconfig folders are read + /// too, under `/run/host`. + pub flatpak: bool, + /// `WINDIR`. + pub windir: Option, + /// `LOCALAPPDATA`. + pub local_app_data: Option, + /// `CRAFT_FONT_STORE`: the font store folder instead of the platform's ([`font_store_dir`]). + pub font_store: Option, +} + +impl FontEnv { + /// The environment of this process. Empty variables count as unset. + pub fn from_process() -> Self { + let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()); + let path = |k: &str| var(k).map(PathBuf::from); + let os = Os::current(); + let home = if os == Os::Windows { path("USERPROFILE").or_else(|| path("HOME")) } else { path("HOME") }; + Self { + os, + root: PathBuf::from("/"), + home, + xdg_data_home: path("XDG_DATA_HOME"), + xdg_data_dirs: var("XDG_DATA_DIRS"), + xdg_config_home: path("XDG_CONFIG_HOME"), + fontconfig_file: path("FONTCONFIG_FILE"), + flatpak: os == Os::Unix && Path::new("/.flatpak-info").exists(), + windir: path("WINDIR"), + local_app_data: path("LOCALAPPDATA"), + font_store: path("CRAFT_FONT_STORE"), + } + } + + /// `path` (absolute, as on the real system) under [`FontEnv::root`]. + pub(crate) fn rooted(&self, path: impl AsRef) -> PathBuf { + let path = path.as_ref(); + let relative: PathBuf = path.components().filter(|c| matches!(c, std::path::Component::Normal(_))).collect(); + if self.root.as_os_str().is_empty() { Path::new("/").join(relative) } else { self.root.join(relative) } + } + + /// `$XDG_DATA_HOME`, else `~/.local/share` (a relative `XDG_DATA_HOME` is ignored, as the XDG + /// specification says). + pub(crate) fn data_home(&self) -> Option { + self.xdg_data_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".local/share"))) + } + + /// `$XDG_CONFIG_HOME`, else `~/.config`. + pub(crate) fn config_home(&self) -> Option { + self.xdg_config_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".config"))) + } + + fn windows_fonts(&self) -> PathBuf { + self.windir.clone().unwrap_or_else(|| PathBuf::from("C:\\Windows")).join("Fonts") + } +} + +/// The folder name of the font store under the platform's per-user data folder, spelled as the +/// suite's other per-user folders are (`ArtCraft Launcher`, `artcraft-launcher`). +fn store_name(os: Os) -> &'static str { + if matches!(os, Os::MacOs | Os::Windows) { "Craft Fonts" } else { "craft-fonts" } +} + +/// The suite's font store: one per-user folder of fonts installed by Craft Font Manager or an +/// app's "install missing font", read by every app's catalog and by no other program. +/// +/// - macOS: `~/Library/Application Support/Craft Fonts` +/// - Windows: `%LOCALAPPDATA%\Craft Fonts` +/// - Linux, the BSDs and Haiku: `$XDG_DATA_HOME/craft-fonts` (`~/.local/share/craft-fonts`) +/// +/// `CRAFT_FONT_STORE` ([`FontEnv::font_store`]) replaces it. `None` where there is no per-user +/// folder (the web), or when the variables it is built from are unset. +pub fn font_store_dir(env: &FontEnv) -> Option { + if let Some(dir) = &env.font_store { + return Some(dir.clone()); + } + let base = match env.os { + Os::MacOs => env.home.as_ref()?.join("Library/Application Support"), + Os::Windows => env.local_app_data.clone()?, + Os::Unix | Os::Haiku => env.data_home()?, + Os::None => return None, + }; + Some(base.join(store_name(env.os))) +} + +/// The platform's font folders, the system's and the user's, without duplicates. Folders that +/// don't exist are kept: a scan skips them, and a change check notices when one appears. +/// +/// - macOS: `/System/Library/Fonts`, `/Library/Fonts`, `/Network/Library/Fonts`, +/// `~/Library/Fonts`, `/System/Library/Assets`, and the font assets macOS downloads on demand +/// (`/System/Library/AssetsV2/com_apple_MobileAsset_Font*`, also under +/// `PreinstalledAssetsV2/InstallWithOs`). +/// - Windows: `%WINDIR%\Fonts` and the current user's `%LOCALAPPDATA%\Microsoft\Windows\Fonts`. +/// The registry's font list is read by the catalog itself. +/// - Linux and the BSDs: `/usr/share/fonts`, `/usr/local/share/fonts`, the Flatpak host folders +/// (`/run/host/fonts`, `/run/host/local-fonts`, `/run/host/user-fonts`), `~/.local/share/fonts`, +/// `~/.fonts`, `$XDG_DATA_HOME/fonts`, `$XDG_DATA_DIRS/*/fonts`, and the `` and +/// `` entries of the fontconfig configuration ([`crate::fontconfig_dirs`]). +/// - Haiku: the system's and the user's `data/fonts` folders, packaged and non-packaged. +pub fn font_dirs(env: &FontEnv) -> Vec { + let mut dirs: Vec = Vec::new(); + match env.os { + Os::MacOs => { + dirs.extend(["/System/Library/Fonts", "/Library/Fonts", "/Network/Library/Fonts"].map(|d| env.rooted(d))); + dirs.extend(env.home.as_ref().map(|h| h.join("Library/Fonts"))); + dirs.push(env.rooted("/System/Library/Assets")); + for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] { + let mut assets: Vec = std::fs::read_dir(env.rooted(parent)) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font")) + .map(|e| e.path()) + .collect(); + assets.sort(); + dirs.extend(assets); + } + } + Os::Windows => { + dirs.push(env.windows_fonts()); + dirs.extend(env.local_app_data.as_ref().map(|l| l.join("Microsoft\\Windows\\Fonts"))); + } + Os::Unix => { + dirs.extend( + ["/usr/share/fonts", "/usr/local/share/fonts", "/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"] + .map(|d| env.rooted(d)), + ); + if let Some(h) = &env.home { + dirs.push(h.join(".local/share/fonts")); + dirs.push(h.join(".fonts")); + } + dirs.extend(env.data_home().map(|d| d.join("fonts"))); + let data_dirs = env.xdg_data_dirs.clone().unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + dirs.extend(std::env::split_paths(&data_dirs).filter(|p| p.is_absolute()).map(|p| env.rooted(p).join("fonts"))); + dirs.extend(crate::fontconfig::fontconfig_dirs(env)); + } + Os::Haiku => { + dirs.extend(["/boot/system/data/fonts", "/boot/system/non-packaged/data/fonts"].map(|d| env.rooted(d))); + if let Some(h) = &env.home { + dirs.push(h.join("config/data/fonts")); + dirs.push(h.join("config/non-packaged/data/fonts")); + } + } + Os::None => {} + } + dedup_dirs(dirs, env.os == Os::Windows) +} + +/// The folders Microsoft Office keeps its own fonts in (Calibri, Cambria, Aptos, and the cloud +/// fonts it downloads), which other apps don't see. Apps that open Office documents add them to +/// their catalog ([`crate::Catalog::set_app_dirs`]). +pub fn office_font_dirs(env: &FontEnv) -> Vec { + match env.os { + Os::MacOs => { + let mut dirs: Vec = + env.home.iter().map(|h| h.join("Library/Group Containers/UBF8T346G9.Office/FontCache/4/CloudFonts")).collect(); + dirs.extend( + ["Microsoft Word", "Microsoft Excel", "Microsoft PowerPoint", "Microsoft Outlook"] + .map(|app| env.rooted(format!("/Applications/{app}.app/Contents/Resources/DFonts"))), + ); + dirs + } + Os::Windows => env.local_app_data.iter().map(|l| l.join("Microsoft\\FontCache\\4\\CloudFonts")).collect(), + _ => Vec::new(), + } +} + +/// `dirs` without repeats (also one folder reached through a link), keeping the first. Windows +/// paths compare ignoring case. +pub(crate) fn dedup_dirs(dirs: Vec, ignore_case: bool) -> Vec { + let mut seen = std::collections::HashSet::new(); + dirs.into_iter() + .filter(|d| { + let key = std::fs::canonicalize(d).unwrap_or_else(|_| d.clone()); + seen.insert(if ignore_case { PathBuf::from(key.to_string_lossy().to_lowercase()) } else { key }) + }) + .collect() +} diff --git a/crates/font-catalog/src/fontconfig.rs b/crates/font-catalog/src/fontconfig.rs new file mode 100644 index 00000000..401e5f31 --- /dev/null +++ b/crates/font-catalog/src/fontconfig.rs @@ -0,0 +1,193 @@ +//! The font folders named by the fontconfig configuration, read with a small tag scanner (no +//! fontconfig library, no XML crate): only ``, `` and `` matter here. +//! The work is bounded: include depth, number and size of the files read, and every file is read +//! at most once. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::FontEnv; + +const MAX_INCLUDE_DEPTH: u32 = 8; +const MAX_FILES: usize = 512; +const MAX_FILE_BYTES: u64 = 1 << 20; + +/// The folders the fontconfig configuration for `env` names, in the order it names them. +/// +/// The main file is `FONTCONFIG_FILE`, else the first of `/etc/fonts/fonts.conf` and +/// `/usr/local/etc/fonts/fonts.conf` (FreeBSD) that exists. The user's own configuration +/// (`$XDG_CONFIG_HOME/fontconfig/fonts.conf` and `conf.d`) is read too, in case the main file +/// doesn't include it. In a Flatpak sandbox ([`FontEnv::flatpak`]) the host's folders are added: +/// those `/run/host/font-dirs.xml` maps, and those of the host's own configuration when the +/// sandbox can see it (`/run/host/etc/fonts/fonts.conf`), under `/run/host`. +pub fn fontconfig_dirs(env: &FontEnv) -> Vec { + let mut reader = Reader { env, out: Vec::new(), seen: HashSet::new(), host: None }; + let root = match &env.fontconfig_file { + Some(f) => Some(f.clone()), + None => ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(|p| env.rooted(p)).into_iter().find(|p| p.is_file()), + }; + if let Some(root) = root { + reader.file(&root, 0); + } + if let Some(c) = env.config_home() { + reader.include(&c.join("fontconfig/fonts.conf"), 0); + reader.include(&c.join("fontconfig/conf.d"), 0); + } + if env.flatpak { + reader.file(&env.rooted("/run/host/font-dirs.xml"), 0); + reader.host = Some(env.rooted("/run/host")); + reader.file(&env.rooted("/run/host/etc/fonts/fonts.conf"), 0); + } + reader.out +} + +struct Reader<'a> { + env: &'a FontEnv, + out: Vec, + seen: HashSet, + /// While reading the host's configuration from a sandbox: where the host's root is. + host: Option, +} + +impl Reader<'_> { + /// Read a configuration file or, for a folder, its `NN-name.conf` files in name order. + fn include(&mut self, path: &Path, depth: u32) { + if path.is_dir() { + let Ok(rd) = std::fs::read_dir(path) else { return }; + let mut files: Vec = rd + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit())) + }) + .collect(); + files.sort(); + for f in files { + self.file(&f, depth); + } + } else { + self.file(path, depth); + } + } + + fn file(&mut self, path: &Path, depth: u32) { + if depth > MAX_INCLUDE_DEPTH || self.seen.len() >= MAX_FILES || !path.is_file() { + return; + } + let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + if !self.seen.insert(key) { + return; + } + if std::fs::metadata(path).map(|m| m.len() > MAX_FILE_BYTES).unwrap_or(true) { + return; + } + let Ok(text) = std::fs::read_to_string(path) else { return }; + let base = path.parent().map(Path::to_path_buf).unwrap_or_default(); + for el in elements(&text) { + match el.name { + "dir" | "remap-dir" => { + if let Some(p) = self.resolve(&el, &base, false) { + self.out.push(p); + } + } + "include" => { + if let Some(p) = self.resolve(&el, &base, true) { + self.include(&p, depth.saturating_add(1)); + } + } + _ => {} + } + } + } + + /// The path an element names: `~` is the home folder, `prefix="xdg"` the XDG data (folders) + /// or config (includes) home, `prefix="relative"` and plain relative paths the including + /// file's folder, `prefix="default"`/`"cwd"` the working folder. Absolute paths are under the + /// root, or under the host's root while reading the host's configuration. + fn resolve(&self, el: &Element, base: &Path, include: bool) -> Option { + let v = el.text.trim(); + if v.is_empty() { + return None; + } + if let Some(rest) = v.strip_prefix('~') { + return Some(self.env.home.as_ref()?.join(rest.trim_start_matches('/'))); + } + let p = Path::new(v); + if p.is_absolute() { + return Some(match &self.host { + Some(host) => host.join(p.strip_prefix("/").unwrap_or(p)), + None => self.env.rooted(p), + }); + } + match el.attr("prefix") { + Some("xdg") => Some(if include { self.env.config_home()? } else { self.env.data_home()? }.join(p)), + Some("default" | "cwd") => std::env::current_dir().ok().map(|c| c.join(p)), + _ => Some(base.join(p)), + } + } +} + +struct Element<'a> { + name: &'a str, + attrs: &'a str, + text: String, +} + +impl Element<'_> { + fn attr(&self, key: &str) -> Option<&str> { + let mut rest = self.attrs; + while let Some(eq) = rest.find('=') { + let k = rest.get(..eq)?.trim(); + let after = rest.get(eq + 1..)?.trim_start(); + let q = after.chars().next()?; + if q != '"' && q != '\'' { + return None; + } + let body = after.get(1..)?; + let end = body.find(q)?; + if k == key { + return body.get(..end); + } + rest = body.get(end + 1..)?; + } + None + } +} + +/// The ``, `` and `` elements of a fontconfig file, comments skipped. +fn elements(src: &str) -> Vec> { + let mut out = Vec::new(); + let mut i = 0; + while let Some(off) = src.get(i..).and_then(|s| s.find('<')) { + let start = i + off; + let Some(tail) = src.get(start..) else { break }; + if tail.starts_with("") { + Some(e) => { + i = start + e + 3; + continue; + } + None => break, + } + } + let Some(close) = tail.find('>') else { break }; + let inner = tail.get(1..close).unwrap_or_default(); + i = start + close + 1; + let name_len = inner.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(inner.len()); + let name = inner.get(..name_len).unwrap_or_default(); + if !matches!(name, "dir" | "remap-dir" | "include") || inner.ends_with('/') { + continue; + } + let attrs = inner.get(name_len..).unwrap_or_default(); + let end_tag = format!(""); + let Some(body_len) = src.get(i..).and_then(|s| s.find(&end_tag)) else { break }; + let body = src.get(i..i + body_len).unwrap_or_default(); + i += body_len + end_tag.len(); + out.push(Element { name, attrs, text: decode_entities(body) }); + } + out +} + +fn decode_entities(s: &str) -> String { + s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") +} diff --git a/crates/font-catalog/src/lib.rs b/crates/font-catalog/src/lib.rs new file mode 100644 index 00000000..c787d3ec --- /dev/null +++ b/crates/font-catalog/src/lib.rs @@ -0,0 +1,37 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! The installed fonts, for every Craft app: discovery on every platform, lookup by family and +//! style or by any other name, and loading shared between threads. +//! +//! A [`Catalog`] scans the platform's font folders ([`font_dirs`]), the suite's font store +//! ([`font_store_dir`]), the app's own folders ([`Catalog::set_app_dirs`]) and, with the `system` +//! feature, the files the platform's font service knows ([`system`]): CoreText on macOS, +//! DirectWrite on Windows, fontconfig on Linux and FreeBSD. On Windows it also reads the +//! registry's font list. The scan reads each file's table directory and its `name`, `OS/2` and +//! `fvar` tables, never whole files, and a rescan reads only files that changed. +//! +//! ```no_run +//! use craft_font_catalog::{Catalog, Match}; +//! +//! let catalog = Catalog::global(); +//! catalog.scan_in_background(); +//! if let Some((face, Match::Exact)) = catalog.resolve("Source Serif 4", "Bold Italic") { +//! let data = catalog.load(&face); +//! } +//! ``` + +mod catalog; +mod env; +mod fontconfig; +mod registry; +mod scan; +mod suitcase; +pub mod system; + +pub use catalog::{Catalog, FontData, FontFileError, Limits, Lister, LoadedFace, Match, Watch}; +pub use env::{FontEnv, Os, font_dirs, font_store_dir, office_font_dirs}; +pub use fontconfig::fontconfig_dirs; +pub use scan::{FaceInfo, LocalizedName, Source, is_font_file, style_italic, style_weight, style_width}; + +#[cfg(test)] +mod tests; diff --git a/crates/font-catalog/src/registry.rs b/crates/font-catalog/src/registry.rs new file mode 100644 index 00000000..11154234 --- /dev/null +++ b/crates/font-catalog/src/registry.rs @@ -0,0 +1,30 @@ +//! The fonts registered with Windows in the registry: one value per face, holding a file name in +//! the Windows font folder or a full path. Programs that install fonts into their own folders +//! register them with full paths, which only the registry lists. + +use std::path::PathBuf; + +/// The full paths the registry lists for the machine's and the current user's fonts, sorted and +/// without repeats. Bare file names (files in the Windows font folder) are left out: the scan +/// reads that folder. Empty elsewhere than on Windows. +pub(crate) fn registered_font_files() -> Vec { + #[cfg(windows)] + { + const MAX_REGISTERED: usize = 1 << 16; + const KEY: &str = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts"; + use windows_registry::{CURRENT_USER, LOCAL_MACHINE}; + let mut files: Vec = [LOCAL_MACHINE, CURRENT_USER] + .into_iter() + .filter_map(|root| root.open(KEY).ok()) + .flat_map(|key| key.values().map(|vs| vs.filter_map(|(_, v)| String::try_from(v).ok()).collect::>()).unwrap_or_default()) + .take(MAX_REGISTERED) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .collect(); + files.sort(); + files.dedup(); + files + } + #[cfg(not(windows))] + Vec::new() +} diff --git a/crates/font-catalog/src/scan.rs b/crates/font-catalog/src/scan.rs new file mode 100644 index 00000000..ae321dac --- /dev/null +++ b/crates/font-catalog/src/scan.rs @@ -0,0 +1,461 @@ +//! Reading face records from font files: the table directory and the `name`, `OS/2` and `fvar` +//! tables, never the whole file. + +use std::path::{Path, PathBuf}; + +use skrifa::raw::{FileRef, TableProvider}; +use skrifa::{MetadataProvider, string::StringId}; + +/// Where a face comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Source { + /// Installed on the system: a platform font folder or the platform's font service. + System, + /// The suite's font store ([`crate::font_store_dir`]). + Store, + /// A folder the app added ([`crate::Catalog::set_app_dirs`]). + App, + /// Bundled with the app and added as bytes. + Bundled, + /// Added by the user to this app. + User, + /// Embedded in, or shipped beside, an open document. + Document, +} + +/// A name in one language, from the font's `name` table. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LocalizedName { + /// The record's BCP 47 language tag (`ja`, `zh-Hant`), when the font says. + pub language: Option, + /// The name. + pub name: String, +} + +/// One face of an installed or added font: one face of a file, or one named instance of a +/// variable font. +#[derive(Clone, Debug, PartialEq)] +pub struct FaceInfo { + /// The family: the typographic family name (name ID 16), else the family name (ID 1), in + /// English when the font has an English record. + pub family: String, + /// The style: the typographic subfamily (ID 17), else the subfamily (ID 2), else `Regular`. + /// A named instance's subfamily name. + pub style: String, + /// The legacy family name (ID 1) when it differs from [`FaceInfo::family`] (`Roboto Light` + /// in the family `Roboto`). + pub legacy_family: Option, + /// The legacy style (ID 2) that goes with [`FaceInfo::legacy_family`]. + pub legacy_style: Option, + /// The family's names in other languages (`ヒラギノ角ゴシック`). + pub localized_families: Vec, + /// The full name (ID 4) of the face itself; `None` for a named instance. + pub full_name: Option, + /// The PostScript name (ID 6, or a named instance's own), as PDF and other documents name fonts. + pub postscript_name: Option, + /// The weight, 1 to 1000 (400 regular, 700 bold): `usWeightClass`, a named instance's `wght`, + /// else the weight the style name implies. + pub weight: f32, + /// The width as a percentage of normal (100; 75 condensed, 125 expanded): `usWidthClass`, a + /// named instance's `wdth`, else the width the style name implies. + pub width: f32, + /// Italic or oblique. + pub italic: bool, + /// A named instance's axis settings (tag, user units); empty for the face itself. + pub variations: Vec<([u8; 4], f32)>, + /// The face has variation axes. + pub variable: bool, + /// The OS/2 `fsType` embedding permissions (0 without an OS/2 table). + pub fs_type: u16, + /// The OS/2 code page bits (`ulCodePageRange1`, `ulCodePageRange2`). + pub code_pages: [u32; 2], + /// The file the face is in; `None` for a face added as bytes. + pub path: Option, + /// The face's number in its file: its index in a collection (0 in a single font), or for a + /// Mac suitcase font the number of its `sfnt` resource. + pub index: u32, + /// Where it comes from. + pub source: Source, + /// For a face added as bytes: which of the catalog's added blobs holds it. + pub(crate) blob: Option, +} + +/// A face record with the normalized names it answers to. +#[derive(Clone, Debug)] +pub(crate) struct Face { + pub info: FaceInfo, + pub keys: Keys, +} + +/// Every name a face answers to besides its family and style, normalized ([`norm`]). +#[derive(Clone, Debug, Default)] +pub(crate) struct Keys { + /// Its family in every language, typographic else legacy. + pub families: Vec, + /// Its style in every language, and a variable font's default instance's name. + pub styles: Vec, + /// Legacy family and style pairs (`Hiragino Sans W3` / `Regular`), per language record. + pub legacy: Vec<(String, String)>, + pub full_names: Vec, + pub postscript: Vec, +} + +const MAX_AXES: usize = 64; +const MAX_INSTANCES: usize = 512; +/// The most faces read from a (possibly damaged) collection. +pub(crate) const MAX_FACES: u32 = 256; +/// The largest table the scan reads. +const MAX_TABLE: u32 = 1 << 20; +/// Tables holding outlines a renderer draws. A face with none of them (outlines in Apple's `hvgl` +/// table, bitmaps only) draws nothing and is left out. +const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"]; + +pub(crate) fn norm(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect() +} + +/// The weight a style name implies (400 when it names none). +pub fn style_weight(style: &str) -> f32 { + const TABLE: &[(&str, f32)] = &[ + ("extralight", 200.0), + ("ultralight", 200.0), + ("semibold", 600.0), + ("demibold", 600.0), + ("extrabold", 800.0), + ("ultrabold", 800.0), + ("hairline", 100.0), + ("thin", 100.0), + ("light", 300.0), + ("medium", 500.0), + ("bold", 700.0), + ("black", 900.0), + ("heavy", 900.0), + ]; + let s = norm(style); + TABLE.iter().find(|(k, _)| s.contains(k)).map_or(400.0, |(_, w)| *w) +} + +/// Whether a style name says italic or oblique. +pub fn style_italic(style: &str) -> bool { + let s = norm(style); + s.contains("italic") || s.contains("oblique") || s == "it" +} + +/// The width a style name implies, in percent of normal (100 when it names none). +pub fn style_width(style: &str) -> f32 { + const TABLE: &[(&str, f32)] = &[ + ("ultracondensed", 50.0), + ("extracondensed", 62.5), + ("semicondensed", 87.5), + ("condensed", 75.0), + ("narrow", 75.0), + ("ultraexpanded", 200.0), + ("extraexpanded", 150.0), + ("semiexpanded", 112.5), + ("expanded", 125.0), + ("extended", 125.0), + ("wide", 125.0), + ]; + let s = norm(style); + TABLE.iter().find(|(k, _)| s.contains(k)).map_or(100.0, |(_, w)| *w) +} + +/// `usWidthClass` (1–9) as a percentage of normal. +fn width_class_percent(class: u16) -> Option { + const PERCENT: [f32; 9] = [50.0, 62.5, 75.0, 87.5, 100.0, 112.5, 125.0, 150.0, 200.0]; + PERCENT.get(usize::from(class).checked_sub(1)?).copied() +} + +fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { + ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) +} + +/// Every string of `id`, with its language. +fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option, String)> { + font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect() +} + +fn all_names(font: &skrifa::FontRef<'_>, id: StringId) -> Vec { + localized(font, id).into_iter().map(|(_, s)| s).collect() +} + +fn sort_dedup(v: &mut Vec) { + v.retain(|k| !k.is_empty()); + v.sort(); + v.dedup(); +} + +/// The faces `font` (face `index` of its file) offers: the face itself, then the named instances +/// of a variable font. Empty when it has no family name. +pub(crate) fn font_faces(font: &skrifa::FontRef<'_>, index: u32, path: Option<&Path>, source: Source) -> Vec { + let Some(family) = name(font, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]) else { return vec![] }; + let style = name(font, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + let typo_family = localized(font, StringId::TYPOGRAPHIC_FAMILY_NAME); + let typo_style = all_names(font, StringId::TYPOGRAPHIC_SUBFAMILY_NAME); + let legacy_family = localized(font, StringId::FAMILY_NAME); + let legacy_style = localized(font, StringId::SUBFAMILY_NAME); + + let family_records = if typo_family.is_empty() { &legacy_family } else { &typo_family }; + let mut localized_families: Vec = Vec::new(); + for (language, n) in family_records { + if *n != family && !localized_families.iter().any(|l| l.name == *n) { + localized_families.push(LocalizedName { language: language.clone(), name: n.clone() }); + } + } + let mut families: Vec = std::iter::once(family.as_str()).chain(family_records.iter().map(|(_, f)| f.as_str())).map(norm).collect(); + // A legacy style names the face beside its legacy family only when the font has a + // typographic style: every weight of `Hiragino Sans` is `Regular` in its own legacy family. + let style_names: Vec = if typo_style.is_empty() { legacy_style.iter().map(|(_, s)| s.clone()).collect() } else { typo_style }; + let mut styles: Vec = std::iter::once(style.as_str()).chain(style_names.iter().map(String::as_str)).map(norm).collect(); + let mut legacy: Vec<(String, String)> = if typo_family.is_empty() { + vec![] + } else { + legacy_family + .iter() + .flat_map(|(lf, f)| legacy_style.iter().filter(move |(ls, _)| ls == lf).map(move |(_, s)| (norm(f), norm(s)))) + .filter(|(f, s)| !f.is_empty() && !s.is_empty()) + .collect() + }; + legacy.sort(); + legacy.dedup(); + let mut postscript: Vec = all_names(font, StringId::POSTSCRIPT_NAME).iter().map(|n| norm(n)).collect(); + let mut full_names: Vec = all_names(font, StringId::FULL_NAME).iter().map(|n| norm(n)).collect(); + for v in [&mut families, &mut styles, &mut full_names, &mut postscript] { + sort_dedup(v); + } + + let os2 = font.os2().ok(); + let weight = os2 + .as_ref() + .map(|t| t.us_weight_class()) + .map(|w| if (1..10).contains(&w) { w * 100 } else { w }) + .filter(|w| (1..=1000).contains(w)) + .map_or_else(|| style_weight(&style), f32::from); + let width = os2.as_ref().and_then(|t| width_class_percent(t.us_width_class())).unwrap_or_else(|| style_width(&style)); + let selection_italic = os2.as_ref().is_some_and(|t| { + let f = t.fs_selection(); + f.contains(skrifa::raw::tables::os2::SelectionFlags::ITALIC) || f.contains(skrifa::raw::tables::os2::SelectionFlags::OBLIQUE) + }); + let fs_type = os2.as_ref().map_or(0, |t| t.fs_type()); + let code_pages = os2.as_ref().map_or([0, 0], |t| [t.ul_code_page_range_1().unwrap_or(0), t.ul_code_page_range_2().unwrap_or(0)]); + let axes = font.axes(); + let legacy_pair = (!typo_family.is_empty()) + .then(|| name(font, &[StringId::FAMILY_NAME])) + .flatten() + .filter(|f| *f != family) + .map(|f| (f, name(font, &[StringId::SUBFAMILY_NAME]))); + + let info = FaceInfo { + family: family.clone(), + style: style.clone(), + legacy_family: legacy_pair.as_ref().map(|(f, _)| f.clone()), + legacy_style: legacy_pair.and_then(|(_, s)| s), + localized_families, + full_name: name(font, &[StringId::FULL_NAME]), + postscript_name: name(font, &[StringId::POSTSCRIPT_NAME]), + weight, + width, + italic: selection_italic || style_italic(&style), + variations: vec![], + variable: !axes.is_empty(), + fs_type, + code_pages, + path: path.map(Path::to_path_buf), + index, + source, + blob: None, + }; + let mut keys = Keys { families, styles, legacy, full_names, postscript }; + let instances = named_instances(font, &info, &mut keys); + std::iter::once(Face { info, keys }).chain(instances).collect() +} + +/// The named instances of a variable face (its `fvar` table) as faces of its family, without the +/// default instance (the face itself) and without names already taken. The default instance's +/// name, when it isn't the face's style (a face `Regular` whose default instance is `Book`), names +/// the face too. Damaged tables give fewer instances or none. +fn named_instances(font: &skrifa::FontRef<'_>, face: &FaceInfo, keys: &mut Keys) -> Vec { + let axes = font.axes(); + if axes.is_empty() || axes.len() > MAX_AXES { + return vec![]; + } + let tags: Vec<[u8; 4]> = axes.iter().map(|a| a.tag().to_be_bytes()).collect(); + let defaults: Vec = axes.iter().map(|a| a.default_value()).collect(); + // Instances without a PostScript name of their own are named `-