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:
Kayshen-X 2026-07-05 14:21:51 +08:00
parent 00c6938c26
commit a218115010
13 changed files with 869 additions and 24 deletions

1
Cargo.lock generated
View file

@ -3342,6 +3342,7 @@ dependencies = [
"op-pen-loader",
"serde",
"serde_json",
"ttf-parser",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",

View file

@ -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;

View file

@ -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`.

View file

@ -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,

View file

@ -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())
}

View 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);
}
}

View 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(""), "");
}
}

View file

@ -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::*;

View file

@ -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); },

View file

@ -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`).

View file

@ -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::*;

View file

@ -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(())

View file

@ -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;