mirror of
https://github.com/aphrody-labs/deckcraft.git
synced 2026-10-13 19:26:55 +00:00
Fonts: Office's downloaded fonts are used; opening a file says which fonts are missing (#326)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8159db8ba5
commit
85366b1a62
7 files changed
+246
-4
No files matched your search
@@ -79,7 +79,10 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
|
||||
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());
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
@@ -101,7 +105,7 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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`).
|
||||
|
||||
@@ -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<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)) {
|
||||
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<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);
|
||||
}
|
||||
};
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Interaction>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -450,6 +450,12 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join("Library/Fonts"));
|
||||
// Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them.
|
||||
dirs.push(h.join("Library/Group Containers/UBF8T346G9.Office/FontCache/4/CloudFonts"));
|
||||
}
|
||||
// Fonts Office installs inside its apps.
|
||||
for app in ["Microsoft PowerPoint", "Microsoft Word", "Microsoft Excel"] {
|
||||
dirs.push(std::path::PathBuf::from(format!("/Applications/{app}.app/Contents/Resources/DFonts")));
|
||||
}
|
||||
// Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font<N>.
|
||||
for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] {
|
||||
@@ -464,7 +470,11 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
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));
|
||||
|
||||
@@ -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<toast::Toast>,
|
||||
/// Documents (by uid) whose missing fonts have been mentioned.
|
||||
fonts_noted: std::collections::HashSet<u64>,
|
||||
pub perf: Perf,
|
||||
/// Synthetic input events (control channel `ui.click` etc.), injected one per frame.
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
@@ -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::<Vec<_>>().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() {
|
||||
|
||||
@@ -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<String> {
|
||||
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::<Vec<_>>());
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user