M7: download a missing free font from the Missing fonts window (#754) (#1510)

A missing family the suite's font store has (Google Fonts, Noto) gets a
Download button. The first one asks the user to allow downloading fonts,
then craft-libs' craft-font-store installs pinned, checksum-verified files
into the Craft apps' shared font folder on a thread of its own. The font
scan reads that folder; once installed the text fits the real font.

font.download / font.downloads drive the store as its agent: refused while
downloading is off, never able to turn it on. Desktop only.
This commit is contained in:
BFlat
2026-10-29 16:41:31 +07:00
committed by GitHub
parent 3d5185c2ab
commit 6ef899cdbb
17 changed files with 642 additions and 52 deletions
+51 -6
View File
@@ -19,6 +19,8 @@ pub struct Missing {
pub family: String,
/// Text layers that use it (0: only a text style or a variable names it).
pub layers: usize,
/// The font store source it can be downloaded from (`Google Fonts`, `Noto`; desktop only).
pub source: Option<String>,
}
/// The families `doc` names that the font database doesn't have, by name. On the desktop the
@@ -61,9 +63,45 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
}
}
}
used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect()
used.into_values()
.filter(|(family, _)| !figcraft_text::has_family(family))
.map(|(family, layers)| Missing { source: source(&family), family, layers })
.collect()
}
/// A family's download ([`downloads`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Download {
/// Bytes downloaded of all its files.
Running { done: u64, total: u64 },
/// Installed and scanned: the next `font.download` of it fits the open document's text.
Installed,
/// Why it failed (nothing was installed).
Failed(String),
}
#[cfg(not(target_arch = "wasm32"))]
pub use download::{allow_downloads, downloads, downloads_allowed, source};
/// The browser keeps its built-in fonts: nothing is downloaded there.
#[cfg(target_arch = "wasm32")]
mod no_download {
pub fn source(_: &str) -> Option<String> {
None
}
pub fn downloads() -> Vec<(String, super::Download)> {
Vec::new()
}
pub fn downloads_allowed() -> bool {
false
}
pub fn allow_downloads() -> std::result::Result<(), String> {
Err("fonts aren't downloaded in the browser".into())
}
}
#[cfg(target_arch = "wasm32")]
pub use no_download::{allow_downloads, downloads, downloads_allowed, source};
/// Every string value of variable `id` in any mode, following aliases (each variable once).
fn string_values(vars: &Variables, id: &str) -> Vec<String> {
let mut out = Vec::new();
@@ -134,18 +172,19 @@ fn refit_text(s: &mut Session, p: &Value) -> Result<Value> {
}
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();
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers, "source": m.source})).collect();
s.request_ui("dialog.missingFonts", json!({}));
Ok(json!({"families": families}))
}
/// The font commands: `text.missingFonts` and `text.refit`.
/// The font commands: `text.missingFonts`, `text.refit`, and on the desktop [`download`]'s.
pub fn specs() -> Vec<CommandSpec> {
vec![
#[allow(unused_mut)]
let mut list = 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}]}",
"{} 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, source (the font store source font.download gets it from, or null)}]}",
false,
missing_fonts
),
@@ -156,8 +195,14 @@ pub fn specs() -> Vec<CommandSpec> {
true,
refit_text
),
]
];
#[cfg(not(target_arch = "wasm32"))]
list.extend(download::specs());
list
}
#[cfg(not(target_arch = "wasm32"))]
pub mod download;
#[cfg(test)]
mod tests;
+168
View File
@@ -0,0 +1,168 @@
//! Downloading a missing font (#754) through the suite's font store, `craft-font-store`: its
//! pinned Google Fonts and Noto indexes (compiled in) tell which families can be had, and it
//! downloads their files, checks each one's size and sha256 and installs them into the one folder
//! every Craft app's font scan reads. Desktop only.
//!
//! Downloading is off until the user turns it on, in the app's own window ([`allow_downloads`],
//! which is not a command). The commands drive the store as its agent, as the store's rules
//! have it: they install while the user has downloading on, are refused while it is off, and can
//! never turn it on.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock, PoisonError};
use craft_font_catalog::{FontEnv, font_store_dir};
use craft_font_store::{Caller, Cancel, FontStore, Index, Network};
use serde_json::{Value, json};
use super::Download;
use crate::cmd::CommandSpec;
use crate::params::req_string;
use crate::{EngineError, Result, Session, bad, spec};
/// Lower-case family → (spelling, download). Installs are the machine's, so the jobs are the
/// process's, whichever document asked.
static JOBS: Mutex<BTreeMap<String, (String, Download)>> = Mutex::new(BTreeMap::new());
/// Set by [`use_store_dir`].
static STORE_DIR: OnceLock<PathBuf> = OnceLock::new();
fn jobs() -> std::sync::MutexGuard<'static, BTreeMap<String, (String, Download)>> {
JOBS.lock().unwrap_or_else(PoisonError::into_inner)
}
fn set(family: &str, state: Download) {
if let Some(job) = jobs().get_mut(&family.to_lowercase()) {
job.1 = state;
}
}
/// Use `dir` as the font store instead of the suite's folder, for this process (tests: they never
/// read the user's setting). The first call wins.
pub fn use_store_dir(dir: PathBuf) {
let _ = STORE_DIR.set(dir);
}
fn store_dir() -> Option<PathBuf> {
if let Some(dir) = STORE_DIR.get() {
return Some(dir.clone());
}
// This crate's tests never touch the user's store.
if cfg!(test) {
return Some(std::env::temp_dir().join(format!("figcraft-font-store-tests-{}", std::process::id())));
}
font_store_dir(&FontEnv::from_process())
}
fn store(caller: Caller) -> Option<FontStore> {
Some(FontStore::new(store_dir()?, caller))
}
/// The indexes, read once: the built-in ones compiled in and the sources the user added as last
/// fetched. No request.
fn index() -> &'static Index {
static INDEX: OnceLock<Index> = OnceLock::new();
INDEX.get_or_init(|| store(Caller::Agent).map(|s| s.offline_indexes()).unwrap_or_else(|| Index::bundled().unwrap_or_default()))
}
/// The name of the source `family` can be downloaded from (`Google Fonts`, `Noto`), if any.
pub fn source(family: &str) -> Option<String> {
let index = index();
index.family(family.trim()).and_then(|f| index.source_of(f)).map(|s| s.name.clone())
}
/// Whether the user turned downloading fonts on (the setting is the store's, shared by every
/// Craft app).
pub fn downloads_allowed() -> bool {
store(Caller::Agent).is_some_and(|s| s.network() == Network::On)
}
/// Turn downloading fonts on. Only from the user's own action in the app's interface (a click in
/// the Missing fonts window), never from a command: agents can't.
pub fn allow_downloads() -> std::result::Result<(), String> {
store(Caller::User).ok_or_else(|| "this system has no font store folder".to_string())?.enable_network().map_err(|e| e.to_string())
}
/// The downloads of this process: (family, state).
pub fn downloads() -> Vec<(String, Download)> {
jobs().values().cloned().collect()
}
fn state_json(family: &str, state: &Download) -> Value {
match state {
Download::Running { done, total } => json!({"family": family, "state": "downloading", "done": done, "total": total}),
Download::Installed => json!({"family": family, "state": "installed"}),
Download::Failed(error) => json!({"family": family, "state": "failed", "error": error}),
}
}
/// `font.download`: start downloading `family`'s files on a thread of its own, or report the
/// download under way; a family that is there now fits the open document's text to it instead.
fn download(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "font.download";
let family = req_string(CMD, p, "family")?.trim().to_string();
let key = family.to_lowercase();
let state = jobs().get(&key).map(|(_, st)| st.clone());
if let Some(running @ Download::Running { .. }) = &state {
return Ok(state_json(&family, running));
}
// Installed: even when no scan sees it (system fonts opted out), it isn't downloaded again.
if state == Some(Download::Installed) || figcraft_text::has_family(&family) {
jobs().remove(&key);
let refitted = crate::fonts::refit_open(s, None, Some(std::slice::from_ref(&family)));
return Ok(json!({"family": family, "state": "installed", "refitted": refitted}));
}
let index = index();
let found = index.family(&family).ok_or_else(|| bad(CMD, format!("{family} is not in the font store's index")))?;
let store = store(Caller::Agent).ok_or_else(|| EngineError::Other("this system has no font store folder".into()))?;
if store.network() != Network::On {
return Err(EngineError::Disabled(CMD.into(), "downloading fonts is turned off; the user turns it on in Text ▸ Missing fonts".into()));
}
let total: u64 = found.files.iter().map(|f| f.size).sum();
let source = index.source_of(found).map(|src| src.name.clone());
jobs().insert(key, (found.family.clone(), Download::Running { done: 0, total }));
let name = found.family.clone();
let spawned = std::thread::Builder::new().name("figcraft-font-download".into()).spawn(move || {
let result = store.install_family(index, found, &Cancel::new(), &mut |p| set(&name, Download::Running { done: p.done, total: p.total }));
match result {
Ok(_) => {
// The new files are scanned here, off the interface's thread.
figcraft_text::rescan_system_fonts();
set(&name, Download::Installed);
}
Err(e) => set(&name, Download::Failed(e.to_string())),
}
});
if let Err(e) = spawned {
jobs().remove(&family.to_lowercase());
return Err(EngineError::Other(format!("couldn't start the download: {e}")));
}
Ok(json!({"family": found.family, "source": source, "state": "downloading", "done": 0, "total": total, "files": found.files.len()}))
}
/// The font commands that download: `font.download` and `font.downloads`.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"font.download",
"Download font",
"{family} downloads a missing font family from the font store (Google Fonts, Noto: pinned, checksum-verified files installed into the Craft apps' shared font folder) on a thread of its own; refused while the user has downloading turned off (only the user turns it on, in Text ▸ Missing fonts). Call again to see its progress; once it is installed, the call fits the open document's text to it (text.refit) → {family, state: downloading|installed, done?, total?, refitted?}",
true,
download
),
spec!(
"font.downloads",
"Font downloads",
"{} this session's font downloads and whether downloading fonts is turned on → {allowed, downloads: [{family, state: downloading|installed|failed, done?, total?, error?}]}",
false,
|_, _| {
let list: Vec<Value> = downloads().iter().map(|(f, st)| state_json(f, st)).collect();
Ok(json!({"allowed": downloads_allowed(), "downloads": list}))
}
),
]
}
#[cfg(test)]
#[path = "download_tests.rs"]
mod tests;
+61
View File
@@ -0,0 +1,61 @@
//! `font.download` without the network: this crate's tests use a store folder of their own, where
//! downloading was never turned on.
use serde_json::{Value, json};
use super::{Download, downloads, downloads_allowed, jobs, set, source};
use crate::{EngineError, Session};
#[test]
fn the_store_index_says_which_missing_families_can_be_downloaded() {
assert_eq!(source("Lato").as_deref(), Some("Google Fonts"));
assert_eq!(source(" lato ").as_deref(), Some("Google Fonts"), "as the document spells it");
assert_eq!(source("FigCraft Not A Store Font"), None);
let mut s = Session::new();
for family in ["FigCraft Not A Store Font", "Lato"] {
s.execute("text.create", &json!({"text": "Hamburgefonstiv", "props": {"fontFamily": family}})).unwrap();
}
let listed = s.execute("text.missingFonts", &json!({})).unwrap();
let ours = |name: &str| listed["families"].as_array().unwrap().iter().find(|f| f["family"] == name).cloned();
assert_eq!(ours("FigCraft Not A Store Font").unwrap()["source"], Value::Null);
if !figcraft_text::has_family("Lato") {
assert_eq!(ours("Lato").unwrap()["source"], "Google Fonts");
}
}
#[test]
fn nothing_downloads_until_the_user_turns_downloading_on() {
let mut s = Session::new();
assert!(!downloads_allowed(), "off by default");
let Some(family) = ["Lato", "Merriweather", "Bungee Shade"].into_iter().find(|f| !figcraft_text::has_family(f)) else { return };
let refused = s.execute("font.download", &json!({"family": family})).unwrap_err();
assert!(matches!(&refused, EngineError::Disabled(cmd, why) if cmd == "font.download" && why.contains("turned off")), "{refused}");
assert!(!downloads().iter().any(|(f, _)| f == family), "no download started");
assert_eq!(s.execute("font.downloads", &json!({})).unwrap()["allowed"], false);
assert!(matches!(s.execute("font.download", &json!({})), Err(EngineError::BadParams { .. })));
let unknown = s.execute("font.download", &json!({"family": "FigCraft Not A Store Font"})).unwrap_err();
assert!(unknown.to_string().contains("not in the font store's index"), "{unknown}");
}
#[test]
fn a_download_reports_its_progress_then_fits_the_text_once_installed() {
let family = "FigCraft Download Progress";
let mut s = Session::new();
jobs().insert(family.to_lowercase(), (family.into(), Download::Running { done: 50, total: 200 }));
let r = s.execute("font.download", &json!({"family": family})).unwrap();
assert_eq!(r, json!({"family": family, "state": "downloading", "done": 50, "total": 200}), "no second download");
let listed = s.execute("font.downloads", &json!({})).unwrap();
assert!(listed["downloads"].as_array().unwrap().contains(&json!({"family": family, "state": "downloading", "done": 50, "total": 200})));
set(family, Download::Failed("downloading x failed".into()));
let listed = s.execute("font.downloads", &json!({})).unwrap();
assert!(listed["downloads"].as_array().unwrap().contains(&json!({"family": family, "state": "failed", "error": "downloading x failed"})));
// Installed (and scanned by the download's thread): the next call fits the text, once.
set(family, Download::Installed);
let r = s.execute("font.download", &json!({"family": family})).unwrap();
assert_eq!(r, json!({"family": family, "state": "installed", "refitted": []}));
assert!(!downloads().iter().any(|(f, _)| f == family));
}
+4
View File
@@ -467,6 +467,10 @@ fn every_command_survives_junk_params() {
json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [
{"$ref": "\u{0}", "field": "ids/18446744073709551616/x"}, {"$ref": {"x": 1}}, {"$step": 0, "field": "//"}, {"$ref": "\u{0}", "field": "/"}],
"props": {"name": {"$ref": "nope", "field": 5}}}}]}),
// Font downloads: family names as junk and as paths (a family is only ever looked up in
// the font index, never used as a path).
json!({"family": "../../\u{0}Lato"}),
json!({"family": ["Lato"], "style": 1e308}),
];
for spec in all_commands() {
if matches!(