From 6ef899cdbb832be94a64fc4d3cfc6abbd65323cd Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 16:41:31 +0700 Subject: [PATCH] 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. --- Cargo.lock | 88 +++++++++ Cargo.toml | 4 + ROADMAP.md | 3 +- crates/engine/Cargo.toml | 6 + crates/engine/src/fonts.rs | 57 +++++- crates/engine/src/fonts/download.rs | 168 ++++++++++++++++ crates/engine/src/fonts/download_tests.rs | 61 ++++++ crates/engine/src/tests.rs | 4 + crates/text/Cargo.toml | 4 + crates/text/src/fontdb.rs | 4 + crates/text/src/fontdb_tests.rs | 7 + crates/ui-egui/locales/en.toml | 6 + crates/ui-egui/src/dialogs/missing_fonts.rs | 209 ++++++++++++++++---- crates/ui-egui/src/missing_fonts_tests.rs | 56 +++++- docs/file-format-parity.md | 5 +- docs/mcp.md | 9 +- docs/roadmap.md | 3 +- 17 files changed, 642 insertions(+), 52 deletions(-) create mode 100644 crates/engine/src/fonts/download.rs create mode 100644 crates/engine/src/fonts/download_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 8770f7be..ad774f84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,11 +939,46 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-atomic" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "rustix 1.1.5", +] + [[package]] name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +[[package]] +name = "craft-font-catalog" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fontique", + "skrifa 0.47.0", + "thiserror 2.0.21", + "windows-registry", +] + +[[package]] +name = "craft-font-store" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-atomic", + "craft-font-catalog", + "flate2", + "serde", + "serde_json", + "sha2", + "skrifa 0.47.0", + "thiserror 2.0.21", + "ureq", +] + [[package]] name = "craft-fonts-build" version = "0.1.0" @@ -1746,6 +1781,8 @@ version = "0.12.1" dependencies = [ "base64 0.22.1", "craft-docpath", + "craft-font-catalog", + "craft-font-store", "deunicode", "figcraft-collab", "figcraft-color", @@ -1923,6 +1960,7 @@ name = "figcraft-text" version = "0.12.1" dependencies = [ "core-text", + "craft-font-catalog", "figcraft-doc", "figcraft-geom", "harfrust 0.13.3", @@ -2116,6 +2154,28 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "fontique" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts 0.41.0", + "roxmltree 0.21.2", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -3425,6 +3485,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2-core-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3683,6 +3753,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parlance" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" + [[package]] name = "pdf-writer" version = "0.15.0" @@ -5359,6 +5435,7 @@ dependencies = [ "rustls-platform-verifier", "ureq-proto", "utf8-zero", + "webpki-roots", ] [[package]] @@ -6504,6 +6581,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.4" diff --git a/Cargo.toml b/Cargo.toml index 46e48b9a..9177f208 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,10 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # pull a second egui family version). craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto +# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs). +craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs). craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button diff --git a/ROADMAP.md b/ROADMAP.md index 5827a75d..59be86c5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged | | 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. | | 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged | | 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) | diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index beedc173..11fb7f2f 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -33,6 +33,12 @@ deunicode = { workspace = true } same-file = { workspace = true } web-time = "1.1.0" +# Downloading a missing font (desktop only; the web keeps its built-in fonts). The bundled index +# (~620 KB) lets the Missing fonts window tell which fonts can be downloaded without a request. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-font-store = { workspace = true, features = ["bundled-index"] } +craft-font-catalog = { workspace = true } + [dev-dependencies] zip = { workspace = true } wat = { workspace = true } diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index dee5d1f7..d1ba177e 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -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, } /// 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 { } } } - 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 { + 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 { let mut out = Vec::new(); @@ -134,18 +172,19 @@ fn refit_text(s: &mut Session, p: &Value) -> Result { } fn missing_fonts(s: &mut Session, _: &Value) -> Result { - let families: Vec = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect(); + let families: Vec = 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 { - 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 { 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; diff --git a/crates/engine/src/fonts/download.rs b/crates/engine/src/fonts/download.rs new file mode 100644 index 00000000..1a8a72a2 --- /dev/null +++ b/crates/engine/src/fonts/download.rs @@ -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> = Mutex::new(BTreeMap::new()); +/// Set by [`use_store_dir`]. +static STORE_DIR: OnceLock = OnceLock::new(); + +fn jobs() -> std::sync::MutexGuard<'static, BTreeMap> { + 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 { + 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 { + 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 = 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 { + 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 { + 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 { + 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 = 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; diff --git a/crates/engine/src/fonts/download_tests.rs b/crates/engine/src/fonts/download_tests.rs new file mode 100644 index 00000000..56f0bca8 --- /dev/null +++ b/crates/engine/src/fonts/download_tests.rs @@ -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)); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fd288f81..e566f661 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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!( diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 5322ca58..9392505d 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -25,6 +25,10 @@ log = { workspace = true } # `get_descriptors` takes ownership of the array correctly (20.x and 21.x leak it on every scan); # it brings core-foundation 0.10, core-graphics 0.25 and core-graphics-types 0.2 beside the older # versions other crates use. Pinned exactly: review the diff before bumping. +# The suite's font store folder (fonts installed by any Craft app) is scanned with the system's. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-font-catalog = { workspace = true } + [target.'cfg(target_os = "macos")'.dependencies] core-text = { version = "=22.0.0", default-features = false, features = ["link"] } objc2 = "0.6" diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 13987171..d62948ae 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -874,6 +874,10 @@ fn system_font_dirs() -> Vec { v.push("/usr/local/share/fonts".into()); v.extend(unix_user_font_dirs(std::env::var_os("HOME"), std::env::var_os("XDG_DATA_HOME"))); } + // Fonts any Craft app downloaded into the suite's font store (a download being installed is a + // `.part` file, which the walk skips). + #[cfg(not(target_arch = "wasm32"))] + v.extend(craft_font_catalog::font_store_dir(&craft_font_catalog::FontEnv::from_process())); v } diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 35b9435b..33cebe17 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -261,6 +261,13 @@ fn the_scan_reads_font_files_without_an_extension() { assert!(system_font_dirs().iter().any(|d| d.ends_with("Adobe/CoreSync/plugins/livetype")), "Adobe Fonts' folder is scanned"); } +/// Fonts downloaded into the suite's font store (by any Craft app) are scanned with the system's. +#[test] +fn the_suite_font_store_is_scanned() { + let store = craft_font_catalog::font_store_dir(&craft_font_catalog::FontEnv::from_process()); + assert!(store.is_none_or(|dir| system_font_dirs().contains(&dir))); +} + /// A rescan offers new families and the styles installed since, and never a file twice. #[test] fn a_rescan_offers_fonts_installed_since() { diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 8707e3e9..b329884c 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -98,6 +98,8 @@ name = "English" "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" +"Allow and download" = "Allow and download" +"Allow downloading fonts?" = "Allow downloading fonts?" "Along the bottom" = "Along the bottom" "Along the left" = "Along the left" "Along the right" = "Along the right" @@ -448,6 +450,8 @@ name = "English" "Dots" = "Dots" "Dotted" = "Dotted" "Down" = "Down" +"Download" = "Download" +"Downloading… {percent}%" = "Downloading… {percent}%" "Drafts" = "Drafts" "Drag the stroke to add a width point" = "Drag the stroke to add a width point" "Draw" = "Draw" @@ -592,6 +596,7 @@ name = "English" "Frame selection" = "Frame selection" "Frame tools" = "Frame tools" "Frames" = "Frames" +"Free fonts come from Google Fonts and Noto, through jsDelivr and GitHub. Each file is checked against the font index before it is installed into the font folder the Craft apps share. Nothing is downloaded until you allow it." = "Free fonts come from Google Fonts and Noto, through jsDelivr and GitHub. Each file is checked against the font index before it is installed into the font folder the Craft apps share. Nothing is downloaded until you allow it." "From the library \"{name}\": read-only" = "From the library \"{name}\": read-only" "From {name}" = "From {name}" "From {server}" = "From {server}" @@ -937,6 +942,7 @@ name = "English" "Not bound" = "Not bound" "Not in a live session" = "Not in a live session" "Not in this font" = "Not in this font" +"Not now" = "Not now" "Not properties, kept as written: {names}" = "Not properties, kept as written: {names}" "Not read yet" = "Not read yet" "Not shared" = "Not shared" diff --git a/crates/ui-egui/src/dialogs/missing_fonts.rs b/crates/ui-egui/src/dialogs/missing_fonts.rs index b6c79477..c38bdd8c 100644 --- a/crates/ui-egui/src/dialogs/missing_fonts.rs +++ b/crates/ui-egui/src/dialogs/missing_fonts.rs @@ -1,13 +1,17 @@ //! 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. +//! +//! A family the font store has (Google Fonts, Noto) gets a Download button (#754): the first +//! one asks the user to turn downloading on, then `font.download` installs it on a thread of its +//! own, and once it's installed the file's text fits the real font and the family leaves the list. use egui::RichText; -use figcraft_engine::fonts::{Missing, missing}; +use figcraft_engine::fonts::{Download, Missing, allow_downloads, downloads, downloads_allowed, missing}; use serde_json::{Value, json}; use crate::FigApp; -use crate::i18n::{tr, trn}; +use crate::i18n::{tr, trf, trn}; use crate::theme::{Tokens, regular}; #[derive(Clone, Debug)] @@ -17,13 +21,20 @@ pub struct Shown { pub families: Vec, /// Asked for before the installed fonts were indexed: the list comes once they are. pub indexing: bool, + /// The family whose Download asks the user to turn downloading fonts on. + pub consent: Option, } impl Shown { + pub fn new(uid: u64, families: Vec, indexing: bool) -> Self { + Self { uid, families, indexing, consent: None } + } + pub fn inspect(&self) -> Value { json!({ - "families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(), + "families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers, "source": m.source})).collect::>(), "indexing": self.indexing, + "consent": self.consent, }) } } @@ -45,7 +56,8 @@ pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { fn list(app: &mut FigApp, uid: u64) -> Option> { let ds = app.session.docs.iter().find(|d| d.uid == uid)?; let families = missing(&ds.doc); - app.ui.missing_fonts = Some(Shown { uid, families: families.clone(), indexing: false }); + let consent = app.ui.missing_fonts.as_mut().filter(|m| m.uid == uid).and_then(|m| m.consent.take()); + app.ui.missing_fonts = Some(Shown { consent, ..Shown::new(uid, families.clone(), false) }); Some(families) } @@ -55,11 +67,28 @@ pub fn request(app: &mut FigApp) { if figcraft_text::installed_fonts_known() { list(app, uid); } else { - app.ui.missing_fonts = Some(Shown { uid, families: Vec::new(), indexing: true }); + app.ui.missing_fonts = Some(Shown::new(uid, Vec::new(), true)); } } pub fn show(app: &mut FigApp, ctx: &egui::Context) { + finish_downloads(app, ctx); + list_when_known(app); + let Some(shown) = &app.ui.missing_fonts else { return }; + let t = Tokens::get(ctx); + let mut open = true; + let mut clicks = Clicks::default(); + crate::dialogs::window(tr("Missing fonts"), ctx).open(&mut open).show(ctx, |ui| clicks = body(ui, &t, shown)); + let close = !open || clicks.close; + act(app, clicks); + if close { + app.ui.missing_fonts = None; + } +} + +/// Lists the fonts of a window that waits for the installed fonts to be indexed, and of a file +/// opened since, once they are; a window whose file closed goes. +fn list_when_known(app: &mut FigApp) { // 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() { @@ -76,40 +105,146 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { 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)); - }); +} + +/// What the user clicked in the window this frame. +#[derive(Default)] +struct Clicks { + /// The family whose Download (or Try again) was clicked. + download: Option, + /// The answer to the consent prompt. + consent: Option, + close: bool, +} + +fn body(ui: &mut egui::Ui, t: &Tokens, shown: &Shown) -> Clicks { + let mut clicks = Clicks::default(); + 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); + let jobs = downloads(); + egui::ScrollArea::vertical().id_salt("missing_fonts").max_height(260.0).show(ui, |ui| { + for m in &shown.families { + let job = jobs.iter().find(|(f, _)| f.eq_ignore_ascii_case(&m.family)).map(|(_, d)| d); + if row(ui, t, m, job) { + clicks.download = Some(m.family.clone()); } - }); + } + }); + } + if shown.consent.is_some() { + clicks.consent = consent_prompt(ui, t); + } + ui.add_space(12.0); + clicks.close = crate::widgets::button(ui, tr("Close"), true, true).clicked(); + clicks +} + +/// Download starts at once while downloading is on, else asks the user first; their Allow turns +/// it on (the only place that does) and starts it. +fn act(app: &mut FigApp, clicks: Clicks) { + if let Some(family) = clicks.download { + if downloads_allowed() { + start(app, &family); + } else if let Some(shown) = app.ui.missing_fonts.as_mut() { + shown.consent = Some(family); + } + } + let Some(allow) = clicks.consent else { return }; + let Some(family) = app.ui.missing_fonts.as_mut().and_then(|m| m.consent.take()) else { return }; + if allow { + match allow_downloads() { + Ok(()) => start(app, &family), + Err(e) => app.show_error(e), } - ui.add_space(12.0); - close_clicked = crate::widgets::button(ui, tr("Close"), true, true).clicked(); + } +} + +/// One missing family: its name, where the file uses it, and for a family the font store has, +/// where it comes from and its Download button (its progress while it downloads, and why it +/// failed). Returns whether Download was clicked. +fn row(ui: &mut egui::Ui, t: &Tokens, m: &Missing, job: Option<&Download>) -> bool { + let mut clicked = false; + 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)); + let Some(source) = &m.source else { return }; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + match job { + Some(Download::Running { done, total }) => { + let percent = if *total == 0 { 0 } else { done.saturating_mul(100) / total }; + ui.label(RichText::new(trf("Downloading… {percent}%", &[("percent", &percent)])).color(t.text_secondary)); + } + Some(Download::Installed) => {} + Some(Download::Failed(_)) => clicked = crate::widgets::button(ui, tr("Try again"), false, true).clicked(), + None => clicked = crate::widgets::button(ui, tr("Download"), false, true).clicked(), + } + ui.label(RichText::new(source).color(t.text_tertiary).font(regular(11.0))); + }); }); - if !open || close_clicked { - app.ui.missing_fonts = None; + if let Some(Download::Failed(error)) = job { + ui.add(egui::Label::new(RichText::new(error).color(t.danger).font(regular(11.0))).wrap()); + } + clicked +} + +/// Asks the user to turn downloading fonts on (the setting every Craft app shares): `Some(true)` +/// when they allow it, `Some(false)` for Not now. +fn consent_prompt(ui: &mut egui::Ui, t: &Tokens) -> Option { + ui.add_space(10.0); + crate::widgets::separator(ui); + ui.add_space(6.0); + ui.label(RichText::new(tr("Allow downloading fonts?")).strong()); + ui.add( + egui::Label::new( + RichText::new(tr( + "Free fonts come from Google Fonts and Noto, through jsDelivr and GitHub. Each file is checked against the font index before it is installed into the font folder the Craft apps share. Nothing is downloaded until you allow it.", + )) + .color(t.text_secondary) + .font(regular(11.0)), + ) + .wrap(), + ); + ui.add_space(6.0); + ui.horizontal(|ui| { + if crate::widgets::button(ui, tr("Allow and download"), true, true).clicked() { + Some(true) + } else if crate::widgets::button(ui, tr("Not now"), false, true).clicked() { + Some(false) + } else { + None + } + }) + .inner +} + +fn start(app: &mut FigApp, family: &str) { + app.run("font.download", json!({"family": family})); +} + +/// A download that finished fits the open file's text to its font (`font.download` again) and +/// leaves the list; one under way repaints the window as it progresses. +fn finish_downloads(app: &mut FigApp, ctx: &egui::Context) { + let jobs = downloads(); + for (family, _) in jobs.iter().filter(|(_, d)| *d == Download::Installed) { + start(app, family); + if let Some(uid) = app.ui.missing_fonts.as_ref().filter(|m| !m.indexing).map(|m| m.uid) { + list(app, uid); + } + } + if jobs.iter().any(|(_, d)| matches!(d, Download::Running { .. })) { + ctx.request_repaint_after(std::time::Duration::from_millis(250)); } } diff --git a/crates/ui-egui/src/missing_fonts_tests.rs b/crates/ui-egui/src/missing_fonts_tests.rs index 2b11260a..d48be5ee 100644 --- a/crates/ui-egui/src/missing_fonts_tests.rs +++ b/crates/ui-egui/src/missing_fonts_tests.rs @@ -46,7 +46,7 @@ fn opening_a_file_lists_its_missing_fonts_and_not_the_installed_ones() { 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})); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing UI", "layers": 2, "source": null}], "indexing": false, "consent": null})); 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")); @@ -102,7 +102,7 @@ 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})); + assert_eq!(listed(&u), json!({"families": [], "indexing": false, "consent": null})); 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")); @@ -121,9 +121,12 @@ fn asked_for_before_the_fonts_are_indexed_it_lists_them_once_they_are() { 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.app.ui.missing_fonts = Some(crate::missing_fonts::Shown::new(uid, Vec::new(), true)); u.frame(vec![]); - assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing Indexing", "layers": 2}], "indexing": false})); + assert_eq!( + listed(&u), + json!({"families": [{"family": "FigCraft Missing Indexing", "layers": 2, "source": null}], "indexing": false, "consent": null}) + ); } #[test] @@ -147,3 +150,48 @@ fn placing_images_or_clearing_recents_does_not_bring_it_back() { assert!(u.app.ui.font_checks.is_empty()); assert_eq!(listed(&u), Value::Null); } + +/// The middle of the button labelled `label` in the window, from the AccessKit tree. +fn click_button(u: &mut Ui, label: &str) { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let update = u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update"); + let node = update.nodes.iter().map(|(_, n)| n).find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no {label:?} button")); + let b = node.bounds().unwrap(); + let at = egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32); + u.button(at, true); + u.button(at, false); + u.frame(vec![]); +} + +#[test] +fn a_family_the_font_store_has_offers_download_and_asks_before_any_download() { + // A store folder of the tests' own, where downloading was never turned on. + figcraft_engine::fonts::download::use_store_dir(std::env::temp_dir().join(format!("figcraft-ui-font-store-{}", std::process::id()))); + assert!(fonts_indexed()); + let Some(store) = ["Lato", "Merriweather"].into_iter().find(|f| !figcraft_text::has_family(f)) else { + eprintln!("skipped: these fonts are installed"); + return; + }; + let path = saved("download", &["FigCraft Missing Download", store]); + 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)["families"], + json!([{"family": "FigCraft Missing Download", "layers": 2, "source": null}, {"family": store, "layers": 1, "source": "Google Fonts"}]) + ); + let shown = crate::import_report_tests::shown_texts(u.frame_output(vec![]).shapes); + assert_eq!(shown.iter().filter(|t| *t == "Download").count(), 1, "only the family the store has: {shown:?}"); + + click_button(&mut u, "Download"); + assert_eq!(listed(&u)["consent"], store, "downloading is off: the user is asked first"); + let shown = crate::import_report_tests::shown_texts(u.frame_output(vec![]).shapes); + assert!(shown.iter().any(|t| t == "Allow downloading fonts?"), "{shown:?}"); + assert!(figcraft_engine::fonts::downloads().is_empty(), "nothing downloads before the user allows it"); + + click_button(&mut u, "Not now"); + assert_eq!(listed(&u)["consent"], Value::Null); + assert!(!figcraft_engine::fonts::downloads_allowed() && figcraft_engine::fonts::downloads().is_empty()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8e861c7e..6a5a9d2e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -177,12 +177,13 @@ source paint coordinates. | Local fonts | yes (font helper / desktop app) | yes (system scan) | | Bundled/shared fonts | Google Fonts in the cloud | optional `CRAFT_FONTS_DIR` build input (storytold/craft-fonts) | | Variable fonts | yes | yes (axes, named instances, per-range axis values) | -| Missing-font replacement on open | yes | listed, not replaced: in the desktop app the Missing fonts window opens after a file that uses them opens and from Text ▸ Missing fonts (`text.missingFonts`; `figcraft-cli info` prints `missing font` lines), and their text draws in a fallback font | +| Missing-font replacement on open | yes | listed, not replaced: in the desktop app the Missing fonts window opens after a file that uses them opens and from Text ▸ Missing fonts (`text.missingFonts`; `figcraft-cli info` prints `missing font` lines), and their text draws in a fallback font. A family the font store has (Google Fonts, Noto) downloads from the window once the user allows downloading fonts (`font.download`, #754): pinned, checksum-verified files into the Craft apps' shared font folder, after which the text draws in it. No substitute picker | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Fonts: the missing-font row adds downloading a missing family the font store has (Google Fonts, Noto) from the Missing fonts window, after the user allows it (#754). Replacing a missing font with another is still missing. Numbers unchanged | | 2026-10-29 | minor | Image key names (#1086): one rule now covers the archive reader, the collab entries and the REST importer's placeholders — 1 to 128 ASCII letters, digits, `-` or `_`. The reader accepts `-` and `_` from now on, so the REST importer's `rest-image-missing` placeholder survives a save and load instead of being dropped as damaged; a placeholder whose `imageRef` can't name an archive member (path text, an over-long ref) or is shaped like a content hash (64 hex digits, which the placeholder's bytes don't hash to) now shares that placeholder instead of being keyed by its raw name. The importers' largest image (256 MiB) is one constant, `figcraft_doc::ImageData::MAX_BYTES`, which live sessions use too. Numbers unchanged | | 2026-10-27 | minor | M6, #1397: damaged generated FigJam shape geometry falls back to usable outer fill or stroke outlines before a rectangle; a surviving sticky author no longer suppresses the outer body-text fallback. Synthetic regressions cover both outlines, missing/invalid/blank bodies, author retention and layer budgets. Numbers unchanged. | | 2026-10-27 | patch | M6, #1143: merge main and isolate offline Penpot support; preserve conversion warnings, retain nested token-set bindings, bound missing-flow warnings and keep export regressions registered. Numbers unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 5ddc9964..c9978dea 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -255,7 +255,14 @@ Or in a project's `.mcp.json`: `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. Fonts are indexed once + (`{families: [{family, layers, source}]}`); their text draws in a fallback font. On the desktop, + `font.download {family}` installs a family whose `source` isn't null (Google Fonts, Noto) from + the font store, in the background: pinned, checksum-verified files into the Craft apps' shared + font folder. It works only while the user has allowed downloading fonts, which only the user can + do, in the app's Missing fonts window; otherwise it is refused. Call it again for its progress + (`{state: downloading, done, total}`); once installed, the call fits the open document's text to + the font (`{state: installed, refitted}`). `font.downloads` lists the downloads and whether + downloading is allowed (`{allowed, downloads: [{family, state, error?}]}`). Fonts are indexed once per process: after installing one, `text.rescanFonts` picks it up (`{added: [families]}`), and `text.loadFont {path | data (base64)}` adds a font file without installing it (`{families}`). Text boxes keep the size they were measured at: both refit the open document's auto-sizing text diff --git a/docs/roadmap.md b/docs/roadmap.md index a481c2db..b59384ce 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged | | 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. | | 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged | | 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) |