Browse / github.com/storytold/craft-libs / patch
font-catalog: faces_in, scope_generation, remove_file_in, and a per-scope added-face limit
font-catalog: faces_in, scope_generation, remove_file_in, and a per-scope added-face limit Scope API gaps found by DesignCraft's Document Fonts port. Stacks on #381 (and #376); until those merge, the diff includes their commits. This PR's own commit is a04023d7. - `faces_in(scope)` lists the scope's own added faces, in the order they were added. A face added twice is listed once. - `scope_generation(scope)` counts only the scope's own adds and removals, so scans, unscoped changes and other scopes don't move it. `generation_in` is unchanged. - `remove_file_in(scope, path)` removes the faces that `add_file_in` added for that path and returns how many it removed. - `Limits::max_added_
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- aiongg (imported from pull request #385, open)
- Base
9ff719499d8f4730b93071abed993e5f12fe1be8- Size
- +1592 −136 in 10 files
- Review
- pending
- Shared
- 2026-10-28
- Id
727b3f0292ea839fd7076730
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock crates/font-catalog/Cargo.toml crates/font-catalog/README.md crates/font-catalog/examples/fonts.rs crates/font-catalog/src/app.rs crates/font-catalog/src/catalog.rs crates/font-catalog/src/env.rs crates/font-catalog/src/lib.rs crates/font-catalog/src/scan.rs crates/font-catalog/src/tests.rs
@@ -784,6 +784,7 @@ name = "craft-font-catalog" version = "0.1.0" dependencies = [ "fontique",+ "libc", "skrifa 0.47.0", "thiserror 2.0.21", "windows-registry",@@ -21,6 +21,9 @@ thiserror = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] fontique = { version = "0.11.1", default-features = false, features = ["system", "fontconfig-dlopen"], optional = true } +[target.'cfg(unix)'.dependencies]+libc = "0.2"+ [target.'cfg(windows)'.dependencies] windows-registry = "0.6" @@ -11,6 +11,7 @@ A `Catalog` reads, in this order: | The platform's font folders (`font_dirs`) | `System` | all | | The suite's font store (`font_store_dir`) | `Store` | all | | The app's own folders (`Catalog::set_app_dirs`, for example `office_font_dirs`) | `App` | all |+| Folders the user or an automation caller picked (`Catalog::set_confined_dirs`) | `User` | all | | Files the platform's font service knows (`system` feature, through fontique) | `System` | macOS, Windows, Linux, FreeBSD | | The registry's font list | `System` | Windows | | Files from injected listers (`Catalog::with_lister`) | `System` | all |@@ -44,7 +45,7 @@ The font service covers fonts outside those folders: ## 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.+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. Only regular files are read (after following links), and on Unix the open doesn't wait, so a FIFO or device named `x.ttf` can't block the scan. The scan follows links, except in the folders given to `set_confined_dirs`: there a link whose target is outside the folder is skipped, and on Unix the file is opened without following a link put in its place later, by the scan and by every load. Windows follows such a link when a load opens the file. Mac suitcase fonts are read from their resource fork, except in confined folders, where they give no faces: the fork is opened through the file's path, which follows links. 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. @@ -59,8 +60,19 @@ Each face (`FaceInfo`) has its family and style, legacy family and style, family - 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.+- `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]>`) while anything holds them; a file replaced since is read again.+- `add_bytes` and `add_file` add fonts from downloads, the user or the app's bundle, within `Limits`, for every lookup. They never wait for a scan. `add_bytes` takes anything that becomes an `Arc<[u8]>` and keeps it, and `add_bytes_many` adds several fonts with one rebuild of the lookups. Added faces win over installed faces of the same family and style. `remove_added` removes a source's faces. `add_file` refuses a path that isn't a regular file.++## Document scopes++A document's own fonts (embedded, or in a folder beside it) go into a scope, so they change only that document's lookups.++- `new_scope()` returns a `Scope`. `add_bytes_in(scope, …)` and `add_file_in(scope, …)` add faces to it.+- `resolve_in`, `by_postscript_name_in`, `family_in`, `styles_in`, `families_in` and `load_family_in` look up in a scope. Inside a scope, its own faces come first, then the faces added without a scope, then the installed faces. A family the scope has faces of keeps its other styles from the installed fonts.+- `faces_in(scope)` lists the scope's own faces in the order they were added (not the shared ones), one of each face as lookups see it. It never waits for a scan.+- Other scopes and the unscoped lookups never see a scope's faces.+- `remove_file_in(scope, path)` removes the faces one `add_file_in` file gave, when the document drops or replaces it. `remove_scope(scope)` removes all of them when the document closes. Other scopes keep theirs, and `remove_added` doesn't touch scoped faces.+- `Limits::max_added_faces` bounds each scope on its own, and the unscoped added faces on their own. ## Changes @@ -68,7 +80,21 @@ Each face (`FaceInfo`) has its family and style, legacy family and style, family - `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.+- `generation` counts the changes unscoped lookups see: scans, and faces added or removed without a scope. A scope's changes leave it alone, so other documents don't lay out again. `generation_in(scope)` adds the scope's own changes and never repeats a value, even after `remove_scope`; a document lays out again when it changes. `scope_generation(scope)` counts the scope's own changes alone (its adds and removals), for a cache that takes shared changes through `changes_since`.+- `changes_since(generation)` lists the families unscoped lookups find differently since then: `added`, `removed`, and `changed` (a style installed or removed, a file replaced). An app re-resolves only those names and lays out only the documents that use them. The catalog keeps the last 64 changes of the family set, at most 4,096 family names in all; for an older generation it answers `full`, and the app re-resolves everything.++## In an app++The `app` module is what every Craft app does with the catalog:++- `app::start(AppFonts { dirs, office, bundled, .. })` at launch, before the first window: the app's folders, Office's (`Office::Bundled` for the fonts in the Office apps' bundles, `Office::All` with the cloud fonts too), its bundled faces as `Source::Bundled`, and the first scan on a background thread. It doesn't wait for the scan. It returns the global catalog and the bundled faces that couldn't be added.+- `app::on_activated()` when a window is activated: `rescan_if_changed` on a worker thread, and a full `rescan` at most every `AppFonts::full_rescan_every` (5 minutes by default), for fonts a font service activates from folders no scan has seen. While a worker runs, activations do nothing. `AppFonts::on_change` is called on the worker when a family changed, to wake an idle window. craft-ui's UI fallback follows rescans by itself.+- `app::rescan_now()` for the app's "Reload fonts" command: a full rescan on the calling thread, which counts as the activations' full rescan and calls `on_change` the same way.+- `app::changes_since(generation)` tells the app which documents to lay out again.+- A CLI calls `app::configure` instead of `start`: the first lookup scans and waits for it, and there are no activation hooks. Its "no system fonts" switch first makes `Catalog::without_installed_fonts()` the global catalog (`Catalog::init_global`), which reads no folders, no font store and no font service, only the faces and folders the app adds. Tests that must not see the host's fonts use the same constructor. On macOS a CLI uses `Office::Bundled` at most: reading Office's CloudFonts folder can raise a privacy prompt.+- On wasm there are no folders, no font service and no threads: `start` adds the bundled faces, other fonts come in through `add_bytes`, and `on_activated` does nothing.++A full rescan asks the font service again. On Linux it takes about 60 ms with 150 font files and 0.5 s with 4,000 (release build); `rescan_if_changed` takes well under a millisecond. ## One enumeration for the UI and documents @@ -80,4 +106,5 @@ With the `system` feature, `system::collection()` returns the process's fontique 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+cargo run -p craft-font-catalog --example fonts --release -- --time # what rescans cost ```@@ -4,6 +4,7 @@ //! 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+//! cargo run -p craft-font-catalog --example fonts --release -- --time # what rescans cost //! ``` use std::time::{Duration, Instant};@@ -28,6 +29,24 @@ fn main() { 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("--time") => {+ for _ in 0..3 {+ let start = Instant::now();+ catalog.rescan_if_changed();+ println!("rescan_if_changed: {:?}", start.elapsed());+ }+ for _ in 0..3 {+ let start = Instant::now();+ let faces = catalog.rescan();+ println!("rescan: {:?} ({faces} faces)", start.elapsed());+ }+ #[cfg(feature = "system")]+ for _ in 0..3 {+ let start = Instant::now();+ craft_font_catalog::system::refresh();+ println!("font service enumeration alone: {:?}", start.elapsed());+ }+ } 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");new file mode 100644@@ -0,0 +1,268 @@+//! What every Craft app does with the catalog: start it at launch, refresh it when a window is+//! activated, and learn which families changed so it lays out only the documents they affect.+//!+//! ```no_run+//! use craft_font_catalog::app::{self, AppFonts, Office};+//!+//! // At launch, before the first window.+//! let started = app::start(AppFonts { dirs: vec!["/path/to/prefs/Fonts".into()], office: Office::All, ..AppFonts::default() });+//! let mut seen = started.catalog.generation();+//!+//! // When a window is activated.+//! app::on_activated();+//!+//! // The app's "Reload fonts" command.+//! app::rescan_now();+//!+//! // Where the app checks for work (each frame, or from `AppFonts::on_change`).+//! if started.catalog.generation() != seen {+//! let changes = app::changes_since(seen);+//! seen = changes.generation;+//! // Re-resolve the families each document asked for that were missing or nearest-style+//! // (all of them when `changes.full`), and lay out the documents where one now resolves+//! // differently or uses a removed or changed family.+//! }+//! ```+//!+//! - Desktop: [`start`] adds the app's folders and bundled faces and scans on a background+//! thread. [`on_activated`] runs [`Catalog::rescan_if_changed`] on a worker thread, and a full+//! [`Catalog::rescan`] at most every [`AppFonts::full_rescan_every`], for fonts a font service+//! (Adobe Fonts, FontBase) activates from folders no scan has seen. craft-ui's UI fallback+//! follows rescans by itself.+//! - CLI: [`configure`] instead of [`start`], so the first lookup scans and waits for it, and no+//! activation hooks or [`Catalog::watch`]. A "no system fonts" switch makes+//! [`Catalog::without_installed_fonts`] the global catalog first ([`Catalog::init_global`]).+//! On macOS, use [`Office::Bundled`] at most: reading Office's CloudFonts folder can raise a+//! privacy prompt, which a CLI can't answer.+//! - wasm: there are no folders, no font service and no threads. [`start`] adds the bundled faces;+//! other fonts come in through [`Catalog::add_bytes`]. [`on_activated`] does nothing.++use std::path::PathBuf;+use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};+use std::time::{Duration, Instant};++use crate::env::{office_bundled_font_dirs, office_cloud_font_dirs};+use crate::{Catalog, Changes, FontFileError, Limits, Source};++/// How often [`on_activated`] runs a full [`Catalog::rescan`] at most, by default. A full rescan+/// lists the font service's files again: about 60 ms with 150 font files and 0.5 s with 4,000 on+/// Linux, on the worker thread.+pub const FULL_RESCAN_EVERY: Duration = Duration::from_secs(5 * 60);++/// Called on the worker thread after [`on_activated`] changed a family.+pub type OnChange = Arc<dyn Fn(&Catalog) + Send + Sync>;++/// Which of Office's font folders an app reads ([`crate::office_font_dirs`]), for the apps that+/// open Office documents.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]+pub enum Office {+ /// None.+ #[default]+ None,+ /// The fonts in the Office apps' bundles (macOS `DFonts`; Windows has none), whose reading+ /// raises no privacy prompt.+ Bundled,+ /// Those and the cloud fonts Office downloads (macOS: in Office's group container, which can+ /// raise a privacy prompt; Windows: under `%LOCALAPPDATA%`).+ All,+}++/// The app's fonts, for [`start`].+#[derive(Clone)]+pub struct AppFonts {+ /// The app's own font folders (`<prefs>/Fonts`, folders the user added).+ pub dirs: Vec<PathBuf>,+ /// Which of Office's font folders to read too.+ pub office: Office,+ /// Fonts that ship with the app (craft-fonts, the app's own), added as [`Source::Bundled`]+ /// with one rebuild of the lookups ([`Catalog::add_bytes_many`]). Shared, not copied:+ /// `Arc::from(bytes)` of a `Vec<u8>` or a `&'static [u8]` copies once.+ pub bundled: Vec<Arc<[u8]>>,+ /// How often [`on_activated`] runs a full [`Catalog::rescan`] at most ([`Duration::MAX`]:+ /// never).+ pub full_rescan_every: Duration,+ /// Called after [`on_activated`] found a family added, removed or changed, to wake an idle+ /// window so it checks [`changes_since`]. Keep it short: activations do nothing while it runs.+ pub on_change: Option<OnChange>,+}++impl Default for AppFonts {+ fn default() -> Self {+ Self { dirs: Vec::new(), office: Office::None, bundled: Vec::new(), full_rescan_every: FULL_RESCAN_EVERY, on_change: None }+ }+}++/// What [`start`] and [`configure`] did.+pub struct Started {+ /// The global catalog.+ pub catalog: &'static Arc<Catalog>,+ /// The bundled faces that couldn't be added, by their place in [`AppFonts::bundled`].+ pub bundled_errors: Vec<(usize, FontFileError)>,+}++/// Start the global catalog for a desktop or web app: [`configure`], then the first scan on a+/// background thread. It doesn't wait for the scan. Call it once, before the first window and+/// before anything else uses [`Catalog::global`] or [`on_activated`].+pub fn start(fonts: AppFonts) -> Started {+ let started = configure(fonts);+ started.catalog.scan_in_background();+ started+}++/// [`start`] without the background scan, for a CLI: the first lookup scans and waits for it.+pub fn configure(fonts: AppFonts) -> Started {+ let catalog = Catalog::global();+ let (full_every, on_change) = (fonts.full_rescan_every, fonts.on_change.clone());+ let bundled_errors = add_app_fonts(catalog, fonts);+ let _ = REFRESH.set(Arc::new(Refresh::new(Arc::clone(catalog), full_every, on_change)));+ Started { catalog, bundled_errors }+}++/// The folders and bundled faces of `fonts` into `catalog`; returns the bundled faces that+/// couldn't be added.+pub(crate) fn add_app_fonts(catalog: &Catalog, fonts: AppFonts) -> Vec<(usize, FontFileError)> {+ let mut dirs = fonts.dirs;+ if fonts.office != Office::None {+ dirs.extend(office_bundled_font_dirs(catalog.env()));+ }+ if fonts.office == Office::All {+ dirs.extend(office_cloud_font_dirs(catalog.env()));+ }+ catalog.set_app_dirs(dirs);+ let added = catalog.add_bytes_many(fonts.bundled, Source::Bundled, &Limits::default());+ added.into_iter().enumerate().filter_map(|(i, r)| Some((i, r.err()?))).collect()+}++/// For a window's activation: on a worker thread, [`Catalog::rescan_if_changed`], or a full+/// [`Catalog::rescan`] when the last was [`AppFonts::full_rescan_every`] ago or more. It never+/// blocks: while a worker runs, another activation does nothing. Nothing happens before the+/// first scan has finished, or on wasm. Returns whether a worker started.+pub fn on_activated() -> bool {+ refresh().activated()+}++fn refresh() -> &'static Arc<Refresh> {+ REFRESH.get_or_init(|| Arc::new(Refresh::new(Arc::clone(Catalog::global()), FULL_RESCAN_EVERY, None)))+}++/// A full [`Catalog::rescan`] now, on this thread, for the app's "Reload fonts" command. It counts+/// as [`on_activated`]'s full rescan, so the next activation doesn't run one again at once, and+/// calls [`AppFonts::on_change`] when a family changed. Returns what changed.+pub fn rescan_now() -> Changes {+ refresh().rescan_now(if cfg!(target_arch = "wasm32") { None } else { Some(Instant::now()) })+}++/// [`Catalog::changes_since`] of the global catalog.+pub fn changes_since(generation: u64) -> Changes {+ Catalog::global().changes_since(generation)+}++static REFRESH: OnceLock<Arc<Refresh>> = OnceLock::new();++/// The activation rescans of one catalog.+pub(crate) struct Refresh {+ catalog: Arc<Catalog>,+ full_every: Duration,+ on_change: Option<OnChange>,+ state: Mutex<RefreshState>,+}++#[derive(Default)]+struct RefreshState {+ running: bool,+ /// When the last full rescan started; the first activation counts the first scan as one.+ /// Not taken at construction: `Instant::now` panics on wasm.+ last_full: Option<Instant>,+}++fn lock(m: &Mutex<RefreshState>) -> MutexGuard<'_, RefreshState> {+ m.lock().unwrap_or_else(PoisonError::into_inner)+}++/// Clears [`RefreshState::running`] when the worker ends, panicking included.+struct Running<'a>(&'a Mutex<RefreshState>);++impl Drop for Running<'_> {+ fn drop(&mut self) {+ lock(self.0).running = false;+ }+}++impl Refresh {+ pub(crate) fn new(catalog: Arc<Catalog>, full_every: Duration, on_change: Option<OnChange>) -> Self {+ Self { catalog, full_every, on_change, state: Mutex::default() }+ }++ fn activated(self: &Arc<Self>) -> bool {+ if cfg!(target_arch = "wasm32") {+ return false;+ }+ self.activated_at(Instant::now())+ }++ pub(crate) fn activated_at(self: &Arc<Self>, now: Instant) -> bool {+ if !self.catalog.is_ready() {+ return false;+ }+ let (full, last_full) = {+ let mut state = lock(&self.state);+ if state.running {+ return false;+ }+ let last_full = *state.last_full.get_or_insert(now);+ let full = now.saturating_duration_since(last_full) >= self.full_every;+ if full {+ state.last_full = Some(now);+ }+ state.running = true;+ (full, last_full)+ };+ let me = Arc::clone(self);+ let spawned = std::thread::Builder::new().name("font-catalog-refresh".into()).spawn(move || me.run(full));+ if spawned.is_err() {+ let mut state = lock(&self.state);+ state.running = false;+ state.last_full = Some(last_full);+ return false;+ }+ true+ }++ fn run(&self, full: bool) {+ let _running = Running(&self.state);+ let before = self.catalog.generation();+ if full {+ self.catalog.rescan();+ } else {+ self.catalog.rescan_if_changed();+ }+ self.changed_since(before);+ }++ /// [`rescan_now`] at `now` (`None` on wasm, which has no clock).+ pub(crate) fn rescan_now(&self, now: Option<Instant>) -> Changes {+ if let Some(now) = now {+ lock(&self.state).last_full = Some(now);+ }+ let before = self.catalog.generation();+ self.catalog.rescan();+ self.changed_since(before)+ }++ /// The changes since `before`, after calling `on_change` when there are any.+ fn changed_since(&self, before: u64) -> Changes {+ let changes = self.catalog.changes_since(before);+ if let Some(on_change) = &self.on_change+ && !changes.is_empty()+ {+ on_change(&self.catalog);+ }+ changes+ }++ /// Whether a worker is running.+ #[cfg(test)]+ pub(crate) fn is_running(&self) -> bool {+ lock(&self.state).running+ }+}@@ -1,6 +1,7 @@ //! The catalog: every installed face, found by any of its names, loaded once. -use std::collections::{HashMap, HashSet};+use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};+use std::hash::{DefaultHasher, Hash, Hasher}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, RwLock, Weak};@@ -50,15 +51,16 @@ pub enum FontFileError { Gone, } -/// Limits on fonts added from files or bytes ([`Catalog::add_bytes`], [`Catalog::add_file`]),-/// which may come from documents or downloads.+/// Limits on fonts added from files or bytes ([`Catalog::add_bytes`], [`Catalog::add_file`] and+/// their scoped forms), 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.+ /// The most added faces one scope holds, and the most added without a scope: each scope and+ /// the unscoped faces count on their own. pub max_added_faces: usize, } @@ -77,6 +79,12 @@ pub enum Match { NearestStyle, } +/// A set of added faces that only lookups in it see: one document's fonts+/// ([`Catalog::new_scope`]). Inside a scope, its own faces come first, then the faces added+/// without a scope, then the installed faces.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+pub struct Scope(u64);+ /// 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 {@@ -106,6 +114,29 @@ enum Alias { Face(usize), } +impl Alias {+ fn face(&self) -> usize {+ match self {+ Alias::Family(i) | Alias::Legacy(i, _) | Alias::Face(i) => *i,+ }+ }++ /// The order [`Index::resolve`] tries kinds of alias in.+ fn tier(&self) -> u8 {+ match self {+ Alias::Family(_) => 0,+ Alias::Legacy(..) => 1,+ Alias::Face(_) => 2,+ }+ }+}++/// What tells faces apart: of the faces alike in it, lookups see only the first.+fn identity(f: &Face) -> (String, String, Option<String>, Vec<(u32, u32)>) {+ let variations = f.info.variations.iter().map(|(t, v)| (u32::from_be_bytes(*t), v.to_bits())).collect();+ (f.info.family.to_ascii_lowercase(), norm(&f.info.style), f.keys.postscript.first().cloned(), variations)+}+ /// One state of the catalog: what lookups read while a scan builds the next one. #[derive(Default)] struct Index {@@ -115,19 +146,26 @@ struct Index { by_family: HashMap<String, Vec<usize>>, aliases: HashMap<String, Vec<Alias>>, postscript: HashMap<String, usize>,+ /// How many of `faces` (the first ones) came from the added faces.+ added: usize,+ /// A hash of each family's faces by lowercase name, for [`Catalog::changes_since`]; set by+ /// [`Catalog::publish`] only, so scope views don't pay for it.+ signatures: HashMap<String, u64>, } 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 {+ fn build<'a>(added: &'a [Face], scanned: impl IntoIterator<Item = &'a Face>) -> Self { let mut faces: Vec<Face> = 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) {+ let mut from_added = 0;+ for (n, f) in added.iter().chain(scanned).enumerate() {+ if seen.insert(identity(f)) { faces.push(f.clone());+ if n < added.len() {+ from_added += 1;+ } } } let mut by_family: HashMap<String, Vec<usize>> = HashMap::new();@@ -169,7 +207,34 @@ impl Index { } 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 }+ Self { faces, infos, families: families.into(), by_family, aliases, postscript, added: from_added, signatures: HashMap::new() }+ }++ /// What [`Index::signatures`] holds: for each family, a hash of its faces' names, styles,+ /// weights, widths, files and the files' sizes and modification times, so whatever can lay+ /// text out differently changes it.+ fn family_signatures(&self) -> HashMap<String, u64> {+ self.by_family+ .iter()+ .map(|(key, faces)| {+ let mut h = DefaultHasher::new();+ for f in faces.iter().filter_map(|i| self.face(*i)) {+ let i = &f.info;+ (&i.family, &i.style, &i.postscript_name, &i.path, i.index, i.blob, i.source, i.italic, f.file).hash(&mut h);+ (i.weight.to_bits(), i.width.to_bits()).hash(&mut h);+ for (tag, value) in &i.variations {+ (tag, value.to_bits()).hash(&mut h);+ }+ }+ (key.clone(), h.finish())+ })+ .collect()+ }++ /// The name of the family with lowercase name `key`.+ fn family_named(&self, key: &str) -> Option<&str> {+ let i = *self.by_family.get(key)?.first()?;+ self.face(i).map(|f| f.info.family.as_str()) } fn face(&self, i: usize) -> Option<&Face> {@@ -240,24 +305,149 @@ impl Index { } } +/// How many changes of the family set the history keeps ([`Catalog::changes_since`]).+const HISTORY_CHANGES: usize = 64;+/// How many family names the history holds in all. A change naming more (the first scan of a+/// large system) clears the history.+const HISTORY_NAMES: usize = 4096;++/// How a family changed between two indexes.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+enum Change {+ Added,+ Removed,+ Changed,+}++/// A family's lowercase name, its name and how it changed.+type FamilyChange = (String, String, Change);++/// The families each recent generation added, removed or changed, for unscoped lookups.+#[derive(Default)]+struct History {+ /// Each generation that changed a family, oldest first, with the families (lowercase name,+ /// name) and how they changed.+ changes: VecDeque<(u64, Vec<FamilyChange>)>,+ names: usize,+ /// The oldest generation [`Catalog::changes_since`] can start from: the history holds every+ /// change after it.+ floor: u64,+}++impl History {+ fn record(&mut self, generation: u64, old: &Index, new: &Index) {+ let mut changes = Vec::new();+ for (key, sig) in &new.signatures {+ let change = match old.signatures.get(key) {+ None => Change::Added,+ Some(old) if old != sig => Change::Changed,+ Some(_) => continue,+ };+ changes.push((key.clone(), new.family_named(key).unwrap_or(key).to_owned(), change));+ }+ for key in old.signatures.keys().filter(|k| !new.signatures.contains_key(*k)) {+ changes.push((key.clone(), old.family_named(key).unwrap_or(key).to_owned(), Change::Removed));+ }+ if changes.is_empty() {+ return;+ }+ if changes.len() > HISTORY_NAMES {+ self.changes.clear();+ self.names = 0;+ self.floor = generation;+ return;+ }+ self.names += changes.len();+ self.changes.push_back((generation, changes));+ while self.changes.len() > HISTORY_CHANGES || self.names > HISTORY_NAMES {+ let Some((generation, changes)) = self.changes.pop_front() else { break };+ self.names -= changes.len();+ self.floor = generation;+ }+ }+}++/// The families unscoped lookups find differently since a generation ([`Catalog::changes_since`]):+/// what an app re-resolves to know which documents to lay out again. Each list is sorted+/// ignoring case.+#[derive(Clone, Debug, Default, PartialEq, Eq)]+pub struct Changes {+ /// The generation these changes lead to: pass it to the next call.+ pub generation: u64,+ /// The catalog no longer knows what changed since then: re-resolve every family, and the+ /// lists are empty.+ pub full: bool,+ /// Families found now that weren't.+ pub added: Vec<String>,+ /// Families that were found and aren't any more.+ pub removed: Vec<String>,+ /// Families found before and now whose faces changed: a style installed or removed, a file+ /// replaced, a face added over an installed one.+ pub changed: Vec<String>,+}++impl Changes {+ /// Nothing changed.+ pub fn is_empty(&self) -> bool {+ !self.full && self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()+ }+}+ /// 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 } } +fn family_name(index: &Index, name: &str) -> Option<String> {+ let i = *index.family_of(name)?.first()?;+ index.face(i).map(|f| f.info.family.clone())+}++/// The faces of the family `family` names; `None` when no family has that name.+fn styles_of(index: &Index, family: &str) -> Option<Vec<FaceInfo>> {+ Some(index.family_of(family)?.iter().filter_map(|i| index.face(*i)).map(|f| f.info.clone()).collect())+}++fn resolve_face(index: &Index, name: &str, style: &str) -> Option<(FaceInfo, Match)> {+ let (i, matched) = index.resolve(name, style)?;+ Some((index.face(i)?.info.clone(), matched))+}++fn postscript_face(index: &Index, name: &str) -> Option<FaceInfo> {+ let i = *index.postscript.get(&norm(name))?;+ index.face(i).map(|f| f.info.clone())+}+ /// What the catalog reads besides the platform's font folders. struct Config { app_dirs: Vec<PathBuf>,+ confined_dirs: Vec<PathBuf>, listers: Vec<Lister>, font_service: bool, } +/// A file's size and modification time: when either differs, the file changed.+type FileStamp = (u64, Option<SystemTime>);++fn file_stamp(meta: &std::fs::Metadata) -> FileStamp {+ (meta.len(), meta.modified().ok())+}++/// A loaded file's bytes, while anything holds them.+struct Loaded {+ /// The file's stamp when read: a file replaced since is read again.+ stamp: FileStamp,+ /// Read without following a link in the file's place.+ no_follow: bool,+ bytes: Weak<[u8]>,+}+ /// A file found by a scan, remembered so a rescan reads only files that changed. struct Cached {- len: u64,- modified: Option<SystemTime>,- faces: Vec<Face>,+ stamp: FileStamp,+ /// Shared with [`Added::scanned`].+ faces: Arc<Vec<Face>>, } #[derive(Default)]@@ -269,6 +459,7 @@ struct ScanState { #[derive(Default)] struct Stamp { app_dirs: Vec<PathBuf>,+ confined_dirs: Vec<PathBuf>, /// Every folder the scan listed, and the folders holding the files listers named, with their /// modification times (`None`: missing). dirs: Vec<(PathBuf, Option<SystemTime>)>,@@ -278,14 +469,162 @@ struct Stamp { #[derive(Default)] struct Added {+ /// Faces added without a scope. faces: Vec<Face>,+ /// The faces the last scan found, by file in path order; `None` before the first scan, which+ /// then builds the index with the faces added before it. Kept here, not read from the scan's+ /// state, so adding faces never waits for a scan.+ scanned: Option<Vec<Arc<Vec<Face>>>>,+ scopes: HashMap<Scope, Scoped>,+ /// The bytes of faces added as bytes, with or without a scope. blobs: HashMap<u64, Arc<[u8]>>, next_blob: u64,+ /// Counts every scope's changes together, so a scope's stamp never repeats.+ scope_changes: u64,+}++impl Added {+ /// Record a change of `scope`'s faces: a new stamp, and a view to build again.+ fn changed(&mut self, scope: Scope) -> &mut Scoped {+ self.scope_changes += 1;+ let stamp = self.scope_changes;+ let scoped = self.scopes.entry(scope).or_default();+ scoped.stamp = stamp;+ scoped.changes += 1;+ scoped.view = None;+ scoped+ }++ /// Whether `scope` (or the unscoped faces) has room for `more` faces within `limits`.+ fn room(&self, scope: Option<Scope>, more: usize, limits: &Limits) -> Result<(), FontFileError> {+ let held = match scope {+ None => self.faces.len(),+ Some(scope) => self.scopes.get(&scope).map_or(0, |s| s.faces.len()),+ };+ if held + more > limits.max_added_faces {+ return Err(FontFileError::TooManyFaces { max: limits.max_added_faces });+ }+ Ok(())+ }++ /// Keep `bytes` for `faces` when `pending` more faces about to be added to `scope` leave room+ /// within `limits`; returns the faces, pointing at the bytes.+ fn store(+ &mut self,+ scope: Option<Scope>,+ bytes: Arc<[u8]>,+ mut faces: Vec<Face>,+ pending: usize,+ limits: &Limits,+ ) -> Result<Vec<Face>, FontFileError> {+ self.room(scope, pending + faces.len(), limits)?;+ let blob = self.next_blob;+ self.next_blob += 1;+ self.blobs.insert(blob, bytes);+ for f in &mut faces {+ f.info.blob = Some(blob);+ }+ Ok(faces)+ }++ /// Drop the bytes no added face uses any more.+ fn drop_unused_blobs(&mut self) {+ let used: HashSet<u64> = self.faces.iter().chain(self.scopes.values().flat_map(|s| &s.faces)).filter_map(|f| f.info.blob).collect();+ self.blobs.retain(|b, _| used.contains(b));+ }+}++/// A scope's faces, and the view its lookups read. A removed scope keeps its entry, with no+/// faces, so its stamp never goes back.+#[derive(Default)]+struct Scoped {+ faces: Vec<Face>,+ /// [`Added::scope_changes`] at the scope's last change, for [`Catalog::generation_in`].+ stamp: u64,+ /// The scope's own changes, for [`Catalog::scope_generation`].+ changes: u64,+ /// Built on the first lookup after a change of the scope or of the shared index.+ view: Option<Arc<View>>,+}++/// What lookups in a scope read: an index of the scope's faces and the shared faces of their+/// families, consulted before the shared index. [`View::answers`] says which of the two a name+/// resolves in, so the result is what one index of every face would give.+struct View {+ /// The shared index this view was built from.+ base: Arc<Index>,+ index: Index,+ /// The shared families and the scope's, sorted ignoring case.+ families: Arc<[String]>,+}++impl View {+ fn build(own: &[Face], base: Arc<Index>) -> Self {+ let keys: BTreeSet<String> = own.iter().map(|f| f.info.family.to_ascii_lowercase()).collect();+ let shared: Vec<Face> = keys.iter().filter_map(|k| base.by_family.get(k)).flatten().filter_map(|i| base.face(*i)).cloned().collect();+ let index = Index::build(own, &shared);+ let mut families: Vec<String> = base.families.to_vec();+ let before = families.len();+ families.extend(index.families.iter().filter(|f| !base.by_family.contains_key(&f.to_ascii_lowercase())).cloned());+ if families.len() > before {+ families.sort_by_cached_key(|f| (f.to_lowercase(), f.clone()));+ }+ Self { base, index, families: families.into() }+ }++ /// Whether `name` resolves to the scope's faces, trying names in [`Index::resolve`]'s order:+ /// an exact family name, then each kind of alias. A family the view holds has scope faces;+ /// otherwise the first kind of alias either index has decides, and the view's aliases list+ /// the scope's faces first.+ fn answers(&self, name: &str) -> bool {+ let lower = name.to_ascii_lowercase();+ if self.index.by_family.contains_key(&lower) {+ return true;+ }+ if self.base.by_family.contains_key(&lower) {+ return false;+ }+ let key = norm(name);+ let (own, shared) = (self.index.aliases.get(&key), self.base.aliases.get(&key));+ for tier in 0..3 {+ if let Some(a) = own.into_iter().flatten().find(|a| a.tier() == tier) {+ return a.face() < self.index.added;+ }+ if shared.into_iter().flatten().any(|a| a.tier() == tier) {+ return false;+ }+ }+ false+ }++ /// The scope's face with PostScript name `name`.+ fn postscript(&self, name: &str) -> Option<usize> {+ self.index.postscript.get(&norm(name)).copied().filter(|i| *i < self.index.added)+ }+}++/// The faces of a font file's `bytes`, within `limits`, before they are added.+fn bytes_faces(bytes: Arc<[u8]>, source: Source, limits: &Limits) -> Result<(Arc<[u8]>, Vec<Face>), FontFileError> {+ let size = bytes.len() as u64;+ if size > limits.max_file_bytes {+ return Err(FontFileError::TooLarge { size, max: limits.max_file_bytes });+ }+ let faces = scan::data_faces(&bytes, limits.max_faces_per_file, None, source);+ if faces.is_empty() {+ return Err(FontFileError::NotAFont);+ }+ Ok((bytes, faces)) } /// A family's faces, loaded once ([`Catalog::load_family`]). type FamilyLoad = Arc<OnceLock<Arc<[LoadedFace]>>>; +/// Why a path isn't read as a font: it is a folder, a FIFO, a device or a socket, which could+/// block or never end.+fn not_a_regular_file() -> FontFileError {+ FontFileError::Io(scan::not_a_regular_file())+}+ /// 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. ///@@ -301,9 +640,13 @@ pub struct Catalog { scan: Mutex<ScanState>, stamp: Mutex<Stamp>, generation: AtomicU64,+ /// Locked while [`Catalog::publish`] moves the generation on, so it and the history agree.+ history: Mutex<History>, added: Mutex<Added>,- loaded: Mutex<HashMap<String, FamilyLoad>>,- files: Mutex<HashMap<PathBuf, Weak<[u8]>>>,+ next_scope: AtomicU64,+ /// Loaded families by scope (`None`: unscoped) and lowercase family name.+ loaded: Mutex<HashMap<(Option<Scope>, String), FamilyLoad>>,+ files: Mutex<HashMap<PathBuf, Loaded>>, } fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {@@ -320,24 +663,51 @@ impl Catalog { pub fn new(env: FontEnv) -> Self { Self { env,- config: RwLock::new(Config { app_dirs: Vec::new(), listers: Vec::new(), font_service: cfg!(feature = "system") }),+ config: RwLock::new(Config {+ app_dirs: Vec::new(),+ confined_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),+ history: Mutex::new(History::default()), added: Mutex::new(Added::default()),+ next_scope: AtomicU64::new(0), loaded: Mutex::new(HashMap::new()), files: Mutex::new(HashMap::new()), } } - /// The process's catalog, for [`FontEnv::from_process`].+ /// A catalog of no installed fonts: no font folders, no font store and no font service. It+ /// finds only the faces added to it ([`Catalog::add_bytes`]) and the folders given to+ /// [`Catalog::set_app_dirs`] and [`Catalog::set_confined_dirs`]. For a CLI's "no system+ /// fonts" switch ([`Catalog::init_global`]) and for tests that must not see the host's fonts.+ pub fn without_installed_fonts() -> Self {+ Self::new(FontEnv { os: Os::None, ..FontEnv::default() }).with_font_service(false)+ }++ /// The process's catalog: the one [`Catalog::init_global`] set, else one for+ /// [`FontEnv::from_process`]. pub fn global() -> &'static Arc<Catalog> {- static GLOBAL: OnceLock<Arc<Catalog>> = OnceLock::new(); GLOBAL.get_or_init(|| Arc::new(Catalog::new(FontEnv::from_process()))) } + /// Make `catalog` the process's catalog ([`Catalog::global`]), for example+ /// [`Catalog::without_installed_fonts`] for a CLI's "no system fonts" switch. Call it before+ /// anything uses the global catalog; once something has, the catalog comes back as the error.+ pub fn init_global(catalog: Catalog) -> Result<&'static Arc<Catalog>, Box<Catalog>> {+ let mut catalog = Some(catalog);+ let global = GLOBAL.get_or_init(|| Arc::new(catalog.take().unwrap_or_else(|| Catalog::new(FontEnv::from_process()))));+ match catalog {+ Some(catalog) => Err(Box::new(catalog)),+ None => Ok(global),+ }+ }+ /// 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]@@ -361,6 +731,13 @@ impl Catalog { self.write_config().app_dirs = dirs; } + /// Folders the user or an automation caller picked, read with their subfolders from the next+ /// scan on, as [`Source::User`]. The scan stays inside each: a link whose target is outside+ /// the folder is skipped. A change shows in [`Catalog::installed_fonts_changed`].+ pub fn set_confined_dirs(&self, dirs: Vec<PathBuf>) {+ self.write_config().confined_dirs = dirs;+ }+ fn write_config(&self) -> std::sync::RwLockWriteGuard<'_, Config> { self.config.write().unwrap_or_else(PoisonError::into_inner) }@@ -432,8 +809,11 @@ impl Catalog { return false; } let stamp = lock(&self.stamp);- if stamp.app_dirs != self.read_config().app_dirs {- return true;+ {+ let config = self.read_config();+ if stamp.app_dirs != config.app_dirs || stamp.confined_dirs != config.confined_dirs {+ return true;+ } } if stamp.dirs.iter().any(|(p, t)| modified(p) != *t) { return true;@@ -462,20 +842,98 @@ impl Catalog { Watch { stop } } - /// Counts the catalog's changes: each scan, and each face added or removed.+ /// Counts the changes unscoped lookups see: each scan, and each face added or removed+ /// without a scope. Faces added to or removed from a scope don't change it, so other+ /// documents don't lay out again; [`Catalog::generation_in`] counts those too. pub fn generation(&self) -> u64 { self.generation.load(Ordering::Acquire) } + /// Counts the changes lookups in `scope` see: [`Catalog::generation`] plus a stamp of the+ /// scope's last change, taken from one counter of every scope's changes. It never goes down+ /// and never repeats a value, [`Catalog::remove_scope`] and adding to the scope again+ /// included. Read it before resolving or laying out, as for [`Catalog::changes_since`]: a+ /// change that lands in between then shows as a newer value next time, instead of the old+ /// layout being kept under it.+ pub fn generation_in(&self, scope: Scope) -> u64 {+ let own = lock(&self.added).scopes.get(&scope).map_or(0, |s| s.stamp);+ self.generation() + own+ }++ /// Counts the changes of `scope`'s own faces: each add to it and each removal from it, and+ /// nothing else (no scan, no unscoped change, no other scope's). 0 for a scope nothing was+ /// added to. It never goes down, [`Catalog::remove_scope`] included. Read it before resolving+ /// or laying out, as [`Catalog::generation_in`] says.+ pub fn scope_generation(&self, scope: Scope) -> u64 {+ lock(&self.added).scopes.get(&scope).map_or(0, |s| s.changes)+ }+ fn snapshot(&self) -> Arc<Index> { 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);+ /// What changed for unscoped lookups since `generation` (a value of+ /// [`Catalog::generation`]): the families added, removed and changed, so an app re-resolves+ /// only the names those affect and lays out only the documents that use them. Take the+ /// generation before resolving a document's fonts, so a change during that shows in the next+ /// call. `full` when the history no longer reaches back that far (it keeps the last 64+ /// changes of the family set, and the first scan of a large system clears it): re-resolve+ /// everything then. Scopes have their own [`Catalog::generation_in`].+ pub fn changes_since(&self, generation: u64) -> Changes {+ let history = lock(&self.history);+ let now = self.generation();+ if generation >= now {+ return Changes { generation: now, ..Changes::default() };+ }+ if generation < history.floor {+ return Changes { generation: now, full: true, ..Changes::default() };+ }+ // Each family's state before the first change and after the last, with its latest name.+ let mut net: HashMap<&str, (bool, bool, &str)> = HashMap::new();+ for (key, name, change) in history.changes.iter().filter(|(g, _)| *g > generation).flat_map(|(_, c)| c) {+ let entry = net.entry(key.as_str()).or_insert((*change != Change::Added, false, name.as_str()));+ entry.1 = *change != Change::Removed;+ entry.2 = name.as_str();+ }+ let mut changes = Changes { generation: now, ..Changes::default() };+ for (before, after, name) in net.into_values() {+ match (before, after) {+ (false, true) => changes.added.push(name.to_owned()),+ (true, false) => changes.removed.push(name.to_owned()),+ (true, true) => changes.changed.push(name.to_owned()),+ (false, false) => {}+ }+ }+ for list in [&mut changes.added, &mut changes.removed, &mut changes.changed] {+ list.sort_by_cached_key(|f| (f.to_lowercase(), f.clone()));+ }+ changes+ }++ fn publish(&self, mut index: Index) {+ index.signatures = index.family_signatures();+ let mut history = lock(&self.history);+ let new = Arc::new(index);+ let old = std::mem::replace(&mut *self.index.write().unwrap_or_else(PoisonError::into_inner), Arc::clone(&new)); lock(&self.loaded).clear();- self.generation.fetch_add(1, Ordering::AcqRel);+ let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1;+ history.record(generation, &old, &new);+ }++ /// The shared index, and the view of `scope` when the scope has faces.+ fn lookup(&self, scope: Scope) -> (Arc<Index>, Option<Arc<View>>) {+ let base = self.snapshot();+ let mut added = lock(&self.added);+ let Some(scoped) = added.scopes.get_mut(&scope).filter(|s| !s.faces.is_empty()) else { return (base, None) };+ if let Some(view) = &scoped.view+ && Arc::ptr_eq(&view.base, &base)+ {+ return (base, Some(Arc::clone(view)));+ }+ let view = Arc::new(View::build(&scoped.faces, Arc::clone(&base)));+ scoped.view = Some(Arc::clone(&view));+ (base, Some(view)) } /// The family names, sorted ignoring case.@@ -483,23 +941,50 @@ impl Catalog { Arc::clone(&self.snapshot().families) } - /// Every face, added faces first.+ /// The family names in `scope` ([`Catalog::families`] plus the scope's), sorted ignoring case.+ pub fn families_in(&self, scope: Scope) -> Arc<[String]> {+ match self.lookup(scope) {+ (_, Some(view)) => Arc::clone(&view.families),+ (base, None) => Arc::clone(&base.families),+ }+ }++ /// Every face added without a scope or installed, added faces first. pub fn faces(&self) -> Arc<[FaceInfo]> { Arc::clone(&self.snapshot().infos) } + /// The faces added to `scope`, in the order they were added; not the unscoped or installed+ /// faces. Of the faces with the same family, style, PostScript name and instance, the first+ /// added is listed, as lookups in the scope see it. It never waits for a scan.+ pub fn faces_in(&self, scope: Scope) -> Vec<FaceInfo> {+ let added = lock(&self.added);+ let mut seen = HashSet::new();+ added.scopes.get(&scope).map_or_else(Vec::new, |s| s.faces.iter().filter(|f| seen.insert(identity(f))).map(|f| f.info.clone()).collect())+ }+ /// 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<String> {- let index = self.snapshot();- let i = *index.family_of(name)?.first()?;- index.face(i).map(|f| f.info.family.clone())+ family_name(&self.snapshot(), name)+ }++ /// [`Catalog::family`] in `scope`.+ pub fn family_in(&self, scope: Scope, name: &str) -> Option<String> {+ let (base, view) = self.lookup(scope);+ view.filter(|v| v.answers(name)).and_then(|v| family_name(&v.index, name)).or_else(|| family_name(&base, name)) } /// The faces of the family `name` names ([`Catalog::family`]). pub fn styles(&self, family: &str) -> Vec<FaceInfo> {- let index = self.snapshot();- index.family_of(family).unwrap_or_default().iter().filter_map(|i| index.face(*i)).map(|f| f.info.clone()).collect()+ styles_of(&self.snapshot(), family).unwrap_or_default()+ }++ /// [`Catalog::styles`] in `scope`: for a family the scope has faces of, the scope's faces and+ /// the shared faces of other styles.+ pub fn styles_in(&self, scope: Scope, family: &str) -> Vec<FaceInfo> {+ let (base, view) = self.lookup(scope);+ view.filter(|v| v.answers(family)).and_then(|v| styles_of(&v.index, family)).or_else(|| styles_of(&base, family)).unwrap_or_default() } /// The face for `name` in `style`, and how it matched. `name` is a family name (any of its@@ -509,17 +994,29 @@ impl Catalog { /// 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))+ resolve_face(&self.snapshot(), name, style)+ }++ /// [`Catalog::resolve`] in `scope`: a name the scope's faces answer to resolves among them+ /// and the shared faces of their families, any other name as [`Catalog::resolve`].+ pub fn resolve_in(&self, scope: Scope, name: &str, style: &str) -> Option<(FaceInfo, Match)> {+ let (base, view) = self.lookup(scope);+ view.filter(|v| v.answers(name)).and_then(|v| resolve_face(&v.index, name, style)).or_else(|| resolve_face(&base, name, style)) } /// 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<FaceInfo> {- let index = self.snapshot();- let i = *index.postscript.get(&norm(name))?;- index.face(i).map(|f| f.info.clone())+ postscript_face(&self.snapshot(), name)+ }++ /// [`Catalog::by_postscript_name`] in `scope`, the scope's faces first.+ pub fn by_postscript_name_in(&self, scope: Scope, name: &str) -> Option<FaceInfo> {+ let (base, view) = self.lookup(scope);+ match view.and_then(|v| Some((v.postscript(name)?, v))) {+ Some((i, v)) => v.index.face(i).map(|f| f.info.clone()),+ None => postscript_face(&base, name),+ } } /// The data of `face`, read from its file (shared with other faces of the file still loaded).@@ -530,24 +1027,41 @@ impl Catalog { } let path = face.path.as_deref().ok_or(FontFileError::Gone)?; if crate::suitcase::is_suitcase(path) {+ // The scan gives a confined folder's suitcases no faces ([`scan::opened_file_faces`]).+ if face.confined {+ return Err(FontFileError::NotAFont);+ } 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 })+ Ok(FontData { bytes: self.file_bytes(path, face.confined)?, index: face.index }) } - fn file_bytes(&self, path: &Path) -> Result<Arc<[u8]>, FontFileError> {- if let Some(bytes) = lock(&self.files).get(path).and_then(Weak::upgrade) {+ /// The bytes of the file at `path`, shared while anything holds them. With `no_follow`+ /// (a confined file), a link in its place is refused, and so are bytes cached by a read that+ /// followed links.+ fn file_bytes(&self, path: &Path, no_follow: bool) -> Result<Arc<[u8]>, FontFileError> {+ // A file replaced in place since it was read is read again.+ let meta = if no_follow { std::fs::symlink_metadata(path) } else { std::fs::metadata(path) };+ if let Some(meta) = meta.ok().filter(std::fs::Metadata::is_file)+ && let Some(bytes) =+ lock(&self.files).get(path).filter(|c| c.stamp == file_stamp(&meta) && (c.no_follow || !no_follow)).and_then(|c| c.bytes.upgrade())+ { return Ok(bytes); }- let size = std::fs::metadata(path)?.len();+ use std::io::Read;+ let file = scan::open_regular(path, no_follow)?;+ let meta = file.metadata()?;+ let size = meta.len(); if size > MAX_LOAD { return Err(FontFileError::TooLarge { size, max: MAX_LOAD }); }- let bytes: Arc<[u8]> = std::fs::read(path)?.into();+ let mut data = Vec::new();+ file.take(MAX_LOAD).read_to_end(&mut data)?;+ let bytes: Arc<[u8]> = data.into(); let mut files = lock(&self.files);- files.retain(|_, w| w.strong_count() > 0);- files.insert(path.to_path_buf(), Arc::downgrade(&bytes));+ files.retain(|_, c| c.bytes.strong_count() > 0);+ files.insert(path.to_path_buf(), Loaded { stamp: file_stamp(&meta), no_follow, bytes: Arc::downgrade(&bytes) }); Ok(bytes) } @@ -557,41 +1071,95 @@ impl Catalog { /// 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());+ let cell = Arc::clone(lock(&self.loaded).entry((None, 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()), ) } + /// [`Catalog::load_family`] in `scope`. A family the scope has no faces of is shared with+ /// unscoped loads.+ pub fn load_family_in(&self, scope: Scope, family: &str) -> Arc<[LoadedFace]> {+ let (_, view) = self.lookup(scope);+ let Some(name) = view.filter(|v| v.answers(family)).and_then(|v| family_name(&v.index, family)) else { return self.load_family(family) };+ let cell = Arc::clone(lock(&self.loaded).entry((Some(scope), name.to_ascii_lowercase())).or_default());+ Arc::clone(cell.get_or_init(|| {+ self.styles_in(scope, &name).into_iter().filter_map(|info| Some(LoadedFace { data: self.load(&info).ok()?, info })).collect()+ }))+ }++ /// A new, empty scope, for one document's fonts. Faces added to it with+ /// [`Catalog::add_bytes_in`] and [`Catalog::add_file_in`] are seen only by lookups in it+ /// ([`Catalog::resolve_in`] and the other `_in` lookups) until [`Catalog::remove_scope`].+ pub fn new_scope(&self) -> Scope {+ Scope(self.next_scope.fetch_add(1, Ordering::Relaxed))+ }+ /// 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<u8>, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, 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();+ /// `source`, seen by every lookup. Returns the faces added. It never waits for a scan: faces+ /// added before the first scan join it. The catalog keeps `bytes` as an `Arc<[u8]>`; pass+ /// one to share it without a copy.+ pub fn add_bytes(&self, bytes: impl Into<Arc<[u8]>>, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {+ self.add_bytes_to(None, bytes.into(), source, limits)+ }++ /// [`Catalog::add_bytes`] for several fonts, with one rebuild of the lookups for all of them+ /// (an app's bundled fonts). Returns each font's faces or why it wasn't added, in order.+ pub fn add_bytes_many<B: Into<Arc<[u8]>>>(+ &self,+ fonts: impl IntoIterator<Item = B>,+ source: Source,+ limits: &Limits,+ ) -> Vec<Result<Vec<FaceInfo>, FontFileError>> {+ let parsed: Vec<_> = fonts.into_iter().map(|b| bytes_faces(b.into(), source, limits)).collect(); 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);+ let mut all = Vec::new();+ let results = parsed+ .into_iter()+ .map(|p| {+ let (bytes, faces) = p?;+ let faces = added.store(None, bytes, faces, all.len(), limits)?;+ let infos = faces.iter().map(|f| f.info.clone()).collect();+ all.extend(faces);+ Ok(infos)+ })+ .collect();+ if !all.is_empty() {+ self.push_added(added, None, all); }- Ok(self.push_added(added, faces))+ results } - /// Add the faces of the font file at `path` for this session, as `source`, read in place.- /// Returns the faces added.+ /// [`Catalog::add_bytes`] into `scope`: only lookups in the scope see the faces.+ pub fn add_bytes_in(&self, scope: Scope, bytes: impl Into<Arc<[u8]>>, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {+ self.add_bytes_to(Some(scope), bytes.into(), source, limits)+ }++ fn add_bytes_to(&self, scope: Option<Scope>, bytes: Arc<[u8]>, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {+ let (bytes, faces) = bytes_faces(bytes, source, limits)?;+ let mut added = lock(&self.added);+ let faces = added.store(scope, bytes, faces, 0, limits)?;+ Ok(self.push_added(added, scope, faces))+ }++ /// Add the faces of the font file at `path` for this session, as `source`, read in place and+ /// seen by every lookup. Returns the faces added. A path that isn't a regular file (after+ /// following links) is refused unopened. pub fn add_file(&self, path: &Path, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {- let size = std::fs::metadata(path)?.len();+ self.add_file_to(None, path, source, limits)+ }++ /// [`Catalog::add_file`] into `scope`: only lookups in the scope see the faces.+ pub fn add_file_in(&self, scope: Scope, path: &Path, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {+ self.add_file_to(Some(scope), path, source, limits)+ }++ fn add_file_to(&self, scope: Option<Scope>, path: &Path, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {+ let meta = std::fs::metadata(path)?;+ if !meta.is_file() {+ return Err(not_a_regular_file());+ }+ let size = meta.len(); if size > limits.max_file_bytes { return Err(FontFileError::TooLarge { size, max: limits.max_file_bytes }); }@@ -604,55 +1172,92 @@ impl Catalog { 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))+ added.room(scope, faces.len(), limits)?;+ Ok(self.push_added(added, scope, faces)) } - fn push_added(&self, mut added: MutexGuard<'_, Added>, faces: Vec<Face>) -> Vec<FaceInfo> {+ /// Unscoped faces join the shared index (a new generation); a scope's faces only its view.+ fn push_added(&self, mut added: MutexGuard<'_, Added>, scope: Option<Scope>, faces: Vec<Face>) -> Vec<FaceInfo> { 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));+ match scope {+ None => {+ added.faces.extend(faces);+ self.rebuild(&added);+ }+ Some(scope) => {+ added.changed(scope).faces.extend(faces);+ // Under the lock, as `publish` does: no load reads the old faces after the stamp.+ lock(&self.loaded).retain(|(s, _), _| *s != Some(scope));+ }+ } infos } - /// Remove every added face of `source` (a closed document's fonts). Returns how many.+ /// Remove every face of `source` added without a scope (a closed document's fonts). Returns+ /// how many. Scopes keep theirs ([`Catalog::remove_scope`]). 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<u64> = added.faces.iter().filter_map(|f| f.info.blob).collect();- added.blobs.retain(|b, _| blobs.contains(b));+ added.drop_unused_blobs(); let removed = before - added.faces.len(); if removed > 0 {- let scanned = self.scanned();- self.publish(Index::build(&added.faces, &scanned));+ self.rebuild(&added);+ }+ removed+ }++ /// Remove `scope`'s faces (its document closed). Returns how many. Other scopes and unscoped+ /// lookups don't change, nor does [`Catalog::generation`]. The scope keeps a few bytes, so+ /// [`Catalog::generation_in`] never repeats a value.+ pub fn remove_scope(&self, scope: Scope) -> usize {+ let mut added = lock(&self.added);+ let removed = added.scopes.get(&scope).map_or(0, |s| s.faces.len());+ if removed == 0 {+ return 0;+ }+ added.changed(scope).faces.clear();+ added.drop_unused_blobs();+ lock(&self.loaded).retain(|(s, _), _| *s != Some(scope));+ removed+ }++ /// Remove the faces [`Catalog::add_file_in`] added to `scope` from the file at `path` (the path+ /// as it was passed; the document deleted or replaced one of its font files). Returns how+ /// many. A change of the scope's faces, as an add is, when there were any.+ pub fn remove_file_in(&self, scope: Scope, path: &Path) -> usize {+ let mut added = lock(&self.added);+ let from_file = |f: &Face| f.info.blob.is_none() && f.info.path.as_deref() == Some(path);+ let removed = added.scopes.get(&scope).map_or(0, |s| s.faces.iter().filter(|f| from_file(f)).count());+ if removed == 0 {+ return 0; }+ added.changed(scope).faces.retain(|f| !from_file(f));+ lock(&self.loaded).retain(|(s, _), _| *s != Some(scope)); removed } - /// The faces the last scan found.- fn scanned(&self) -> Vec<Face> {- 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()+ /// Publish the added faces with the last scan's; before the first scan, which does that,+ /// nothing.+ fn rebuild(&self, added: &Added) {+ if let Some(scanned) = &added.scanned {+ self.publish(Index::build(&added.faces, scanned.iter().flat_map(|f| f.iter())));+ } } fn scan_now(&self, refresh: bool) { let mut state = lock(&self.scan);- let (app_dirs, listers, service) = {+ let (app_dirs, confined_dirs, listers, service) = { let c = self.read_config();- (c.app_dirs.clone(), c.listers.clone(), c.font_service)+ (c.app_dirs.clone(), c.confined_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)));+ // Each folder with its source and whether the walk stays inside it.+ let mut roots: Vec<(PathBuf, Source, bool)> = font_dirs(&self.env).into_iter().map(|d| (d, Source::System, false)).collect();+ roots.extend(font_store_dir(&self.env).map(|d| (d, Source::Store, false)));+ roots.extend(app_dirs.iter().map(|d| (d.clone(), Source::App, false)));+ roots.extend(confined_dirs.iter().map(|d| (d.clone(), Source::User, true))); let mut named: Vec<PathBuf> = Vec::new(); let mut registered = Vec::new();@@ -668,13 +1273,14 @@ impl Catalog { } 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);+ for (root, source, confined) in &roots {+ let inside = confined.then(|| walk.key(root));+ walk.dir(root, *source, 0, inside.as_deref()); } let mut parents = HashSet::new(); for file in named { if file.is_dir() {- walk.dir(&file, Source::System, 0);+ walk.dir(&file, Source::System, 0, None); continue; } if let Some(parent) = file.parent()@@ -682,34 +1288,49 @@ impl Catalog { { walk.dirs.push((parent.to_path_buf(), modified(parent))); }- walk.file(file, Source::System, true);+ walk.file(file, Source::System, true, None); } let mut files = HashMap::new();- for (path, source, by_name) in walk.files {+ for (path, source, by_name, real) 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);+ // A FIFO or device named like a font would block the scan or never end.+ if !meta.is_file() {+ continue;+ }+ let stamp = file_stamp(&meta);+ let cached = state.files.remove(&path).filter(|c| c.stamp == stamp); 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 if by_name || scan::is_font_file(&path) || crate::suitcase::is_suitcase(&path) => {+ Arc::new(scan::opened_file_faces(&path, MAX_SCAN_READ, source, real))+ } None => continue, };- for f in &mut faces {- f.info.source = source;+ if faces.iter().any(|f| f.info.source != source || f.info.confined != real || f.file != Some(stamp)) {+ for f in Arc::make_mut(&mut faces) {+ f.info.source = source;+ f.info.confined = real;+ f.file = Some(stamp);+ } }- files.insert(path, Cached { len, modified: mtime, faces });+ files.insert(path, Cached { stamp, faces }); }+ let mut scanned: Vec<(&PathBuf, &Cached)> = files.iter().collect();+ scanned.sort_by(|a, b| a.0.cmp(b.0));+ let scanned: Vec<Arc<Vec<Face>>> = scanned.into_iter().map(|(_, c)| Arc::clone(&c.faces)).collect(); 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));+ *lock(&self.stamp) = Stamp { app_dirs, confined_dirs, dirs: walk.dirs, registered };+ let mut added = lock(&self.added);+ added.scanned = Some(scanned);+ self.rebuild(&added); } } +static GLOBAL: OnceLock<Arc<Catalog>> = OnceLock::new();+ /// Stops a [`Catalog::watch`] when dropped. #[must_use = "dropping a Watch stops it"] pub struct Watch {@@ -729,8 +1350,9 @@ struct Walk { seen_files: HashSet<PathBuf>, entries: usize, dirs: Vec<(PathBuf, Option<SystemTime>)>,- /// Each file with where it was found and whether a lister named it.- files: Vec<(PathBuf, Source, bool)>,+ /// Each file with where it was found, whether a lister named it, and whether it is a+ /// confined folder's file recorded by its real path (opened without following a link).+ files: Vec<(PathBuf, Source, bool, bool)>, } impl Walk {@@ -739,9 +1361,11 @@ impl Walk { if self.ignore_case { PathBuf::from(real.to_string_lossy().to_lowercase()) } else { real } } - fn dir(&mut self, dir: &Path, source: Source, depth: usize) {+ /// Walk `dir` and its subfolders. With `inside` (a confined folder's [`Walk::key`]), folders+ /// and files whose real path is outside it are skipped.+ fn dir(&mut self, dir: &Path, source: Source, depth: usize, inside: Option<&Path>) { let key = self.key(dir);- if !self.seen_dirs.insert(key) {+ if inside.is_some_and(|root| !key.starts_with(root)) || !self.seen_dirs.insert(key) { return; } // Stamped before it is listed: a font installed meanwhile changes it after this.@@ -761,18 +1385,29 @@ impl Walk { for (path, is_dir) in paths { if is_dir { if depth < MAX_DEPTH {- self.dir(&path, source, depth + 1);+ self.dir(&path, source, depth + 1, inside); } } else {- self.file(path, source, false);+ self.file(path, source, false, inside); } } } - fn file(&mut self, path: PathBuf, source: Source, by_name: bool) {+ /// Record `path`. In a confined folder its real path is recorded, so a link swapped in after+ /// this check can't lead the scan outside the folder.+ fn file(&mut self, path: PathBuf, source: Source, by_name: bool, inside: Option<&Path>) { let key = self.key(&path);- if self.seen_files.insert(key) {- self.files.push((path, source, by_name));+ if inside.is_some_and(|root| !key.starts_with(root)) || !self.seen_files.insert(key) {+ return; }+ let (path, real) = match inside {+ // On Windows the real path is a `\\?\` path, which apps would show.+ Some(_) if cfg!(unix) => match std::fs::canonicalize(&path) {+ Ok(real) => (real, true),+ Err(_) => return,+ },+ _ => (path, false),+ };+ self.files.push((path, source, by_name, real)); } }@@ -207,21 +207,31 @@ pub fn font_dirs(env: &FontEnv) -> Vec<PathBuf> { /// 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<PathBuf> {+ let mut dirs = office_cloud_font_dirs(env);+ dirs.extend(office_bundled_font_dirs(env));+ dirs+}++/// The folders Office downloads its cloud fonts into: in Office's group container on macOS,+/// whose reading can raise a privacy prompt, and under `%LOCALAPPDATA%` on Windows.+pub(crate) fn office_cloud_font_dirs(env: &FontEnv) -> Vec<PathBuf> { match env.os {- Os::MacOs => {- let mut dirs: Vec<PathBuf> =- 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::MacOs => env.home.iter().map(|h| h.join("Library/Group Containers/UBF8T346G9.Office/FontCache/4/CloudFonts")).collect(), Os::Windows => env.local_app_data.iter().map(|l| l.join("Microsoft\\FontCache\\4\\CloudFonts")).collect(), _ => Vec::new(), } } +/// The fonts inside the Office apps' bundles on macOS (`DFonts`); none elsewhere.+pub(crate) fn office_bundled_font_dirs(env: &FontEnv) -> Vec<PathBuf> {+ match env.os {+ Os::MacOs => ["Microsoft Word", "Microsoft Excel", "Microsoft PowerPoint", "Microsoft Outlook"]+ .map(|app| env.rooted(format!("/Applications/{app}.app/Contents/Resources/DFonts")))+ .into(),+ _ => 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<PathBuf>, ignore_case: bool) -> Vec<PathBuf> {@@ -10,6 +10,9 @@ //! 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. //!+//! Apps start it, refresh it on window activation and learn which families changed through+//! [`app`].+//! //! ```no_run //! use craft_font_catalog::{Catalog, Match}; //!@@ -20,6 +23,7 @@ //! } //! ``` +pub mod app; mod catalog; mod env; mod fontconfig;@@ -28,7 +32,7 @@ mod scan; mod suitcase; pub mod system; -pub use catalog::{Catalog, FontData, FontFileError, Limits, Lister, LoadedFace, Match, Watch};+pub use catalog::{Catalog, Changes, FontData, FontFileError, Limits, Lister, LoadedFace, Match, Scope, 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};@@ -78,6 +78,9 @@ pub struct FaceInfo { pub source: Source, /// For a face added as bytes: which of the catalog's added blobs holds it. pub(crate) blob: Option<u64>,+ /// A confined folder's file ([`crate::Catalog::set_confined_dirs`]), recorded by its real+ /// path: on Unix its loads don't follow a link put in its place after the scan.+ pub(crate) confined: bool, } /// A face record with the normalized names it answers to.@@ -85,6 +88,9 @@ pub struct FaceInfo { pub(crate) struct Face { pub info: FaceInfo, pub keys: Keys,+ /// The size and modification time of the scanned file the face is in, so a file replaced in+ /// place changes its family's signature ([`crate::Catalog::changes_since`]).+ pub file: Option<(u64, Option<std::time::SystemTime>)>, } /// Every name a face answers to besides its family and style, normalized ([`norm`]).@@ -264,10 +270,11 @@ pub(crate) fn font_faces(font: &skrifa::FontRef<'_>, index: u32, path: Option<&P index, source, blob: None,+ confined: false, }; 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()+ std::iter::once(Face { info, keys, file: None }).chain(instances).collect() } /// The named instances of a variable face (its `fvar` table) as faces of its family, without the@@ -327,7 +334,7 @@ fn named_instances(font: &skrifa::FontRef<'_>, face: &FaceInfo, keys: &mut Keys) ..face.clone() }; let keys = Keys { families: keys.families.clone(), styles, legacy: vec![], full_names: vec![], postscript };- out.push(Face { info, keys });+ out.push(Face { info, keys, file: None }); } sort_dedup(&mut keys.styles); sort_dedup(&mut keys.postscript);@@ -356,7 +363,17 @@ pub(crate) fn data_faces(data: &[u8], max_faces: u32, path: Option<&Path>, sourc /// and `OS/2` tables, at most `max_read` bytes in all: a scan opens thousands of files, many of /// them megabytes long. A Mac suitcase font's `sfnt` resources are read instead. pub(crate) fn file_faces(path: &Path, max_read: u64, source: Source) -> Vec<Face> {+ opened_file_faces(path, max_read, source, false)+}++/// [`file_faces`], opening the file with [`open_regular`]'s `no_follow`. With `no_follow`, a+/// suitcase gives no faces: its resource fork is opened through the file's path, which follows+/// a link in its place.+pub(crate) fn opened_file_faces(path: &Path, max_read: u64, source: Source, no_follow: bool) -> Vec<Face> { if crate::suitcase::is_suitcase(path) {+ if no_follow {+ return vec![];+ } if crate::suitcase::fork_len(path) > max_read { return vec![]; }@@ -369,12 +386,35 @@ pub(crate) fn file_faces(path: &Path, max_read: u64, source: Source) -> Vec<Face } return faces; }- match std::fs::File::open(path) {+ match open_regular(path, no_follow) { Ok(file) => reader_faces(file, MAX_FACES, max_read, path, source), Err(_) => vec![], } } +pub(crate) fn not_a_regular_file() -> std::io::Error {+ std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a regular file")+}++/// `path` opened for reading, when it is a regular file (after following links). The check is+/// on the open file, so a FIFO or device swapped in after an earlier check is refused too; on+/// Unix the open doesn't wait for a FIFO's writer (`O_NONBLOCK`, which reads of a regular file+/// ignore). With `no_follow`, on Unix a link as the last component is refused (`O_NOFOLLOW`);+/// links in the folders above it are still followed.+pub(crate) fn open_regular(path: &Path, no_follow: bool) -> std::io::Result<std::fs::File> {+ let mut options = std::fs::OpenOptions::new();+ options.read(true);+ #[cfg(unix)]+ std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, libc::O_NONBLOCK | if no_follow { libc::O_NOFOLLOW } else { 0 });+ #[cfg(not(unix))]+ let _ = no_follow;+ let file = options.open(path)?;+ if !file.metadata()?.is_file() {+ return Err(not_a_regular_file());+ }+ Ok(file)+}+ fn reader_faces(mut file: impl std::io::Read + std::io::Seek, max_faces: u32, max_read: u64, path: &Path, source: Source) -> Vec<Face> { use std::io::SeekFrom; let mut read = 0u64;@@ -1,7 +1,7 @@ use std::path::{Path, PathBuf};-use std::sync::atomic::{AtomicUsize, Ordering};-use std::sync::{Arc, mpsc};-use std::time::Duration;+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};+use std::sync::{Arc, Mutex, mpsc};+use std::time::{Duration, Instant}; use write_fonts::FontBuilder; use write_fonts::tables::fvar::{AxisInstanceArrays, Fvar, InstanceRecord, VariationAxisRecord};@@ -428,6 +428,201 @@ fn added_fonts_come_first_and_respect_the_limits() { assert!(matches!(cat.load(&bold), Err(FontFileError::Gone))); } +#[test]+fn scoped_fonts_are_seen_only_in_their_scope() {+ let root = temp("scope");+ let cat = system(&root);+ let limits = Limits::default();+ let (a, b) = (cat.new_scope(), cat.new_scope());+ cat.rescan();+ let generation = cat.generation();+ // Each scope replaces the installed Test Sans Bold with its own (told apart by fsType); A also+ // has a family of its own.+ let a_bold = cat.add_bytes_in(a, font(&Spec { fs_type: 4, ..spec("Test Sans", "Bold", 700) }), Source::Document, &limits).unwrap();+ let file = root.join("doc-a/DocOnly.ttf");+ write(&file, font(&spec("Doc Only", "Regular", 400)));+ cat.add_file_in(a, &file, Source::Document, &limits).unwrap();+ cat.add_bytes_in(b, font(&Spec { fs_type: 8, ..spec("Test Sans", "Bold", 700) }), Source::Document, &limits).unwrap();+ assert_eq!(cat.generation(), generation, "scoped changes leave other documents alone");+ assert!(cat.generation_in(a) > generation);++ let bold = |scope: Option<Scope>| {+ let (face, m) = match scope {+ Some(s) => cat.resolve_in(s, "Test Sans", "Bold"),+ None => cat.resolve("Test Sans", "Bold"),+ }+ .unwrap();+ assert_eq!(m, Match::Exact);+ (face.source, face.fs_type)+ };+ assert_eq!(bold(Some(a)), (Source::Document, 4));+ assert_eq!(bold(Some(b)), (Source::Document, 8));+ assert_eq!(bold(None), (Source::System, 0));+ // The scope's family keeps its shared styles, and other families resolve as unscoped.+ assert_eq!(cat.resolve_in(a, "Test Sans", "Regular").unwrap(), cat.resolve("Test Sans", "Regular").unwrap());+ assert_eq!(cat.styles_in(a, "test sans").len(), 5);+ assert_eq!(cat.resolve_in(a, "Host Serif", "Bold").unwrap().0.source, Source::System);+ assert!(cat.families_in(a).iter().any(|f| f == "Doc Only"));+ assert!(!cat.families_in(b).iter().any(|f| f == "Doc Only"));+ assert!(!cat.families().iter().any(|f| f == "Doc Only"));+ assert_eq!(cat.family_in(a, "doc only").as_deref(), Some("Doc Only"));+ assert!(cat.family("Doc Only").is_none());+ assert!(cat.by_postscript_name_in(a, "DocOnly-Regular").is_some());+ assert!(cat.by_postscript_name_in(b, "DocOnly-Regular").is_none());+ assert!(cat.by_postscript_name("DocOnly-Regular").is_none());+ assert!(cat.load_family_in(a, "Test Sans").iter().any(|f| f.info.fs_type == 4));+ assert!(cat.load_family("Test Sans").iter().all(|f| f.info.fs_type == 0));++ // The limit on added faces counts each scope on its own: A is full, B isn't.+ let two = Limits { max_added_faces: 2, ..limits };+ assert!(matches!(+ cat.add_bytes_in(a, font(&spec("One More", "Regular", 400)), Source::Document, &two),+ Err(FontFileError::TooManyFaces { max: 2 })+ ));+ cat.add_bytes_in(b, font(&spec("One More", "Regular", 400)), Source::Document, &two).unwrap();++ // A face added without a scope comes after the scope's own, and the scopes' faces don't+ // count against unscoped adds.+ cat.add_bytes(font(&Spec { fs_type: 2, ..spec("Test Sans", "Bold", 700) }), Source::User, &two).unwrap();+ assert_eq!(bold(None), (Source::User, 2));+ assert_eq!(bold(Some(b)), (Source::Document, 8));++ let before = cat.generation();+ assert_eq!(cat.remove_scope(a), 2);+ assert_eq!(cat.generation(), before);+ assert_eq!(bold(Some(a)), (Source::User, 2));+ assert_eq!(bold(Some(b)), (Source::Document, 8));+ assert!(cat.family_in(a, "Doc Only").is_none());+ assert!(matches!(cat.load(&a_bold[0]), Err(FontFileError::Gone)));+ // Removing unscoped faces keeps the scopes'.+ assert_eq!(cat.remove_added(Source::User), 1);+ assert_eq!(bold(Some(b)), (Source::Document, 8));+ assert_eq!(cat.load_family_in(b, "Test Sans").iter().filter(|f| f.info.fs_type == 8).count(), 1);+}++#[test]+fn a_scope_lists_counts_and_removes_its_own_files() {+ let root = temp("scope-own");+ let cat = system(&root);+ let limits = Limits::default();+ let (a, b) = (cat.new_scope(), cat.new_scope());+ assert_eq!(cat.scope_generation(a), 0);+ let (one, two) = (root.join("doc-a/One.ttf"), root.join("doc-a/Two.ttc"));+ write(&one, font(&spec("Doc One", "Regular", 400)));+ write(&two, collection(&[font(&spec("Doc Two", "Regular", 400)), font(&spec("Doc Two", "Bold", 700))]));+ cat.add_file_in(a, &one, Source::Document, &limits).unwrap();+ cat.add_file_in(a, &two, Source::Document, &limits).unwrap();+ // The same face again is listed once.+ cat.add_bytes_in(a, font(&spec("Doc One", "Regular", 400)), Source::Document, &limits).unwrap();+ let generation = cat.scope_generation(a);+ assert_eq!(generation, 3);++ // Another scope's, unscoped and scanned changes neither show in A's faces nor move its count.+ cat.add_bytes_in(b, font(&spec("Other Doc", "Regular", 400)), Source::Document, &limits).unwrap();+ cat.add_bytes(font(&spec("Shared Face", "Regular", 400)), Source::User, &limits).unwrap();+ cat.rescan();+ let names = |s: Scope| cat.faces_in(s).iter().map(|f| format!("{} {}", f.family, f.style)).collect::<Vec<_>>();+ assert_eq!(names(a), ["Doc One Regular", "Doc Two Regular", "Doc Two Bold"]);+ assert_eq!(names(b), ["Other Doc Regular"]);+ assert_eq!(cat.scope_generation(a), generation);+ assert_eq!(cat.scope_generation(b), 1);++ assert_eq!(cat.remove_file_in(a, &two), 2);+ assert_eq!(cat.scope_generation(a), generation + 1);+ assert_eq!(names(a), ["Doc One Regular"]);+ assert!(cat.family_in(a, "Doc Two").is_none());+ assert_eq!(cat.remove_file_in(a, &two), 0);+ assert_eq!(cat.remove_file_in(b, &one), 0);+ assert_eq!(cat.scope_generation(a), generation + 1);+ assert_eq!(cat.scope_generation(b), 1);+ assert_eq!(cat.remove_scope(a), 2);+ assert_eq!(cat.scope_generation(a), generation + 2);+ assert!(cat.faces_in(a).is_empty());+}++#[test]+fn a_scope_resolves_as_one_index_of_every_face_would() {+ let root = temp("scope-names");+ let cat = system(&root);+ let limits = Limits::default();+ let scope = cat.new_scope();+ // A family spelled without the space answers to "Test Sans" only through an alias, so the+ // installed family of that exact name wins there, as it does unscoped.+ let doc = font(&Spec { fs_type: 4, ..spec("TestSans", "Bold", 700) });+ cat.add_bytes_in(scope, doc.clone(), Source::Document, &limits).unwrap();+ assert_eq!(cat.resolve_in(scope, "Test Sans", "Bold"), cat.resolve("Test Sans", "Bold"));+ assert_eq!(cat.styles_in(scope, "Test Sans"), cat.styles("Test Sans"));+ assert_eq!(cat.family_in(scope, "Test Sans").as_deref(), Some("Test Sans"));+ let (own, m) = cat.resolve_in(scope, "TestSans", "Bold").unwrap();+ assert_eq!((own.fs_type, m), (4, Match::Exact));+ assert_eq!(cat.by_postscript_name_in(scope, "TestSans-Bold").unwrap().fs_type, 4);+ assert_eq!(cat.by_postscript_name_in(scope, "TestSans-Regular"), cat.by_postscript_name("TestSans-Regular"));++ // The scope's generation never repeats, removed and added to again included.+ let added = cat.generation_in(scope);+ assert_eq!(cat.remove_scope(scope), 1);+ let removed = cat.generation_in(scope);+ assert_eq!(cat.remove_scope(scope), 0);+ assert_eq!(cat.generation_in(scope), removed);+ assert_eq!(cat.resolve_in(scope, "TestSans", "Bold"), cat.resolve("TestSans", "Bold"));+ cat.add_bytes_in(scope, doc, Source::Document, &limits).unwrap();+ let again = cat.generation_in(scope);+ assert!(added < removed && removed < again, "{added} {removed} {again}");+ assert_eq!(cat.resolve_in(scope, "TestSans", "Bold").unwrap().0.fs_type, 4);+}++#[cfg(unix)]+#[test]+fn the_scan_skips_files_that_are_not_regular() {+ let root = temp("fifo");+ let dir = root.join("usr/share/fonts");+ write(&dir.join("Real.ttf"), font(&spec("Real Face", "Regular", 400)));+ let fifo = dir.join("Blocking.ttf");+ assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());+ let named = fifo.clone();+ let cat = Arc::new(Catalog::new(linux_env(&root)).with_font_service(false).with_lister(Arc::new(move || vec![named.clone()])));+ // Opening a FIFO blocks until a writer comes, so the scan runs on a thread with a deadline.+ let (tx, rx) = mpsc::channel();+ let (scanning, opened) = (Arc::clone(&cat), fifo.clone());+ std::thread::spawn(move || {+ // The open itself doesn't wait, whatever an earlier check said.+ let direct = crate::scan::file_faces(&opened, u64::MAX, Source::User);+ tx.send((direct.len(), scanning.families().to_vec()))+ });+ let (direct, families) = rx.recv_timeout(Duration::from_secs(10)).expect("the scan opened the FIFO");+ assert_eq!((direct, families.as_slice()), (0, ["Real Face".to_string()].as_slice()));+ assert!(matches!(cat.add_file(&fifo, Source::User, &Limits::default()), Err(FontFileError::Io(_))));+ assert!(matches!(cat.add_file(&dir, Source::User, &Limits::default()), Err(FontFileError::Io(_))));+}++#[cfg(unix)]+#[test]+fn a_confined_folder_skips_links_that_leave_it() {+ use std::os::unix::fs::symlink;+ let root = temp("confined");+ let picked = root.join("picked");+ write(&picked.join("Inside.ttf"), font(&spec("Inside Face", "Regular", 400)));+ write(&picked.join("sub/Deep.ttf"), font(&spec("Deep Face", "Regular", 400)));+ write(&root.join("outside/Secret.ttf"), font(&spec("Secret Face", "Regular", 400)));+ write(&root.join("outside/dir/Other.ttf"), font(&spec("Other Face", "Regular", 400)));+ symlink(root.join("outside/Secret.ttf"), picked.join("Secret.ttf")).unwrap();+ symlink(root.join("outside/dir"), picked.join("dir")).unwrap();+ symlink(&picked, root.join("picked-link")).unwrap();+ let cat = Catalog::new(linux_env(&root)).with_font_service(false);+ // The folder may itself be a link; what counts is where it leads.+ cat.set_confined_dirs(vec![root.join("picked-link")]);+ assert_eq!(cat.families().to_vec(), ["Deep Face", "Inside Face"]);+ assert_eq!(cat.styles("Inside Face")[0].source, Source::User);+ // A confined file opens by its real path without following a link swapped in for it.+ assert!(crate::scan::open_regular(&picked.join("Inside.ttf"), true).is_ok());+ assert!(crate::scan::open_regular(&picked.join("Secret.ttf"), true).is_err());+ // As an app folder, the same folder follows its links.+ cat.set_confined_dirs(vec![]);+ cat.set_app_dirs(vec![picked]);+ assert!(cat.rescan_if_changed());+ assert_eq!(cat.families().to_vec(), ["Deep Face", "Inside Face", "Other Face", "Secret Face"]);+}+ #[test] fn the_scan_reads_only_what_it_may() { let root = temp("bounded");@@ -498,3 +693,257 @@ fn fork_of(fonts: &[Vec<u8>]) -> Vec<u8> { fork.extend(map); fork }++#[test]+fn changes_since_names_the_families_added_removed_and_changed() {+ let root = temp("changes");+ let dir = root.join("app-fonts");+ write(&dir.join("AppFace-Regular.ttf"), font(&spec("App Face", "Regular", 400)));+ let cat = Catalog::without_installed_fonts();+ cat.set_app_dirs(vec![dir.clone()]);+ let names = |v: &[String]| v.to_vec();+ let start = cat.generation();+ assert_eq!(names(&cat.changes_since(start).added), Vec::<String>::new());+ cat.rescan();+ let scanned = cat.generation();+ assert_eq!(names(&cat.changes_since(start).added), ["App Face"]);+ // Added and removed faces.+ let limits = Limits::default();+ cat.add_bytes(font(&spec("Added Face", "Regular", 400)), Source::User, &limits).unwrap();+ let changes = cat.changes_since(scanned);+ assert_eq!((names(&changes.added), changes.generation, changes.full), (vec!["Added Face".to_string()], cat.generation(), false));+ let added = changes.generation;+ cat.remove_added(Source::User);+ assert_eq!(names(&cat.changes_since(added).removed), ["Added Face"]);+ assert!(cat.changes_since(scanned).is_empty());+ assert!(cat.changes_since(cat.generation()).is_empty());+ // A scope's faces don't count.+ let scope = cat.new_scope();+ cat.add_bytes_in(scope, font(&spec("Scoped Face", "Regular", 400)), Source::Document, &limits).unwrap();+ assert!(cat.changes_since(scanned).is_empty());+ // A rescan: a family installed, a style added to one, then a family removed.+ let before = cat.generation();+ write(&dir.join("Installed-Regular.ttf"), font(&spec("Installed Face", "Regular", 400)));+ write(&dir.join("AppFace-Bold.ttf"), font(&spec("App Face", "Bold", 700)));+ cat.rescan();+ let changes = cat.changes_since(before);+ assert_eq!((names(&changes.added), names(&changes.changed)), (vec!["Installed Face".to_string()], vec!["App Face".to_string()]));+ assert!(!cat.rescan_if_changed());+ cat.rescan();+ assert_eq!(cat.changes_since(changes.generation), Changes { generation: cat.generation(), ..Changes::default() });+ std::fs::remove_file(dir.join("Installed-Regular.ttf")).unwrap();+ cat.rescan();+ let changes = cat.changes_since(before);+ assert_eq!((names(&changes.added), names(&changes.removed), names(&changes.changed)), (vec![], vec![], vec!["App Face".to_string()]));+ assert_eq!(names(&cat.changes_since(changes.generation - 1).removed), ["Installed Face"]);+ // The history keeps the last 64 changes of the family set; older generations get `full`.+ let bytes = font(&spec("Churn", "Regular", 400));+ for _ in 0..40 {+ cat.add_bytes(bytes.clone(), Source::User, &limits).unwrap();+ cat.remove_added(Source::User);+ }+ let changes = cat.changes_since(before);+ assert!(changes.full && changes.added.is_empty() && !changes.is_empty());+ assert!(!cat.changes_since(cat.generation() - 10).full);+}++#[test]+fn the_catalog_without_installed_fonts_sees_none_of_the_hosts() {+ let cat = Catalog::without_installed_fonts();+ assert!(font_dirs(cat.env()).is_empty() && font_store_dir(cat.env()).is_none());+ assert_eq!(cat.rescan(), 0);+ assert!(cat.families().is_empty());+ assert!(!cat.installed_fonts_changed());+ // It still takes bundled faces and the app's folders.+ cat.add_bytes(font(&spec("Bundled Face", "Regular", 400)), Source::Bundled, &Limits::default()).unwrap();+ let root = temp("empty");+ write(&root.join("AppFace.ttf"), font(&spec("App Face", "Regular", 400)));+ cat.set_app_dirs(vec![root]);+ cat.rescan();+ assert_eq!(cat.families().to_vec(), ["App Face", "Bundled Face"]);+}++/// Waits up to 10 seconds for `done`.+fn wait_for(done: impl Fn() -> bool) {+ let deadline = Instant::now() + Duration::from_secs(10);+ while !done() {+ assert!(Instant::now() < deadline, "timed out");+ std::thread::sleep(Duration::from_millis(1));+ }+}++#[test]+fn activations_share_one_worker_and_space_out_full_rescans() {+ use crate::app::Refresh;++ let root = temp("activate");+ // A font service that names `listed` and waits while `blocked`, so a full rescan is+ // visible (each calls it) and can be held running.+ let (calls, blocked, listed) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicBool::new(false)), Arc::new(Mutex::new(Vec::new())));+ let (c, b, l) = (Arc::clone(&calls), Arc::clone(&blocked), Arc::clone(&listed));+ let lister: Lister = Arc::new(move || {+ c.fetch_add(1, Ordering::SeqCst);+ while b.load(Ordering::SeqCst) {+ std::thread::sleep(Duration::from_millis(1));+ }+ l.lock().unwrap().clone()+ });+ let cat = Arc::new(Catalog::without_installed_fonts().with_lister(lister));+ let (tx, rx) = mpsc::channel();+ let on_change: app::OnChange = Arc::new(move |c: &Catalog| {+ let _ = tx.send(c.families().to_vec());+ });+ let refresh = Arc::new(Refresh::new(Arc::clone(&cat), Duration::from_secs(60), Some(on_change)));+ let t0 = Instant::now();+ assert!(!refresh.activated_at(t0), "nothing before the first scan");+ cat.rescan();+ assert_eq!(calls.load(Ordering::SeqCst), 1);++ // Within the interval: a change check only, which doesn't ask the font service.+ assert!(refresh.activated_at(t0));+ wait_for(|| !refresh.is_running());+ assert_eq!(calls.load(Ordering::SeqCst), 1);++ // After it: a full rescan, finding a font only the service knows. Activations while it runs+ // start nothing.+ let service = root.join("service/f1");+ write(&service, font(&spec("Service Face", "Regular", 400)));+ listed.lock().unwrap().push(service);+ blocked.store(true, Ordering::SeqCst);+ assert!(refresh.activated_at(t0 + Duration::from_secs(61)));+ wait_for(|| calls.load(Ordering::SeqCst) == 2);+ assert!(!refresh.activated_at(t0 + Duration::from_secs(200)));+ assert!(!refresh.activated_at(t0 + Duration::from_secs(300)));+ blocked.store(false, Ordering::SeqCst);+ assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap(), ["Service Face"]);+ wait_for(|| !refresh.is_running());+ assert_eq!(calls.load(Ordering::SeqCst), 2);++ // The interval counts from that full rescan.+ assert!(refresh.activated_at(t0 + Duration::from_secs(120)));+ wait_for(|| !refresh.is_running());+ assert_eq!(calls.load(Ordering::SeqCst), 2);+ assert!(refresh.activated_at(t0 + Duration::from_secs(121)));+ wait_for(|| !refresh.is_running());+ assert_eq!(calls.load(Ordering::SeqCst), 3);+ // Nothing changed, so no call.+ assert!(rx.try_recv().is_err());+}++#[test]+fn a_file_replaced_in_place_loads_its_new_bytes() {+ let root = temp("replace");+ let path = root.join("fonts/Face.ttf");+ write(&path, font(&spec("Face", "Regular", 400)));+ let cat = Catalog::without_installed_fonts();+ cat.set_app_dirs(vec![root.join("fonts")]);+ let old = cat.load(&cat.styles("Face")[0]).unwrap();+ // Held, so the old bytes stay cached.+ let new = font(&Spec { width: 7, ..spec("Face", "Regular Wide", 400) });+ std::fs::write(&path, &new).unwrap();+ cat.rescan();+ let face = cat.styles("Face")[0].clone();+ assert_eq!(face.style, "Regular Wide");+ assert_eq!(&*cat.load(&face).unwrap().bytes, new.as_slice());+ assert_ne!(&*old.bytes, new.as_slice());+}++#[cfg(unix)]+#[test]+fn a_confined_face_never_loads_through_a_link_swapped_in_after_the_scan() {+ let root = temp("swap");+ let picked = root.join("picked");+ let inside = picked.join("Inside.ttf");+ write(&inside, font(&spec("Inside Face", "Regular", 400)));+ write(&root.join("outside/Secret.ttf"), font(&spec("Inside Face", "Regular", 400)));+ let cat = Catalog::without_installed_fonts();+ cat.set_confined_dirs(vec![picked]);+ let face = cat.styles("Inside Face")[0].clone();+ // Loaded and held, so its bytes are cached; then swapped for a link before a rescan.+ let held = cat.load(&face).unwrap();+ std::fs::remove_file(&inside).unwrap();+ std::os::unix::fs::symlink(root.join("outside/Secret.ttf"), &inside).unwrap();+ assert!(cat.load(&face).is_err());+ drop(held);+ assert!(cat.load(&face).is_err());+ // An unconfined face of the same path follows the link.+ let unconfined = FaceInfo { confined: false, ..face };+ assert!(cat.load(&unconfined).is_ok());+}++/// A catalog whose font service blocks while `blocked`, and counts its calls.+fn blocking_catalog(blocked: &Arc<AtomicBool>, calls: &Arc<AtomicUsize>) -> Arc<Catalog> {+ let (b, c) = (Arc::clone(blocked), Arc::clone(calls));+ let lister: Lister = Arc::new(move || {+ c.fetch_add(1, Ordering::SeqCst);+ while b.load(Ordering::SeqCst) {+ std::thread::sleep(Duration::from_millis(1));+ }+ Vec::new()+ });+ Arc::new(Catalog::without_installed_fonts().with_lister(lister))+}++#[test]+fn adding_fonts_never_waits_for_a_scan() {+ let (blocked, calls) = (Arc::new(AtomicBool::new(true)), Arc::new(AtomicUsize::new(0)));+ let cat = blocking_catalog(&blocked, &calls);+ // During the first scan: the app's bundled fonts, one rebuild for all of them.+ cat.scan_in_background();+ wait_for(|| calls.load(Ordering::SeqCst) == 1);+ let bundled: Vec<Arc<[u8]>> =+ vec![font(&spec("Bundled One", "Regular", 400)).into(), b"not a font".as_slice().into(), font(&spec("Bundled Two", "Regular", 400)).into()];+ let errors = crate::app::add_app_fonts(&cat, app::AppFonts { bundled, ..app::AppFonts::default() });+ assert_eq!(errors.iter().map(|(i, _)| *i).collect::<Vec<_>>(), [1]);+ assert!(!cat.is_ready() && blocked.load(Ordering::SeqCst));+ assert_eq!(cat.generation(), 0);+ blocked.store(false, Ordering::SeqCst);+ assert_eq!(cat.families().to_vec(), ["Bundled One", "Bundled Two"]);+ assert_eq!(cat.generation(), 1);+ // During a rescan: the face is seen at once.+ blocked.store(true, Ordering::SeqCst);+ let rescan = std::thread::spawn({+ let cat = Arc::clone(&cat);+ move || cat.rescan()+ });+ wait_for(|| calls.load(Ordering::SeqCst) == 2);+ cat.add_bytes(font(&spec("Later", "Regular", 400)), Source::User, &Limits::default()).unwrap();+ assert_eq!(cat.families().to_vec(), ["Bundled One", "Bundled Two", "Later"]);+ blocked.store(false, Ordering::SeqCst);+ assert_eq!(rescan.join().unwrap(), 3);+ assert_eq!(cat.families().to_vec(), ["Bundled One", "Bundled Two", "Later"]);+}++#[test]+fn rescan_now_counts_as_the_full_rescan() {+ use crate::app::Refresh;++ let (blocked, calls) = (Arc::new(AtomicBool::new(false)), Arc::new(AtomicUsize::new(0)));+ let cat = blocking_catalog(&blocked, &calls);+ let root = temp("rescan-now");+ cat.set_app_dirs(vec![root.clone()]);+ let (tx, rx) = mpsc::channel();+ let on_change: app::OnChange = Arc::new(move |c: &Catalog| {+ let _ = tx.send(c.families().to_vec());+ });+ let refresh = Arc::new(Refresh::new(Arc::clone(&cat), Duration::from_secs(60), Some(on_change)));+ let t0 = Instant::now();+ cat.rescan();+ assert!(refresh.activated_at(t0));+ wait_for(|| !refresh.is_running());+ // Reload fonts, after a font is installed: on this thread, with on_change.+ write(&root.join("New.ttf"), font(&spec("New Face", "Regular", 400)));+ let changes = refresh.rescan_now(Some(t0 + Duration::from_secs(59)));+ assert_eq!(changes.added, ["New Face"]);+ assert_eq!(rx.try_recv().unwrap(), ["New Face"]);+ assert_eq!(calls.load(Ordering::SeqCst), 2);+ // The interval counts from it: no full rescan at t0 + 60 s.+ assert!(refresh.activated_at(t0 + Duration::from_secs(60)));+ wait_for(|| !refresh.is_running());+ assert_eq!(calls.load(Ordering::SeqCst), 2);+ assert!(refresh.activated_at(t0 + Duration::from_secs(119)));+ wait_for(|| !refresh.is_running());+ assert_eq!(calls.load(Ordering::SeqCst), 3);+ assert!(refresh.rescan_now(None).is_empty());+ assert!(rx.try_recv().is_err());+}