From 54106ca9b0c170e245e420de46ac5a8eb7de105e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 20 Jun 2026 16:13:26 +0300 Subject: [PATCH] feat(web): lazy per-script font loading for international CAD text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web build has no system-font access, so CAD text in non-Latin scripts (Cyrillic, Greek, CJK, …) rendered as nothing — build_fallback was a hard None on wasm (#141). Instead, ship per-script Noto subsets under web/fonts (one alphabet per file, SIL OFL 1.1) and fetch them lazily over HTTP the first time a drawing uses that script, then outline glyphs with ttf-parser. - web_font: Script enum + script_of (char → script font), a per-script store with lazy fetch, and a pending queue drained by the app loop. - CJK is split by language (chinese/japanese/korean). Han ideographs share code points but differ by language, so the shared block is routed by the document's $DWGCODEPAGE (932→JP, 949→KR, GB/936→CN); kana is always Japanese, Hangul always Korean. Re-tessellates when the language changes. - build_fallback (wasm) outlines from the fetched subset; clear_fallback_cache lets glyphs that missed while a font was in flight reappear on load. - PollWebFonts subscription (web only) + WebFontLoaded message drive fetches. - Trunk copy-dir serves web/fonts; nothing is bundled into the native binary (desktop keeps using system fonts). Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + Cargo.toml | 7 +- index.html | 3 + src/app/mod.rs | 5 + src/app/update.rs | 53 ++++++++++ src/app/view.rs | 9 ++ src/scene/text/mod.rs | 1 + src/scene/text/ttf_glyph.rs | 32 +++++- src/scene/text/web_font.rs | 199 ++++++++++++++++++++++++++++++++++++ web/fonts/OFL.txt | 102 ++++++++++++++++++ web/fonts/arabic.ttf | Bin 0 -> 108120 bytes web/fonts/chinese.ttf | Bin 0 -> 10193848 bytes web/fonts/cyrillic.ttf | Bin 0 -> 52424 bytes web/fonts/devanagari.ttf | Bin 0 -> 153624 bytes web/fonts/generate.sh | 46 +++++++++ web/fonts/greek.ttf | Bin 0 -> 43704 bytes web/fonts/hebrew.ttf | Bin 0 -> 14032 bytes web/fonts/japanese.ttf | Bin 0 -> 10430500 bytes web/fonts/korean.ttf | Bin 0 -> 1691072 bytes web/fonts/latin.ttf | Bin 0 -> 106376 bytes web/fonts/thai.ttf | Bin 0 -> 17892 bytes 21 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 src/scene/text/web_font.rs create mode 100644 web/fonts/OFL.txt create mode 100644 web/fonts/arabic.ttf create mode 100644 web/fonts/chinese.ttf create mode 100644 web/fonts/cyrillic.ttf create mode 100644 web/fonts/devanagari.ttf create mode 100755 web/fonts/generate.sh create mode 100644 web/fonts/greek.ttf create mode 100644 web/fonts/hebrew.ttf create mode 100644 web/fonts/japanese.ttf create mode 100644 web/fonts/korean.ttf create mode 100644 web/fonts/latin.ttf create mode 100644 web/fonts/thai.ttf diff --git a/Cargo.lock b/Cargo.lock index 63d47cdfd..da37c7e39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,6 +45,7 @@ dependencies = [ "ttf-parser", "ureq", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", "windows-sys 0.61.2", "winresource", diff --git a/Cargo.toml b/Cargo.toml index b52638ac9..061e5cce4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,8 +91,12 @@ serde_json = "1" [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = "0.1" wasm-bindgen = "0.2" +# fetch() returns a JS Promise; bridge it to a Rust future for the per-script +# web font loader (#141). +wasm-bindgen-futures = "0.4" js-sys = "0.3" -# window.open (external URLs) + Blob/anchor file downloads (Save). +# window.open (external URLs) + Blob/anchor file downloads (Save) + fetch() +# (Response) for the lazy per-script web font loader (#141). web-sys = { version = "0.3", features = [ "Window", "Navigator", @@ -101,6 +105,7 @@ web-sys = { version = "0.3", features = [ "HtmlAnchorElement", "Blob", "Url", + "Response", ] } # Browsers without WebGPU (e.g. Firefox by default) need wgpu's WebGL2 backend; # `webgl` enables it. `fira-sans` embeds the default UI font — the web has no diff --git a/index.html b/index.html index f41d5ca96..2908689bd 100644 --- a/index.html +++ b/index.html @@ -42,6 +42,9 @@ + +
diff --git a/src/app/mod.rs b/src/app/mod.rs index 4c27919ed..561507c5c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -794,6 +794,11 @@ pub enum DsField { #[derive(Debug, Clone)] pub enum Message { Tick(Instant), + /// Web: periodic check for per-script fonts a drawing needs but hasn't + /// fetched yet (#141). Native: never emitted. + PollWebFonts, + /// Web: a per-script font finished fetching — `Ok(bytes)` or `Err(reason)`. + WebFontLoaded(crate::scene::text::web_font::Script, Result, String>), OpenFile, /// File picker returned. `Some((path, size_in_bytes))` → start loading; /// `None` → user cancelled the dialog (no overlay shown). diff --git a/src/app/update.rs b/src/app/update.rs index 92264ffec..3441ae569 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -364,6 +364,42 @@ impl OpenCADStudio { fn update_inner(&mut self, msg: Message) -> Task { match msg { + // Web: a drawing referenced a script whose Noto subset isn't loaded + // yet (recorded during text tessellation). Kick off one fetch per + // pending script; the result comes back as `WebFontLoaded`. (#141) + Message::PollWebFonts => { + let pending = crate::scene::text::web_font::take_pending(); + if pending.is_empty() { + return Task::none(); + } + Task::batch(pending.into_iter().map(|script| { + Task::perform(crate::scene::text::web_font::fetch(script), move |res| { + Message::WebFontLoaded(script, res) + }) + })) + } + + // Web: a per-script font arrived. Store it, drop the stale fallback + // glyph cache (entries that resolved to nothing while it loaded), + // and re-tessellate so the text appears. (#141) + Message::WebFontLoaded(script, res) => { + match res { + Ok(bytes) => { + crate::scene::text::web_font::insert(script, Some(bytes)); + crate::scene::text::ttf_glyph::clear_fallback_cache(); + for tab in self.tabs.iter_mut() { + tab.scene.bump_geometry(); + } + } + Err(e) => { + crate::scene::text::web_font::insert(script, None); + self.command_line + .push_error(&format!("Font load failed ({script:?}): {e}")); + } + } + Task::none() + } + Message::Tick(t) => { let i = self.active_tab; self.tabs[i].scene.update(t - self.start); @@ -500,6 +536,15 @@ impl OpenCADStudio { self.tabs[i].current_path = Some(path.clone()); self.tabs[i].scene.document = doc; + // Route shared CJK ideographs to the language matching this + // drawing's code page (web per-language font split). Drop the + // glyph cache if it changed so Han re-resolves to the new + // language's font; geometry is (re)built below regardless. (#141) + if crate::scene::text::web_font::set_cjk_lang_from_codepage( + &self.tabs[i].scene.document.header.code_page, + ) { + crate::scene::text::ttf_glyph::clear_fallback_cache(); + } // Current model-space annotation scale comes from the drawing's // CANNOSCALEVALUE (paper/drawing factor); the multiplier we use // for text/dim sizing is its inverse (1:50 -> 0.02 -> 50.0). @@ -1298,6 +1343,14 @@ impl OpenCADStudio { self.sync_ribbon_from_selection(); // Grid/snap follow the newly active drawing's viewport. self.adopt_view_display(idx); + // Shared CJK ideographs follow the newly active drawing's + // language; re-tessellate if it differs from the last. (#141) + if crate::scene::text::web_font::set_cjk_lang_from_codepage( + &self.tabs[idx].scene.document.header.code_page, + ) { + crate::scene::text::ttf_glyph::clear_fallback_cache(); + self.tabs[idx].scene.bump_geometry(); + } } Task::none() } diff --git a/src/app/view.rs b/src/app/view.rs index c2a66111d..27b91d3ae 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1630,11 +1630,20 @@ impl OpenCADStudio { } else { Subscription::none() }; + // Web: poll for per-script fonts that a drawing's text needs but hasn't + // fetched yet. Cheap — `PollWebFonts` is a no-op when nothing is + // pending. Native has system fonts, so no polling. (#141) + #[cfg(target_arch = "wasm32")] + let web_fonts = + iced::time::every(std::time::Duration::from_millis(300)).map(|_| Message::PollWebFonts); + #[cfg(not(target_arch = "wasm32"))] + let web_fonts = Subscription::none(); iced::Subscription::batch([ frames, history_tick, grip_dwell, caret_blink, + web_fonts, event::listen_with(|ev, status, win_id| { use iced::event::Status; match ev { diff --git a/src/scene/text/mod.rs b/src/scene/text/mod.rs index 5e04a77d2..7ae9dfe49 100644 --- a/src/scene/text/mod.rs +++ b/src/scene/text/mod.rs @@ -2,4 +2,5 @@ pub mod lff; pub mod font_face; pub mod sysfont; pub mod ttf_glyph; +pub mod web_font; pub mod complex_lt; diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index 567949a01..1203c1347 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -263,11 +263,35 @@ pub fn fallback_glyph(ch: char) -> Option> { built } -/// Web: no system fonts → no cosmic-text fallback (LFF-missing glyphs simply -/// do not render). +/// Drop the cached fallback glyphs so the next lookup re-resolves. The web +/// build calls this when a per-script font finishes loading: glyphs that +/// resolved to `None` while the font was still in flight then get a real +/// outline. (#141) +pub fn clear_fallback_cache() { + fallback_cache().lock().unwrap().clear(); +} + +/// Web: outline the glyph from the lazily-fetched per-script Noto subset that +/// covers it. Returns `None` while that font is still loading (the char renders +/// once it arrives and the fallback cache is cleared). (#141) #[cfg(target_arch = "wasm32")] -fn build_fallback(_ch: char) -> Option> { - None +fn build_fallback(ch: char) -> Option> { + let script = crate::scene::text::web_font::script_of(ch)?; + let bytes = crate::scene::text::web_font::request(script)?; + let face = ttf_parser::Face::parse(&bytes, 0).ok()?; + let gid = face.glyph_index(ch)?; + let k = cap_scale(&face); + let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; + let mut fl = OutlineFlattener::new(k); + face.outline_glyph(gid, &mut fl); + fl.flush(); + if fl.contours.is_empty() { + return None; + } + Some(Arc::new(Glyph { + strokes: fl.contours, + advance, + })) } #[cfg(not(target_arch = "wasm32"))] diff --git a/src/scene/text/web_font.rs b/src/scene/text/web_font.rs new file mode 100644 index 000000000..b75054196 --- /dev/null +++ b/src/scene/text/web_font.rs @@ -0,0 +1,199 @@ +//! Per-script web font store + lazy fetch (#141). +//! +//! The desktop build outlines glyphs for non-LFF scripts from the user's +//! installed system fonts (see `ttf_glyph::build_fallback`). The web build has +//! no system-font access, so it instead lazily fetches a per-script Noto subset +//! — served from `fonts/