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
@@ -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);@@ -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(); } }@@ -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]@@ -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::*;@@ -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 font@@ -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 {@@ -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;@@ -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"));+ } }@@ -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 a@@ -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")); }@@ -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");+}@@ -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 };@@ -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();@@ -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 Диалогов прозорец за липсващи шрифтове@@ -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ísmem@@ -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 skrifttype@@ -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 angezeigt@@ -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}): το κείμενό της εμφανίζεται με άλλη γραμματοσειρά@@ -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 fuente@@ -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 fontilla@@ -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 police@@ -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 meg@@ -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 font@@ -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 font@@ -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}): テキストは別のフォントで表示されます@@ -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}): მისი ტექსტი სხვა შრიფტით ჩანს@@ -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}): 해당 텍스트는 다른 글꼴로 표시됩니다@@ -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 lettertype@@ -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ą@@ -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 fonte@@ -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}); его текст отображается другим шрифтом@@ -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 teckensnitt@@ -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österiliyor@@ -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}); його текст показано іншим шрифтом@@ -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ác@@ -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}):使用它的文字以其他字体显示@@ -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}):使用此字體的文字會以其他字體顯示@@ -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};@@ -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());@@ -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());@@ -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");+}@@ -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> {@@ -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`. |@@ -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