mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user