feat(web): user font import — family-aware CanvasKit + import UI + IndexedDB (phases 3-4)
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.
This commit is contained in:
parent
00c6938c26
commit
a218115010
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3342,6 +3342,7 @@ dependencies = [
|
|||
"op-pen-loader",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ttf-parser",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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<String>;
|
||||
/// 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<String> {
|
||||
// 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<String> {
|
||||
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<String> {
|
||||
self.backend.register_imported_font_from_bytes(bytes)
|
||||
}
|
||||
fn imported_family_list(&self) -> Vec<String> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -1011,7 +1011,7 @@ pub(crate) fn read_file(file: web_sys::File, mode: ReadMode, on_done: Box<dyn Fn
|
|||
}
|
||||
|
||||
/// Extract a byte vec from a `FileReader.result` ArrayBuffer.
|
||||
fn js_bytes(value: &JsValue) -> Option<Vec<u8>> {
|
||||
pub(crate) fn js_bytes(value: &JsValue) -> Option<Vec<u8>> {
|
||||
let buf = value.clone().dyn_into::<js_sys::ArrayBuffer>().ok()?;
|
||||
Some(js_sys::Uint8Array::new(&buf).to_vec())
|
||||
}
|
||||
|
|
|
|||
71
crates/op-host-web/src/font_meta.rs
Normal file
71
crates/op-host-web/src/font_meta.rs
Normal file
|
|
@ -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<String> {
|
||||
// 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<String> = None;
|
||||
let mut typographic: Option<String> = 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);
|
||||
}
|
||||
}
|
||||
278
crates/op-host-web/src/font_store_idb.rs
Normal file
278
crates/op-host-web/src/font_store_idb.rs
Normal file
|
|
@ -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<dyn FnOnce(IdbDatabase)>) {
|
||||
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::<dyn FnMut()>::once(move || {
|
||||
if let Ok(db) = upgrade_req.result().and_then(|v| {
|
||||
v.dyn_into::<IdbDatabase>()
|
||||
.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::<dyn FnMut()>::once(move || {
|
||||
let db = success_req
|
||||
.result()
|
||||
.ok()
|
||||
.and_then(|v| v.dyn_into::<IdbDatabase>().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::<dyn FnMut()>::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<IdbObjectStore> {
|
||||
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::<dyn FnMut(web_sys::Event)>::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<dyn FnOnce(Vec<(String, Vec<u8>)>)>) {
|
||||
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::<dyn FnMut()>::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::<dyn FnMut()>::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<u8>)> {
|
||||
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::<js_sys::Uint8Array>().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(""), "");
|
||||
}
|
||||
}
|
||||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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); },
|
||||
|
|
|
|||
|
|
@ -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<String>;
|
||||
/// 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<String>;
|
||||
/// 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`).
|
||||
|
|
|
|||
|
|
@ -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<C: RepaintContext + 'static>(inner: &InnerRc<C
|
|||
start_system_font_query(inner);
|
||||
}
|
||||
load_used_system_fonts(inner);
|
||||
drain_font_import_request(inner);
|
||||
drain_font_remove_request(inner);
|
||||
}
|
||||
|
||||
fn should_query_system_fonts<C: RepaintContext + 'static>(inner: &InnerRc<C>) -> 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<C: RepaintContext + 'static>(inner: &InnerRc<C>) {
|
||||
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<String> = 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<C: RepaintContext + 'static>(inner: &InnerRc<C>) {
|
||||
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<C: RepaintContext + 'static>(inner: &InnerRc<C>) {
|
||||
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<C: RepaintContext + 'static>(inner: &InnerRc<C>, bytes: Vec<u8>) {
|
||||
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<C: RepaintContext + 'static>(inner: &InnerRc<C>) {
|
||||
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::*;
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn imported_family_list(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn remove_imported_font(&mut self, _family: &str) {}
|
||||
|
||||
fn repaint(&mut self) -> Result<(), JsValue> {
|
||||
self.repaint_count += 1;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue