Fix #81: Chinese, Japanese and Korean names show in the UI, not boxes

Layer and object names, document tabs and any other UI text in scripts the
UI fonts lack now draw in an installed font that covers them, as the canvas
already did.

Root cause: egui only had the bundled UI fonts (Source Sans 3, JetBrains
Mono) and its defaults, none with CJK glyphs. After each frame the UI now
looks at the text it painted; the first time a character the UI fonts lack
appears, a thread finds an installed font covering it through the font
database (the canvas's fallback search, so a font the canvas loaded is
reused, its bytes shared rather than copied) and adds it to every egui
family as the last fallback. Nothing is bundled, Latin-only UIs load no
extra font, and on the web, without system fonts, the boxes remain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-06 00:49:22 +07:00
1 parent f12428264c
commit c4b87e36b2
3 files changed
+209

No files matched your search

+7
View File
@@ -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<Arc<FontFace>> {
self.fallback_for(c, 0)
}
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
+5
View File
@@ -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<egui::Event>,
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);
}
+197
View File
@@ -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<char>,
/// The fonts added to egui, by name.
added: HashSet<String>,
/// The faces covering the characters being looked for (native: looked for on a thread).
#[cfg(not(target_arch = "wasm32"))]
pending: Option<std::sync::mpsc::Receiver<Vec<Arc<FontFace>>>>,
}
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<Arc<FontFace>> = 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<char> {
#[cfg(not(target_arch = "wasm32"))]
if self.pending.is_some() {
return vec![];
}
let mut layers: Vec<LayerId> = ctx.memory(|m| m.layer_ids().collect());
if !layers.contains(&LayerId::background()) {
layers.push(LayerId::background());
}
let mut chars: Vec<char> = 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<char> = 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<FontFace>]) {
let families: Vec<FontFamily> = 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<char> = 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());
}
}