feat: add web CJK font loading and robust cross-platform font detection
This commit is contained in:
1 parent
ab72c5ad56
commit
1fee62dd54
8 files changed
+220
-51
No files matched your search
@@ -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",
|
||||
] }
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) {}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user