mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
Merge fix/758 into batch 16
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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, ¶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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user