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/