diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 3e08474a2..65b19ee46 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 c5901ac50..ef5c638fa 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 10147c2cc..fe89d0a75 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 c8f34fc15..4b6cd3fcc 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 b942fc250..f011238d9 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 000000000..3548124d5 --- /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::installed_fonts_known`), 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 000000000..a6e2d213f --- /dev/null +++ b/crates/engine/src/fonts_tests.rs @@ -0,0 +1,126 @@ +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 (bindings are applied after the next + // command); the cycle resolves to nothing. + let NodeKind::Text(bound) = &s.doc().unwrap().node(t).unwrap().kind else { panic!() }; + assert_eq!(bound.style.font_family, "FigCraft Missing F"); + 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 30f626ad8..af0f555e6 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 a6d52d672..f9eda2c6d 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) @@ -888,6 +893,16 @@ fn system_fonts_opted_out() -> bool { std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0") } +/// Every font text can use is known: the system fonts were indexed, or there are none to index +/// (opted out, or the web). Until then a family the scan would find counts as missing. +pub fn installed_fonts_known() -> bool { + #[cfg(not(target_arch = "wasm32"))] + if !system_fonts_opted_out() { + return system_fonts_scanned(); + } + true +} + /// Index the system fonts the first time the font database is used (headless apps: a run that /// lays out no text never pays for the scan). The desktop app scans in the background instead. pub fn scan_system_fonts_on_demand() { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 27456d824..683350f61 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, + installed_fonts_known, 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 59b25d949..41c5b9d6a 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" @@ -197,6 +198,7 @@ name = "English" "C or ⇧C" = "C or ⇧C" "CMYK colour" = "CMYK colour" "Can view" = "Can view" +"Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…" "Checking the server…" = "Checking the server…" "Clear from this list" = "Clear from this list" "Code or invite link" = "Code or invite link" @@ -771,6 +773,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 +1377,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 +1597,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 +1705,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 01c5813bd..db4fba3e0 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" @@ -200,6 +201,7 @@ status = "draft — needs native review" "C or ⇧C" = "C o ⇧C" "CMYK colour" = "Color CMYK" "Can view" = "Puede ver" +"Checking the fonts installed on this computer…" = "Comprobando las fuentes instaladas en este equipo…" "Checking the server…" = "Comprobando el servidor…" "Clear from this list" = "Borrar de esta lista" "Code or invite link" = "Código o enlace de invitación" @@ -774,6 +776,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 +1378,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 +1598,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 +1706,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 +2132,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 39001b56e..448501abf 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 8742ccd33..a24115823 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 18445a7a6..55c5438d2 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; @@ -508,6 +511,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, @@ -636,6 +642,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, @@ -764,6 +772,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 } @@ -878,6 +887,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, @@ -1669,6 +1679,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 000000000..b6c794777 --- /dev/null +++ b/crates/ui-egui/src/missing_fonts.rs @@ -0,0 +1,115 @@ +//! 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, + /// Asked for before the installed fonts were indexed: the list comes once they are. + pub indexing: bool, +} + +impl Shown { + pub fn inspect(&self) -> Value { + json!({ + "families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(), + "indexing": self.indexing, + }) + } +} + +/// After the user opened a file, or imported pages into one (results with a `summary`; not a +/// placed image or SVG, a file that was open already, or Clear recent): look for missing fonts in +/// it once that can be told ([`show`]). Not in the browser, which has only its built-in fonts. +pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { + if !cfg!(target_arch = "wasm32") + && matches!(command, "file.open" | "file.openRecent" | "file.import") + && result.is_some_and(|r| r.get("summary").is_some()) + && 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(), indexing: false }); + Some(families) +} + +/// Text ▸ Missing fonts (`dialog.missingFonts`): the window for the file in front. +pub fn request(app: &mut FigApp) { + let Ok(uid) = app.session.ds().map(|d| d.uid) else { return }; + if figcraft_text::installed_fonts_known() { + list(app, uid); + } else { + app.ui.missing_fonts = Some(Shown { uid, families: Vec::new(), indexing: true }); + } +} + +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + // Whether the installed fonts are indexed is asked only while a list waits for it. + let waiting = app.ui.missing_fonts.as_ref().filter(|m| m.indexing).map(|m| m.uid); + if (waiting.is_some() || !app.ui.font_checks.is_empty()) && figcraft_text::installed_fonts_known() { + if let Some(uid) = waiting { + list(app, uid); + } + if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() { + 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.indexing { + ui.label(tr("Checking the fonts installed on this computer…")); + } else 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 000000000..2b11260a4 --- /dev/null +++ b/crates/ui-egui/src/missing_fonts_tests.rs @@ -0,0 +1,149 @@ +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. +fn fonts_indexed() -> bool { + figcraft_text::scan_system_fonts(); + figcraft_text::installed_fonts_known() +} + +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("file.close", json!({})); + + 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}], "indexing": false})); + 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() { + assert!(fonts_indexed()); + let mut u = Ui::new(Session::new()); + assert!(u.app.run("text.missingFonts", json!({})).is_some()); + assert_eq!(listed(&u), json!({"families": [], "indexing": false})); + 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"); +} + +#[test] +fn asked_for_before_the_fonts_are_indexed_it_lists_them_once_they_are() { + assert!(fonts_indexed()); + let path = saved("indexing", &["FigCraft Missing Indexing"]); + let mut u = Ui::new(Session::new()); + u.app.run("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + // As Text ▸ Missing fonts leaves it while the startup scan is still running. + let uid = u.app.session.ds().unwrap().uid; + u.app.ui.missing_fonts = Some(crate::missing_fonts::Shown { uid, families: Vec::new(), indexing: true }); + u.frame(vec![]); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing Indexing", "layers": 2}], "indexing": false})); +} + +#[test] +fn placing_images_or_clearing_recents_does_not_bring_it_back() { + assert!(fonts_indexed()); + let path = saved("place", &["FigCraft Missing Place"]); + let svg = std::env::temp_dir().join(format!("figcraft-missing-fonts-place-{}.svg", std::process::id())); + std::fs::write(&svg, r#""#).unwrap(); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + u.frame(vec![]); + assert_eq!(listed(&u)["families"][0]["family"], "FigCraft Missing Place"); + u.app.ui.missing_fonts = None; + // A placed SVG, the same file opened again (it only comes to the front), Clear recent. + assert!(u.app.run_user("file.import", json!({"path": svg.to_string_lossy()})).is_some()); + assert!(u.app.run_user("file.open", json!({"path": path})).is_some()); + u.app.run_user("file.openRecent", json!({"clear": true})); + std::fs::remove_file(&path).unwrap(); + std::fs::remove_file(&svg).unwrap(); + u.frame(vec![]); + assert!(u.app.ui.font_checks.is_empty()); + assert_eq!(listed(&u), Value::Null); +} diff --git a/docs/cli.md b/docs/cli.md index 7b35c2d54..7af73fd60 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/mcp.md b/docs/mcp.md index e63283bdc..a84bf05b2 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