From 5ccae709a0e2f530d141f44197f62ea111c75d20 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 1 Mar 2026 02:56:12 +0300 Subject: [PATCH] Add system font enumeration via font-kit Rust: list_system_fonts and load_system_font Tauri commands using font-kit crate for cross-platform system font access. TS: font shim detects Tauri runtime and uses invoke() for font listing/loading instead of queryLocalFonts (unavailable in WKWebView). --- desktop/Cargo.lock | 159 +++++++++++++++++++++++++++++++++++-- desktop/Cargo.toml | 1 + desktop/src/lib.rs | 110 ++++++++++++++++++++++++- packages/core/src/index.ts | 2 +- src/engine/fonts.ts | 74 ++++++++++++++++- 5 files changed, 338 insertions(+), 8 deletions(-) diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index b2646376f..0f74301d9 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -487,6 +487,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -503,6 +513,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + [[package]] name = "core-graphics" version = "0.24.0" @@ -510,12 +533,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.11.0", - "core-foundation", - "core-graphics-types", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", "foreign-types", "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + [[package]] name = "core-graphics-types" version = "0.2.0" @@ -523,7 +557,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types", "libc", ] @@ -736,6 +782,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -789,6 +844,18 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -931,6 +998,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + [[package]] name = "fnv" version = "1.0.7" @@ -943,6 +1016,31 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "font-kit" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" +dependencies = [ + "bitflags 2.11.0", + "byteorder", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "core-text", + "dirs", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -979,6 +1077,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "futf" version = "0.1.5" @@ -2359,6 +2468,7 @@ dependencies = [ name = "open_pencil" version = "0.1.0" dependencies = [ + "font-kit", "serde", "serde_json", "tauri", @@ -2446,6 +2556,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf9027960355bf3afff9841918474a81a5f972ac6d226d518060bba758b5ad57" +dependencies = [ + "rustc_version", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3507,8 +3636,8 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.11.0", "block2", - "core-foundation", - "core-graphics", + "core-foundation 0.10.1", + "core-graphics 0.24.0", "crossbeam-channel", "dispatch", "dlopen2", @@ -5043,6 +5172,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5203,6 +5341,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 755d9d085..54b881a6e 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -27,4 +27,5 @@ tauri-plugin-dialog = "2.6.0" tauri-plugin-fs = "2.4.5" zstd = "0.13" zip = { version = "2", default-features = false } +font-kit = "0.14.3" diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index b5270ee71..f8fe33a4b 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -3,6 +3,114 @@ use tauri::{ Emitter, Manager, }; +use font_kit::source::SystemSource; +use serde::Serialize; + +#[derive(Serialize, Clone)] +struct FontFamily { + family: String, + styles: Vec, +} + +#[tauri::command] +fn list_system_fonts() -> Vec { + let source = SystemSource::new(); + let mut families: Vec = Vec::new(); + + if let Ok(family_names) = source.all_families() { + for name in &family_names { + if let Ok(handle) = source.select_family_by_name(name) { + let styles: Vec = handle + .fonts() + .iter() + .filter_map(|f| { + f.load().ok().map(|font| { + let props = font.properties(); + let mut style = match props.weight.0 as i32 { + 0..=150 => "Thin", + 151..=250 => "ExtraLight", + 251..=350 => "Light", + 351..=450 => "Regular", + 451..=550 => "Medium", + 551..=650 => "SemiBold", + 651..=750 => "Bold", + 751..=850 => "ExtraBold", + _ => "Black", + } + .to_string(); + if props.style == font_kit::properties::Style::Italic { + style.push_str(" Italic"); + } + style + }) + }) + .collect(); + + if !styles.is_empty() { + families.push(FontFamily { + family: name.clone(), + styles, + }); + } + } + } + } + + families.sort_by(|a, b| a.family.cmp(&b.family)); + families +} + +#[tauri::command] +fn load_system_font(family: String, style: String) -> Result, String> { + let source = SystemSource::new(); + let family_handle = source + .select_family_by_name(&family) + .map_err(|e| format!("Font family not found: {e}"))?; + + let is_italic = style.contains("Italic"); + let weight_str = style.replace(" Italic", ""); + let weight = match weight_str.as_str() { + "Thin" => font_kit::properties::Weight::THIN, + "ExtraLight" => font_kit::properties::Weight::EXTRA_LIGHT, + "Light" => font_kit::properties::Weight::LIGHT, + "Regular" | "" => font_kit::properties::Weight::NORMAL, + "Medium" => font_kit::properties::Weight::MEDIUM, + "SemiBold" => font_kit::properties::Weight::SEMIBOLD, + "Bold" => font_kit::properties::Weight::BOLD, + "ExtraBold" => font_kit::properties::Weight::EXTRA_BOLD, + "Black" => font_kit::properties::Weight::BLACK, + _ => font_kit::properties::Weight::NORMAL, + }; + let style_prop = if is_italic { + font_kit::properties::Style::Italic + } else { + font_kit::properties::Style::Normal + }; + + for handle in family_handle.fonts() { + if let Ok(font) = handle.load() { + let props = font.properties(); + let w_diff = (props.weight.0 - weight.0).abs(); + if w_diff < 50.0 && props.style == style_prop { + if let Some(data) = font.copy_font_data() { + return Ok((*data).clone()); + } + } + } + } + + // Fallback: return first font in family + if let Some(handle) = family_handle.fonts().first() { + if let Ok(font) = handle.load() { + if let Some(data) = font.copy_font_data() { + return Ok((*data).clone()); + } + } + } + + Err(format!("Could not load font {family} {style}")) +} + #[tauri::command] fn build_fig_file( schema_deflated: Vec, @@ -63,7 +171,7 @@ fn build_fig_file( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![build_fig_file]) + .invoke_handler(tauri::generate_handler![build_fig_file, list_system_fonts, load_system_font]) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d75f46187..845635b7a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,7 +49,7 @@ export { export { SkiaRenderer, type RenderOverlays } from './renderer' export { computeLayout, computeAllLayouts } from './layout' export { getCanvasKit, type CanvasKitOptions } from './canvaskit' -export { loadFont, listFamilies } from './fonts' +export { loadFont, listFamilies, initFontService, getFontProvider, ensureNodeFont } from './fonts' export { parseColor, colorToHex, colorToHexRaw, colorToRgba255 } from './color' export { vectorNetworkToPath, decodeVectorNetworkBlob, encodeVectorNetworkBlob, computeVectorBounds } from './vector' export { computeSelectionBounds, computeSnap, type SnapGuide } from './snap' diff --git a/src/engine/fonts.ts b/src/engine/fonts.ts index 618810238..3204df388 100644 --- a/src/engine/fonts.ts +++ b/src/engine/fonts.ts @@ -1 +1,73 @@ -export { loadFont, listFamilies } from '@open-pencil/core' +export { initFontService, getFontProvider, ensureNodeFont } from '@open-pencil/core' + +import { loadFont as loadFontCore, getFontProvider } from '@open-pencil/core' + +interface TauriFontFamily { + family: string + styles: string[] +} + +let tauriFontsCache: TauriFontFamily[] | null = null + +function isTauri(): boolean { + return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window +} + +async function getTauriFonts(): Promise { + if (tauriFontsCache) return tauriFontsCache + try { + const { invoke } = await import('@tauri-apps/api/core') + tauriFontsCache = await invoke('list_system_fonts') + return tauriFontsCache + } catch { + return [] + } +} + +export async function listFamilies(): Promise { + if (isTauri()) { + const fonts = await getTauriFonts() + return fonts.map((f) => f.family) + } + + const { listFamilies: coreList } = await import('@open-pencil/core') + return coreList() +} + +export async function loadFont(family: string, style = 'Regular'): Promise { + if (isTauri()) { + try { + const { invoke } = await import('@tauri-apps/api/core') + const data = await invoke('load_system_font', { family, style }) + const buffer = new Uint8Array(data).buffer + + const provider = getFontProvider() + if (provider) provider.registerFont(buffer, family) + + const weight = styleToWeight(style) + const italic = style.toLowerCase().includes('italic') ? 'italic' : 'normal' + const face = new FontFace(family, buffer, { weight: String(weight), style: italic }) + await face.load() + document.fonts.add(face) + + return buffer + } catch { + return loadFontCore(family, style) + } + } + + return loadFontCore(family, style) +} + +function styleToWeight(style: string): number { + const s = style.toLowerCase() + if (s.includes('thin') || s.includes('hairline')) return 100 + if (s.includes('extralight') || s.includes('ultralight')) return 200 + if (s.includes('light')) return 300 + if (s.includes('medium')) return 500 + if (s.includes('semibold') || s.includes('demibold')) return 600 + if (s.includes('extrabold') || s.includes('ultrabold')) return 800 + if (s.includes('black') || s.includes('heavy')) return 900 + if (s.includes('bold')) return 700 + return 400 +}