From 1fee62dd54b58ad75cd46120061842eda5c49173 Mon Sep 17 00:00:00 2001 From: Mithril-3d Date: Mon, 12 Oct 2026 02:15:43 +0900 Subject: [PATCH] feat: add web CJK font loading and robust cross-platform font detection --- apps/solvecraft-web/Cargo.toml | 2 +- apps/solvecraft-web/src/main.rs | 41 +++++ crates/engine/src/cmd/component_clone.rs | 20 +-- crates/kernel/src/coplanar.rs | 2 +- crates/ui-egui/src/canvas.rs | 9 +- crates/ui-egui/src/fonts.rs | 186 +++++++++++++++++++---- crates/ui-egui/src/issue_report.rs | 2 +- docs/localization.md | 9 +- 8 files changed, 220 insertions(+), 51 deletions(-) diff --git a/apps/solvecraft-web/Cargo.toml b/apps/solvecraft-web/Cargo.toml index 1d7fca0e..476758b4 100644 --- a/apps/solvecraft-web/Cargo.toml +++ b/apps/solvecraft-web/Cargo.toml @@ -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", ] } diff --git a/apps/solvecraft-web/src/main.rs b/apps/solvecraft-web/src/main.rs index 85627d91..7c9bcf6a 100644 --- a/apps/solvecraft-web/src/main.rs +++ b/apps/solvecraft-web/src/main.rs @@ -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, 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(); diff --git a/crates/engine/src/cmd/component_clone.rs b/crates/engine/src/cmd/component_clone.rs index 403b928f..f5e3ab5f 100644 --- a/crates/engine/src/cmd/component_clone.rs +++ b/crates/engine/src/cmd/component_clone.rs @@ -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); diff --git a/crates/kernel/src/coplanar.rs b/crates/kernel/src/coplanar.rs index 256ea52b..10b73cd9 100644 --- a/crates/kernel/src/coplanar.rs +++ b/crates/kernel/src/coplanar.rs @@ -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. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a9c488a2..9f4bbcae 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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, }; diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index 46f69e0b..936f207b 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -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) -> 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> { + 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> { + 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> { + // 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> = 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) {} diff --git a/crates/ui-egui/src/issue_report.rs b/crates/ui-egui/src/issue_report.rs index 8c1aa870..7e3359ee 100644 --- a/crates/ui-egui/src/issue_report.rs +++ b/crates/ui-egui/src/issue_report.rs @@ -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) { - 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())); diff --git a/docs/localization.md b/docs/localization.md index a2691d11..35551672 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -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