collective

Browse / github.com/storytold/vectorcraft / patch

Type: fonts a host supplies: text.addFont and font sources activated on demand

Type: fonts a host supplies: text.addFont and font sources activated on demand ## What and why The web build has no installed fonts and loads only the faces it was built with, so a page that embeds VectorCraft has no way to supply the fonts its documents use, and no way to offer a collection larger than it should load at once. This adds both, as a pluggable font provider ([target-app-parity.md](https://github.com/storytold/vectorcraft/blob/main/docs/target-app-parity.md) puts Adobe Fonts out of scope, "pluggable provider APIs at most"). It behaves as Illustrator does with **Auto-activate Adobe Fonts** on ([Adobe: missing fonts](https://helpx.adobe.com/illustrator/using/

Status
open
Score
0.00
Adopters
0 other operators
Author
addibble (imported from pull request #4091, open)
Base
8622d0c71c18a166d666314058be7f0e3e9b290f
Size
+945 −26 in 44 files
Review
pending
Shared
2026-10-28
Id
4e06ca6c06c26b2de131f267

How it went

No other operator has reported using it yet.

Diff

raw · apps/vectorcraft-web/src/fonts.rs apps/vectorcraft-web/src/web.rs crates/engine/src/cmd/fonts.rs crates/text/src/fontdb.rs crates/text/src/fontdb/db.rs crates/text/src/fontdb/lookup.rs crates/text/src/lib.rs crates/ui-egui/src/app/commands.rs crates/ui-egui/src/canvas/tests.rs crates/ui-egui/src/dialogs/missing_fonts.rs crates/ui-egui/src/dialogs/missing_fonts/tests.rs crates/ui-egui/src/font_menu.rs crates/ui-egui/src/font_menu/tests.rs crates/ui-egui/src/i18n/bg.tsv crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/da.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/el.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fi.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/id.tsv crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/ka.tsv crates/ui-egui/src/i18n/ko.tsv crates/ui-egui/src/i18n/nl.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-br.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/sv.tsv crates/ui-egui/src/i18n/tr.tsv crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/i18n/vi.tsv crates/ui-egui/src/i18n/zh-hans.tsv crates/ui-egui/src/i18n/zh-hant.tsv crates/ui-egui/src/lib.rs crates/ui-egui/src/panels/character/tests.rs crates/ui-egui/src/panels/properties/tests.rs crates/ui-egui/src/tests/font_menu.rs crates/ui-egui/src/tests/fonts.rs docs/control-protocol.md docs/mcp.md

diff --git a/apps/vectorcraft-web/src/fonts.rs b/apps/vectorcraft-web/src/fonts.rs--- a/apps/vectorcraft-web/src/fonts.rs+++ b/apps/vectorcraft-web/src/fonts.rs@@ -3,11 +3,17 @@ //! ones before the app starts, [`spawn_background_fonts`] the rest once it runs. The browser checks //! each file against its SHA-256 (Subresource Integrity). A face that fails is logged and skipped: //! the app runs without it. With `WEB_FONTS` empty (built without craft-fonts) both do nothing.+//!+//! Font sources (`text.addFontSources`): the faces a page can supply. [`fetch_requested`] fetches+//! each one type has asked for (`FontDb::take_source_requests`), checked against its SHA-256, and+//! keeps the bytes until the app's next frame hands them to `text.addFont` ([`take_fetched`]). At+//! most [`BACKGROUND_PARALLEL`] are fetched at once, and a file larger than `text.addFont` takes+//! ([`SOURCE_MAX_BYTES`]) is refused before its bytes are copied into the app's memory.  use std::cell::Cell; use std::rc::Rc; -use vectorcraft_text::{FontDb, WEB_FONTS, WebFont};+use vectorcraft_text::{FontDb, FontSource, WEB_FONTS, WebFont}; use wasm_bindgen::JsCast as _; use wasm_bindgen::closure::Closure; use wasm_bindgen_futures::JsFuture;@@ -18,10 +24,54 @@ const STARTUP_TIMEOUT_MS: i32 = 20_000; const BACKGROUND_TIMEOUT_MS: i32 = 120_000; /// Background fetches at once, leaving the connection to the app's own requests. const BACKGROUND_PARALLEL: usize = 4;+/// The largest font source fetched: the most `text.addFont` takes.+const SOURCE_MAX_BYTES: usize = 64 << 20;  thread_local! {     /// The background fetch runs once per page, though a graphics restart creates the app again.     static BACKGROUND_STARTED: Cell<bool> = const { Cell::new(false) };+    /// Font sources fetched and not yet added, with the file's bytes.+    static FETCHED: std::cell::RefCell<Vec<(FontSource, Vec<u8>)>> = const { std::cell::RefCell::new(Vec::new()) };+    /// Font sources that failed to fetch, and why, for the status line.+    static FAILED: std::cell::RefCell<Vec<(FontSource, String)>> = const { std::cell::RefCell::new(Vec::new()) };+    /// Font sources asked for and not fetched yet, waiting for a free fetch.+    static WAITING: std::cell::RefCell<std::collections::VecDeque<FontSource>> = const { std::cell::RefCell::new(std::collections::VecDeque::new()) };+    /// Font sources being fetched now (at most [`BACKGROUND_PARALLEL`]).+    static IN_FLIGHT: Cell<usize> = const { Cell::new(0) };+}++/// Fetch, in the background, the font sources type has asked for: at most+/// [`BACKGROUND_PARALLEL`] at once, the rest when one finishes (each asks `ctx` for a frame, whose+/// call here starts the next). A fetched file waits for [`take_fetched`]. A source that fails is+/// logged; its type stays in the fallback face.+pub fn fetch_requested(ctx: &egui::Context) {+    WAITING.with(|w| w.borrow_mut().extend(FontDb::global().take_source_requests()));+    while IN_FLIGHT.get() < BACKGROUND_PARALLEL {+        let Some(src) = WAITING.with(|w| w.borrow_mut().pop_front()) else { break };+        IN_FLIGHT.set(IN_FLIGHT.get() + 1);+        let ctx = ctx.clone();+        wasm_bindgen_futures::spawn_local(async move {+            match fetch(&src.url, &src.integrity, BACKGROUND_TIMEOUT_MS, Some(SOURCE_MAX_BYTES)).await {+                Ok(bytes) => FETCHED.with(|f| f.borrow_mut().push((src, bytes))),+                Err(e) => {+                    log::warn!("font source {} {}: {e}", src.family, src.style);+                    FAILED.with(|f| f.borrow_mut().push((src, e)));+                }+            }+            IN_FLIGHT.set(IN_FLIGHT.get().saturating_sub(1));+            ctx.request_repaint();+        });+    }+}++/// The font sources fetched since the last call, with their files' bytes, for `text.addFont`.+pub fn take_fetched() -> Vec<(FontSource, Vec<u8>)> {+    FETCHED.with(|f| std::mem::take(&mut *f.borrow_mut()))+}++/// The font sources that failed to fetch since the last call, and why.+pub fn take_failed() -> Vec<(FontSource, String)> {+    FAILED.with(|f| std::mem::take(&mut *f.borrow_mut())) }  /// Fetch and register the `startup` faces (one after the other). Returns how many loaded.@@ -61,7 +111,7 @@ pub fn spawn_background_fonts(ctx: egui::Context) {  /// Fetch one face and add it to the font database. False (and a console warning) on failure. async fn load(f: &WebFont, timeout_ms: i32) -> bool {-    let bytes = match fetch(f.url, f.integrity, timeout_ms).await {+    let bytes = match fetch(f.url, f.integrity, timeout_ms, None).await {         Ok(bytes) => bytes,         Err(e) => {             log::warn!("web font {} {}: {e}", f.family, f.style);@@ -77,7 +127,11 @@ async fn load(f: &WebFont, timeout_ms: i32) -> bool {  /// GET `url` (relative to the page) with Subresource Integrity `integrity`, aborted after /// `timeout_ms`.-async fn fetch(url: &str, integrity: &str, timeout_ms: i32) -> Result<Vec<u8>, String> {+/// The file at `url`, checked against `integrity` (Subresource Integrity), given up after+/// `timeout_ms`. With `max_bytes`, a larger file is refused before its bytes are copied into the+/// app's memory. The browser has read and checked the whole body by the time the response+/// arrives (it must, to check its integrity), so the download itself is not cut short.+async fn fetch(url: &str, integrity: &str, timeout_ms: i32, max_bytes: Option<usize>) -> Result<Vec<u8>, String> {     let window = web_sys::window().ok_or("no window")?;     let abort = web_sys::AbortController::new().map_err(js)?;     let init = web_sys::RequestInit::new();@@ -95,7 +149,11 @@ async fn fetch(url: &str, integrity: &str, timeout_ms: i32) -> Result<Vec<u8>, S             return Err(format!("HTTP {} for {url}", response.status()));         }         let buffer = JsFuture::from(response.array_buffer().map_err(js)?).await.map_err(js)?;-        Ok(js_sys::Uint8Array::new(&buffer).to_vec())+        let bytes = js_sys::Uint8Array::new(&buffer);+        if max_bytes.is_some_and(|max| bytes.length() as usize > max) {+            return Err(format!("{url} is larger than {} MB", max_bytes.unwrap_or_default() >> 20));+        }+        Ok(bytes.to_vec())     }     .await;     window.clear_timeout_with_handle(timer);diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs--- a/apps/vectorcraft-web/src/web.rs+++ b/apps/vectorcraft-web/src/web.rs@@ -369,6 +369,29 @@ impl eframe::App for WebShell {             self.restart(&why);         }         let Some(app) = &mut self.app else { return };+        // Font sources: those type asked for are fetched; those fetched join the session, as+        // `text.addFont` adds a file, and the type set in them redraws.+        // The host is told how each one ended, so it is no longer pending.+        crate::fonts::fetch_requested(ctx);+        let db = vectorcraft_text::FontDb::global();+        for (src, bytes) in crate::fonts::take_fetched() {+            let name = format!("{} {}", src.family, src.style);+            let params = serde_json::json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes), "name": name });+            let added = app.run("text.addFont", params);+            let loaded = db.source_settled(&src, added.is_ok());+            if let Err(e) = added {+                log::warn!("font source {name}: {e}");+                app.font_source_failed(&src.family, &e);+            } else if !loaded {+                // The file is a font, but not of the family the host said it is.+                log::warn!("font source {name}: the file has no face of {}", src.family);+                app.font_source_failed(&src.family, &format!("the file has no face of {}", src.family));+            }+        }+        for (src, reason) in crate::fonts::take_failed() {+            db.source_settled(&src, false);+            app.font_source_failed(&src.family, &reason);+        }         app.tick(ctx);         crate::api::poll_replies();         app.finish_ime_logic(ctx);@@ -421,6 +444,11 @@ impl eframe::App for WebShell {             }             app.ui(ui);         }+        // Type laid out in this frame may have asked for font sources: the next frame's logic+        // fetches them, so it is asked for now rather than at the next input.+        if vectorcraft_text::FontDb::global().has_source_requests() {+            ui.ctx().request_repaint();+        }         crate::api::poll_replies();     } }diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs--- a/crates/engine/src/cmd/fonts.rs+++ b/crates/engine/src/cmd/fonts.rs@@ -51,6 +51,24 @@ pub fn specs() -> Vec<CommandSpec> {             always,             font_list         ),+        cmd!(+            query "text.addFont",+            "Add Font",+            [],+            None,+            "{dataBase64: a font file (TrueType, OpenType or a collection, at most 64 MB), name?: what to call it in an error (default \"the font\")} give this session the file's faces, as installing it would (a page that embeds VectorCraft on the web, where no fonts are installed, supplies its fonts this way); text in a face that became available redraws in it. A face already loaded (the same family, style and version) is not added again. Not an edit: nothing is saved, journaled or undone → {faces: [{family, style}] (the faces added), found (the faces the file holds)}",+            always,+            add_font+        ),+        cmd!(+            query "text.addFontSources",+            "Add Font Sources",+            [],+            None,+            "{fonts: [{family, style?: \"Regular\", url: where the host gets the file (for the web build, a URL relative to the page), sha256: the file's SHA-256 in hex}] (1 to 10000)} fonts the host can supply, as a font provider's are activated on demand: listed in the font menus and text.fontList from now on, and fetched when type names their family (the web build fetches it, checks it against its SHA-256 and adds it as text.addFont does; until then the type draws in the fallback face). A family and style already a source is kept as it was, unless it failed to load: given again, it is asked for again. Not an edit: nothing is saved, journaled or undone → {added, sources (how many there are now)}",+            always,+            add_font_sources+        ),         cmd!(             "text.rescanFonts",             "Refresh Font List",@@ -404,6 +422,8 @@ fn font_param(p: &Value, k: &str, c: &str) -> Result<(String, Option<String>)> {     Ok((family.to_string(), v.get("style").and_then(Value::as_str).map(str::to_string))) } +/// `text.replaceFont`: the type in one family and style (in the selection, or the whole document)+/// set in another, which is available, installed or a font source. fn replace(s: &mut Session, p: &Value) -> Result<Value> {     const C: &str = "text.replaceFont";     let (ff, fs) = font_param(p, "from", C)?;@@ -412,6 +432,8 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {     // Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans).     let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) {         Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(),+        // A font source's family: the type takes it now and draws in it once the host adds it.+        _ if db.is_source(&tf) => tf.clone(),         _ => return Err(bad(C, format!("font family `{tf}` is not available"))),     };     let ids = scope(s, bool_or(p, "selectionOnly", false))?;@@ -469,6 +491,8 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {     Ok(json!({ "count": n })) } +/// `text.fontList`: every family the font menus list, or one family's styles and the axes of one+/// style (none for a font source not loaded yet). fn font_list(_: &mut Session, p: &Value) -> Result<Value> {     let db = vectorcraft_text::FontDb::global();     match str_param(p, "family") {@@ -480,8 +504,10 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {             let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());             let styles = db.styles(family);             let style = str_param(p, "style").map(str::to_string).or_else(|| styles.first().cloned()).unwrap_or_else(|| "Regular".into());-            let axes: Vec<Value> = db-                .face(family, &style)+            // A font source not loaded yet has no axes to tell, and listing it asks for nothing+            // (only type set in it does).+            let face = if db.is_pending_source(family, &style) { None } else { db.face(family, &style) };+            let axes: Vec<Value> = face                 .map(|face| {                     let axes = face.axes();                     axes.iter()@@ -500,14 +526,91 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {     } } +/// `text.rescanFonts`: the font folders scanned again, and the type laid out in the fonts available now. pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {     let db = vectorcraft_text::FontDb::global();     #[cfg(not(target_arch = "wasm32"))]     let faces = db.load_system_fonts();     #[cfg(target_arch = "wasm32")]     let faces = 0;-    // The canvas redraws type in the fonts available now (renderers lay it out again when the-    // fonts change), and the bounds follow; that isn't an edit to save.+    relayout_text(s);+    Ok(json!({ "families": db.menu_family_list().len(), "faces": faces }))+}++/// The most bytes a font file given to `text.addFont` may have (large CJK collections are tens of+/// megabytes).+const MAX_ADDED_FONT: usize = 64 << 20;++/// `text.addFont`: a font file's faces, given as base64, added to the session's font database; the+/// type laid out again when any was added.+fn add_font(s: &mut Session, p: &Value) -> Result<Value> {+    const C: &str = "text.addFont";+    let name = str_param(p, "name").unwrap_or("the font");+    let data = str_param(p, "dataBase64").ok_or_else(|| bad(C, "give the font file as dataBase64"))?;+    // Base64 is four characters for three bytes: refuse an oversized file before decoding it.+    if data.len() / 4 * 3 > MAX_ADDED_FONT {+        return Err(bad(C, format!("{name} is larger than {} MB", MAX_ADDED_FONT >> 20)));+    }+    let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(C, format!("{name}: dataBase64 is not base64")))?;+    let report = vectorcraft_text::FontDb::global().add_font_reporting(bytes);+    if report.found == 0 {+        return Err(bad(C, format!("{name} is not a TrueType, OpenType or collection font")));+    }+    if !report.added.is_empty() {+        relayout_text(s);+    }+    let faces: Vec<Value> = report.added.iter().map(|(family, style)| json!({ "family": family, "style": style })).collect();+    Ok(json!({ "faces": faces, "found": report.found }))+}++/// The most font sources one `text.addFontSources` call may give.+const MAX_FONT_SOURCES: usize = 10_000;++/// `text.addFontSources`: the fonts a host can supply, checked (each a family, a style, a URL and a+/// SHA-256) and registered with the font database as [`vectorcraft_text::FontSource`]s.+fn add_font_sources(_: &mut Session, p: &Value) -> Result<Value> {+    const C: &str = "text.addFontSources";+    let fonts =+        p.get("fonts").and_then(Value::as_array).ok_or_else(|| bad(C, "give fonts, a list of fonts each with a family, a url and a sha256"))?;+    if fonts.is_empty() || fonts.len() > MAX_FONT_SOURCES {+        return Err(bad(C, format!("give 1 to {MAX_FONT_SOURCES} fonts")));+    }+    let mut list = Vec::with_capacity(fonts.len());+    for (i, f) in fonts.iter().enumerate() {+        let text = |key: &str, max: usize| -> Result<String> {+            let v = f.get(key).and_then(Value::as_str).map(str::trim).unwrap_or("");+            if v.is_empty() || v.len() > max {+                return Err(bad(C, format!("fonts[{i}].{key} is 1 to {max} characters")));+            }+            Ok(v.to_string())+        };+        let family = text("family", 256)?;+        let style = match f.get("style") {+            None | Some(Value::Null) => "Regular".to_string(),+            Some(_) => text("style", 256)?,+        };+        let url = text("url", 2048)?;+        let sha256 = text("sha256", 64)?;+        let digest = hex_bytes(&sha256).filter(|d| d.len() == 32).ok_or_else(|| bad(C, format!("fonts[{i}].sha256 is 64 hexadecimal digits")))?;+        let integrity = format!("sha256-{}", vectorcraft_format::base64_encode(&digest));+        list.push(vectorcraft_text::FontSource { family, style, url, integrity });+    }+    let db = vectorcraft_text::FontDb::global();+    let added = db.add_sources(list);+    Ok(json!({ "added": added, "sources": db.sources().len() }))+}++/// `hex`'s bytes, two digits a byte; `None` when it is no even run of hexadecimal digits.+fn hex_bytes(hex: &str) -> Option<Vec<u8>> {+    if !hex.len().is_multiple_of(2) {+        return None;+    }+    (0..hex.len()).step_by(2).map(|i| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok())).collect()+}++/// The canvas redraws type in the fonts available now (renderers lay it out again when the fonts+/// change), and the bounds follow; that isn't an edit to save.+fn relayout_text(s: &mut Session) {     for d in &mut s.docs {         let mut texts = vec![];         d.doc.walk(|n| {@@ -527,13 +630,185 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {         }         d.revision += 1;     }-    Ok(json!({ "families": db.menu_family_list().len(), "faces": faces })) }  #[cfg(test)] mod tests {     use super::*; +    /// Font sources: listed before they are loaded, requested once when type names their family,+    /// drawn once the host adds the file; bad input is refused.+    #[test]+    fn a_font_source_is_listed_requested_once_and_drawn_once_added() {+        let _fonts = crate::adding_fonts();+        const FAMILY: &str = "Sourced Fnt 1";+        let db = vectorcraft_text::FontDb::global();+        let mut s = Session::new();+        s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();+        let sha = "ab".repeat(32);+        let added = s.execute("text.addFontSources", &json!({"fonts": [{"family": FAMILY, "url": "fonts/sourced.ttf", "sha256": sha}]})).unwrap();+        assert_eq!(added["added"], 1, "{added}");+        let again = s+            .execute("text.addFontSources", &json!({"fonts": [{"family": FAMILY, "style": "Regular", "url": "elsewhere.ttf", "sha256": sha}]}))+            .unwrap();+        assert_eq!(again["added"], 0, "a family and style already a source is kept");+        let list = s.execute("text.fontList", &json!({})).unwrap();+        assert!(list["families"].as_array().unwrap().iter().any(|f| f == FAMILY), "listed before it is loaded");+        assert_eq!(s.execute("text.fontList", &json!({"family": FAMILY})).unwrap()["styles"], json!(["Regular"]));+        assert!(db.take_source_requests().iter().all(|r| r.family != FAMILY), "listing a source asks for nothing");++        let source = vectorcraft_text::FontSource {+            family: FAMILY.into(),+            style: "Regular".into(),+            url: "fonts/sourced.ttf".into(),+            integrity: format!("sha256-{}", vectorcraft_format::base64_encode(&[0xab; 32])),+        };+        s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Sourced", "font": FAMILY})).unwrap();+        s.execute("text.fonts", &json!({})).unwrap();+        let asked = db.take_source_requests();+        assert_eq!(asked.iter().filter(|r| r.family == FAMILY).collect::<Vec<_>>(), [&source], "requested once type names it");+        s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Again", "font": FAMILY})).unwrap();+        s.execute("text.fonts", &json!({})).unwrap();+        assert!(db.take_source_requests().iter().all(|r| r.family != FAMILY), "and only once");++        let status = |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap()[0]["status"].clone();+        assert_eq!(status(&mut s), "missing");+        let data = vectorcraft_format::base64_encode(&vectorcraft_testkit::fonts::renamed(FAMILY));+        s.execute("text.addFont", &json!({"dataBase64": data})).unwrap();+        assert_eq!(status(&mut s), "exact", "drawn in it once the host adds the file");++        for bad in [+            json!({}),+            json!({"fonts": []}),+            json!({"fonts": [{"family": "X", "url": "u", "sha256": "zz"}]}),+            json!({"fonts": [{"url": "u", "sha256": sha}]}),+        ] {+            assert!(s.execute("text.addFontSources", &bad).is_err(), "{bad}");+        }+    }++    /// Each style of a source counts apart: with its Regular added, a family's Bold source is still+    /// to come, asked for when type takes Bold and not for Regular.+    #[test]+    fn a_source_s_styles_are_pending_apart() {+        let _fonts = crate::adding_fonts();+        const FAMILY: &str = "Two Styles 01";+        let db = vectorcraft_text::FontDb::global();+        let source = |style: &str| vectorcraft_text::FontSource {+            family: FAMILY.into(),+            style: style.into(),+            url: format!("fonts/{style}.ttf"),+            integrity: format!("sha256-{}", vectorcraft_format::base64_encode(&[0x5a; 32])),+        };+        db.add_sources(vec![source("Regular"), source("Bold")]);+        assert!(db.is_pending_source(FAMILY, "Regular") && db.is_pending_source(FAMILY, "Bold"));+        assert!(db.add_font(vectorcraft_testkit::fonts::renamed(FAMILY)) > 0);+        assert!(!db.is_pending_source(FAMILY, "Regular"), "Regular is loaded");+        assert!(db.is_pending_source(FAMILY, "Bold"), "Bold is still to come");+        db.face(FAMILY, "Regular");+        assert!(db.take_source_requests().iter().all(|r| r.family != FAMILY), "the loaded style asks for nothing");+        db.face(FAMILY, "Bold");+        assert_eq!(db.take_source_requests().into_iter().filter(|r| r.family == FAMILY).collect::<Vec<_>>(), [source("Bold")]);+    }++    /// A style loaded some other way is not pending though the family's only source has another+    /// style: Regular added by hand, Bold the only source, so Regular asks for nothing and keeps+    /// its sample, and Bold is still to come.+    #[test]+    fn a_loaded_style_is_not_pending_for_another_style_s_source() {+        let _fonts = crate::adding_fonts();+        const FAMILY: &str = "Bold Source 1";+        let db = vectorcraft_text::FontDb::global();+        let integrity = format!("sha256-{}", vectorcraft_format::base64_encode(&[0x3c; 32]));+        db.add_sources(vec![vectorcraft_text::FontSource { family: FAMILY.into(), style: "Bold".into(), url: "fonts/bold.ttf".into(), integrity }]);+        assert!(db.add_font(vectorcraft_testkit::fonts::renamed(FAMILY)) > 0);+        assert!(!db.is_pending_source(FAMILY, "Regular"), "Regular is loaded");+        assert!(db.is_pending_source(FAMILY, "Bold"));+        db.face(FAMILY, "Regular");+        assert!(db.take_source_requests().iter().all(|r| r.family != FAMILY), "the loaded style asks for nothing");+    }++    /// A source the host is done with is no longer pending: one that failed is drawn in the+    /// fallback face and counted missing until the host gives it again, which asks for it again;+    /// one whose file was added is done though the file names its style otherwise.+    #[test]+    fn a_settled_source_is_no_longer_pending() {+        let _fonts = crate::adding_fonts();+        let db = vectorcraft_text::FontDb::global();+        let source = |family: &str, style: &str| vectorcraft_text::FontSource {+            family: family.into(),+            style: style.into(),+            url: format!("fonts/{style}.ttf"),+            integrity: format!("sha256-{}", vectorcraft_format::base64_encode(&[0x42; 32])),+        };+        let failed = source("Failed Src 01", "Regular");+        db.add_sources(vec![failed.clone()]);+        db.face(&failed.family, "Regular");+        assert!(db.take_source_requests().contains(&failed));+        db.source_settled(&failed, false);+        assert!(!db.is_pending_source(&failed.family, "Regular"), "a failed source is not on its way");+        db.face(&failed.family, "Regular");+        assert!(!db.take_source_requests().contains(&failed), "nor asked for again by itself");+        assert_eq!(db.add_sources(vec![failed.clone()]), 1, "given again, it is taken");+        assert!(db.is_pending_source(&failed.family, "Regular"));+        db.face(&failed.family, "Regular");+        assert!(db.take_source_requests().contains(&failed), "and asked for again");++        // The host's "Book" turns out to be a Regular face: loaded all the same.+        let book = source("Book Style 01", "Book");+        db.add_sources(vec![book.clone()]);+        assert!(db.add_font(vectorcraft_testkit::fonts::renamed(&book.family)) > 0);+        assert!(db.is_pending_source(&book.family, "Book"));+        assert!(db.source_settled(&book, true));+        assert!(!db.is_pending_source(&book.family, "Book"));++        // A file of another family is a failure: no longer pending, asked for again when given again.+        let other = source("Wrong Family1", "Regular");+        db.add_sources(vec![other.clone()]);+        assert!(db.add_font(vectorcraft_testkit::fonts::renamed("Other Family1")) > 0);+        assert!(!db.source_settled(&other, true), "the file holds another family");+        assert!(!db.is_pending_source(&other.family, "Regular"));+        assert_eq!(db.add_sources(vec![other.clone()]), 1, "a failure, so given again it is taken");+    }++    /// A source's italic styles are listed after its upright ones, as loaded faces' are.+    #[test]+    fn a_source_s_italics_follow_its_uprights() {+        let _fonts = crate::adding_fonts();+        const FAMILY: &str = "Italic Order 01";+        let mut s = Session::new();+        let fonts: Vec<Value> = ["Bold Italic", "Italic", "Bold", "Regular"]+            .iter()+            .map(|style| json!({"family": FAMILY, "style": style, "url": format!("fonts/{style}.ttf"), "sha256": "cd".repeat(32)}))+            .collect();+        s.execute("text.addFontSources", &json!({ "fonts": fonts })).unwrap();+        let styles = s.execute("text.fontList", &json!({"family": FAMILY})).unwrap()["styles"].clone();+        assert_eq!(styles, json!(["Regular", "Bold", "Italic", "Bold Italic"]));+    }++    /// Text › Add Font: a font file's faces join the session, text set in its family is no longer+    /// missing, a face already loaded is not added again, and bytes that are no font are refused.+    #[test]+    fn a_font_given_as_bytes_joins_the_session_once() {+        let _fonts = crate::adding_fonts();+        let mut s = Session::new();+        s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();+        s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Added", "font": "Added Font 01"})).unwrap();+        let status = |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap()[0]["status"].clone();+        assert_eq!(status(&mut s), "missing");+        let data = vectorcraft_format::base64_encode(&vectorcraft_testkit::fonts::renamed("Added Font 01"));+        let added = s.execute("text.addFont", &json!({"dataBase64": data})).unwrap();+        assert_eq!(added["found"], 1, "{added}");+        assert_eq!(added["faces"], json!([{"family": "Added Font 01", "style": "Regular"}]));+        assert_eq!(status(&mut s), "exact");+        let again = s.execute("text.addFont", &json!({"dataBase64": data})).unwrap();+        assert_eq!(again, json!({"faces": [], "found": 1}));+        let junk = s.execute("text.addFont", &json!({"dataBase64": vectorcraft_format::base64_encode(b"not a font"), "name": "junk.ttf"}));+        assert!(junk.unwrap_err().to_string().contains("junk.ttf is not a TrueType"));+        assert!(s.execute("text.addFont", &json!({"dataBase64": "%%%"})).unwrap_err().to_string().contains("not base64"));+        assert!(s.execute("text.addFont", &json!({})).unwrap_err().to_string().contains("dataBase64"));+    }+     /// Type › Options › Additional Fonts Folder (#683) adds its folder to the system font scan,     /// trimmed; clearing it takes the folder out again.     #[test]diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs--- a/crates/text/src/fontdb.rs+++ b/crates/text/src/fontdb.rs@@ -95,6 +95,10 @@ pub struct FontDb {     family_cache: Mutex<Option<Arc<[String]>>>,     menu_cache: Mutex<Option<MenuFamilies>>,     generation: AtomicU64,+    /// Fonts a host can supply on request ([`FontDb::add_sources`]), and those requested and not+    /// yet taken ([`FontDb::take_source_requests`]).+    sources: RwLock<Vec<FontSource>>,+    source_requests: Mutex<SourceRequests>,     /// System fallback state: characters no system font covers.     #[cfg(not(target_arch = "wasm32"))]     sys: Mutex<SysFallback>,@@ -150,6 +154,45 @@ const CANONICAL_CACHE_MAX: usize = 4_096; /// [`FontDb::menu_family_list`], with the [`FontDb::family_list`] it was built from. type MenuFamilies = (Arc<[String]>, Arc<[String]>); +/// A font a host can supply when type needs it, as a font provider's fonts are activated on+/// demand: listed in the font menus before it is loaded, and fetched by the host from `url` when+/// type names it (the web build does so, checking the bytes against `integrity`); until then the+/// type draws in the fallback face.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct FontSource {+    pub family: String,+    pub style: String,+    /// Where the host gets the file: for the web build, a URL relative to the page.+    pub url: String,+    /// The file's Subresource Integrity value, `sha256-<base64>`.+    pub integrity: String,+}++/// The sources requested ([`FontDb::face`] met their family missing): each requested once.+#[derive(Debug, Default)]+struct SourceRequests {+    asked: std::collections::HashSet<(String, String)>,+    queue: Vec<FontSource>,+    /// How each source the host took on ended ([`FontDb::source_settled`]): `true` when its file was+    /// added, `false` when it couldn't be fetched or added. Keyed as `asked` is.+    settled: HashMap<(String, String), bool>,+}++/// A source's key in [`SourceRequests`]: its family and style, in lowercase.+fn source_key(src: &FontSource) -> (String, String) {+    (src.family.to_ascii_lowercase(), src.style.to_ascii_lowercase())+}++/// What [`FontDb::add_font_reporting`] did with a font file's bytes.+#[derive(Clone, Debug, Default, PartialEq, Eq)]+pub struct FontsAdded {+    /// The faces the file holds: 0 when it is no TrueType, OpenType or collection font.+    pub found: usize,+    /// The faces it added, as (family, style); a face already loaded (the same family, style and+    /// version) is not added again.+    pub added: Vec<(String, String)>,+}+ #[cfg(test)] mod alias_tests {     use super::*;diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs--- a/crates/text/src/fontdb/db.rs+++ b/crates/text/src/fontdb/db.rs@@ -1,5 +1,4 @@ use super::build::make_face;-#[cfg(not(target_arch = "wasm32"))] use super::build::{style_italic, style_weight}; #[cfg(not(target_arch = "wasm32"))] use super::cache::CatalogFace;@@ -9,7 +8,7 @@ use super::dirs::confined_font_dirs; use super::face::{BUNDLED, FontBytes, FontFace, Instances}; #[cfg(not(target_arch = "wasm32"))] use super::names::SysFallback;-use super::names::name;+use super::names::{name, norm}; use super::scan::enumerate_faces; #[cfg(not(target_arch = "wasm32"))] use super::scan::{file_face_names, is_font_file};@@ -61,6 +60,8 @@ impl FontDb {             version_scanned: RwLock::new(std::collections::HashSet::new()),             family_cache: Mutex::new(None),             menu_cache: Mutex::new(None),+            sources: RwLock::new(Vec::new()),+            source_requests: Mutex::new(SourceRequests::default()),             generation: AtomicU64::new(0),             #[cfg(not(target_arch = "wasm32"))]             sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),@@ -126,6 +127,7 @@ impl FontDb {         }         let mut v: Vec<String> = self.read_faces().iter().map(|f| f.family.clone()).collect();         v.extend(catalog.values().map(|c| c.name.clone()));+        v.extend(self.sources.read().unwrap_or_else(|e| e.into_inner()).iter().map(|s| s.family.clone()));         v.sort_by_key(|a| a.to_lowercase());         v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));         let list: Arc<[String]> = v.into();@@ -185,36 +187,145 @@ impl FontDb {                 }             }         }+        for src in self.sources.read().unwrap_or_else(|e| e.into_inner()).iter().filter(|s| s.family.eq_ignore_ascii_case(family)) {+            if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&src.style)) {+                v.push((style_italic(&src.style), style_weight(&src.style), src.style.clone()));+            }+        }         v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2)));         v.dedup_by(|a, b| a.2 == b.2);         v.into_iter().map(|t| t.2).collect()     } +    /// Fonts a host can supply ([`FontSource`]): listed in the font menus from now on and+    /// requested when type names their family. A family and style already a source is kept as it+    /// was, unless it failed to load ([`FontDb::source_settled`]): given again, it replaces the+    /// failed one and is asked for again. Returns how many were added.+    pub fn add_sources(&self, list: Vec<FontSource>) -> usize {+        let mut sources = self.sources.write().unwrap_or_else(|e| e.into_inner());+        let mut requests = self.source_requests.lock().unwrap_or_else(|e| e.into_inner());+        let mut added = 0;+        for src in list {+            let key = source_key(&src);+            let same = |s: &FontSource| s.family.eq_ignore_ascii_case(&src.family) && s.style.eq_ignore_ascii_case(&src.style);+            if let Some(at) = sources.iter().position(same) {+                if requests.settled.get(&key) != Some(&false) {+                    continue;+                }+                sources.remove(at);+                requests.settled.remove(&key);+                requests.asked.remove(&key);+            }+            sources.push(src);+            added += 1;+        }+        drop(requests);+        drop(sources);+        if added > 0 {+            self.changed();+        }+        added+    }++    /// The fonts a host can supply ([`FontDb::add_sources`]).+    pub fn sources(&self) -> Vec<FontSource> {+        self.sources.read().unwrap_or_else(|e| e.into_inner()).clone()+    }++    /// The sources type has asked for since the last call, for the host to fetch and add+    /// ([`FontDb::add_font`]); each is asked for once.+    pub fn take_source_requests(&self) -> Vec<FontSource> {+        std::mem::take(&mut self.source_requests.lock().unwrap_or_else(|e| e.into_inner()).queue)+    }++    /// Whether type has asked for sources not taken yet ([`FontDb::take_source_requests`]): a host+    /// that takes them once a frame asks for another frame.+    pub fn has_source_requests(&self) -> bool {+        !self.source_requests.lock().unwrap_or_else(|e| e.into_inner()).queue.is_empty()+    }++    /// The host is done with `src`: its file was added (`added`), or it couldn't be fetched or+    /// added. Either way it is no longer pending. An added file counts only when a face of the+    /// source's family is now loaded (by any of the family's names; its style may be named+    /// otherwise, "Book" for a "Regular" source): a file of another family leaves the type in the+    /// fallback face, so it counts as a failure. A failed source is asked for again only once the+    /// host gives it again ([`FontDb::add_sources`]). Returns whether the source loaded.+    pub fn source_settled(&self, src: &FontSource, added: bool) -> bool {+        let loaded = added && self.is_loaded(&self.canonical(&src.family, "").0);+        self.source_requests.lock().unwrap_or_else(|e| e.into_inner()).settled.insert(source_key(src), loaded);+        loaded+    }++    /// The source that type set in `family` and `style` asks for: the family's source of that+    /// style, else its first; `None` when that style is loaded (however it came, by any of its+    /// names, as [`FontDb::find`] takes them), when no source supplies the family, or when the+    /// source's own face is loaded or the host is done with it ([`FontDb::source_settled`]).+    pub(super) fn pending_source(&self, family: &str, style: &str) -> Option<FontSource> {+        let loaded = |style: &str| {+            let ns = norm(style);+            self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family) && (norm(&f.style) == ns || f.keys.styles.contains(&ns)))+        };+        if loaded(style) {+            return None;+        }+        let src = {+            let sources = self.sources.read().unwrap_or_else(|e| e.into_inner());+            let of_family = || sources.iter().filter(|s| s.family.eq_ignore_ascii_case(family));+            of_family().find(|s| norm(&s.style) == norm(style)).or_else(|| of_family().next())?.clone()+        };+        let settled = self.source_requests.lock().unwrap_or_else(|e| e.into_inner()).settled.contains_key(&source_key(&src));+        (!settled && !loaded(&src.style)).then_some(src)+    }++    /// Ask for the source of `family` in `style` ([`FontDb::pending_source`]), once.+    pub(super) fn request_source(&self, family: &str, style: &str) {+        let Some(src) = self.pending_source(family, style) else { return };+        let key = source_key(&src);+        let mut requests = self.source_requests.lock().unwrap_or_else(|e| e.into_inner());+        if requests.asked.insert(key) {+            requests.queue.push(src);+        }+    }+     /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or     /// every face was already present).     pub fn add_font(&self, bytes: Vec<u8>) -> usize {         self.add_font_from(bytes, None)     } +    /// [`FontDb::add_font`], telling an unreadable file (no faces found) from one whose faces are+    /// all loaded already: what the file holds and which faces it added.+    pub fn add_font_reporting(&self, bytes: Vec<u8>) -> FontsAdded {+        self.add_font_report(bytes, None)+    }+     /// [`FontDb::add_font`] for the bytes of the file at `path` (when known).     pub(super) fn add_font_from(&self, bytes: Vec<u8>, path: Option<&Path>) -> usize {+        self.add_font_report(bytes, path).added.len()+    }++    /// [`FontDb::add_font_reporting`] for the bytes of the file at `path` (when known).+    pub(super) fn add_font_report(&self, bytes: Vec<u8>, path: Option<&Path>) -> FontsAdded {         self.add_fonts([(bytes, path.map(Path::to_path_buf))])     }      /// Add the faces of `files` (each file's bytes, and the file when known) all at once: a lookup     /// on another thread finds all of them or none, never a family with only some of its styles     /// loaded, whose nearest style it would take instead (Arial Bold Italic, the first of Arial's-    /// files, for upright Regular text). Returns the number of faces added.-    pub(super) fn add_fonts(&self, files: impl IntoIterator<Item = (Vec<u8>, Option<PathBuf>)>) -> usize {+    /// files, for upright Regular text). Returns what the files held and which faces they added.+    pub(super) fn add_fonts(&self, files: impl IntoIterator<Item = (Vec<u8>, Option<PathBuf>)>) -> FontsAdded {         // Another version of a family and style is kept: a document may name it (the first one         // loaded stays the one the family and style alone resolve to).         let same = |f: &FontFace, family: &str, style: &str, version: &str| {             f.family.eq_ignore_ascii_case(family) && f.style.eq_ignore_ascii_case(style) && f.version == version         };+        let mut report = FontsAdded { found: 0, added: vec![] };         let mut made: Vec<FontFace> = Vec::new();         for (bytes, path) in files {             let data = Arc::new(bytes);-            for (i, spec) in enumerate_faces(&data) {+            let faces = enumerate_faces(&data);+            report.found += faces.len();+            for (i, spec) in faces {                 let version = skrifa::FontRef::from_index(&data, i).ok().and_then(|f| name(&f, &[StringId::VERSION_STRING])).unwrap_or_default();                 let present = |f: &FontFace| same(f, &spec.family, &spec.style, &version);                 if self.read_faces().iter().any(|f| present(f)) || made.iter().any(present) {@@ -226,21 +337,20 @@ impl FontDb {             }         }         let mut faces = self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner);-        let before = faces.len();         for f in made {             // Another thread may have added the same face meanwhile.             if !faces.iter().any(|g| same(g, &f.family, &f.style, &f.version)) {+                report.added.push((f.family.clone(), f.style.clone()));                 faces.push(Arc::new(f));             }         }-        let added = faces.len() - before;         drop(faces);         #[cfg(test)]         faces_published();-        if added > 0 {+        if !report.added.is_empty() {             self.changed();         }-        added+        report     }      /// Catalog the installed fonts unless that has been done: the first caller scans the fontdiff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs--- a/crates/text/src/fontdb/lookup.rs+++ b/crates/text/src/fontdb/lookup.rs@@ -148,7 +148,7 @@ impl FontDb {         paths.sort();         paths.dedup();         let files = paths.into_iter().flat_map(|p| font_files(&p).into_iter().map(move |data| (data, Some(p.clone()))));-        self.add_fonts(files) > 0 || self.is_loaded(family)+        !self.add_fonts(files).added.is_empty() || self.is_loaded(family)     }      /// Resolve a family + style to a face, falling back to the closest style of the family, then to@@ -172,6 +172,10 @@ impl FontDb {                 return Some(f);             }         }+        // A host can supply it: asked for once, drawn in the fallback until the host adds it.+        if found.as_ref().is_none_or(|f| norm(&f.style) != norm(style)) {+            self.request_source(family, style);+        }         found             .or_else(|| self.find(FALLBACK_FAMILY, style))             .or_else(|| self.find(FALLBACK_FAMILY, "Regular"))@@ -319,7 +323,19 @@ impl FontDb {     /// Is `family` available (loaded, or installed on the system)?     pub fn has_family(&self, family: &str) -> bool {         let (family, _) = self.canonical(family, "");-        self.is_loaded(&family) || self.read_catalog().contains_key(&family.to_ascii_lowercase())+        self.is_loaded(&family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) || self.is_source(&family)+    }++    /// Whether `family` in `style` is a font source not loaded yet: what draws it would request it+    /// ([`FontDb::face`]), so a font menu's sample leaves it out and only type set in it asks. Each+    /// style counts apart: a family with Regular loaded may still have Bold to come.+    pub fn is_pending_source(&self, family: &str, style: &str) -> bool {+        self.pending_source(family, style).is_some()+    }++    /// Whether a host can supply `family` ([`FontDb::add_sources`]).+    pub fn is_source(&self, family: &str) -> bool {+        self.sources.read().unwrap_or_else(|e| e.into_inner()).iter().any(|s| s.family.eq_ignore_ascii_case(family))     }      pub(crate) fn is_loaded(&self, family: &str) -> bool {diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs--- a/crates/text/src/lib.rs+++ b/crates/text/src/lib.rs@@ -35,9 +35,9 @@ pub use bitmap::GlyphImage; pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont}; pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; pub use fontdb::{-    FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, WantedFont, WantedFonts, app_font_dir,-    is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined, style_weight,-    system_font_dirs, user_font_dirs,+    FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontSource, FontTraits, FontsAdded, IcfMargins, PlatformFontFiles, WantedFont,+    WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined,+    style_weight, system_font_dirs, user_font_dirs, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; use std::sync::Arc;diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs--- a/crates/ui-egui/src/app/commands.rs+++ b/crates/ui-egui/src/app/commands.rs@@ -175,4 +175,10 @@ impl VectorcraftApp {     pub fn status(&mut self, s: impl Into<String>) {         self.ui.status = s.into();     }++    /// A font source (`text.addFontSources`) the host couldn't fetch or add: its type stays in the+    /// fallback face, which the status line says.+    pub fn font_source_failed(&mut self, font: &str, reason: &str) {+        self.status(format!("{font} couldn't be loaded ({reason}): its text shows in another font"));+    } }diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs--- a/crates/ui-egui/src/canvas/tests.rs+++ b/crates/ui-egui/src/canvas/tests.rs@@ -3553,6 +3553,8 @@ fn show_hidden_characters_lays_out_only_type_that_changed() { /// that arrives, a frame draws what a new overlay draws, and the marks moved. #[test] fn show_hidden_characters_follows_edits_zooms_and_fonts() {+    // Adds to the shared font database: other tests' font lists hold still meanwhile.+    let _fonts = crate::adding_fonts();     let mut app = VectorcraftApp::new(Session::new(), Default::default());     app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();     // The type is set in Bold of a family that is not installed and whose name starts with adiff --git a/crates/ui-egui/src/dialogs/missing_fonts.rs b/crates/ui-egui/src/dialogs/missing_fonts.rs--- a/crates/ui-egui/src/dialogs/missing_fonts.rs+++ b/crates/ui-egui/src/dialogs/missing_fonts.rs@@ -43,16 +43,21 @@ pub(super) const SPEC: DialogSpec = DialogSpec {  /// After a document opened (`document.open`'s result `r`): its Missing Fonts dialog waits for the /// dialog slot. On the web the status line shows how many fonts are missing instead, unless it-/// shows the open's notes.+/// shows the open's notes; a font a font source supplies is not counted, since the web build is+/// fetching it (`text.addFontSources`): one that fails to load says so when it does+/// ([`VectorcraftApp::font_source_failed`]). pub fn after_open(app: &mut VectorcraftApp, r: &Value) {     after_open_for(app, r, cfg!(target_arch = "wasm32")); } +/// [`after_open`], on the web (`web`: the status line, leaving out fonts a source is fetching) or+/// on the desktop (the Missing Fonts dialog). fn after_open_for(app: &mut VectorcraftApp, r: &Value, web: bool) {     let Some(st) = app.session.active() else { return };     let uid = st.uid;     if web {-        let n = vectorcraft_engine::cmd::fontfiles::missing_fonts(&st.doc).len();+        let db = vectorcraft_text::FontDb::global();+        let n = vectorcraft_engine::cmd::fontfiles::missing_fonts(&st.doc).iter().filter(|f| !db.is_pending_source(&f.family, &f.style)).count();         if n > 0 && r["warnings"].as_array().is_none_or(Vec::is_empty) {             app.status(format!("{n} font(s) aren't available here: their text shows in another font"));         }diff --git a/crates/ui-egui/src/dialogs/missing_fonts/tests.rs b/crates/ui-egui/src/dialogs/missing_fonts/tests.rs--- a/crates/ui-egui/src/dialogs/missing_fonts/tests.rs+++ b/crates/ui-egui/src/dialogs/missing_fonts/tests.rs@@ -336,3 +336,27 @@ fn the_web_says_how_many_fonts_are_missing() {     after_open_for(&mut app, &json!({"warnings": ["fonts"]}), true);     assert_eq!(app.ui.status, "Opened with 1 note(s): fonts"); }++/// A font a font source supplies isn't counted missing when a document opens on the web: it is+/// on its way. One that fails to load says so then.+#[test]+fn the_web_leaves_out_a_font_a_source_is_fetching() {+    // Adds to the shared font database: other tests' font lists hold still meanwhile.+    let _fonts = crate::adding_fonts();+    const FAMILY: &str = "On Its Way 01";+    let integrity = format!("sha256-{}", vectorcraft_format::base64_encode(&[0xef; 32]));+    vectorcraft_text::FontDb::global().add_sources(vec![vectorcraft_text::FontSource {+        family: FAMILY.into(),+        style: "Regular".into(),+        url: "fonts/on-its-way.ttf".into(),+        integrity,+    }]);+    let mut app = VectorcraftApp::new(Session::new(), Default::default());+    app.run("file.new", json!({"width": 400, "height": 300})).unwrap();+    app.run("text.create", json!({"x": 10, "y": 40, "text": "Coming", "font": FAMILY})).unwrap();+    app.ui.status.clear();+    after_open_for(&mut app, &json!({}), true);+    assert_eq!(app.ui.status, "", "nothing missing while the source is fetched");+    app.font_source_failed(FAMILY, "HTTP 404");+    assert_eq!(app.ui.status, "On Its Way 01 couldn't be loaded (HTTP 404): its text shows in another font");+}diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs--- a/crates/ui-egui/src/font_menu.rs+++ b/crates/ui-egui/src/font_menu.rs@@ -589,6 +589,9 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio     let lit = st.highlight.and_then(|i| rows.get(i)).and_then(Row::pick);     match lit {         Some((f, None)) if f == current => st.previewed.take().map(|_| FontPick::EndPreview),+        // A font source not loaded yet isn't previewed: that would fetch every family the+        // highlight passes over. Choosing it fetches it.+        Some((f, s)) if is_pending(&f, s.as_deref()) => st.previewed.take().map(|_| FontPick::EndPreview),         Some(pick) if st.previewed.as_ref() != Some(&pick) => {             st.previewed = Some(pick.clone());             Some(FontPick::Preview(pick.0, pick.1))@@ -662,6 +665,10 @@ thread_local! { /// on native (the worker asks for a frame when it is done), a few per frame; `None` until it is /// ready. fn sample_texture(ctx: &egui::Context, family: &str, style: Option<&str>, sample: Option<&str>, height: f32) -> Option<egui::TextureHandle> {+    // Not cached while the font is on its way: its sample draws once it arrives.+    if is_pending(family, style) {+        return None;+    }     let px = (height * ctx.pixels_per_point()).round().clamp(8.0, 96.0) as u32;     let key: Key = (family.to_string(), style.unwrap_or("Regular").to_string(), sample.unwrap_or_default().to_string(), px);     let texture = |img: egui::ColorImage| ctx.load_texture(format!("font-sample-{family}-{px}"), img, egui::TextureOptions::LINEAR);@@ -718,6 +725,12 @@ fn sample_texture(ctx: &egui::Context, family: &str, style: Option<&str>, sample     }) } +/// Whether `family` in `style` (a family's row: Regular) is a font source not loaded yet, which the+/// menu neither samples nor previews ([`vectorcraft_text::FontDb::is_pending_source`]).+fn is_pending(family: &str, style: Option<&str>) -> bool {+    vectorcraft_text::FontDb::global().is_pending_source(family, style.unwrap_or("Regular"))+}+ /// Longest sample, in pixels per pixel of height (wider samples are cut). const MAX_ASPECT: f32 = 10.0; @@ -725,6 +738,11 @@ const MAX_ASPECT: f32 = 10.0; /// `px` pixels high: a coverage mask as white with alpha. fn render_sample(family: &str, style: &str, text: &str, px: u32) -> Option<egui::ColorImage> {     let db = vectorcraft_text::FontDb::global();+    // A font source not loaded yet has no sample: drawing one would fetch every family the menu+    // shows. Choosing it sets type in it, which fetches it.+    if db.is_pending_source(family, style) {+        return None;+    }     let face: Arc<vectorcraft_text::FontFace> = db.face(family, style)?;     let covers = |s: &str| !s.trim().is_empty() && s.chars().filter(|c| !c.is_whitespace()).all(|c| face.covers(c));     let text = if covers(text) { text } else { FALLBACK_SAMPLE };diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs--- a/crates/ui-egui/src/font_menu/tests.rs+++ b/crates/ui-egui/src/font_menu/tests.rs@@ -240,6 +240,8 @@ fn the_variable_filter_is_named_as_fonts_are() { /// filters; clicked again, every family is back. #[test] fn the_variable_filter_lists_only_variable_families() {+    // Adds to the shared font database: other tests' font lists hold still meanwhile.+    let _fonts = crate::adding_fonts();     use vectorcraft_text::FontClass;     use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};     let db = vectorcraft_text::FontDb::global();diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv--- a/crates/ui-egui/src/i18n/bg.tsv+++ b/crates/ui-egui/src/i18n/bg.tsv@@ -2489,6 +2489,8 @@ font-technology	Variable	Променливи 	Reflect Y	Отражение по Y 	Reflect…	Отражение… 	Refresh	Обновяване+	Add Font	Добавяне на шрифт+	Add Font Sources	Добавяне на източници на шрифтове 	Refresh Font List	Обновяване на списъка с шрифтове 	Missing Fonts	Липсващи шрифтове 	Missing Fonts Dialog	Диалогов прозорец за липсващи шрифтовеdiff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv--- a/crates/ui-egui/src/i18n/cs.tsv+++ b/crates/ui-egui/src/i18n/cs.tsv@@ -3200,6 +3200,8 @@ theme	Appearance	Motiv 	Add Font Files	Přidat soubory písem 	Font List	Seznam písem 	Fonts in Document	Písma v dokumentu+	Add Font	Přidat písmo+	Add Font Sources	Přidat zdroje písem 	Refresh Font List	Obnovit seznam písem 	Replace Font	Nahradit písmo 	Select Text by Font	Vybrat text podle písma@@ -5614,3 +5616,12 @@ axis	Y Axis	osa Y @msg	a {width}×{height} screenshot's byte count overflows	počet bajtů snímku {width}×{height} přeteče @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	okno {what} ({points} bodů × {scale} DPI) nemůže určit velikost snímku 	Live preview	Živý náhled+@msg	give the font file as dataBase64	zadejte soubor písma jako dataBase64+@msg	{name} is larger than {_1} MB	{name} je větší než {_1} MB+@msg	{name} is not a TrueType, OpenType or collection font	{name} není písmo TrueType, OpenType ani kolekce písem+@msg	{name}: dataBase64 is not base64	{name}: dataBase64 není base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 má 64 šestnáctkových číslic+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} má 1 až {max} znaků+@msg	give 1 to {MAX_FONT_SOURCES} fonts	zadejte 1 až {MAX_FONT_SOURCES} písem+@msg	give fonts, a list of fonts each with a family, a url and a sha256	zadejte fonts, seznam písem, každé s family, url a sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	písmo {font} se nepodařilo načíst ({reason}): jeho text se zobrazuje jiným písmemdiff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv--- a/crates/ui-egui/src/i18n/da.tsv+++ b/crates/ui-egui/src/i18n/da.tsv@@ -2963,6 +2963,8 @@ theme	Appearance	Udseende 	Add Font Files	Tilføj skrifttypefiler 	Font List	Skrifttypeliste 	Fonts in Document	Skrifttyper i dokumentet+	Add Font	Tilføj skrifttype+	Add Font Sources	Tilføj skrifttypekilder 	Refresh Font List	Opdatér skrifttypeliste 	Replace Font	Erstat skrifttype 	Select Text by Font	Markér tekst efter skrifttypediff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv--- a/crates/ui-egui/src/i18n/de.tsv+++ b/crates/ui-egui/src/i18n/de.tsv@@ -2510,6 +2510,8 @@ mark-style	Japanese	Japanisch 	Reflect Y	Y spiegeln 	Reflect…	Spiegeln… 	Refresh	Aktualisieren+	Add Font	Schrift hinzufügen+	Add Font Sources	Schriftquellen hinzufügen 	Refresh Font List	Schriftliste aktualisieren 	Missing Fonts	Fehlende Schriften 	Missing Fonts Dialog	Dialogfeld „Fehlende Schriften“@@ -5196,3 +5198,12 @@ axis	Y Axis	Y-Achse @msg	a {width}×{height} screenshot's byte count overflows	die Byteanzahl eines {width}×{height}-Screenshots läuft über @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	die {what} des Fensters ({points} Punkte × {scale} DPI) kann keinen Screenshot bemessen 	Live preview	Live-Vorschau+@msg	give the font file as dataBase64	geben Sie die Schriftdatei als dataBase64 an+@msg	{name} is larger than {_1} MB	{name} ist größer als {_1} MB+@msg	{name} is not a TrueType, OpenType or collection font	{name} ist keine TrueType-, OpenType- oder Sammlungsschrift+@msg	{name}: dataBase64 is not base64	{name}: dataBase64 ist kein Base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 besteht aus 64 Hexadezimalziffern+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} hat 1 bis {max} Zeichen+@msg	give 1 to {MAX_FONT_SOURCES} fonts	geben Sie 1 bis {MAX_FONT_SOURCES} Schriften an+@msg	give fonts, a list of fonts each with a family, a url and a sha256	geben Sie fonts an, eine Liste von Schriften mit je family, url und sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	{font} konnte nicht geladen werden ({reason}): Ihr Text wird in einer anderen Schrift angezeigtdiff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv--- a/crates/ui-egui/src/i18n/el.tsv+++ b/crates/ui-egui/src/i18n/el.tsv@@ -3091,6 +3091,8 @@ theme	Appearance	Εμφάνιση 	Reflect X	Κατοπτρισμός X 	Reflect Y	Κατοπτρισμός Y 	Refresh	Ανανέωση+	Add Font	Προσθήκη γραμματοσειράς+	Add Font Sources	Προσθήκη πηγών γραμματοσειρών 	Refresh Font List	Ανανέωση λίστας γραμματοσειρών 	Missing Fonts	Γραμματοσειρές που λείπουν 	Missing Fonts Dialog	Παράθυρο γραμματοσειρών που λείπουν@@ -5566,3 +5568,12 @@ axis	Y Axis	Άξονας Y @msg	a {width}×{height} screenshot's byte count overflows	το πλήθος byte ενός στιγμιότυπου {width}×{height} υπερχειλίζει @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	το {what} του παραθύρου ({points} σημεία × {scale} DPI) δεν μπορεί να καθορίσει μέγεθος στιγμιότυπου 	Live preview	Ζωντανή προεπισκόπηση+@msg	give the font file as dataBase64	δώστε το αρχείο γραμματοσειράς ως dataBase64+@msg	{name} is larger than {_1} MB	το {name} είναι μεγαλύτερο από {_1} MB+@msg	{name} is not a TrueType, OpenType or collection font	το {name} δεν είναι γραμματοσειρά TrueType, OpenType ή συλλογή γραμματοσειρών+@msg	{name}: dataBase64 is not base64	{name}: το dataBase64 δεν είναι base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	το fonts[{i}].sha256 είναι 64 δεκαεξαδικά ψηφία+@msg	fonts[{i}].{key} is 1 to {max} characters	το fonts[{i}].{key} έχει 1 έως {max} χαρακτήρες+@msg	give 1 to {MAX_FONT_SOURCES} fonts	δώστε 1 έως {MAX_FONT_SOURCES} γραμματοσειρές+@msg	give fonts, a list of fonts each with a family, a url and a sha256	δώστε fonts, μια λίστα γραμματοσειρών, καθεμία με family, url και sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	η γραμματοσειρά {font} δεν φορτώθηκε ({reason}): το κείμενό της εμφανίζεται με άλλη γραμματοσειράdiff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv--- a/crates/ui-egui/src/i18n/es.tsv+++ b/crates/ui-egui/src/i18n/es.tsv@@ -2544,6 +2544,8 @@ font-technology	Variable	Variables 	Reflect Y	Reflejar Y 	Reflect…	Reflejar… 	Refresh	Actualizar+	Add Font	Añadir fuente+	Add Font Sources	Añadir orígenes de fuentes 	Refresh Font List	Actualizar lista de fuentes 	Missing Fonts	Fuentes que faltan 	Missing Fonts Dialog	Cuadro de diálogo Fuentes que faltan@@ -5556,3 +5558,12 @@ axis	Y Axis	Eje Y @msg	a {width}×{height} screenshot's byte count overflows	el número de bytes de una captura de {width}×{height} se desborda @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	el {what} de la ventana ({points} puntos × {scale} DPI) no puede dimensionar una captura 	Live preview	Vista previa en directo+@msg	give the font file as dataBase64	indica el archivo de fuente como dataBase64+@msg	{name} is larger than {_1} MB	{name} ocupa más de {_1} MB+@msg	{name} is not a TrueType, OpenType or collection font	{name} no es una fuente TrueType, OpenType ni una colección de fuentes+@msg	{name}: dataBase64 is not base64	{name}: dataBase64 no está en base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 tiene 64 dígitos hexadecimales+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} tiene de 1 a {max} caracteres+@msg	give 1 to {MAX_FONT_SOURCES} fonts	indica de 1 a {MAX_FONT_SOURCES} fuentes+@msg	give fonts, a list of fonts each with a family, a url and a sha256	indica fonts, una lista de fuentes, cada una con family, url y sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	no se pudo cargar {font} ({reason}): su texto se muestra con otra fuentediff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv--- a/crates/ui-egui/src/i18n/fi.tsv+++ b/crates/ui-egui/src/i18n/fi.tsv@@ -3140,6 +3140,8 @@ theme	Appearance	Ulkoasu 	Add Font Files	Lisää fonttitiedostoja 	Font List	Fonttiluettelo 	Fonts in Document	Asiakirjan fontit+	Add Font	Lisää fontti+	Add Font Sources	Lisää fonttilähteitä 	Refresh Font List	Päivitä fonttiluettelo 	Replace Font	Korvaa fontti 	Select Text by Font	Valitse teksti fontin mukaan@@ -5645,3 +5647,12 @@ axis	Y Axis	Y-akseli @msg	a {width}×{height} screenshot's byte count overflows	{width}×{height} kuvakaappauksen tavumäärä ylittää rajan @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	ikkunan {what} ({points} pistettä × {scale} DPI) ei voi määrittää kuvakaappauksen kokoa 	Live preview	Reaaliaikainen esikatselu+@msg	give the font file as dataBase64	anna fonttitiedosto dataBase64-muodossa+@msg	{name} is larger than {_1} MB	{name} on suurempi kuin {_1} Mt+@msg	{name} is not a TrueType, OpenType or collection font	{name} ei ole TrueType-, OpenType- tai kokoelmafontti+@msg	{name}: dataBase64 is not base64	{name}: dataBase64 ei ole base64-muotoa+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 on 64 heksadesimaalinumeroa+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} on 1–{max} merkkiä+@msg	give 1 to {MAX_FONT_SOURCES} fonts	anna 1–{MAX_FONT_SOURCES} fonttia+@msg	give fonts, a list of fonts each with a family, a url and a sha256	anna fonts, luettelo fonteista, joista jokaisella on family, url ja sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	fonttia {font} ei voitu ladata ({reason}): sen teksti näytetään toisella fontilladiff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv--- a/crates/ui-egui/src/i18n/fr.tsv+++ b/crates/ui-egui/src/i18n/fr.tsv@@ -2541,6 +2541,8 @@ font-technology	Variable	Variables 	Reflect Y	Miroir Y 	Reflect…	Miroir… 	Refresh	Actualiser+	Add Font	Ajouter une police+	Add Font Sources	Ajouter des sources de polices 	Refresh Font List	Actualiser la liste des polices 	Missing Fonts	Polices manquantes 	Missing Fonts Dialog	Boîte de dialogue Polices manquantes@@ -5613,3 +5615,12 @@ axis	Y Axis	Axe Y @msg	a {width}×{height} screenshot's byte count overflows	le nombre d'octets d'une capture {width}×{height} dépasse la limite @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	la {what} de la fenêtre ({points} points × {scale} DPI) ne permet pas de dimensionner une capture 	Live preview	Aperçu en direct+@msg	give the font file as dataBase64	indiquez le fichier de police en dataBase64+@msg	{name} is larger than {_1} MB	{name} dépasse {_1} Mo+@msg	{name} is not a TrueType, OpenType or collection font	{name} n’est pas une police TrueType, OpenType ni une collection de polices+@msg	{name}: dataBase64 is not base64	{name} : dataBase64 n’est pas du base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 compte 64 chiffres hexadécimaux+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} compte de 1 à {max} caractères+@msg	give 1 to {MAX_FONT_SOURCES} fonts	indiquez de 1 à {MAX_FONT_SOURCES} polices+@msg	give fonts, a list of fonts each with a family, a url and a sha256	indiquez fonts, une liste de polices ayant chacune family, url et sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	impossible de charger {font} ({reason}) : son texte s’affiche dans une autre policediff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv--- a/crates/ui-egui/src/i18n/hu.tsv+++ b/crates/ui-egui/src/i18n/hu.tsv@@ -3080,6 +3080,8 @@ mark-style	Japanese	Japán 	Reflect Y	Y-tükrözés 	Reflect…	Tükrözés… 	Refresh	Frissítés+	Add Font	Betűtípus hozzáadása+	Add Font Sources	Betűtípusforrások hozzáadása 	Refresh Font List	Betűtípuslista frissítése 	Registered Trademark Symbol	Bejegyzett védjegy jele 	Registration marks	Illesztőjelek@@ -5600,3 +5602,12 @@ axis	Y Axis	Y tengely @msg	a {width}×{height} screenshot's byte count overflows	a(z) {width}×{height} képernyőkép bájtszáma túlcsordul @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	az ablak {what} ({points} pont × {scale} DPI) nem tud képernyőkép-méretet adni 	Live preview	Élő előnézet+@msg	give the font file as dataBase64	a betűtípusfájlt dataBase64 formában add meg+@msg	{name} is larger than {_1} MB	{name} nagyobb, mint {_1} MB+@msg	{name} is not a TrueType, OpenType or collection font	{name} nem TrueType, OpenType vagy gyűjteményes betűtípus+@msg	{name}: dataBase64 is not base64	{name}: a dataBase64 nem base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	a fonts[{i}].sha256 64 hexadecimális számjegyből áll+@msg	fonts[{i}].{key} is 1 to {max} characters	a fonts[{i}].{key} 1–{max} karakter hosszú+@msg	give 1 to {MAX_FONT_SOURCES} fonts	1–{MAX_FONT_SOURCES} betűtípust adj meg+@msg	give fonts, a list of fonts each with a family, a url and a sha256	add meg a fonts listát: betűtípusok, mindegyik family, url és sha256 értékkel+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	a(z) {font} nem tölthető be ({reason}); a szövege más betűtípussal jelenik megdiff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv--- a/crates/ui-egui/src/i18n/id.tsv+++ b/crates/ui-egui/src/i18n/id.tsv@@ -2963,6 +2963,8 @@ theme	Appearance	Tampilan 	Add Font Files	Tambahkan file font 	Font List	Daftar font 	Fonts in Document	Font dalam dokumen+	Add Font	Tambahkan font+	Add Font Sources	Tambahkan sumber font 	Refresh Font List	Segarkan daftar font 	Replace Font	Ganti font 	Select Text by Font	Pilih teks berdasarkan fontdiff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv--- a/crates/ui-egui/src/i18n/it.tsv+++ b/crates/ui-egui/src/i18n/it.tsv@@ -2575,6 +2575,8 @@ mark-style	Japanese	Giapponese 	Reflect Y	Rifletti Y 	Reflect…	Rifletti… 	Refresh	Aggiorna+	Add Font	Aggiungi font+	Add Font Sources	Aggiungi origini dei font 	Refresh Font List	Aggiorna elenco font 	Missing Fonts	Font mancanti 	Missing Fonts Dialog	Finestra di dialogo Font mancanti@@ -5230,3 +5232,12 @@ axis	Y Axis	Asse Y @msg	a {width}×{height} screenshot's byte count overflows	il numero di byte di uno screenshot {width}×{height} va in overflow @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	la {what} della finestra ({points} punti × {scale} DPI) non può dimensionare uno screenshot 	Live preview	Anteprima dal vivo+@msg	give the font file as dataBase64	specifica il file del font come dataBase64+@msg	{name} is larger than {_1} MB	{name} supera {_1} MB+@msg	{name} is not a TrueType, OpenType or collection font	{name} non è un font TrueType, OpenType né una raccolta di font+@msg	{name}: dataBase64 is not base64	{name}: dataBase64 non è in base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 è di 64 cifre esadecimali+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} ha da 1 a {max} caratteri+@msg	give 1 to {MAX_FONT_SOURCES} fonts	specifica da 1 a {MAX_FONT_SOURCES} font+@msg	give fonts, a list of fonts each with a family, a url and a sha256	specifica fonts, un elenco di font, ciascuno con family, url e sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	impossibile caricare {font} ({reason}): il suo testo viene mostrato con un altro fontdiff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv--- a/crates/ui-egui/src/i18n/ja.tsv+++ b/crates/ui-egui/src/i18n/ja.tsv@@ -2559,6 +2559,8 @@ mark-style	Japanese	日本式 	Reflect X	X 軸でリフレクト 	Reflect Y	Y 軸でリフレクト 	Refresh	更新+	Add Font	フォントを追加+	Add Font Sources	フォントの提供元を追加 	Refresh Font List	フォントリストを更新 	Missing Fonts	見つからないフォント 	Missing Fonts Dialog	見つからないフォントダイアログ@@ -5292,3 +5294,12 @@ axis	Y Axis	Y 軸 @msg	a {width}×{height} screenshot's byte count overflows	{width}×{height} のスクリーンショットのバイト数がオーバーフローします @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	ウィンドウの {what}({points} ポイント × {scale} DPI)ではスクリーンショットのサイズを決められません 	Live preview	ライブプレビュー+@msg	give the font file as dataBase64	フォントファイルを dataBase64 で指定してください+@msg	{name} is larger than {_1} MB	{name} は {_1} MB を超えています+@msg	{name} is not a TrueType, OpenType or collection font	{name} は TrueType、OpenType、コレクションのいずれのフォントでもありません+@msg	{name}: dataBase64 is not base64	{name}: dataBase64 が base64 ではありません+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 は 16 進数 64 桁です+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} は 1~{max} 文字です+@msg	give 1 to {MAX_FONT_SOURCES} fonts	フォントを 1~{MAX_FONT_SOURCES} 個指定してください+@msg	give fonts, a list of fonts each with a family, a url and a sha256	fonts に、family、url、sha256 を持つフォントのリストを指定してください+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	{font} を読み込めませんでした ({reason}): テキストは別のフォントで表示されますdiff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv--- a/crates/ui-egui/src/i18n/ka.tsv+++ b/crates/ui-egui/src/i18n/ka.tsv@@ -2773,6 +2773,8 @@ theme	Appearance	იერსახე 	Add Font Files	შრიფტის ფაილების დამატება 	Font List	შრიფტების სია 	Fonts in Document	დოკუმენტის შრიფტები+	Add Font	შრიფტის დამატება+	Add Font Sources	შრიფტის წყაროების დამატება 	Refresh Font List	შრიფტების სიის განახლება 	Replace Font	შრიფტის ჩანაცვლება 	Select Text by Font	ტექსტის მონიშვნა შრიფტის მიხედვით@@ -5530,3 +5532,12 @@ axis	Y Axis	Y ღერძი @msg	a {width}×{height} screenshot's byte count overflows	{width}×{height} ეკრანის სურათის ბაიტების რაოდენობა გადაივსო @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	ფანჯრის {what} ({points} პუნქტი × {scale} DPI) ეკრანის სურათის ზომას ვერ განსაზღვრავს 	Live preview	ცოცხალი წინასწარი ნახვა+@msg	give the font file as dataBase64	მიუთითეთ შრიფტის ფაილი dataBase64-ის სახით+@msg	{name} is larger than {_1} MB	{name} {_1} MB-ზე დიდია+@msg	{name} is not a TrueType, OpenType or collection font	{name} არ არის TrueType, OpenType ან კოლექციის შრიფტი+@msg	{name}: dataBase64 is not base64	{name}: dataBase64 არ არის base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 არის 64 თექვსმეტობითი ციფრი+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} შეიცავს 1-დან {max}-მდე სიმბოლოს+@msg	give 1 to {MAX_FONT_SOURCES} fonts	მიუთითეთ 1-დან {MAX_FONT_SOURCES}-მდე შრიფტი+@msg	give fonts, a list of fonts each with a family, a url and a sha256	მიუთითეთ fonts — შრიფტების სია, თითოეული family-ით, url-ით და sha256-ით+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	{font} ვერ ჩაიტვირთა ({reason}): მისი ტექსტი სხვა შრიფტით ჩანსdiff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv--- a/crates/ui-egui/src/i18n/ko.tsv+++ b/crates/ui-egui/src/i18n/ko.tsv@@ -2553,6 +2553,8 @@ mark-style	Japanese	일본식 	Reflect X	X축 반사 	Reflect Y	Y축 반사 	Refresh	새로 고침+	Add Font	글꼴 추가+	Add Font Sources	글꼴 소스 추가 	Refresh Font List	글꼴 목록 새로 고침 	Missing Fonts	누락된 글꼴 	Missing Fonts Dialog	누락된 글꼴 대화상자@@ -5193,3 +5195,12 @@ axis	Y Axis	Y축 @msg	a {width}×{height} screenshot's byte count overflows	{width}×{height} 스크린샷의 바이트 수가 넘칩니다 @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	창의 {what}({points}포인트 × {scale} DPI)로는 스크린샷 크기를 정할 수 없습니다 	Live preview	실시간 미리 보기+@msg	give the font file as dataBase64	글꼴 파일을 dataBase64로 지정하세요+@msg	{name} is larger than {_1} MB	{name}이(가) {_1}MB보다 큽니다+@msg	{name} is not a TrueType, OpenType or collection font	{name}은(는) TrueType, OpenType 또는 컬렉션 글꼴이 아닙니다+@msg	{name}: dataBase64 is not base64	{name}: dataBase64가 base64가 아닙니다+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256은(는) 16진수 64자리입니다+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key}은(는) 1~{max}자입니다+@msg	give 1 to {MAX_FONT_SOURCES} fonts	글꼴을 1~{MAX_FONT_SOURCES}개 지정하세요+@msg	give fonts, a list of fonts each with a family, a url and a sha256	fonts에 family, url, sha256이 있는 글꼴 목록을 지정하세요+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	{font}을(를) 불러올 수 없습니다({reason}): 해당 텍스트는 다른 글꼴로 표시됩니다diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv--- a/crates/ui-egui/src/i18n/nl.tsv+++ b/crates/ui-egui/src/i18n/nl.tsv@@ -2977,6 +2977,8 @@ theme	Appearance	Vormgeving 	Add Font Files	Lettertypebestanden toevoegen 	Font List	Lettertypelijst 	Fonts in Document	Lettertypen in document+	Add Font	Lettertype toevoegen+	Add Font Sources	Lettertypebronnen toevoegen 	Refresh Font List	Lettertypelijst vernieuwen 	Replace Font	Lettertype vervangen 	Select Text by Font	Tekst selecteren op lettertypediff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv--- a/crates/ui-egui/src/i18n/pl.tsv+++ b/crates/ui-egui/src/i18n/pl.tsv@@ -3078,6 +3078,8 @@ theme	Appearance	Wygląd 	Add Font Files	Dodaj pliki czcionek 	Font List	Lista czcionek 	Fonts in Document	Czcionki w dokumencie+	Add Font	Dodaj czcionkę+	Add Font Sources	Dodaj źródła czcionek 	Refresh Font List	Odśwież listę czcionek 	Replace Font	Zastąp czcionkę 	Select Text by Font	Zaznacz tekst według czcionki@@ -5466,3 +5468,12 @@ axis	Y Axis	Oś Y @msg	a {width}×{height} screenshot's byte count overflows	liczba bajtów zrzutu ekranu {width}×{height} przekracza limit @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	{what} okna ({points} punktów × {scale} DPI) nie pozwala ustalić rozmiaru zrzutu ekranu 	Live preview	Podgląd na żywo+@msg	give the font file as dataBase64	podaj plik czcionki jako dataBase64+@msg	{name} is larger than {_1} MB	{name} jest większy niż {_1} MB+@msg	{name} is not a TrueType, OpenType or collection font	{name} nie jest czcionką TrueType, OpenType ani kolekcją czcionek+@msg	{name}: dataBase64 is not base64	{name}: dataBase64 nie jest w base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 to 64 cyfry szesnastkowe+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} ma od 1 do {max} znaków+@msg	give 1 to {MAX_FONT_SOURCES} fonts	podaj od 1 do {MAX_FONT_SOURCES} czcionek+@msg	give fonts, a list of fonts each with a family, a url and a sha256	podaj fonts, listę czcionek, każdą z family, url i sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	nie udało się wczytać czcionki {font} ({reason}) — jej tekst jest wyświetlany inną czcionkądiff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv--- a/crates/ui-egui/src/i18n/pt-br.tsv+++ b/crates/ui-egui/src/i18n/pt-br.tsv@@ -2106,6 +2106,8 @@ 	Reflect Y	Espelhar Y 	Reflect…	Espelhar… 	Refresh	Atualizar+	Add Font	Adicionar Fonte+	Add Font Sources	Adicionar Origens de Fontes 	Refresh Font List	Atualizar Lista de Fontes 	Missing Fonts	Fontes Ausentes 	Missing Fonts Dialog	Diálogo de Fontes Ausentes@@ -5531,3 +5533,12 @@ axis	Y Axis	Eixo Y @msg	a {width}×{height} screenshot's byte count overflows	a contagem de bytes de uma captura {width}×{height} transborda @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	o {what} da janela ({points} pontos × {scale} DPI) não pode dimensionar uma captura 	Live preview	Visualização ao vivo+@msg	give the font file as dataBase64	informe o arquivo da fonte como dataBase64+@msg	{name} is larger than {_1} MB	{name} é maior que {_1} MB+@msg	{name} is not a TrueType, OpenType or collection font	{name} não é uma fonte TrueType, OpenType nem uma coleção de fontes+@msg	{name}: dataBase64 is not base64	{name}: dataBase64 não é base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 tem 64 dígitos hexadecimais+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} tem de 1 a {max} caracteres+@msg	give 1 to {MAX_FONT_SOURCES} fonts	informe de 1 a {MAX_FONT_SOURCES} fontes+@msg	give fonts, a list of fonts each with a family, a url and a sha256	informe fonts, uma lista de fontes, cada uma com family, url e sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	não foi possível carregar {font} ({reason}): o texto dela aparece em outra fontediff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv--- a/crates/ui-egui/src/i18n/ru.tsv+++ b/crates/ui-egui/src/i18n/ru.tsv@@ -2571,6 +2571,8 @@ mark-style	Japanese	Японский 	Reflect Y	Отразить по Y 	Reflect…	Отразить… 	Refresh	Обновить+	Add Font	Добавить шрифт+	Add Font Sources	Добавить источники шрифтов 	Refresh Font List	Обновить список шрифтов 	Missing Fonts	Отсутствующие шрифты 	Missing Fonts Dialog	Диалоговое окно «Отсутствующие шрифты»@@ -5226,3 +5228,12 @@ axis	Y Axis	Ось Y @msg	a {width}×{height} screenshot's byte count overflows	количество байтов снимка {width}×{height} переполняется @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	{what} окна ({points} пунктов × {scale} DPI) не позволяет задать размер снимка 	Live preview	Просмотр в реальном времени+@msg	give the font file as dataBase64	укажите файл шрифта как dataBase64+@msg	{name} is larger than {_1} MB	{name} больше {_1} МБ+@msg	{name} is not a TrueType, OpenType or collection font	{name} — не шрифт TrueType, OpenType и не коллекция шрифтов+@msg	{name}: dataBase64 is not base64	{name}: dataBase64 — не base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 — это 64 шестнадцатеричные цифры+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} содержит от 1 до {max} символов+@msg	give 1 to {MAX_FONT_SOURCES} fonts	укажите от 1 до {MAX_FONT_SOURCES} шрифтов+@msg	give fonts, a list of fonts each with a family, a url and a sha256	укажите fonts — список шрифтов, у каждого family, url и sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	не удалось загрузить шрифт {font} ({reason}); его текст отображается другим шрифтомdiff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv--- a/crates/ui-egui/src/i18n/sv.tsv+++ b/crates/ui-egui/src/i18n/sv.tsv@@ -2490,6 +2490,8 @@ font-technology	Variable	Variabla 	Reflect…	Spegla… 	Refresh	Uppdatera 	Refresh Font List	Uppdatera typsnittslistan+	Add Font	Lägg till typsnitt+	Add Font Sources	Lägg till typsnittskällor 	Missing Fonts	Saknade typsnitt 	Missing Fonts Dialog	Dialogruta för saknade typsnitt 	These fonts aren't available. Their text shows in another font.	De här typsnitten är inte tillgängliga. Deras text visas med ett annat typsnitt.@@ -5638,3 +5640,12 @@ axis	Y Axis	Y-axel @msg	a {width}×{height} screenshot's byte count overflows	byteantalet för en skärmbild på {width}×{height} överstiger gränsen @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	fönstrets {what} ({points} punkter × {scale} DPI) kan inte bestämma en skärmbilds storlek 	Live preview	Direkt förhandsgranskning+@msg	give the font file as dataBase64	ange typsnittsfilen som dataBase64+@msg	{name} is larger than {_1} MB	{name} är större än {_1} MB+@msg	{name} is not a TrueType, OpenType or collection font	{name} är inte ett TrueType-, OpenType- eller samlingstypsnitt+@msg	{name}: dataBase64 is not base64	{name}: dataBase64 är inte base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 består av 64 hexadecimala siffror+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} har 1 till {max} tecken+@msg	give 1 to {MAX_FONT_SOURCES} fonts	ange 1 till {MAX_FONT_SOURCES} typsnitt+@msg	give fonts, a list of fonts each with a family, a url and a sha256	ange fonts, en lista med typsnitt som vart och ett har family, url och sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	{font} kunde inte läsas in ({reason}): dess text visas med ett annat teckensnittdiff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv--- a/crates/ui-egui/src/i18n/tr.tsv+++ b/crates/ui-egui/src/i18n/tr.tsv@@ -2363,6 +2363,8 @@ font-technology	Variable	Değişken 	Reflect Y	Y'yi yansıt 	Reflect…	Yansıt… 	Refresh	Yenile+	Add Font	Yazı Tipi Ekle+	Add Font Sources	Yazı Tipi Kaynakları Ekle 	Refresh Font List	Yazı Tipi Listesini Yenile 	Missing Fonts	Eksik Yazı Tipleri 	Missing Fonts Dialog	Eksik Yazı Tipleri İletişim Kutusu@@ -5571,3 +5573,12 @@ axis	Y Axis	Y ekseni @msg	a {width}×{height} screenshot's byte count overflows	{width}×{height} ekran görüntüsünün bayt sayısı taşıyor @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	pencerenin {what} ({points} nokta × {scale} DPI) bir ekran görüntüsünü boyutlandıramaz 	Live preview	Canlı önizleme+@msg	give the font file as dataBase64	yazı tipi dosyasını dataBase64 olarak verin+@msg	{name} is larger than {_1} MB	{name}, {_1} MB'tan büyük+@msg	{name} is not a TrueType, OpenType or collection font	{name} bir TrueType, OpenType ya da koleksiyon yazı tipi değil+@msg	{name}: dataBase64 is not base64	{name}: dataBase64, base64 değil+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 64 onaltılık basamaktır+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} 1 ile {max} karakter arasıdır+@msg	give 1 to {MAX_FONT_SOURCES} fonts	1 ile {MAX_FONT_SOURCES} arası yazı tipi verin+@msg	give fonts, a list of fonts each with a family, a url and a sha256	fonts verin: her birinde family, url ve sha256 olan bir yazı tipi listesi+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	{font} yüklenemedi ({reason}): metni başka bir yazı tipinde gösteriliyordiff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv--- a/crates/ui-egui/src/i18n/uk.tsv+++ b/crates/ui-egui/src/i18n/uk.tsv@@ -2567,6 +2567,8 @@ mark-style	Japanese	Японський 	Reflect Y	Відобразити за Y 	Reflect…	Відобразити… 	Refresh	Оновити+	Add Font	Додати шрифт+	Add Font Sources	Додати джерела шрифтів 	Refresh Font List	Оновити список шрифтів 	Missing Fonts	Відсутні шрифти 	Missing Fonts Dialog	Діалогове вікно «Відсутні шрифти»@@ -5210,3 +5212,12 @@ axis	Y Axis	Вісь Y @msg	a {width}×{height} screenshot's byte count overflows	кількість байтів знімка {width}×{height} переповнюється @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	{what} вікна ({points} пунктів × {scale} DPI) не дозволяє задати розмір знімка 	Live preview	Перегляд у реальному часі+@msg	give the font file as dataBase64	вкажіть файл шрифту як dataBase64+@msg	{name} is larger than {_1} MB	{name} більший за {_1} МБ+@msg	{name} is not a TrueType, OpenType or collection font	{name} — не шрифт TrueType, OpenType і не колекція шрифтів+@msg	{name}: dataBase64 is not base64	{name}: dataBase64 — не base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 — це 64 шістнадцяткові цифри+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} має від 1 до {max} символів+@msg	give 1 to {MAX_FONT_SOURCES} fonts	вкажіть від 1 до {MAX_FONT_SOURCES} шрифтів+@msg	give fonts, a list of fonts each with a family, a url and a sha256	вкажіть fonts — список шрифтів, кожен із family, url і sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	не вдалося завантажити шрифт {font} ({reason}); його текст показано іншим шрифтомdiff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv--- a/crates/ui-egui/src/i18n/vi.tsv+++ b/crates/ui-egui/src/i18n/vi.tsv@@ -2857,6 +2857,8 @@ mark-style	Japanese	Kiểu Nhật 	Reflect Y	Phản chiếu Y 	Reflect…	Phản chiếu… 	Refresh	Làm mới+	Add Font	Thêm phông chữ+	Add Font Sources	Thêm nguồn phông chữ 	Refresh Font List	Làm mới danh sách phông chữ 	Registered Trademark Symbol	Ký hiệu nhãn hiệu đã đăng ký 	Registration marks	Dấu căn chỉnh@@ -5210,3 +5212,12 @@ axis	Y Axis	Trục Y @msg	a {width}×{height} screenshot's byte count overflows	số byte của ảnh chụp {width}×{height} bị tràn @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	{what} của cửa sổ ({points} điểm × {scale} DPI) không thể định cỡ ảnh chụp 	Live preview	Xem trước trực tiếp+@msg	give the font file as dataBase64	hãy cung cấp tệp phông chữ dưới dạng dataBase64+@msg	{name} is larger than {_1} MB	{name} lớn hơn {_1} MB+@msg	{name} is not a TrueType, OpenType or collection font	{name} không phải phông chữ TrueType, OpenType hay bộ sưu tập phông chữ+@msg	{name}: dataBase64 is not base64	{name}: dataBase64 không phải base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 gồm 64 chữ số thập lục phân+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} dài từ 1 đến {max} ký tự+@msg	give 1 to {MAX_FONT_SOURCES} fonts	hãy cung cấp từ 1 đến {MAX_FONT_SOURCES} phông chữ+@msg	give fonts, a list of fonts each with a family, a url and a sha256	hãy cung cấp fonts, danh sách phông chữ, mỗi phông có family, url và sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	không tải được {font} ({reason}): văn bản hiển thị bằng phông khácdiff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv--- a/crates/ui-egui/src/i18n/zh-hans.tsv+++ b/crates/ui-egui/src/i18n/zh-hans.tsv@@ -2446,6 +2446,8 @@ font-script	Japanese	汉字 	Reflect Y	Y 轴镜像 	Reflect…	镜像… 	Refresh	刷新+	Add Font	添加字体+	Add Font Sources	添加字体来源 	Refresh Font List	刷新字体列表 	Missing Fonts	缺失的字体 	Missing Fonts Dialog	缺失的字体对话框@@ -5453,3 +5455,12 @@ axis	Y Axis	Y 轴 @msg	a {width}×{height} screenshot's byte count overflows	{width}×{height} 截图的字节数溢出 @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	窗口的{what}({points} 点 × {scale} DPI)无法确定截图大小 	Live preview	实时预览+@msg	give the font file as dataBase64	请以 dataBase64 给出字体文件+@msg	{name} is larger than {_1} MB	{name} 大于 {_1} MB+@msg	{name} is not a TrueType, OpenType or collection font	{name} 不是 TrueType、OpenType 或字体集合文件+@msg	{name}: dataBase64 is not base64	{name}:dataBase64 不是 base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 是 64 位十六进制数字+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} 为 1 到 {max} 个字符+@msg	give 1 to {MAX_FONT_SOURCES} fonts	请给出 1 到 {MAX_FONT_SOURCES} 个字体+@msg	give fonts, a list of fonts each with a family, a url and a sha256	请给出 fonts:字体列表,每项含 family、url 和 sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	无法加载 {font}({reason}):使用它的文字以其他字体显示diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv--- a/crates/ui-egui/src/i18n/zh-hant.tsv+++ b/crates/ui-egui/src/i18n/zh-hant.tsv@@ -3349,6 +3349,8 @@ font-script	Japanese	漢字 	Reflect Y	鏡射 Y 	Reflect…	鏡射… 	Refresh	重新整理+	Add Font	加入字體+	Add Font Sources	加入字體來源 	Refresh Font List	重新整理字體清單 	Missing Fonts	遺失的字體 	Missing Fonts Dialog	遺失的字體對話框@@ -5249,3 +5251,12 @@ axis	Y Axis	Y 軸 @msg	a {width}×{height} screenshot's byte count overflows	{width}×{height} 螢幕擷取畫面的位元組數溢出 @msg	the window's {what} ({points} points × {scale} DPI) cannot size a screenshot	視窗的{what}({points} 點 × {scale} DPI)無法決定螢幕擷取畫面的大小 	Live preview	即時預覽+@msg	give the font file as dataBase64	請以 dataBase64 提供字體檔案+@msg	{name} is larger than {_1} MB	{name} 大於 {_1} MB+@msg	{name} is not a TrueType, OpenType or collection font	{name} 不是 TrueType、OpenType 或字體集合檔案+@msg	{name}: dataBase64 is not base64	{name}:dataBase64 不是 base64+@msg	fonts[{i}].sha256 is 64 hexadecimal digits	fonts[{i}].sha256 是 64 位十六進位數字+@msg	fonts[{i}].{key} is 1 to {max} characters	fonts[{i}].{key} 為 1 到 {max} 個字元+@msg	give 1 to {MAX_FONT_SOURCES} fonts	請提供 1 到 {MAX_FONT_SOURCES} 個字體+@msg	give fonts, a list of fonts each with a family, a url and a sha256	請提供 fonts:字體清單,每項含 family、url 和 sha256+@msg	{font} couldn't be loaded ({reason}): its text shows in another font	無法載入 {font}({reason}):使用此字體的文字會以其他字體顯示diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -79,6 +79,14 @@ use tests::{ }; #[cfg(test)] mod tests;+/// Held by the tests that add fonts or font sources to the shared+/// [`vectorcraft_text::FontDb::global`] for as long as they run, and by those that read its font+/// list more than once: adding clears the database's cached lists.+#[cfg(test)]+pub(crate) fn adding_fonts() -> std::sync::MutexGuard<'static, ()> {+    static FONTS: std::sync::Mutex<()> = std::sync::Mutex::new(());+    FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner)+}  use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, Sender};diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs--- a/crates/ui-egui/src/panels/character/tests.rs+++ b/crates/ui-egui/src/panels/character/tests.rs@@ -134,6 +134,8 @@ fn kerning_at_a_caret_sets_the_pair_not_the_whole_text() { /// font names it; a drag along one sets that axis of the selected type in one undo step. #[test] fn variable_fonts_get_a_slider_per_axis() {+    // Adds to the shared font database: other tests' font lists hold still meanwhile.+    let _fonts = crate::adding_fonts();     use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};     let db = vectorcraft_text::FontDb::global();     db.add_font(two_axis_font().unwrap());diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs--- a/crates/ui-egui/src/panels/properties/tests.rs+++ b/crates/ui-egui/src/panels/properties/tests.rs@@ -451,6 +451,8 @@ fn type_paragraph_section_hyphenates() { /// Variable fonts get the same Variable Font button in Properties as in the Character panel. #[test] fn properties_variable_font_button_for_variable_type() {+    // Adds to the shared font database: other tests' font lists hold still meanwhile.+    let _fonts = crate::adding_fonts();     use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};     vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());     let mut app = VectorcraftApp::new(Session::new(), Default::default());diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs--- a/crates/ui-egui/src/tests/font_menu.rs+++ b/crates/ui-egui/src/tests/font_menu.rs@@ -507,3 +507,70 @@ fn a_preview_stays_put_while_the_menu_is_still() {     assert_eq!(picked, Some(FontPick::EndPreview));     assert_eq!(font(&app), "Inter"); }++/// A font source not loaded yet has no sample in the menu, and showing it asks for nothing (only+/// type set in it does); once its file is added, its sample draws.+#[test]+fn a_font_source_has_no_sample_until_its_file_is_added() {+    // Adds to the shared font database: other tests' font lists hold still meanwhile.+    let _fonts = crate::adding_fonts();+    const FAMILY: &str = "Menu Source 1";+    let db = vectorcraft_text::FontDb::global();+    let integrity = format!("sha256-{}", vectorcraft_format::base64_encode(&[0xcd; 32]));+    db.add_sources(vec![vectorcraft_text::FontSource { family: FAMILY.into(), style: "Regular".into(), url: "fonts/menu.ttf".into(), integrity }]);+    assert!(db.menu_family_list().iter().any(|f| f == FAMILY), "listed in the menus");+    assert!(crate::font_menu::render_sample_for_test(FAMILY, "Sample", 24).is_none());+    assert!(db.take_source_requests().iter().all(|r| r.family != FAMILY), "a sample asks for nothing");+    assert!(db.add_font(vectorcraft_testkit::fonts::renamed(FAMILY)) > 0);+    assert!(crate::font_menu::render_sample_for_test(FAMILY, "Sample", 24).is_some(), "drawn once added");+}++/// Arrowing onto a font source not loaded yet previews nothing and asks for nothing: a highlight+/// passing over the menu would otherwise fetch every source family it passes.+#[test]+fn the_highlight_does_not_preview_a_font_source_not_loaded_yet() {+    // Adds to the shared font database: other tests' font lists hold still meanwhile.+    let _fonts = crate::adding_fonts();+    // A family of this test's own, sorting last (the font database is the process's, so other+    // tests' menus see what this one adds), and the source right after it, where ↓ lands.+    const CURRENT: &str = "Zz Menu Test0";+    const FAMILY: &str = "Zz Menu Test0 Source";+    let db = vectorcraft_text::FontDb::global();+    assert!(db.add_font(vectorcraft_testkit::fonts::renamed(CURRENT)) > 0 || db.has_family(CURRENT));+    let integrity = format!("sha256-{}", vectorcraft_format::base64_encode(&[0x77; 32]));+    db.add_sources(vec![vectorcraft_text::FontSource { family: FAMILY.into(), style: "Regular".into(), url: "fonts/menu.ttf".into(), integrity }]);+    let ctx = egui::Context::default();+    crate::theme::install_fonts(&ctx);+    let mut app = VectorcraftApp::new(Session::new(), Default::default());+    app.run("file.new", json!({"width": 300, "height": 200})).unwrap();+    let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Gagaku", "font": CURRENT})).unwrap()["id"].as_u64().unwrap();+    app.session.execute("select.set", &json!({"ids": [id]})).unwrap();+    let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {+        let input =+            egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() };+        let (mut at, mut picked) = (egui::Pos2::ZERO, None);+        let mut out = ctx.run_ui(input, |ui| {+            at = ui.next_widget_position();+            picked = font_menu(ui, "test-pending", CURRENT, 260.0, None, MenuLook::default());+            if let Some(p) = picked.clone() {+                crate::font_menu::apply(app, ui.ctx(), p);+            }+        });+        out.textures_delta.clear();+        (at, picked)+    };+    let (at, _) = frame(&mut app, vec![]);+    let click = at + egui::vec2(40.0, 10.0);+    let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };+    frame(&mut app, vec![egui::Event::PointerMoved(click), button(true)]);+    frame(&mut app, vec![button(false)]);+    for _ in 0..3 {+        frame(&mut app, vec![]);+    }+    let (_, picked) = frame(&mut app, key(egui::Key::ArrowDown));+    assert!(!matches!(picked, Some(FontPick::Preview(..))), "{picked:?}");+    for _ in 0..3 {+        frame(&mut app, vec![]);+    }+    assert!(db.take_source_requests().iter().all(|r| r.family != FAMILY), "the highlight asks for nothing");+}diff --git a/crates/ui-egui/src/tests/fonts.rs b/crates/ui-egui/src/tests/fonts.rs--- a/crates/ui-egui/src/tests/fonts.rs+++ b/crates/ui-egui/src/tests/fonts.rs@@ -8,6 +8,8 @@ use crate::{VectorcraftApp, menus};  #[test] fn type_font_menu_lists_every_available_family() {+    // The font list is read twice: no test adds to it meanwhile.+    let _fonts = crate::adding_fonts();     let mut app = VectorcraftApp::new(Session::new(), Default::default());     app.run("file.new", json!({})).unwrap();     let fonts = |app: &VectorcraftApp| -> Vec<String> {diff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -940,6 +940,11 @@ app: Errors reject with an `Error` carrying the method's message. Requests run on the UI's next frame, so a hidden tab (whose frames the browser pauses) answers when it is shown again. +The browser has no installed fonts, so a host page supplies the fonts its documents use:+`vectorcraft.execute("text.addFont", {dataBase64})` gives the session a font file's faces, and type set in them redraws;+`vectorcraft.execute("text.addFontSources", {fonts: [{family, style, url, sha256}]})` lists a whole collection in the+font menus and lets the app fetch each face itself when type uses it (`docs/mcp.md`, fonts).+ ## Revision history  | Date | Change | Summary |@@ -952,3 +957,5 @@ hidden tab (whose frames the browser pauses) answers when it is shown again. | 2026-10-13 | minor | Documented count, pending-event and per-frame request budgets (#1709). | | 2026-10-13 | minor | Documented transport deadlines and connection-slot recovery (#1726). | | 2026-10-18 | minor | Documented `window.vectorcraft`, the web build's control channel. |+| 2026-10-22 | minor | A host page supplies fonts with `text.addFont`. |+| 2026-10-22 | minor | A host page lists the fonts it can supply with `text.addFontSources`. |diff --git a/docs/mcp.md b/docs/mcp.md--- a/docs/mcp.md+++ b/docs/mcp.md@@ -4881,12 +4881,28 @@ first style), empty for a static font. `text.rescanFonts` (Character panel menu › Refresh Font List) scans the font folders again, for fonts installed or removed since the app started: type set in a font that became available redraws in it, and type set in a family that was removed redraws in the fallback font and is listed as missing, without editing the document. The app does so by-itself when its window comes to the front and a font folder changed meanwhile.+itself when its window comes to the front and a font folder changed meanwhile. `text.addFont` gives the session a font+file's faces from its bytes (`dataBase64`: TrueType, OpenType or a collection, at most 64 MB), as installing it would,+for as long as the session runs: the web build has no installed fonts, so a page that embeds VectorCraft supplies the+fonts its documents use this way (`window.vectorcraft.execute("text.addFont", {dataBase64})`). Type set in a face it+adds redraws in it; a face already loaded (the same family, style and version) is not added again, and bytes that are+no font are refused. It answers `{faces: [{family, style}], found}` (the faces added, and the faces the file holds) and+is not an edit: nothing is saved, journaled or undone. `text.addFontSources {fonts: [{family, style?, url, sha256}]}`+registers fonts the host can supply, as a font provider's fonts are activated on demand: they are listed in the font+menus and `text.fontList` from then on (a menu shows a source not loaded yet by name, without a sample, and `text.fontList` gives no axes for it; listing a source asks for nothing), and type set in+one asks for it. The web build fetches what type asks for from `url` (relative to the page), four at a time and at most 64 MB each, checks it against its+SHA-256, and adds it as `text.addFont` does; until then the type draws in the fallback face. Desktop and+`vectorcraft-cli` fetch no source: their host adds its file with `text.addFont`, and until it does the family stays+listed, its type draws in the fallback face and `text.fonts` reports it `missing`. `text.replaceFont` takes a+source's family. A family and style already a source is kept, unless it failed to load (it is then+asked for again); the answer is `{added, sources}`; not an edit either.  ```json {"name":"run_command","arguments":{"command":"text.fontList","params":{}}} {"name":"run_command","arguments":{"command":"text.fontList","params":{"family":"Source Serif 4"}}} {"name":"run_command","arguments":{"command":"text.rescanFonts","params":{}}}+{"name":"run_command","arguments":{"command":"text.addFont","params":{"dataBase64":"AAEAAAAS…"}}}+{"name":"run_command","arguments":{"command":"text.addFontSources","params":{"fonts":[{"family":"Roboto Slab","url":"fonts/RobotoSlab.ttf","sha256":"d2985f67…"}]}}} ```  `text.missingFonts` lists the fonts that the active document's type uses, in its layers and its symbols, and that aren't