diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 3e08474a..65b19ee4 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -155,6 +155,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult { Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames})) }) .collect(); + let missing = figcraft_engine::fonts::missing(&doc); let v = json!({ "file": path, "name": doc.name, @@ -164,6 +165,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult { "variables": doc.variables.variables.len(), "images": doc.images.len(), "notes": notes, + "missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(), }); if as_json { println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); @@ -187,6 +189,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult { println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]); } } + for m in &missing { + let layers = if m.layers == 1 { "text layer" } else { "text layers" }; + println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers); + } Ok(ExitCode::SUCCESS) } diff --git a/apps/figcraft-cli/tests/fonts.rs b/apps/figcraft-cli/tests/fonts.rs index c5901ac5..ef5c638f 100644 --- a/apps/figcraft-cli/tests/fonts.rs +++ b/apps/figcraft-cli/tests/fonts.rs @@ -45,3 +45,25 @@ fn headless_runs_resolve_installed_families_unless_opted_out() { assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family"); assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back"); } + +#[test] +fn info_lists_the_fonts_a_file_uses_that_are_missing() { + let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let (script, file) = (d.join("make.json"), d.join("doc.figcraft")); + let text = + |family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}}); + std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap(); + let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli")); + let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr)); + + let out = cli().arg("info").arg(&file).arg("--json").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}])); + + let out = cli().arg("info").arg(&file).output().unwrap(); + let shown = String::from_utf8_lossy(&out.stdout); + assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}"); +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 10147c2c..fe89d0a7 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -161,6 +161,7 @@ fn main() -> eframe::Result { log::error!("{f}: {e}"); } } + let opened: Vec = session.docs.iter().map(|d| d.uid).collect(); // Documents a crashed or killed run left unsaved come back; this run keeps its own copies. let recovery_root = services::config_dir().map(|d| d.join("Recovery")); // The preferences first: they hold the online files a recovered document may be one of. @@ -194,6 +195,8 @@ fn main() -> eframe::Result { let mut fig = FigApp::new(session, Box::new(svc)); fig.automation_roots = roots; fig.ui.home = false; + // Files named on the command line are the user's too: their missing fonts are listed. + fig.ui.font_checks = opened; fig.note_recovered(recovered.copies); if let Some(link) = &args.join { figcraft_ui_egui::live::open_invite(&mut fig, link); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index c8f34fc1..4b6cd3fc 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -213,6 +213,8 @@ pub static MENU: &[MenuItem] = &[ m!("Text/Case", "Uppercase", "text.caseUpper"), m!("Text/Case", "Lowercase", "text.caseLower"), m!("Text/Case", "Title case", "text.caseTitle"), + m!("Text", SEP, ""), + m!("Text", "Missing fonts…", "text.missingFonts"), // Arrange m!("Arrange", "Align left", "align.left"), m!("Arrange", "Align horizontal centers", "align.hcenter"), @@ -287,7 +289,7 @@ pub fn items() -> impl Iterator { } /// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones). -const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"]; +const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"]; /// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer. pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b942fc25..f011238d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -80,6 +80,7 @@ fn registry() -> &'static Registry { list.extend(page::specs()); list.extend(view::specs()); list.extend(text::specs()); + list.extend(crate::fonts::specs()); list.extend(styles::specs()); list.extend(image::specs()); list.extend(variables::specs()); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs new file mode 100644 index 00000000..e20d4d40 --- /dev/null +++ b/crates/engine/src/fonts.rs @@ -0,0 +1,100 @@ +//! Fonts a document names that this machine doesn't have (#752): text layers, their styled +//! ranges, text styles and the string variables bound to a font family. Text in such a family +//! is drawn in a fallback font, so the user is told which ones are missing. + +use std::collections::BTreeMap; + +use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::{Result, Session, spec}; + +/// A family the document names that no installed or built-in font provides. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Missing { + /// As the document spells it. + pub family: String, + /// Text layers that use it (0: only a text style or a variable names it). + pub layers: usize, +} + +/// The families `doc` names that the font database doesn't have, by name. On the desktop the +/// installed fonts are indexed in the background: ask once that's done +/// (`figcraft_text::system_fonts_scanned`), or every installed family counts as missing. +pub fn missing(doc: &Document) -> Vec { + // Lower-case family → (spelling, layers). + let mut used: BTreeMap = BTreeMap::new(); + let mut add = |family: &str, layer: bool| { + let family = family.trim(); + if family.is_empty() { + return; + } + let e = used.entry(family.to_lowercase()).or_insert_with(|| (family.to_string(), 0)); + e.1 += usize::from(layer); + }; + for n in doc.nodes() { + if let NodeKind::Text(t) = &n.kind { + let mut families: Vec<&str> = t.spans().into_iter().map(|(_, _, st)| st.font_family.as_str()).collect(); + families.push(&t.style.font_family); + families.sort_by_key(|f| f.trim().to_lowercase()); + families.dedup_by_key(|f| f.trim().to_lowercase()); + for f in families { + add(f, true); + } + } + if let Some(var) = n.bound.get("fontFamily") { + for f in string_values(&doc.variables, var) { + add(&f, false); + } + } + } + for st in &doc.styles { + if let StyleKind::Text { style } = &st.kind { + add(&style.font_family, false); + } + if let Some(var) = st.bound.get("fontFamily") { + for f in string_values(&doc.variables, var) { + add(&f, false); + } + } + } + used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect() +} + +/// Every string value of variable `id` in any mode, following aliases (at most 32 deep). +fn string_values(vars: &Variables, id: &str) -> Vec { + let mut out = Vec::new(); + let mut todo = vec![(id.to_string(), 0)]; + while let Some((id, depth)) = todo.pop() { + let Some(var) = vars.get(&id) else { continue }; + for v in var.values.values() { + match v { + VariableValue::String(s) => out.push(s.clone()), + VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)), + _ => {} + } + } + } + out +} + +fn missing_fonts(s: &mut Session, _: &Value) -> Result { + let families: Vec = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect(); + s.request_ui("dialog.missingFonts", json!({})); + Ok(json!({"families": families})) +} + +pub fn specs() -> Vec { + vec![spec!( + "text.missingFonts", + "Missing fonts", + "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", + false, + missing_fonts + )] +} + +#[cfg(test)] +#[path = "fonts_tests.rs"] +mod tests; diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs new file mode 100644 index 00000000..6ba925b3 --- /dev/null +++ b/crates/engine/src/fonts_tests.rs @@ -0,0 +1,123 @@ +use serde_json::json; + +use super::*; +use crate::params::id_of; +use figcraft_doc::{Style, StyleRun, TextStyle}; + +fn text(s: &mut Session, family: &str) -> figcraft_doc::NodeId { + let id = id_of(&s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"]).unwrap(); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind { + t.style.font_family = family.into(); + } + id +} + +fn families(s: &mut Session) -> Vec<(String, u64)> { + let r = s.execute("text.missingFonts", &json!({})).unwrap(); + r["families"].as_array().unwrap().iter().map(|f| (f["family"].as_str().unwrap().to_string(), f["layers"].as_u64().unwrap())).collect() +} + +/// An installed family (built in, or found by the system scan), if this machine has any. +fn installed() -> Option { + figcraft_text::scan_system_fonts(); + figcraft_text::family_names().into_iter().next() +} + +#[test] +fn lists_families_of_layers_ranges_styles_and_variables_once_each() { + let mut s = Session::new(); + text(&mut s, "FigCraft Missing A"); + let b = text(&mut s, "figcraft missing a"); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(b).unwrap().kind { + let style = TextStyle { font_family: "FigCraft Missing B".into(), ..t.style.clone() }; + t.runs.push(StyleRun { start: 0, end: 3, style }); + } + let doc = s.doc_mut().unwrap(); + doc.styles.push(Style { + id: "S:1".into(), + name: "Heading".into(), + description: String::new(), + kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } }, + bound: Default::default(), + }); + s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "Family", "type": "string", "value": "FigCraft Missing D"})).unwrap(); + s.execute("variable.set", &json!({"variable": "Family", "mode": "Dark", "value": "FigCraft Missing H"})).unwrap(); + let var = s.doc().unwrap().variables.get("Family").unwrap().id.clone(); + s.doc_mut().unwrap().get_mut(b).unwrap().bound.insert("fontFamily".into(), var); + + assert_eq!( + families(&mut s), + vec![ + ("FigCraft Missing A".to_string(), 2), + ("FigCraft Missing B".to_string(), 1), + ("FigCraft Missing C".to_string(), 0), + ("FigCraft Missing D".to_string(), 0), + ("FigCraft Missing H".to_string(), 0), + ] + ); + assert!(s.ui_requests.iter().any(|r| r.kind == "dialog.missingFonts")); +} + +#[test] +fn installed_families_are_not_listed() { + let Some(family) = installed() else { + eprintln!("skipped: no fonts on this machine"); + return; + }; + let mut s = Session::new(); + text(&mut s, &family); + text(&mut s, &family.to_uppercase()); + text(&mut s, "FigCraft Missing E"); + assert_eq!(families(&mut s), vec![("FigCraft Missing E".to_string(), 1)]); +} + +#[test] +fn a_family_found_only_by_the_system_scan_is_not_missing() { + figcraft_text::scan_system_fonts(); + let embedded: Vec = figcraft_text::CRAFT_FONTS.iter().map(|f| f.family.to_lowercase()).collect(); + let Some(family) = figcraft_text::family_names().into_iter().find(|f| !embedded.contains(&f.to_lowercase())) else { + eprintln!("skipped: no system fonts on this machine"); + return; + }; + let mut s = Session::new(); + text(&mut s, &family); + assert!(families(&mut s).is_empty(), "{family} is installed"); +} + +#[test] +fn blank_families_and_aliases_are_handled() { + let mut s = Session::new(); + text(&mut s, " "); + s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "Base", "type": "string", "value": "FigCraft Missing F"})).unwrap(); + let base = s.doc().unwrap().variables.get("Base").unwrap().id.clone(); + let t = text(&mut s, "FigCraft Missing G"); + let doc = s.doc_mut().unwrap(); + // A variable that only aliases another, and one aliasing itself. + let mut alias = doc.variables.get(&base).unwrap().clone(); + alias.id = "V:alias".into(); + alias.name = "Alias".into(); + for v in alias.values.values_mut() { + *v = VariableValue::Alias(base.clone()); + } + let mut cycle = alias.clone(); + cycle.id = "V:cycle".into(); + cycle.name = "Cycle".into(); + for v in cycle.values.values_mut() { + *v = VariableValue::Alias("V:cycle".into()); + } + doc.variables.variables.push(alias); + doc.variables.variables.push(cycle); + doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into()); + let other = text(&mut s, "FigCraft Missing G"); + s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into()); + // The bound layer takes the value its alias resolves to; the cycle resolves to nothing. + assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]); +} + +#[test] +fn needs_an_open_document() { + let mut s = Session::empty(); + assert!(s.execute("text.missingFonts", &json!({})).is_err()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2254269e..508e70f9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ pub mod distance; pub mod doc_urls; pub mod drop; pub mod file_access; +pub mod fonts; pub mod guard; pub mod guides; pub mod inspect; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index a6d52d67..8d1840b1 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -852,6 +852,11 @@ pub fn family_names() -> Vec { db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families() } +/// The global db has `family` (embedded, loaded or indexed; any case). +pub fn has_family(family: &str) -> bool { + db().read().unwrap_or_else(std::sync::PoisonError::into_inner).has_family(family) +} + /// Style names of a family in the global db. pub fn family_styles(family: &str) -> Vec { db().write().unwrap_or_else(std::sync::PoisonError::into_inner).styles(family) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 27456d82..721471ad 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -16,8 +16,8 @@ mod onpath; pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ - Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords, - scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, + no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 59b25d94..4003891b 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -91,6 +91,7 @@ name = "English" "All components" = "All components" "All fonts" = "All fonts" "All supported properties" = "All supported properties" +"All the fonts this file uses are installed" = "All the fonts this file uses are installed" "All variables" = "All variables" "Alpha" = "Alpha" "Alphabetical" = "Alphabetical" @@ -771,6 +772,7 @@ name = "English" "Mirror angle and length" = "Mirror angle and length" "Mirroring" = "Mirroring" "Missing" = "Missing" +"Missing fonts" = "Missing fonts" "Missing layer" = "Missing layer" "Missing style" = "Missing style" "Missing variable" = "Missing variable" @@ -1374,6 +1376,7 @@ name = "English" "This condition is edited as an expression" = "This condition is edited as an expression" "This file" = "This file" "This file has unsaved changes" = "This file has unsaved changes" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." "This frame isn't on the branch." = "This frame isn't on the branch." @@ -1593,6 +1596,7 @@ name = "English" "nobody else here yet" = "nobody else here yet" "non-default slot settings are not represented and were skipped" = "non-default slot settings are not represented and were skipped" "now" = "now" +"only in a text style or variable" = "only in a text style or variable" "or" = "or" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "preferred slot component keys cannot be resolved to file nodes and were skipped" @@ -1700,6 +1704,7 @@ name = "English" "{n} replies" = { one = "{n} reply", other = "{n} replies" } "{n} rows" = { one = "{n} row", other = "{n} rows" } "{n} styles" = { one = "{n} style", other = "{n} styles" } +"{n} text layers" = { one = "{n} text layer", other = "{n} text layers" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } "{n} weeks ago" = { one = "{n} week ago", other = "{n} weeks ago" } "{n} years ago" = { one = "{n} year ago", other = "{n} years ago" } diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 01c5813b..b442d6d3 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -93,6 +93,7 @@ status = "draft — needs native review" "All comments" = "Todos los comentarios" "All components" = "Todos los componentes" "All fonts" = "Todas las fuentes" +"All the fonts this file uses are installed" = "Todas las fuentes que usa este archivo están instaladas" "All supported properties" = "Todas las propiedades compatibles" "All variables" = "Todas las variables" "Alpha" = "Alfa" @@ -774,6 +775,7 @@ status = "draft — needs native review" "Missing" = "No encontrado" "Missing layer" = "Capa no encontrada" "Missing style" = "Estilo no encontrado" +"Missing fonts" = "Fuentes que faltan" "Missing variable" = "Variable no encontrada" "Miter" = "Inglete" "Miter angle" = "Ángulo de inglete" @@ -1375,6 +1377,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Esta condición se edita como expresión" "This file" = "Este archivo" "This file has unsaved changes" = "Este archivo tiene cambios sin guardar" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo." "This frame is new on the branch." = "Este marco es nuevo en la rama." "This frame isn't on the branch." = "Este marco no está en la rama." @@ -1594,6 +1597,7 @@ status = "draft — needs native review" "nobody else here yet" = "aún no hay nadie más" "non-default slot settings are not represented and were skipped" = "los ajustes de ranura no predeterminados no se representan y se omitieron" "now" = "ahora" +"only in a text style or variable" = "solo en un estilo de texto o una variable" "or" = "o" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "se conservaron las uniones de trazo de los puntos; los trazos discontinuos o de ancho variable se dibujan con la unión de la capa" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "las claves de componentes preferidos de las ranuras no corresponden a capas del archivo y se omitieron" @@ -1701,6 +1705,7 @@ status = "draft — needs native review" "{n} replies" = { one = "{n} respuesta", other = "{n} respuestas" } "{n} rows" = { one = "{n} fila", other = "{n} filas" } "{n} styles" = { one = "{n} estilo", other = "{n} estilos" } +"{n} text layers" = { one = "{n} capa de texto", other = "{n} capas de texto" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } "{n} weeks ago" = { one = "hace {n} semana", other = "hace {n} semanas" } "{n} years ago" = { one = "hace {n} año", other = "hace {n} años" } @@ -2126,6 +2131,7 @@ status = "draft — needs native review" "text.listOutdent" = "Reducir sangría de la lista" "text.listStyle" = "Estilo de lista" "text.maxLines" = "Máximo de líneas" +"text.missingFonts" = "Fuentes que faltan" "text.numberList" = "Lista numerada" "text.onPath" = "Texto en trazado" "text.paragraphIndent" = "Sangría de párrafo" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 39001b56..448501ab 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -151,6 +151,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { || u.find.is_some() || u.batch_rename.is_some() || u.import_report.is_some() + || u.missing_fonts.is_some() || u.confirm_close || u.export_preview || u.shortcuts_panel @@ -164,6 +165,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { || u.find.take().is_some() || u.batch_rename.take().is_some() || u.import_report.take().is_some() + || u.missing_fonts.take().is_some() { return; } @@ -178,6 +180,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { // Dropped files waiting to open: their card, then (once it has shown) the files. crate::pending_open::pending_opens(app, ctx); crate::import_report::show(app, ctx); + crate::missing_fonts::show(app, ctx); let t = Tokens::get(ctx); if app.ui.about { let mut open = true; diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index 8742ccd3..a2411582 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -108,7 +108,7 @@ fn escape_closes_the_import_report_like_other_simple_dialogs() { assert!(u.app.ui.import_report.is_none(), "Escape closes the report"); } -fn shown_texts(shapes: Vec) -> Vec { +pub(crate) fn shown_texts(shapes: Vec) -> Vec { fn walk(shape: &egui::Shape, out: &mut Vec) { match shape { egui::Shape::Text(t) => out.push(t.galley.text().to_string()), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d1be479..b3a705ca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -40,6 +40,7 @@ pub mod live; pub mod live_text; pub mod measure_overlay; pub mod menus; +pub mod missing_fonts; pub mod online; pub mod pending_open; pub mod plugins_ui; @@ -149,6 +150,8 @@ mod menu_aim_tests; #[cfg(test)] mod menu_input_tests; #[cfg(test)] +mod missing_fonts_tests; +#[cfg(test)] mod online_tests; #[cfg(test)] mod open_issues_tests; @@ -506,6 +509,9 @@ pub struct UiState { pub pending_open_frames: u8, /// The latest successful file import's summary and compatibility notes. pub import_report: Option, + /// Files the user opened whose fonts are yet to be checked (by document uid). + pub font_checks: Vec, + pub missing_fonts: Option, pub screenshot_requests: Vec<(u64, String)>, pub frame_ms: f64, pub render_ms: f64, @@ -633,6 +639,8 @@ impl Default for UiState { pending_opens: Vec::new(), pending_open_frames: 0, import_report: None, + font_checks: Vec::new(), + missing_fonts: None, screenshot_requests: Vec::new(), frame_ms: 0.0, render_ms: 0.0, @@ -761,6 +769,7 @@ impl FigApp { if let Some(result) = &result { import_report::capture(self, id, ¶ms, result); } + missing_fonts::check_later(self, id, result.as_ref()); result } @@ -875,6 +884,7 @@ impl FigApp { self.ui.about_tab = dialogs::AboutTab::About; } "dialog.preferences" => self.ui.preferences = true, + "dialog.missingFonts" => missing_fonts::request(self), "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, "palette.actions" => menus::toggle_palette(&mut self.ui), "panel.design" => self.ui.right_tab = RightTab::Design, @@ -1661,6 +1671,7 @@ impl FigApp { "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); state["pendingOpens"] = json!(self.ui.pending_opens.len()); + state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); state } } diff --git a/crates/ui-egui/src/missing_fonts.rs b/crates/ui-egui/src/missing_fonts.rs new file mode 100644 index 00000000..8236ca90 --- /dev/null +++ b/crates/ui-egui/src/missing_fonts.rs @@ -0,0 +1,102 @@ +//! The Missing fonts window (#752): the fonts a file uses that aren't installed, whose text is +//! drawn in a fallback font. It opens by itself after the user opens a file that has any (once +//! the installed fonts are indexed and any import report is closed), and from Text ▸ Missing fonts. + +use egui::RichText; +use figcraft_engine::fonts::{Missing, missing}; +use serde_json::{Value, json}; + +use crate::FigApp; +use crate::i18n::{tr, trn}; +use crate::theme::{Tokens, regular}; + +#[derive(Clone, Debug)] +pub struct Shown { + /// The document it lists: the window goes when that document closes. + pub uid: u64, + pub families: Vec, +} + +impl Shown { + pub fn inspect(&self) -> Value { + json!({"families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>()}) + } +} + +/// After a file the user opened (or imported into) is in front: look for missing fonts in it +/// once that can be told ([`show`]). +pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { + if result.is_some() + && matches!(command, "file.open" | "file.openRecent" | "file.import") + && let Ok(ds) = app.session.ds() + { + app.ui.font_checks.push(ds.uid); + } +} + +/// The window for document `uid`, listing what it misses (even nothing, when asked for). +fn list(app: &mut FigApp, uid: u64) -> Option> { + let ds = app.session.docs.iter().find(|d| d.uid == uid)?; + let families = missing(&ds.doc); + app.ui.missing_fonts = Some(Shown { uid, families: families.clone() }); + Some(families) +} + +/// Text ▸ Missing fonts (`dialog.missingFonts`): the window for the file in front. +pub fn request(app: &mut FigApp) { + if let Ok(uid) = app.session.ds().map(|d| d.uid) { + list(app, uid); + } +} + +/// Installed fonts are indexed: before that, every one of them would count as missing. The +/// browser has only its built-in fonts, so files there don't open the window by themselves. +fn fonts_known() -> bool { + !cfg!(target_arch = "wasm32") && figcraft_text::system_fonts_scanned() +} + +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() && fonts_known() { + let uid = app.ui.font_checks.remove(0); + if list(app, uid).is_some_and(|f| f.is_empty()) { + app.ui.missing_fonts = None; + } + } + if app.ui.missing_fonts.as_ref().is_some_and(|m| !app.session.docs.iter().any(|d| d.uid == m.uid)) { + app.ui.missing_fonts = None; + } + let Some(shown) = &app.ui.missing_fonts else { return }; + let t = Tokens::get(ctx); + let mut open = true; + let mut close_clicked = false; + crate::dialogs::window(tr("Missing fonts"), ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(380.0); + if shown.families.is_empty() { + ui.label(tr("All the fonts this file uses are installed")); + } else { + ui.label( + RichText::new(tr( + "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them.", + )) + .color(t.text_secondary) + .font(regular(11.0)), + ); + ui.add_space(6.0); + egui::ScrollArea::vertical().id_salt("missing_fonts").max_height(260.0).show(ui, |ui| { + for m in &shown.families { + ui.horizontal(|ui| { + ui.label(RichText::new(&m.family).strong()); + let used = + if m.layers == 0 { tr("only in a text style or variable").to_string() } else { trn("{n} text layers", m.layers, &[]) }; + ui.label(RichText::new(used).color(t.text_secondary)); + }); + } + }); + } + ui.add_space(12.0); + close_clicked = crate::widgets::button(ui, tr("Close"), true, true).clicked(); + }); + if !open || close_clicked { + app.ui.missing_fonts = None; + } +} diff --git a/crates/ui-egui/src/missing_fonts_tests.rs b/crates/ui-egui/src/missing_fonts_tests.rs new file mode 100644 index 00000000..ba86cc7e --- /dev/null +++ b/crates/ui-egui/src/missing_fonts_tests.rs @@ -0,0 +1,112 @@ +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::import_report::Report; +use crate::layers_drag_tests::Ui; + +/// A saved file with text in `families` (the first two layers share the first family). +fn saved(name: &str, families: &[&str]) -> String { + let mut s = Session::new(); + for (i, f) in families.iter().chain(families.first()).enumerate() { + s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 40 * i, "props": {"fontFamily": f}})).unwrap(); + } + let path = std::env::temp_dir().join(format!("figcraft-missing-fonts-{name}-{}.figcraft", std::process::id())); + let path = path.to_string_lossy().into_owned(); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + path +} + +/// The installed fonts are indexed, as the desktop app does at startup; false when this machine +/// opted out (nothing would ever count as known). +fn fonts_indexed() -> bool { + figcraft_text::scan_system_fonts(); + figcraft_text::system_fonts_scanned() +} + +fn listed(u: &Ui) -> Value { + u.app.inspect()["missingFonts"].clone() +} + +#[test] +fn opening_a_file_lists_its_missing_fonts_and_not_the_installed_ones() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let installed = figcraft_text::family_names().into_iter().next(); + let mut families = vec!["FigCraft Missing UI"]; + families.extend(installed.as_deref()); + let path = saved("open", &families); + let mut u = Ui::new(Session::new()); + // Programmatic opens (control channel, MCP, scripts) never open the window. + u.app.run("file.open", json!({"path": path})); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); + + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + u.frame(vec![]); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing UI", "layers": 2}]})); + u.frame(vec![]); + let shown = u.frame_output(vec![]).shapes; + assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "2 text layers")); + + // It goes with its file. + u.app.run("file.close", json!({})); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); +} + +#[test] +fn a_file_without_missing_fonts_opens_no_window() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let Some(installed) = figcraft_text::family_names().into_iter().next() else { + eprintln!("skipped: no fonts on this machine"); + return; + }; + let path = saved("clean", &[&installed]); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); + assert!(u.app.ui.font_checks.is_empty(), "the check ran"); +} + +#[test] +fn it_waits_for_the_import_report_to_close() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let path = saved("report", &["FigCraft Missing Report"]); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + let mut report = + Report::from_result("file.open", &json!({}), &json!({"warnings": ["Synthetic note"], "summary": {"name": "Synthetic"}})).unwrap(); + report.uid = u.app.session.ds().unwrap().uid; + u.app.ui.import_report = Some(report); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null, "the import report comes first"); + u.app.ui.import_report = None; + u.frame(vec![]); + assert_eq!(listed(&u)["families"][0]["family"], "FigCraft Missing Report"); +} + +#[test] +fn text_menu_shows_the_window_for_the_file_in_front_even_with_nothing_missing() { + let mut u = Ui::new(Session::new()); + assert!(u.app.run("text.missingFonts", json!({})).is_some()); + assert_eq!(listed(&u), json!({"families": []})); + u.frame(vec![]); + let shown = u.frame_output(vec![]).shapes; + assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "All the fonts this file uses are installed")); + let escape = + vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]; + u.frame(escape); + assert_eq!(listed(&u), Value::Null, "Escape closes it"); +} diff --git a/docs/cli.md b/docs/cli.md index 7b35c2d5..7af73fd6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -29,7 +29,9 @@ be open to the public: [server.md](server.md#running-a-public-server)). See [ser ### `info [--json]` Pages, node counts, top-level frames (id, type, name, size), style/variable/image counts. Repairs -of a damaged file and import warnings go to stderr as `note:` lines (`notes` with `--json`). +of a damaged file and import warnings go to stderr as `note:` lines (`notes` with `--json`). Fonts the +file names that aren't installed are listed as `missing font` lines (`missingFonts: [{family, +layers}]` with `--json`); their text renders in a fallback font. Every subcommand that reads a file also takes the public REST API file JSON (the response of `GET /v1/files/:key` or of its nodes endpoint, written to a `.json` file): it is imported on the fly. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b65f7729..54e2dd25 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -159,7 +159,7 @@ each. | Local fonts | yes (font helper / desktop app) | yes (system scan) | | Bundled/shared fonts | Google Fonts in the cloud | optional `CRAFT_FONTS_DIR` build input (storytold/craft-fonts) | | Variable fonts | yes | yes (axes, named instances, per-range axis values) | -| Missing-font replacement on open | yes | warnings only (REST import) | +| Missing fonts on open | listed, with replacement | listed (Missing fonts window after an open, Text ▸ Missing fonts…, `text.missingFonts`, `figcraft-cli info`); no replacement; text draws in a fallback font | ## Revision history @@ -167,6 +167,7 @@ each. |---|---|---| | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | +| 2026-10-17 | minor | Missing fonts (#752): the fonts a file names that aren't installed (text layers, styled ranges, text styles, font family variables in any mode) are listed when the user opens it (once installed fonts are indexed and any import report is closed), from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. The row claimed REST import warnings no code produced. | | 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged | | 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index e63283bd..a84bf05b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -233,6 +233,8 @@ Or in a project's `.mcp.json`: user units, e.g. `{"wght": 550, "wdth": 85}` (`null` = back to what the style name implies); `text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}` lists a font's axes and named instances (named instances are also valid `fontStyle` values). + `text.missingFonts` lists the families the document names that aren't installed + (`{families: [{family, layers}]}`); their text draws in a fallback font. OpenType features: `features {"liga": false, "tnum": true}`. Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and `textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline