diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
diff --git a/crates/font-catalog/Cargo.toml b/crates/font-catalog/Cargo.toml
--- a/crates/font-catalog/Cargo.toml
+++ b/crates/font-catalog/Cargo.toml
@@ -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"
 
diff --git a/crates/font-catalog/README.md b/crates/font-catalog/README.md
--- a/crates/font-catalog/README.md
+++ b/crates/font-catalog/README.md
@@ -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. Mac suitcase fonts are read from their resource fork. Damaged files give no faces, never a panic.
 
 Each face (`FaceInfo`) has its family and style, legacy family and style, family names in other languages, full name, PostScript name, weight, width, italic, a named instance's axis settings, `fsType`, the OS/2 code page bits, its file and index, and its source. Each named instance of a variable font is a face of its own.
 
@@ -60,7 +61,17 @@ 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.
+- `add_bytes` and `add_file` add fonts from downloads, the user or the app's bundle, within `Limits`, for every lookup. 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.
+- Other scopes and the unscoped lookups never see a scope's faces.
+- `remove_scope(scope)` removes its faces when the document closes. Other scopes keep theirs, and `remove_added` doesn't touch scoped faces.
+- `Limits::max_added_faces` counts the added faces of every scope together.
 
 ## Changes
 
@@ -68,7 +79,7 @@ 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.
 
 ## One enumeration for the UI and documents
 
diff --git a/crates/font-catalog/src/catalog.rs b/crates/font-catalog/src/catalog.rs
--- a/crates/font-catalog/src/catalog.rs
+++ b/crates/font-catalog/src/catalog.rs
@@ -1,6 +1,6 @@
 //! The catalog: every installed face, found by any of its names, loaded once.
 
-use std::collections::{HashMap, HashSet};
+use std::collections::{BTreeSet, HashMap, HashSet};
 use std::path::{Path, PathBuf};
 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
 use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, RwLock, Weak};
@@ -50,15 +50,15 @@ 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 the catalog holds in all, every scope's included.
     pub max_added_faces: usize,
 }
 
@@ -77,6 +77,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 +112,23 @@ 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,
+        }
+    }
+}
+
 /// One state of the catalog: what lookups read while a scan builds the next one.
 #[derive(Default)]
 struct Index {
@@ -115,6 +138,8 @@ 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,
 }
 
 impl Index {
@@ -123,11 +148,15 @@ impl Index {
     fn build(added: &[Face], scanned: &[Face]) -> Self {
         let mut faces: Vec<Face> = Vec::new();
         let mut seen = HashSet::new();
-        for f in added.iter().chain(scanned) {
+        let mut from_added = 0;
+        for (n, f) in added.iter().chain(scanned).enumerate() {
             let variations: Vec<(u32, u32)> = f.info.variations.iter().map(|(t, v)| (u32::from_be_bytes(*t), v.to_bits())).collect();
             let key = (f.info.family.to_ascii_lowercase(), norm(&f.info.style), f.keys.postscript.first().cloned(), variations);
             if seen.insert(key) {
                 faces.push(f.clone());
+                if n < added.len() {
+                    from_added += 1;
+                }
             }
         }
         let mut by_family: HashMap<String, Vec<usize>> = HashMap::new();
@@ -169,7 +198,7 @@ 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 }
     }
 
     fn face(&self, i: usize) -> Option<&Face> {
@@ -246,9 +275,30 @@ fn style_key(style: &str) -> String {
     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,
 }
@@ -269,6 +319,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 +329,115 @@ struct Stamp {
 
 #[derive(Default)]
 struct Added {
+    /// Faces added without a scope.
     faces: 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.view = None;
+        scoped
+    }
+
+    /// Every added face, every scope's included: what [`Limits::max_added_faces`] bounds.
+    fn count(&self) -> usize {
+        self.faces.len() + self.scopes.values().map(|s| s.faces.len()).sum::<usize>()
+    }
+
+    /// 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,
+    /// 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)
+    }
 }
 
 /// 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.
 ///
@@ -302,7 +454,9 @@ pub struct Catalog {
     stamp: Mutex<Stamp>,
     generation: AtomicU64,
     added: Mutex<Added>,
-    loaded: Mutex<HashMap<String, FamilyLoad>>,
+    next_scope: AtomicU64,
+    /// Loaded families by scope (`None`: unscoped) and lowercase family name.
+    loaded: Mutex<HashMap<(Option<Scope>, String), FamilyLoad>>,
     files: Mutex<HashMap<PathBuf, Weak<[u8]>>>,
 }
 
@@ -320,13 +474,19 @@ 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),
             added: Mutex::new(Added::default()),
+            next_scope: AtomicU64::new(0),
             loaded: Mutex::new(HashMap::new()),
             files: Mutex::new(HashMap::new()),
         }
@@ -361,6 +521,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 +599,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,11 +632,22 @@ 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.
+    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
+    }
+
     fn snapshot(&self) -> Arc<Index> {
         self.ensure();
         Arc::clone(&self.index.read().unwrap_or_else(PoisonError::into_inner))
@@ -478,12 +659,35 @@ impl Catalog {
         self.generation.fetch_add(1, Ordering::AcqRel);
     }
 
+    /// 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.
     pub fn families(&self) -> Arc<[String]> {
         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)
     }
@@ -491,15 +695,25 @@ impl Catalog {
     /// 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 +723,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).
@@ -540,11 +766,15 @@ impl Catalog {
         if let Some(bytes) = lock(&self.files).get(path).and_then(Weak::upgrade) {
             return Ok(bytes);
         }
-        let size = std::fs::metadata(path)?.len();
+        use std::io::Read;
+        let file = scan::open_regular(path, false)?;
+        let size = file.metadata()?.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));
@@ -557,15 +787,42 @@ 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.
+    /// `source`, seen by every lookup. Returns the faces added.
     pub fn add_bytes(&self, bytes: Vec<u8>, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {
+        self.add_bytes_to(None, bytes, source, limits)
+    }
+
+    /// [`Catalog::add_bytes`] into `scope`: only lookups in the scope see the faces.
+    pub fn add_bytes_in(&self, scope: Scope, bytes: Vec<u8>, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {
+        self.add_bytes_to(Some(scope), bytes, source, limits)
+    }
+
+    fn add_bytes_to(&self, scope: Option<Scope>, 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 });
@@ -576,7 +833,7 @@ impl Catalog {
         }
         self.ensure();
         let mut added = lock(&self.added);
-        if added.faces.len() + faces.len() > limits.max_added_faces {
+        if added.count() + faces.len() > limits.max_added_faces {
             return Err(FontFileError::TooManyFaces { max: limits.max_added_faces });
         }
         let blob = added.next_blob;
@@ -585,13 +842,27 @@ impl Catalog {
         for f in &mut faces {
             f.info.blob = Some(blob);
         }
-        Ok(self.push_added(added, faces))
+        Ok(self.push_added(added, scope, faces))
     }
 
-    /// Add the faces of the font file at `path` for this session, as `source`, read in place.
-    /// Returns the faces added.
+    /// 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 });
         }
@@ -606,27 +877,37 @@ impl Catalog {
         }
         self.ensure();
         let added = lock(&self.added);
-        if added.faces.len() + faces.len() > limits.max_added_faces {
+        if added.count() + faces.len() > limits.max_added_faces {
             return Err(FontFileError::TooManyFaces { max: limits.max_added_faces });
         }
-        Ok(self.push_added(added, faces))
+        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);
+                let scanned = self.scanned();
+                self.publish(Index::build(&added.faces, &scanned));
+            }
+            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();
@@ -635,6 +916,21 @@ impl Catalog {
         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
+    }
+
     /// The faces the last scan found.
     fn scanned(&self) -> Vec<Face> {
         let scan = lock(&self.scan);
@@ -645,14 +941,16 @@ impl Catalog {
 
     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 +966,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,17 +981,23 @@ 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 };
+            // A FIFO or device named like a font would block the scan or never end.
+            if !meta.is_file() {
+                continue;
+            }
             let (len, mtime) = (meta.len(), meta.modified().ok());
             let cached = state.files.remove(&path).filter(|c| c.len == len && c.modified == mtime);
             let mut faces = match cached {
                 Some(c) => c.faces,
-                None if by_name || scan::is_font_file(&path) || crate::suitcase::is_suitcase(&path) => scan::file_faces(&path, MAX_SCAN_READ, source),
+                None if by_name || scan::is_font_file(&path) || crate::suitcase::is_suitcase(&path) => {
+                    scan::opened_file_faces(&path, MAX_SCAN_READ, source, real)
+                }
                 None => continue,
             };
             for f in &mut faces {
@@ -703,7 +1008,7 @@ impl Catalog {
         state.files = files;
         drop(state);
 
-        *lock(&self.stamp) = Stamp { app_dirs, dirs: walk.dirs, registered };
+        *lock(&self.stamp) = Stamp { app_dirs, confined_dirs, dirs: walk.dirs, registered };
         let added = lock(&self.added);
         let scanned = self.scanned();
         self.publish(Index::build(&added.faces, &scanned));
@@ -729,8 +1034,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 +1045,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 +1069,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));
     }
 }
diff --git a/crates/font-catalog/src/lib.rs b/crates/font-catalog/src/lib.rs
--- a/crates/font-catalog/src/lib.rs
+++ b/crates/font-catalog/src/lib.rs
@@ -28,7 +28,7 @@ mod scan;
 mod suitcase;
 pub mod system;
 
-pub use catalog::{Catalog, FontData, FontFileError, Limits, Lister, LoadedFace, Match, Watch};
+pub use catalog::{Catalog, 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};
diff --git a/crates/font-catalog/src/scan.rs b/crates/font-catalog/src/scan.rs
--- a/crates/font-catalog/src/scan.rs
+++ b/crates/font-catalog/src/scan.rs
@@ -356,6 +356,11 @@ 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`.
+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 crate::suitcase::fork_len(path) > max_read {
             return vec![];
@@ -369,12 +374,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;
diff --git a/crates/font-catalog/src/tests.rs b/crates/font-catalog/src/tests.rs
--- a/crates/font-catalog/src/tests.rs
+++ b/crates/font-catalog/src/tests.rs
@@ -428,6 +428,156 @@ 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 every scope.
+    let three = Limits { max_added_faces: 3, ..limits };
+    assert!(matches!(cat.add_bytes(font(&spec("One More", "Regular", 400)), Source::User, &three), Err(FontFileError::TooManyFaces { max: 3 })));
+
+    // A face added without a scope comes after the scope's own.
+    cat.add_bytes(font(&Spec { fs_type: 2, ..spec("Test Sans", "Bold", 700) }), Source::User, &limits).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_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");
