collective

Browse / github.com/storytold/deckcraft / patch

fix(engine): isolate embedded fonts per presentation

fix(engine): isolate embedded fonts per presentation ## Problem DeckCraft's process-wide font database deduplicates loaded faces by family/style. Two presentations embedding different subsets or metrics under the same family/style therefore collide: the later document can render with the earlier document's face. ## Change This now includes the application integration requested in review, not just a caller-facing font API. - Add `FontDb::add_document_fonts(Vec<(Vec<u8>, String, String)>)` for bytes, declared source family and private alias. Select matching collection families before the renamed-single-family fallback; preserve styles and weig

Status
open
Score
0.00
Adopters
0 other operators
Author
mrzv (imported from pull request #1206, open)
Base
14f40c408c67d2cb43e30a47dd179aada6dba53a
Size
+1464 −95 in 20 files
Review
pending
Shared
2026-10-28
Id
27bf2f53a84b136b99b4ded4

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/cmd/edit.rs crates/engine/src/cmd/file.rs crates/engine/src/cmd/text.rs crates/engine/src/fonts.rs crates/engine/src/lib.rs crates/engine/src/tests_document_fonts.rs crates/fonts/src/fallback.rs crates/fonts/src/fontdb.rs crates/fonts/src/lib.rs crates/model/src/defaults.rs crates/model/src/lib.rs crates/render/src/images.rs crates/render/src/metafile.rs crates/render/src/paint.rs crates/render/src/tests.rs crates/text/src/lib.rs crates/ui-egui/src/ribbon.rs docs/architecture.md docs/file-format-parity.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -7,6 +7,8 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Font isolation update (2026-10-24):** engine loading now uses document-local embedded font aliases for rendering, including theme/script/symbol/bullet and WMF/EMF text. Editing and saved family names stay unchanged; feature scores and milestone names are unchanged.+ DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere (macOS, Windows, Linux, BSD and the web). This page is the summary; the detail is in@@ -134,6 +136,8 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | What landed | |---|---|+| 2026-10-24 | Engine integration of document-local embedded fonts: different decks sharing a family/style keep their own metrics, including theme/script/symbol/bullet and WMF/EMF text. Original UI/native/PPTX font names stay intact; the active font picker and formatting-preserving clipboard retain document-local fonts without exposing them to global discovery or fallback |+| 2026-10-22 | Font database API: document embedders can register font bytes under distinct private aliases, preserving styles/weights without exposing the source typographic or Windows family names. Collection selection and idempotent registration have synthetic regressions; application PPTX callers are unchanged | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table | | 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |@@ -285,6 +289,8 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Document-local font aliases are now installed by the engine and resolved at rendering lookups, with per-document metafile caching and original editing/export names retained. Font pickers show active document families; clipboard rendering preserves its source scope, and retained source-only families are copied with their embedded parts. Existing destination families stay authoritative. Scores unchanged |+| 2026-10-22 | minor | Progress log: additive document-local font alias API for library embedders. No application caller or parity score changes | | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | | 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged | | 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs--- a/crates/engine/src/cmd/edit.rs+++ b/crates/engine/src/cmd/edit.rs@@ -73,6 +73,21 @@ fn slides_scope(s: &Session, p: &Value) -> bool { }  fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {+    let caret = copying_caret(s);+    let plain = copy_content(s, p)?;+    if !caret {+        let st = s.doc()?;+        let aliases = st.doc.font_aliases.clone();+        let fonts = st.doc.embedded_fonts.clone();+        let media = st.doc.media.iter().filter(|m| fonts.iter().any(|f| f.faces().contains(&Some(m.id)))).cloned().collect();+        s.clipboard.source_font_aliases = aliases;+        s.clipboard.source_fonts = fonts;+        s.clipboard.source_font_media = media;+    }+    Ok(plain)+}++fn copy_content(s: &mut Session, p: &Value) -> Result<String> {     let st = s.doc()?;     // Text selection.     if let Some(t) = &st.selection.text {@@ -121,7 +136,8 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {     let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();     let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n");     let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));-    s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() };+    let source_font_aliases = st.doc.font_aliases.clone();+    s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, source_font_aliases, ..Default::default() };     Ok(plain) } @@ -261,6 +277,104 @@ fn bring_media(cmd: &str, doc: &mut deckcraft_model::Presentation, media: &[deck }  fn paste(s: &mut Session, p: &Value) -> Result<Value> {+    let external = str_param(p, "text").is_some_and(|t| t != s.clipboard.plain);+    if external || s.clipboard.source_fonts.is_empty() || s.doc()?.selection.text.is_some() {+        return paste_content(s, p);+    }+    // Inspect the pasted result, not the source's layout or placeholder formatting.+    let pasted = paste_content(s, p)?;+    let mut families = Vec::<String>::new();+    {+        let st = s.doc()?;+        let mut add = |family: &str| {+            if !family.is_empty() && !families.iter().any(|f| f.eq_ignore_ascii_case(family)) {+                families.push(family.to_string());+            }+        };+        let mut visit = |slide: &Slide, shapes: &[Shape]| {+            let Some(ctx) = deckcraft_model::resolve::Ctx::for_slide(&st.doc, slide) else { return };+            deckcraft_model::walk(shapes, &mut |shape, _| {+                if let Some(body) = &shape.text {+                    crate::fonts::body_fonts(&ctx, shape, body, true, None, &mut add);+                }+                if let deckcraft_model::ShapeKind::Table(table) = &shape.kind {+                    for cell in table.rows.iter().flat_map(|r| &r.cells) {+                        crate::fonts::body_fonts(&ctx, shape, &cell.text, true, None, &mut add);+                    }+                }+            });+        };+        if !s.clipboard.slides.is_empty() {+            for slide in st.doc.slides.iter().filter(|slide| st.selection.slides.contains(&slide.id)) {+                visit(slide, &slide.shapes);+            }+        } else if !s.clipboard.shapes.is_empty()+            && let Some(slide) = st.current_slide()+        {+            for id in &st.selection.shapes {+                if let Some(shape) = st.shape(*id) {+                    visit(slide, std::slice::from_ref(shape));+                }+            }+        }+    }+    retain_pasted_fonts(s, &families)?;+    Ok(pasted)+}++pub(super) fn retain_pasted_text_fonts(s: &mut Session, a: deckcraft_model::edit::Pos, b: deckcraft_model::edit::Pos) -> Result<()> {+    if a == b || s.clipboard.source_fonts.is_empty() {+        return Ok(());+    }+    let mut families = Vec::<String>::new();+    let st = s.doc()?;+    if let (Some(t), Some(slide)) = (&st.selection.text, st.current_slide())+        && let (Some(body), Some(ctx)) = (super::text::body_of(st, t), deckcraft_model::resolve::Ctx::for_slide(&st.doc, slide))+    {+        let notes_shape = Shape::default();+        let shape = if t.notes { Some(&notes_shape) } else { st.shape(t.shape) };+        if let Some(shape) = shape {+            crate::fonts::body_fonts(&ctx, shape, body, true, Some((a, b)), &mut |f| {+                if !f.is_empty() && !families.iter().any(|family| family.eq_ignore_ascii_case(f)) {+                    families.push(f.to_string());+                }+            });+        }+    }+    retain_pasted_fonts(s, &families)+}++fn retain_pasted_fonts(s: &mut Session, families: &[String]) -> Result<()> {+    let clip = &s.clipboard;+    let doc = &s.doc()?.doc;+    let fonts: Vec<_> = clip+        .source_fonts+        .iter()+        .filter(|font| {+            families.iter().any(|f| f.eq_ignore_ascii_case(font.typeface.trim()))+                && !doc.embedded_fonts.iter().any(|f| f.typeface.trim().eq_ignore_ascii_case(font.typeface.trim()))+                && clip.source_font_aliases.iter().any(|(f, _)| f.eq_ignore_ascii_case(font.typeface.trim()))+        })+        .cloned()+        .collect();+    if !fonts.is_empty() {+        let media: Vec<_> = clip.source_font_media.iter().filter(|m| fonts.iter().any(|f| f.faces().contains(&Some(m.id)))).cloned().collect();+        let aliases: Vec<_> =+            clip.source_font_aliases.iter().filter(|(f, _)| fonts.iter().any(|font| font.typeface.trim().eq_ignore_ascii_case(f))).cloned().collect();+        s.edit(|doc, _| {+            let media_map = bring_media("edit.paste", doc, &media)?;+            for mut font in fonts {+                crate::media_refs::visit_mut::font(&mut font, &mut remap_media(&media_map));+                doc.embedded_fonts.push(font);+            }+            Arc::make_mut(&mut doc.font_aliases).extend(aliases);+            Ok(())+        })?;+    }+    Ok(())+}++fn paste_content(s: &mut Session, p: &Value) -> Result<Value> {     // External plain text (from the system clipboard) wins when given and differs from ours.     // HTML from Pages or Keynote keeps bold, italic and underline when it says the same words.     if let Some(t) = str_param(p, "text")@@ -277,7 +391,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {     let editing = s.doc()?.selection.text.is_some();     if editing {         if let Some(body) = s.clipboard.text.clone() {-            return super::text::paste_body(s, &body);+            return super::text::paste_body(s, &body, true);         }         let plain = s.clipboard.plain.clone();         return paste_plain(s, &plain);@@ -345,7 +459,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {  fn paste_external_body(s: &mut Session, body: &TextBody) -> Result<Value> {     if s.doc()?.selection.text.is_some() {-        return super::text::paste_body(s, body);+        return super::text::paste_body(s, body, false);     }     let plain = body.text();     let st = s.doc()?;@@ -464,6 +578,7 @@ fn paste_picture(s: &mut Session) -> Result<Value> {     }     let st = s.doc()?;     let mut doc = (*st.doc).clone();+    doc.font_aliases = clip.source_font_aliases.clone();     let media = bring_media("edit.pasteSpecial", &mut doc, &clip.media)?;     crate::media_refs::visit_mut::shapes(&mut clip.shapes, &mut remap_media(&media));     let mut slide = st.current_slide().cloned().ok_or_else(|| bad("edit.pasteSpecial", "no slide"))?;diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs--- a/crates/engine/src/cmd/file.rs+++ b/crates/engine/src/cmd/file.rs@@ -229,11 +229,7 @@ pub fn outline_text(bytes: &[u8]) -> String {  pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {     let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());-    crate::fonts::install_embedded_fonts(&p);-    let missing_fonts = crate::fonts::missing_fonts(&p);-    let mut st = DocState::new(p, path, title);-    st.missing_fonts = missing_fonts;-    s.add_document(st)+    s.add_document(DocState::new(p, path, title)) }  /// A presentation read from disk, not yet added to a session: [`read_presentation_file`] needs nodiff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs--- a/crates/engine/src/cmd/text.rs+++ b/crates/engine/src/cmd/text.rs@@ -373,6 +373,14 @@ pub fn ime_surrounding(st: &DocState, t: &TextSel) -> Option<ImeSurrounding> {  /// Edit the active text body and selection; refits auto-sizing text boxes afterwards. pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result<T> {+    edit_text_before_fit(s, f, |_, _| Ok(()))+}++fn edit_text_before_fit<T>(+    s: &mut Session,+    f: impl FnOnce(&mut TextBody, &mut TextSel) -> T,+    before_fit: impl FnOnce(&mut Session, &T) -> Result<()>,+) -> Result<T> {     let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?;     // Materialise an inherited placeholder box before editing (so autofit can change it).     let r = s.edit(|doc, sel| {@@ -391,6 +399,7 @@ pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut T         sel.text = Some(t);         Ok(r)     })?;+    before_fit(s, &r)?;     if !t0.notes && t0.cell.is_none() {         fit_text_box(s, t0.shape)?;     }@@ -1126,22 +1135,31 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {     })) } -pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result<Value> {+pub(crate) fn paste_body(s: &mut Session, src: &TextBody, retain_fonts: bool) -> Result<Value> {     let inherited = inherited_aligns_for_edit(s);-    let pos = edit_text(s, |body, t| {-        let (a, b) = t.ordered();-        let at = ed::delete(body, a, b);-        let c = ed::insert_body(body, at, src);-        t.anchor = c;-        t.caret = c;-        let last = body.paragraphs.len().saturating_sub(1);-        for pi in at.0..=c.0.min(last) {-            if let Some(para) = body.paragraphs.get_mut(pi) {-                promote_rtl_if_needed(para, &inherited);+    let (_, pos) = edit_text_before_fit(+        s,+        |body, t| {+            let (a, b) = t.ordered();+            let at = ed::delete(body, a, b);+            let c = ed::insert_body(body, at, src);+            t.anchor = c;+            t.caret = c;+            let last = body.paragraphs.len().saturating_sub(1);+            for pi in at.0..=c.0.min(last) {+                if let Some(para) = body.paragraphs.get_mut(pi) {+                    promote_rtl_if_needed(para, &inherited);+                }             }-        }-        c-    })?;+            (at, c)+        },+        |s, &(a, b)| {+            if retain_fonts {+                super::edit::retain_pasted_text_fonts(s, a, b)?;+            }+            Ok(())+        },+    )?;     Ok(json!({"caret": [pos.0, pos.1]})) } 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@@ -6,19 +6,36 @@ use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::text::{Bullet, TextBody}; use deckcraft_model::{Presentation, Shape, ShapeKind}; -/// Load each embedded face into the font database, so text in that family draws with the file's-/// font on a machine that doesn't have it. A face already loaded (bundled or installed) stays;-/// the part bytes are kept either way. MicroType Express subsets have no TrueType or OpenType-/// font to load and are left for the round trip.-pub fn install_embedded_fonts(p: &Presentation) {+/// Install supported embedded faces under private document-local families. Original names and+/// unsupported MicroType Express subsets remain unchanged for editing and round trip.+pub fn install_embedded_fonts(p: &mut Presentation) {+    use std::sync::atomic::{AtomicU64, Ordering};+    static NEXT_SCOPE: AtomicU64 = AtomicU64::new(1);+    let scope = NEXT_SCOPE.fetch_add(1, Ordering::Relaxed);     let db = FontDb::global();+    let mut aliases: Vec<(String, String)> = Vec::new();+    let mut faces = Vec::new();     for font in &p.embedded_fonts {+        let family = font.typeface.trim();+        if family.is_empty() {+            continue;+        }+        let alias = if let Some((_, alias)) = aliases.iter().find(|(source, _)| source.eq_ignore_ascii_case(family)) {+            alias.clone()+        } else {+            let alias = format!("deckcraft-document-{scope}-font-{}", aliases.len());+            aliases.push((family.to_owned(), alias.clone()));+            alias+        };         for id in font.faces().into_iter().flatten() {             let Some(item) = p.media(id) else { continue };             let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue };-            db.add_font(sfnt.to_vec());+            faces.push((sfnt.to_vec(), family.to_owned(), alias.clone()));         }     }+    db.add_document_fonts(faces);+    aliases.retain(|(_, alias)| db.has_family(alias));+    p.font_aliases = std::sync::Arc::new(aliases); }  /// The font families the slides' text is drawn in (theme fonts resolved, bullet fonts included)@@ -27,7 +44,7 @@ pub fn missing_fonts(p: &Presentation) -> Vec<String> {     let db = FontDb::global();     let mut out: Vec<String> = Vec::new();     for family in used_fonts(p) {-        if !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {+        if !db.has_family(p.render_font_family(&family)) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {             out.push(family);         }     }@@ -38,21 +55,21 @@ pub fn missing_fonts(p: &Presentation) -> Vec<String> { /// The families the slides' text runs and bullets are drawn in. pub fn used_fonts(p: &Presentation) -> Vec<String> {     let mut out: Vec<String> = Vec::new();-    let mut add = |f: String| {-        let f = f.trim().to_string();-        if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {-            out.push(f);+    let mut add = |f: &str| {+        let f = f.trim();+        if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(f)) {+            out.push(f.to_string());         }     };     for slide in &p.slides {         let Some(ctx) = Ctx::for_slide(p, slide) else { continue };         deckcraft_model::walk(&slide.shapes, &mut |s, _| {             if let Some(body) = &s.text {-                body_fonts(&ctx, s, body, &mut add);+                body_fonts(&ctx, s, body, false, None, &mut add);             }             if let ShapeKind::Table(t) = &s.kind {                 for cell in t.rows.iter().flat_map(|r| &r.cells) {-                    body_fonts(&ctx, s, &cell.text, &mut add);+                    body_fonts(&ctx, s, &cell.text, false, None, &mut add);                 }             }         });@@ -60,21 +77,68 @@ pub fn used_fonts(p: &Presentation) -> Vec<String> {     out } -fn body_fonts(ctx: &Ctx, s: &Shape, body: &TextBody, add: &mut dyn FnMut(String)) {-    for para in &body.paragraphs {-        let has_text = para.runs.iter().any(|r| !r.text.trim().is_empty());-        if !has_text {+pub(crate) fn body_fonts(+    ctx: &Ctx,+    s: &Shape,+    body: &TextBody,+    include_slots: bool,+    range: Option<(deckcraft_model::edit::Pos, deckcraft_model::edit::Pos)>,+    add: &mut dyn FnMut(&str),+) {+    for (pi, para) in body.paragraphs.iter().enumerate() {+        if range.is_some_and(|(a, b)| pi < a.0 || pi > b.0) {             continue;         }-        for run in para.runs.iter().filter(|r| !r.text.trim().is_empty()) {+        let mut offset = 0usize;+        let mut has_text = false;+        for run in &para.runs {+            let (from, count) = if let Some((a, b)) = range {+                let end = offset.saturating_add(run.char_len());+                let from = if pi == a.0 { a.1.saturating_sub(offset) } else { 0 };+                let to = if pi == b.0 { b.1.min(end).saturating_sub(offset) } else { end - offset };+                offset = end;+                if from >= to {+                    continue;+                }+                if matches!(run.kind, deckcraft_model::text::RunKind::Text) { (from, to - from) } else { (0, usize::MAX) }+            } else {+                (0, usize::MAX)+            };+            let chars = run.text.chars().skip(from).take(count);+            if !chars.clone().any(|c| !c.is_whitespace()) {+                continue;+            }+            has_text = true;             let r = resolve::run(ctx, s, para, &run.props);-            add(resolve::font_family(ctx, &r));+            add(&resolve::font_family(ctx, &r));+            if include_slots {+                let major = r.font.as_deref().is_some_and(|f| f.starts_with("+mj"));+                for (explicit, fallback) in+                    [(&r.font_ea, if major { "+mj-ea" } else { "+mn-ea" }), (&r.font_cs, if major { "+mj-cs" } else { "+mn-cs" }), (&r.font_sym, "")]+                {+                    add(&ctx.master.theme.font(explicit.as_deref().unwrap_or(fallback)));+                }+                let set = if major { &ctx.master.theme.fonts.major } else { &ctx.master.theme.fonts.minor };+                let mut previous = None;+                for script in chars.filter_map(deckcraft_fonts::script::script_of) {+                    if previous == Some(script) {+                        continue;+                    }+                    previous = Some(script);+                    if let Ok(tag) = std::str::from_utf8(&script)+                        && let Some(family) = set.script(tag)+                    {+                        add(family);+                    }+                }+            }         }         let pp = resolve::para(ctx, s, para);-        if matches!(pp.bullet, Some(Bullet::Char { .. }))+        if has_text+            && matches!(pp.bullet, Some(Bullet::Char { .. }))             && let Some(f) = pp.bullet_font.as_deref()         {-            add(ctx.master.theme.font(f));+            add(&ctx.master.theme.font(f));         }     } }diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -244,7 +244,9 @@ fn next_uid() -> u64 { }  impl DocState {-    pub fn new(doc: Presentation, path: Option<String>, title: String) -> Self {+    pub fn new(mut doc: Presentation, path: Option<String>, title: String) -> Self {+        fonts::install_embedded_fonts(&mut doc);+        let missing_fonts = fonts::missing_fonts(&doc);         let doc = Arc::new(doc);         DocState {             saved_doc: doc.clone(),@@ -256,7 +258,7 @@ impl DocState {             interaction: None,             uid: next_uid(),             title,-            missing_fonts: Vec::new(),+            missing_fonts,             composition: None,             compare: None,             last_duplicate: None,@@ -456,6 +458,11 @@ pub struct Clipboard {     /// The copied shapes' theme colours (colour map applied) and fonts, for Paste Special ›     /// Keep Source Formatting.     pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>,+    /// Runtime font scope retained for Paste Special › Picture, without changing saved names.+    pub source_font_aliases: Arc<Vec<(String, String)>>,+    /// Embedded definitions and font bytes available to formatting-preserving paste.+    pub source_fonts: Vec<deckcraft_model::EmbeddedFont>,+    pub source_font_media: Vec<deckcraft_model::MediaItem>,     /// How many times the shapes were pasted (each paste offsets further).     pub pastes: u32, }@@ -846,4 +853,6 @@ mod soak_tests; #[cfg(test)] mod tests; #[cfg(test)]+mod tests_document_fonts;+#[cfg(test)] mod tests_keys;diff --git a/crates/engine/src/tests_document_fonts.rs b/crates/engine/src/tests_document_fonts.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/tests_document_fonts.rs@@ -0,0 +1,608 @@+use std::sync::Arc;++use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, TextBody};+use deckcraft_model::{ColorRef, EmbeddedFont, Fill, Presentation, Rgba, Shape, ShapeId, Xfrm};+use serde_json::json;++use crate::{DocState, Session, cmd};++const FAMILY: &str = "DeckCraft ScopeA";+const FONT_REFS: [&str; 4] = [FAMILY, "+mn-lt", "+mj-lt", FAMILY];++/// Derive two faces from the contributor-original CC0 fixture, without adding assets or+/// registering a font directly. The private test family also avoids other parallel tests.+fn font_bytes(advance: u16) -> Vec<u8> {+    let mut bytes = include_bytes!("../../fonts/tests/fixtures/legacy-regular.ttf").to_vec();+    for (from, to) in [+        (b"DeckCraft Legacy".to_vec(), FAMILY.as_bytes().to_vec()),+        ("DeckCraft Legacy".encode_utf16().flat_map(u16::to_be_bytes).collect(), FAMILY.encode_utf16().flat_map(u16::to_be_bytes).collect()),+    ] {+        assert_eq!(from.len(), to.len());+        let offsets: Vec<_> = bytes.windows(from.len()).enumerate().filter_map(|(i, s)| (s == from).then_some(i)).collect();+        assert!(!offsets.is_empty());+        for offset in offsets {+            bytes[offset..offset + to.len()].copy_from_slice(&to);+        }+    }+    let tables = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;+    let hmtx = (0..tables).map(|i| 12 + 16 * i).find(|&r| &bytes[r..r + 4] == b"hmtx").unwrap();+    let offset = u32::from_be_bytes(bytes[hmtx + 8..hmtx + 12].try_into().unwrap()) as usize;+    // Glyph 1 is A; its outline is unchanged, only its advance differs (1000 units/em).+    bytes[offset + 4..offset + 6].copy_from_slice(&advance.to_be_bytes());+    bytes+}++fn deck(advance: Option<u16>) -> String {+    let mut p = Presentation::default();+    for master in &mut p.masters {+        let master = Arc::make_mut(master);+        master.shapes.clear();+        for layout in &mut master.layouts {+            layout.shapes.clear();+        }+        for set in [&mut master.theme.fonts.major, &mut master.theme.fonts.minor] {+            set.latin = FAMILY.into();+            set.ea = FAMILY.into();+            set.cs = FAMILY.into();+        }+    }+    let slide = Arc::make_mut(&mut p.slides[0]);+    slide.shapes.clear();+    slide.show_master_shapes = false;+    for (i, font) in FONT_REFS.iter().enumerate() {+        let mut body = TextBody::from_text("AAA");+        body.body.inset_l = Some(0.0);+        body.body.inset_r = Some(0.0);+        body.body.inset_t = Some(0.0);+        body.body.inset_b = Some(0.0);+        body.body.anchor = Some(Anchor::Top);+        body.body.autofit = Some(AutoFit::None);+        let para = &mut body.paragraphs[0];+        para.props.align = Some(Align::Left);+        para.props.bullet = Some(Bullet::None);+        if i == 3 {+            para.props.bullet = Some(Bullet::Char { char: "A".into() });+            para.props.bullet_font = Some(FAMILY.into());+            para.props.bullet_size = Some(1.0);+            para.props.margin_left = Some(40.0);+            para.props.indent = Some(-40.0);+        }+        let props = &mut para.runs[0].props;+        props.font = Some((*font).into());+        props.font_ea = Some(FAMILY.into());+        props.font_cs = Some(FAMILY.into());+        props.font_sym = Some(FAMILY.into());+        props.size = Some(40.0);+        props.bold = Some(false);+        props.italic = Some(false);+        props.fill = Some(Fill::solid(ColorRef::rgb(Rgba::BLACK)));+        slide.shapes.push(Shape {+            id: ShapeId(100 + i as u32),+            name: format!("font-reference-{i}"),+            xfrm: Some(Xfrm::new(20.0, 20.0 + 80.0 * i as f64, 400.0, 70.0)),+            fill: Some(Fill::None),+            text: Some(body),+            text_box: true,+            ..Default::default()+        });+    }+    if let Some(advance) = advance {+        let id = p.add_media("scope.ttf", "font/ttf", font_bytes(advance)).unwrap();+        p.embedded_fonts.push(EmbeddedFont { typeface: FAMILY.into(), regular: Some(id), ..Default::default() });+    }+    cmd::base64_encode(&cmd::file::save_bytes(&p, "pptx").unwrap())+}++fn open(s: &mut Session, name: &str, data: &str) -> usize {+    let result = s.execute("file.openBytes", &json!({"name": name, "data": data})).unwrap();+    assert_eq!(result["missingFonts"], json!([]), "{name}: {result}");+    let st = s.doc().unwrap();+    assert!(st.missing_fonts.is_empty());+    assert!(!st.is_dirty(), "installing an embedded font must not dirty {name}");+    s.active_index().unwrap()+}++fn assert_original_names(st: &DocState) {+    let p = &st.doc;+    let fonts = &p.masters[0].theme.fonts;+    for set in [&fonts.major, &fonts.minor] {+        assert_eq!(set.latin, FAMILY);+        assert_eq!(set.ea, FAMILY);+        assert_eq!(set.cs, FAMILY);+    }+    assert_eq!(p.embedded_fonts[0].typeface, FAMILY);+    assert_eq!(p.slides[0].shapes.len(), FONT_REFS.len());+    for (shape, expected) in p.slides[0].shapes.iter().zip(FONT_REFS) {+        let para = &shape.text.as_ref().unwrap().paragraphs[0];+        let props = &para.runs[0].props;+        assert_eq!(props.font.as_deref(), Some(expected));+        assert_eq!(props.font_ea.as_deref(), Some(FAMILY));+        assert_eq!(props.font_cs.as_deref(), Some(FAMILY));+        assert_eq!(props.font_sym.as_deref(), Some(FAMILY));+    }+    let bullet = &p.slides[0].shapes[3].text.as_ref().unwrap().paragraphs[0].props;+    assert_eq!(bullet.bullet_font.as_deref(), Some(FAMILY));+}++fn assert_rendered_advances(st: &DocState, advance: u16) -> Vec<u8> {+    assert_original_names(st);+    let placed = deckcraft_render::place_slide(&st.doc, 0);+    assert_eq!(placed.texts.len(), FONT_REFS.len());+    let expected = f64::from(advance) * 40.0 / 1000.0;+    for (i, text) in placed.texts.iter().enumerate() {+        let layout = &text.layout;+        assert_eq!(layout.lines.len(), 1, "reference {}", FONT_REFS[i]);+        let line = &layout.lines[0];+        assert!(+            (line.extent.1 - line.extent.0 - 3.0 * expected).abs() < 0.01,+            "reference {}: {:?}, expected {}",+            FONT_REFS[i],+            line.extent,+            3.0 * expected+        );+        let glyphs: Vec<_> = layout.runs.iter().filter(|r| !r.cells.is_empty()).flat_map(|r| &r.glyphs).collect();+        assert_eq!(glyphs.len(), 3);+        for pair in glyphs.windows(2) {+            assert!((pair[1].1 - pair[0].1 - expected).abs() < 0.01, "reference {}: {glyphs:?}", FONT_REFS[i]);+        }+        if i == 3 {+            let bullet = layout.runs.iter().find(|r| r.cells.is_empty()).expect("rendered bullet");+            assert_eq!(bullet.glyphs.len(), 1);+            let width = bullet.face.advance(bullet.glyphs[0].0) * bullet.size / bullet.face.upem;+            assert!((width - expected).abs() < 0.01, "embedded bullet face: {width} vs {expected}");+        }+    }+    let image = deckcraft_render::render_slide(&st.doc, 0, &deckcraft_render::RenderOpts::default());+    assert!(+        image.pixels.as_chunks::<4>().0.iter().any(|px| px[3] > 0 && px[0] < 64 && px[1] < 64 && px[2] < 64),+        "the embedded squares must actually be painted"+    );+    image.pixels+}++#[test]+fn opening_two_decks_isolates_embedded_font_metrics_and_preserves_saved_names() {+    let mut s = Session::new();+    let first = open(&mut s, "narrow.pptx", &deck(Some(700)));+    let narrow = assert_rendered_advances(s.doc().unwrap(), 700);+    let second = open(&mut s, "wide.pptx", &deck(Some(900)));+    let wide = assert_rendered_advances(s.doc().unwrap(), 900);+    assert_ne!(narrow, wide, "different advances must change the raster, not just a font lookup result");+    s.set_active(first);+    assert_eq!(assert_rendered_advances(s.doc().unwrap(), 700), narrow, "opening another deck must not change the first deck");++    for (document, advance) in [(first, 700), (second, 900)] {+        for format in ["deckcraft", "pptx"] {+            s.set_active(document);+            let saved = s.execute("file.saveBytes", &json!({"format": format})).unwrap();+            open(&mut s, &format!("roundtrip.{format}"), saved["data"].as_str().unwrap());+            assert_rendered_advances(s.doc().unwrap(), advance);+        }+    }+    s.set_active(first);+    assert_eq!(assert_rendered_advances(s.doc().unwrap(), 700), narrow);+    s.set_active(second);+    assert_eq!(assert_rendered_advances(s.doc().unwrap(), 900), wide);++    // A third document that names the same family but does not embed it must not borrow either+    // deck's private face, or silently stop reporting that the original family is missing.+    let result = s.execute("file.openBytes", &json!({"name": "unembedded.pptx", "data": deck(None)})).unwrap();+    assert_eq!(result["missingFonts"], json!([FAMILY]));+    assert_eq!(s.doc().unwrap().missing_fonts, vec![FAMILY.to_string()]);+    assert!(!s.doc().unwrap().is_dirty());+}++/// Append a replacement SFNT table; the original fixture's outlines and all other tables stay.+fn replace_table(bytes: &mut Vec<u8>, tag: &[u8; 4], table: &[u8]) {+    let tables = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;+    let record = (0..tables).map(|i| 12 + 16 * i).find(|&r| &bytes[r..r + 4] == tag).unwrap();+    while !bytes.len().is_multiple_of(4) {+        bytes.push(0);+    }+    let offset = bytes.len() as u32;+    bytes.extend_from_slice(table);+    bytes[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes());+    bytes[record + 12..record + 16].copy_from_slice(&(table.len() as u32).to_be_bytes());+}++/// Remap the CC0 square to exactly the requested characters. In particular, the synthetic+/// Symbol face has only Greek Alpha: U+F041 must use Symbol's Unicode remapping, not a PUA cmap.+fn slot_font(family: &str, advance: u16, characters: &[u32], semibold: bool) -> Vec<u8> {+    let mut bytes = if semibold {+        include_bytes!("../../fonts/tests/fixtures/legacy-semibold.ttf").to_vec()+    } else {+        include_bytes!("../../fonts/tests/fixtures/legacy-regular.ttf").to_vec()+    };+    let style = if semibold { "Semibold" } else { "Regular" };+    let mut names = vec![0; 6 + 4 * 12];+    names[2..4].copy_from_slice(&4_u16.to_be_bytes());+    names[4..6].copy_from_slice(&(54_u16).to_be_bytes());+    for (i, (id, text)) in [(1_u16, family), (2, style), (16, family), (17, style)].into_iter().enumerate() {+        let value: Vec<_> = text.encode_utf16().flat_map(u16::to_be_bytes).collect();+        let offset = (names.len() - 54) as u16;+        let record = 6 + 12 * i;+        for (j, value) in [3_u16, 1, 0x0409, id, value.len() as u16, offset].into_iter().enumerate() {+            names[record + 2 * j..record + 2 * j + 2].copy_from_slice(&value.to_be_bytes());+        }+        names.extend_from_slice(&value);+    }+    replace_table(&mut bytes, b"name", &names);+    // One Windows UCS-4 format-12 subtable, with one single-character group per mapping.+    let mut cmap = Vec::new();+    for value in [0_u16, 1, 3, 10] {+        cmap.extend_from_slice(&value.to_be_bytes());+    }+    cmap.extend_from_slice(&12_u32.to_be_bytes());+    cmap.extend_from_slice(&12_u16.to_be_bytes());+    cmap.extend_from_slice(&0_u16.to_be_bytes());+    for value in [16 + 12 * characters.len() as u32, 0, characters.len() as u32] {+        cmap.extend_from_slice(&value.to_be_bytes());+    }+    for &character in characters {+        for value in [character, character, 1] {+            cmap.extend_from_slice(&value.to_be_bytes());+        }+    }+    replace_table(&mut bytes, b"cmap", &cmap);+    let mut hmtx = Vec::new();+    for value in [500_u16, 0, advance, 100] {+        hmtx.extend_from_slice(&value.to_be_bytes());+    }+    replace_table(&mut bytes, b"hmtx", &hmtx);+    bytes+}++#[test]+fn embedded_fonts_resolve_script_slots_symbol_remapping_and_bold_style() {+    const LATIN: &str = "DeckCraft Slot Latin";+    const EA: &str = "DeckCraft Slot EastAsian";+    const CS: &str = "DeckCraft Slot Complex";+    let mut p = Presentation::default();+    for master in &mut p.masters {+        let master = Arc::make_mut(master);+        master.shapes.clear();+        for layout in &mut master.layouts {+            layout.shapes.clear();+        }+        master.theme.fonts.minor.latin = LATIN.into();+        master.theme.fonts.minor.ea = EA.into();+        master.theme.fonts.minor.cs = CS.into();+        master.theme.fonts.minor.scripts.clear();+    }+    for (family, advance, characters, bold) in [+        (LATIN, 400, &[0x0041][..], false),+        (LATIN, 850, &[0x0041][..], true),+        (EA, 780, &[0x4E00][..], false),+        (CS, 930, &[0x0627][..], false),+        ("Symbol", 1050, &[0x0391][..], false),+    ] {+        let id = p.add_media(&format!("slot-{advance}.ttf"), "font/ttf", slot_font(family, advance, characters, bold)).unwrap();+        if bold {+            p.embedded_fonts.iter_mut().find(|font| font.typeface == family).unwrap().bold = Some(id);+        } else {+            p.embedded_fonts.push(EmbeddedFont { typeface: family.into(), regular: Some(id), ..Default::default() });+        }+    }+    let slide = Arc::make_mut(&mut p.slides[0]);+    slide.shapes.clear();+    slide.show_master_shapes = false;+    for (i, text) in ["AA", "AA", "一一", "اا", "\u{F041}\u{F041}"].into_iter().enumerate() {+        let mut body = TextBody::from_text(text);+        body.body.autofit = Some(AutoFit::None);+        body.body.anchor = Some(Anchor::Top);+        let para = &mut body.paragraphs[0];+        para.props.align = Some(Align::Left);+        para.props.bullet = Some(Bullet::None);+        let props = &mut para.runs[0].props;+        props.font = Some(LATIN.into());+        props.font_ea = Some("+mn-ea".into());+        props.font_cs = Some("+mn-cs".into());+        props.font_sym = Some("Symbol".into());+        props.bold = Some(i == 1);+        props.italic = Some(false);+        props.size = Some(40.0);+        props.fill = Some(Fill::solid(ColorRef::rgb(Rgba::BLACK)));+        slide.shapes.push(Shape {+            id: ShapeId(200 + i as u32),+            name: format!("font-slot-{i}"),+            xfrm: Some(Xfrm::new(20.0, 20.0 + 80.0 * i as f64, 400.0, 70.0)),+            fill: Some(Fill::None),+            text: Some(body),+            text_box: true,+            ..Default::default()+        });+    }+    let mut s = Session::new();+    let data = cmd::base64_encode(&cmd::file::save_bytes(&p, "pptx").unwrap());+    open(&mut s, "font-slots.pptx", &data);+    let placed = deckcraft_render::place_slide(&s.doc().unwrap().doc, 0);+    assert_eq!(placed.texts.len(), 5);+    for ((text, advance), label) in placed.texts.iter().zip([400, 850, 780, 930, 1050]).zip(["regular", "bold", "EA", "CS", "Symbol"]) {+        let layout = &text.layout;+        assert_eq!(layout.lines.len(), 1, "{label}");+        let width = layout.lines[0].extent.1 - layout.lines[0].extent.0;+        let expected = 2.0 * f64::from(advance) * 40.0 / 1000.0;+        assert!((width - expected).abs() < 0.01, "{label}: width {width}, expected {expected}");+        let runs: Vec<_> = layout.runs.iter().filter(|run| !run.cells.is_empty()).collect();+        assert_eq!(runs.iter().map(|run| run.glyphs.len()).sum::<usize>(), 2, "{label}");+        assert!(runs.iter().flat_map(|run| &run.glyphs).all(|glyph| glyph.0 == 1), "{label}: draw the embedded square, not a fallback or .notdef");+        assert!(runs.iter().all(|run| !run.fake_bold), "{label}: select the embedded weight, not synthetic bold");+    }+    let image = deckcraft_render::render_slide(&s.doc().unwrap().doc, 0, &deckcraft_render::RenderOpts::default());+    // Every row, including the PUA Symbol row, must paint its two real embedded outlines.+    for row in 0..5 {+        let painted = (20 + 80 * row..90 + 80 * row)+            .any(|y| (20..420).any(|x| image.straight_at(x, y).is_some_and(|px| px[3] > 0 && px[0] < 64 && px[1] < 64 && px[2] < 64)));+        assert!(painted, "font slot row {row} must actually be rendered");+    }+}++#[test]+fn pasting_picture_keeps_source_document_embedded_font() {+    let mut s = Session::new();+    let source = open(&mut s, "source.pptx", &deck(Some(700)));+    let destination = open(&mut s, "destination.pptx", &deck(Some(900)));+    s.set_active(source);+    let st = s.doc().unwrap();+    let expected = deckcraft_render::render_shape(&st.doc, st.current_slide().unwrap(), ShapeId(100), 2.0).unwrap().to_png();+    s.execute("edit.select", &json!({"ids": [100]})).unwrap();+    s.execute("edit.copy", &json!({})).unwrap();+    s.set_active(destination);+    let result = s.execute("edit.pasteSpecial", &json!({"as": "picture"})).unwrap();+    let id = ShapeId(result["id"].as_u64().or_else(|| result["ids"][0].as_u64()).unwrap() as u32);+    let st = s.doc().unwrap();+    let deckcraft_model::ShapeKind::Picture { fill } = &st.shape(id).unwrap().kind else { panic!("expected pasted picture") };+    assert_eq!(*st.doc.media(fill.media).unwrap().data, expected, "picture must retain the copied document's glyph advances");+}++#[test]+fn pasted_shapes_keep_source_only_embedded_families() {+    for (mode, id) in [("destinationTheme", 100), ("keepSource", 101)] {+        let mut s = Session::new();+        let source = open(&mut s, "source.pptx", &deck(Some(700)));+        s.execute("file.new", &json!({})).unwrap();+        let destination = s.active_index().unwrap();+        s.set_active(source);+        s.execute("edit.select", &json!({"ids": [id]})).unwrap();+        s.execute("edit.copy", &json!({})).unwrap();+        s.set_active(destination);+        s.execute("edit.pasteSpecial", &json!({"as": mode})).unwrap();+        let placed = deckcraft_render::place_slide(&s.doc().unwrap().doc, 0);+        let line = &placed.texts.last().unwrap().layout.lines[0];+        assert!((line.extent.1 - line.extent.0 - 84.0).abs() < 0.01, "{mode}: pasted explicit font must keep its advances");+        s.execute("edit.undo", &json!({})).unwrap();+        assert!(s.doc().unwrap().doc.embedded_fonts.is_empty(), "one undo removes both pasted shapes and their imported fonts");+        s.execute("edit.redo", &json!({})).unwrap();+        for format in ["deckcraft", "pptx"] {+            let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, format).unwrap();+            open(&mut s, &format!("pasted.{format}"), &cmd::base64_encode(&bytes));+            let placed = deckcraft_render::place_slide(&s.doc().unwrap().doc, 0);+            let line = &placed.texts.last().unwrap().layout.lines[0];+            assert!((line.extent.1 - line.extent.0 - 84.0).abs() < 0.01, "{mode}/{format}: retained source-only face must survive save and reopen");+        }+    }+}++#[test]+fn pasted_shapes_keep_the_destinations_existing_embedded_family() {+    let mut s = Session::new();+    let source = open(&mut s, "source.pptx", &deck(Some(700)));+    let destination = open(&mut s, "destination.pptx", &deck(Some(900)));+    s.set_active(source);+    s.execute("edit.select", &json!({"ids": [100]})).unwrap();+    s.execute("edit.copy", &json!({})).unwrap();+    s.set_active(destination);+    s.execute("edit.paste", &json!({})).unwrap();+    let placed = deckcraft_render::place_slide(&s.doc().unwrap().doc, 0);+    let line = &placed.texts.last().unwrap().layout.lines[0];+    assert!((line.extent.1 - line.extent.0 - 108.0).abs() < 0.01, "an existing destination family must not be overwritten");+    assert_eq!(s.doc().unwrap().doc.embedded_fonts.len(), 1);+}++#[test]+fn rich_text_paste_fits_using_the_retained_embedded_font() {+    let mut s = Session::new();+    let source = open(&mut s, "source.pptx", &deck(Some(700)));+    let mut p = Presentation::default();+    let mut body = TextBody::from_text("");+    body.body.autofit = Some(AutoFit::Shape);+    body.body.wrap = Some(false);+    let slide = Arc::make_mut(&mut p.slides[0]);+    slide.shapes.clear();+    slide.shapes.push(Shape {+        id: ShapeId(200),+        text: Some(body),+        text_box: true,+        xfrm: Some(Xfrm::new(20.0, 20.0, 30.0, 30.0)),+        ..Default::default()+    });+    s.add_document(DocState::new(p, None, "Destination".into()));+    let destination = s.active_index().unwrap();+    s.set_active(source);+    s.execute("text.edit", &json!({"id": 100})).unwrap();+    s.execute("text.selectAll", &json!({})).unwrap();+    s.execute("edit.copy", &json!({})).unwrap();+    s.set_active(destination);+    s.execute("text.edit", &json!({"id": 200})).unwrap();+    s.execute("edit.paste", &json!({})).unwrap();+    let fitted = s.doc().unwrap().shape(ShapeId(200)).unwrap().xfrm.unwrap();+    cmd::text::fit_text_box(&mut s, ShapeId(200)).unwrap();+    let refitted = s.doc().unwrap().shape(ShapeId(200)).unwrap().xfrm.unwrap();+    assert!((fitted.w - refitted.w).abs() < 0.01, "paste must fit once with the actual face, not retain fallback dimensions");+}++#[test]+fn pasting_shape_as_plain_text_does_not_import_its_font() {+    let mut s = Session::new();+    let source = open(&mut s, "source.pptx", &deck(Some(700)));+    s.execute("file.openBytes", &json!({"name": "unembedded.pptx", "data": deck(None)})).unwrap();+    let destination = s.active_index().unwrap();+    s.set_active(source);+    s.execute("edit.select", &json!({"ids": [100]})).unwrap();+    s.execute("edit.copy", &json!({})).unwrap();+    s.set_active(destination);+    s.execute("text.edit", &json!({"id": 100})).unwrap();+    s.execute("edit.paste", &json!({})).unwrap();+    assert!(s.doc().unwrap().doc.embedded_fonts.is_empty(), "plain text must not change the destination's existing family resolution");+    assert!(s.doc().unwrap().doc.font_aliases.is_empty());+}++#[test]+fn pasted_slide_fonts_follow_the_resulting_layout_not_the_current_slide() {+    let mut s = Session::new();+    let source = open(&mut s, "source.pptx", &deck(Some(700)));+    let source_layout = s.doc().unwrap().doc.slides[0].layout;+    let mut p = Presentation::default();+    let mut other = (*p.masters[0]).clone();+    other.id = deckcraft_model::MasterId(9001);+    other.theme.fonts.minor.latin = FAMILY.into();+    other.layouts[0].id = source_layout;+    other.shapes.clear();+    other.layouts[0].shapes.clear();+    let first = Arc::make_mut(&mut p.masters[0]);+    first.layouts[0].id = deckcraft_model::LayoutId(9000);+    first.shapes.clear();+    first.layouts[0].shapes.clear();+    let slide = Arc::make_mut(&mut p.slides[0]);+    slide.layout = deckcraft_model::LayoutId(9000);+    slide.shapes.clear();+    p.masters.push(Arc::new(other));+    s.add_document(DocState::new(p, None, "Multi-master destination".into()));+    let destination = s.active_index().unwrap();+    s.set_active(source);+    s.edit(|doc, _| {+        let slide = Arc::make_mut(&mut doc.slides[0]);+        slide.shapes.retain(|shape| shape.id == ShapeId(101));+        let props = &mut slide.shapes[0].text.as_mut().unwrap().paragraphs[0].runs[0].props;+        props.font_ea = None;+        props.font_cs = None;+        props.font_sym = None;+        Ok(())+    })+    .unwrap();+    s.execute("edit.copy", &json!({"scope": "slides"})).unwrap();+    s.set_active(destination);+    s.execute("edit.paste", &json!({})).unwrap();+    let placed = deckcraft_render::place_slide(&s.doc().unwrap().doc, 1);+    let line = &placed.texts[0].layout.lines[0];+    assert!((line.extent.1 - line.extent.0 - 84.0).abs() < 0.01, "the copied slide's retained layout resolves its theme font");+}++#[test]+fn pasted_placeholder_fonts_follow_the_resulting_nonplaceholder_style() {+    let mut s = Session::new();+    let source = open(&mut s, "source.pptx", &deck(Some(700)));+    s.edit(|doc, _| {+        let shape = &mut Arc::make_mut(&mut doc.slides[0]).shapes[0];+        shape.ph = Some(deckcraft_model::Placeholder { kind: deckcraft_model::PhType::Title, ..Default::default() });+        let props = &mut shape.text.as_mut().unwrap().paragraphs[0].runs[0].props;+        props.font = None;+        props.font_ea = None;+        props.font_cs = None;+        props.font_sym = None;+        Ok(())+    })+    .unwrap();+    let mut destination = Presentation::default();+    let mut level = deckcraft_model::text::LevelStyle::default();+    level.run.font = Some(FAMILY.into());+    destination.default_text_style.set(0, level);+    let master = Arc::make_mut(&mut destination.masters[0]);+    let mut title = deckcraft_model::text::LevelStyle::default();+    title.run.font = Some("Unembedded placeholder family".into());+    master.title_style.set(0, title);+    master.shapes.clear();+    master.layouts[0].shapes.clear();+    Arc::make_mut(&mut destination.slides[0]).shapes.clear();+    s.add_document(DocState::new(destination, None, "Destination".into()));+    let destination = s.active_index().unwrap();+    s.set_active(source);+    s.execute("edit.select", &json!({"ids": [100]})).unwrap();+    s.execute("edit.copy", &json!({})).unwrap();+    s.set_active(destination);+    s.execute("edit.paste", &json!({})).unwrap();+    let p = &s.doc().unwrap().doc;+    assert!(p.slides[0].shapes[0].ph.is_none());+    let placed = deckcraft_render::place_slide(p, 0);+    let line = &placed.texts[0].layout.lines[0];+    assert!((line.extent.1 - line.extent.0 - 84.0).abs() < 0.01, "pasted nonplaceholder uses its resulting default font");+}++#[test]+fn pasted_text_retains_embedded_theme_script_family() {+    const ARABIC: &str = "DeckCraft Pasted Arabic";+    let mut source = Presentation::default();+    let id = source.add_media("arabic.ttf", "font/ttf", slot_font(ARABIC, 930, &[0x0627], false)).unwrap();+    source.embedded_fonts.push(EmbeddedFont { typeface: ARABIC.into(), regular: Some(id), ..Default::default() });+    let mut destination = source.clone();+    destination.embedded_fonts.clear();+    destination.media.clear();+    for p in [&mut source, &mut destination] {+        let master = Arc::make_mut(&mut p.masters[0]);+        master.shapes.clear();+        master.layouts[0].shapes.clear();+        master.theme.fonts.minor.cs.clear();+        master.theme.fonts.minor.scripts = vec![deckcraft_model::theme::ScriptFont { script: "Arab".into(), typeface: ARABIC.into() }];+        Arc::make_mut(&mut p.slides[0]).shapes.clear();+    }+    let mut body = TextBody::from_text("اا");+    body.body.autofit = Some(AutoFit::None);+    body.paragraphs[0].props.bullet = Some(Bullet::None);+    body.paragraphs[0].runs[0].props.size = Some(40.0);+    Arc::make_mut(&mut source.slides[0]).shapes.push(Shape {+        id: ShapeId(100),+        xfrm: Some(Xfrm::new(20.0, 20.0, 400.0, 70.0)),+        text: Some(body),+        text_box: true,+        ..Default::default()+    });+    let mut s = Session::new();+    s.add_document(DocState::new(source, None, "Source".into()));+    s.execute("edit.select", &json!({"ids": [100]})).unwrap();+    s.execute("edit.copy", &json!({})).unwrap();+    s.add_document(DocState::new(destination, None, "Destination".into()));+    s.execute("edit.paste", &json!({})).unwrap();+    let placed = deckcraft_render::place_slide(&s.doc().unwrap().doc, 0);+    let line = &placed.texts[0].layout.lines[0];+    assert!((line.extent.1 - line.extent.0 - 74.4).abs() < 0.01, "script family must survive a source-only font transfer");+}++#[test]+fn rich_text_paste_does_not_import_replaced_paragraph_defaults() {+    let mut s = Session::new();+    let source = open(&mut s, "source.pptx", &deck(Some(700)));+    let mut destination_doc = (*s.doc().unwrap().doc).clone();+    destination_doc.embedded_fonts.clear();+    s.add_document(DocState::new(destination_doc, None, "Destination".into()));+    let destination = s.active_index().unwrap();+    for (index, family) in [(source, FAMILY), (destination, "Unembedded destination defaults")] {+        s.set_active(index);+        s.edit(|doc, _| {+            for master in &mut doc.masters {+                let master = Arc::make_mut(master);+                for set in [&mut master.theme.fonts.major, &mut master.theme.fonts.minor] {+                    set.latin = "Unembedded theme".into();+                    set.ea.clear();+                    set.cs.clear();+                }+            }+            let para = &mut Arc::make_mut(&mut doc.slides[0]).shapes[0].text.as_mut().unwrap().paragraphs[0];+            para.def_props.font = Some(family.into());+            para.runs[0].props.font = None;+            para.runs[0].props.font_ea = None;+            para.runs[0].props.font_cs = None;+            para.runs[0].props.font_sym = None;+            Ok(())+        })+        .unwrap();+    }+    s.set_active(source);+    s.execute("text.edit", &json!({"id": 100})).unwrap();+    s.execute("text.selectAll", &json!({})).unwrap();+    s.execute("edit.copy", &json!({})).unwrap();+    s.set_active(destination);+    s.execute("text.edit", &json!({"id": 100})).unwrap();+    s.execute("edit.paste", &json!({})).unwrap();+    assert!(s.doc().unwrap().doc.font_aliases.is_empty(), "discarded source paragraph defaults must not import a font");+    assert!(s.doc().unwrap().doc.embedded_fonts.is_empty());+}diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs--- a/crates/fonts/src/fallback.rs+++ b/crates/fonts/src/fallback.rs@@ -151,7 +151,7 @@ impl FontDb {         {             return f;         }-        let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));+        let first = || chain.first().cloned().unwrap_or_else(|| self.public_face(crate::DEFAULT_FAMILY, style));         let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };         let script = cluster.iter().find_map(|c| script_of(*c));         // Scripts with a platform cascade (Arabic, Hebrew, Thai, Devanagari): the bundled face,@@ -161,7 +161,7 @@ impl FontDb {             let bundled = bundled_families(s);             for fam in &bundled {                 let f = self.face(fam, style);-                if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {+                if !f.is_document_font() && f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {                     return f;                 }             }@@ -171,7 +171,7 @@ impl FontDb {                 }                 if self.is_loaded(fam) || self.has_family(fam) {                     let f = self.face(fam, style);-                    if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {+                    if !f.is_document_font() && f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {                         return f;                     }                 }@@ -191,7 +191,7 @@ impl FontDb {             _ => {                 // Searched before `first` runs: it may load the default family, which needs the                 // faces' write lock, so the read lock must not be held across it.-                let found = self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned();+                let found = self.read_faces().iter().find(|f| !f.is_document_font() && !f.is_color() && covers_cluster(f, cluster)).cloned();                 found.unwrap_or_else(first)             }         };@@ -214,6 +214,7 @@ impl FontDb {             let faces = db.read_faces();             let mut v: Vec<&Arc<FontFace>> = faces                 .iter()+                .filter(|f| !f.is_document_font())                 .filter(|f| !bundled.iter().any(|b| f.family.eq_ignore_ascii_case(b)) && !CRAFT_FONTS.iter().any(|c| c.family == f.family))                 .filter(|f| covers_cluster(f, cluster))                 .collect();diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs--- a/crates/fonts/src/fontdb.rs+++ b/crates/fonts/src/fontdb.rs@@ -108,10 +108,12 @@ enum FontBytes { /// One loaded font face. pub struct FontFace {     id: u32,-    /// Typographic family name (e.g. "Source Sans 3").+    /// Lookup family: the typographic family for public faces, a private alias for document fonts.     pub family: String,     /// Typographic style name (e.g. "Semibold", "Italic").     pub style: String,+    /// The document's declared family for a private alias; never a global lookup name.+    source_family: Option<String>,     /// The Windows (GDI) family and style names (`name` IDs 1 and 2) when the family differs from     /// the typographic one: "Segoe UI Semibold" / "Regular" for Segoe UI Semibold. Documents name     /// fonts this way.@@ -208,6 +210,14 @@ impl std::fmt::Debug for FontFace { }  impl FontFace {+    /// The document's original font name, or the ordinary face's family. This identity is for+    /// source-specific text handling, not font lookup; document faces are looked up by `family`.+    pub fn source_family(&self) -> &str {+        self.source_family.as_deref().unwrap_or(&self.family)+    }+    pub(crate) fn is_document_font(&self) -> bool {+        self.source_family.is_some()+    }     pub fn data(&self) -> &[u8] {         match &self.bytes {             FontBytes::Static(b) => b,@@ -691,6 +701,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords         id: NEXT_ID.fetch_add(1, Ordering::Relaxed),         family,         style,+        source_family: None,         legacy,         weight,         italic,@@ -832,9 +843,9 @@ impl FontDb {         self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner)     } -    /// Family names available (loaded plus cataloged system fonts), sorted and deduplicated.+    /// Public family names available (loaded plus cataloged system fonts), sorted and deduplicated.     pub fn families(&self) -> Vec<String> {-        let mut v: Vec<String> = self.read_faces().iter().map(|f| f.family.clone()).collect();+        let mut v: Vec<String> = self.read_faces().iter().filter(|f| !f.is_document_font()).map(|f| f.family.clone()).collect();         #[cfg(not(target_arch = "wasm32"))]         v.extend(self.read_catalog().iter().map(|c| c.family.clone()));         v.sort_by_key(|a| a.to_lowercase());@@ -864,23 +875,62 @@ impl FontDb {     /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or     /// every face was already present).     pub fn add_font(&self, bytes: Vec<u8>) -> usize {-        self.add_fonts(vec![bytes])+        self.add_fonts(std::iter::once((bytes, None)))+    }++    /// Register `(bytes, source_family, private_alias)` font files without exposing their original+    /// typographic or Windows family names. Faces matching either source name are selected first;+    /// if none match, a single-family file accepts the document's declared name.+    ///+    /// All selected styles are published together. Returns the number of faces added (0 for+    /// unparseable files, unmatched multi-family collections, empty aliases or existing styles).+    /// Names are trimmed; family/style deduplication is case-insensitive, as with [`Self::add_font`].+    /// Aliases are caller-chosen lookup names, not access controls: use a distinct alias for each+    /// document's subset/content, and resolve its text through that alias. They are excluded from+    /// global family discovery and fallback; explicit alias lookup and styles remain available.+    pub fn add_document_fonts(&self, files: Vec<(Vec<u8>, String, String)>) -> usize {+        self.add_fonts(files.into_iter().map(|(bytes, family, alias)| (bytes, Some((family, alias)))))     }      /// Add font files together: lookups see all of their faces or none, never part of a family.     /// Returns the number of faces added.-    fn add_fonts(&self, files: Vec<Vec<u8>>) -> usize {+    fn add_fonts(&self, files: impl IntoIterator<Item = (Vec<u8>, Option<(String, String)>)>) -> usize {         let present = |faces: &[Arc<FontFace>], family: &str, style: &str| {             faces.iter().any(|f| f.family.eq_ignore_ascii_case(family) && f.style.eq_ignore_ascii_case(style))         };         let mut new = Vec::new();-        for bytes in files {+        for (bytes, registration) in files {+            if registration.as_ref().is_some_and(|(_, alias)| alias.trim().is_empty()) {+                continue;+            }             let data = Arc::new(bytes);-            for (i, family, style, coords) in enumerate_faces(&data) {+            let found = enumerate_faces(&data);+            let matches = |index, family: &str, source: &str| {+                family.eq_ignore_ascii_case(source.trim())+                    || skrifa::FontRef::from_index(&data, index)+                        .ok()+                        .and_then(|f| name(&f, &[StringId::FAMILY_NAME]))+                        .is_some_and(|legacy| legacy.eq_ignore_ascii_case(source.trim()))+            };+            let source_matches = registration.as_ref().is_some_and(|(source, _)| found.iter().any(|(i, family, _, _)| matches(*i, family, source)));+            let single_family = registration.is_some()+                && !source_matches+                && found.first().is_some_and(|(_, first, _, _)| found.iter().all(|(_, family, _, _)| family.eq_ignore_ascii_case(first)));+            for (i, mut family, style, coords) in found {+                if let Some((source, alias)) = &registration {+                    if (source_matches && !matches(i, &family, source)) || (!source_matches && !single_family) {+                        continue;+                    }+                    family = alias.trim().to_owned();+                }                 if present(&self.read_faces(), &family, &style) {                     continue;                 }-                if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, coords) {+                if let Some(mut f) = make_face(FontBytes::Owned(data.clone()), i, family, style, coords) {+                    if let Some((source, _)) = &registration {+                        f.source_family = Some(source.trim().to_owned());+                        f.legacy = None;+                    }                     new.push(Arc::new(f));                 }             }@@ -999,7 +1049,13 @@ impl FontDb {             p.dedup();             p         };-        self.add_fonts(paths.iter().filter(|p| std::fs::metadata(p).is_ok_and(|m| font_file_ok(&m))).filter_map(|p| std::fs::read(p).ok()).collect());+        self.add_fonts(+            paths+                .iter()+                .filter(|p| std::fs::metadata(p).is_ok_and(|m| font_file_ok(&m)))+                .filter_map(|p| std::fs::read(p).ok())+                .map(|bytes| (bytes, None)),+        );         self.is_loaded(family)     } @@ -1007,22 +1063,31 @@ impl FontDb {     /// an open substitute ([`substitutes`]), then to Inter. Installed system fonts are found by name     /// whatever ran before.     pub fn face(&self, family: &str, style: &str) -> Arc<FontFace> {-        if let Some(f) = self.try_family(family, style) {+        self.resolve_face(family, style, true)+    }++    pub(crate) fn public_face(&self, family: &str, style: &str) -> Arc<FontFace> {+        self.resolve_face(family, style, false)+    }++    fn resolve_face(&self, family: &str, style: &str, allow_document: bool) -> Arc<FontFace> {+        if let Some(f) = self.try_family(family, style).filter(|f| allow_document || !f.is_document_font()) {             return f;         }         for sub in substitutes(family) {-            if let Some(f) = self.try_family(sub, style) {+            if let Some(f) = self.try_family(sub, style).filter(|f| !f.is_document_font()) {                 return f;             }         }         for sub in [FALLBACK_FAMILY, "Helvetica Neue", "Arial", "Liberation Sans", "DejaVu Sans", "Segoe UI"] {-            if let Some(f) = self.try_family(sub, style) {+            if let Some(f) = self.try_family(sub, style).filter(|f| !f.is_document_font()) {                 return f;             }         }         self.find(LAST_RESORT_FAMILY, "Regular")-            .or_else(|| self.find(FALLBACK_FAMILY, "Regular"))-            .or_else(|| self.read_faces().first().cloned())+            .filter(|f| !f.is_document_font())+            .or_else(|| self.find(FALLBACK_FAMILY, "Regular").filter(|f| !f.is_document_font()))+            .or_else(|| self.read_faces().iter().find(|f| !f.is_document_font()).cloned())             .unwrap_or_else(last_resort_face)     } @@ -1036,9 +1101,9 @@ impl FontDb {             return self.face(family, style);         }         if let Some(twin) = office_twin(family) {-            return self.face(twin, style);+            return self.public_face(twin, style);         }-        self.face(MISSING_FONT_FAMILY, style)+        self.public_face(MISSING_FONT_FAMILY, style)     }      fn try_family(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {@@ -1110,7 +1175,11 @@ impl FontDb {                 None => coords.push((tag, v)),             }         }-        let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?;+        let mut f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?;+        f.source_family = base.source_family.clone();+        if f.is_document_font() {+            f.legacy = None;+        }         let f = Arc::new(f);         self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(f.clone());         Some(f)@@ -1195,7 +1264,7 @@ impl FontDb {      fn loaded_fallback_filtered(&self, c: char, exclude: u32, accept: &impl Fn(&FontFace) -> bool) -> Option<Arc<FontFace>> {         let faces = self.read_faces();-        let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();+        let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| !f.is_document_font() && f.id != exclude).collect();         order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));         order.into_iter().find(|f| f.covers(c) && accept(f)).cloned()     }@@ -1219,7 +1288,7 @@ impl FontDb {                 return false;             }         }-        let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f));+        let covered = |db: &FontDb| db.read_faces().iter().any(|f| !f.is_document_font() && f.covers(c) && accept(f));         let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();         let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);         // Another thread may have loaded a face for `c` (faces are only ever added), or found@@ -1264,7 +1333,8 @@ impl FontDb {     pub(crate) fn color_face_for(&self, key: char, cluster: &[char]) -> Option<Arc<FontFace>> {         let pick = |db: &FontDb| {             let faces = db.read_faces();-            let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| crate::covers_cluster(f, cluster) && f.is_color()).collect();+            let mut v: Vec<&Arc<FontFace>> =+                faces.iter().filter(|f| !f.is_document_font() && crate::covers_cluster(f, cluster) && f.is_color()).collect();             v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));             v.first().map(|f| (*f).clone())         };@@ -1296,7 +1366,8 @@ impl FontDb {         };         let pick = |db: &FontDb| {             let faces = db.read_faces();-            let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| !f.is_color() && crate::covers_cluster(f, cluster)).collect();+            let mut v: Vec<&Arc<FontFace>> =+                faces.iter().filter(|f| !f.is_document_font() && !f.is_color() && crate::covers_cluster(f, cluster)).collect();             v.sort_by_key(|f| (rank(f), f.italic, (f.weight - 400.0).abs() as i32));             v.first().map(|f| (*f).clone())         };diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs--- a/crates/fonts/src/lib.rs+++ b/crates/fonts/src/lib.rs@@ -199,6 +199,311 @@ mod tests {         assert!(!fontdb::last_resort_face().is_color());     } +    #[test]+    fn document_aliases_isolate_different_contents_without_publishing_source_names() {+        let bytes = include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec();+        let mut wider = bytes.clone();+        // Give "A" a different advance without changing either family name or the style.+        let tables = u16::from_be_bytes([wider[4], wider[5]]) as usize;+        let hmtx = (0..tables).map(|i| 12 + 16 * i).find(|&r| &wider[r..r + 4] == b"hmtx").unwrap();+        let offset = u32::from_be_bytes(wider[hmtx + 8..hmtx + 12].try_into().unwrap()) as usize;+        wider[offset + 4..offset + 6].copy_from_slice(&900_u16.to_be_bytes());+        let db = FontDb::with_font_dirs(Vec::new());+        db.set_system_fallback(false);+        let files = vec![+            (bytes.clone(), "DeckCraft Legacy".into(), "document-a".into()),+            (wider.clone(), "DeckCraft Legacy".into(), "document-b".into()),+            (bytes.clone(), " deckcraft legacy ".into(), " DOCUMENT-A ".into()),+        ];+        assert_eq!(db.add_document_fonts(files.clone()), 2);+        let a = db.face("document-a", "Semibold");+        let b = db.face("document-b", "Semibold");+        assert_ne!(a.id(), b.id());+        assert_eq!(a.data(), bytes);+        assert_eq!(b.data(), wider);+        assert_eq!(shape(&a, "A", &[], |c| c)[0].x_advance, 700);+        assert_eq!(shape(&b, "A", &[], |c| c)[0].x_advance, 900);+        assert_eq!((a.style.as_str(), a.weight), ("Semibold", 600.0));+        assert!(a.legacy.is_none() && b.legacy.is_none());+        assert_eq!(a.source_family(), "DeckCraft Legacy");+        assert_eq!(b.source_family(), "DeckCraft Legacy");+        assert_eq!(db.add_document_fonts(files), 0);+        assert_eq!(db.face("document-a", "Semibold").id(), a.id());+        assert_eq!(db.styles("document-a"), ["Semibold"]);+        for source in ["DeckCraft Legacy", "DeckCraft Legacy Semibold"] {+            assert!(!db.is_loaded(source));+            assert!(!db.has_family(source));+            assert!(!db.families().iter().any(|name| name == source));+            let unembedded = db.face(source, "Regular");+            assert_ne!(unembedded.id(), a.id());+            assert_ne!(unembedded.id(), b.id());+        }+        // Ordinary user registration still publishes both names and keeps its original dedup rule.+        assert_eq!(db.add_font(bytes.clone()), 1);+        assert_eq!(db.add_font(bytes), 0);+        assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy");+        assert_eq!(db.face("DeckCraft Legacy", "Semibold").source_family(), "DeckCraft Legacy");+        assert_eq!(db.face("document-b", "Semibold").id(), b.id());+    }++    fn font_with_table(source: &[u8], tag: &[u8; 4], table: &[u8]) -> Vec<u8> {+        // Rewrite only the table directory of an existing CC0 fixture; no font assets are added.+        let count = u16::from_be_bytes([source[4], source[5]]) as usize;+        let mut records = source[12..12 + count * 16].as_chunks::<16>().0.to_vec();+        let extra = !records.iter().any(|r| &r[..4] == tag);+        let shift = if extra { 16 } else { 0 };+        let mut font = source.to_vec();+        if extra {+            drop(font.splice(12..12, [0; 16]));+            for record in &mut records {+                let offset = u32::from_be_bytes(record[8..12].try_into().unwrap()) + shift;+                record[8..12].copy_from_slice(&offset.to_be_bytes());+            }+        }+        while !font.len().is_multiple_of(4) {+            font.push(0);+        }+        let mut record = [0; 16];+        record[..4].copy_from_slice(tag);+        record[8..12].copy_from_slice(&(font.len() as u32).to_be_bytes());+        record[12..16].copy_from_slice(&(table.len() as u32).to_be_bytes());+        records.retain(|r| &r[..4] != tag);+        records.push(record);+        records.sort_by_key(|r| [r[0], r[1], r[2], r[3]]);+        let count = records.len() as u16;+        let selector = count.ilog2() as u16;+        let search_range = (1_u16 << selector) * 16;+        for (offset, value) in [(4, count), (6, search_range), (8, selector), (10, count * 16 - search_range)] {+            font[offset..offset + 2].copy_from_slice(&value.to_be_bytes());+        }+        for (index, record) in records.iter().enumerate() {+            font[12 + index * 16..28 + index * 16].copy_from_slice(record);+        }+        font.extend_from_slice(table);+        font+    }++    fn font_with_cmap(source: &[u8], mappings: &[(char, u32)]) -> Vec<u8> {+        let mut mappings = mappings.to_vec();+        mappings.sort_by_key(|(c, _)| *c);+        // Windows Unicode full-repertoire cmap, with one format-12 group per mapped character.+        let mut cmap = b"\0\0\0\x01\0\x03\0\x0a\0\0\0\x0c\0\x0c\0\0".to_vec();+        for value in [16 + mappings.len() as u32 * 12, 0, mappings.len() as u32] {+            cmap.extend_from_slice(&value.to_be_bytes());+        }+        for (c, glyph) in mappings {+            for value in [c as u32, c as u32, glyph] {+                cmap.extend_from_slice(&value.to_be_bytes());+            }+        }+        font_with_table(source, b"cmap", &cmap)+    }++    #[test]+    fn document_aliases_are_explicit_only_for_discovery_and_plain_fallback() {+        let probe = '\u{10FFFD}';+        let bytes = font_with_cmap(include_bytes!("../tests/fixtures/legacy-regular.ttf"), &[('A', 1), ('ا', 1), (probe, 1)]);+        let db = FontDb::with_font_dirs(Vec::new());+        db.set_system_fallback(false);+        let ordinary = db.face(LAST_RESORT_FAMILY, "Regular");+        assert!(ordinary.covers('A'));+        let chain = std::slice::from_ref(&ordinary);+        // Exercise single-character fallback, whole-cluster scanning, the platform-script+        // cascade, and text-presentation fallback without borrowing the document's face.+        let clusters = [vec![probe], vec!['A', probe], vec!['ا', probe], vec![probe, '\u{FE0E}']];+        let cascades: Vec<u32> = clusters.iter().map(|c| db.cascade(c, chain, "Regular").id()).collect();+        let families = db.families();+        let fallback = db.fallback_for(probe, u32::MAX).map(|f| f.id());+        let plain = db.plain_face_for(probe, &[probe]).map(|f| f.id());+        assert_eq!(db.add_document_fonts(vec![(bytes.clone(), " DeckCraft Legacy ".into(), "private-plain".into())]), 1);+        let private = db.document_face("private-plain", "Regular");+        assert_eq!(private.family, "private-plain");+        assert_eq!(private.source_family(), "DeckCraft Legacy");+        assert_eq!(db.styles("private-plain"), ["Regular"]);+        assert!(db.has_family("private-plain"));+        assert_eq!(db.families(), families);+        assert!(!db.has_family("DeckCraft Legacy"));+        assert_eq!(db.fallback_for(probe, u32::MAX).map(|f| f.id()), fallback);+        assert_eq!(db.plain_face_for(probe, &[probe]).map(|f| f.id()), plain);+        assert!(db.fallback_for_filtered(probe, u32::MAX, |f| f.id() == private.id()).is_none());+        for (cluster, before) in clusters.iter().zip(cascades) {+            assert_eq!(db.cascade(cluster, chain, "Regular").id(), before);+            assert_eq!(db.cascade(cluster, std::slice::from_ref(&private), "Regular").id(), private.id());+        }+        let shaped = shape(&private, &probe.to_string(), &[], |c| c);+        assert_eq!((shaped[0].gid, shaped[0].x_advance), (1, 700));+        // The same synthesized font remains globally discoverable when explicitly added as a user font.+        assert_eq!(db.add_font(bytes), 1);+        let public = db.face("DeckCraft Legacy", "Regular");+        assert!(db.families().iter().any(|family| family == "DeckCraft Legacy"));+        assert_eq!(db.fallback_for_filtered(probe, u32::MAX, |f| f.id() == public.id()).unwrap().id(), public.id());+    }++    #[test]+    fn document_colour_aliases_do_not_enter_global_emoji_fallback() {+        let probe = '\u{10FFFD}';+        let emoji = '😀';+        let bytes = font_with_cmap(include_bytes!("../../render/tests/fixtures/colr-squares.ttf"), &[(emoji, 2), (probe, 2)]);+        let db = FontDb::with_font_dirs(Vec::new());+        db.set_system_fallback(false);+        let ordinary = db.face(LAST_RESORT_FAMILY, "Regular");+        let before = db.cascade(&[emoji], std::slice::from_ref(&ordinary), "Regular").id();+        let colour = db.color_face_for(probe, &[probe]).map(|f| f.id());+        let families = db.families();+        assert_eq!(db.add_document_fonts(vec![(bytes.clone(), "DeckCraft Colr Squares".into(), "private-colour".into())]), 1);+        let private = db.face("private-colour", "Regular");+        assert!(private.is_color() && private.covers(emoji));+        assert_eq!(db.families(), families);+        assert_eq!(db.color_face_for(probe, &[probe]).map(|f| f.id()), colour);+        assert_eq!(db.cascade(&[emoji], std::slice::from_ref(&ordinary), "Regular").id(), before);+        assert_eq!(db.cascade(&[emoji], std::slice::from_ref(&private), "Regular").id(), private.id());+        assert!(db.fallback_for_filtered(probe, u32::MAX, |f| f.id() == private.id()).is_none());+        let shaped = shape(&private, &probe.to_string(), &[], |c| c);+        assert_eq!((shaped[0].gid, shaped[0].x_advance), (2, 1000));+        assert_eq!(db.add_font(bytes), 1);+        let public = db.face("DeckCraft Colr Squares", "Regular");+        assert!(public.is_color());+        assert_eq!(db.fallback_for_filtered(probe, u32::MAX, |f| f.id() == public.id()).unwrap().id(), public.id());+    }++    #[test]+    fn document_variable_instances_preserve_private_source_identity() {+        let probe = '\u{10FFFD}';+        let bytes = font_with_cmap(include_bytes!("../tests/fixtures/legacy-regular.ttf"), &[(probe, 1)]);+        // One real fvar weight axis; outlines stay those of the existing CC0 square fixture.+        let mut fvar = b"\0\x01\0\0\0\x10\0\x02\0\x01\0\x14\0\0\0\x08wght".to_vec();+        for value in [100_i32, 400, 900] {+            fvar.extend_from_slice(&(value << 16).to_be_bytes());+        }+        fvar.extend_from_slice(&[0, 0, 1, 0]);+        let bytes = font_with_table(&bytes, b"fvar", &fvar);+        let db = FontDb::with_font_dirs(Vec::new());+        db.set_system_fallback(false);+        assert_eq!(db.add_document_fonts(vec![(bytes.clone(), "Declared variable".into(), "private-variable".into())]), 1);+        assert_eq!(db.axes("private-variable", "Regular").len(), 1);+        let base = db.face("private-variable", "Regular");+        let instance = db.face("private-variable", "Regular {wght:650}");+        assert_ne!(instance.id(), base.id());+        assert_eq!(instance.weight, 650.0);+        assert_eq!(instance.source_family(), "Declared variable");+        assert!(instance.legacy.is_none());+        assert_eq!(db.face("private-variable", "Regular {wght:650}").id(), instance.id());+        assert_eq!(db.styles("private-variable"), ["Regular"]);+        assert!(!db.families().iter().any(|family| family == "private-variable"));+        assert!(db.fallback_for_filtered(probe, u32::MAX, |f| f.id() == instance.id()).is_none());+        assert_eq!(db.cascade(&[probe], std::slice::from_ref(&instance), "Regular").id(), instance.id());+        assert_eq!(shape(&instance, &probe.to_string(), &[], |c| c)[0].gid, 1);+        assert_eq!(db.add_font(bytes), 1);+        let public = db.face("DeckCraft Legacy", "Regular {wght:650}");+        assert_eq!(public.source_family(), "DeckCraft Legacy");+        assert_eq!(db.fallback_for_filtered(probe, u32::MAX, |f| f.id() == public.id()).unwrap().id(), public.id());+    }++    #[test]+    fn private_aliases_matching_global_fallback_names_are_not_implicitly_selected() {+        let bytes = include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec();+        let db = FontDb::with_font_dirs(Vec::new());+        db.set_system_fallback(false);+        assert_eq!(+            db.add_document_fonts(vec![+                (bytes.clone(), "DeckCraft Legacy".into(), "Calibri".into()),+                (bytes, "DeckCraft Legacy".into(), "Arial".into()),+            ]),+            2+        );+        let aliases = [db.face("Calibri", "Regular").id(), db.face("Arial", "Regular").id()];+        for requested in ["Unknown document family", "Arial CE"] {+            assert!(!aliases.contains(&db.document_face(requested, "Regular").id()), "{requested}");+        }+        for requested in ["Unknown family", "Helvetica", "Calibri Light"] {+            assert!(!aliases.contains(&db.face(requested, "Regular").id()), "{requested}");+        }+        assert!(!db.families().iter().any(|family| matches!(family.as_str(), "Calibri" | "Arial")));+    }++    fn font_collection(sources: [&[u8]; 2]) -> Vec<u8> {+        // Build a TTC in memory from the existing contributor-original CC0 test fonts.+        let mut collection = b"ttcf\0\x01\0\0\0\0\0\x02\0\0\0\0\0\0\0\0".to_vec();+        for (index, source) in sources.iter().enumerate() {+            while !collection.len().is_multiple_of(4) {+                collection.push(0);+            }+            let base = collection.len() as u32;+            collection[12 + index * 4..16 + index * 4].copy_from_slice(&base.to_be_bytes());+            let mut font = source.to_vec();+            let tables = u16::from_be_bytes([font[4], font[5]]) as usize;+            for table in 0..tables {+                let pos = 12 + table * 16 + 8;+                let offset = u32::from_be_bytes(font[pos..pos + 4].try_into().unwrap()) + base;+                font[pos..pos + 4].copy_from_slice(&offset.to_be_bytes());+            }+            collection.extend_from_slice(&font);+        }+        collection+    }++    #[test]+    fn document_collection_selects_declared_family_without_style_collisions() {+        let collection =+            font_collection([include_bytes!("../tests/fixtures/legacy-regular.ttf"), include_bytes!("../../render/tests/fixtures/colr-squares.ttf")]);+        let db = FontDb::with_font_dirs(Vec::new());+        assert_eq!(db.add_document_fonts(vec![(collection.clone(), "Unknown".into(), "not-added".into())]), 0);+        assert!(!db.is_loaded("not-added"));+        assert_eq!(+            db.add_document_fonts(vec![+                (collection.clone(), "DeckCraft Legacy".into(), "document-legacy".into()),+                (collection, "DeckCraft Colr Squares".into(), "document-colour".into()),+            ]),+            2+        );+        let legacy = db.face("document-legacy", "Regular");+        let colour = db.face("document-colour", "Regular");+        assert_eq!((legacy.index(), colour.index()), (0, 1));+        assert_eq!((legacy.style.as_str(), colour.style.as_str()), ("Regular", "Regular"));+        assert!(!legacy.is_color());+        assert!(colour.is_color());+        assert!(legacy.legacy.is_none() && colour.legacy.is_none());+        assert!(!db.is_loaded("DeckCraft Legacy"));+        assert!(!db.is_loaded("DeckCraft Colr Squares"));+    }++    #[test]+    fn document_collection_matches_windows_name_before_single_family_fallback() {+        let collection =+            font_collection([include_bytes!("../tests/fixtures/legacy-regular.ttf"), include_bytes!("../tests/fixtures/legacy-semibold.ttf")]);+        let db = FontDb::with_font_dirs(Vec::new());+        assert_eq!(db.add_document_fonts(vec![(collection.clone(), " deckcraft legacy semibold ".into(), "document-semibold".into())]), 1);+        let face = db.face("document-semibold", "Regular");+        assert_eq!((face.index(), face.style.as_str(), face.weight), (1, "Semibold", 600.0));+        assert!(face.legacy.is_none());+        assert_eq!(db.styles("document-semibold"), ["Semibold"]);+        assert!(!db.is_loaded("DeckCraft Legacy"));+        assert!(!db.is_loaded("DeckCraft Legacy Semibold"));+        // A typographic-family match selects all styles. A renamed single-family subset does too.+        assert_eq!(+            db.add_document_fonts(vec![+                (collection.clone(), "DeckCraft Legacy".into(), "document-all".into()),+                (collection, "Renamed subset".into(), "document-renamed".into()),+            ]),+            4+        );+        for alias in ["document-all", "document-renamed"] {+            assert_eq!(db.styles(alias), ["Regular", "Semibold"]);+            assert_eq!(db.face(alias, "Regular").weight, 400.0);+            assert_eq!(db.face(alias, "Semibold").weight, 600.0);+        }+        assert_eq!(+            db.add_document_fonts(vec![+                (include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec(), "Renamed".into(), "single-file".into()),+                (include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec(), "DeckCraft Legacy".into(), " ".into()),+                (vec![0; 12], "Invalid".into(), "invalid".into()),+            ]),+            1+        );+        assert_eq!(db.face("single-file", "Regular").family, "single-file");+        assert!(!db.is_loaded("invalid"));+    }+     #[test]     fn last_resort_face_parses() {         let f = fontdb::last_resort_face();diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs--- a/crates/model/src/defaults.rs+++ b/crates/model/src/defaults.rs@@ -512,6 +512,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen         first_slide_number: 1,         next_id: next.max(256),         embedded_fonts: vec![],+        font_aliases: Arc::default(),         table_styles: vec![],         tags: vec![],         kept_parts: vec![],diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs--- a/crates/model/src/lib.rs+++ b/crates/model/src/lib.rs@@ -996,6 +996,9 @@ pub struct Presentation {     pub next_id: u32,     /// Embedded fonts. The face bytes live in [`Self::media`].     pub embedded_fonts: Vec<EmbeddedFont>,+    /// Runtime-only font aliases for this document's embedded faces. Saved names stay unchanged.+    #[serde(skip)]+    pub font_aliases: Arc<Vec<(String, String)>>,     /// Table styles defined in the file (`ppt/tableStyles.xml`).     #[serde(skip_serializing_if = "Vec::is_empty")]     pub table_styles: Vec<table::TableStyleDef>,@@ -1030,6 +1033,11 @@ impl Default for Presentation { }  impl Presentation {+    /// Resolve a saved family name only at the rendering font-database boundary.+    pub fn render_font_family<'a>(&'a self, family: &'a str) -> &'a str {+        self.font_aliases.iter().find(|(source, _)| source.eq_ignore_ascii_case(family.trim())).map_or(family, |(_, alias)| alias.as_str())+    }+     /// A fresh id, unique across slides, shapes, layouts, masters and media in this deck. `None`     /// once every id is in use (a hostile file can arrive that way): the deck cannot grow     /// further, and callers must refuse the change rather than duplicate ids (#407).diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs--- a/crates/render/src/images.rs+++ b/crates/render/src/images.rs@@ -1,10 +1,10 @@-//! Decoded pictures shared by every render on this process, keyed by the media bytes' address and-//! the requested size level and adjustments, so each picture is decoded once per size.+//! Decoded pictures shared by every render on this process, keyed by the media bytes' address,+//! requested size and adjustments. Metafiles also key the document's private font aliases.  use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex}; -use deckcraft_model::style::PictureAdjust;+use deckcraft_model::{Presentation, style::PictureAdjust}; use vello_cpu::Pixmap;  const BUDGET: usize = 512 << 20;@@ -22,13 +22,15 @@ const BAD_BUDGET: usize = 64 << 20;  struct Entry {     _bytes: Arc<Vec<u8>>,+    // Keep pointer-keyed font scopes alive, just as `_bytes` protects the media address.+    _font_aliases: Option<Arc<Vec<(String, String)>>>,     pm: Arc<Pixmap>,     stamp: u64, }  #[derive(Default)] struct Cache {-    map: HashMap<(usize, u32, u64), Entry>,+    map: HashMap<(usize, u32, u64, usize), Entry>,     bytes: usize,     clock: u64,     /// Failed sources by address; each holds its bytes so the address can't be reused while it@@ -119,16 +121,19 @@ fn duotone_rgb(a: &PictureAdjust) -> Option<(deckcraft_color::Rgba, deckcraft_co /// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF; WMF and EMF drawn at /// [`crate::metafile::DEFAULT_SIDE`]) to straight RGBA. pub fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {-    decode_at(bytes, crate::metafile::DEFAULT_SIDE)+    decode_at(bytes, crate::metafile::DEFAULT_SIDE, None) }  /// [`decode`], with a metafile drawn with its longer side `side` px.-fn decode_at(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {+fn decode_at(bytes: &[u8], side: u32, pres: Option<&Presentation>) -> Option<image::RgbaImage> {     if bytes.len() > 512 << 20 {         return None;     }     if crate::metafile::is_metafile(bytes) {-        return crate::metafile::rasterize(bytes, side);+        return match pres {+            Some(pres) => crate::metafile::rasterize_in(bytes, side, Some(pres)),+            None => crate::metafile::rasterize(bytes, side),+        };     }     decode_raster(bytes, MAX_RGBA) }@@ -319,13 +324,27 @@ fn png_crc(data: &[u8]) -> u32 {  /// A premultiplied pixmap of the picture, downsampled so its longer side is near `want` px. pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Option<Arc<Pixmap>> {+    decode_cached(bytes, want, adj, None)+}++/// Decode slide media with its document's private fonts; raster pictures retain the shared cache.+pub(crate) fn decode_for_presentation(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust, pres: &Presentation) -> Option<Arc<Pixmap>> {+    if pres.font_aliases.is_empty() || !crate::metafile::is_metafile(bytes) {+        return decode_for(bytes, want, adj);+    }+    decode_cached(bytes, want, adj, Some(pres))+}++fn decode_cached(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust, pres: Option<&Presentation>) -> Option<Arc<Pixmap>> {     let addr = Arc::as_ptr(bytes) as usize;     if with(|c| c.is_bad(addr, bytes)) {         return None;     }     // Power-of-two size buckets so zooming doesn't re-decode every frame.     let bucket = (want.max(16.0).log2().ceil() as u32).min(15);-    let key = (addr, bucket, adjust_key(adj));+    let font_aliases = pres.map(|p| &p.font_aliases);+    let scope = font_aliases.map_or(0, |aliases| Arc::as_ptr(aliases) as usize);+    let key = (addr, bucket, adjust_key(adj), scope);     if let Some(pm) = with(|c| {         c.clock += 1;         let clock = c.clock;@@ -337,7 +356,7 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio         return Some(pm);     }     let target = 1u32 << bucket;-    let Some(mut img) = decode_at(bytes, target) else {+    let Some(mut img) = decode_at(bytes, target, pres) else {         with(|c| c.mark_bad(addr, bytes));         return None;     };@@ -354,7 +373,7 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio         c.clock += 1;         let n = w as usize * h as usize * 4;         c.bytes += n;-        c.map.insert(key, Entry { _bytes: bytes.clone(), pm: pm.clone(), stamp: c.clock });+        c.map.insert(key, Entry { _bytes: bytes.clone(), _font_aliases: font_aliases.cloned(), pm: pm.clone(), stamp: c.clock });         while c.bytes > BUDGET && c.map.len() > 1 {             let Some(oldest) = c.map.iter().min_by_key(|(_, e)| e.stamp).map(|(k, _)| *k) else { break };             if let Some(e) = c.map.remove(&oldest) {diff --git a/crates/render/src/metafile.rs b/crates/render/src/metafile.rs--- a/crates/render/src/metafile.rs+++ b/crates/render/src/metafile.rs@@ -2,9 +2,9 @@ //! `deckcraft-metafile` and drawn as vectors into a bitmap of the size the slide needs, so they go //! through the same crop, tile and recolour path as any other picture. //!-//! Text items name the font the file asks for; they are drawn with the face slide text would use-//! for that family (`FontDb::document_face`, so a missing font falls back the same way), symbol-//! fonts mapped as bullets map them, and the file's own advances (the dx array) placing each+//! Text items keep the source family for symbol mapping, while document rendering resolves it+//! through the presentation's private font aliases before `FontDb::document_face` lookup.+//! Missing fonts fall back as slide text does, and the file's own advances (the dx array) place each //! character.  use std::collections::HashMap;@@ -34,6 +34,11 @@ pub fn is_metafile(bytes: &[u8]) -> bool { /// Draw the metafile `bytes` into straight RGBA with its longer side `side` px (transparent where /// it draws nothing). `None` when it doesn't parse or has no size. pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {+    rasterize_in(bytes, side, None)+}++/// Draw with the containing document's private fonts, or ordinary fonts for standalone export.+pub(crate) fn rasterize_in(bytes: &[u8], side: u32, pres: Option<&deckcraft_model::Presentation>) -> Option<image::RgbaImage> {     let mut pic = deckcraft_metafile::parse(bytes).ok()?;     if !pic.has_size() {         return None;@@ -107,7 +112,7 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {                 if let Some(c) = clip {                     ctx.push_clip_layer(&c.to_path(0.1));                 }-                draw_text(ctx, t, transform);+                draw_text(ctx, t, transform, pres);                 if clip.is_some() {                     ctx.pop_layer();                 }@@ -154,7 +159,7 @@ struct Glyph { }  /// Draws a text item; `m` maps its text space to the output.-fn draw_text(ctx: &mut RenderContext, t: &Text, m: Affine) {+fn draw_text(ctx: &mut RenderContext, t: &Text, m: Affine, pres: Option<&deckcraft_model::Presentation>) {     let db = FontDb::global();     let font = &t.font;     let family = if font.family.trim().is_empty() { "Arial" } else { font.family.trim() };@@ -165,7 +170,8 @@ fn draw_text(ctx: &mut RenderContext, t: &Text, m: Affine) {         (false, true) => "Italic",         (false, false) => "Regular",     };-    let face = db.document_face(family, style);+    let lookup_family = pres.map_or(family, |p| p.render_font_family(family));+    let face = db.document_face(lookup_family, style);     let upem = face.upem.max(1.0);     let (asc, desc) = win_metrics(&face);     // LOGFONT height: negative is the em, positive the cell (ascent plus descent).diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs--- a/crates/render/src/paint.rs+++ b/crates/render/src/paint.rs@@ -205,7 +205,7 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b     // of it, magnified (#762).     let want = (dest.width().abs().max(dest.height().abs()) * px_scale).max(1.0);     let frame = gif_time.and_then(|t| images::gif_pixmap(&item.data, &pf.adjust, t));-    let Some(pm) = frame.or_else(|| images::decode_for(&item.data, want, &pf.adjust)) else {+    let Some(pm) = frame.or_else(|| images::decode_for_presentation(&item.data, want, &pf.adjust, pres)) else {         missing(ctx, bounds);         return;     };diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs--- a/crates/render/src/tests.rs+++ b/crates/render/src/tests.rs@@ -1859,6 +1859,92 @@ fn metafile_text_ink_lands_at_its_reference_point() {     assert!((88..112).contains(&y1), "baseline one ascent below the top: {y1}"); } +#[test]+fn metafile_document_fonts_keep_metrics_and_cached_pictures_scoped() {+    use std::sync::Arc;++    let bytes = include_bytes!("../../fonts/tests/fixtures/legacy-regular.ttf").to_vec();+    let mut wider = bytes.clone();+    // Keep the source family and square outline, changing only A's advance (700 -> 1000).+    let tables = u16::from_be_bytes([wider[4], wider[5]]) as usize;+    let hmtx = (0..tables).map(|i| 12 + 16 * i).find(|&r| &wider[r..r + 4] == b"hmtx").unwrap();+    let offset = u32::from_be_bytes(wider[hmtx + 8..hmtx + 12].try_into().unwrap()) as usize;+    wider[offset + 4..offset + 6].copy_from_slice(&1000_u16.to_be_bytes());+    let db = deckcraft_fonts::FontDb::global();+    assert_eq!(+        db.add_document_fonts(vec![+            (bytes, "DeckCraft Legacy".into(), "metafile-private-narrow".into()),+            (wider, "DeckCraft Legacy".into(), "metafile-private-wide".into()),+        ]),+        2+    );+    let mut recs = font_records(-720, 0, " deckcraft legacy ", 0);+    recs.push(text_record(360, 360, "AA", &[]));+    let mut a = Presentation::default();+    let media = a.add_media("scoped.wmf", "image/x-wmf", wmf_with(2880, 1440, &recs)).expect("media");+    let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };+    Arc::make_mut(&mut a.slides[0]).shapes =+        vec![Shape { id: ShapeId(606), xfrm: Some(Xfrm::new(0.0, 0.0, 288.0, 144.0)), kind: ShapeKind::Picture { fill }, ..Default::default() }];+    a.font_aliases = Arc::new(vec![("DeckCraft Legacy".into(), "metafile-private-narrow".into())]);+    let mut b = a.clone();+    b.font_aliases = Arc::new(vec![("DeckCraft Legacy".into(), "metafile-private-wide".into())]);+    let data = &a.media(media).expect("shared media").data;+    assert!(Arc::ptr_eq(data, &b.media(media).unwrap().data));+    let narrow = crate::metafile::rasterize_in(data, 288, Some(&a)).expect("narrow");+    let wide = crate::metafile::rasterize_in(data, 288, Some(&b)).expect("wide");+    let na = mf_ink(&narrow).expect("narrow squares");+    let wb = mf_ink(&wide).expect("wide squares");+    assert_eq!((na.0, na.1, na.3), (wb.0, wb.1, wb.3), "same source outlines and vertical metrics");+    assert!((20..=23).contains(&(wb.2 - na.2)), "the second square follows its own font's advance: {na:?} vs {wb:?}");++    let adj = deckcraft_model::style::PictureAdjust::default();+    let cached_a = crate::images::decode_for_presentation(data, 288.0, &adj, &a).expect("cache a");+    let cached_b = crate::images::decode_for_presentation(data, 288.0, &adj, &b).expect("cache b");+    assert!(!Arc::ptr_eq(&cached_a, &cached_b), "identical metafile bytes do not share document fonts");+    let snapshot = a.clone();+    let cached_snapshot = crate::images::decode_for_presentation(data, 288.0, &adj, &snapshot).expect("snapshot cache");+    assert!(Arc::ptr_eq(&cached_a, &cached_snapshot), "snapshots share the same font scope");+    assert!(Arc::strong_count(&a.font_aliases) >= 3, "the cache retains the pointer-keyed scope");++    let opts = RenderOpts { scale: 1.0, ..Default::default() };+    let rendered_a = render_slide(&a, 0, &opts);+    let rendered_b = render_slide(&b, 0, &opts);+    assert!(rendered_a.pixel(100, 60)[0] < 20 && rendered_b.pixel(100, 60)[0] > 240);+    assert!(rendered_a.pixel(140, 60)[0] > 240 && rendered_b.pixel(140, 60)[0] < 20);+    assert_eq!(render_slide(&a, 0, &opts).pixels, rendered_a.pixels, "switching back restores the original scoped picture");+    assert_eq!(render_slide(&b, 0, &opts).pixels, rendered_b.pixels);+    let text = deckcraft_metafile::parse(data)+        .expect("wmf")+        .items+        .into_iter()+        .find_map(|item| match item {+            deckcraft_metafile::Item::Text(text) => Some(text),+            _ => None,+        })+        .expect("text");+    assert_eq!(text.font.family.trim(), "deckcraft legacy", "source family is never replaced by the rendering alias");+}++#[test]+fn raster_picture_cache_is_shared_across_document_font_scopes() {+    use std::sync::Arc;++    let mut png = std::io::Cursor::new(Vec::new());+    image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 2, image::Rgb([30, 60, 90])))+        .write_to(&mut png, image::ImageFormat::Png)+        .expect("png");+    let data = Arc::new(png.into_inner());+    let a = Presentation { font_aliases: Arc::new(vec![("same source".into(), "raster-private-a".into())]), ..Default::default() };+    let mut b = a.clone();+    b.font_aliases = Arc::new(vec![("same source".into(), "raster-private-b".into())]);+    let adj = deckcraft_model::style::PictureAdjust::default();+    let standalone = crate::images::decode_for(&data, 64.0, &adj).expect("standalone");+    let cached_a = crate::images::decode_for_presentation(&data, 64.0, &adj, &a).expect("a");+    let cached_b = crate::images::decode_for_presentation(&data, 64.0, &adj, &b).expect("b");+    assert!(Arc::ptr_eq(&standalone, &cached_a) && Arc::ptr_eq(&cached_a, &cached_b));+    assert_eq!((cached_a.width(), cached_a.height()), (4, 2));+}+ #[test] fn metafile_text_centre_baseline_and_escapement() {     // TA_CENTER | TA_BASELINE at (72, 36) pt: the ink is centred on 144 px and sits on 72 px.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@@ -436,7 +436,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl     let family = resolve::font_family(ctx, r);     let bold = r.bold.unwrap_or(false);     let italic = r.italic.unwrap_or(false);-    let face = FontDb::global().document_face(&family, style_name(bold, italic));+    let face = FontDb::global().document_face(ctx.pres.render_font_family(&family), style_name(bold, italic));     let fake_bold = bold && face.weight < 550.0;     let fake_italic = italic && !face.italic;     let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0);@@ -504,6 +504,7 @@ fn face_chain(ctx: &Ctx, st: &Style, slot: FontSlot, script: Option<ScriptTag>)     let db = FontDb::global();     let mut chain: Vec<Arc<FontFace>> = Vec::with_capacity(3);     let mut push = |family: &str| {+        let family = ctx.pres.render_font_family(family);         // Only families that exist: `FontDb::face` would substitute the default face.         if db.has_family(family) {             let f = db.face(family, st.style_name);@@ -878,6 +879,7 @@ fn shape_cells(             .sym             .as_deref()             .filter(|_| cluster.first().is_some_and(|c| ('\u{F000}'..='\u{F0FF}').contains(c)))+            .map(|f| ctx.pres.render_font_family(f))             .filter(|f| db.has_family(f))             .map(|f| db.face(f, st.style_name))             .filter(|f| covers_symbol(f, cluster.first().copied().unwrap_or(' ')));@@ -1168,7 +1170,7 @@ fn symbol_map_char(face: &FontFace, ch: char, sym: Option<&str>) -> Option<char>         return None;     }     let sym = sym?;-    if !is_symbol_font(sym) || !face.family.eq_ignore_ascii_case(sym) {+    if !is_symbol_font(sym) || !face.source_family().eq_ignore_ascii_case(sym) {         return None;     }     symbol_font_draw(face, code)@@ -1350,7 +1352,7 @@ fn symbol_font_draw(face: &FontFace, code: u32) -> Option<char> {         return Some(pua);     }     // The table below is the Symbol font's encoding; Wingdings, Webdings and the like are not Symbol.-    if !face.family.eq_ignore_ascii_case("symbol") {+    if !face.source_family().eq_ignore_ascii_case("symbol") {         return None;     }     if let Some(real) = symbol_unicode(code) {@@ -1371,7 +1373,7 @@ fn symbol_font_draw(face: &FontFace, code: u32) -> Option<char> { /// symbol font that `face` is (not a substitute) and it has the glyph. fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option<char> {     let font = font?;-    if !is_symbol_font(font) || !face.family.eq_ignore_ascii_case(font) {+    if !is_symbol_font(font) || !face.source_family().eq_ignore_ascii_case(font) {         return None;     }     let first = c.chars().next()?;@@ -1936,7 +1938,10 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts                     // The run's own fonts set the line; a fallback face (emoji, a symbol or a                     // script the run's fonts lack) does not move the baseline in PowerPoint.                     let own = Arc::ptr_eq(face, &st.face)-                        || [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()].into_iter().flatten().any(|f| face.family.eq_ignore_ascii_case(f));+                        || [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()]+                            .into_iter()+                            .flatten()+                            .any(|f| face.source_family().eq_ignore_ascii_case(f));                     let (a, d) = line_metrics(if own { face } else { &st.face }, sz);                     asc = asc.max(a);                     desc = desc.max(d);diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs--- a/crates/ui-egui/src/ribbon.rs+++ b/crates/ui-egui/src/ribbon.rs@@ -545,6 +545,13 @@ pub fn font_families(app: &SlideApp) -> Vec<String> {     }     v.extend(deckcraft_fonts::FontDb::global().families());     v.dedup();+    if let Some(st) = app.session.active() {+        for (source, _) in st.doc.font_aliases.iter() {+            if !v.iter().any(|family| family.eq_ignore_ascii_case(source)) {+                v.push(source.clone());+            }+        }+    }     v } @@ -3567,6 +3574,33 @@ mod tests {     use super::*;     use deckcraft_model::anim::TextBuild; +    #[test]+    #[allow(clippy::unwrap_used)]+    fn font_picker_keeps_only_active_document_embedded_families() {+        let mut p = deckcraft_model::Presentation::default();+        let id = p.add_media("local.ttf", "font/ttf", include_bytes!("../../fonts/tests/fixtures/legacy-regular.ttf").to_vec()).unwrap();+        p.embedded_fonts.push(deckcraft_model::EmbeddedFont { typeface: "DeckCraft Legacy".into(), regular: Some(id), ..Default::default() });+        let mut session = deckcraft_engine::Session::new();+        session.add_document(deckcraft_engine::DocState::new(p, None, "Embedded".into()));+        let mut app = SlideApp::new(session, crate::Services::default());+        let choices = font_families(&app);+        assert!(choices.iter().any(|family| family == "DeckCraft Legacy"), "embedded family must remain selectable by its saved name");+        assert!(!choices.iter().any(|family| family.starts_with("deckcraft-document-")), "private aliases must not appear in the picker");+        use egui_kittest::kittest::Queryable;+        let shape = app.session.execute("shape.insert", &json!({"preset": "rect", "rect": [20, 20, 200, 80], "text": "A"})).unwrap()["id"].clone();+        app.session.execute("edit.select", &json!({"ids": [shape]})).unwrap();+        let mut harness = egui_kittest::Harness::builder()+            .with_size(egui::vec2(800.0, 600.0))+            .build_ui_state(|ui, app: &mut SlideApp| font_group(app, ui, &json!({})), app);+        harness.run_steps(2);+        harness.get_by_label("font").click();+        harness.run_steps(2);+        assert!(harness.query_by_label("DeckCraft Legacy").is_some(), "the real font popup contains the original embedded name");+        let app = harness.state_mut();+        app.session.add_document(deckcraft_engine::DocState::new(deckcraft_model::Presentation::default(), None, "Other".into()));+        assert!(!font_families(app).iter().any(|family| family == "DeckCraft Legacy"), "another document must not inherit the family");+    }+     /// Paints one transition tile and one animation tile and returns the colour of every label.     fn tile_label_colours(dark: bool) -> Vec<Color32> {         let ctx = egui::Context::default();diff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -1,6 +1,6 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: minor, community PR wave 2026-10-14: 320 engine commands, 41 dialogs, the `metafile` crate, 1,641 tests; duplicated L2/L5/L6 rows and headers folded; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** minor (document-local font alias registration API; previous: minor, native chart payloads stored in bounded ZIP members) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)  How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what@@ -27,6 +27,21 @@ nothing below L6 uses egui, eframe, winit or rfd. The media codecs and containers are clean-room pure Rust, 59k lines, shared in spirit with FilmCraft. +## Document font registration++`FontDb::add_document_fonts` accepts `(bytes, source_family, private_alias)` entries for document+embedders. It selects faces by typographic or Windows family name before accepting a renamed+single-family file; an unmatched multi-family collection contributes nothing. Selected styles,+weights and variable instances stay intact, but only the private alias is registered: neither the+original typographic name nor the Windows name can resolve to those faces.++Callers must choose distinct aliases for different documents/subsets and resolve their text using+those aliases. Aliases are ordinary database names, not access controls or separate databases.+All styles in a call are published together; repeat alias/style registrations add nothing.+`add_font` and installed-font loading retain their existing public names and deduplication.+This is an additive library API; the application's PPTX registration path is unchanged.++ ## Data model  A `Presentation` holds a theme set, slide masters with their layouts, slides, sections, custom@@ -81,6 +96,7 @@ macOS, Windows, Linux, FreeBSD and web artifacts.  | Date | Change | Summary | |---|---|---|+| 2026-10-22 | minor | Document embedders can register font bytes under caller-owned aliases without exposing source family names; collection selection honors typographic/Windows names before single-family fallback. Application callers, parity scores and milestone scope unchanged | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-14 | minor | Community PR wave folded: one L2/L5/L6 row each; command registry 320 (`cmd!` registrations in `cmd/*`), 41 dialogs (titled in `dialogs.rs`), 202k lines, 24 crates (`metafile` added at L0), 1,641 tests. The per-PR rows below give the counts each PR saw on its own branch | | 2026-10-14 | minor | Size and Position dialog (`shape.sizePosition`): position, size, rotation, scale and lock aspect in one undo step. 227 commands, 28 dialogs |diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- a/docs/file-format-parity.md+++ b/docs/file-format-parity.md@@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (embedded fonts isolated per open document, with original saved names retained; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)  Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**@@ -63,7 +63,7 @@ intermediate build that required inline `bytes` cannot read the new referenced r | WMV | yes (Windows) | embedded, not playable | | | Online video (YouTube embeds) | yes | no | Cloud feature | | Embedded OLE objects | yes | kept on round trip (opaque), not insertable or editable | `ShapeKind::Opaque` |-| Embedded fonts | yes | yes | `p:embeddedFontLst` read and written. TrueType, OpenType and Embedded OpenType faces are used to draw; MicroType Express subsets are kept and written back, not drawn |+| Embedded fonts | yes | yes | `p:embeddedFontLst` read and written. TrueType, OpenType and Embedded OpenType faces draw under document-local aliases, including explicit/theme/script/bullet and WMF/EMF references; original UI and saved names stay unchanged. Private faces do not enter global font discovery or fallback. MicroType Express subsets are kept and written back, not drawn | | 3-D models (`.glb`) | yes | kept | The model part round-trips (`p:contentPart`, including an `mc:AlternateContent` choice and its fallback picture). Not drawn as a mesh and not inserted from the ribbon |  ## PPTX coverage (PresentationML / DrawingML)@@ -119,6 +119,7 @@ the round trip even though DeckCraft doesn't model it.  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Engine loading installs supported embedded faces under private per-document aliases; two-deck rendering and native/PPTX round trips preserve each deck's metrics and original family names. WMF/EMF cache entries are font-scoped; ordinary raster caches remain shared. The active font picker retains original names, and formatting-preserving paste transfers retained source-only embedded families without replacing destination families. MicroType Express and parity scores unchanged | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Native `.deckcraft` files serialize a nonempty chart `kept` sidecar (relationships, part names, content types and bytes). Older files without it still load; empty sidecars are omitted. Headless PPTX → edit → native save/reopen → PPTX checks keep the workbook and drawing bytes; editing chart data still drops the stale workbook | | 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen |