feat: add web CJK font loading and robust cross-platform font detection

This commit is contained in:
Mithril-3d authored and bherbruck committed 2026-10-12 10:06:50 -04:00
1 parent ab72c5ad56
commit 1fee62dd54
8 files changed
+220 -51

No files matched your search

+1 -1
View File
@@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"BeforeUnloadEvent", "Event", "EventTarget",
"BeforeUnloadEvent", "Event", "EventTarget", "Response",
] }
+41
View File
@@ -334,6 +334,45 @@ mod web {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Fetch font bytes asynchronously through the browser.
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
let window = web_sys::window().ok_or("no window")?;
let resp_val = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await.map_err(|e| format!("{e:?}"))?;
let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "response cast failed")?;
if !resp.ok() {
return Err(format!("HTTP {}", resp.status()));
}
let ab_val = wasm_bindgen_futures::JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?;
let uint8_arr = js_sys::Uint8Array::new(&ab_val);
Ok(uint8_arr.to_vec())
}
/// Load Japanese/CJK fallback font in the browser.
fn load_web_fonts(ctx: egui::Context) {
wasm_bindgen_futures::spawn_local(async move {
let urls = [
"./fonts/BIZUDPGothic-Regular.ttf",
"./fonts/NotoSansCJKjp-Regular.otf",
"https://cdn.jsdelivr.net/gh/googlefonts/morisawa-biz-ud-gothic@main/fonts/ttf/BIZUDPGothic-Regular.ttf",
];
for url in urls {
match fetch_font_bytes(url).await {
Ok(bytes) => {
if solvecraft_ui_egui::fonts::install_cjk_font(&ctx, bytes) {
log::info!("loaded web CJK font from {url}");
ctx.request_repaint();
break;
}
}
Err(err) => {
log::debug!("fetching font from {url} failed: {err}");
}
}
}
});
}
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
@@ -355,6 +394,8 @@ mod web {
canvas,
options,
Box::new(move |cc| {
solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
load_web_fonts(cc.egui_ctx.clone());
let files = Rc::new(Files::default());
files::start(&files);
let panel = files.clone();
+8 -12
View File
@@ -515,20 +515,16 @@ pub(super) fn paste(
| L::Body { body }
| L::Vertex { body, .. }
| L::Iso { body, .. }
| L::Spun { body, .. } => {
if !bodies.contains_key(body) {
notices.push(format!("{} retains external linked body {body}", feature.name));
}
| L::Spun { body, .. }
if !bodies.contains_key(body) =>
{
notices.push(format!("{} retains external linked body {body}", feature.name));
}
L::SketchCurve { sketch, .. } | L::SketchPoint { sketch, .. } => {
if !features.contains_key(sketch) {
notices.push(format!("{} retains external linked sketch {sketch}", feature.name));
}
L::SketchCurve { sketch, .. } | L::SketchPoint { sketch, .. } if !features.contains_key(sketch) => {
notices.push(format!("{} retains external linked sketch {sketch}", feature.name));
}
L::OnSurface { sketch, body, .. } => {
if !features.contains_key(sketch) || !bodies.contains_key(body) {
notices.push(format!("{} retains external surface link", feature.name));
}
L::OnSurface { sketch, body, .. } if !features.contains_key(sketch) || !bodies.contains_key(body) => {
notices.push(format!("{} retains external surface link", feature.name));
}
L::Intersection { a, b } => {
pending.push(a);
+1 -1
View File
@@ -508,7 +508,7 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) ->
let Some(radial) = (q - along).normalized() else { continue };
let on = along + radial * c.radius;
let e = (c.radius * 0.05).max(size * 1e-4);
if !(inside.contains(on + radial * e) && !inside.contains(on - radial * e)) {
if !inside.contains(on + radial * e) || inside.contains(on - radial * e) {
continue;
}
// The cylinder over the tool's length holds no material of either body.
+2 -7
View File
@@ -217,13 +217,8 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
None
};
let sign = match (to_span, &d.kind) {
(Some(span), _) => {
if span < 0.0 {
-1.0
} else {
1.0
}
}
(Some(span), _) if span < 0.0 => -1.0,
(Some(_), _) => 1.0,
(_, Kind::Extrude { direction: 1, .. }) => -1.0,
_ => 1.0,
};
+160 -26
View File
@@ -1,8 +1,20 @@
//! System font loading and fallback setup for multi-language (CJK/Japanese) support.
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
/// Install font bytes as fallback for Proportional and Monospace font families.
/// Returns true if the font was parsed and installed successfully.
pub fn install_cjk_font(ctx: &egui::Context, bytes: Vec<u8>) -> bool {
if ttf_parser::Face::parse(&bytes, 0).is_err() {
return false;
}
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert("cjk_fallback".to_owned(), Arc::new(egui::FontData::from_owned(bytes)));
fonts.families.entry(egui::FontFamily::Proportional).or_default().push("cjk_fallback".to_owned());
fonts.families.entry(egui::FontFamily::Monospace).or_default().push("cjk_fallback".to_owned());
ctx.set_fonts(fonts);
true
}
/// Candidate font paths to look for on different operating systems.
#[cfg(not(target_arch = "wasm32"))]
const FONT_CANDIDATES: &[&str] = &[
@@ -12,45 +24,167 @@ const FONT_CANDIDATES: &[&str] = &[
"C:\\Windows\\Fonts\\YuGothR.ttc",
"C:\\Windows\\Fonts\\msgothic.ttc",
"C:\\Windows\\Fonts\\BIZ-UDGothicR.ttc",
"C:\\Windows\\Fonts\\BIZ-UDMinchoM.ttc",
"C:\\Windows\\Fonts\\yumin.ttf",
// macOS
"/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/Hiragino Sans W3.ttc",
"/System/Library/Fonts/Hiragino Sans W4.ttc",
"/System/Library/Fonts/Hiragino Sans W6.ttc",
"/System/Library/Fonts/Supplemental/Hiragino Sans W3.ttc",
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
"/System/Library/Fonts/Supplemental/Songti.ttc",
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/AppleSDGothicNeo.ttc",
"/Library/Fonts/Arial Unicode.ttf",
"/Library/Fonts/Hiragino Sans W3.ttc",
// Linux
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJKjp-Regular.otf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf",
"/usr/share/fonts/truetype/fonts-japanese-gothic.ttf",
"/usr/share/fonts/truetype/takao-gothic/TakaoPGothic.ttf",
"/usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf",
"/usr/share/fonts/truetype/vlgothic/VL-PGothic-Regular.ttf",
"/usr/share/fonts/truetype/ipafont/ipag.ttf",
"/usr/share/fonts/truetype/ipaexfont/ipaexg.ttf",
"/usr/share/fonts/opentype/ipafont/ipag.ttf",
"/usr/share/fonts/opentype/ipaexfont/ipaexg.ttf",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
"/usr/share/fonts/cjkuni-uming/uming.ttc",
];
#[cfg(not(target_arch = "wasm32"))]
fn try_read_font(path: &std::path::Path) -> Option<Vec<u8>> {
if let Ok(bytes) = std::fs::read(path)
&& ttf_parser::Face::parse(&bytes, 0).is_ok()
{
Some(bytes)
} else {
None
}
}
/// Recursively search for matching CJK font files up to a bounded depth.
#[cfg(not(target_arch = "wasm32"))]
fn scan_dir_for_font(dir: &std::path::Path, keywords: &[&str], depth_remaining: usize) -> Option<Vec<u8>> {
if depth_remaining == 0 {
return None;
}
let rd = std::fs::read_dir(dir).ok()?;
let mut subdirs = Vec::new();
for entry in rd.flatten() {
let p = entry.path();
if p.is_file() {
let name = p.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
let is_font = name.ends_with(".ttc") || name.ends_with(".ttf") || name.ends_with(".otf");
if is_font
&& keywords.iter().any(|k| name.contains(k))
&& let Some(bytes) = try_read_font(&p)
{
return Some(bytes);
}
} else if p.is_dir() {
subdirs.push(p);
}
}
for sub in subdirs {
if let Some(bytes) = scan_dir_for_font(&sub, keywords, depth_remaining.saturating_sub(1)) {
return Some(bytes);
}
}
None
}
/// Find a CJK fallback font on the local system or in CRAFT_FONTS_DIR.
#[cfg(not(target_arch = "wasm32"))]
fn find_system_cjk_font() -> Option<Vec<u8>> {
// 1. Check optional craft-fonts pack directory if specified.
if let Ok(craft_dir) = std::env::var("CRAFT_FONTS_DIR") {
let dir = std::path::Path::new(&craft_dir);
let names = ["BIZUDPGothic-Regular.ttf", "BIZ-UDGothicR.ttc", "NotoSansCJK-Regular.ttc", "NotoSansJP-Regular.ttf"];
for name in names {
if let Some(bytes) = try_read_font(&dir.join(name)) {
return Some(bytes);
}
}
}
// 2. Check well-known candidate paths.
for path in FONT_CANDIDATES {
if let Some(bytes) = try_read_font(std::path::Path::new(path)) {
return Some(bytes);
}
}
// 3. Platform-specific dynamic directory search.
#[cfg(target_os = "windows")]
{
if let Ok(windir) = std::env::var("WINDIR").or_else(|_| std::env::var("SystemRoot")) {
let fonts_dir = std::path::PathBuf::from(windir).join("Fonts");
let names = ["meiryo.ttc", "YuGothM.ttc", "YuGothR.ttc", "msgothic.ttc", "BIZ-UDGothicR.ttc"];
for name in names {
if let Some(bytes) = try_read_font(&fonts_dir.join(name)) {
return Some(bytes);
}
}
}
if let Ok(local) = std::env::var("LOCALAPPDATA") {
let user_fonts = std::path::PathBuf::from(local).join("Microsoft").join("Windows").join("Fonts");
if let Some(bytes) = scan_dir_for_font(&user_fonts, &["biz", "gothic", "noto", "yugoth"], 2) {
return Some(bytes);
}
}
}
#[cfg(target_os = "macos")]
{
let dirs = ["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"];
let keywords = ["hiragino", "pingfang", "arial unicode", "songti", "gothic"];
for d in dirs {
if let Some(bytes) = scan_dir_for_font(std::path::Path::new(d), &keywords, 2) {
return Some(bytes);
}
}
}
#[cfg(target_os = "linux")]
{
let search_dirs = [std::path::PathBuf::from("/usr/share/fonts"), std::path::PathBuf::from("/usr/local/share/fonts")];
let keywords = ["notosanscjk", "noto", "cjk", "takao", "vlgothic", "ipa", "droid"];
for dir in &search_dirs {
if let Some(bytes) = scan_dir_for_font(dir, &keywords, 3) {
return Some(bytes);
}
}
if let Ok(home) = std::env::var("HOME") {
let user_fonts = std::path::PathBuf::from(home).join(".local/share/fonts");
if let Some(bytes) = scan_dir_for_font(&user_fonts, &keywords, 2) {
return Some(bytes);
}
}
}
None
}
/// Configure font definitions with system CJK fallback if available.
#[cfg(not(target_arch = "wasm32"))]
pub fn setup_fonts(ctx: &egui::Context) {
let mut font_bytes: Option<Vec<u8>> = None;
for path in FONT_CANDIDATES {
if let Ok(bytes) = std::fs::read(path)
&& ttf_parser::Face::parse(&bytes, 0).is_ok()
{
font_bytes = Some(bytes);
break;
}
if let Some(bytes) = find_system_cjk_font() {
install_cjk_font(ctx, bytes);
}
let Some(bytes) = font_bytes else {
return;
};
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert("cjk_fallback".to_owned(), Arc::new(egui::FontData::from_owned(bytes)));
fonts.families.entry(egui::FontFamily::Proportional).or_default().push("cjk_fallback".to_owned());
fonts.families.entry(egui::FontFamily::Monospace).or_default().push("cjk_fallback".to_owned());
ctx.set_fonts(fonts);
}
/// Browsers cannot read system font files. A web host can supply licensed fonts through egui.
/// On web, setup_fonts does not perform synchronous disk reads.
/// Use asynchronous loading through [`solvecraft_web`] or host scripts.
#[cfg(target_arch = "wasm32")]
pub fn setup_fonts(_ctx: &egui::Context) {}
+1 -1
View File
@@ -400,7 +400,7 @@ pub(crate) fn tick(app: &mut SolveApp, ctx: &egui::Context) {
}
pub(crate) fn receive(app: &mut SolveApp, token: u64, image: Arc<ColorImage>) {
if !app.issue_report.pending.as_ref().is_some_and(|p| p.token == token) {
if app.issue_report.pending.as_ref().is_none_or(|p| p.token != token) {
return;
}
let stale = app.issue_report.pending.as_ref().is_some_and(|p| p.signature != signature(app, app.dialog.as_ref()));
+6 -3
View File
@@ -30,9 +30,12 @@ use a render scope that restores the previous locale when it exits, including ne
Standalone translated panels enter the same scope. Measure rendered text to size custom
widgets; UTF-8 byte lengths do not describe their displayed width.
The desktop host tries system CJK fonts and skips files that cannot be parsed. No system fonts
are redistributed. Japanese glyph coverage depends on installed fonts; browser hosts cannot
load local system fonts and must provide suitably licensed font assets separately.
The desktop host tries system CJK fonts across Windows, macOS and Linux, as well as an optional
`CRAFT_FONTS_DIR` font pack, and skips files that cannot be parsed. No system fonts are redistributed.
Japanese glyph coverage on desktop depends on installed system fonts or craft-fonts. In the browser,
`solvecraft-web` asynchronously loads a licensed CJK font (trying self-hosted `./fonts/` first, then
falling back to a public CDN) and installs it into egui once fetched so that localized text renders
seamlessly without requiring local system font access.
The current messages support named substitution, not locale-specific plural selection,
number/date formatting, or right-to-left layout and shaping. Add those capabilities when