diff --git a/Cargo.lock b/Cargo.lock index 63d47cdf..da37c7e3 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 b52638ac..061e5cce 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 f41d5ca9..2908689b 100644 --- a/index.html +++ b/index.html @@ -42,6 +42,9 @@ + +
diff --git a/src/app/mod.rs b/src/app/mod.rs index 4c27919e..561507c5 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 92264ffe..3441ae56 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 c2a66111..27b91d3a 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 5e04a77d..7ae9dfe4 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 567949a0..1203c134 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 00000000..b7505419 --- /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/