Merge fix/758 into batch 16

This commit is contained in:
bflatastic
2026-10-18 11:16:43 +07:00
19 changed files with 576 additions and 5 deletions
+6
View File
@@ -155,6 +155,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
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<ExitCode> {
"variables": doc.variables.variables.len(),
"images": doc.images.len(),
"notes": notes,
"missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::<Vec<_>>(),
});
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<ExitCode> {
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)
}
+22
View File
@@ -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}");
}
+3
View File
@@ -161,6 +161,7 @@ fn main() -> eframe::Result {
log::error!("{f}: {e}");
}
}
let opened: Vec<u64> = 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);
+3 -1
View File
@@ -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<Item = &'static MenuItem> {
}
/// 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>) {
+1
View File
@@ -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());
+100
View File
@@ -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<Missing> {
// Lower-case family → (spelling, layers).
let mut used: BTreeMap<String, (String, usize)> = 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<String> {
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<Value> {
let families: Vec<Value> = 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<CommandSpec> {
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;
+126
View File
@@ -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<String> {
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<String> = 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());
}
+1
View File
@@ -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;
+15
View File
@@ -852,6 +852,11 @@ pub fn family_names() -> Vec<String> {
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<String> {
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() {
+2 -2
View File
@@ -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;
+6
View File
@@ -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" }
+7
View File
@@ -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"
+3
View File
@@ -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;
+1 -1
View File
@@ -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<egui::epaint::ClippedShape>) -> Vec<String> {
pub(crate) fn shown_texts(shapes: Vec<egui::epaint::ClippedShape>) -> Vec<String> {
fn walk(shape: &egui::Shape, out: &mut Vec<String>) {
match shape {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
+11
View File
@@ -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<import_report::Report>,
/// Files the user opened whose fonts are yet to be checked (by document uid).
pub font_checks: Vec<u64>,
pub missing_fonts: Option<missing_fonts::Shown>,
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, &params, 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
}
}
+115
View File
@@ -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<Missing>,
/// 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::<Vec<_>>(),
"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<Vec<Missing>> {
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;
}
}
+149
View File
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>"#).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);
}
+3 -1
View File
@@ -29,7 +29,9 @@ be open to the public: [server.md](server.md#running-a-public-server)). See [ser
### `info <file> [--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.
+2
View File
@@ -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