diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
 
 **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (M6: bounded document-owned fonts extracted from #1334; native snapshots, scoped measurement, rendering and SVG/PDF export; owner font-policy approval required; colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
 
 FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
 native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -132,6 +132,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
 
 ## Progress log
 
+- **2026-10-27** · M6: bounded document-owned fonts extracted from #1334; native snapshots, scoped measurement, rendering and SVG/PDF export; owner font-policy approval required. Numbers unchanged.
+
 - **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
 - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
 - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
@@ -348,6 +350,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | M6: bounded document-owned fonts extracted from #1334; native snapshots, scoped measurement, rendering and SVG/PDF export; owner font-policy approval required. Numbers unchanged. |
 | 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
 | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
 | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -182,6 +182,14 @@ pub struct Document {
     /// Images by content hash; serialized separately by the format crate.
     #[serde(skip)]
     pub images: BTreeMap<String, ImageData>,
+    /// Font files belonging to this design (SHA-256 -> original OpenType/TrueType bytes).
+    /// Serialized as separate `fonts/<sha256>` entries in `.figcraft`, never in JSON.
+    #[serde(skip)]
+    pub embedded_fonts: BTreeMap<String, Arc<Vec<u8>>>,
+    /// Redistribution notices for document-owned fonts, indexed by font SHA-256.
+    /// Stored in document JSON so portable fonts retain their license on save/reopen.
+    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+    pub embedded_font_licenses: BTreeMap<String, String>,
     /// The page shown when the file opens.
     #[serde(default)]
     pub current_page: Option<NodeId>,
@@ -249,6 +257,8 @@ impl Document {
             styles: Vec::new(),
             variables: Variables::default(),
             images: BTreeMap::new(),
+            embedded_fonts: BTreeMap::new(),
+            embedded_font_licenses: BTreeMap::new(),
             current_page: None,
             document_colors: Vec::new(),
             comments: Vec::new(),
diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs
--- a/crates/engine/src/cmd/file/load.rs
+++ b/crates/engine/src/cmd/file/load.rs
@@ -108,6 +108,7 @@ pub(super) fn load_with(
             if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" {
                 loaded.doc.name = file_stem(name_hint);
             }
+            validate_document_fonts(&loaded.doc, &mut loaded.issues);
             Ok((loaded, false))
         }
     }
@@ -335,6 +336,18 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_forma
     }))
 }
 
+/// Font data from documents is registered at runtime only; never written into system
+/// font folders. SHA-256 ids avoid re-registering the same file on each reopen. A
+/// document keeps the bytes so the font survives save/reopen on a different computer.
+fn validate_document_fonts(doc: &Document, warnings: &mut Vec<String>) {
+    // Never add portable fonts to the process-global font database: a separate open
+    // document can contain different bytes with exactly the same family/style.
+    let families = figcraft_text::document_font_families(doc);
+    if families.is_empty() && !doc.embedded_fonts.is_empty() {
+        warnings.push("Embedded font data is not readable; original family names were preserved".into());
+    }
+}
+
 /// Link imported instances to their mains (layer sources, overrides found by comparing values),
 /// innermost first so outer instances settle last.
 fn link_instances(doc: &mut Document) {
diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs
--- a/crates/engine/src/cmd/file/open.rs
+++ b/crates/engine/src/cmd/file/open.rs
@@ -155,11 +155,17 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
                 None => &figcraft_format::rest::no_images,
             };
             let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None };
-            let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?;
+            let (mut other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?;
             let summary = import_summary(&other.doc);
-            let pages = append_pages(s.doc_mut()?, &other.doc)?;
+            let pages = append_pages(s.doc_mut()?, &other.doc, &mut other.issues)?;
+            if !other.issues.is_empty() {
+                s.message = Some(match other.issues.as_slice() {
+                    [one] => format!("{}: {one}", file_stem(&name)),
+                    more => format!("{}: {} import warnings; first: {}", file_stem(&name), more.len(), more[0]),
+                });
+            }
             let mut out = json!({"pages": pages, "summary": summary});
-            if rest {
+            if rest || !other.issues.is_empty() {
                 out["warnings"] = json!(other.issues);
             }
             Ok(out)
@@ -178,7 +184,11 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
 /// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers
 /// their source and interactions and flows their targets wherever those are. Styles, variables and
 /// collections whose id the document already has are kept as they are.
-pub(super) fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
+pub(super) fn append_pages(doc: &mut Document, other: &Document, warnings: &mut Vec<String>) -> Result<Vec<NodeId>> {
+    let (_, skipped) = figcraft_format::merge_embedded_fonts(doc, other);
+    if skipped > 0 {
+        warnings.push(format!("{skipped} embedded fonts were skipped (invalid or over the size/count limit)"));
+    }
     for (k, v) in &other.images {
         doc.images.entry(k.clone()).or_insert_with(|| v.clone());
     }
diff --git a/crates/engine/src/cmd/file/rest_import_tests.rs b/crates/engine/src/cmd/file/rest_import_tests.rs
--- a/crates/engine/src/cmd/file/rest_import_tests.rs
+++ b/crates/engine/src/cmd/file/rest_import_tests.rs
@@ -76,7 +76,7 @@ fn appending_pages_with_cached_library_components_brings_the_components() {
     for i in 0..8 {
         doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap();
     }
-    append_pages(&mut doc, &other).unwrap();
+    append_pages(&mut doc, &other, &mut Vec::new()).unwrap();
     assert!(doc.validate().is_empty(), "{:?}", doc.validate());
     assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc));
     let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap();
diff --git a/crates/engine/src/derived.rs b/crates/engine/src/derived.rs
--- a/crates/engine/src/derived.rs
+++ b/crates/engine/src/derived.rs
@@ -13,6 +13,7 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec<NodeId> {
 /// Keep derived state consistent after edits: text boxes resize to their content, groups wrap
 /// their children, instances follow their main components, auto layout re-flows.
 pub fn normalize(doc: &mut Document, before: &Document) {
+    let _fonts = figcraft_text::enter_document_fonts(doc);
     // Only what was touched since the last normalisation (edited, added or removed).
     let mut touched: BTreeSet<NodeId> = doc.take_dirty();
     // A variable or style edit changes no layer directly, but bound and linked layers must follow it.
diff --git a/crates/engine/src/measure.rs b/crates/engine/src/measure.rs
--- a/crates/engine/src/measure.rs
+++ b/crates/engine/src/measure.rs
@@ -6,6 +6,7 @@ pub struct TextMeasure;
 
 impl figcraft_layout::Measure for TextMeasure {
     fn text_height(&self, doc: &Document, id: NodeId, width: f64) -> Option<f64> {
+        let _fonts = figcraft_text::enter_document_fonts(doc);
         let n = doc.node(id)?;
         let NodeKind::Text(t) = &n.kind else { return None };
         let mut t = t.clone();
@@ -15,6 +16,7 @@ impl figcraft_layout::Measure for TextMeasure {
         Some(figcraft_text::layout(&t, Some(width), None).height.max(1.0))
     }
     fn text_size(&self, doc: &Document, id: NodeId) -> Option<(f64, f64)> {
+        let _fonts = figcraft_text::enter_document_fonts(doc);
         let n = doc.node(id)?;
         let NodeKind::Text(t) = &n.kind else { return None };
         if t.auto_resize == TextAutoResize::WidthAndHeight {
@@ -27,6 +29,7 @@ impl figcraft_layout::Measure for TextMeasure {
         Some((l.width.ceil().max(1.0), l.height.max(1.0)))
     }
     fn text_baseline(&self, doc: &Document, id: NodeId) -> Option<f64> {
+        let _fonts = figcraft_text::enter_document_fonts(doc);
         let n = doc.node(id)?;
         let (l, dy) = figcraft_text::layout_node(n)?;
         l.lines.first().map(|line| line.baseline + dy).filter(|b| b.is_finite())
diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs
--- a/crates/engine/src/session/exec.rs
+++ b/crates/engine/src/session/exec.rs
@@ -44,6 +44,8 @@ impl Session {
         let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid));
         // Cut and copy write the clipboard before a later check can refuse the command.
         let clipboard_before = self.clipboard.clone();
+        // The command and its normalisation must measure text using only its own fonts.
+        let _fonts = before.as_ref().map(|(doc, _, _, _)| figcraft_text::enter_document_fonts(doc));
         let result = guard::run(id, || (spec.run)(self, params))
             .and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
             .and_then(|v| self.check_preview(id, &before).map(|()| v))
diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml
--- a/crates/format/Cargo.toml
+++ b/crates/format/Cargo.toml
@@ -19,3 +19,6 @@ ruzstd = { workspace = true }
 sha1 = { workspace = true }
 sha2 = { workspace = true }
 image = { workspace = true }
+
+[dev-dependencies]
+skrifa = { workspace = true }
diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs
--- a/crates/format/src/large_tests.rs
+++ b/crates/format/src/large_tests.rs
@@ -198,9 +198,18 @@ fn the_header_is_read_on_its_own() {
     for junk in ["[]", "[2]", "3", "null", "\"doc\"", "{", ""] {
         assert!(matches!(from_json(junk), Err(FormatError::Corrupt(_))), "{junk}");
     }
-    // The image index is read from the header too.
-    let (_, _, index) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap();
-    assert_eq!(index, Some(["ab".to_string()].into_iter().collect()));
+    // The image and font indexes come from the single header pass.
+    let (_, _, images, fonts) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap();
+    assert_eq!(images, Some(["ab".to_string()].into_iter().collect()));
+    assert_eq!(fonts, Some(BTreeSet::new()));
+    let with_fonts = d.replacen("\"fontIndex\":[]", "\"fontIndex\":[\"font-a\",42,\"font-b\"]", 1);
+    let (_, _, _, fonts) = parse_json(&with_fonts).unwrap();
+    assert_eq!(fonts, Some(["font-a".to_string(), "font-b".to_string()].into_iter().collect()));
+    // Older documents have no fontIndex, which preserves the legacy load path.
+    let mut legacy: Value = serde_json::from_str(&d).unwrap();
+    legacy.as_object_mut().unwrap().remove("fontIndex");
+    let (_, _, _, fonts) = parse_json(&legacy.to_string()).unwrap();
+    assert_eq!(fonts, None);
 }
 
 #[test]
diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -2,7 +2,8 @@
 //!
 //! A zip archive holding `document.json` (the [`Document`] as JSON, with a `format`/`version`
 //! header; layer fields at their default values are left out and read back as those defaults),
-//! `images/<sha256>` for every image the document (or a saved version) uses, an optional
+//! `images/<sha256>` and `fonts/<sha256>` for binary assets the document, versions or branches
+//! use (deduplicated and indexed per snapshot), an optional
 //! `thumbnail.png` and an optional version history: `versions/index.json` (`{"versions": [{id,
 //! name, description, time, images}]}`, oldest first) plus one `versions/<id>.json` snapshot per
 //! version (a `document.json` of its own). Branches are kept the same way: `branches/index.json`
@@ -64,6 +65,92 @@ const BRANCH_INDEX: &str = "branches/index.json";
 pub const MAX_BRANCHES: usize = 100;
 /// Deflate level of JSON members (see [`json_options`]).
 const JSON_LEVEL: i64 = 3;
+/// Limits on font binaries carried with a document (not the system's font registry).
+const MAX_FONT_MEMBER: u64 = 32 * 1024 * 1024;
+const MAX_FONT_TOTAL: u64 = 128 * 1024 * 1024;
+const MAX_FONT_COUNT: usize = 128;
+/// Limit missing-font issue messages even when a damaged archive lists many hashes.
+const MAX_FONT_ISSUES: usize = 20;
+
+/// Keep diagnostics bounded even for untrusted font indexes with very large hash lists.
+#[derive(Default)]
+struct MissingFontIssues {
+    first: BTreeSet<String>,
+    additional_references: usize,
+}
+
+impl MissingFontIssues {
+    fn note(&mut self, hash: &str) {
+        let display = clip(hash, 80);
+        if self.first.contains(&display) {
+            return;
+        }
+        if self.first.len() < MAX_FONT_ISSUES {
+            self.first.insert(display);
+        } else {
+            self.additional_references = self.additional_references.saturating_add(1);
+        }
+    }
+
+    fn report(self, issues: &mut Vec<String>) {
+        issues.extend(
+            self.first
+                .into_iter()
+                .map(|hash| format!("Embedded font {hash} is listed in the document or its history but its data is missing; saving again drops it")),
+        );
+        if self.additional_references > 0 {
+            issues.push(format!("{} additional references to missing embedded fonts", self.additional_references));
+        }
+    }
+}
+
+fn valid_font_hash(hash: &str) -> bool {
+    hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit())
+}
+
+/// Merge portable fonts while respecting the same asset limits as `.figcraft` loading.
+/// Existing fonts take priority. Returns the number of new font/license records and the number skipped.
+pub fn merge_embedded_fonts(target: &mut Document, source: &Document) -> (usize, usize) {
+    let mut used: u64 = target.embedded_fonts.values().map(|b| b.len() as u64).sum();
+    let mut accepted = 0;
+    let mut skipped = 0;
+    for (hash, bytes) in &source.embedded_fonts {
+        if target.embedded_fonts.contains_key(hash) {
+            continue;
+        }
+        let len = bytes.len() as u64;
+        if !valid_font_hash(hash)
+            || !is_sfnt_font(bytes)
+            || image_hash(bytes) != *hash
+            || len > MAX_FONT_MEMBER
+            || used.saturating_add(len) > MAX_FONT_TOTAL
+            || target.embedded_fonts.len() >= MAX_FONT_COUNT
+        {
+            skipped += 1;
+            continue;
+        }
+        used += len;
+        target.embedded_fonts.insert(hash.clone(), bytes.clone());
+        accepted += 1;
+    }
+    for (hash, license) in &source.embedded_font_licenses {
+        if target.embedded_fonts.contains_key(hash) && !target.embedded_font_licenses.contains_key(hash) {
+            target.embedded_font_licenses.insert(hash.clone(), license.clone());
+            accepted += 1;
+        }
+    }
+    (accepted, skipped)
+}
+
+/// Recognise OpenType/TrueType files, never pass arbitrary ZIP bytes to a font parser.
+fn is_sfnt_font(bytes: &[u8]) -> bool {
+    bytes.len() >= 12
+        && (bytes.starts_with(b"OTTO")
+            || bytes.starts_with(b"true")
+            || bytes.starts_with(b"typ1")
+            || bytes.starts_with(&[0, 1, 0, 0])
+            || bytes.starts_with(b"ttcf"))
+}
 
 #[derive(Debug)]
 pub enum FormatError {
@@ -103,8 +190,8 @@ enum Snapshot {
     /// Saved in this session (or already read).
     Doc(Arc<Document>),
     /// As read from a file: its `document.json`-style JSON, parsed on first use, and the images
-    /// it uses.
-    Stored { json: Arc<Vec<u8>>, images: BTreeMap<String, ImageData> },
+    /// it uses (both kinds of binary assets are shared with the active file).
+    Stored { json: Arc<Vec<u8>>, images: BTreeMap<String, ImageData>, fonts: BTreeMap<String, Arc<Vec<u8>>> },
 }
 
 impl Version {
@@ -125,10 +212,11 @@ impl Version {
     pub fn document(&self) -> Result<Arc<Document>, FormatError> {
         match &self.snapshot {
             Snapshot::Doc(d) => Ok(d.clone()),
-            Snapshot::Stored { json, images } => {
+            Snapshot::Stored { json, images, fonts } => {
                 let text = std::str::from_utf8(json).map_err(|e| FormatError::Corrupt(e.to_string()))?;
                 let (mut doc, _) = from_json(text)?;
                 doc.images = images.clone();
+                doc.embedded_fonts = fonts.clone();
                 Ok(Arc::new(doc))
             }
         }
@@ -141,6 +229,14 @@ impl Version {
             Snapshot::Stored { images, .. } => images,
         }
     }
+
+    /// Portable fonts owned by this exact snapshot, not by the current document.
+    pub fn fonts(&self) -> &BTreeMap<String, Arc<Vec<u8>>> {
+        match &self.snapshot {
+            Snapshot::Doc(d) => &d.embedded_fonts,
+            Snapshot::Stored { fonts, .. } => fonts,
+        }
+    }
 }
 
 /// Where a branch is in its life.
@@ -233,13 +329,15 @@ struct DocumentJson<'a> {
     version: u32,
     #[serde(rename = "imageIndex", serialize_with = "image_index")]
     images: &'a BTreeMap<String, ImageData>,
+    #[serde(rename = "fontIndex")]
+    fonts: Vec<&'a String>,
     #[serde(flatten)]
     doc: &'a Document,
 }
 
 impl<'a> DocumentJson<'a> {
     fn new(doc: &'a Document) -> Self {
-        Self { format: FORMAT, version: VERSION, images: &doc.images, doc }
+        Self { format: FORMAT, version: VERSION, images: &doc.images, fonts: doc.embedded_fonts.keys().collect(), doc }
     }
 }
 
@@ -445,18 +543,23 @@ pub fn write_json(doc: &Document, w: impl Write) -> Result<(), FormatError> {
 
 /// Parse `document.json` content.
 pub fn from_json(text: &str) -> Result<(Document, Vec<String>), FormatError> {
-    parse_json(text).map(|(doc, issues, _)| (doc, issues))
+    parse_json(text).map(|(doc, issues, _, _)| (doc, issues))
 }
 
 /// The image hashes a `document.json` lists in its `imageIndex` (`None` without one).
 type ImageIndex = Option<BTreeSet<String>>;
+/// The font hashes a `document.json` lists in its `fontIndex` (`None` without one).
+type FontIndex = Option<BTreeSet<String>>;
+/// A parsed document, its validation issues, and the image and font indexes.
+type ParsedJson = (Document, Vec<String>, ImageIndex, FontIndex);
 
-/// The header of a `document.json` (its format version and image index), read in a pass that
-/// skips over everything else without building it.
+/// The header of a `document.json` (format version, image and font indexes), read in a pass
+/// that skips over everything else without building it.
 #[derive(Default)]
 struct Header {
     version: Option<u64>,
     images: ImageIndex,
+    fonts: FontIndex,
 }
 
 impl<'de> Deserialize<'de> for Header {
@@ -472,6 +575,12 @@ impl<'de> Deserialize<'de> for Header {
                 while let Some(key) = map.next_key::<String>()? {
                     match key.as_str() {
                         "version" => h.version = map.next_value::<Value>()?.as_u64(),
+                        "fontIndex" => {
+                            h.fonts = map
+                                .next_value::<Value>()?
+                                .as_array()
+                                .map(|values| values.iter().filter_map(Value::as_str).map(str::to_owned).collect());
+                        }
                         "imageIndex" => {
                             h.images = map
                                 .next_value::<Value>()?
@@ -490,10 +599,10 @@ impl<'de> Deserialize<'de> for Header {
     }
 }
 
-/// Parse `document.json` content; also returns its [`ImageIndex`]. The header is checked first
-/// (a newer file is refused before its document is read), then the document is deserialized
+/// Parse `document.json` content; also returns its image and font indexes. The header is checked
+/// first (a newer file is refused before its document is read), then the document is deserialized
 /// straight from the text.
-fn parse_json(text: &str) -> Result<(Document, Vec<String>, ImageIndex), FormatError> {
+fn parse_json(text: &str) -> Result<ParsedJson, FormatError> {
     let corrupt = |e: serde_json::Error| FormatError::Corrupt(e.to_string());
     let header: Header = serde_json::from_str(text).map_err(corrupt)?;
     if let Some(ver) = header.version
@@ -506,7 +615,7 @@ fn parse_json(text: &str) -> Result<(Document, Vec<String>, ImageIndex), FormatE
     if !issues.is_empty() {
         doc.repair();
     }
-    Ok((doc, issues, header.images))
+    Ok((doc, issues, header.images, header.fonts))
 }
 
 pub(crate) fn io_err(e: impl std::fmt::Display) -> FormatError {
@@ -583,6 +692,32 @@ pub fn save_bytes_with_history(
                 put(&mut z, &format!("images/{hash}"), stored, &img.bytes)?;
             }
         }
+        // A snapshot/branch can use a font no longer present in the current file.
+        // Store every distinct binary once, and retain ownership in each index.
+        let branch_fonts =
+            branches.iter().flat_map(|b| [&b.base.embedded_fonts, &b.head.embedded_fonts].into_iter().chain(b.versions.iter().map(Version::fonts)));
+        let mut written_fonts = BTreeSet::new();
+        let mut font_bytes = 0u64;
+        for (hash, bytes) in std::iter::once(&doc.embedded_fonts).chain(versions.iter().map(Version::fonts)).chain(branch_fonts).flatten() {
+            if written_fonts.contains(hash) {
+                continue;
+            }
+            let len = bytes.len() as u64;
+            if !valid_font_hash(hash)
+                || !is_sfnt_font(bytes)
+                || image_hash(bytes) != *hash
+                || len > MAX_FONT_MEMBER
+                || font_bytes.saturating_add(len) > MAX_FONT_TOTAL
+                || written_fonts.len() >= MAX_FONT_COUNT
+            {
+                // Keep the file saveable. The indexed hash is retained so loading the file
+                // reports that its binary is missing, including for versions and branches.
+                continue;
+            }
+            written_fonts.insert(hash);
+            font_bytes += len;
+            put(&mut z, &format!("fonts/{hash}"), stored, bytes)?;
+        }
         if let Some(t) = thumbnail_png {
             put(&mut z, "thumbnail.png", stored, t)?;
         }
@@ -598,8 +733,10 @@ pub fn save_bytes_with_history(
                 put_json(&mut z, &format!("branches/{}/head.json", b.id), &b.head)?;
                 put_versions(&mut z, &format!("branches/{}/versions", b.id), &b.versions)?;
                 let images = |d: &Document| d.images.keys().cloned().collect::<Vec<String>>();
+                let fonts = |d: &Document| d.embedded_fonts.keys().cloned().collect::<Vec<String>>();
                 index.push(json!({"id": b.id, "name": b.name, "time": b.time, "client": b.client, "state": b.state,
-                    "baseImages": images(&b.base), "headImages": images(&b.head)}));
+                    "baseImages": images(&b.base), "headImages": images(&b.head),
+                    "baseFonts": fonts(&b.base), "headFonts": fonts(&b.head)}));
             }
             put(&mut z, BRANCH_INDEX, json_options(), &serde_json::to_vec(&json!({"branches": index})).map_err(io_err)?)?;
         }
@@ -629,7 +766,8 @@ fn put_versions<W: Write + Seek>(z: &mut zip::ZipWriter<W>, dir: &str, versions:
             Snapshot::Stored { json, .. } => put(z, &name, json_options(), json)?,
         }
         let images: Vec<&String> = v.images().keys().collect();
-        let mut entry = json!({"id": v.id, "name": v.name, "description": v.description, "time": v.time, "images": images});
+        let fonts: Vec<&String> = v.fonts().keys().collect();
+        let mut entry = json!({"id": v.id, "name": v.name, "description": v.description, "time": v.time, "images": images, "fonts": fonts});
         if v.auto {
             entry["auto"] = json!(true);
         }
@@ -688,7 +826,7 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F
     let packed = z.by_name("document.json").map(|f| f.compressed_size()).unwrap_or(0);
     let limit = document_limit(packed, bytes.len() as u64);
     let text = String::from_utf8(read_member(&mut z, "document.json", limit)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
-    let (mut doc, mut issues, own_images) = parse_json(&text)?;
+    let (mut doc, mut issues, own_images, own_fonts) = parse_json(&text)?;
     let ImagesRead { images, unread, past_budget } = read_images(&mut z, own_images.as_ref(), budget);
     report_lost_images(&mut issues, &unread, &past_budget, own_images.as_ref(), &images);
     let images_past_budget = past_budget.len();
@@ -696,9 +834,11 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F
         Ok(lib) => doc.published = lib.map(Arc::new),
         Err(e) => issues.push(format!("the published library was skipped: {e}")),
     }
+    let all_fonts = read_fonts(&mut z, &mut issues, MAX_FONT_TOTAL);
+    let mut missing_fonts = MissingFontIssues::default();
     let mut budget = MAX_VERSION_BYTES;
-    let versions = read_versions(&mut z, "versions", &images, &mut issues, &mut budget);
-    let branches = read_branches(&mut z, &images, &mut issues);
+    let versions = read_versions(&mut z, "versions", &images, &all_fonts, &mut missing_fonts, &mut issues, &mut budget);
+    let branches = read_branches(&mut z, &images, &all_fonts, &mut missing_fonts, &mut issues);
     // Images only the version history or branches use stay with them.
     let branch_images =
         branches.iter().flat_map(|b| b.base.images.keys().chain(b.head.images.keys()).chain(b.versions.iter().flat_map(|v| v.images().keys())));
@@ -709,9 +849,66 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F
         .filter(|h| own_images.as_ref().is_some_and(|o| !o.contains(*h)))
         .collect();
     doc.images = images.into_iter().filter(|(h, _)| !history_only.contains(h)).collect();
+    doc.embedded_fonts = match own_fonts {
+        Some(owned) => select_fonts(&all_fonts, owned.iter().map(String::as_str), &mut missing_fonts),
+        None => all_fonts,
+    };
+    missing_fonts.report(&mut issues);
     Ok(Loaded { doc, issues, versions, branches, images_past_budget })
 }
 
+/// Load font binaries saved by FigCraft, without extracting anything to disk. Damaged
+/// fonts are reported instead of silently changing the document's typography.
+fn read_fonts<R: Read + Seek>(z: &mut zip::ZipArchive<R>, issues: &mut Vec<String>, mut left: u64) -> BTreeMap<String, Arc<Vec<u8>>> {
+    let mut fonts = BTreeMap::new();
+    let mut names: Vec<String> = z.file_names().filter(|n| n.starts_with("fonts/")).map(str::to_owned).collect();
+    names.sort();
+    if names.len() > MAX_FONT_COUNT {
+        issues.push(format!("{} fonts exceed the embedded-font limit; extras were skipped", names.len() - MAX_FONT_COUNT));
+    }
+    for name in names.into_iter().take(MAX_FONT_COUNT) {
+        let Some(hash) = name.strip_prefix("fonts/").filter(|s| valid_font_hash(s)) else {
+            issues.push("An embedded font has an invalid filename and was skipped".into());
+            continue;
+        };
+        let limit = left.min(MAX_FONT_MEMBER);
+        match read_member(z, &name, limit) {
+            Ok(bytes) => {
+                left -= bytes.len() as u64;
+                if is_sfnt_font(&bytes) && image_hash(&bytes) == hash {
+                    fonts.insert(hash.to_owned(), Arc::new(bytes));
+                } else {
+                    issues.push(format!("Embedded font {hash} failed validation; original font name stays in the text"));
+                }
+            }
+            Err(err) => {
+                // A failed read can have inflated its whole allowance before failing its CRC.
+                left = left.saturating_sub(limit);
+                issues.push(format!("Embedded font {hash} could not be read: {err}"));
+            }
+        }
+    }
+    fonts
+}
+
+
+/// Fonts listed by one snapshot, sharing their verified bytes with the ZIP reader.
+fn select_fonts<'a>(
+    all: &BTreeMap<String, Arc<Vec<u8>>>,
+    hashes: impl Iterator<Item = &'a str>,
+    missing: &mut MissingFontIssues,
+) -> BTreeMap<String, Arc<Vec<u8>>> {
+    hashes
+        .filter_map(|h| match all.get(h) {
+            Some(bytes) => Some((h.to_owned(), bytes.clone())),
+            None => {
+                missing.note(h);
+                None
+            }
+        })
+        .collect()
+}
+
 /// The archive's `images/` members by hash, within `budget`, and the ones that couldn't be read
 /// with why. The document's own images (`own`) are read first, so a file past the budget loses
 /// the images only its history uses before its own.
@@ -805,6 +1002,8 @@ fn read_versions<R: Read + Seek>(
     z: &mut zip::ZipArchive<R>,
     dir: &str,
     images: &BTreeMap<String, ImageData>,
+    fonts: &BTreeMap<String, Arc<Vec<u8>>>,
+    missing_fonts: &mut MissingFontIssues,
     issues: &mut Vec<String>,
     budget: &mut u64,
 ) -> Vec<Version> {
@@ -850,13 +1049,14 @@ fn read_versions<R: Read + Seek>(
             .filter_map(Value::as_str)
             .filter_map(|h| images.get(h).map(|i| (h.to_string(), i.clone())))
             .collect();
+        let owned_fonts = e.get("fonts").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str);
         out.push(Version {
             id: id.to_string(),
             name: text("name", MAX_VERSION_NAME),
             description: text("description", MAX_VERSION_DESCRIPTION),
             time: e.get("time").and_then(Value::as_u64).unwrap_or(0),
             auto: e.get("auto").and_then(Value::as_bool).unwrap_or(false),
-            snapshot: Snapshot::Stored { json: Arc::new(json), images },
+            snapshot: Snapshot::Stored { json: Arc::new(json), images, fonts: select_fonts(fonts, owned_fonts, missing_fonts) },
         });
     }
     out
@@ -864,7 +1064,13 @@ fn read_versions<R: Read + Seek>(
 
 /// The branches of an archive (none when it has no index), parsed. Damaged entries are skipped and
 /// reported in `issues`; nothing here fails the load.
-fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<String, ImageData>, issues: &mut Vec<String>) -> Vec<Branch> {
+fn read_branches<R: Read + Seek>(
+    z: &mut zip::ZipArchive<R>,
+    images: &BTreeMap<String, ImageData>,
+    fonts: &BTreeMap<String, Arc<Vec<u8>>>,
+    missing_fonts: &mut MissingFontIssues,
+    issues: &mut Vec<String>,
+) -> Vec<Branch> {
     if z.index_for_name(BRANCH_INDEX).is_none() {
         return Vec::new();
     }
@@ -896,6 +1102,9 @@ fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
             let (mut doc, _) = from_json(std::str::from_utf8(&json).map_err(|e| FormatError::Corrupt(e.to_string()))?)?;
             let hashes = e.get(list).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str);
             doc.images = hashes.filter_map(|h| images.get(h).map(|i| (h.to_string(), i.clone()))).collect();
+            let font_key = if part == "base" { "baseFonts" } else { "headFonts" };
+            let owned = e.get(font_key).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str);
+            doc.embedded_fonts = select_fonts(fonts, owned, missing_fonts);
             Ok(Arc::new(doc))
         };
         let (base, head) = match (snapshot("base", "baseImages"), snapshot("head", "headImages")) {
@@ -911,7 +1120,7 @@ fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
             time: e.get("time").and_then(Value::as_u64).unwrap_or(0),
             client: e.get("client").and_then(Value::as_u64).and_then(|c| u32::try_from(c).ok()).unwrap_or(0),
             state: e.get("state").and_then(|s| BranchState::deserialize(s).ok()).unwrap_or(BranchState::Active),
-            versions: read_versions(z, &format!("branches/{id}/versions"), images, issues, &mut budget),
+            versions: read_versions(z, &format!("branches/{id}/versions"), images, fonts, missing_fonts, issues, &mut budget),
             base,
             head,
         });
@@ -1058,3 +1267,208 @@ mod tests {
         assert!(add_image(&mut Document::new("x"), b"zzz".to_vec()).is_err());
     }
 }
+
+#[cfg(test)]
+mod portable_font_tests {
+    use super::*;
+    use std::io::Write;
+
+    #[test]
+    fn portable_font_survives_save_and_reopen() {
+        // Minimal SFNT signature is sufficient for archive persistence; rendering
+        // validates font tables in figcraft-text, which can reject this stub.
+        let font = vec![0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0];
+        let hash = format!("{:x}", sha2::Sha256::digest(&font));
+        let mut doc = Document::new("Embedded-font persistence");
+        doc.embedded_fonts.insert(hash.clone(), Arc::new(font.clone()));
+        doc.embedded_font_licenses.insert(hash.clone(), "SIL OPEN FONT LICENSE Version 1.1".into());
+        let bytes = save_bytes(&doc, None).unwrap();
+        let (loaded, issues) = load_bytes(&bytes).unwrap();
+        assert!(issues.is_empty(), "{issues:?}");
+        assert_eq!(loaded.embedded_fonts.get(&hash).map(|a| a.as_slice()), Some(font.as_slice()));
+        assert_eq!(loaded.embedded_font_licenses.get(&hash).map(String::as_str), Some("SIL OPEN FONT LICENSE Version 1.1"));
+    }
+
+    // Regression: versions and branch heads may own faces that the active file no longer uses.
+    // They must survive multiple save/reopen cycles without leaking into the active design.
+    #[test]
+    fn historical_fonts_survive_save_and_reopen() {
+        fn font(seed: u8) -> (String, Arc<Vec<u8>>) {
+            let bytes = vec![0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, seed];
+            (format!("{:x}", sha2::Sha256::digest(&bytes)), Arc::new(bytes))
+        }
+        let current = Document::new("Current has no fonts");
+        let mut historic = Document::new("Old typeface");
+        let (version_hash, version_font) = font(1);
+        historic.embedded_fonts.insert(version_hash.clone(), version_font.clone());
+        historic.embedded_font_licenses.insert(version_hash.clone(), "OFL notice".into());
+        let version = Version::new("font-v1", "Typeface", "", 1, Arc::new(historic));
+
+        let mut head = current.clone();
+        let (branch_hash, branch_font) = font(2);
+        head.embedded_fonts.insert(branch_hash.clone(), branch_font.clone());
+        let branch = Branch {
+            id: "font-branch".into(),
+            name: "Branch".into(),
+            time: 2,
+            client: 3,
+            state: BranchState::Active,
+            base: Arc::new(current.clone()),
+            head: Arc::new(head),
+            versions: vec![version.clone()],
+        };
+        let bytes = save_bytes_with_history(&current, None, &[version], &[branch]).unwrap();
+        let zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
+        let binary_members = zip.file_names().filter(|n| n.starts_with("fonts/")).count();
+        assert_eq!(binary_members, 2, "shared fonts are written once");
+        for archive in [bytes.clone(), {
+            let loaded = load_bytes_with_versions(&bytes).unwrap();
+            save_bytes_with_history(&loaded.doc, None, &loaded.versions, &loaded.branches).unwrap()
+        }] {
+            let loaded = load_bytes_with_versions(&archive).unwrap();
+            assert!(loaded.issues.is_empty(), "{:?}", loaded.issues);
+            assert!(loaded.doc.embedded_fonts.is_empty(), "history fonts must not leak into the active design");
+            let version_doc = loaded.versions[0].document().unwrap();
+            assert_eq!(version_doc.embedded_fonts.get(&version_hash), Some(&version_font));
+            assert_eq!(version_doc.embedded_font_licenses[&version_hash], "OFL notice");
+            assert!(loaded.branches[0].base.embedded_fonts.is_empty());
+            assert_eq!(loaded.branches[0].head.embedded_fonts.get(&branch_hash), Some(&branch_font));
+            assert_eq!(loaded.branches[0].versions[0].document().unwrap().embedded_fonts.get(&version_hash), Some(&version_font));
+        }
+    }
+
+    #[test]
+    fn skipped_fonts_are_reported_for_active_version_and_branch_owners() {
+        let broken = |seed: u8| {
+            let bytes = vec![seed; 12]; // Not an SFNT; save deliberately omits it.
+            let hash = format!("{:x}", sha2::Sha256::digest(&bytes));
+            (hash, Arc::new(bytes))
+        };
+        let mut doc = Document::new("Active invalid font");
+        let (active_hash, active_bytes) = broken(1);
+        doc.embedded_fonts.insert(active_hash.clone(), active_bytes);
+        let mut previous = Document::new("Version invalid font");
+        let (version_hash, version_bytes) = broken(2);
+        previous.embedded_fonts.insert(version_hash.clone(), version_bytes);
+        let version = Version::new("v1", "Earlier", "", 1, Arc::new(previous));
+        let mut head = Document::new("Branch invalid font");
+        let (branch_hash, branch_bytes) = broken(3);
+        head.embedded_fonts.insert(branch_hash.clone(), branch_bytes);
+        let mut branch_previous = Document::new("Branch version invalid font");
+        let (branch_version_hash, branch_version_bytes) = broken(4);
+        branch_previous.embedded_fonts.insert(branch_version_hash.clone(), branch_version_bytes);
+        let branch = Branch {
+            id: "branch-1".into(),
+            name: "Bad font branch".into(),
+            time: 2,
+            client: 3,
+            state: BranchState::Active,
+            base: Arc::new(Document::new("Empty")),
+            head: Arc::new(head),
+            versions: vec![Version::new("branch-version", "Old branch", "", 1, Arc::new(branch_previous))],
+        };
+        let bytes = save_bytes_with_history(&doc, None, &[version], &[branch]).unwrap();
+        let loaded = load_bytes_with_versions(&bytes).unwrap();
+        assert!(loaded.doc.embedded_fonts.is_empty());
+        assert_eq!(loaded.versions.len(), 1);
+        assert_eq!(loaded.branches.len(), 1);
+        for hash in [active_hash, version_hash, branch_hash, branch_version_hash] {
+            assert!(loaded.issues.iter().any(|issue| issue.contains(&hash) && issue.contains("missing")), "no issue for {hash}: {:?}", loaded.issues);
+        }
+    }
+
+    #[test]
+    fn missing_font_diagnostics_have_a_fixed_size_budget() {
+        let mut doc = Document::new("Many invalid font binaries");
+        for seed in 0..(MAX_FONT_ISSUES + 50) {
+            let bytes = vec![seed as u8; 12];
+            let hash = format!("{:x}", sha2::Sha256::digest(&bytes));
+            doc.embedded_fonts.insert(hash, Arc::new(bytes));
+        }
+        let archive = save_bytes(&doc, None).unwrap();
+        let (_, issues) = load_bytes(&archive).unwrap();
+        assert_eq!(issues.len(), MAX_FONT_ISSUES + 1, "{issues:?}");
+        assert!(issues.last().is_some_and(|message| message.contains("additional references")));
+    }
+
+    #[test]
+    fn merged_portable_fonts_obey_limits_and_keep_licenses() {
+        let mut source = Document::new("Downloaded faces");
+        for seed in 0..130u16 {
+            let mut font = vec![0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0];
+            font.extend(seed.to_le_bytes());
+            let hash = image_hash(&font);
+            source.embedded_fonts.insert(hash.clone(), Arc::new(font));
+            source.embedded_font_licenses.insert(hash, "OFL notice".into());
+        }
+        let mut target = Document::new("Existing project");
+        let (added, skipped) = merge_embedded_fonts(&mut target, &source);
+        assert_eq!((added, skipped), (256, 2));
+        assert_eq!(target.embedded_fonts.len(), 128);
+        assert_eq!(target.embedded_font_licenses.len(), 128);
+        // Merging twice never duplicates fonts or license records.
+        let (added, skipped) = merge_embedded_fonts(&mut target, &source);
+        assert_eq!((added, skipped), (0, 2));
+        let bytes = save_bytes(&target, None).unwrap();
+        let (loaded, issues) = load_bytes(&bytes).unwrap();
+        assert!(issues.is_empty(), "{issues:?}");
+        assert_eq!(loaded.embedded_fonts.len(), 128);
+    }
+
+    #[test]
+    fn invalid_fonts_do_not_prevent_saving_other_assets() {
+        let mut doc = Document::new("Invalid font bytes");
+        doc.embedded_fonts.insert("not-a-sha256".into(), Arc::new(vec![0, 1, 2, 3]));
+        let bytes = save_bytes(&doc, None).unwrap();
+        let archive = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap();
+        assert_eq!(archive.file_names().filter(|n| n.starts_with("fonts/")).count(), 0);
+        let (opened, issues) = load_bytes(&bytes).unwrap();
+        assert!(opened.embedded_fonts.is_empty());
+        assert!(issues.iter().any(|issue| issue.contains("not-a-sha256") && issue.contains("missing")), "{issues:?}");
+    }
+
+    #[test]
+    fn tampered_embedded_font_is_reported_not_used() {
+        let font = vec![0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0];
+        let hash = format!("{:x}", sha2::Sha256::digest(&font));
+        let mut doc = Document::new("Font integrity");
+        doc.embedded_fonts.insert(hash.clone(), Arc::new(font));
+        let archive = save_bytes(&doc, None).unwrap();
+        let mut source = zip::ZipArchive::new(std::io::Cursor::new(&archive)).unwrap();
+        let mut output = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
+        for i in 0..source.len() {
+            let mut entry = source.by_index(i).unwrap();
+            let name = entry.name().to_owned();
+            output.start_file(name.as_str(), zip::write::SimpleFileOptions::default()).unwrap();
+            if name.starts_with("fonts/") {
+                output.write_all(b"NOT A VALID FONT").unwrap();
+            } else {
+                std::io::copy(&mut entry, &mut output).unwrap();
+            }
+        }
+        let tampered = output.finish().unwrap().into_inner();
+        let (loaded, issues) = load_bytes(&tampered).unwrap();
+        assert!(loaded.embedded_fonts.is_empty());
+        assert!(issues.iter().any(|e| e.contains("Embedded font")), "{issues:?}");
+    }
+}
+
+#[cfg(test)]
+mod font_read_budget_tests {
+    use super::*;
+
+    #[test]
+    fn rejected_font_bytes_still_consume_the_shared_budget() {
+        let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
+        for i in 0..4 {
+            writer.start_file(format!("fonts/{i:064x}"), zip::write::SimpleFileOptions::default()).unwrap();
+            writer.write_all(b"invalid").unwrap();
+        }
+        let bytes = writer.finish().unwrap().into_inner();
+        let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
+        let mut issues = Vec::new();
+        assert!(read_fonts(&mut archive, &mut issues, 14).is_empty());
+        assert_eq!(issues.iter().filter(|s| s.contains("failed validation")).count(), 2, "{issues:?}");
+        assert_eq!(issues.iter().filter(|s| s.contains("could not be read")).count(), 2, "{issues:?}");
+    }
+}
diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs
--- a/crates/pdf/src/lib.rs
+++ b/crates/pdf/src/lib.rs
@@ -95,6 +95,7 @@ pub fn export_nodes_pdf(doc: &Document, ids: &[NodeId]) -> Result<Vec<u8>, PdfEr
 /// One page per group of ids. For print, a page of one frame is trimmed to the frame's box (its
 /// shadows and outside strokes go into the bleed).
 pub fn export_groups(doc: &Document, groups: &[Vec<NodeId>], opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
+    let _fonts = figcraft_text::enter_document_fonts(doc);
     // All pages are checked before bounds, raster fallbacks or PDF writing start.
     for group in groups {
         figcraft_render::check_export_depth(doc, group)?;
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -188,6 +188,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
 
 /// [`render_view_single`] as part of a render already started (one of its bands).
 fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
+    let _fonts = figcraft_text::enter_document_fonts(doc);
     let mut pix = new_pixmap(view.width, view.height)?;
     let bg = opts.background.or_else(|| match doc.node(page).map(|p| &p.kind) {
         Some(NodeKind::Page { background, .. }) => Some(*background),
@@ -235,6 +236,7 @@ pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut
 /// [`render_nodes_into`] `time` seconds into animated GIFs.
 fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) {
     let bounds = draw::Bounds::default();
+    let _fonts = figcraft_text::enter_document_fonts(doc);
     let ctx = draw::Ctx {
         doc,
         scale: view.scale,
diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs
--- a/crates/svg/src/lib.rs
+++ b/crates/svg/src/lib.rs
@@ -1028,6 +1028,7 @@ pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<S
 /// Like [`export_svg`], with an actionable error when the visible subtree is too deep. No partial
 /// SVG is returned; the depth limit is relative to each exported root, not the document's page.
 pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Result<String, SvgError> {
+    let _fonts = figcraft_text::enter_document_fonts(doc);
     check_export_depth(doc, ids)?;
     let bounds: Rect = match opts.bounds {
         Some(b) if [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) => b,
diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs
--- a/crates/text/src/fontdb.rs
+++ b/crates/text/src/fontdb.rs
@@ -1,4 +1,5 @@
-//! The font database: embedded craft-fonts faces plus (on desktop) the system's fonts.
+//! The global database contains craft-fonts and system faces. Portable document faces are
+//! selected only within a scoped operation, never registered globally.
 
 use std::collections::{BTreeMap, HashMap};
 use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
@@ -1090,6 +1091,9 @@ pub fn postscript_face(ps: &str) -> Option<(String, String)> {
 }
 
 pub fn find_face(family: &str, style: &str) -> Option<Arc<Face>> {
+    if let Some(face) = scoped_document_face(family, style) {
+        return Some(face);
+    }
     let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
     db.find(family, style).or_else(|| db.default_face())
 }
@@ -1216,6 +1220,150 @@ pub fn face_with_char(c: char, families: &[&str]) -> Option<Arc<Face>> {
     families.iter().filter_map(|f| db.find_one(f, "Regular")).find(|f| f.has_char(c))
 }
 
+/// Fonts carried by a design are NOT registered in the process-wide system database.
+/// Their selection is scoped to one rendering or editing operation, and restored on drop.
+/// Concurrent page-rendering threads and other open tabs have independent scopes.
+struct DocumentFontScope {
+    signature: u64,
+    faces: BTreeMap<String, Vec<Arc<Face>>>,
+}
+
+std::thread_local! {
+    static DOCUMENT_FONTS: std::cell::RefCell<Option<DocumentFontScope>> = const { std::cell::RefCell::new(None) };
+}
+
+/// Guard restoring the previous scope even if an operation panics.
+pub struct DocumentFontGuard(Option<DocumentFontScope>);
+
+impl Drop for DocumentFontGuard {
+    fn drop(&mut self) {
+        DOCUMENT_FONTS.with(|cell| {
+            *cell.borrow_mut() = self.0.take();
+        });
+    }
+}
+
+/// The complete font selection signature used by the layout cache.
+pub(crate) fn document_font_signature() -> u64 {
+    DOCUMENT_FONTS.with(|cell| cell.borrow().as_ref().map_or(0, |s| s.signature))
+}
+
+/// Find a face carried by the current document, never a face from another tab.
+pub(crate) fn scoped_document_face(family: &str, style: &str) -> Option<Arc<Face>> {
+    DOCUMENT_FONTS.with(|cell| {
+        let scope = cell.borrow();
+        let faces = scope.as_ref()?.faces.get(&family.to_lowercase())?;
+        let want = norm_style(style);
+        if let Some(face) = faces.iter().find(|face| norm_style(&face.style) == want || face.instance(style).is_some()) {
+            return Some(face.clone());
+        }
+        let weight = i32::from(figcraft_doc::style_weight(style));
+        let italic = style.to_lowercase().contains("italic");
+        faces
+            .iter()
+            .min_by_key(|face| {
+                let w = face.axis("wght").map(|a| a.clamp(f64::from(weight)).round() as i32).unwrap_or(i32::from(face.weight));
+                (w - weight).abs() + if face.italic == italic { 0 } else { 1000 }
+            })
+            .cloned()
+    })
+}
+
+/// Document-specific family names, for the font inspector and import warnings.
+pub fn document_font_families(doc: &figcraft_doc::Document) -> Vec<String> {
+    let _scope = enter_document_fonts(doc);
+    DOCUMENT_FONTS.with(|cell| {
+        cell.borrow().as_ref().map(|s| s.faces.values().flat_map(|fs| fs.first().map(|f| f.family.clone())).collect()).unwrap_or_default()
+    })
+}
+
+/// Select the portable faces for `doc` for the lifetime of the returned guard.
+/// Parsing is cached by binary hash, but the choice of face is never global.
+pub fn enter_document_fonts(doc: &figcraft_doc::Document) -> DocumentFontGuard {
+    use std::hash::{Hash, Hasher};
+    // Most recently used timestamp is a monotonic counter; evict a single least-used face.
+    type ParsedCache = (u64, BTreeMap<String, (Vec<Arc<Face>>, u64)>);
+    static PARSED: OnceLock<Mutex<ParsedCache>> = OnceLock::new();
+    let parsed = PARSED.get_or_init(|| Mutex::new((0, BTreeMap::new())));
+    let mut hasher = std::collections::hash_map::DefaultHasher::new();
+    let mut faces: BTreeMap<String, Vec<Arc<Face>>> = BTreeMap::new();
+    for (hash, bytes) in doc.embedded_fonts.iter().take(128) {
+        hash.hash(&mut hasher);
+        let own = {
+            let mut cached = parsed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+            cached.0 = cached.0.wrapping_add(1);
+            let stamp = cached.0;
+            if let Some((faces, last_used)) = cached.1.get_mut(hash) {
+                *last_used = stamp;
+                faces.clone()
+            } else {
+                let count = match FileRef::new(bytes) {
+                    Ok(FileRef::Collection(collection)) if collection.len() <= 64 => collection.len(),
+                    Ok(FileRef::Font(_)) => 1,
+                    _ => 0,
+                };
+                let loaded: Vec<Arc<Face>> =
+                    (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(bytes.clone()), i, None, None).map(Arc::new)).collect();
+                // The current scope retains its own Arc<Face>s when a cached entry is evicted.
+                if cached.1.len() >= 128
+                    && let Some(oldest) = cached.1.iter().min_by_key(|(_, (_, used))| *used).map(|(h, _)| h.clone())
+                {
+                    cached.1.remove(&oldest);
+                }
+                cached.1.insert(hash.clone(), (loaded.clone(), stamp));
+                loaded
+            }
+        };
+        for face in own {
+            faces.entry(face.family.to_lowercase()).or_default().push(face);
+        }
+    }
+    let signature = if doc.embedded_fonts.is_empty() { 0 } else { hasher.finish() };
+    let previous = DOCUMENT_FONTS.with(|cell| cell.replace(Some(DocumentFontScope { signature, faces })));
+    DocumentFontGuard(previous)
+}
+
+#[cfg(test)]
+mod isolated_document_tests {
+    use super::*;
+    use figcraft_doc::Document;
+
+    #[test]
+    fn two_documents_can_use_different_bytes_for_the_same_named_face() {
+        scan_system_fonts();
+        let original = db().write().unwrap().default_face();
+        let Some(original) = original else {
+            return;
+        }; // Minimal CI can have no font installed.
+        let raw = original.bytes.data().to_vec();
+        let mut changed = raw.clone();
+        // A trailing non-table byte changes the binary identity without changing its
+        // OpenType family/style name or glyph metrics.
+        changed.push(0x67);
+        let mut doc_a = Document::new("A");
+        let mut doc_b = Document::new("B");
+        doc_a.embedded_fonts.insert("a".repeat(64), Arc::new(raw));
+        doc_b.embedded_fonts.insert("b".repeat(64), Arc::new(changed));
+        let family = original.family.clone();
+        let style = original.style.clone();
+        {
+            let _a = enter_document_fonts(&doc_a);
+            let a = scoped_document_face(&family, &style).unwrap();
+            assert_eq!(a.bytes.data(), original.bytes.data());
+            {
+                let _b = enter_document_fonts(&doc_b);
+                let b = scoped_document_face(&family, &style).unwrap();
+                assert!(b.bytes.data().ends_with(&[0x67]));
+                assert_ne!(a.id, b.id);
+            }
+            assert_eq!(scoped_document_face(&family, &style).unwrap().id, a.id);
+        }
+        assert!(scoped_document_face(&family, &style).is_none());
+        // Loading neither font changed the process-wide fallback selection.
+        assert_eq!(db().write().unwrap().find(&family, &style).unwrap().id, original.id);
+    }
+}
+
 #[cfg(test)]
 #[path = "fontdb_tests.rs"]
 mod tests;
diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -344,7 +344,11 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
     let mut faces: Vec<Option<Arc<Face>>> = Vec::with_capacity(styles.len());
     let mut style_fallbacks: Vec<Vec<Arc<Face>>> = Vec::with_capacity(styles.len());
     for s in &styles {
-        faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face()));
+        faces.push(
+            crate::fontdb::scoped_document_face(&s.font_family, &s.font_style)
+                .or_else(|| dbw.find(&s.font_family, &s.font_style))
+                .or_else(|| dbw.default_face()),
+        );
         style_fallbacks.push(dbw.fallbacks_for(&s.font_style));
     }
     // Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only
diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs
--- a/crates/text/src/lib.rs
+++ b/crates/text/src/lib.rs
@@ -16,9 +16,9 @@ mod onpath;
 pub use breaks::{backspace_step, grapheme_step};
 pub use color::{ColorBitmap, ColorInk, ColorLayer};
 pub use fontdb::{
-    Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face,
-    has_family, installed_fonts_known, load_font_bytes, no_coords, opt_out_of_system_fonts, rescan_system_fonts, scan_system_fonts,
-    scan_system_fonts_on_demand, system_fonts_scanned,
+    Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, document_font_families, enter_document_fonts, face_with_char,
+    family_names, family_styles, find_face, has_family, installed_fonts_known, load_font_bytes, no_coords, opt_out_of_system_fonts,
+    rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned,
 };
 pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
 pub use onpath::OnPath;
@@ -31,6 +31,7 @@ struct Cached {
     w: Option<u64>,
     h: Option<u64>,
     layout: std::sync::Arc<TextLayout>,
+    font_scope: u64,
 }
 
 /// Cached layouts by a cheap fingerprint; entries are confirmed with `TextData`'s full equality.
@@ -81,8 +82,9 @@ fn fingerprint(t: &figcraft_doc::TextData, w: Option<u64>, h: Option<u64>) -> u6
 /// Layout with a process-wide cache keyed by the text content/style and box size.
 pub fn layout_cached(t: &figcraft_doc::TextData, w: Option<f64>, h: Option<f64>) -> std::sync::Arc<TextLayout> {
     let (wb, hb) = (w.map(f64::to_bits), h.map(f64::to_bits));
-    let key = fingerprint(t, wb, hb);
-    let hit = |c: &Cached| c.w == wb && c.h == hb && c.text == *t;
+    let font_scope = fontdb::document_font_signature();
+    let key = fingerprint(t, wb, hb) ^ font_scope.rotate_left(17);
+    let hit = |c: &Cached| c.w == wb && c.h == hb && c.font_scope == font_scope && c.text == *t;
     if let Some(l) =
         layout_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).buckets.get(&key).and_then(|b| b.iter().find(|c| hit(c)))
     {
@@ -104,7 +106,7 @@ pub fn layout_cached(t: &figcraft_doc::TextData, w: Option<f64>, h: Option<f64>)
     } else {
         0
     };
-    bucket.push(Cached { text: t.clone(), w: wb, h: hb, layout: l.clone() });
+    bucket.push(Cached { text: t.clone(), w: wb, h: hb, layout: l.clone(), font_scope });
     c.len = c.len + 1 - dropped;
     l
 }
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -219,6 +219,8 @@ impl FigApp {
         if self.ui.icon_gallery {
             icons::gallery(&ctx);
         }
+        // Font previews and measurement controls see the same portable faces as the canvas.
+        let _fonts = self.session.doc().ok().map(figcraft_text::enter_document_fonts);
         theme::set_doc_profile(&ctx, self.session.doc().map_or(figcraft_color::ColorProfile::Srgb, |d| d.color_profile));
         widgets::set_nudge(&ctx, self.session.prefs.nudge_small, self.session.prefs.nudge_big);
         self.sync_dev_mode();
diff --git a/crates/ui-egui/src/canvas/comments.rs b/crates/ui-egui/src/canvas/comments.rs
--- a/crates/ui-egui/src/canvas/comments.rs
+++ b/crates/ui-egui/src/canvas/comments.rs
@@ -939,10 +939,8 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun
             }
         }
         // It closes: an open thread reads itself again.
-        Some(Act::MarkUnread) => {
-            if app.run("comment.markRead", json!({"id": id, "read": false})).is_some() {
-                app.ui.comments.close();
-            }
+        Some(Act::MarkUnread) if app.run("comment.markRead", json!({"id": id, "read": false})).is_some() => {
+            app.ui.comments.close();
         }
         Some(Act::Run(cmd, p)) => {
             let thread_deleted = cmd == "comment.delete" && p.get("reply").is_none();
@@ -958,7 +956,7 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun
                 }
             }
         }
-        None => {}
+        Some(Act::MarkUnread) | None => {}
     }
 }
 
diff --git a/crates/ui-egui/src/dialogs/branch_ui.rs b/crates/ui-egui/src/dialogs/branch_ui.rs
--- a/crates/ui-egui/src/dialogs/branch_ui.rs
+++ b/crates/ui-egui/src/dialogs/branch_ui.rs
@@ -194,20 +194,16 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
         Some(Action::Pick(key, side)) => {
             view.picks.insert(key, side);
         }
-        Some(Action::Update) => {
-            if app.run("branch.updateFromMain", json!({"id": view.id, "resolutions": resolutions()})).is_some() {
-                app.toast(tr("Branch updated from main"));
-                view.picks.clear();
-            }
+        Some(Action::Update) if app.run("branch.updateFromMain", json!({"id": view.id, "resolutions": resolutions()})).is_some() => {
+            app.toast(tr("Branch updated from main"));
+            view.picks.clear();
         }
-        Some(Action::Merge) => {
-            if app.run("branch.merge", json!({"id": view.id, "resolutions": resolutions()})).is_some() {
-                app.toast(tr("Branch merged"));
-                open = false;
-            }
+        Some(Action::Merge) if app.run("branch.merge", json!({"id": view.id, "resolutions": resolutions()})).is_some() => {
+            app.toast(tr("Branch merged"));
+            open = false;
         }
         Some(Action::Close) => open = false,
-        None => {}
+        Some(Action::Update | Action::Merge) | None => {}
     }
     if open {
         app.ui.branch_review = Some(view);
diff --git a/crates/ui-egui/src/home/home_recents.rs b/crates/ui-egui/src/home/home_recents.rs
--- a/crates/ui-egui/src/home/home_recents.rs
+++ b/crates/ui-egui/src/home/home_recents.rs
@@ -611,10 +611,8 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize
     };
     action = action.or(chosen);
     match action {
-        Some(Action::Open(path)) => {
-            if app.run_user("file.open", json!({"path": path})).is_some() {
-                app.ui.home = false;
-            }
+        Some(Action::Open(ref path)) if app.run_user("file.open", json!({"path": path})).is_some() => {
+            app.ui.home = false;
         }
         Some(Action::Reveal(path)) => app.services.reveal_path(Path::new(&path)),
         Some(Action::Remove(path)) => {
@@ -629,7 +627,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize
             view.store(&mut app.session.prefs);
             app.services.save_prefs(&app.session.prefs);
         }
-        None => {}
+        Some(Action::Open(_)) | None => {}
     }
     files.len()
 }
diff --git a/crates/ui-egui/src/panels/dev_compare.rs b/crates/ui-egui/src/panels/dev_compare.rs
--- a/crates/ui-egui/src/panels/dev_compare.rs
+++ b/crates/ui-egui/src/panels/dev_compare.rs
@@ -214,15 +214,13 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
         Some(Action::Select(id)) => {
             app.run("select.set", json!({"ids": [id]}));
         }
-        Some(Action::Resolve) => {
-            if app.run("dev.resolveChange", json!({"ids": [view.id]})).is_some() {
-                app.toast(tr("Change resolved"));
-                open = false;
-            }
+        Some(Action::Resolve) if app.run("dev.resolveChange", json!({"ids": [view.id]})).is_some() => {
+            app.toast(tr("Change resolved"));
+            open = false;
         }
         Some(Action::Close) => open = false,
         Some(Action::Against(v)) => view.version = v,
-        None => {}
+        Some(Action::Resolve) | None => {}
     }
     if open {
         app.ui.dev.compare = Some(view);
diff --git a/crates/ui-egui/src/panels/versions.rs b/crates/ui-egui/src/panels/versions.rs
--- a/crates/ui-egui/src/panels/versions.rs
+++ b/crates/ui-egui/src/panels/versions.rs
@@ -541,15 +541,13 @@ pub fn preview_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
         action
     });
     match action {
-        Some(true) => {
-            if app.run("version.restore", json!({"id": id})).is_some() {
-                app.toast(trf("Restored “{name}”", &[("name", &name)]));
-            }
+        Some(true) if app.run("version.restore", json!({"id": id})).is_some() => {
+            app.toast(trf("Restored “{name}”", &[("name", &name)]));
         }
         Some(false) => {
             app.run("version.exitPreview", json!({}));
         }
-        None => {}
+        Some(true) | None => {}
     }
 }
 
diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs
--- a/crates/ui-egui/src/widgets/style_picker.rs
+++ b/crates/ui-egui/src/widgets/style_picker.rs
@@ -1117,13 +1117,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
             app.run("style.delete", json!({"style": id}));
             p.edit = None;
         }
-        Some(Act::ToggleFolder(f)) => {
-            if !app.ui.style_folds.remove(&f) {
-                app.ui.style_folds.insert(f);
-            }
+        Some(Act::ToggleFolder(f)) if !app.ui.style_folds.remove(&f) => {
+            app.ui.style_folds.insert(f);
         }
         Some(Act::ToggleView) => app.ui.style_grid = !app.ui.style_grid,
-        None => {}
+        Some(Act::ToggleFolder(_)) | None => {}
     }
     if close {
         if let Some(e) = &p.edit {
diff --git a/crates/vectordrawable/src/lib.rs b/crates/vectordrawable/src/lib.rs
--- a/crates/vectordrawable/src/lib.rs
+++ b/crates/vectordrawable/src/lib.rs
@@ -81,6 +81,7 @@ pub enum VdError {
 /// The layers `ids` (bottom to top) as an Android Vector Drawable. Colours are written as they
 /// are: convert a Display P3 document to sRGB first (`Document::in_srgb`).
 pub fn try_export(doc: &Document, ids: &[NodeId], opts: &VdOptions) -> Result<VdExport, VdError> {
+    let _fonts = figcraft_text::enter_document_fonts(doc);
     check_export_depth(doc, ids)?;
     let area = match opts.bounds {
         Some(b) if [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) => b,
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
 # File format parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (M6: bounded document-owned fonts extracted from #1334; native snapshots, scoped measurement, rendering and SVG/PDF export; owner font-policy approval required; Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
 
 Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
 `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -170,6 +170,8 @@ each.
 
 ## Fonts
 
+Native documents may carry bounded font binaries with SHA-256 identities and license metadata. Saves preserve document, version and branch ownership; unreadable or missing binaries produce warnings. Rendering, measurement and SVG/PDF export select fonts within the current document. No font is downloaded or registered globally. This model change is pending the owner's font-policy decision.
+
 | | Figma | FigCraft |
 |---|---|---|
 | Local fonts | yes (font helper / desktop app) | yes (system scan) |
@@ -181,6 +183,7 @@ each.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | M6: bounded document-owned fonts extracted from #1334; native snapshots, scoped measurement, rendering and SVG/PDF export; owner font-policy approval required. Numbers unchanged. |
 | 2026-10-26 | minor | Changes made to the open file by another app are no longer lost on save (#1330): the app now remembers the file's size and modification time when it opens, reverts or saves it, and Save and auto-save refuse while the file on disk differs, with a message pointing to Revert to saved or Save as (the tab stays unsaved; closing it stays open). Scripts pass `file.save {overwrite: true}` to replace the file anyway; Save as and a save to an explicit `path` write as before. Not detected: a change of the same size inside one modification-time tick of a coarse file system. No prompt with choices yet, and a change isn't noticed until a save (`save_disk_changes_tests`). Numbers unchanged |
 | 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged |
 | 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged |
