diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d8227bcb..eafe9cf4 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -675,6 +675,13 @@ impl FontDb { None } + /// A face that covers `c`, for text drawn outside the canvas (the app's own UI): the loaded + /// fonts first, then (native) an installed one, loaded on demand. Characters no font covers + /// are remembered, so they are looked for once. + pub fn face_covering(&self, c: char) -> Option> { + self.fallback_for(c, 0) + } + fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { let faces = self.read_faces(); let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 00314226..104d0d6c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub mod sysclip; pub mod theme; pub mod titlebar; pub mod toolbar; +mod ui_fonts; pub mod unsaved; pub mod widgets; pub mod workspaces; @@ -257,6 +258,8 @@ pub struct VectorcraftApp { pub synthetic: Vec, styled: bool, fonts_ready: bool, + /// Installed fonts added to the UI's for characters its own fonts lack (CJK names…). + ui_fonts: ui_fonts::UiFonts, frame: u64, last_time: f64, /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. @@ -336,6 +339,7 @@ impl VectorcraftApp { synthetic: vec![], styled: false, fonts_ready: false, + ui_fonts: Default::default(), frame: 0, last_time: 0.0, canvas_rect: None, @@ -791,6 +795,7 @@ impl VectorcraftApp { if self.custom_titlebar { titlebar::resize_zones(ui); } + self.ui_fonts.frame(&ctx); self.perf.frame_ms = now_ms() - t0; let _ = json!(null); } diff --git a/crates/ui-egui/src/ui_fonts.rs b/crates/ui-egui/src/ui_fonts.rs new file mode 100644 index 00000000..a8f44dfb --- /dev/null +++ b/crates/ui-egui/src/ui_fonts.rs @@ -0,0 +1,197 @@ +//! Fallback fonts for the app's own text: layer and file names (and anything else the UI shows) in +//! Chinese, Japanese, Korean or other scripts the UI fonts lack are drawn in an installed font that +//! covers them. Nothing is bundled for them: the first time a frame paints a character the UI fonts +//! don't have, an installed font covering it is looked for (in the background on native) and added +//! to egui's families as their last fallback, from the next frame on. Text the UI fonts cover costs +//! no font loading or memory. On the web, which has no system fonts, such characters stay +//! missing-glyph boxes. + +use std::collections::HashSet; +use std::sync::Arc; + +use egui::epaint::Shape; +use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily}; +use egui::{FontData, FontFamily, FontId, LayerId}; +use vectorcraft_text::{FontDb, FontFace}; + +#[derive(Default)] +pub(crate) struct UiFonts { + /// Characters looked at already: the UI fonts have them, a fallback was added (or is being + /// looked for), or no font has them. + seen: HashSet, + /// The fonts added to egui, by name. + added: HashSet, + /// The faces covering the characters being looked for (native: looked for on a thread). + #[cfg(not(target_arch = "wasm32"))] + pending: Option>>>, +} + +impl UiFonts { + /// After a frame is laid out: add the fallback fonts found since the last frame, and look for + /// fonts covering the characters this frame painted that the UI fonts lack. + pub(crate) fn frame(&mut self, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(faces) = self.pending.as_ref().and_then(|rx| rx.try_recv().ok()) { + self.pending = None; + self.install(ctx, &faces); + } + let missing = self.missing(ctx); + if missing.is_empty() { + return; + } + let find = move || { + let mut faces: Vec> = vec![]; + for c in missing { + if let Some(f) = FontDb::global().face_covering(c) + && !faces.iter().any(|x| x.id() == f.id()) + { + faces.push(f); + } + } + faces + }; + // Native: the installed fonts are read off the UI thread. Characters met meanwhile wait for + // the next search (they aren't marked seen until then). + #[cfg(not(target_arch = "wasm32"))] + { + let (tx, rx) = std::sync::mpsc::channel(); + let repaint = ctx.clone(); + let spawned = std::thread::Builder::new().name("ui-font-fallback".into()).spawn(move || { + // The receiver is gone only when the app is. + let _ = tx.send(find()); + repaint.request_repaint(); + }); + if spawned.is_ok() { + self.pending = Some(rx); + } + } + // The web has only the loaded fonts to look through: quick, on this thread. + #[cfg(target_arch = "wasm32")] + self.install(ctx, &find()); + } + + /// Characters painted this frame that the UI fonts lack and haven't been looked for, now + /// marked seen. None while a search is running. + fn missing(&mut self, ctx: &egui::Context) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + if self.pending.is_some() { + return vec![]; + } + let mut layers: Vec = ctx.memory(|m| m.layer_ids().collect()); + if !layers.contains(&LayerId::background()) { + layers.push(LayerId::background()); + } + let mut chars: Vec = vec![]; + let seen = &self.seen; + let mut note = |text: &str| { + // Most UI text is ASCII, which the UI fonts cover. + if !text.is_ascii() { + chars.extend(text.chars().filter(|c| !c.is_ascii() && !c.is_whitespace() && !c.is_control() && !seen.contains(c))); + } + }; + ctx.graphics(|g| { + for l in &layers { + for s in g.get(*l).into_iter().flat_map(|list| list.all_entries()) { + painted_text(&s.shape, &mut note); + } + } + }); + if chars.is_empty() { + return chars; + } + chars.sort_unstable(); + chars.dedup(); + let font = FontId::new(13.0, FontFamily::Proportional); + let missing: Vec = ctx.fonts_mut(|f| chars.iter().copied().filter(|c| !f.has_glyph(&font, *c)).collect()); + self.seen.extend(chars); + missing + } + + /// Add `faces` to every egui family, after the fonts it has. + fn install(&mut self, ctx: &egui::Context, faces: &[Arc]) { + let families: Vec = ctx.fonts(|f| f.definitions().families.keys().cloned().collect()); + for face in faces { + let name = format!("{} {} #{}", face.family, face.style, face.face_index()); + if !self.added.insert(name.clone()) { + continue; + } + // The face's file data, shared with the process-wide font database (which keeps every + // face it loads for the session) instead of copied: one handle is leaked per font added. + let face: &'static FontFace = Box::leak(Box::new(face.clone())); + let data = FontData { index: face.face_index(), ..FontData::from_static(face.file_data()) }; + let families = families.iter().map(|family| InsertFontFamily { family: family.clone(), priority: FontPriority::Lowest }).collect(); + ctx.add_font(FontInsert { name, data, families }); + } + if !faces.is_empty() { + ctx.request_repaint(); + } + } + + /// Wait for the font search running, if any, and add what it found (tests). + #[cfg(test)] + pub(crate) fn finish(&mut self, ctx: &egui::Context) { + if let Some(faces) = self.pending.take().and_then(|rx| rx.recv().ok()) { + self.install(ctx, &faces); + } + } +} + +/// Call `f` with the text of `shape` and the shapes it holds. +fn painted_text(shape: &Shape, f: &mut impl FnMut(&str)) { + match shape { + Shape::Text(t) => f(t.galley.text()), + Shape::Vec(v) => v.iter().for_each(|s| painted_text(s, f)), + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Run a frame painting `text`, then the fallback search after it. + fn frame(ctx: &egui::Context, fonts: &mut UiFonts, text: &str) { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.label(text); + fonts.frame(ui.ctx()); + }); + out.textures_delta.clear(); + } + + fn has_glyph(ctx: &egui::Context, c: char) -> bool { + ctx.fonts_mut(|f| f.has_glyph(&FontId::new(13.0, FontFamily::Proportional), c)) + } + + #[test] + fn latin_text_loads_no_fonts() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut fonts = UiFonts::default(); + frame(&ctx, &mut fonts, "Layer 1 — Café"); + assert!(fonts.pending.is_none() && fonts.added.is_empty()); + } + + #[test] + fn cjk_names_get_an_installed_fallback_font() { + let text = "Logo 标志 ロゴ 로고"; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut fonts = UiFonts::default(); + frame(&ctx, &mut fonts, text); + assert!(!has_glyph(&ctx, '标'), "the UI fonts have no CJK"); + fonts.finish(&ctx); + // The fonts arrive with the next frame. + frame(&ctx, &mut fonts, text); + let installed: Vec = text.chars().filter(|c| !c.is_ascii() && FontDb::global().face_covering(*c).is_some()).collect(); + if installed.is_empty() { + eprintln!("no installed font covers {text}: nothing to check"); + return; + } + for c in installed { + assert!(has_glyph(&ctx, c), "{c} has a glyph"); + } + // Looked for once: a later frame starts no search. + frame(&ctx, &mut fonts, text); + assert!(fonts.pending.is_none()); + } +}