diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -146,6 +146,7 @@ app's JSON control channel (`deckcraft --control PORT`).
 | 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
 | 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
 | 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
+| 2026-10-19 | Fonts: the macOS font scan leaves Microsoft Office's CloudFonts container out — it is another app's data, so reading it raises the privacy prompt that held a headless `deckcraft-cli render` blocked until somebody answered it; the Office families stay covered by the substitutes (#981) |
 | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
 | 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
 | 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs
--- a/crates/fonts/src/fontdb.rs
+++ b/crates/fonts/src/fontdb.rs
@@ -484,25 +484,7 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
     }
     let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
     if cfg!(target_os = "macos") {
-        dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
-        if let Some(h) = &home {
-            dirs.push(h.join("Library/Fonts"));
-            // Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them.
-            dirs.push(h.join("Library/Group Containers/UBF8T346G9.Office/FontCache/4/CloudFonts"));
-        }
-        // Fonts Office installs inside its apps.
-        for app in ["Microsoft PowerPoint", "Microsoft Word", "Microsoft Excel"] {
-            dirs.push(std::path::PathBuf::from(format!("/Applications/{app}.app/Contents/Resources/DFonts")));
-        }
-        // Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font<N>.
-        for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] {
-            let Ok(entries) = std::fs::read_dir(parent) else { continue };
-            for e in entries.flatten() {
-                if e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") {
-                    dirs.push(e.path());
-                }
-            }
-        }
+        dirs = macos_font_dirs(home.as_deref());
     } else if cfg!(windows) {
         let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
         dirs.push(root.join("Fonts"));
@@ -535,6 +517,38 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
     dirs
 }
 
+/// The macOS font folders for a home directory: the system's, the user's own, the fonts Office
+/// installs inside its apps, and the font assets macOS ships (`com_apple_MobileAsset_Font<N>`).
+///
+/// Microsoft Office's **containers** are deliberately not among them (#981): they hold another
+/// app's data, so on macOS 14 and later reading them raises the "would like to access data from
+/// other apps" prompt, whose `opendir` blocks until somebody answers it. The scan runs on the main
+/// thread before the first lookup, so a headless `deckcraft-cli render` hung behind a prompt nobody
+/// was there to dismiss. The Office families that live there are covered by [`substitutes`] instead.
+///
+/// Compiled for every target (`std::fs` only fails at run time on the web, and
+/// [`system_font_dirs`] returns before this is reached there).
+pub(crate) fn macos_font_dirs(home: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
+    let mut dirs: Vec<std::path::PathBuf> = vec!["/System/Library/Fonts".into(), "/Library/Fonts".into()];
+    if let Some(h) = home {
+        dirs.push(h.join("Library/Fonts"));
+    }
+    // Fonts Office installs inside its apps (bundle data, readable without a prompt).
+    for app in ["Microsoft PowerPoint", "Microsoft Word", "Microsoft Excel"] {
+        dirs.push(std::path::PathBuf::from(format!("/Applications/{app}.app/Contents/Resources/DFonts")));
+    }
+    // Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font<N>.
+    for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] {
+        let Ok(entries) = std::fs::read_dir(parent) else { continue };
+        for e in entries.flatten() {
+            if e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") {
+                dirs.push(e.path());
+            }
+        }
+    }
+    dirs
+}
+
 /// `<data dir>/fonts` for `XDG_DATA_HOME` (default `~/.local/share`) and each `XDG_DATA_DIRS`
 /// entry (default `/usr/local/share:/usr/share`).
 fn xdg_font_dirs(data_home: Option<&str>, data_dirs: Option<&str>, home: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs
--- a/crates/fonts/src/lib.rs
+++ b/crates/fonts/src/lib.rs
@@ -175,6 +175,25 @@ mod tests {
         FontDb::global().face(DEFAULT_FAMILY, "Regular")
     }
 
+    #[test]
+    fn the_macos_scan_leaves_other_apps_data_alone() {
+        let home = std::path::Path::new("/Users/tester");
+        let dirs = fontdb::macos_font_dirs(Some(home));
+        assert!(dirs.contains(&std::path::PathBuf::from("/System/Library/Fonts")), "{dirs:?}");
+        assert!(dirs.contains(&home.join("Library/Fonts")), "{dirs:?}");
+        // Fonts inside Office's own app bundles stay: they are readable without a prompt.
+        assert!(dirs.iter().any(|d| d.ends_with("Microsoft PowerPoint.app/Contents/Resources/DFonts")), "{dirs:?}");
+        // Office's containers do not: they are another app's data, so on macOS 14+ reading them
+        // raises the privacy prompt that blocks the scan — and with it a headless render — until
+        // somebody answers it (#981). The families there are covered by `substitutes`.
+        assert!(!dirs.iter().any(|d| d.to_string_lossy().contains("UBF8T346G9.Office")), "{dirs:?}");
+        assert!(!dirs.iter().any(|d| d.to_string_lossy().contains("CloudFonts")), "{dirs:?}");
+        // Without a home the system folders are still scanned, and still nothing of Office's.
+        let none = fontdb::macos_font_dirs(None);
+        assert!(none.contains(&std::path::PathBuf::from("/Library/Fonts")), "{none:?}");
+        assert!(!none.iter().any(|d| d.to_string_lossy().contains("UBF8T346G9.Office")), "{none:?}");
+    }
+
     #[test]
     fn emoji_clusters_are_recognised() {
         let c = |s: &str| s.chars().collect::<Vec<char>>();
