diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 55aac8b..cab5805 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -79,7 +79,10 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result { pub fn add_opened(s: &mut Session, name: &str, path: Option, 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()); - s.add_document(DocState::new(p, path, title)) + 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) } #[cfg(not(target_arch = "wasm32"))] @@ -89,7 +92,8 @@ fn open(s: &mut Session, p: &Value) -> Result { let doc = open_presentation(path, &bytes)?; let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes); let i = add_opened(s, path, native.then(|| path.to_string()), doc); - Ok(json!({"document": i, "slides": s.doc()?.doc.slides.len()})) + let st = s.doc()?; + Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts})) } #[cfg(target_arch = "wasm32")] fn open(_s: &mut Session, _p: &Value) -> Result { @@ -101,7 +105,7 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result { let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?; let doc = open_presentation(name, &data)?; let i = add_opened(s, name, None, doc); - Ok(json!({"document": i})) + Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts})) } /// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`). diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs new file mode 100644 index 0000000..fcbd2fe --- /dev/null +++ b/crates/engine/src/fonts.rs @@ -0,0 +1,65 @@ +//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text +//! draws in a stand-in; see `FontDb::document_face`). + +use deckcraft_fonts::FontDb; +use deckcraft_model::resolve::{self, Ctx}; +use deckcraft_model::text::{Bullet, TextBody}; +use deckcraft_model::{Presentation, Shape, ShapeKind}; + +/// The font families the slides' text is drawn in (theme fonts resolved, bullet fonts included) +/// that aren't installed or bundled, sorted, without duplicates (case-insensitive). +pub fn missing_fonts(p: &Presentation) -> Vec { + let db = FontDb::global(); + let mut out: Vec = Vec::new(); + for family in used_fonts(p) { + if !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) { + out.push(family); + } + } + out.sort_by_key(|f| f.to_lowercase()); + out +} + +/// The families the slides' text runs and bullets are drawn in. +fn used_fonts(p: &Presentation) -> Vec { + let mut out: Vec = 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); + } + }; + 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); + } + 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); + } + } + }); + } + 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 { + continue; + } + for run in para.runs.iter().filter(|r| !r.text.trim().is_empty()) { + let r = resolve::run(ctx, s, para, &run.props); + add(resolve::font_family(ctx, &r)); + } + let pp = resolve::para(ctx, s, para); + if matches!(pp.bullet, Some(Bullet::Char { .. })) + && let Some(f) = pp.bullet_font.as_deref() + { + add(ctx.master.theme.font(f)); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2d2d2a..b1775c2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,6 +9,7 @@ pub mod cmd; pub mod connect; +pub mod fonts; pub mod guard; pub mod keys; pub mod links; @@ -141,6 +142,9 @@ pub struct DocState { pub interaction: Option, pub uid: u64, pub title: String, + /// Fonts the file's text uses that aren't installed, found when it was opened (empty for new + /// documents); the app mentions them once. + pub missing_fonts: Vec, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -158,6 +162,7 @@ impl DocState { interaction: None, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), title, + missing_fonts: Vec::new(), } } pub fn is_dirty(&self) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aefd0b..5703730 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -504,6 +504,30 @@ fn hyperlink_on_a_word_or_shape_links_it() { assert!(s.doc().unwrap().current_slide().unwrap().shapes[0].click.is_some(), "a selected shape gets the link as its click action"); } +/// A .pptx (base64) whose first slide's title is set in `font`. +fn deck_in_font(font: &str) -> String { + let mut p = deckcraft_model::Presentation::default(); + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + let mut body = deckcraft_model::text::TextBody::from_text("Hello"); + body.paragraphs[0].runs[0].props.font = Some(font.into()); + slide.shapes[0].text = Some(body); + crate::cmd::base64_encode(&crate::cmd::file::save_bytes(&p, "pptx").unwrap()) +} + +#[test] +fn opening_a_file_lists_the_fonts_it_uses_that_are_missing() { + let mut s = Session::with_new(); + let r = s.execute("file.openBytes", &json!({"name": "a.pptx", "data": deck_in_font("Qqq Nonexistent Sans")})).unwrap(); + assert_eq!(r["missingFonts"], json!(["Qqq Nonexistent Sans"])); + assert_eq!(s.doc().unwrap().missing_fonts, vec!["Qqq Nonexistent Sans".to_string()]); + // A font that is there (bundled or installed) isn't listed; new documents list nothing. + let have = deckcraft_fonts::FontDb::global().face(deckcraft_fonts::FALLBACK_FAMILY, "Regular").family.clone(); + let r = s.execute("file.openBytes", &json!({"name": "b.pptx", "data": deck_in_font(&have)})).unwrap(); + assert_eq!(r["missingFonts"], json!([])); + s.execute("file.new", &json!({})).unwrap(); + assert!(s.doc().unwrap().missing_fonts.is_empty()); +} + #[test] fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() { // Flips don't mirror text, so a click lands on the character drawn under it. diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 3a28ce1..31cb9a1 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -450,6 +450,12 @@ pub fn system_font_dirs() -> Vec { dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into)); if let Some(h) = &home { dirs.push(h.join("Library/Fonts")); + // Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them. + dirs.push(h.join("Library/Group Containers/UBF8T346G9.Office/FontCache/4/CloudFonts")); + } + // Fonts Office installs inside its apps. + for app in ["Microsoft PowerPoint", "Microsoft Word", "Microsoft Excel"] { + dirs.push(std::path::PathBuf::from(format!("/Applications/{app}.app/Contents/Resources/DFonts"))); } // Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font. for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] { @@ -464,7 +470,11 @@ pub fn system_font_dirs() -> Vec { let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); dirs.push(root.join("Fonts")); if let Some(l) = std::env::var_os("LOCALAPPDATA") { - dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + let l = std::path::PathBuf::from(l); + dirs.push(l.join("Microsoft\\Windows\\Fonts")); + // Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them; + // DeckCraft never ships or needs them. + dirs.push(l.join("Microsoft\\FontCache\\4\\CloudFonts")); } } else { dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bef64a4..a02218c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ pub mod status; pub mod textures; pub mod theme; pub mod thumbs; +pub mod toast; pub mod widgets; use std::sync::mpsc::{Receiver, Sender}; @@ -181,6 +182,10 @@ pub struct SlideApp { /// Audio/video playback (editor control bar, slide show, `media.*` commands). pub media: media::MediaHost, pub status: Option<(String, f64)>, + /// A brief notice over the bottom of the window (missing fonts on opening a file). + pub toast: Option, + /// Documents (by uid) whose missing fonts have been mentioned. + fonts_noted: std::collections::HashSet, pub perf: Perf, /// Synthetic input events (control channel `ui.click` etc.), injected one per frame. pub synthetic: Vec, @@ -224,6 +229,8 @@ impl SlideApp { show: None, media: media::MediaHost::new(audio_out), status: None, + toast: None, + fonts_noted: Default::default(), perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 }, synthetic: vec![], canvas_rect: None, @@ -730,6 +737,8 @@ impl SlideApp { } } }); + toast::note_missing_fonts(self); + toast::show(self, &ctx); dialogs::show(self, &ctx); menus::palette(self, &ctx); for url in std::mem::take(&mut self.pending_urls) { @@ -889,6 +898,37 @@ mod tests { sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default() } + #[test] + fn opening_a_file_with_missing_fonts_shows_a_toast_once() { + use egui_kittest::kittest::Queryable; + let mut p = deckcraft_model::Presentation::default(); + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + let mut body = deckcraft_model::text::TextBody::from_text("Hello"); + body.paragraphs[0].runs[0].props.font = Some("Qqq Nonexistent Sans".into()); + slide.shapes[0].text = Some(body); + let data = deckcraft_engine::cmd::base64_encode(&deckcraft_engine::cmd::file::save_bytes(&p, "pptx").unwrap()); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + assert!(harness.state().toast.is_none(), "a new presentation has nothing to say"); + harness.state_mut().session.execute("file.openBytes", &json!({"name": "a.pptx", "data": data})).unwrap(); + harness.run_steps(2); + let text = harness.state().toast.as_ref().map(|t| t.text.clone()).unwrap_or_default(); + assert!(text.starts_with("Qqq Nonexistent Sans isn't installed"), "{text}"); + assert!(harness.query_by_label_contains("Qqq Nonexistent Sans isn't installed").is_some()); + // Closed, it stays closed for that document. + harness.get_by_label("Close").click(); + harness.run_steps(2); + assert!(harness.state().toast.is_none()); + harness.run_steps(2); + assert!(harness.state().toast.is_none()); + } + /// #17: Home created a new blank presentation instead of going to the start screen. #[test] fn home_shows_the_start_screen_over_the_open_presentation() { diff --git a/crates/ui-egui/src/toast.rs b/crates/ui-egui/src/toast.rs new file mode 100644 index 0000000..0dcfb05 --- /dev/null +++ b/crates/ui-egui/src/toast.rs @@ -0,0 +1,94 @@ +//! Brief notices over the window's bottom edge: shown once, gone after a few seconds or when +//! closed. Used to say a presentation's fonts aren't installed when it is first opened. + +use egui::{Align2, CornerRadius, Margin, Stroke, vec2}; + +use crate::SlideApp; +use crate::icons::{self, Icon}; +use crate::theme::{self, Tokens}; + +/// How long a toast stays, in milliseconds. +const SHOW_MS: f64 = 8000.0; + +pub struct Toast { + pub text: String, + pub at: f64, +} + +/// The notice for fonts a presentation uses that aren't installed, or `None` when all are. +pub fn missing_fonts_message(fonts: &[String]) -> Option { + let names = match fonts { + [] => return None, + [a] => a.clone(), + [a, b] => format!("{a} and {b}"), + [a, b, c] => format!("{a}, {b} and {c}"), + [a, b, c, rest @ ..] => format!("{a}, {b}, {c} and {} more", rest.len()), + }; + let (it, them) = if fonts.len() == 1 { ("isn't", "it") } else { ("aren't", "them") }; + Some(format!("{names} {it} installed on this computer. Text that uses {them} is shown in a similar font.")) +} + +/// Toast the active presentation's missing fonts, once per opened document. +pub fn note_missing_fonts(app: &mut SlideApp) { + let Some(d) = app.session.active() else { return }; + if !app.fonts_noted.insert(d.uid) { + return; + } + if let Some(text) = missing_fonts_message(&d.missing_fonts) { + app.toast = Some(Toast { text, at: crate::now_ms() }); + } +} + +pub fn show(app: &mut SlideApp, ctx: &egui::Context) { + let Some(toast) = &app.toast else { return }; + if crate::now_ms() - toast.at > SHOW_MS { + app.toast = None; + return; + } + let t = Tokens::get(ctx); + let text = toast.text.clone(); + let mut close = false; + egui::Area::new(egui::Id::new("toast")).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -44.0)).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Frame::NONE + .fill(t.card) + .stroke(Stroke::new(1.0, t.card_border)) + .corner_radius(CornerRadius::same(6)) + .inner_margin(Margin::symmetric(14, 10)) + .shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: t.shadow }) + .show(ui, |ui| { + ui.set_max_width(520.0); + ui.horizontal(|ui| { + ui.add(egui::Label::new(egui::RichText::new(&text).font(theme::font(13.0)).color(t.text)).wrap()); + let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Close")); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), r.shrink(3.0), Icon::Close, t.text_dim, false); + if resp.on_hover_text("Close").clicked() { + close = true; + } + }); + }); + }); + if close { + app.toast = None; + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_message_names_the_fonts() { + let f = |v: &[&str]| missing_fonts_message(&v.iter().map(|s| s.to_string()).collect::>()); + assert_eq!(f(&[]), None); + assert_eq!(f(&["Aptos"]).unwrap(), "Aptos isn't installed on this computer. Text that uses it is shown in a similar font."); + assert!(f(&["Aptos", "Sora"]).unwrap().starts_with("Aptos and Sora aren't installed")); + assert!(f(&["A", "B", "C"]).unwrap().starts_with("A, B and C aren't")); + assert!(f(&["A", "B", "C", "D", "E"]).unwrap().starts_with("A, B, C and 2 more aren't")); + } +}