From a218115010b4f8f2aba4146b42663f3bf9092c07 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 5 Jul 2026 14:21:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20user=20font=20import=20=E2=80=94?= =?UTF-8?q?=20family-aware=20CanvasKit=20+=20import=20UI=20+=20IndexedDB?= =?UTF-8?q?=20(phases=203-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring user-imported fonts to the browser host, matching the native flow. Phase 3 — family-aware CanvasKit text (the web BLOCKER): drawText now carries font_family and a new measureTextFamilyStyled FFI mirrors it, so the editor measures caret/layout with the same family it draws (closing the family-blind trap). op_ck_bridge.js keys imported typefaces by family and resolves them PER CHARACTER: chars the imported face covers draw with it, the rest fall to the existing script-segmented system/CJK/emoji path (no tofu, no dropped family) — draw + measure split on identical importedCoverage segments so advances agree. register/removeImportedFont with replace + wasm-heap free. Phase 4 — import UI + persistence: web ImportFont opens a hidden .ttf/.otf file input -> FileReader -> 16 MiB cap -> family parsed in Rust via ttf-parser (font_meta.rs; the vendored CanvasKit exposes no getFamilyName) -> register + persist bytes in IndexedDB (font_store_idb.rs; DB openpencil / store imported_fonts, keyed by family, async errors logged) -> refresh snapshot + repaint. Remove drops registry + IndexedDB. Mount re-registers persisted fonts (skipping any the user already changed this session) before the first family-aware paint. font_import_supported is true on web, so the picker's imported group + Import row are live. font_meta family extraction is unit-tested (real .ttf bytes -> family) so the core is verified headlessly; runtime IndexedDB/FileReader/rendering need a browser smoke test. Codex-reviewed (2 rounds) — getFamilyName BLOCKER, mixed-script fallback, IndexedDB async errors, and the mount race all addressed. --- Cargo.lock | 1 + .../src/widgets/property_panel_typography.rs | 5 +- crates/op-host-web/Cargo.toml | 14 + crates/op-host-web/src/canvaskit.rs | 88 ++++++ crates/op-host-web/src/dom_io.rs | 2 +- crates/op-host-web/src/font_meta.rs | 71 +++++ crates/op-host-web/src/font_store_idb.rs | 278 ++++++++++++++++++ crates/op-host-web/src/lib.rs | 7 + crates/op-host-web/src/op_ck_bridge.js | 190 ++++++++++-- crates/op-host-web/src/repaint_ctx.rs | 15 + crates/op-host-web/src/web_fonts.rs | 178 +++++++++++ .../op-host-web/src/widget_host/io_tests.rs | 14 + .../src/widget_host/property_dispatch.rs | 30 +- 13 files changed, 869 insertions(+), 24 deletions(-) create mode 100644 crates/op-host-web/src/font_meta.rs create mode 100644 crates/op-host-web/src/font_store_idb.rs diff --git a/Cargo.lock b/Cargo.lock index 94674c722..a4083caf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3342,6 +3342,7 @@ dependencies = [ "op-pen-loader", "serde", "serde_json", + "ttf-parser", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", diff --git a/crates/op-editor-ui/src/widgets/property_panel_typography.rs b/crates/op-editor-ui/src/widgets/property_panel_typography.rs index feb5f213c..f989a121c 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_typography.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_typography.rs @@ -221,8 +221,9 @@ pub fn font_picker_layout( cy += NO_RESULTS_H; } // The Import action sits at the bottom of the content, visible - // regardless of the search filter — but only when the host can - // actually import (desktop). Web omits it so there is no dead row. + // regardless of the search filter — but only when the host can actually + // import (desktop rfd dialog + web file-input both set the capability). A + // host that can't import omits it so there is no dead row. if allow_import { content.push((FontPickerRow::ImportAction, cy, IMPORT_ACTION_H)); cy += IMPORT_ACTION_H; diff --git a/crates/op-host-web/Cargo.toml b/crates/op-host-web/Cargo.toml index 6fc260f44..e77e2fdbd 100644 --- a/crates/op-host-web/Cargo.toml +++ b/crates/op-host-web/Cargo.toml @@ -12,6 +12,11 @@ path = "src/lib.rs" crate-type = ["cdylib", "rlib"] [dependencies] +# Pure-Rust font metadata parser — the vendored CanvasKit build exposes no +# family-name introspection (no Typeface.getFamilyName / FontMgr family APIs), +# so an imported font's family is extracted here (wasm-safe, unit-testable) +# rather than in JS. See `font_meta`. +ttf-parser = "0.25" # Phase 7.3 reorg: openpencil-shell-core dissolved — widget facade / # theme / layout scene / scene vars / render-backend / gesture types # all resolve through the op-editor-ui crate. @@ -96,6 +101,15 @@ features = [ "HtmlCanvasElement", "HtmlElement", "HtmlInputElement", + # IndexedDB persistence for user-imported fonts (Phase 4, font_store_idb). + # Compile-time binding toggles only — the `web` stub baseline stays green. + "IdbDatabase", + "IdbFactory", + "IdbObjectStore", + "IdbOpenDbRequest", + "IdbRequest", + "IdbTransaction", + "IdbTransactionMode", "ImageData", "KeyboardEvent", # `daemon_base` derives the daemon origin from `window.location`. diff --git a/crates/op-host-web/src/canvaskit.rs b/crates/op-host-web/src/canvaskit.rs index 179ad466a..e020defc2 100644 --- a/crates/op-host-web/src/canvaskit.rs +++ b/crates/op-host-web/src/canvaskit.rs @@ -200,6 +200,7 @@ extern "C" { fn draw_text( this: &OpCk, t: &str, + family: &str, x: f32, y: f32, sz: f32, @@ -214,8 +215,34 @@ extern "C" { fn measure_text(this: &OpCk, t: &str, sz: f32) -> f32; #[wasm_bindgen(method, js_name = measureTextStyled)] fn measure_text_styled(this: &OpCk, t: &str, sz: f32, weight: i32, italic: bool) -> f32; + /// Family-aware measure: when `family` resolves to a registered imported + /// font the whole run is measured with that single typeface, so the caret / + /// layout geometry agrees to sub-pixel with what `drawText` paints for the + /// same (text, family, sz, weight, italic). Empty `family` = family-blind. + #[wasm_bindgen(method, js_name = measureTextFamilyStyled)] + fn measure_text_family_styled( + this: &OpCk, + t: &str, + family: &str, + sz: f32, + weight: i32, + italic: bool, + ) -> f32; #[wasm_bindgen(method, js_name = registerSystemFont)] fn register_system_font(this: &OpCk, family: &str, bytes: &[u8]) -> bool; + /// Register a user-imported font face; the family becomes selectable by + /// name in `drawText` / `measureTextFamilyStyled`. Replaces any prior face + /// under the same (case-insensitive) family key. Returns `false` on parse + /// failure. + #[wasm_bindgen(method, js_name = registerImportedFont)] + fn register_imported_font(this: &OpCk, family: &str, bytes: &[u8]) -> bool; + /// Display names of every registered imported family — mirrors the JS + /// registry into the Rust snapshot after add / remove and at mount. + #[wasm_bindgen(method, js_name = importedFamilyList)] + fn imported_family_list(this: &OpCk) -> Vec; + /// Drop a previously imported font face by family name (no-op if absent). + #[wasm_bindgen(method, js_name = removeImportedFont)] + fn remove_imported_font(this: &OpCk, family: &str); #[wasm_bindgen(method, js_name = clipRect)] fn clip_rect(this: &OpCk, x: f32, y: f32, w: f32, h: f32); #[wasm_bindgen(method, js_name = clipRoundRect)] @@ -290,6 +317,31 @@ impl CanvasKitBackend { let ph = ((self.logical_h as f32) * self.dpr).round() as u32; self.ck.resize(pw.max(1), ph.max(1)); } + /// Register a user-imported font face (mirrors `register_system_font` but + /// for the family-selectable imported registry). Returns `true` when the + /// face parsed and is now selectable by `family`. + pub fn register_imported_font(&mut self, family: &str, bytes: &[u8]) -> bool { + self.ck.register_imported_font(family, bytes) + } + /// Register a font whose family is unknown (a fresh browser import). Returns + /// the extracted family display name, or `None` on parse failure / no + /// family name (the CanvasKit side returns an empty string). + pub fn register_imported_font_from_bytes(&mut self, bytes: &[u8]) -> Option { + // The vendored CanvasKit build can't report a typeface's family name, + // so parse it in Rust, then register through the family-known FFI. + let family = crate::font_meta::parse_family(bytes)?; + self.ck + .register_imported_font(&family, bytes) + .then_some(family) + } + /// Display names of every registered imported family. + pub fn imported_family_list(&self) -> Vec { + self.ck.imported_family_list() + } + /// Drop a previously imported font face by family name. + pub fn remove_imported_font(&mut self, family: &str) { + self.ck.remove_imported_font(family); + } } impl RenderBackend for CanvasKitBackend { @@ -542,6 +594,7 @@ impl RenderBackend for CanvasKitBackend { let c = run.color; self.ck.draw_text( run.content.as_str(), + run.font_family.as_str(), x, y, run.font_size, @@ -571,6 +624,21 @@ impl RenderBackend for CanvasKitBackend { self.ck .measure_text_styled(text, font_size, weight as i32, italic) } + /// Family-aware measure so an editable field's caret / selection geometry + /// lines up with the glyphs `draw_text` paints for a named imported family. + /// Forwards to the JS `measureTextFamilyStyled`, which shares the exact + /// typeface + font sizing the family-aware `drawText` path uses. + fn measure_text_family_styled( + &mut self, + text: &str, + font_size: f32, + family: &str, + weight: u16, + italic: bool, + ) -> f32 { + self.ck + .measure_text_family_styled(text, family, font_size, i32::from(weight), italic) + } fn clip_rect(&mut self, rect: Rect) { self.ck @@ -737,6 +805,18 @@ impl crate::repaint_ctx::RepaintContext for CkInner { fn register_system_font(&mut self, family: &str, bytes: &[u8]) -> bool { self.backend.ck.register_system_font(family, bytes) } + fn register_imported_font(&mut self, family: &str, bytes: &[u8]) -> bool { + self.backend.register_imported_font(family, bytes) + } + fn register_imported_font_from_bytes(&mut self, bytes: &[u8]) -> Option { + self.backend.register_imported_font_from_bytes(bytes) + } + fn imported_family_list(&self) -> Vec { + self.backend.imported_family_list() + } + fn remove_imported_font(&mut self, family: &str) { + self.backend.remove_imported_font(family); + } fn repaint(&mut self) -> Result<(), JsValue> { // CanvasKit present is infallible (GPU flush, no pixel round-trip). CkInner::repaint(self); @@ -822,6 +902,10 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { { let mut b = inner.borrow_mut(); let _ = b.resize_to_window(&window)?; + // The CanvasKit backend accepts runtime font bytes, so the browser + // shell supports user font import — flip the flag the shared picker + // reads to paint the Imported group + "Import font…" row (#Phase 4). + b.host.editor_state_mut().editor_ui.font_import_supported = true; // First frame paints synchronously so the shell is visible immediately // (no one-frame blank). Subsequent input-driven repaints coalesce // through the rAF installed below. @@ -843,6 +927,9 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { })); } crate::web_fonts::drain_font_requests(&inner); + // Re-register any user-imported fonts persisted in IndexedDB (async; repaints + // when the read lands so their text re-shapes with the imported typeface). + crate::web_fonts::load_imported_fonts_at_mount(&inner); // Populate the chat model picker from the daemon's `/api/ai/models` // catalog (best-effort; async, repaints when the response lands). @@ -1288,6 +1375,7 @@ pub async fn ck_smoke(canvas_id: String) -> Result<(), JsValue> { ); be.ck.draw_text( "OpenPencil Rust -> CanvasKit GPU", + "", 20.0, 40.0, 28.0, diff --git a/crates/op-host-web/src/dom_io.rs b/crates/op-host-web/src/dom_io.rs index a5b76403f..6b001021f 100644 --- a/crates/op-host-web/src/dom_io.rs +++ b/crates/op-host-web/src/dom_io.rs @@ -1011,7 +1011,7 @@ pub(crate) fn read_file(file: web_sys::File, mode: ReadMode, on_done: Box Option> { +pub(crate) fn js_bytes(value: &JsValue) -> Option> { let buf = value.clone().dyn_into::().ok()?; Some(js_sys::Uint8Array::new(&buf).to_vec()) } diff --git a/crates/op-host-web/src/font_meta.rs b/crates/op-host-web/src/font_meta.rs new file mode 100644 index 000000000..595a724e7 --- /dev/null +++ b/crates/op-host-web/src/font_meta.rs @@ -0,0 +1,71 @@ +//! Pure-Rust font-family extraction for the web import path. +//! +//! The vendored CanvasKit build (`assets/canvaskit/canvaskit.js`) is a slimmed +//! build with NO family-name introspection — `Typeface` has no +//! `getFamilyName()` and `FontMgr` exposes no `countFamilies`/`getFamilyName`. +//! So a freshly imported font's family (needed to key the CanvasKit typeface +//! map, the picker entry, and the IndexedDB record) is parsed here from the +//! font's `name` table with `ttf-parser` — wasm-safe and unit-testable without +//! a browser, unlike the JS/CanvasKit round-trip. + +/// Extract a font's family display name from its raw `.ttf` / `.otf` bytes. +/// Prefers the Typographic Family (name id 16) over the legacy Family (id 1), +/// and a decodable Unicode/Windows record. Returns `None` when the bytes are +/// not a parseable font or carry no usable family name. +pub(crate) fn parse_family(bytes: &[u8]) -> Option { + // Face index 0 — a plain .ttf/.otf has one face; a .ttc collection's first. + let face = ttf_parser::Face::parse(bytes, 0).ok()?; + + const FAMILY: u16 = 1; + const TYPOGRAPHIC_FAMILY: u16 = 16; + + let mut family: Option = None; + let mut typographic: Option = None; + for name in face.names() { + if name.name_id != FAMILY && name.name_id != TYPOGRAPHIC_FAMILY { + continue; + } + // `to_string` decodes Unicode/Windows-platform records; Mac-Roman and + // other legacy encodings yield `None` and are skipped — every font + // that targets browsers/OSes ships a Windows Unicode family record. + let Some(value) = name.to_string() else { + continue; + }; + let trimmed = value.trim(); + if trimmed.is_empty() { + continue; + } + if name.name_id == TYPOGRAPHIC_FAMILY { + typographic.get_or_insert_with(|| trimmed.to_string()); + } else { + family.get_or_insert_with(|| trimmed.to_string()); + } + } + typographic.or(family) +} + +#[cfg(test)] +mod tests { + use super::parse_family; + + // A real bundled OFL font (shared with op-host-desktop). include_bytes from + // a sibling crate's assets is a compile-time file read, not a dep edge. + const INSTRUMENT_SERIF: &[u8] = + include_bytes!("../../op-host-desktop/assets/fonts/InstrumentSerif-Regular.ttf"); + const OUTFIT_VF: &[u8] = include_bytes!("../../op-host-desktop/assets/fonts/Outfit-VF.ttf"); + + #[test] + fn extracts_family_from_real_fonts() { + assert_eq!( + parse_family(INSTRUMENT_SERIF).as_deref(), + Some("Instrument Serif") + ); + assert_eq!(parse_family(OUTFIT_VF).as_deref(), Some("Outfit")); + } + + #[test] + fn rejects_non_font_bytes() { + assert_eq!(parse_family(b"this is not a font file"), None); + assert_eq!(parse_family(&[]), None); + } +} diff --git a/crates/op-host-web/src/font_store_idb.rs b/crates/op-host-web/src/font_store_idb.rs new file mode 100644 index 000000000..d7cf7f1a8 --- /dev/null +++ b/crates/op-host-web/src/font_store_idb.rs @@ -0,0 +1,278 @@ +// Browser boundary: IndexedDB persistence needs a real browser to exercise the +// open / put / delete / getAll round-trip; the pure key normalizer is unit-tested. +//! IndexedDB persistence for user-imported fonts (web host, Phase 4). +//! +//! The desktop host copies imported font files into a disk-backed +//! [`FontStore`](../../op-host-desktop/src/fonts.rs); the browser has no +//! filesystem, so we persist the raw font bytes in IndexedDB instead and +//! re-register them into the CanvasKit family registry on the next mount. +//! +//! DB `"openpencil"`, object store `"imported_fonts"` (out-of-line keys). Each +//! record is `{ family: string, bytes: Uint8Array }` stored under the family +//! key (see [`primary_key`]). Everything here is defensive: any IndexedDB error +//! logs to the console and degrades to session-only persistence — a failed +//! store never panics or blocks the editor. + +use wasm_bindgen::closure::Closure; +use wasm_bindgen::{JsCast, JsValue}; +use web_sys::{IdbDatabase, IdbObjectStore, IdbOpenDbRequest, IdbTransactionMode}; + +const DB_NAME: &str = "openpencil"; +const STORE: &str = "imported_fonts"; +const DB_VERSION: u32 = 1; + +/// CSS font stack → IndexedDB record key. Mirrors the JS `primaryFamilyKey`: +/// first family before a comma, quotes stripped, trimmed, lowercased; generic +/// keywords resolve to an empty key. The same value keys both `put_font` and +/// `delete_font`, so add / remove round-trip regardless of how the JS registry +/// keys its in-memory map. +pub(crate) fn primary_key(family: &str) -> String { + let first = family + .split(',') + .next() + .unwrap_or(family) + .trim() + .trim_matches(['"', '\'']) + .trim(); + let key = first.to_lowercase(); + const GENERIC: [&str; 5] = [ + "system-ui", + "sans-serif", + "serif", + "monospace", + "-apple-system", + ]; + if GENERIC.contains(&key.as_str()) { + String::new() + } else { + key + } +} + +fn console_warn(msg: &str) { + web_sys::console::warn_1(&JsValue::from_str(msg)); +} + +/// Open (and, on first use, create) the imported-fonts DB, then invoke +/// `on_ready` with the live database. Any failure logs and drops the callback +/// (session-only degrade). One-shot closures are `forget()`-leaked — these fire +/// on rare user actions (import / remove) plus once at mount, so the leak is +/// negligible and keeps the event wiring simple. +fn open_db(on_ready: Box) { + let result = (|| -> Result<(), JsValue> { + let window = + web_sys::window().ok_or_else(|| JsValue::from_str("font-store: window unavailable"))?; + let factory = window + .indexed_db()? + .ok_or_else(|| JsValue::from_str("font-store: IndexedDB unavailable"))?; + let open_req: IdbOpenDbRequest = factory.open_with_u32(DB_NAME, DB_VERSION)?; + + // onupgradeneeded (first open / version bump) — create the store. The + // request's `result()` is the upgrading database at this point. + { + let upgrade_req = open_req.clone(); + let upgrade = Closure::::once(move || { + if let Ok(db) = upgrade_req.result().and_then(|v| { + v.dyn_into::() + .map_err(|_| JsValue::from_str("upgrade: not a database")) + }) { + // Ignore an "already exists" error — harmless on re-entry. + let _ = db.create_object_store(STORE); + } + }); + open_req.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref())); + upgrade.forget(); + } + + // onsuccess → hand the ready database to the caller. + { + let success_req = open_req.clone(); + let mut once = Some(on_ready); + let success = Closure::::once(move || { + let db = success_req + .result() + .ok() + .and_then(|v| v.dyn_into::().ok()); + if let (Some(cb), Some(db)) = (once.take(), db) { + cb(db); + } + }); + open_req.set_onsuccess(Some(success.as_ref().unchecked_ref())); + success.forget(); + } + + // onerror → log + drop the callback (session-only degrade). + { + let error = Closure::::once(move || { + console_warn("[font-store] IndexedDB open failed; imported fonts are session-only"); + }); + open_req.set_onerror(Some(error.as_ref().unchecked_ref())); + error.forget(); + } + Ok(()) + })(); + if let Err(e) = result { + web_sys::console::warn_1(&e); + } +} + +/// Open a read/write transaction on the store, returning `None` (after logging) +/// on any failure so callers can bail without panicking. +fn writable_store(db: &IdbDatabase) -> Option { + match db.transaction_with_str_and_mode(STORE, IdbTransactionMode::Readwrite) { + Ok(tx) => tx.object_store(STORE).ok(), + Err(e) => { + web_sys::console::warn_1(&e); + None + } + } +} + +/// Attach a logging `onerror` to an IDB request so an ASYNC failure (quota +/// exceeded, transaction abort) surfaces in the console instead of being +/// silently dropped — the synchronous `Err` from `put`/`delete` only covers +/// request creation. `forget()`-leaked like the module's one-shot open +/// closures (fires only on rare import/remove writes). +fn log_request_errors(req: &web_sys::IdbRequest, what: &'static str) { + let cb = Closure::::new(move |_e: web_sys::Event| { + console_warn(what); + }); + req.set_onerror(Some(cb.as_ref().unchecked_ref())); + cb.forget(); +} + +/// Persist a font under its family key. `{ family, bytes }`, keyed by +/// `family_key`; replaces any prior record for the same family. Fire-and-forget +/// (the transaction auto-commits) and non-blocking. +pub(crate) fn put_font(family_key: &str, family: &str, bytes: &[u8]) { + if family_key.is_empty() { + return; + } + let family_key = family_key.to_string(); + let family = family.to_string(); + // Copy into a fresh JS `Uint8Array` (own buffer) so the structured clone + // that IndexedDB performs doesn't reference wasm linear memory. + let arr = js_sys::Uint8Array::from(bytes); + open_db(Box::new(move |db| { + let Some(store) = writable_store(&db) else { + return; + }; + let record = js_sys::Object::new(); + let _ = js_sys::Reflect::set( + &record, + &JsValue::from_str("family"), + &JsValue::from_str(&family), + ); + let _ = js_sys::Reflect::set(&record, &JsValue::from_str("bytes"), &arr); + match store.put_with_key(&record, &JsValue::from_str(&family_key)) { + Ok(req) => log_request_errors(&req, "font-store: IndexedDB put failed"), + Err(e) => web_sys::console::warn_1(&e), + } + })); +} + +/// Delete the persisted font for `family_key` (no-op if absent). +pub(crate) fn delete_font(family_key: &str) { + if family_key.is_empty() { + return; + } + let family_key = family_key.to_string(); + open_db(Box::new(move |db| { + let Some(store) = writable_store(&db) else { + return; + }; + match store.delete(&JsValue::from_str(&family_key)) { + Ok(req) => log_request_errors(&req, "font-store: IndexedDB delete failed"), + Err(e) => web_sys::console::warn_1(&e), + } + })); +} + +/// Load every persisted font, invoking `on_done` with `(family, bytes)` pairs. +/// `on_done` is only called on a successful read; any error path logs and drops +/// it (mount-time load simply skips — the editor still runs, imports just don't +/// survive the reload). +pub(crate) fn load_all(on_done: Box)>)>) { + open_db(Box::new(move |db| { + let store = match db.transaction_with_str(STORE) { + Ok(tx) => match tx.object_store(STORE) { + Ok(store) => store, + Err(e) => { + web_sys::console::warn_1(&e); + return; + } + }, + Err(e) => { + web_sys::console::warn_1(&e); + return; + } + }; + let req = match store.get_all() { + Ok(req) => req, + Err(e) => { + web_sys::console::warn_1(&e); + return; + } + }; + let result_req = req.clone(); + let mut once = Some(on_done); + let success = Closure::::once(move || { + let value = result_req.result().unwrap_or(JsValue::NULL); + let fonts = parse_records(&value); + if let Some(cb) = once.take() { + cb(fonts); + } + }); + req.set_onsuccess(Some(success.as_ref().unchecked_ref())); + success.forget(); + let error = Closure::::once(move || { + console_warn("[font-store] IndexedDB read failed; no persisted fonts loaded"); + }); + req.set_onerror(Some(error.as_ref().unchecked_ref())); + error.forget(); + })); +} + +/// Decode a `getAll()` result array into `(family, bytes)` pairs, skipping any +/// malformed record. +fn parse_records(value: &JsValue) -> Vec<(String, Vec)> { + let array = js_sys::Array::from(value); + let mut out = Vec::new(); + for entry in array.iter() { + let family = js_sys::Reflect::get(&entry, &JsValue::from_str("family")) + .ok() + .and_then(|v| v.as_string()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let bytes = js_sys::Reflect::get(&entry, &JsValue::from_str("bytes")) + .ok() + .and_then(|v| v.dyn_into::().ok()) + .map(|arr| arr.to_vec()); + if let (Some(family), Some(bytes)) = (family, bytes) { + if !bytes.is_empty() { + out.push((family, bytes)); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn primary_key_matches_js_normalization() { + assert_eq!(primary_key("Inter"), "inter"); + assert_eq!(primary_key("\"My Font\", sans-serif"), "my font"); + assert_eq!(primary_key(" 'Roboto Mono' "), "roboto mono"); + } + + #[test] + fn primary_key_rejects_generic_families() { + assert_eq!(primary_key("sans-serif"), ""); + assert_eq!(primary_key("system-ui"), ""); + assert_eq!(primary_key("-apple-system"), ""); + assert_eq!(primary_key(""), ""); + } +} diff --git a/crates/op-host-web/src/lib.rs b/crates/op-host-web/src/lib.rs index 439b3c043..708d39e9c 100644 --- a/crates/op-host-web/src/lib.rs +++ b/crates/op-host-web/src/lib.rs @@ -81,6 +81,13 @@ mod web_settings; mod web_clipboard; #[cfg(feature = "canvaskit")] mod web_fonts; +// IndexedDB persistence for user-imported fonts (Phase 4). +#[cfg(feature = "canvaskit")] +mod font_store_idb; +// Pure-Rust font-family extraction for imported fonts (the vendored CanvasKit +// build has no family-name introspection). +#[cfg(feature = "canvaskit")] +mod font_meta; #[cfg(not(feature = "canvaskit"))] use wasm_bindgen::prelude::*; diff --git a/crates/op-host-web/src/op_ck_bridge.js b/crates/op-host-web/src/op_ck_bridge.js index 5c6d827a4..749928b56 100644 --- a/crates/op-host-web/src/op_ck_bridge.js +++ b/crates/op-host-web/src/op_ck_bridge.js @@ -35,6 +35,12 @@ export async function opCkInit(canvasId) { const systemTypefaces = []; const systemTypefaceKeys = new Set(); + // User-imported font faces, keyed by normalized family name -> { tf, family }. + // A named family is a deliberate single-typeface choice (mirrors how native + // resolves a named family via FontMgr before any script fallback), so + // imported text shapes the WHOLE run with one typeface instead of + // re-segmenting by script. + const importedTypefaces = new Map(); const coverageCache = new Map(); const browserTextCanvas = document.createElement('canvas'); const browserTextCtx = browserTextCanvas.getContext('2d', { willReadFrequently: true }); @@ -231,6 +237,101 @@ export async function opCkInit(canvasId) { }; const tfFor = (t, emojiRun) => systemTypefaceFor(t, emojiRun) || CK.Typeface.GetDefault(); const runWidth = (f, s) => { const ids = f.getGlyphIDs(s); return f.getGlyphWidths(ids).reduce((a, v) => a + v, 0); }; + // Normalize a CSS font stack to an imported-family key, mirroring jian-skia + // `primary_font_family`: first family before a comma, quotes stripped; + // empty / generic keywords resolve to no imported family. + const GENERIC_FAMILIES = new Set(['system-ui', 'sans-serif', 'serif', 'monospace', '-apple-system']); + const primaryFamilyKey = (family) => { + const first = String(family || '').split(',')[0].trim().replace(/^["']|["']$/g, '').trim(); + if (!first) return ''; + const key = first.toLowerCase(); + return GENERIC_FAMILIES.has(key) ? '' : key; + }; + // Resolve the imported typeface for a family stack (null when unregistered). + const familyTypeface = (family) => { + const key = primaryFamilyKey(family); + if (!key) return null; + const entry = importedTypefaces.get(key); + return entry ? entry.tf : null; + }; + // Shared "typeface + font for (family, sz)" so the family-aware draw and + // measure paths build the SAME CK.Font and agree to sub-pixel. Caller deletes. + const importedFamilyFont = (tf, sz, italic) => { + const f = new CK.Font(tf, sz); + if (italic) f.setSkewX(-0.25); + return f; + }; + // Split a run into maximal {text, imported} segments by whether the imported + // typeface has a glyph for each char (glyph id 0 = .notdef = uncovered). This + // is per-CHARACTER, so a mixed run keeps the imported face for the chars it + // covers and falls back (system/CJK/emoji) only for the rest — matching how + // native resolves a named family per character, instead of dropping the + // imported family for the whole run. `getGlyphIDs` returns one id per + // codepoint, so it aligns with the codepoint iteration; if the counts don't + // line up we conservatively treat the whole run as uncovered. + const importedCoverageSegments = (tf, sz, t) => { + const cps = Array.from(t); + if (cps.length === 0) return []; + const f = new CK.Font(tf, sz); + let ids = null; + try { + ids = f.getGlyphIDs(t); + } catch (e) { + ids = null; + } + f.delete(); + if (!ids || ids.length !== cps.length) return [{ text: t, imported: false }]; + const out = []; + let cur = ''; + let curImported = null; + for (let i = 0; i < cps.length; i++) { + const imp = ids[i] !== 0; + if (curImported === null) { + curImported = imp; + cur = cps[i]; + } else if (imp === curImported) { + cur += cps[i]; + } else { + out.push({ text: cur, imported: curImported }); + cur = cps[i]; + curImported = imp; + } + } + if (cur) out.push({ text: cur, imported: curImported }); + return out; + }; + // Draw a run via the script-segmented fallback (system / CJK / emoji / + // browser-canvas), returning the advance consumed. Shared by the + // family-blind path and the uncovered segments of a family-aware run, so the + // two stay identical. Mirrors `measureTextStyled` advance-for-advance. + const drawScriptRun = (t, x, y, sz, weight, italic, r, g, b, a) => { + const segs = segments(t); + if (segs.length === 0) return 0; + if (allSegmentsUseBrowserTextFallback(segs)) { + let cx = x; + for (const seg of segs) cx += drawBrowserText(seg.text, cx, y, sz, weight, italic, r, g, b, a); + return cx - x; + } + const p = fillPaint(r, g, b, a); + if (weight >= 600 && isPaintStyle(CK.PaintStyle.StrokeAndFill)) { + setPaintStyle(p, CK.PaintStyle.StrokeAndFill); + p.setStrokeWidth(sz * 0.06); + } + let cx = x; + for (const seg of segs) { + if (shouldUseBrowserTextFallback(seg.text, seg.emoji)) { + cx += drawBrowserText(seg.text, cx, y, sz, weight, italic, r, g, b, a); + continue; + } + const f = new CK.Font(tfFor(seg.text, seg.emoji), sz); + if (italic && !seg.emoji) f.setSkewX(-0.25); + canvas.drawText(seg.text, cx, y, p, f); + cx += runWidth(f, seg.text); + f.delete(); + } + p.delete(); + return cx - x; + }; const pathIsFinite = (bounds) => bounds && bounds.length >= 4 && bounds.every((v) => Number.isFinite(v)); const fitPathToRect = (path, x, y, w, h) => { if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { @@ -389,14 +490,17 @@ export async function opCkInit(canvasId) { offsetPath.delete(); path.delete(); }, - drawText(t, x, y, sz, weight, italic, r, g, b, a) { - const segs = segments(t); - if (segs.length === 0) return; - if (allSegmentsUseBrowserTextFallback(segs)) { - let cx = x; - for (const seg of segs) { - cx += drawBrowserText(seg.text, cx, y, sz, weight, italic, r, g, b, a); - } + drawText(t, family, x, y, sz, weight, italic, r, g, b, a) { + if (!t) return; + // Per-CHARACTER family resolution: chars the imported face covers draw + // with it; the rest fall to the script-segmented path — so a mixed run + // keeps the imported family where it applies and never renders tofu, + // matching native. Draw + measure split on the SAME importedCoverage + // segments and share drawScriptRun/importedFamilyFont, so advances agree. + const importedTf = familyTypeface(family); + const covSegs = importedTf ? importedCoverageSegments(importedTf, sz, t) : null; + if (!covSegs || (covSegs.length === 1 && !covSegs[0].imported)) { + drawScriptRun(t, x, y, sz, weight, italic, r, g, b, a); return; } const p = fillPaint(r, g, b, a); @@ -405,22 +509,41 @@ export async function opCkInit(canvasId) { p.setStrokeWidth(sz * 0.06); } let cx = x; - for (const seg of segs) { - if (shouldUseBrowserTextFallback(seg.text, seg.emoji)) { - cx += drawBrowserText(seg.text, cx, y, sz, weight, italic, r, g, b, a); - continue; + for (const seg of covSegs) { + if (seg.imported) { + const f = importedFamilyFont(importedTf, sz, italic); + canvas.drawText(seg.text, cx, y, p, f); + cx += runWidth(f, seg.text); + f.delete(); + } else { + cx += drawScriptRun(seg.text, cx, y, sz, weight, italic, r, g, b, a); } - const f = new CK.Font(tfFor(seg.text, seg.emoji), sz); - if (italic && !seg.emoji) f.setSkewX(-0.25); - canvas.drawText(seg.text, cx, y, p, f); - cx += runWidth(f, seg.text); - f.delete(); } p.delete(); }, measureText(t, sz) { return this.measureTextStyled(t, sz, 400, false); }, + measureTextFamilyStyled(t, family, sz, weight, italic) { + const importedTf = familyTypeface(family); + const covSegs = importedTf ? importedCoverageSegments(importedTf, sz, t) : null; + if (!covSegs || (covSegs.length === 1 && !covSegs[0].imported)) { + // No imported family (or it covers nothing): the family-blind + // script-segmented measure — the SAME path drawText falls to. + return this.measureTextStyled(t, sz, weight, italic); + } + let w = 0; + for (const seg of covSegs) { + if (seg.imported) { + const f = importedFamilyFont(importedTf, sz, italic); + w += runWidth(f, seg.text); + f.delete(); + } else { + w += this.measureTextStyled(seg.text, sz, weight, italic); + } + } + return w; + }, measureTextStyled(t, sz, weight, italic) { let w = 0; for (const seg of segments(t)) { @@ -452,6 +575,39 @@ export async function opCkInit(canvasId) { coverageCache.clear(); return true; }, + registerImportedFont(family, bytes) { + const key = primaryFamilyKey(family); + if (!key) return false; + const tf = CK.Typeface.MakeFreeTypeFaceFromData(copyBytes(bytes)); + if (!tf) return false; + // Replace any prior face under the same key, freeing its wasm-heap tf. + const prev = importedTypefaces.get(key); + if (prev && prev.tf && prev.tf.delete) prev.tf.delete(); + importedTypefaces.set(key, { tf, family: String(family || '') }); + coverageCache.clear(); + return true; + }, + // (Fresh browser imports parse the family name in Rust via `ttf-parser` — + // the vendored CanvasKit build exposes no family-name API — then register + // through `registerImportedFont` above with the known family.) + // The display names of every registered imported family, so the Rust snapshot + // (which doesn't own the registry on web) can mirror the picker's Imported + // group after every add / remove and at mount. + importedFamilyList() { + const out = []; + for (const entry of importedTypefaces.values()) { + if (entry && entry.family) out.push(entry.family); + } + return out; + }, + removeImportedFont(family) { + const key = primaryFamilyKey(family); + if (!key) return; + const entry = importedTypefaces.get(key); + if (!entry) return; + if (entry.tf && entry.tf.delete) entry.tf.delete(); + importedTypefaces.delete(key); + }, clipRect(x, y, w, h) { canvas.clipRect(CK.LTRBRect(x, y, x + w, y + h), CK.ClipOp.Intersect, true); }, clipRoundRect(x, y, w, h, rad) { canvas.clipRRect(CK.RRectXY(CK.LTRBRect(x, y, x + w, y + h), rad, rad), CK.ClipOp.Intersect, true); }, diff --git a/crates/op-host-web/src/repaint_ctx.rs b/crates/op-host-web/src/repaint_ctx.rs index 1118318c4..a7d60ff23 100644 --- a/crates/op-host-web/src/repaint_ctx.rs +++ b/crates/op-host-web/src/repaint_ctx.rs @@ -24,6 +24,21 @@ pub(crate) trait RepaintContext { /// face was registered. Backends that cannot accept runtime font bytes may /// return `false`. fn register_system_font(&mut self, family: &str, bytes: &[u8]) -> bool; + /// Register a user-imported font face so canvas text can shape against it + /// by family name. Returns `true` when the face was registered; backends + /// that cannot accept runtime font bytes may return `false`. + fn register_imported_font(&mut self, family: &str, bytes: &[u8]) -> bool; + /// Register a user-imported font whose family is unknown (a fresh file + /// import). The backend parses the bytes, extracts the family display name, + /// and returns it (`None` on parse failure / no family name). Used only by + /// the import file-picker path; `register_imported_font` re-registers a + /// known family from IndexedDB. + fn register_imported_font_from_bytes(&mut self, bytes: &[u8]) -> Option; + /// Display names of every registered imported family — the web snapshot + /// source (Rust doesn't own the registry on web). + fn imported_family_list(&self) -> Vec; + /// Drop a previously imported font face by family name (no-op if absent). + fn remove_imported_font(&mut self, family: &str); /// Re-paint through the owning backend. Returns the present error if the /// backend's present step failed (the CanvasKit path is infallible and /// always returns `Ok`). diff --git a/crates/op-host-web/src/web_fonts.rs b/crates/op-host-web/src/web_fonts.rs index ad090c9dd..5e514a5d1 100644 --- a/crates/op-host-web/src/web_fonts.rs +++ b/crates/op-host-web/src/web_fonts.rs @@ -11,6 +11,7 @@ use std::cell::{Cell, RefCell}; use std::collections::{HashMap, HashSet}; use std::rc::Rc; +use std::sync::Arc; use crate::repaint_ctx::RepaintContext; use jian_ops_schema::node::{PenNode, TextContent}; @@ -113,6 +114,8 @@ pub(crate) fn drain_font_requests(inner: &InnerRc(inner: &InnerRc) -> bool { @@ -390,6 +393,181 @@ fn is_bundled_family(family: &str) -> bool { .any(|bundled| bundled.eq_ignore_ascii_case(family)) } +// --------------------------------------------------------------------- +// User font import / removal (Phase 4) — the web counterpart of the +// desktop `font_import_host`. `ImportFont` / `RemoveImportedFont` raise +// pending flags (mirrors native); this drain performs the browser IO: +// hidden file input → FileReader → CanvasKit family registry → IndexedDB. +// --------------------------------------------------------------------- + +/// File-picker accept filter for imported fonts (matches the desktop's +/// `.ttf` / `.otf` rfd filter). +const FONT_ACCEPT: &str = ".ttf,.otf,font/ttf,font/otf"; + +/// Reject fonts larger than this before/after read — same 16 MiB ceiling +/// the desktop `FontStore` enforces (a CJK / variable font can reach a few +/// MiB; 16 MiB is a generous cap that still rejects a mis-picked huge file). +const MAX_FONT_BYTES: usize = 16 * 1024 * 1024; + +/// Re-register every persisted imported font from IndexedDB at mount, then +/// refresh the picker snapshot + repaint. Per spec §C5: if the async read +/// resolves before the first paint completes the family is registered in time; +/// otherwise the repaint here re-paints any on-screen text with the newly +/// available typeface (a one-frame fallback flash is acceptable). +pub(crate) fn load_imported_fonts_at_mount(inner: &InnerRc) { + let inner = inner.clone(); + crate::font_store_idb::load_all(Box::new(move |fonts| { + if fonts.is_empty() { + return; + } + { + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; + // Don't clobber a font the user imported/changed this session before + // the async IndexedDB read resolved: only register persisted + // families not already present. (A family the user REMOVED in that + // same pre-load window can still be re-added — a rare, self- + // correcting race the user resolves by removing it again.) + let present: std::collections::HashSet = b + .imported_family_list() + .iter() + .map(|f| crate::font_store_idb::primary_key(f)) + .collect(); + for (family, bytes) in &fonts { + let key = crate::font_store_idb::primary_key(family); + if key.is_empty() || present.contains(&key) { + continue; + } + b.register_imported_font(family, bytes); + } + } + refresh_imported_font_snapshot(&inner); + })); +} + +/// Mirror the CanvasKit imported-font registry into the editor-state snapshot +/// the picker reads (`imported_font_families`), mark dirty, and repaint. +fn refresh_imported_font_snapshot(inner: &InnerRc) { + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; + let families = b.imported_family_list(); + b.host_mut() + .editor_state_mut() + .editor_ui + .imported_font_families = Arc::new(families); + b.host_mut().mark_editor_state_dirty(); + let _ = b.repaint(); +} + +/// Drain a pending `ImportFont`: open the hidden file input, read the chosen +/// font's bytes, register it (extracting its family), persist to IndexedDB, and +/// refresh the picker snapshot. All steps are non-fatal — a cancel / oversize / +/// parse failure logs and leaves the editor untouched. +fn drain_font_import_request(inner: &InnerRc) { + let requested = { + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; + std::mem::take( + &mut b + .host_mut() + .editor_state_mut() + .editor_ui + .pending_font_import, + ) + }; + if !requested { + return; + } + let inner = inner.clone(); + crate::dom_io::open_file_picker( + FONT_ACCEPT, + Box::new(move |file| { + // Reject an oversize file by `File.size` before reading it into + // memory (the post-read cap is the backstop). + if file.size() as usize > MAX_FONT_BYTES { + console_warn_font(&format!( + "import rejected: font is too large ({:.1} MiB; max {} MiB)", + file.size() / (1024.0 * 1024.0), + MAX_FONT_BYTES / (1024 * 1024) + )); + return; + } + let inner = inner.clone(); + crate::dom_io::read_file( + file, + crate::dom_io::ReadMode::Bytes, + Box::new(move |value| { + let Some(bytes) = crate::dom_io::js_bytes(&value) else { + console_warn_font("import failed: could not read the font file"); + return; + }; + if bytes.len() > MAX_FONT_BYTES { + console_warn_font("import rejected: font exceeds the 16 MiB cap"); + return; + } + import_font_bytes(&inner, bytes); + }), + ); + }), + ); +} + +/// Register imported font bytes: extract the family via CanvasKit, persist to +/// IndexedDB, and refresh the snapshot. A parse failure (no family name) logs +/// and does nothing else. +fn import_font_bytes(inner: &InnerRc, bytes: Vec) { + let family = { + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; + b.register_imported_font_from_bytes(&bytes) + }; + let Some(family) = family else { + console_warn_font("import rejected: could not parse the font (no family name)"); + return; + }; + // Persist (non-blocking) then reflect the new family in the picker. + crate::font_store_idb::put_font( + &crate::font_store_idb::primary_key(&family), + &family, + &bytes, + ); + refresh_imported_font_snapshot(inner); +} + +/// Drain a pending `RemoveImportedFont`: drop the family from the CanvasKit +/// registry + IndexedDB, then refresh the snapshot. +fn drain_font_remove_request(inner: &InnerRc) { + let family = { + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; + b.host_mut() + .editor_state_mut() + .editor_ui + .pending_font_remove + .take() + }; + let Some(family) = family else { + return; + }; + { + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; + b.remove_imported_font(&family); + } + crate::font_store_idb::delete_font(&crate::font_store_idb::primary_key(&family)); + refresh_imported_font_snapshot(inner); +} + +fn console_warn_font(msg: &str) { + web_sys::console::warn_1(&JsValue::from_str(&format!("[font-import] {msg}"))); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/op-host-web/src/widget_host/io_tests.rs b/crates/op-host-web/src/widget_host/io_tests.rs index 78eaab6de..b32916dc9 100644 --- a/crates/op-host-web/src/widget_host/io_tests.rs +++ b/crates/op-host-web/src/widget_host/io_tests.rs @@ -42,6 +42,20 @@ impl RepaintContext for TestRepaintContext { false } + fn register_imported_font(&mut self, _family: &str, _bytes: &[u8]) -> bool { + false + } + + fn register_imported_font_from_bytes(&mut self, _bytes: &[u8]) -> Option { + None + } + + fn imported_family_list(&self) -> Vec { + Vec::new() + } + + fn remove_imported_font(&mut self, _family: &str) {} + fn repaint(&mut self) -> Result<(), JsValue> { self.repaint_count += 1; Ok(()) diff --git a/crates/op-host-web/src/widget_host/property_dispatch.rs b/crates/op-host-web/src/widget_host/property_dispatch.rs index ed48570fa..3990a41a4 100644 --- a/crates/op-host-web/src/widget_host/property_dispatch.rs +++ b/crates/op-host-web/src/widget_host/property_dispatch.rs @@ -437,10 +437,32 @@ impl WidgetHost { } self.editor_state.editor_ui.close_font_picker(); } - // Font import / removal are desktop-only until Phase 4 wires - // the browser file input + WASM registry path. No-op on web - // (the picker keeps the empty Imported group + inert rows). - A::ImportFont | A::RemoveImportedFont(_) => {} + A::ImportFont => { + // Raise a pending request; `web_fonts::drain_font_requests` + // (post-press / mount) opens the hidden file input, reads the + // chosen `.ttf` / `.otf`, registers it through the CanvasKit + // family registry, and persists it to IndexedDB. Keep the + // picker open so the new family appears once it lands. + self.editor_state.editor_ui.pending_font_import = true; + } + A::RemoveImportedFont(index) => { + // Resolve the family against the SAME entries list the picker + // painted / hit-tested, then hand it to the drain to drop from + // the CanvasKit registry + IndexedDB (mirrors the native arm). + let family = { + let ui = &self.editor_state.editor_ui; + op_editor_ui::widgets::property_panel_typography::font_picker_entries( + &ui.imported_font_families, + &ui.system_font_families, + &ui.font_picker_search, + ) + .get(index) + .map(|e| e.family.to_string()) + }; + if let Some(family) = family { + self.editor_state.editor_ui.pending_font_remove = Some(family); + } + } A::ToggleFontWeightPicker => { let ui = &mut self.editor_state.editor_ui; ui.font_weight_picker_open = !ui.font_weight_picker_open;