diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1833,6 +1833,8 @@ dependencies = [
  "log",
  "objc2 0.6.5",
  "png",
+ "serde_json",
+ "sha2",
  "skrifa 0.47.0",
  "unicode-bidi",
  "unicode-segmentation",
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 (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
@@ -348,6 +348,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | Portable text branch integrated with the upstream engine/module refactor and kerning layout changes. Capture, edit guards, gesture commit and host snapshot handling follow the new source locations; no behavior or format change intended. |
+| 2026-10-26 | minor | Portable text review: omit unchanged appearances from base metadata updates without new wire properties, share immutable geometry across edits/history, cache exact-face availability with scan invalidation, and update missing-font translations. Harden restored PDF links and line ranges; retain atomic run metadata; defer gesture appearance capture to commit, with synthetic regressions and a resize measurement. Server-side missing-font display retained; no font binaries. Readiness estimates 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 |
@@ -413,6 +415,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
 | 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
 | 2026-10-22 | minor | Type settings keeps its width (#1029): in languages whose labels run past the 110 px label column (French "Retrait de paragraphe", "Espacement de liste"), the List spacing, Paragraph indent, Max lines and decoration rows were wider than the popover, which then widened every frame while open. Those fields now take the width left after their label; English is unchanged. Regression test in `type_settings_tests`. Numbers unchanged. |
 | 2026-10-22 | minor | `fill`/`stroke` props accept `"none"` (either case) and `null` as no paint (M1, #1036): mapped to an empty fills/strokes list, as `fills: []` already did (undocumented), so an outline-only shape is one call; `KEYS` and `docs/mcp.md` document it, the error names the accepted forms, `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged |
+| 2026-10-23 | minor | Capture host text appearance without changing the local tab until hosting succeeds; failed hosting/local sharing preserves document identity and clean/dirty state. Readiness estimates unchanged. |
+| 2026-10-22 | minor | Portable glyph geometry preserves missing-font text across native saves and collaboration; commands and gestures protect edits, and SVG/PDF retain outlines. Review follow-up: retain host/local-share captures locally without changing dirty state, ignore non-geometric style links in fingerprints, and check guest fonts only. No font binaries. Readiness estimates unchanged. |
+| 2026-10-22 | minor | Startup graphics-error tests assert translated hints rather than English substrings, so desktop recovery tests selecting a different UI language do not make them fail. Runtime behavior and readiness estimates unchanged. |
 | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged |
 | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
 | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
diff --git a/crates/collab/src/diff.rs b/crates/collab/src/diff.rs
--- a/crates/collab/src/diff.rs
+++ b/crates/collab/src/diff.rs
@@ -44,7 +44,7 @@ pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
             (Some(o), Some(n)) => {
                 for p in Prop::FIELDS {
                     if p.differs(o, n) {
-                        fields.push(Change::Set { id, prop: *p, value: p.get(n)? });
+                        fields.push(Change::Set { id, prop: *p, value: field_update(*p, o, n)? });
                     }
                 }
                 if o.parent != n.parent {
@@ -121,6 +121,43 @@ pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
     Ok(Diff { changes, replica })
 }
 
+/// Omit unchanged immutable geometry from a text edit. Full creations/snapshots and edits
+/// which regenerate geometry still carry the complete appearance. Explicit null clears it;
+/// the explicit metadata marker preserves even concurrently changed text and geometry.
+fn field_update(prop: Prop, before: &figcraft_doc::Node, after: &figcraft_doc::Node) -> Result<Json, Error> {
+    if prop != Prop::Kind {
+        return prop.get(after);
+    }
+    let (Some(old), Some(new)) = (before.kind.text(), after.kind.text()) else { return prop.get(after) };
+    if old.appearance.is_some() && old.appearance == new.appearance && old.runs == new.runs && same_text_geometry(old, new) {
+        let mut kind = after.kind.clone();
+        if let figcraft_doc::NodeKind::Text(t) = &mut kind {
+            t.appearance = None;
+        }
+        let mut value = Json::of(&kind)?;
+        if let Some(object) = value.0.as_object_mut() {
+            object.insert("unchangedAppearance".into(), serde_json::Value::Bool(true));
+        }
+        return Ok(value);
+    }
+    prop.get(after)
+}
+
+fn same_text_geometry(a: &figcraft_doc::TextData, b: &figcraft_doc::TextData) -> bool {
+    let clean = |t: &figcraft_doc::TextData| {
+        let mut t = t.clone();
+        t.appearance = None;
+        t.style.link = None;
+        t.style.style_id = None;
+        for run in &mut t.runs {
+            run.style.link = None;
+            run.style.style_id = None;
+        }
+        t
+    };
+    clean(a) == clean(b)
+}
+
 /// Depth of `id` in `doc` (pages are 0).
 fn depth(doc: &Document, id: NodeId) -> usize {
     let mut d = 0;
diff --git a/crates/collab/src/diff_tests.rs b/crates/collab/src/diff_tests.rs
--- a/crates/collab/src/diff_tests.rs
+++ b/crates/collab/src/diff_tests.rs
@@ -250,3 +250,143 @@ fn invalid_changes_are_refused_and_lenient_mode_skips_them() {
     let mut r = a.clone();
     assert!(r.apply(&[Change::Delete { id: NodeId(12345) }, Change::Remove { entry: Entry::Comment(1) }], Mode::Strict).is_ok());
 }
+
+fn portable_text() -> figcraft_doc::TextData {
+    let mut text = figcraft_doc::TextData::new("Portable");
+    text.appearance = Some(Arc::new(figcraft_doc::TextAppearance {
+        fingerprint: "synthetic geometry".into(),
+        box_width: 100.0,
+        box_height: 20.0,
+        width: 100.0,
+        height: 20.0,
+        glyphs: vec![figcraft_doc::TextGlyph {
+            d: "M0 0L10 0L10 10Z".repeat(10000),
+            x: 0.0,
+            y: 0.0,
+            size: 20.0,
+            advance: 10.0,
+            char_start: 0,
+            char_end: 1,
+            style: 0,
+            rtl: false,
+        }],
+        lines: vec![],
+        decorations: vec![],
+        styles: vec![],
+        truncated: false,
+    }));
+    text
+}
+
+#[test]
+fn ordinary_text_edits_do_not_retransmit_unchanged_geometry() {
+    let mut doc = Document::new("Portable");
+    let page = doc.first_page().unwrap();
+    let id = doc.create(page, NodeKind::Text(portable_text()), "Text", 0.0, 0.0, 100.0, 20.0).unwrap();
+    let base = Replica::new(doc.clone());
+    // Full joins still carry the appearance, including a binary snapshot round trip.
+    round_trip(&Replica::new(Document::new("Portable")), &doc);
+    for action in 0..3 {
+        let mut edited = doc.clone();
+        let n = edited.get_mut(id).unwrap();
+        match action {
+            0 => n.transform = figcraft_geom::Affine::translate((15.0, 0.0)) * n.transform,
+            1 => n.fills = vec![Paint::solid(Color::new(1.0, 0.0, 0.0, 1.0))],
+            _ => {
+                if let NodeKind::Text(t) = &mut n.kind {
+                    t.style.link = Some("https://example.org".into());
+                }
+            }
+        }
+        let changes = round_trip(&base, &edited);
+        let bytes = message::encode(&changes).unwrap().len();
+        assert!(bytes < 4096, "unchanged outlines leaked into action {action}: {bytes} bytes");
+    }
+    let mut metadata = doc.clone();
+    if let NodeKind::Text(t) = &mut metadata.get_mut(id).unwrap().kind {
+        t.style.link = Some("https://example.org/new-link".into());
+    }
+    let metadata_changes = diff(&base, &metadata).unwrap().changes;
+    let mut edited = doc.clone();
+    if let NodeKind::Text(t) = &mut edited.get_mut(id).unwrap().kind {
+        t.characters = "Changed".into();
+        Arc::make_mut(t.appearance.as_mut().unwrap()).fingerprint = "new geometry".into();
+    }
+    let changes = round_trip(&base, &edited);
+    assert_eq!(changes.iter().filter(|c| matches!(c, Change::Set { prop: Prop::Kind, .. })).count(), 1);
+    assert!(changes.iter().any(|c| matches!(c, Change::Set { prop: Prop::Kind, value, .. } if value.0.to_string().contains("glyphs"))));
+    let mut concurrent = Replica::new(edited.clone());
+    concurrent.apply(&metadata_changes, Mode::Strict).unwrap();
+    let current = concurrent.document().node(id).unwrap().kind.text().unwrap();
+    assert_eq!(current.characters, "Changed");
+    assert_eq!(current.appearance.as_ref().unwrap().fingerprint, "new geometry");
+    assert_eq!(current.style.link.as_deref(), Some("https://example.org/new-link"));
+    let updated = Replica::new(edited.clone());
+    if let NodeKind::Text(t) = &mut edited.get_mut(id).unwrap().kind {
+        t.appearance = None;
+    }
+    round_trip(&updated, &edited);
+}
+
+#[test]
+fn portable_metadata_and_concurrent_text_edits_converge_with_undo() {
+    for seed in 0..8 {
+        let mut doc = Document::new("Portable race");
+        let page = doc.first_page().unwrap();
+        let id = doc.create(page, NodeKind::Text(portable_text()), "Text", 0.0, 0.0, 100.0, 20.0).unwrap();
+        let mut sim = crate::sim::Sim::new(doc, 2, seed, 5).unwrap();
+        let mut letters = sim.document(0).unwrap().clone();
+        if let NodeKind::Text(t) = &mut letters.get_mut(id).unwrap().kind {
+            t.characters = "New letters".into();
+            Arc::make_mut(t.appearance.as_mut().unwrap()).fingerprint = "new letters geometry".into();
+        }
+        let mut metadata = sim.document(1).unwrap().clone();
+        if let NodeKind::Text(t) = &mut metadata.get_mut(id).unwrap().kind {
+            t.style.link = Some("https://example.org".into());
+        }
+        sim.edit(0, &letters).unwrap();
+        sim.edit(1, &metadata).unwrap();
+        sim.settle(&crate::NoRecompute, 1000).unwrap();
+        sim.converged().unwrap();
+        let t = sim.server.document().node(id).unwrap().kind.text().unwrap();
+        assert_eq!(t.characters, "New letters", "seed {seed}");
+        assert_eq!(t.appearance.as_ref().unwrap().fingerprint, "new letters geometry");
+        sim.undo(1, &crate::NoRecompute).unwrap();
+        sim.settle(&crate::NoRecompute, 1000).unwrap();
+        sim.converged().unwrap();
+        assert_eq!(sim.server.document().node(id).unwrap().kind.text().unwrap().characters, "New letters", "metadata undo, seed {seed}");
+        assert!(sim.violations.is_empty(), "{:?}", sim.violations);
+    }
+}
+
+#[test]
+fn compact_base_metadata_keeps_concurrent_run_overrides() {
+    let mut doc = Document::new("Portable metadata race");
+    let page = doc.first_page().unwrap();
+    let mut text = portable_text();
+    text.runs.push(figcraft_doc::StyleRun { start: 0, end: 8, style: text.style.clone() });
+    let id = doc.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 100.0, 20.0).unwrap();
+    let base = Replica::new(doc.clone());
+    let mut metadata = doc.clone();
+    if let NodeKind::Text(t) = &mut metadata.get_mut(id).unwrap().kind {
+        t.style.link = Some("https://example.org/base".into());
+    }
+    let changes = diff(&base, &metadata).unwrap().changes;
+    let mut concurrent = doc.clone();
+    if let NodeKind::Text(t) = &mut concurrent.get_mut(id).unwrap().kind {
+        t.runs[0].style.link = Some("https://example.org/concurrent".into());
+    }
+    let mut replica = Replica::new(concurrent);
+    replica.apply(&changes, Mode::Strict).unwrap();
+    let t = replica.document().node(id).unwrap().kind.text().unwrap();
+    assert_eq!(t.style.link.as_deref(), Some("https://example.org/base"));
+    assert_eq!(t.runs[0].style.link.as_deref(), Some("https://example.org/concurrent"));
+    // Run-level edits use the existing atomic kind write rather than silently dropping
+    // unmatched ranges when concurrent typography splits or merges them.
+    let mut edited = doc.clone();
+    if let NodeKind::Text(t) = &mut edited.get_mut(id).unwrap().kind {
+        t.runs[0].style.link = Some("https://example.org/run".into());
+    }
+    let changes = round_trip(&base, &edited);
+    assert!(changes.iter().any(|c| matches!(c, Change::Set { prop: Prop::Kind, value, .. } if value.0.get("unchangedAppearance").is_none())));
+}
diff --git a/crates/collab/src/replica.rs b/crates/collab/src/replica.rs
--- a/crates/collab/src/replica.rs
+++ b/crates/collab/src/replica.rs
@@ -236,6 +236,17 @@ impl Replica {
                 if node.is_page() != matches!(kind, NodeKind::Page { .. }) {
                     return Err(Error::Invalid("a node can't become or stop being a page".into()));
                 }
+                // A compact metadata edit must not replace newer concurrent characters or
+                // typography while retaining geometry from a different text revision.
+                if value.0.get("unchangedAppearance").and_then(serde_json::Value::as_bool) == Some(true)
+                    && let (NodeKind::Text(current), NodeKind::Text(metadata)) = (&mut node.kind, &kind)
+                {
+                    current.style.link = metadata.style.link.clone();
+                    current.style.style_id = metadata.style.style_id.clone();
+                    // The compact form changes only base metadata. Runs may have been split
+                    // by a concurrent edit; leave their explicit overrides untouched.
+                    return Ok(Relist::No);
+                }
                 node.kind = kind;
                 Ok(Relist::No)
             }
diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs
--- a/crates/doc/src/text.rs
+++ b/crates/doc/src/text.rs
@@ -399,6 +399,11 @@ pub struct StyleRun {
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
 #[serde(default)]
 pub struct TextData {
+    /// Portable display geometry. The editable characters/styles remain authoritative; the
+    /// fingerprint prevents using outlines after their content or layout inputs changed.
+    /// Immutable and shared so ordinary text edits and history do not copy glyph paths.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub appearance: Option<std::sync::Arc<TextAppearance>>,
     pub characters: String,
     pub style: TextStyle,
     /// Ranges whose style differs from `style`; full styles (already merged), non-overlapping, sorted.
@@ -447,6 +452,58 @@ pub struct TextData {
     pub path: Option<Box<TextPath>>,
 }
 
+/// Font-independent text display data, never a font file. Shared and saved with the text node.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct TextAppearance {
+    pub fingerprint: String,
+    pub box_width: f64,
+    pub box_height: f64,
+    pub width: f64,
+    pub height: f64,
+    pub glyphs: Vec<TextGlyph>,
+    pub lines: Vec<TextLine>,
+    pub decorations: Vec<TextDecorationPath>,
+    pub styles: Vec<TextStyle>,
+    pub truncated: bool,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct TextGlyph {
+    pub d: String,
+    pub x: f64,
+    pub y: f64,
+    pub size: f64,
+    pub advance: f64,
+    pub char_start: usize,
+    pub char_end: usize,
+    pub style: usize,
+    pub rtl: bool,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct TextLine {
+    pub glyph_start: usize,
+    pub glyph_end: usize,
+    pub char_start: usize,
+    pub char_end: usize,
+    pub top: f64,
+    pub height: f64,
+    pub baseline: f64,
+    pub width: f64,
+    pub x: f64,
+    pub para_end: bool,
+    pub break_after: bool,
+    pub rtl: bool,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct TextDecorationPath {
+    pub rect: [f64; 4],
+    pub style: usize,
+    pub kind: DecorationStyle,
+    pub thickness: f64,
+}
+
 /// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the
 /// horizontal alignment: left starts, centre centres, right ends at it); glyphs past an open
 /// path's ends are hidden, a closed path wraps them around.
diff --git a/crates/engine/src/canvas/textedit.rs b/crates/engine/src/canvas/textedit.rs
--- a/crates/engine/src/canvas/textedit.rs
+++ b/crates/engine/src/canvas/textedit.rs
@@ -90,6 +90,13 @@ fn text_of(s: &Session, id: NodeId) -> Result<TextData> {
 /// Start editing text node `id` with the caret at `caret` (None = select all).
 pub fn begin(s: &mut Session, id: NodeId, caret: Option<usize>, created: bool) -> Result<()> {
     let t = text_of(s, id)?;
+    let node = s.doc()?.get(id)?;
+    if figcraft_text::appearance::has_saved(&t, Some(node.width), Some(node.height)) && !figcraft_text::appearance::fonts_available(&t) {
+        return Err(EngineError::Disabled(
+            "text.edit".into(),
+            "Install the missing fonts or replace them with installed fonts before editing this text".into(),
+        ));
+    }
     let n = t.char_count();
     s.begin_interaction("Edit text")?;
     let ds = s.ds_mut()?;
diff --git a/crates/engine/src/cmd/file/save.rs b/crates/engine/src/cmd/file/save.rs
--- a/crates/engine/src/cmd/file/save.rs
+++ b/crates/engine/src/cmd/file/save.rs
@@ -55,6 +55,7 @@ impl FileSnapshot {
     /// The file's bytes: content, thumbnail and version history.
     pub fn encode(&self) -> Result<Vec<u8>> {
         let mut doc = (*self.doc).clone();
+        figcraft_text::appearance::capture(&mut doc, None);
         doc.current_page = Some(self.page);
         let thumb = thumbnail(&doc);
         figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches)
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,11 @@ 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) {
+    normalize_with_capture(doc, before, true);
+}
+
+/// Gesture previews are local and disposable; capture their final geometry at commit.
+pub(super) fn normalize_with_capture(doc: &mut Document, before: &Document, capture: bool) {
     // 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.
@@ -132,6 +137,9 @@ pub fn normalize(doc: &mut Document, before: &Document) {
     // 4b. Board connectors follow the layers they join; their labels sit at their middles.
     board::settle_routes(doc, before, &touched);
     touched.extend(doc.take_dirty());
+    if capture {
+        figcraft_text::appearance::capture(doc, Some(&touched.iter().copied().collect::<Vec<_>>()));
+    }
     // 5. Ready-for-dev layers whose contents changed show "Changed".
     mark_dev_changes(doc, before, &touched);
     doc.take_dirty();
diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs
--- a/crates/engine/src/doc_state.rs
+++ b/crates/engine/src/doc_state.rs
@@ -140,6 +140,7 @@ impl DocState {
         doc.link_layer_text_styles();
         cmd::styles::apply_paint_bindings_on_load(&mut doc);
         components::mark_layer_modes(&mut doc);
+        figcraft_text::appearance::capture(&mut doc, None);
         // Initial import settlement belongs to the saved baseline, not the user's first edit.
         let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0));
         let doc = Arc::new(doc);
diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs
--- a/crates/engine/src/fonts.rs
+++ b/crates/engine/src/fonts.rs
@@ -139,9 +139,25 @@ fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
     Ok(json!({"families": families}))
 }
 
+fn preserve_appearance(s: &mut Session, _: &Value) -> Result<Value> {
+    let mut doc = s.doc()?.clone();
+    let captured = figcraft_text::appearance::capture(&mut doc, None);
+    if captured > 0 {
+        *s.doc_mut()? = doc;
+    }
+    Ok(json!({"captured": captured}))
+}
+
 /// The font commands: `text.missingFonts` and `text.refit`.
 pub fn specs() -> Vec<CommandSpec> {
     vec![
+        spec!(
+            "text.preserveAppearance",
+            "Preserve text appearance",
+            "{}  save portable glyph outlines for text whose fonts are installed → {captured}",
+            true,
+            preserve_appearance
+        ),
         spec!(
             "text.missingFonts",
             "Missing fonts",
diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs
--- a/crates/engine/src/live_tests.rs
+++ b/crates/engine/src/live_tests.rs
@@ -17,6 +17,7 @@ pub(crate) struct Script {
     pub(crate) events: Vec<Event>,
     pub(crate) sent: Vec<Presence>,
     pub(crate) hosted: Vec<HostOptions>,
+    refuse_host: bool,
     joined: Vec<String>,
     pub(crate) left: usize,
     pub(crate) msgs: Vec<ClientMsg>,
@@ -37,6 +38,9 @@ pub(crate) struct Mock(pub(crate) Arc<Mutex<Script>>);
 impl LiveLink for Mock {
     fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {
         let mut s = self.0.lock().unwrap();
+        if s.refuse_host {
+            return Err("synthetic host failure".into());
+        }
         s.session = session;
         s.hosted.push(opts);
         // Each session listens on its own port (47900 for the first).
@@ -117,6 +121,65 @@ pub(crate) fn session() -> (Session, Arc<Mutex<Script>>) {
     (s, script)
 }
 
+#[test]
+fn hosting_retains_late_captured_geometry_locally_without_changing_dirty_state() {
+    late_font_hosting_cases(false);
+}
+
+#[test]
+fn failed_hosting_does_not_change_the_document_or_saved_baseline() {
+    late_font_hosting_cases(true);
+}
+
+fn late_font_hosting_cases(refuse_host: bool) {
+    use figcraft_doc::{NodeKind, TextData};
+    use figcraft_text::fontdb::{Face, FontBytes};
+
+    figcraft_text::scan_system_fonts();
+    let Some(font) = figcraft_text::find_face("Arial", "Regular") else {
+        eprintln!("late capture: no source face available for this system-font regression");
+        return;
+    };
+    for share in [false, true] {
+        for dirty in [false, true] {
+            let family = format!("Synthetic late sharing face {share} {dirty} {refuse_host}");
+            let mut doc = Document::new("Synthetic late font indexing");
+            let mut text = TextData::new("Portable text");
+            text.style.font_family = family.clone();
+            let id = doc.create(doc.first_page().unwrap(), NodeKind::Text(text), "Text", 0.0, 0.0, 200.0, 40.0).unwrap();
+            let mut s = Session::with_document(doc);
+            let script = Arc::new(Mutex::new(Script::default()));
+            script.lock().unwrap().refuse_host = refuse_host;
+            s.live.install(Box::new(Mock(script.clone())));
+            if dirty {
+                s.execute("node.set", &json!({"ids":[id],"props":{"x":12}})).unwrap();
+            }
+            assert_eq!(s.ds().unwrap().is_dirty(), dirty);
+            assert!(s.doc().unwrap().node(id).unwrap().kind.text().unwrap().appearance.is_none());
+            let before = s.ds().unwrap().doc.clone();
+            // Simulate the font scan finishing after the file has already opened.
+            let face = Face::from_bytes(FontBytes::Shared(Arc::new(font.bytes.data().to_vec())), font.index, Some(&family), Some("Regular")).unwrap();
+            figcraft_text::db().write().unwrap().add(face);
+            let (command, params) = if share { ("collab.share", json!({"via":"local"})) } else { ("collab.host", json!({})) };
+            let result = s.execute(command, &params);
+            let ds = s.ds().unwrap();
+            assert_eq!(ds.is_dirty(), dirty);
+            if refuse_host {
+                assert!(result.unwrap_err().to_string().contains("synthetic host failure"));
+                assert!(Arc::ptr_eq(&before, &ds.doc));
+                assert!(script.lock().unwrap().hosted.is_empty());
+                continue;
+            }
+            result.unwrap();
+            let n = ds.doc.node(id).unwrap();
+            assert!(figcraft_text::appearance::has_saved(n.kind.text().unwrap(), Some(n.width), Some(n.height)));
+            let recorded = script.lock().unwrap();
+            let sent = &recorded.hosted.last().unwrap().doc;
+            assert!(Arc::ptr_eq(&ds.doc, sent), "the local tab and peers must retain the same captured document");
+        }
+    }
+}
+
 pub(crate) fn push(script: &Arc<Mutex<Script>>, events: Vec<Event>) {
     script.lock().unwrap().events.extend(events);
 }
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
@@ -48,7 +48,8 @@ impl Session {
             .and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
             .and_then(|v| self.check_preview(id, &before).map(|()| v))
             .and_then(|v| self.check_live_access(id, &before).map(|()| v))
-            .and_then(|v| self.check_finite_geometry(id, &before).map(|()| v));
+            .and_then(|v| self.check_finite_geometry(id, &before).map(|()| v))
+            .and_then(|v| self.check_text_fonts(id, &before).map(|()| v));
         match &result {
             Ok(_) => {
                 self.after_command(spec, before);
@@ -92,6 +93,50 @@ impl Session {
         Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into()))
     }
 
+    /// Preserve absent-font text until an explicit, installed font replacement is selected.
+    fn check_text_fonts(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
+        let Some((old, _, _, uid)) = before else { return Ok(()) };
+        let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
+        if Arc::ptr_eq(&ds.doc, old) {
+            return Ok(());
+        }
+        Self::check_text_font_edit(id, old, &ds.doc)
+    }
+
+    pub(super) fn check_text_font_edit(id: &str, old: &Document, candidate: &Document) -> Result<()> {
+        for n in candidate.nodes() {
+            if old.node_arc(n.id).zip(candidate.node_arc(n.id)).is_some_and(|(a, b)| Arc::ptr_eq(a, b)) {
+                continue;
+            }
+            let Some(b) = old.node(n.id) else { continue };
+            let (figcraft_doc::NodeKind::Text(a), figcraft_doc::NodeKind::Text(t)) = (&b.kind, &n.kind) else { continue };
+            if a == t && b.width == n.width && b.height == n.height {
+                continue;
+            }
+            if !figcraft_text::appearance::has_saved(a, Some(b.width), Some(b.height)) {
+                continue;
+            }
+            if figcraft_text::appearance::has_saved(t, Some(n.width), Some(n.height)) {
+                // Metadata-only edits do not need installed fonts or new outlines.
+                continue;
+            }
+            let mut a = a.clone();
+            let mut t = t.clone();
+            a.appearance = None;
+            t.appearance = None;
+            if (a != t || b.width != n.width || b.height != n.height)
+                && !figcraft_text::appearance::fonts_available(&a)
+                && !figcraft_text::appearance::fonts_available(&t)
+            {
+                return Err(EngineError::Disabled(
+                    id.into(),
+                    "Install the missing fonts or explicitly replace them with installed fonts before editing this text".into(),
+                ));
+            }
+        }
+        Ok(())
+    }
+
     /// A layer's position and size stay finite: infinity and NaN are written as `null`, so a file
     /// holding them saves but can't be reopened. Refuse such an edit (it is rolled back).
     fn check_finite_geometry(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
@@ -150,7 +195,9 @@ impl Session {
         let changed = !Arc::ptr_eq(&ds.doc, &bdoc);
         // Saving may adopt the chosen filename as the document title. The encoded snapshot is
         // already final; normalising it again would detach it from the saved baseline.
-        if changed && !matches!(spec.id, "file.save" | "file.saveAs") {
+        // Hosting likewise only captures text metadata into the final shared document; keep its
+        // saved baseline and the host's view aligned with the snapshot sent to the link.
+        if changed && !matches!(spec.id, "file.save" | "file.saveAs" | "collab.host" | "collab.share") {
             normalize(ds.doc_mut(), &bdoc);
         }
         let edit = changed && ds.interaction.is_none();
diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs
--- a/crates/engine/src/session/undo.rs
+++ b/crates/engine/src/session/undo.rs
@@ -48,7 +48,8 @@ impl Session {
         let res = f(&mut doc, &mut sel);
         if res.is_ok() {
             cmd::styles::detach_edited(&mut doc, &before);
-            normalize(&mut doc, &it.doc);
+            crate::derived::normalize_with_capture(&mut doc, &it.doc, false);
+            Self::check_text_font_edit(&it.label, &it.doc, &doc)?;
             // The same preview again (a handle held still) keeps the document: a new one would
             // re-render the canvas for nothing.
             if doc == *ds.doc && sel == ds.selection {
@@ -69,6 +70,8 @@ impl Session {
         let (uid, page) = (ds.uid, ds.page);
         ds.revision += 1;
         if !Arc::ptr_eq(&it.doc, &ds.doc) && *it.doc != *ds.doc {
+            let changed = changed_nodes(&ds.doc, &it.doc);
+            figcraft_text::appearance::capture(ds.doc_mut(), Some(&changed));
             self.record(uid, Some(&it.label), it.doc, it.selection, page);
         } else {
             ds.doc = it.doc;
diff --git a/crates/engine/src/sharing/live/host.rs b/crates/engine/src/sharing/live/host.rs
--- a/crates/engine/src/sharing/live/host.rs
+++ b/crates/engine/src/sharing/live/host.rs
@@ -38,6 +38,16 @@ pub(super) fn host(s: &mut Session, p: &Value) -> Result<Value> {
     Ok(s.live_summary())
 }
 
+/// Prepare the host snapshot without changing the local tab; adopt it only after hosting succeeds.
+fn document_for_sharing(s: &Session) -> Result<(Arc<Document>, u64)> {
+    let ds = s.ds()?;
+    let mut doc = (*ds.doc).clone();
+    if figcraft_text::appearance::capture(&mut doc, None) > 0 {
+        return Ok((Arc::new(doc), ds.uid));
+    }
+    Ok((ds.doc.clone(), ds.uid))
+}
+
 /// Start hosting the open file for command `cmd` (on `bind` unless `p` says where; `short`: with
 /// a secret a join code can carry): the new session.
 fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) -> Result<SessionId> {
@@ -51,9 +61,19 @@ fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) ->
         Some("lan") => Bind::Lan,
         Some(other) => other.parse::<IpAddr>().map(Bind::Addr).map_err(|_| bad(cmd, "`bind` must be \"local\", \"lan\" or an IP address"))?,
     };
-    let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
-    let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short };
+    let (doc, uid) = document_for_sharing(s)?;
+    let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc: doc.clone(), short };
     let (id, info) = s.live.start(cmd, |l, id| l.host(id, opts).map_err(|e| EngineError::Other(format!("couldn't start the live session: {e}"))))?;
+    // A failed host start must leave both the document and its saved baseline untouched.
+    if let Some(ds) = s.docs.iter_mut().find(|d| d.uid == uid)
+        && !Arc::ptr_eq(&ds.doc, &doc)
+    {
+        let was_dirty = ds.is_dirty();
+        ds.doc = doc;
+        if !was_dirty {
+            ds.mark_saved();
+        }
+    }
     s.live.open(Live { id, status: Status::Live, info: Some(info), doc_uid: Some(uid), tab: Some(uid), ..Live::default() });
     Ok(id)
 }
@@ -69,7 +89,12 @@ pub(super) fn publish(s: &mut Session, p: &Value) -> Result<Value> {
 /// codes for others too): the new session.
 fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: bool) -> Result<SessionId> {
     let key = string(p, "key").map(|k| k.trim().to_string()).filter(|k| !k.is_empty());
-    let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
+    // Publishing is read-only until welcome installs the server's captured document locally.
+    let (doc, uid) = s.ds().map(|ds| {
+        let mut doc = (*ds.doc).clone();
+        figcraft_text::appearance::capture(&mut doc, None);
+        (Arc::new(doc), ds.uid)
+    })?;
     let name = string(p, "name").map(|n| n.trim().chars().filter(|c| !c.is_control()).take(256).collect::<String>()).filter(|n| !n.is_empty());
     let me = display_name(&s.prefs);
     let opts = PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me, doc, share };
diff --git a/crates/engine/tests/portable_text.rs b/crates/engine/tests/portable_text.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/tests/portable_text.rs
@@ -0,0 +1,223 @@
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
+//! Separate processes deliberately have different font databases. Never put a font in the file.
+use figcraft_doc::{Document, NodeKind, TextAutoResize, TextData};
+use figcraft_engine::Session;
+use figcraft_text::fontdb::{Face, FontBytes};
+use serde_json::json;
+
+const FAMILY: &str = "FigCraft synthetic portable test face";
+
+#[test]
+fn portable_text_other_computer() {
+    if let Some(dir) = std::env::var_os("FIGCRAFT_PORTABLE_TEXT_CHILD") {
+        let dir = std::path::PathBuf::from(dir);
+        assert!(!figcraft_text::has_family(FAMILY));
+        let (doc, issues) = figcraft_format::load_bytes(&std::fs::read(dir.join("test.figcraft")).unwrap()).unwrap();
+        assert!(issues.is_empty());
+        let id = doc.nodes().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap().id;
+        let n = doc.node(id).unwrap();
+        let (l, _) = figcraft_text::layout_node(n).unwrap();
+        assert!(!l.glyphs.is_empty());
+        assert!(l.glyphs.iter().all(|g| g.face.is_none()));
+        let changes: Vec<figcraft_collab::Change> =
+            figcraft_collab::message::decode(&std::fs::read(dir.join("snapshot.bin")).unwrap(), usize::MAX).unwrap();
+        let replica = figcraft_collab::Replica::from_snapshot(&changes).unwrap();
+        assert_eq!(figcraft_render::render_node(replica.document(), id, 2.0).unwrap().data(), std::fs::read(dir.join("pixels.bin")).unwrap());
+        let pixels = figcraft_render::render_node(&doc, id, 2.0).unwrap();
+        assert_eq!(pixels.data(), std::fs::read(dir.join("pixels.bin")).unwrap());
+        let svg = figcraft_svg::export_svg(&doc, &[id], &Default::default()).unwrap();
+        assert_eq!(svg, std::fs::read_to_string(dir.join("text.svg")).unwrap());
+        let live_svg = figcraft_svg::export_svg(&doc, &[id], &figcraft_svg::SvgOptions { outline_text: false, ..Default::default() }).unwrap();
+        assert!(!live_svg.contains("<text"), "an absent font must not change the exported appearance");
+        assert!(figcraft_pdf::export_nodes_pdf(&doc, &[id]).unwrap().starts_with(b"%PDF"));
+        let mut linked = doc.clone();
+        for url in [Some("https://example.org/current"), None] {
+            if let NodeKind::Text(t) = &mut linked.get_mut(id).unwrap().kind {
+                t.style.link = url.map(str::to_owned);
+                let saved = std::sync::Arc::make_mut(t.appearance.as_mut().unwrap());
+                for style in &mut saved.styles {
+                    style.link = Some("https://example.org/stale".into());
+                }
+            }
+            let pdf = figcraft_pdf::export_nodes_pdf(&linked, &[id]).unwrap();
+            let pdf = String::from_utf8_lossy(&pdf);
+            assert!(!pdf.contains("https://example.org/stale"));
+            assert_eq!(pdf.contains("https://example.org/current"), url.is_some());
+        }
+        let mut session = Session::with_document(doc);
+        let old = session.doc().unwrap().clone();
+        assert!(figcraft_engine::textedit::begin(&mut session, id, None, false).is_err());
+        let error = session.execute("node.set", &json!({"ids":[id],"props":{"characters":"Changed"}})).unwrap_err();
+        assert!(error.to_string().contains("missing fonts"), "{error}");
+        assert_eq!(*session.doc().unwrap(), old);
+        let baseline = session.ds().unwrap().doc.clone();
+        session.begin_interaction("Resize absent-font text").unwrap();
+        let error = session
+            .preview(|doc, _| {
+                doc.get_mut(id)?.width = 180.0;
+                Ok(())
+            })
+            .unwrap_err();
+        assert!(error.to_string().contains("missing fonts"), "{error}");
+        assert!(std::sync::Arc::ptr_eq(&baseline, &session.ds().unwrap().doc));
+        session.commit_interaction().unwrap();
+        assert!(std::sync::Arc::ptr_eq(&baseline, &session.ds().unwrap().doc));
+        session.begin_interaction("Edit absent-font text").unwrap();
+        assert!(
+            session
+                .preview(|doc, _| {
+                    if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
+                        t.characters = "Gesture edit".into();
+                    }
+                    Ok(())
+                })
+                .is_err()
+        );
+        session.cancel_interaction().unwrap();
+        assert!(std::sync::Arc::ptr_eq(&baseline, &session.ds().unwrap().doc));
+        session.begin_interaction("Move absent-font text").unwrap();
+        session
+            .preview(|doc, _| {
+                doc.get_mut(id)?.transform = figcraft_geom::Affine::translate((25.0, 0.0));
+                Ok(())
+            })
+            .unwrap();
+        let last_valid = session.ds().unwrap().doc.clone();
+        assert!(
+            session
+                .preview(|doc, _| {
+                    doc.get_mut(id)?.width = 180.0;
+                    Ok(())
+                })
+                .is_err()
+        );
+        assert!(std::sync::Arc::ptr_eq(&last_valid, &session.ds().unwrap().doc));
+        session.commit_interaction().unwrap();
+        assert_eq!(session.doc().unwrap().node(id).unwrap().transform.as_coeffs()[4], 25.0);
+        assert!(session.undo().unwrap());
+        assert_eq!(*session.doc().unwrap(), old);
+        session.execute("node.set", &json!({"ids":[id],"props":{"x":150}})).unwrap();
+        assert_eq!(session.doc().unwrap().node(id).unwrap().transform.as_coeffs()[4], 150.0);
+        assert_eq!(session.doc().unwrap().node(id).unwrap().width, n_width(&old, id));
+        let baseline = session.ds().unwrap().doc.clone();
+        let was_dirty = session.ds().unwrap().is_dirty();
+        assert_eq!(session.execute("text.preserveAppearance", &json!({})).unwrap()["captured"], 0);
+        assert!(std::sync::Arc::ptr_eq(&baseline, &session.ds().unwrap().doc), "a no-op must not create a document edit");
+        assert_eq!(session.ds().unwrap().is_dirty(), was_dirty);
+        let reopened = figcraft_format::load_bytes(&figcraft_format::save_bytes(session.doc().unwrap(), None).unwrap()).unwrap().0;
+        assert!(figcraft_text::layout_node(reopened.node(id).unwrap()).unwrap().0.glyphs.iter().all(|g| g.face.is_none()));
+        return;
+    }
+    figcraft_text::scan_system_fonts();
+    let Some(font) = figcraft_text::find_face("Arial", "Regular") else {
+        eprintln!("portable text: skipped separate-process check because no source face is available");
+        return;
+    };
+    let face =
+        Face::from_bytes(FontBytes::Shared(std::sync::Arc::new(font.bytes.data().to_vec())), font.index, Some(FAMILY), Some("Regular")).unwrap();
+    let mut probe = TextData::new("Cache probe");
+    probe.style.font_family = FAMILY.into();
+    assert!(!figcraft_text::appearance::fonts_available(&probe));
+    figcraft_text::db().write().unwrap().add(face);
+    assert!(figcraft_text::appearance::fonts_available(&probe), "adding a face invalidates the negative cache");
+    let mut doc = Document::new("Synthetic portable text");
+    let mut t = TextData::new("AV office — Привет\nSecond line");
+    t.style.font_family = FAMILY.into();
+    t.style.font_size = 32.0;
+    t.auto_resize = TextAutoResize::None;
+    let id = doc.create(doc.first_page().unwrap(), NodeKind::Text(t), "Portable", 0.0, 0.0, 360.0, 120.0).unwrap();
+    assert_eq!(figcraft_text::appearance::capture(&mut doc, None), 1);
+    assert_eq!(figcraft_text::appearance::capture(&mut doc, None), 0);
+    // Old imported files can link a text style on the layer before per-character links exist.
+    // Opening must settle those links before fingerprinting the portable geometry.
+    let style = doc.node(id).unwrap().kind.text().unwrap().style.clone();
+    doc.styles.push(figcraft_doc::Style {
+        id: "synthetic-type".into(),
+        name: "Synthetic type".into(),
+        description: String::new(),
+        kind: figcraft_doc::StyleKind::Text { style },
+        bound: Default::default(),
+    });
+    doc.get_mut(id).unwrap().styles.text = Some("synthetic-type".into());
+    let mut doc = (*figcraft_engine::DocState::new(doc, None).doc).clone();
+    let node = doc.node(id).unwrap();
+    assert!(figcraft_text::appearance::has_saved(node.kind.text().unwrap(), Some(node.width), Some(node.height)));
+    assert_eq!(figcraft_text::appearance::capture(&mut doc, None), 0);
+    let dir = std::env::temp_dir().join(format!("figcraft-portable-text-{}", std::process::id()));
+    std::fs::create_dir_all(&dir).unwrap();
+    let replica = figcraft_collab::Replica::new(doc.clone());
+    std::fs::write(dir.join("snapshot.bin"), figcraft_collab::message::encode(&replica.snapshot().unwrap()).unwrap()).unwrap();
+    std::fs::write(dir.join("test.figcraft"), figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
+    std::fs::write(dir.join("pixels.bin"), figcraft_render::render_node(&doc, id, 2.0).unwrap().data()).unwrap();
+    std::fs::write(dir.join("text.svg"), figcraft_svg::export_svg(&doc, &[id], &Default::default()).unwrap()).unwrap();
+    let child = std::process::Command::new(std::env::current_exe().unwrap())
+        .args(["--exact", "portable_text_other_computer", "--nocapture"])
+        .env("FIGCRAFT_PORTABLE_TEXT_CHILD", &dir)
+        .env("FIGCRAFT_NO_SYSTEM_FONTS", "1")
+        .output()
+        .unwrap();
+    assert!(child.status.success(), "{}\n{}", String::from_utf8_lossy(&child.stdout), String::from_utf8_lossy(&child.stderr));
+    eprintln!("portable text: separate-process missing-font equivalence and edit protection verified");
+    std::fs::remove_dir_all(dir).unwrap();
+}
+
+fn n_width(doc: &Document, id: figcraft_doc::NodeId) -> f64 {
+    doc.node(id).unwrap().width
+}
+
+#[test]
+fn resizing_captures_once_at_commit_and_cancel_retains_original_geometry() {
+    figcraft_text::scan_system_fonts();
+    let Some(face) = figcraft_text::find_face("Arial", "Regular") else {
+        assert!(std::env::var_os("CRAFT_FONTS_REQUIRED").is_none(), "measurement needs a source face");
+        return;
+    };
+    let mut doc = Document::new("Synthetic resize measurement");
+    let mut text = TextData::new("Portable text resize measurement. ".repeat(100));
+    text.style.font_family = face.family.clone();
+    text.style.font_style = face.style.clone();
+    text.auto_resize = TextAutoResize::None;
+    let id = doc.create(doc.first_page().unwrap(), NodeKind::Text(text), "Text", 0.0, 0.0, 600.0, 8000.0).unwrap();
+    let mut session = Session::with_document(doc);
+    let before = session.doc().unwrap().clone();
+    let original = before.node(id).unwrap().kind.text().unwrap().appearance.clone().unwrap();
+    let start = std::time::Instant::now();
+    for step in 0..16 {
+        let mut preview = before.clone();
+        preview.get_mut(id).unwrap().width = 610.0 + f64::from(step);
+        figcraft_engine::normalize(&mut preview, &before);
+    }
+    let eager = start.elapsed();
+    session.begin_interaction("Resize portable text").unwrap();
+    let start = std::time::Instant::now();
+    for step in 0..16 {
+        session
+            .preview(|doc, _| {
+                doc.get_mut(id)?.width = 610.0 + f64::from(step);
+                Ok(())
+            })
+            .unwrap();
+        let current = session.doc().unwrap().node(id).unwrap().kind.text().unwrap();
+        assert!(std::sync::Arc::ptr_eq(current.appearance.as_ref().unwrap(), &original));
+    }
+    let previews = start.elapsed();
+    session.commit_interaction().unwrap();
+    let committed = session.doc().unwrap().node(id).unwrap();
+    assert!(figcraft_text::appearance::has_saved(committed.kind.text().unwrap(), Some(committed.width), Some(committed.height)));
+    assert!(!std::sync::Arc::ptr_eq(committed.kind.text().unwrap().appearance.as_ref().unwrap(), &original));
+    eprintln!(
+        "portable resize, 3300 characters / 16 previews: eager={eager:?}, deferred previews={previews:?}, including commit={:?}",
+        start.elapsed()
+    );
+    assert!(session.undo().unwrap());
+    assert_eq!(*session.doc().unwrap(), before);
+    session.begin_interaction("Cancel resize").unwrap();
+    session
+        .preview(|doc, _| {
+            doc.get_mut(id)?.width = 700.0;
+            Ok(())
+        })
+        .unwrap();
+    session.cancel_interaction().unwrap();
+    assert_eq!(*session.doc().unwrap(), before);
+}
diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs
--- a/crates/pdf/src/text.rs
+++ b/crates/pdf/src/text.rs
@@ -99,10 +99,15 @@ const MAX_LINKS: usize = 10_000;
 /// Note the hyperlinked text of a layout (`gxf`: layout → PDF page): one area per line and run of
 /// glyphs linking to the same URL.
 fn record_links(ex: &mut Exporter, layout: &TextLayout, gxf: Affine) {
+    let mut visited = 0usize;
     for line in &layout.lines {
         let mut run: Option<(&str, f64, f64)> = None;
         let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
         for g in glyphs.iter().map(Some).chain([None]) {
+            if ex.links.len() >= MAX_LINKS || (g.is_some() && visited >= 100_000) {
+                return;
+            }
+            visited += usize::from(g.is_some());
             let link = g.and_then(|g| {
                 let url = layout.styles.get(g.style)?.link.as_deref().filter(|u| !u.trim().is_empty())?;
                 let (a, b) = (g.x, g.x + g.advance);
@@ -166,18 +171,26 @@ fn glyph_runs(
             // Glyphs at a variable font instance other than the default are outlined (an
             // embedded font draws its default instance).
             let has_source = g.char_end > g.char_start && g.char_end <= chars.len() && g.coords.is_empty();
-            let font = if has_source && g.size.is_finite() && g.size > 0.0 && g.x.is_finite() && g.y.is_finite() { ex.font(&g.face) } else { None };
+            let font = if has_source && g.size.is_finite() && g.size > 0.0 && g.x.is_finite() && g.y.is_finite() {
+                g.face.as_ref().and_then(|f| ex.font(f))
+            } else {
+                None
+            };
             let Some(font) = font else {
                 // List markers, ellipses and unembeddable fonts: outlines.
                 rest.extend(g.path());
                 continue;
             };
-            let continues = run.as_ref().is_some_and(|r| r.face == g.face.id && (r.size - g.size).abs() < 1e-9);
+            let Some(face) = &g.face else {
+                rest.extend(g.path());
+                continue;
+            };
+            let continues = run.as_ref().is_some_and(|r| r.face == face.id && (r.size - g.size).abs() < 1e-9);
             if !continues {
                 if let Some(r) = run.take() {
                     write_run(s, r, p, w, h, gxf, ex.ink());
                 }
-                run = Some(Run { face: g.face.id, font, size: g.size, x0: g.x, y0: g.y, glyphs: Vec::new(), text: String::new(), last_chars: None });
+                run = Some(Run { face: face.id, font, size: g.size, x0: g.x, y0: g.y, glyphs: Vec::new(), text: String::new(), last_chars: None });
             }
             if let Some(r) = run.as_mut() {
                 push_glyph(r, g, chars);
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
@@ -598,7 +598,7 @@ impl W<'_> {
     /// on a path stays outlined; write a `<tspan>` per styled run when mixed styles are needed.
     fn text_element(&mut self, n: &Node, xf: Affine) -> Option<String> {
         let NodeKind::Text(t) = &n.kind else { return None };
-        if t.path.is_some() {
+        if t.path.is_some() || (t.appearance.is_some() && !figcraft_text::appearance::fonts_available(t)) {
             return None;
         }
         let st = &t.style;
diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml
--- a/crates/text/Cargo.toml
+++ b/crates/text/Cargo.toml
@@ -18,6 +18,8 @@ png = { workspace = true }
 unicode-bidi = { workspace = true }
 unicode-segmentation = { workspace = true }
 log = { workspace = true }
+serde_json = { workspace = true }
+sha2 = { workspace = true }
 
 # Fonts that font managers (RightFont, FontBase, Typeface) activate without copying them into a font
 # folder are known only to Core Text. Its safe wrappers list every available font's file (Core Text
diff --git a/crates/text/src/appearance.rs b/crates/text/src/appearance.rs
new file mode 100644
--- /dev/null
+++ b/crates/text/src/appearance.rs
@@ -0,0 +1,434 @@
+//! Portable text geometry: no installed font is needed to display an unchanged text layer.
+use std::sync::Arc;
+
+use figcraft_doc::{Document, NodeKind, TextAppearance, TextAutoResize, TextData, TextDecorationPath, TextGlyph, TextLine};
+use figcraft_geom::{Affine, BezPath, Rect};
+use sha2::{Digest, Sha256};
+
+use crate::{Decoration, Glyph, Line, TextLayout, no_coords};
+
+const MAX_GLYPHS: usize = 100_000;
+const MAX_PATH_BYTES: usize = 16 * 1024 * 1024;
+
+/// All named faces must exist before reshaping or capturing; never preserve a substitute as
+/// though it were the author's font. A variable font's named instance counts as that face.
+pub fn fonts_available(t: &TextData) -> bool {
+    fonts_available_in(crate::db(), t)
+}
+
+fn fonts_available_in(database: &std::sync::RwLock<crate::FontDb>, t: &TextData) -> bool {
+    let styles = || std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style));
+    {
+        let db = database.read().unwrap_or_else(std::sync::PoisonError::into_inner);
+        let mut complete = true;
+        for s in styles() {
+            match db.cached_named_face(&s.font_family, &s.font_style) {
+                Some(false) => return false,
+                Some(true) => {}
+                None => complete = false,
+            }
+        }
+        if complete {
+            return true;
+        }
+    }
+    let mut db = database.write().unwrap_or_else(std::sync::PoisonError::into_inner);
+    styles().all(|s| db.named_face_available(&s.font_family, &s.font_style))
+}
+
+fn fingerprint(t: &TextData, w: Option<f64>, h: Option<f64>) -> Option<String> {
+    // The immutable appearance is shared: cloning editable inputs never copies glyph paths.
+    let mut source = t.clone();
+    source.appearance = None;
+    source.style.style_id = None;
+    source.style.link = None;
+    for run in &mut source.runs {
+        run.style.style_id = None;
+        run.style.link = None;
+    }
+    let (w, h) = if t.path.is_some() {
+        (None, None)
+    } else {
+        match t.auto_resize {
+            TextAutoResize::WidthAndHeight => (None, None),
+            TextAutoResize::Height => (w, None),
+            TextAutoResize::None => (w, None),
+            TextAutoResize::Truncate => (w, h),
+        }
+    };
+    let bytes = serde_json::to_vec(&(source, w, h)).ok()?;
+    Some(format!("{:x}", Sha256::digest(bytes)))
+}
+
+/// Only protect/use outlines that still describe this text, not a snapshot left behind by
+/// an explicit font replacement, an imported style update or a direct document edit.
+pub fn has_saved(t: &TextData, w: Option<f64>, h: Option<f64>) -> bool {
+    t.appearance.as_ref().is_some_and(|a| fingerprint(t, w, h).is_some_and(|key| key == a.fingerprint))
+}
+
+pub(crate) fn restore(t: &TextData, w: Option<f64>, h: Option<f64>) -> Option<TextLayout> {
+    let a = t.appearance.as_ref()?;
+    if fonts_available(t)
+        || a.fingerprint != fingerprint(t, w, h)?
+        || a.glyphs.len() > MAX_GLYPHS
+        || a.lines.len() > MAX_GLYPHS
+        || a.decorations.len() > MAX_GLYPHS
+        || !a.width.is_finite()
+        || !a.height.is_finite()
+        || !a.box_width.is_finite()
+        || !a.box_height.is_finite()
+        || a.styles.len() > MAX_GLYPHS
+    {
+        return None;
+    }
+    let chars = t.char_count();
+    let mut bytes = 0usize;
+    let glyphs = a
+        .glyphs
+        .iter()
+        .map(|g| {
+            bytes = bytes.saturating_add(g.d.len());
+            if bytes > MAX_PATH_BYTES
+                || g.style >= a.styles.len()
+                || g.char_start > g.char_end
+                || g.char_end > chars
+                || ![g.x, g.y, g.size, g.advance].iter().all(|v| v.is_finite())
+            {
+                return None;
+            }
+            let path = BezPath::from_svg(&g.d).ok()?;
+            if !path.is_finite() {
+                return None;
+            }
+            Some(Glyph {
+                face: None,
+                saved_path: Some(Arc::new(path)),
+                coords: no_coords(),
+                gid: 0,
+                x: g.x,
+                y: g.y,
+                size: g.size,
+                advance: g.advance,
+                char_start: g.char_start,
+                char_end: g.char_end,
+                style: g.style,
+                rtl: g.rtl,
+                warp: Affine::IDENTITY,
+            })
+        })
+        .collect::<Option<Vec<_>>>()?;
+    let mut previous_end = 0;
+    let lines = a
+        .lines
+        .iter()
+        .map(|l| {
+            if l.glyph_start < previous_end
+                || l.glyph_start > l.glyph_end
+                || l.glyph_end > glyphs.len()
+                || l.char_start > l.char_end
+                || l.char_end > chars
+                || ![l.top, l.height, l.baseline, l.width, l.x].iter().all(|v| v.is_finite())
+            {
+                return None;
+            }
+            previous_end = l.glyph_end;
+            Some(Line {
+                glyph_start: l.glyph_start,
+                glyph_end: l.glyph_end,
+                char_start: l.char_start,
+                char_end: l.char_end,
+                top: l.top,
+                height: l.height,
+                baseline: l.baseline,
+                width: l.width,
+                x: l.x,
+                para_end: l.para_end,
+                break_after: l.break_after,
+                rtl: l.rtl,
+            })
+        })
+        .collect::<Option<Vec<_>>>()?;
+    let decorations = a
+        .decorations
+        .iter()
+        .map(|d| {
+            if d.style >= a.styles.len() || !d.rect.iter().all(|v| v.is_finite()) || !d.thickness.is_finite() {
+                return None;
+            }
+            Some(Decoration { rect: Rect::new(d.rect[0], d.rect[1], d.rect[2], d.rect[3]), style: d.style, kind: d.kind, thickness: d.thickness })
+        })
+        .collect::<Option<Vec<_>>>()?;
+    // Saved geometry owns drawing styles, never hyperlink destinations. Resolve current
+    // editable metadata by character span, including links changed after capture.
+    let mut glyphs = glyphs;
+    let mut styles = a.styles.clone();
+    for style in &mut styles {
+        style.link = None;
+        style.style_id = None;
+    }
+    let spans = t.spans();
+    let mut mapped = std::collections::BTreeMap::new();
+    for glyph in &mut glyphs {
+        let index = spans.partition_point(|(_, end, _)| *end <= glyph.char_start).min(spans.len().saturating_sub(1));
+        let Some((_, _, current)) = spans.get(index) else { continue };
+        if current.link.is_none() && current.style_id.is_none() {
+            continue;
+        }
+        let mut style = styles.get(glyph.style).cloned()?;
+        let key = (glyph.style, index);
+        glyph.style = *mapped.entry(key).or_insert_with(|| {
+            style.link = current.link.clone();
+            style.style_id = current.style_id.clone();
+            let id = styles.len();
+            styles.push(style);
+            id
+        });
+    }
+    Some(TextLayout { glyphs, lines, decorations, styles, width: a.width, height: a.height, truncated: a.truncated, on_path: None })
+}
+
+/// Add/update appearances on machines with the original fonts. Existing valid geometry is
+/// retained on a guest missing fonts. Returns the number of changed text layers.
+pub fn capture(doc: &mut Document, ids: Option<&[figcraft_doc::NodeId]>) -> usize {
+    let ids = ids.map_or_else(|| doc.nodes().map(|n| n.id).collect::<Vec<_>>(), |v| v.to_vec());
+    let mut changed = 0;
+    for id in ids {
+        let Some(n) = doc.node(id) else { continue };
+        let NodeKind::Text(t) = &n.kind else { continue };
+        let Some(key) = fingerprint(t, Some(n.width), Some(n.height)) else { continue };
+        if t.appearance.as_ref().is_some_and(|a| a.fingerprint == key) || !fonts_available(t) {
+            continue;
+        }
+        let Some((l, _)) = crate::layout_node(n) else { continue };
+        if l.glyphs.len() > MAX_GLYPHS || l.glyphs.iter().any(|g| g.color(1.0).is_some()) {
+            continue;
+        }
+        let glyphs: Vec<_> = l
+            .glyphs
+            .iter()
+            .map(|g| TextGlyph {
+                d: g.path().to_svg(),
+                x: g.x,
+                y: g.y,
+                size: g.size,
+                advance: g.advance,
+                char_start: g.char_start,
+                char_end: g.char_end,
+                style: g.style,
+                rtl: g.rtl,
+            })
+            .collect();
+        if glyphs.iter().map(|g| g.d.len()).sum::<usize>() > MAX_PATH_BYTES {
+            continue;
+        }
+        let a = TextAppearance {
+            fingerprint: key,
+            box_width: n.width,
+            box_height: n.height,
+            width: l.width,
+            height: l.height,
+            glyphs,
+            lines: l
+                .lines
+                .iter()
+                .map(|v| TextLine {
+                    glyph_start: v.glyph_start,
+                    glyph_end: v.glyph_end,
+                    char_start: v.char_start,
+                    char_end: v.char_end,
+                    top: v.top,
+                    height: v.height,
+                    baseline: v.baseline,
+                    width: v.width,
+                    x: v.x,
+                    para_end: v.para_end,
+                    break_after: v.break_after,
+                    rtl: v.rtl,
+                })
+                .collect(),
+            decorations: l
+                .decorations
+                .iter()
+                .map(|d| TextDecorationPath {
+                    rect: [d.rect.x0, d.rect.y0, d.rect.x1, d.rect.y1],
+                    style: d.style,
+                    kind: d.kind,
+                    thickness: d.thickness,
+                })
+                .collect(),
+            styles: l.styles.clone(),
+            truncated: l.truncated,
+        };
+        if let Ok(n) = doc.get_mut(id)
+            && let NodeKind::Text(t) = &mut n.kind
+        {
+            t.appearance = Some(Arc::new(a));
+            changed += 1;
+        }
+    }
+    changed
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn synthetic() -> TextData {
+        let mut t = TextData::new("X");
+        t.style.font_family = "Synthetic absent portable font".into();
+        let a = TextAppearance {
+            fingerprint: fingerprint(&t, Some(80.0), Some(40.0)).unwrap(),
+            box_width: 80.0,
+            box_height: 40.0,
+            width: 25.0,
+            height: 40.0,
+            glyphs: vec![TextGlyph {
+                d: "M0 0L25 0L25 40Z".into(),
+                x: 0.0,
+                y: 30.0,
+                size: 40.0,
+                advance: 25.0,
+                char_start: 0,
+                char_end: 1,
+                style: 0,
+                rtl: false,
+            }],
+            lines: vec![TextLine {
+                glyph_start: 0,
+                glyph_end: 1,
+                char_start: 0,
+                char_end: 1,
+                top: 0.0,
+                height: 40.0,
+                baseline: 30.0,
+                width: 25.0,
+                x: 0.0,
+                para_end: true,
+                break_after: false,
+                rtl: false,
+            }],
+            decorations: vec![],
+            styles: vec![t.style.clone()],
+            truncated: false,
+        };
+        t.appearance = Some(Arc::new(a));
+        t
+    }
+
+    #[test]
+    #[cfg(not(target_arch = "wasm32"))]
+    fn warm_missing_face_check_does_not_require_a_database_write_lock() {
+        let t = synthetic();
+        let database = Arc::new(std::sync::RwLock::new(crate::FontDb::default()));
+        assert!(!fonts_available_in(&database, &t));
+        let read = database.read().unwrap();
+        let worker_database = database.clone();
+        let (tx, rx) = std::sync::mpsc::channel();
+        let worker = std::thread::spawn(move || tx.send(fonts_available_in(&worker_database, &t)).unwrap());
+        let result = rx.recv_timeout(std::time::Duration::from_secs(5));
+        // Always release and join, even if the regression needs the write lock.
+        drop(read);
+        worker.join().unwrap();
+        assert!(!result.unwrap());
+    }
+
+    #[test]
+    fn editing_inputs_and_fingerprinting_share_large_geometry() {
+        let t = synthetic();
+        let cloned = t.clone();
+        assert!(Arc::ptr_eq(t.appearance.as_ref().unwrap(), cloned.appearance.as_ref().unwrap()));
+        assert!(has_saved(&cloned, Some(80.0), Some(40.0)));
+        assert_eq!(Arc::strong_count(t.appearance.as_ref().unwrap()), 2);
+    }
+
+    #[test]
+    fn style_bindings_and_links_do_not_invalidate_saved_geometry() {
+        let mut t = synthetic();
+        t.auto_resize = TextAutoResize::None;
+        t.runs.push(figcraft_doc::StyleRun { start: 0, end: 1, style: t.style.clone() });
+        Arc::make_mut(t.appearance.as_mut().unwrap()).fingerprint = fingerprint(&t, Some(80.0), Some(40.0)).unwrap();
+        t.style.style_id = Some("shared-style".into());
+        t.style.link = Some("https://example.org/base".into());
+        t.runs[0].style.style_id = Some("run-style".into());
+        t.runs[0].style.link = Some("https://example.org/run".into());
+        assert!(restore(&t, Some(80.0), Some(40.0)).is_some());
+        let mut changed = t.clone();
+        changed.style.font_size += 1.0;
+        assert!(!has_saved(&changed, Some(80.0), Some(40.0)));
+        let mut changed = t.clone();
+        changed.runs[0].style.fills = Some(vec![]);
+        assert!(!has_saved(&changed, Some(80.0), Some(40.0)));
+        assert!(!has_saved(&t, Some(160.0), Some(40.0)));
+    }
+
+    #[test]
+    fn current_layout_box_invalidates_stale_geometry_but_ignores_non_layout_dimensions() {
+        for mode in [TextAutoResize::None, TextAutoResize::Height, TextAutoResize::Truncate, TextAutoResize::WidthAndHeight] {
+            let mut t = synthetic();
+            t.auto_resize = mode;
+            Arc::make_mut(t.appearance.as_mut().unwrap()).fingerprint = fingerprint(&t, Some(80.0), Some(40.0)).unwrap();
+            assert!(has_saved(&t, Some(80.0), Some(40.0)));
+            let width_matters = mode != TextAutoResize::WidthAndHeight;
+            assert_eq!(has_saved(&t, Some(160.0), Some(40.0)), !width_matters);
+            assert_eq!(restore(&t, Some(160.0), Some(40.0)).is_some(), !width_matters);
+            let height_matters = mode == TextAutoResize::Truncate;
+            assert_eq!(has_saved(&t, Some(80.0), Some(90.0)), !height_matters);
+            assert_eq!(restore(&t, Some(80.0), Some(90.0)).is_some(), !height_matters);
+        }
+    }
+
+    #[test]
+    fn saved_geometry_is_validated_and_never_reused_for_changed_text() {
+        let mut t = synthetic();
+        assert_eq!(restore(&t, Some(80.0), Some(40.0)).unwrap().to_path().to_svg(), "M0,0 L25,0 L25,40 Z");
+        t.characters = "Y".into();
+        assert!(restore(&t, Some(80.0), Some(40.0)).is_none());
+        let mut t = synthetic();
+        Arc::make_mut(t.appearance.as_mut().unwrap()).glyphs[0].char_end = 100;
+        assert!(restore(&t, Some(80.0), Some(40.0)).is_none());
+        let mut t = synthetic();
+        Arc::make_mut(t.appearance.as_mut().unwrap()).glyphs[0].style = 100;
+        assert!(restore(&t, Some(80.0), Some(40.0)).is_none());
+        let mut t = synthetic();
+        Arc::make_mut(t.appearance.as_mut().unwrap()).lines[0].glyph_end = 100;
+        assert!(restore(&t, Some(80.0), Some(40.0)).is_none());
+    }
+
+    #[test]
+    fn restored_links_follow_current_character_spans_not_saved_styles() {
+        let mut t = synthetic();
+        t.characters = "XY".into();
+        t.runs.push(figcraft_doc::StyleRun { start: 1, end: 2, style: t.style.clone() });
+        let key = fingerprint(&t, Some(80.0), Some(40.0)).unwrap();
+        let a = Arc::make_mut(t.appearance.as_mut().unwrap());
+        a.fingerprint = key;
+        a.styles[0].link = Some("https://example.org/stale".into());
+        let mut second = a.glyphs[0].clone();
+        second.char_start = 1;
+        second.char_end = 2;
+        a.glyphs.push(second);
+        a.lines[0].glyph_end = 2;
+        a.lines[0].char_end = 2;
+        t.runs[0].style.link = Some("https://example.org/current".into());
+        let layout = restore(&t, Some(80.0), Some(40.0)).unwrap();
+        assert!(layout.styles[layout.glyphs[0].style].link.is_none());
+        assert_eq!(layout.styles[layout.glyphs[1].style].link.as_deref(), Some("https://example.org/current"));
+        assert!(layout.styles.iter().all(|s| s.link.as_deref() != Some("https://example.org/stale")));
+        t.runs[0].style.link = None;
+        let layout = restore(&t, Some(80.0), Some(40.0)).unwrap();
+        assert!(layout.styles.iter().all(|s| s.link.is_none()));
+    }
+
+    #[test]
+    fn restored_line_ranges_cannot_multiply_glyph_traversal() {
+        let mut t = synthetic();
+        let a = Arc::make_mut(t.appearance.as_mut().unwrap());
+        a.lines = vec![a.lines[0].clone(); MAX_GLYPHS];
+        assert!(restore(&t, Some(80.0), Some(40.0)).is_none());
+        let a = Arc::make_mut(t.appearance.as_mut().unwrap());
+        a.lines.truncate(2);
+        a.lines[1].glyph_start = 1;
+        a.lines[1].glyph_end = 1;
+        assert!(restore(&t, Some(80.0), Some(40.0)).is_some(), "empty trailing lines are valid");
+    }
+}
diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs
--- a/crates/text/src/behaviour_tests.rs
+++ b/crates/text/src/behaviour_tests.rs
@@ -295,9 +295,14 @@ fn hangul_comes_from_a_korean_font_when_one_is_installed() {
         let hangul: Vec<_> = l.glyphs.iter().filter(|g| g.char_start >= 3).collect();
         assert!(!hangul.is_empty());
         for g in &hangul {
-            assert!(g.face.has_char('\u{D55C}'), "{style}: drawn by {} {}, which has no Hangul", g.face.family, g.face.style);
+            assert!(
+                g.face.as_ref().unwrap().has_char('\u{D55C}'),
+                "{style}: drawn by {} {}, which has no Hangul",
+                g.face.as_ref().unwrap().family,
+                g.face.as_ref().unwrap().style
+            );
         }
-        let used = &hangul[0].face;
+        let used = hangul[0].face.as_ref().unwrap();
         let nearest = find_face(&used.family, style).map_or(used.weight, |n| n.weight);
         assert_eq!(used.weight, nearest, "{style}: the fallback face is the one nearest the run's style");
     }
@@ -325,9 +330,15 @@ fn thai_comes_from_a_thai_font_when_one_is_installed() {
     let glyphs: Vec<_> = l.glyphs.iter().filter(|g| chars.get(g.char_start).is_some_and(is_thai)).collect();
     assert!(!glyphs.is_empty());
     for g in &glyphs {
-        assert!(g.gid != 0, "char {}: missing glyph in {} {}", g.char_start, g.face.family, g.face.style);
+        assert!(g.gid != 0, "char {}: missing glyph in {} {}", g.char_start, g.face.as_ref().unwrap().family, g.face.as_ref().unwrap().style);
         let cluster = chars.get(g.char_start..g.char_end).unwrap_or(&[]);
-        assert!(cluster.iter().all(|c| g.face.has_char(*c)), "chars {}..{}: drawn by {}, which lacks some", g.char_start, g.char_end, g.face.family);
+        assert!(
+            cluster.iter().all(|c| g.face.as_ref().unwrap().has_char(*c)),
+            "chars {}..{}: drawn by {}, which lacks some",
+            g.char_start,
+            g.char_end,
+            g.face.as_ref().unwrap().family
+        );
     }
     let drawn: std::collections::BTreeSet<usize> = glyphs.iter().flat_map(|g| g.char_start..g.char_end).collect();
     let want: std::collections::BTreeSet<usize> = (3..chars.len()).filter(|i| chars[*i] != ' ').collect();
@@ -351,7 +362,13 @@ fn symbols_come_from_a_symbol_font_when_one_is_installed() {
         let drawn: Vec<_> = l.glyphs.iter().filter(|g| g.char_start == 2).collect();
         assert!(!drawn.is_empty(), "U+{:04X} has a glyph", c as u32);
         for g in drawn {
-            assert!(g.face.has_char(c), "U+{:04X} drawn by {} {}, which lacks it", c as u32, g.face.family, g.face.style);
+            assert!(
+                g.face.as_ref().unwrap().has_char(c),
+                "U+{:04X} drawn by {} {}, which lacks it",
+                c as u32,
+                g.face.as_ref().unwrap().family,
+                g.face.as_ref().unwrap().style
+            );
         }
     }
 }
@@ -494,7 +511,7 @@ fn a_cluster_is_shaped_with_one_face() {
     // Wherever the base char's face is, its marks and joiners go with it (one glyph run).
     for s in ["e\u{0301}", "\u{1F468}\u{200D}\u{1F469}", "漢\u{FE00}"] {
         let l = layout(&text(s, TextAutoResize::WidthAndHeight), None, None);
-        let faces: std::collections::HashSet<u32> = l.glyphs.iter().map(|g| g.face.id).collect();
+        let faces: std::collections::HashSet<u32> = l.glyphs.iter().map(|g| g.face.as_ref().unwrap().id).collect();
         assert_eq!(faces.len(), 1, "{s:?}");
     }
 }
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
@@ -361,7 +361,7 @@ fn tag_of(s: &str) -> Option<skrifa::Tag> {
     <[u8; 4]>::try_from(s.as_bytes()).ok().map(|b| skrifa::Tag::new(&b))
 }
 
-fn norm_style(s: &str) -> String {
+pub(crate) fn norm_style(s: &str) -> String {
     s.to_lowercase().replace([' ', '-', '_'], "")
 }
 
@@ -507,6 +507,7 @@ struct Pending {
 
 #[derive(Default)]
 pub struct FontDb {
+    named_available: HashMap<(String, String), bool>,
     faces: Vec<Arc<Face>>,
     /// Lower-case family → (display family, face indices).
     families: BTreeMap<String, (String, Vec<usize>)>,
@@ -562,6 +563,7 @@ fn raw_db() -> &'static RwLock<FontDb> {
 
 impl FontDb {
     pub fn add(&mut self, face: Face) -> Arc<Face> {
+        self.named_available.clear();
         let key = face.family.to_lowercase();
         let display = face.family.clone();
         let face = Arc::new(face);
@@ -694,6 +696,38 @@ impl FontDb {
         }
     }
 
+    /// A warm exact-face check requires only the database read lock.
+    pub(crate) fn cached_named_face(&self, family: &str, style: &str) -> Option<bool> {
+        self.named_available.get(&(family.to_string(), style.to_string())).copied()
+    }
+
+    /// Check named styles without accepting a nearest-style substitute. Missing results are
+    /// cached too; adding fonts or rescanning invalidates both positive and negative entries.
+    pub(crate) fn named_face_available(&mut self, family: &str, style: &str) -> bool {
+        if let Some(v) = self.cached_named_face(family, style) {
+            return v;
+        }
+        let available = self.find(family, style).is_some_and(|f| {
+            let italic = style.to_lowercase().contains("italic");
+            let weight = f64::from(figcraft_doc::style_weight(style));
+            let variable_weight = f.axes.iter().any(|a| a.tag == "wght" && a.min <= weight && weight <= a.max);
+            let italic_value = if italic { 1.0 } else { 0.0 };
+            let variable_italic = f.italic == italic
+                || f.axes
+                    .iter()
+                    .any(|a| (a.tag == "ital" && a.min <= italic_value && italic_value <= a.max) || (italic && a.tag == "slnt" && a.min < 0.0));
+            norm_style(&f.style) == norm_style(style) || f.instance(style).is_some() || (variable_weight && variable_italic)
+        });
+        // Bound retained name bytes as well as the entry count for untrusted documents.
+        if family.len().saturating_add(style.len()) <= 1024 {
+            if self.named_available.len() >= 4096 {
+                self.named_available.clear();
+            }
+            self.named_available.insert((family.to_string(), style.to_string()), available);
+        }
+        available
+    }
+
     /// The best face for `family` + `style` (exact style name, else nearest weight/italic).
     pub fn find(&mut self, family: &str, style: &str) -> Option<Arc<Face>> {
         self.load_family(family, None);
@@ -784,6 +818,7 @@ impl FontDb {
     /// family not loaded yet takes the walk's files, and a loaded family keeps its faces and gets
     /// the files it hasn't read (styles installed since) as pending, read when it is next used.
     fn merge_rescan(&mut self, found: BTreeMap<String, (String, Vec<Pending>)>) -> Rescan {
+        self.named_available.clear();
         let mut out = Rescan::default();
         for (k, (display, entries)) in found {
             if self.families.contains_key(&k) {
@@ -825,6 +860,7 @@ impl FontDb {
     /// its faces, which win, and the installed styles it lacks wait beside them (#1246: release
     /// builds embed Poppins Regular, Italic and Bold, so an installed Poppins SemiBold went unseen).
     fn merge_scan(&mut self, found: BTreeMap<String, (String, Vec<Pending>)>) {
+        self.named_available.clear();
         for (k, (display, mut entries)) in found {
             if let Some((_, idx)) = self.families.get(&k) {
                 let have: Vec<&Arc<Face>> = idx.iter().filter_map(|i| self.faces.get(*i)).collect();
diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs
--- a/crates/text/src/fontdb_tests.rs
+++ b/crates/text/src/fontdb_tests.rs
@@ -491,3 +491,24 @@ fn core_text_lists_the_installed_font_files() {
     assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts")), "{} files", files.len());
     assert!(files.iter().all(|f| f.is_absolute()));
 }
+
+#[test]
+fn named_face_checks_cache_missing_results_and_invalidate_after_scan() {
+    let mut db = FontDb::default();
+    assert_eq!(db.cached_named_face("Missing portable font", "Regular"), None);
+    assert!(!db.named_face_available("Missing portable font", "Regular"));
+    assert_eq!(db.cached_named_face("Missing portable font", "Regular"), Some(false));
+    db.merge_scan(BTreeMap::new());
+    assert_eq!(db.cached_named_face("Missing portable font", "Regular"), None);
+    assert!(!db.named_face_available("Missing portable font", "Regular"));
+    db.merge_rescan(BTreeMap::new());
+    assert_eq!(db.cached_named_face("Missing portable font", "Regular"), None);
+}
+
+#[test]
+fn named_face_cache_does_not_retain_oversized_names() {
+    let mut db = FontDb::default();
+    let family = "X".repeat(1025);
+    assert!(!db.named_face_available(&family, "Regular"));
+    assert!(db.named_available.is_empty());
+}
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
@@ -16,7 +16,9 @@ use crate::fontdb::{Coords, Face, db, no_coords};
 /// A positioned glyph (pen position on the baseline, layout coordinates, y down).
 #[derive(Clone, Debug)]
 pub struct Glyph {
-    pub face: Arc<Face>,
+    pub face: Option<Arc<Face>>,
+    /// Placed outline from the document when the original face is unavailable.
+    pub saved_path: Option<Arc<BezPath>>,
     /// Variable font coordinates (empty = the face's default instance).
     pub coords: Coords,
     pub gid: u32,
@@ -38,13 +40,14 @@ pub struct Glyph {
 impl Glyph {
     /// The glyph outline placed in layout coordinates.
     pub fn path(&self) -> BezPath {
-        self.layer_path(self.gid)
+        self.saved_path.as_ref().map_or_else(|| self.layer_path(self.gid), |p| (**p).clone())
     }
 
     /// The outline of one of the face's glyphs (a COLR layer), placed like this glyph.
     pub fn layer_path(&self, gid: u32) -> BezPath {
-        let s = self.size / self.face.upem;
-        let mut p = (*self.face.outline_at(gid, &self.coords)).clone();
+        let Some(face) = &self.face else { return BezPath::new() };
+        let s = self.size / face.upem;
+        let mut p = (*face.outline_at(gid, &self.coords)).clone();
         p.apply_affine(self.warp * Affine::new([s, 0.0, 0.0, s, self.x, self.y]));
         p
     }
@@ -56,12 +59,13 @@ impl Glyph {
         if !scale.is_finite() || scale <= 0.0 {
             return None;
         }
-        if let Some(layers) = self.face.color_layers(self.gid) {
+        let face = self.face.as_ref()?;
+        if let Some(layers) = face.color_layers(self.gid) {
             return Some(ColorInk::Layers(layers));
         }
         // Whole device pixels per em: rasters stay on the pixel grid and the cache stays small.
         let px = (self.size * scale).round().clamp(1.0, MAX_PX_PER_EM);
-        let bitmap = self.face.color_bitmap(self.gid, &self.coords, px)?;
+        let bitmap = face.color_bitmap(self.gid, &self.coords, px)?;
         let s = px / self.size;
         let place = self.warp * Affine::translate((self.x + bitmap.left / s, self.y + bitmap.top / s)) * Affine::scale(1.0 / s);
         Some(ColorInk::Bitmap(bitmap, place))
@@ -325,6 +329,9 @@ pub(crate) fn visual_order(levels: &[u8]) -> Vec<usize> {
 /// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width
 /// pass `None`). `box_height` is used for truncation only.
 pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) -> TextLayout {
+    if let Some(saved) = crate::appearance::restore(text, box_width, box_height) {
+        return saved;
+    }
     let chars: Vec<char> = text.characters.chars().collect();
     let spans = text.spans();
     let mut styles: Vec<TextStyle> = Vec::new();
@@ -551,7 +558,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
     let glyph_at = |it: &Item, pen: f64, baseline: f64| {
         let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
         (it.gid != 0 || !it.is_space()).then(|| Glyph {
-            face: it.face.clone(),
+            face: Some(it.face.clone()),
+            saved_path: None,
             coords: it.coords.clone(),
             gid: it.gid,
             x: pen + it.dx,
@@ -673,7 +681,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
                     let gid = face.glyph_for(c);
                     let adv = face.advance_at(gid, &coords) * k;
                     out.glyphs.push(Glyph {
-                        face: face.clone(),
+                        face: Some(face.clone()),
+                        saved_path: None,
                         coords: coords.clone(),
                         gid,
                         x: mx,
@@ -845,7 +854,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
                 let x1 = last.x + last.advance;
                 // The style's own font and size (not a fallback face or a small-cap scale), so the
                 // line runs at one height and weight through mixed scripts.
-                let f = faces.get(g0.style).and_then(Option::as_ref).unwrap_or(&g0.face);
+                let Some(f) = faces.get(g0.style).and_then(Option::as_ref).or(g0.face.as_ref()) else { continue };
                 let size = sanitize_size(st.font_size);
                 let s = size / f.upem;
                 let look = st.look();
@@ -1031,7 +1040,7 @@ fn ink_cuts(glyphs: &[Glyph], y0: f64, y1: f64, gap: f64) -> Vec<(f64, f64)> {
 fn add_ellipsis(out: &mut TextLayout, wrap: Option<f64>) {
     let Some(line) = out.lines.last().cloned() else { return };
     let Some(last) = out.glyphs.get(line.glyph_end.saturating_sub(1)).cloned() else { return };
-    let face = last.face.clone();
+    let Some(face) = last.face.clone() else { return };
     let gid = face.glyph_for('…');
     let gid = if gid == 0 { face.glyph_for('.') } else { gid };
     let k = last.size / face.upem;
@@ -1041,12 +1050,15 @@ fn add_ellipsis(out: &mut TextLayout, wrap: Option<f64>) {
     while out.glyphs.len() > line.glyph_start && out.glyphs.last().is_some_and(|g| g.x + g.advance + adv > limit) {
         out.glyphs.pop();
     }
-    while out.glyphs.len() > line.glyph_start && out.glyphs.last().is_some_and(|g| g.char_end > g.char_start && g.gid == g.face.glyph_for(' ')) {
+    while out.glyphs.len() > line.glyph_start
+        && out.glyphs.last().is_some_and(|g| g.char_end > g.char_start && g.face.as_ref().is_some_and(|f| g.gid == f.glyph_for(' ')))
+    {
         out.glyphs.pop();
     }
     let x = out.glyphs.last().filter(|_| out.glyphs.len() > line.glyph_start).map(|g| g.x + g.advance).unwrap_or(0.0);
     out.glyphs.push(Glyph {
-        face,
+        face: Some(face),
+        saved_path: None,
         coords: last.coords.clone(),
         gid,
         x,
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
@@ -6,6 +6,7 @@
 //! fills [`Glyph::path`]s and the editor uses [`TextLayout::caret`] / [`TextLayout::hit`].
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
 
+pub mod appearance;
 mod breaks;
 pub mod color;
 pub mod display;
@@ -133,6 +134,12 @@ pub fn auto_size(n: &Node) -> Option<(f64, f64)> {
     if t.path.is_some() {
         return None;
     }
+    if appearance::has_saved(t, Some(n.width), Some(n.height)) && !appearance::fonts_available(t) {
+        return match t.auto_resize {
+            TextAutoResize::WidthAndHeight | TextAutoResize::Height => Some((n.width, n.height)),
+            TextAutoResize::None | TextAutoResize::Truncate => None,
+        };
+    }
     match t.auto_resize {
         TextAutoResize::WidthAndHeight => {
             let l = layout_cached(t, None, None);
diff --git a/crates/text/src/list_tests.rs b/crates/text/src/list_tests.rs
--- a/crates/text/src/list_tests.rs
+++ b/crates/text/src/list_tests.rs
@@ -16,7 +16,7 @@ fn markers_use_the_paragraph_font() {
         eprintln!("no fonts; skipping");
         return;
     }
-    assert_eq!(markers[0].face.id, markers[1].face.id, "both bullets in one font");
+    assert_eq!(markers[0].face.as_ref().unwrap().id, markers[1].face.as_ref().unwrap().id, "both bullets in one font");
     assert_eq!(markers[0].gid, markers[1].gid);
     // The Hebrew paragraph's bullet is on its right (see `rtl_list_tests`).
     assert!(markers[1].x > markers[0].x);
diff --git a/crates/text/src/rtl_list_tests.rs b/crates/text/src/rtl_list_tests.rs
--- a/crates/text/src/rtl_list_tests.rs
+++ b/crates/text/src/rtl_list_tests.rs
@@ -45,7 +45,7 @@ fn marker(l: &TextLayout, li: usize) -> Vec<&crate::Glyph> {
 /// The left and right ends of line `li`'s visible text glyphs (not spaces).
 fn text_span(l: &TextLayout, li: usize) -> (f64, f64) {
     let ln = &l.lines[li];
-    let g = l.glyphs[ln.glyph_start..ln.glyph_end].iter().filter(|g| g.char_end > g.char_start && g.gid != g.face.glyph_for(' '));
+    let g = l.glyphs[ln.glyph_start..ln.glyph_end].iter().filter(|g| g.char_end > g.char_start && g.gid != g.face.as_ref().unwrap().glyph_for(' '));
     g.fold((f64::MAX, f64::MIN), |(a, b), g| (a.min(g.x), b.max(g.x + g.advance)))
 }
 
@@ -83,8 +83,8 @@ fn numbers_read_with_the_full_stop_on_their_left() {
         let m = marker(&l, li);
         // ".1", ".2", ".3": the full stop first from the left, then the digit.
         assert_eq!(m.len(), 2);
-        assert_eq!(m[0].gid, m[0].face.glyph_for('.'));
-        assert_eq!(m[1].gid, m[1].face.glyph_for(char::from(b'1' + li as u8)));
+        assert_eq!(m[0].gid, m[0].face.as_ref().unwrap().glyph_for('.'));
+        assert_eq!(m[1].gid, m[1].face.as_ref().unwrap().glyph_for(char::from(b'1' + li as u8)));
         assert!(m[0].x < m[1].x);
         // Half an em between the text and the number; the numbers start at one x.
         let (_, text_right) = text_span(&l, li);
diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs
--- a/crates/text/src/tests.rs
+++ b/crates/text/src/tests.rs
@@ -313,7 +313,7 @@ fn variable_font_axes_change_shaping_and_outlines() {
     let light = layout(&t, None, None);
     t.style.variations.insert("wght".into(), wght.max);
     let heavy = layout(&t, None, None);
-    assert!(heavy.glyphs.iter().filter(|g| g.face.id == face.id).all(|g| !g.coords.is_empty()), "glyphs carry the instance");
+    assert!(heavy.glyphs.iter().filter(|g| g.face.as_ref().unwrap().id == face.id).all(|g| !g.coords.is_empty()), "glyphs carry the instance");
     let first = |l: &TextLayout| l.glyphs.first().map(|g| g.path().elements().to_vec());
     assert_ne!(first(&light), first(&heavy), "the weight axis changes the outlines");
     assert!(heavy.width >= light.width, "heavier is not narrower: {} < {}", heavy.width, light.width);
@@ -532,7 +532,7 @@ fn lower_case_writes_final_sigma() {
     }
     let gids = |t: &TextData| layout(t, None, None).glyphs.iter().map(|g| (g.gid, g.char_start)).collect::<Vec<_>>();
     let want = gids(&TextData::new("οδος ας"));
-    let Some(face) = layout(&TextData::new("ς"), None, None).glyphs.first().map(|g| g.face.clone()) else { return };
+    let Some(face) = layout(&TextData::new("ς"), None, None).glyphs.first().and_then(|g| g.face.clone()) else { return };
     if face.glyph_for('σ') == face.glyph_for('ς') {
         eprintln!("face has no distinct final sigma; skipping");
         return;
diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml
--- a/crates/ui-egui/locales/bn.toml
+++ b/crates/ui-egui/locales/bn.toml
@@ -1439,7 +1439,8 @@ status = "draft — needs native review"
 "This condition is edited as an expression" = "এই শর্তটি এক্সপ্রেশন হিসেবে এডিট করা হয়েছে"
 "This file" = "এই ফাইল"
 "This file has unsaved changes" = "এই ফাইলে সেভ করা হয়নি এমন পরিবর্তন রয়েছে"
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "এই ফাইলে এমন ফন্ট ব্যবহার করা হয়েছে যা এই কম্পিউটারে ইনস্টল করা নেই। আপনি ইনস্টল না করা পর্যন্ত সেগুলোর টেক্সট একটি ফলব্যাক ফন্টে দেখানো হয়।"
+# Updated warning: AI-assisted draft; native review pending.
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "এই ফাইলে এমন ফন্ট ব্যবহার করা হয়েছে যা এই কম্পিউটারে ইনস্টল করা নেই। সংরক্ষিত টেক্সটের আউটলাইন থাকলে সেগুলি লেখার চেহারা বজায় রাখে। টেক্সট সম্পাদনার আগে অনুপস্থিত ফন্টগুলি ইনস্টল করুন বা বিকল্প ফন্ট বেছে নিন।"
 "This file is on {server}, open only to you. Share it to let others in with a new code." = "এই ফাইলটি {server}-এ আছে, শুধুমাত্র আপনার জন্য খোলা। নতুন কোড দিয়ে অন্যদের প্রবেশ করাতে এটি শেয়ার করুন।"
 "This frame is new on the branch." = "এই ফ্রেমটি ব্রাঞ্চে নতুন।"
 "This frame isn't on the branch." = "এই ফ্রেমটি ব্রাঞ্চে নেই।"
diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml
--- a/crates/ui-egui/locales/en.toml
+++ b/crates/ui-egui/locales/en.toml
@@ -1482,7 +1482,7 @@ name = "English"
 "This file" = "This file"
 "This file has unsaved changes" = "This file has unsaved changes"
 "This file is too large to join here (up to {mb} MB)" = "This file is too large to join here (up to {mb} MB)"
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them."
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text."
 "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code."
 "This frame is new on the branch." = "This frame is new on the branch."
 "This frame isn't on the branch." = "This frame isn't on the branch."
diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml
--- a/crates/ui-egui/locales/es.toml
+++ b/crates/ui-egui/locales/es.toml
@@ -1476,7 +1476,7 @@ status = "draft — needs native review"
 "This file" = "Este archivo"
 "This file has unsaved changes" = "Este archivo tiene cambios sin guardar"
 "This file is too large to join here (up to {mb} MB)" = "Este archivo es demasiado grande para unirte aquí (hasta {mb} MB)"
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales."
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "Este archivo usa fuentes que no están instaladas en este equipo. Los contornos de texto guardados conservan su aspecto cuando están disponibles. Instala las fuentes que faltan o elige sustitutas antes de editar el texto."
 "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo."
 "This frame is new on the branch." = "Este marco es nuevo en la rama."
 "This frame isn't on the branch." = "Este marco no está en la rama."
@@ -2297,6 +2297,7 @@ status = "draft — needs native review"
 "text.paragraphSpacing" = "Espacio entre párrafos"
 "text.pathOptions" = "Opciones de texto en trazado"
 "text.preview" = "Previsualizar cambio de texto"
+"text.preserveAppearance" = "Conservar el aspecto del texto"
 "text.refit" = "Ajustar texto a las fuentes"
 "text.removeFromPath" = "Quitar texto del trazado"
 "text.rescanFonts" = "Volver a buscar fuentes instaladas"
diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml
--- a/crates/ui-egui/locales/fi.toml
+++ b/crates/ui-egui/locales/fi.toml
@@ -1445,7 +1445,8 @@ status = "draft — needs native review"
 "This file" = "Tämä tiedosto"
 "This file has unsaved changes" = "Tässä tiedostossa on tallentamattomia muutoksia"
 "This file is on {server}, open only to you. Share it to let others in with a new code." = "Tämä tiedosto on palvelimella {server} vain sinun käytössäsi. Jaa se, jotta muut voivat liittyä uudella koodilla."
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Tämä tiedosto käyttää fontteja, joita ei ole asennettu tälle tietokoneelle. Teksti piirretään korvaavalla fontilla, kunnes asennat puuttuvat fontit."
+# Updated warning: AI-assisted draft; native review pending.
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "Tämä tiedosto käyttää fontteja, joita ei ole asennettu tälle tietokoneelle. Tallennetut tekstin ääriviivat säilyttävät sen ulkoasun, kun ne ovat saatavilla. Asenna puuttuvat fontit tai valitse korvaavat fontit ennen tekstin muokkaamista."
 "This frame is new on the branch." = "Tämä kehys on uusi haarassa."
 "This frame isn't on the branch." = "Tätä kehystä ei ole haarassa."
 "This server is open to the public: no key needed." = "Tämä palvelin on julkinen: avainta ei tarvita."
diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml
--- a/crates/ui-egui/locales/it.toml
+++ b/crates/ui-egui/locales/it.toml
@@ -1392,7 +1392,8 @@ status = "draft — needs native review"
 "This file" = "Questo file"
 "This file has unsaved changes" = "Questo file ha modifiche non salvate"
 "This file is too large to join here (up to {mb} MB)" = "Questo file è troppo grande per parteciparvi da qui (fino a {mb} MB)"
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Questo file usa font che non sono installati su questo computer. Il loro testo è disegnato con un font di riserva finché non li installi."
+# Updated warning: AI-assisted draft; native review pending.
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "Questo file usa caratteri che non sono installati su questo computer. I contorni del testo salvati ne preservano l’aspetto, quando disponibili. Installa i caratteri mancanti o scegli quelli sostitutivi prima di modificare il testo."
 "This file is on {server}, open only to you. Share it to let others in with a new code." = "Questo file è su {server}, aperto solo a te. Condividilo per far entrare altri con un nuovo codice."
 "This frame is new on the branch." = "Questo frame è nuovo nel ramo."
 "This frame isn't on the branch." = "Questo frame non è nel ramo."
diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml
--- a/crates/ui-egui/locales/ka.toml
+++ b/crates/ui-egui/locales/ka.toml
@@ -1441,7 +1441,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
 "This condition is edited as an expression" = "ეს პირობა ექსპრესიად რედაქტირდება"
 "This file" = "ეს ფაილი"
 "This file has unsaved changes" = "ამ ფაილს შეუნახავი ცვლილებები აქვს"
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "ეს ფაილი იყენებს შრიფტებს, რომლებიც ამ კომპიუტერზე დაინსტალირებული არ არის. მათი ტექსტი სათადარიგო შრიფტით გამოისახება, სანამ მათ არ დააინსტალირებთ."
+# Updated warning: AI-assisted draft; native review pending.
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "ეს ფაილი იყენებს შრიფტებს, რომლებიც ამ კომპიუტერზე დაინსტალირებული არ არის. ტექსტის შენახული კონტურები მის იერსახეს ინარჩუნებს, თუ ისინი ხელმისაწვდომია. ტექსტის რედაქტირებამდე დააინსტალირეთ დაკარგული შრიფტები ან აირჩიეთ შემცვლელები."
 "This file is on {server}, open only to you. Share it to let others in with a new code." = "ეს ფაილი {server}-ზეა და მხოლოდ თქვენთვისაა ღია. გააზიარეთ, რომ სხვები ახალი კოდით შემოუშვათ."
 "This frame is new on the branch." = "ეს ჩარჩო განშტოებაში ახალია."
 "This frame isn't on the branch." = "ეს ჩარჩო განშტოებაში არ არის."
diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml
--- a/crates/ui-egui/locales/ko.toml
+++ b/crates/ui-egui/locales/ko.toml
@@ -1818,7 +1818,8 @@ status = "draft — needs native review"
 "The destination page was removed. Nothing was imported." = "대상 페이지가 삭제되었습니다. 아무것도 가져오지 않았습니다."
 "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "창이 너무 작아 {size}% 대신 {drawn}%로 표시합니다. {size}%를 사용하려면 창을 키우세요."
 "This file is too large to join here (up to {mb} MB)" = "이 파일은 여기에 추가하기에 너무 큽니다(최대 {mb}MB)."
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "이 파일은 이 컴퓨터에 없는 글꼴을 사용합니다. 해당 글꼴을 설치할 때까지 대체 글꼴로 표시합니다."
+# Updated warning: AI-assisted draft; native review pending.
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "이 파일은 이 컴퓨터에 설치되지 않은 글꼴을 사용합니다. 저장된 텍스트 윤곽선이 있으면 원래 모양이 유지됩니다. 텍스트를 편집하기 전에 누락된 글꼴을 설치하거나 대체 글꼴을 선택하세요."
 "Top padding" = "위쪽 안쪽 여백"
 "Try another search, or browse templates." = "다른 검색어를 입력하거나 템플릿을 둘러보세요."
 "Try another word, or look at every kind." = "다른 단어를 검색하거나 모든 종류를 확인하세요."
diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml
--- a/crates/ui-egui/locales/nl.toml
+++ b/crates/ui-egui/locales/nl.toml
@@ -1445,7 +1445,8 @@ status = "draft — needs native review"
 "This file" = "Dit bestand"
 "This file has unsaved changes" = "Dit bestand heeft niet-opgeslagen wijzigingen"
 "This file is on {server}, open only to you. Share it to let others in with a new code." = "Dit bestand staat op {server}, alleen toegankelijk voor jou. Deel het om anderen binnen te laten met een nieuwe code."
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Dit bestand gebruikt lettertypen die niet op deze computer zijn geïnstalleerd. Hun tekst wordt in een vervangend lettertype getoond tot je ze installeert."
+# Updated warning: AI-assisted draft; native review pending.
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "Dit bestand gebruikt lettertypen die niet op deze computer zijn geïnstalleerd. Opgeslagen tekstcontouren behouden de weergave waar ze beschikbaar zijn. Installeer de ontbrekende lettertypen of kies vervangende lettertypen voordat je tekst bewerkt."
 "This frame is new on the branch." = "Dit frame is nieuw op de branch."
 "This frame isn't on the branch." = "Dit frame staat niet op de branch."
 "This server is open to the public: no key needed." = "Deze server is voor iedereen toegankelijk: geen sleutel nodig."
diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml
--- a/crates/ui-egui/locales/pl.toml
+++ b/crates/ui-egui/locales/pl.toml
@@ -1452,7 +1452,8 @@ name = "Polski"
 "This file" = "Ten plik"
 "This file has unsaved changes" = "Ten plik ma niezapisane zmiany"
 "This file is too large to join here (up to {mb} MB)" = "Ten plik jest zbyt duży, aby do niego dołączyć (maksymalnie {mb} MB)"
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Ten plik używa czcionek, które nie są zainstalowane na tym komputerze. Do czasu ich zainstalowania tekst jest wyświetlany czcionką zastępczą."
+# Updated warning: AI-assisted draft; native review pending.
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "Ten plik używa czcionek, które nie są zainstalowane na tym komputerze. Zapisane kontury tekstu zachowują jego wygląd tam, gdzie są dostępne. Przed edycją tekstu zainstaluj brakujące czcionki lub wybierz zamienniki."
 "This file is on {server}, open only to you. Share it to let others in with a new code." = "Ten plik znajduje się na {server}, otwarty tylko dla Ciebie. Udostępnij go, aby wpuścić innych nowym kodem."
 "This frame is new on the branch." = "Ta ramka jest nowa w gałęzi."
 "This frame isn't on the branch." = "Tej ramki nie ma w gałęzi."
diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml
--- a/crates/ui-egui/locales/pt-BR.toml
+++ b/crates/ui-egui/locales/pt-BR.toml
@@ -1815,7 +1815,8 @@ status = "draft — needs native review"
 "The destination document was closed. Nothing was imported." = "O documento de destino foi fechado. Nada foi importado."
 "The destination page was removed. Nothing was imported." = "A página de destino foi removida. Nada foi importado."
 "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "A janela é pequena demais para {size}%, então a interface é exibida em {drawn}%. Aumente a janela para usar {size}%."
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este arquivo usa fontes que não estão instaladas neste computador. O texto será exibido com uma fonte substituta até que você as instale."
+# Updated warning: AI-assisted draft; native review pending.
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "Este arquivo usa fontes que não estão instaladas neste computador. Os contornos de texto salvos preservam sua aparência quando disponíveis. Instale as fontes ausentes ou escolha substitutas antes de editar o texto."
 "Top padding" = "Espaçamento interno superior"
 "Try another search, or browse templates." = "Tente outra busca ou explore os modelos."
 "Try another word, or look at every kind." = "Tente outra palavra ou veja todos os tipos."
diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml
--- a/crates/ui-egui/locales/ru.toml
+++ b/crates/ui-egui/locales/ru.toml
@@ -1453,7 +1453,7 @@ name = "Русский"
 "This file has unsaved changes" = "В файле есть несохранённые изменения"
 "This file is on {server}, open only to you. Share it to let others in with a new code." = "Файл находится на {server} и доступен только вам. Включите общий доступ, чтобы пригласить других по новому коду."
 "This file is too large to join here (up to {mb} MB)" = "Этот файл слишком большой, чтобы подключиться здесь (до {mb} МБ)"
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "В этом файле используются шрифты, не установленные на этом компьютере. Их текст отрисовывается запасным шрифтом, пока вы их не установите."
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "В файле используются шрифты, которых нет на этом компьютере. Сохранённые контуры текста сохраняют его внешний вид там, где они доступны. Для редактирования установите отсутствующие шрифты или выберите замену."
 "This frame is new on the branch." = "Этот Frame добавлен в ветке."
 "This frame isn't on the branch." = "Этого Frame нет в ветке."
 "This server is open to the public: no key needed." = "Сервер общедоступен: ключ не нужен."
@@ -2262,6 +2262,7 @@ name = "Русский"
 "text.paragraphSpacing" = "Интервал между абзацами"
 "text.pathOptions" = "Параметры текста на пути"
 "text.preview" = "Предпросмотр изменения текста"
+"text.preserveAppearance" = "Сохранить внешний вид текста"
 "text.refit" = "Подогнать текст под шрифты"
 "text.removeFromPath" = "Снять текст с пути"
 "text.rescanFonts" = "Повторно найти установленные шрифты"
diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml
--- a/crates/ui-egui/locales/sv.toml
+++ b/crates/ui-egui/locales/sv.toml
@@ -1455,7 +1455,8 @@ name = "Svenska"
 "This file has unsaved changes" = "Filen har osparade ändringar"
 "This file is on {server}, open only to you. Share it to let others in with a new code." = "Filen ligger på {server} och är bara öppen för dig. Dela den för att släppa in andra med en ny kod."
 "This file is too large to join here (up to {mb} MB)" = "Den här filen är för stor för att ansluta till här (högst {mb} MB)"
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Den här filen använder typsnitt som inte är installerade på den här datorn. Deras text ritas med ett reservtypsnitt tills du installerar dem."
+# Updated warning: AI-assisted draft; native review pending.
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "Den här filen använder teckensnitt som inte är installerade på den här datorn. Sparade textkonturer bevarar utseendet där de finns tillgängliga. Installera de saknade teckensnitten eller välj ersättningar innan du redigerar texten."
 "This frame is new on the branch." = "Den här framen är ny på grenen."
 "This frame isn't on the branch." = "Den här framen finns inte på grenen."
 "This server is open to the public: no key needed." = "Servern är öppen för alla: ingen nyckel behövs."
diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml
--- a/crates/ui-egui/locales/th.toml
+++ b/crates/ui-egui/locales/th.toml
@@ -1435,7 +1435,8 @@ status = "draft — needs native review"
 "This file" = "ไฟล์นี้"
 "This file has unsaved changes" = "ไฟล์นี้มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก"
 "This file is too large to join here (up to {mb} MB)" = "ไฟล์นี้ใหญ่เกินกว่าจะเข้าร่วมที่นี่ (สูงสุด {mb} MB)"
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "ไฟล์นี้ใช้ฟอนต์ที่ยังไม่ได้ติดตั้งในคอมพิวเตอร์เครื่องนี้ ข้อความจะแสดงด้วยฟอนต์สำรองจนกว่าคุณจะติดตั้ง"
+# Updated warning: AI-assisted draft; native review pending.
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "ไฟล์นี้ใช้แบบอักษรที่ยังไม่ได้ติดตั้งในคอมพิวเตอร์เครื่องนี้ หากมีเส้นเค้าโครงข้อความที่บันทึกไว้ ระบบจะใช้เส้นเค้าโครงนั้นเพื่อคงรูปลักษณ์เดิม โปรดติดตั้งแบบอักษรที่ขาดหายไปหรือเลือกแบบอักษรทดแทนก่อนแก้ไขข้อความ"
 "This file is on {server}, open only to you. Share it to let others in with a new code." = "ไฟล์นี้อยู่บน {server} เปิดได้เฉพาะคุณ แชร์เพื่อให้คนอื่นเข้าได้ด้วยรหัสใหม่"
 "This frame is new on the branch." = "เฟรมนี้เป็นเฟรมใหม่บนแบรนช์"
 "This frame isn't on the branch." = "เฟรมนี้ไม่ได้อยู่บนแบรนช์"
diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml
--- a/crates/ui-egui/locales/vi.toml
+++ b/crates/ui-egui/locales/vi.toml
@@ -1394,7 +1394,8 @@ status = "draft — needs native review"
 "This file has unsaved changes" = "Tệp này có thay đổi chưa lưu"
 "This file is on {server}, open only to you. Share it to let others in with a new code." = "Tệp này đang ở trên {server} và chỉ mình bạn mở được. Hãy chia sẻ để người khác tham gia bằng mã mới."
 "This file is too large to join here (up to {mb} MB)" = "Tệp này quá lớn để tham gia tại đây (tối đa {mb} MB)"
-"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Tệp này sử dụng phông chữ chưa được cài đặt trên máy tính này. Văn bản sẽ hiển thị bằng phông chữ dự phòng cho đến khi bạn cài đặt chúng."
+# Updated warning: AI-assisted draft; native review pending.
+"This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text." = "Tệp này sử dụng các phông chữ chưa được cài đặt trên máy tính này. Đường viền văn bản đã lưu sẽ giữ nguyên hình thức hiển thị nếu có. Hãy cài đặt các phông chữ còn thiếu hoặc chọn phông chữ thay thế trước khi chỉnh sửa văn bản."
 "This frame is new on the branch." = "Khung này là mới trên nhánh."
 "This frame isn't on the branch." = "Khung này không có trên nhánh."
 "This server is open to the public: no key needed." = "Máy chủ này mở công khai: không cần khóa."
diff --git a/crates/ui-egui/src/dialogs/missing_fonts.rs b/crates/ui-egui/src/dialogs/missing_fonts.rs
--- a/crates/ui-egui/src/dialogs/missing_fonts.rs
+++ b/crates/ui-egui/src/dialogs/missing_fonts.rs
@@ -60,6 +60,16 @@ pub fn request(app: &mut FigApp) {
 }
 
 pub fn show(app: &mut FigApp, ctx: &egui::Context) {
+    // Joined server documents bypass file.open; check them once their tab is live.
+    if let Ok(ds) = app.session.ds() {
+        let uid = ds.uid;
+        if app.session.live().status == figcraft_engine::live::Status::Live
+            && app.session.live().info.as_ref().is_some_and(|info| info.role == figcraft_engine::live::Role::Guest)
+            && app.ui.font_checked.insert(uid)
+        {
+            app.ui.font_checks.push(uid);
+        }
+    }
     // Whether the installed fonts are indexed is asked only while a list waits for it.
     let waiting = app.ui.missing_fonts.as_ref().filter(|m| m.indexing).map(|m| m.uid);
     if (waiting.is_some() || !app.ui.font_checks.is_empty()) && figcraft_text::installed_fonts_known() {
@@ -89,7 +99,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
         } else {
             ui.label(
                 RichText::new(tr(
-                    "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them.",
+                    "This file uses fonts that aren't installed on this computer. Saved text outlines preserve its appearance where available. Install the missing fonts or choose replacements before editing text.",
                 ))
                 .color(t.text_secondary)
                 .font(regular(11.0)),
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -428,6 +428,7 @@ pub struct UiState {
     pub import_report: Option<import_report::Report>,
     /// Files the user opened whose fonts are yet to be checked (by document uid).
     pub font_checks: Vec<u64>,
+    pub font_checked: std::collections::HashSet<u64>,
     pub missing_fonts: Option<missing_fonts::Shown>,
     pub screenshot_requests: Vec<(u64, String)>,
     pub frame_ms: f64,
@@ -588,6 +589,7 @@ impl Default for UiState {
             pending_open_frames: 0,
             import_report: None,
             font_checks: Vec::new(),
+            font_checked: Default::default(),
             missing_fonts: None,
             screenshot_requests: Vec::new(),
             frame_ms: 0.0,
diff --git a/crates/ui-egui/src/live_docs_tests.rs b/crates/ui-egui/src/live_docs_tests.rs
--- a/crates/ui-egui/src/live_docs_tests.rs
+++ b/crates/ui-egui/src/live_docs_tests.rs
@@ -73,6 +73,18 @@ fn hana() -> Peer {
     Peer { client_id: 2, name: "Hana".into(), color: "#9747FF".into() }
 }
 
+#[test]
+fn hosting_does_not_schedule_an_extra_missing_font_check() {
+    let (mut h, _) = harness();
+    let uid = h.app.session.ds().unwrap().uid;
+    h.app.ui.font_checks.clear();
+    h.app.ui.font_checked.remove(&uid);
+    run(&mut h, "collab.host", json!({}));
+    assert_eq!(h.app.session.live().info.as_ref().unwrap().role, Role::Host);
+    assert!(!h.app.ui.font_checked.contains(&uid));
+    assert!(!h.app.ui.font_checks.contains(&uid));
+}
+
 #[test]
 fn the_share_popover_is_the_session_of_the_tab_in_front() {
     let (mut h, queue) = harness();
diff --git a/docs/architecture.md b/docs/architecture.md
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,6 +1,6 @@
 # FigCraft architecture
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (the `vectordrawable` crate, L4: Android Vector Drawable export; earlier: the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (portable text integrated with the engine module refactor; the `vectordrawable` crate, L4: Android Vector Drawable export; earlier: the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
 
 How FigCraft is built today. Engine first, a thin swappable UI, everything a command; a Figma-shaped
 data model (a scene graph of frames with auto layout, components and variables). ~108,800 lines of
@@ -82,6 +82,8 @@ input.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | minor | Portable text review: omit unchanged appearances from text updates without new wire properties, share immutable geometry across edits/history, cache exact-face availability with scan invalidation, and retain/update six missing-font translations. Server-side missing-font display retained; no font binaries. Readiness estimates unchanged. |
+| 2026-10-22 | minor | Portable glyph geometry preserves missing-font text across native saves and collaboration; commands and gestures protect edits, and SVG/PDF retain outlines. No font binaries. Readiness estimates unchanged. |
 | 2026-10-18 | minor | Added `vectordrawable` (L4), the Android Vector Drawable (XML) writer |
 | 2026-10-11 | minor | `collab-net` hosts through one hub for the app's sessions and the standalone server; added the `figcraft-server` app |
 | 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) |
diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md
--- a/docs/collab-protocol.md
+++ b/docs/collab-protocol.md
@@ -999,3 +999,18 @@ chooses to share on the network; the default is the invite link with a random to
    join codes (online through the server, or carrying the host's address on the local network)
    and by your online files (the app remembers what it owns and opens it again as its owner).
 6. **Accounts**: people signing in, per-person permissions, teams and projects.
+
+## Portable text appearance (2026-10-20)
+
+Native text nodes can carry `appearance`: a fingerprint of editable text/style/box inputs,
+positioned vector glyph outlines, line metrics and styled decorations. These are document
+geometry, not font binaries. Full creations and snapshots carry them in node kinds. Metadata-only
+text-to-text `kind` updates with unchanged runs omit `appearance` and carry `unchangedAppearance: true`; receivers
+apply only base-style hyperlinks/style identifiers, leaving all current run overrides intact and preserving concurrently changed characters and
+typography together with their geometry. A full kind update without the metadata marker replaces its appearance (null or omission clears it). Regenerated geometry is included with edited
+text in the same property update. Run metadata uses a normal atomic kind update rather than a partial run merge. Moves, node-paint changes and base metadata do not retransmit
+unchanged outlines. Logs, saves, undo and redo retain appearances. No message variant or
+property is added. A receiving machine with absent faces uses the saved geometry and keeps the
+author's text box. SVG/PDF exports use those outlines too. See [portable-text.md](portable-text.md).
+Both server and clients must implement the retained-field update semantics; older builds may
+discard the optional geometry when applying an update. Upgrade participating clients together.
diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md
--- a/docs/collaboration-parity.md
+++ b/docs/collaboration-parity.md
@@ -26,7 +26,9 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | minor | Portable text review: omit unchanged appearances from text updates without new wire properties, share immutable geometry across edits/history, cache exact-face availability with scan invalidation, and retain/update six missing-font translations. Server-side missing-font display retained; no font binaries. Readiness estimates unchanged. |
 | 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged |
+| 2026-10-22 | minor | Portable glyph geometry preserves missing-font text across native saves and collaboration; commands and gestures protect edits, and SVG/PDF retain outlines. No font binaries. Readiness estimates unchanged. |
 | 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged |
 | 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove <name> from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged |
 | 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
diff --git a/docs/portable-text.md b/docs/portable-text.md
new file mode 100644
--- /dev/null
+++ b/docs/portable-text.md
@@ -0,0 +1,89 @@
+# Portable text appearance
+
+Text remains editable characters and styled ranges. A machine with the named font faces also
+saves positioned glyph outlines, decorations, line metrics and the text box in `TextData.appearance`.
+A fingerprint covers the text and its layout settings, so geometry from an older edit is never
+used for different characters or typography. No font file is embedded, installed or distributed.
+
+Opening, saving, publishing and locally editing text capture appearances where the original
+faces exist. `text.preserveAppearance {}` captures existing layers too, including a document that
+was published before this feature. Run it on a machine with the original fonts. A collaborator
+cannot recover the original appearance from a substitute font; missing faces are never captured
+as if they were the originals. The command reports the number captured.
+
+On a machine without the faces, valid saved outlines draw on the canvas and in SVG/PDF exports.
+Its text boxes are not auto-sized in a substitute font. Moving layers preserves their geometry.
+Editing preserved text or its typography requires installing the missing faces or explicitly
+replacing them with installed ones. The Missing fonts window also checks joined server tabs.
+Documents without saved geometry retain the previous fallback display; the warning says that
+saved outlines apply where available. This does not repair a file whose text was already replaced.
+
+Full node creations and snapshots carry appearances. Base-style metadata-only text-kind updates (hyperlinks and shared-style identifiers), with unchanged runs, omit
+`appearance` and carry `unchangedAppearance: true`; the receiver applies only those metadata
+fields, retaining its current characters, typography, run overrides and geometry even after a concurrent edit.
+Changes to run metadata use the existing atomic full-kind update, avoiding a partial merge that
+could silently discard links when concurrent edits split or merge run ranges.
+A full kind update without the metadata marker replaces its appearance (null or omission clears it). Content/typography changes which regenerate geometry
+send the complete appearance with the edited text in the same atomic property update. Moving
+a layer, changing its node paint or updating base metadata does not resend unchanged glyph data.
+Server persistence, undo and redo retain appearances. No message variant or property is added.
+Update participating clients and the server together: older receivers may discard the optional
+geometry on an omitted-field update. Native `.figcraft` saves retain the existing appearance;
+the experimental `.fig` exporter does not currently write this extension.
+
+Limits: 100,000 glyphs and 16 MiB of outline strings per layer. Colour bitmap/COLR glyphs currently
+skip capture rather than silently losing their colour. Outlines preserve display, not font-based
+editing. Font substitutions and missing styles still need the original fonts or an explicit
+replacement. Different installed versions of the same named face are not detected.
+
+Validation uses the current layout box, including sizes changed by layout or variable resolution.
+Only dimensions affecting the selected auto-resize mode participate in the fingerprint. Both
+commands and gesture previews reject changes to preserved text when its faces are absent; a
+rejected gesture keeps the last valid document and cannot commit invalid geometry. Capturing
+zero layers leaves the document identity and dirty state unchanged.
+
+Starting a host (including local sharing) keeps newly captured geometry in the local tab and
+sends that same document to peers, without changing whether the tab has unsaved edits.
+Publishing captures the outgoing snapshot; its welcome installs that document locally before
+editing becomes available. Hyperlinks and
+shared-style identifiers do not invalidate outlines; actual rendering and layout inputs still
+do. Automatic live-session font checks apply to joined guest tabs, not local hosts.
+
+Validation: `crates/engine/tests/portable_text.rs` starts a separate process without system fonts.
+It verifies identical rendered pixels and outlined SVG, PDF export, native save/reopen, protected
+text editing and moving a layer. The synthetic face exists only in the author's process; neither
+its font bytes nor private artwork are stored in test fixtures.
+Review regressions also cover late font indexing before hosting/local sharing, clean and dirty
+tabs, non-geometric style metadata, and the host's missing-font check queue.
+
+Hosting prepares the captured snapshot before starting the link, and adopts it locally only
+after startup succeeds. A rejected host/local-share start leaves both the local document and
+its saved baseline untouched. Regressions cover successful and rejected starts with late font
+indexing for clean and dirty tabs.
+
+## Why capture for collaboration rather than export only?
+
+A newly joined collaborator may not have the author’s fonts. The server’s document must already
+contain enough display geometry to render the design before that collaborator edits or exports
+anything. Capturing only at export time would not fix that live-document use case. This is
+font-independent display, not permission to edit text using an absent font.
+
+Appearances are immutable shared data in memory (`Arc`); cloning a text node, an undo entry or
+fingerprinting editable inputs does not clone its outline strings. The serialized native shape
+is unchanged. Exact named-face availability (including missing results) is bounded and cached:
+a warm appearance check takes a read lock, not the font database’s write lock. Adding fonts and
+both initial indexing and rescans invalidate these results, including late-installed styles.
+The cache contains face names/results only, never font binaries.
+
+Restored geometry takes hyperlinks from current editable character spans, not saved style links.
+Removing or replacing a link therefore also updates missing-font PDF annotations. Saved line
+glyph ranges must be ordered and non-overlapping, bounding cumulative traversal by glyph count;
+PDF link collection also stops at its annotation/work budget independently.
+
+Gesture previews normalize layout without capturing outlines on every disposable step. Commit
+captures the final geometry before history/live synchronization; cancel retains the original.
+Ordinary commands and typing still capture before synchronization. A synthetic 3,300-character
+resize test measures eager capture versus deferred previews and verifies commit/undo/cancel.
+On the local Windows optimized test build, 16 steps took 335.8 ms with eager capture versus
+2.9 ms for deferred previews and 21.7 ms including commit. This synthetic measurement isolates
+outline-capture overhead; it is not an end-to-end canvas or network benchmark.
