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).
This commit is contained in:
Danila Poyarkov 2026-03-01 02:56:12 +03:00
parent f7d1d59dc1
commit 5ccae709a0
5 changed files with 338 additions and 8 deletions

159
desktop/Cargo.lock generated
View file

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

View file

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

View file

@ -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<String>,
}
#[tauri::command]
fn list_system_fonts() -> Vec<FontFamily> {
let source = SystemSource::new();
let mut families: Vec<FontFamily> = 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<String> = 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<Vec<u8>, 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<u8>,
@ -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())

View file

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

View file

@ -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<TauriFontFamily[]> {
if (tauriFontsCache) return tauriFontsCache
try {
const { invoke } = await import('@tauri-apps/api/core')
tauriFontsCache = await invoke<TauriFontFamily[]>('list_system_fonts')
return tauriFontsCache
} catch {
return []
}
}
export async function listFamilies(): Promise<string[]> {
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<ArrayBuffer | null> {
if (isTauri()) {
try {
const { invoke } = await import('@tauri-apps/api/core')
const data = await invoke<number[]>('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
}