openpencil/desktop/src/lib.rs
Danila Poyarkov 1e35c0d3a8 Preserve fig-kiwi version on roundtrip
Parse the version field from the fig-kiwi header during .fig import
and store it on SceneGraph.figKiwiVersion. Use the original version
when re-exporting. New files default to 101 (current Figma standard).

Previously hardcoded to 106, which Figma rejected on import.

Fixes #153
2026-03-25 06:02:24 +03:00

502 lines
18 KiB
Rust

use tauri::{
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder},
Emitter, Manager,
};
use font_kit::source::SystemSource;
use serde::Serialize;
use std::sync::OnceLock;
#[derive(Serialize, Clone)]
struct FontFamily {
family: String,
styles: Vec<String>,
}
static FONT_CACHE: OnceLock<Vec<FontFamily>> = OnceLock::new();
fn enumerate_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]
async fn list_system_fonts() -> Vec<FontFamily> {
if let Some(cached) = FONT_CACHE.get() {
return cached.clone();
}
let families = tauri::async_runtime::spawn_blocking(enumerate_system_fonts)
.await
.unwrap_or_default();
let _ = FONT_CACHE.set(families.clone());
families
}
fn load_system_font_blocking(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]
async fn load_system_font(family: String, style: String) -> Result<Vec<u8>, String> {
tauri::async_runtime::spawn_blocking(move || load_system_font_blocking(family, style))
.await
.map_err(|e| format!("Font load task failed: {e}"))?
}
#[derive(serde::Deserialize)]
struct ImageEntry {
name: String,
data: Vec<u8>,
}
#[tauri::command]
fn build_fig_file(
schema_deflated: Vec<u8>,
kiwi_data: Vec<u8>,
thumbnail_png: Vec<u8>,
meta_json: String,
images: Option<Vec<ImageEntry>>,
fig_kiwi_version: Option<u32>,
) -> Result<Vec<u8>, String> {
use std::io::{Cursor, Write};
// Zstd-compress kiwi data with content size in frame header
let mut encoder = zstd::Encoder::new(Vec::new(), 3).map_err(|e| e.to_string())?;
encoder
.include_contentsize(true)
.map_err(|e| e.to_string())?;
encoder
.set_pledged_src_size(Some(kiwi_data.len() as u64))
.map_err(|e| e.to_string())?;
encoder.write_all(&kiwi_data).map_err(|e| e.to_string())?;
let zstd_data = encoder.finish().map_err(|e| e.to_string())?;
// Build fig-kiwi container
let version: u32 = fig_kiwi_version.unwrap_or(101);
let fig_kiwi_len = 8 + 4 + 4 + schema_deflated.len() + 4 + zstd_data.len();
let mut fig_kiwi = Vec::with_capacity(fig_kiwi_len);
fig_kiwi.extend_from_slice(b"fig-kiwi");
fig_kiwi.extend_from_slice(&version.to_le_bytes());
fig_kiwi.extend_from_slice(&(schema_deflated.len() as u32).to_le_bytes());
fig_kiwi.extend_from_slice(&schema_deflated);
fig_kiwi.extend_from_slice(&(zstd_data.len() as u32).to_le_bytes());
fig_kiwi.extend_from_slice(&zstd_data);
// Deflate-compress the schema for verification it's already deflated
// (schema_deflated is already deflated, we just pass it through)
// Build ZIP with canvas.fig + thumbnail.png + meta.json (all STORED)
let buf = Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(buf);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
zip.start_file("canvas.fig", options)
.map_err(|e| e.to_string())?;
zip.write_all(&fig_kiwi).map_err(|e| e.to_string())?;
zip.start_file("thumbnail.png", options)
.map_err(|e| e.to_string())?;
zip.write_all(&thumbnail_png).map_err(|e| e.to_string())?;
zip.start_file("meta.json", options)
.map_err(|e| e.to_string())?;
zip.write_all(meta_json.as_bytes())
.map_err(|e| e.to_string())?;
if let Some(image_entries) = images {
for entry in image_entries {
zip.start_file(&entry.name, options)
.map_err(|e| e.to_string())?;
zip.write_all(&entry.data)
.map_err(|e| e.to_string())?;
}
}
let result = zip.finish().map_err(|e| e.to_string())?;
Ok(result.into_inner())
}
fn show_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.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())
.plugin(tauri_plugin_shell::init())
.on_menu_event(|app, event| {
#[cfg(debug_assertions)]
if event.id().0.as_str() == "dev-tools" {
if let Some(window) = app.get_webview_window("main") {
if window.is_devtools_open() {
window.close_devtools();
} else {
window.open_devtools();
}
}
return;
}
let _ = app.emit("menu-event", event.id().0.as_str());
})
.setup(|app| {
#[cfg(target_os = "macos")]
let app_menu = SubmenuBuilder::new(app, "OpenPencil")
.item(&PredefinedMenuItem::about(
app,
Some("About OpenPencil"),
None,
)?)
.separator()
.item(&PredefinedMenuItem::services(app, None)?)
.separator()
.item(&PredefinedMenuItem::hide(app, None)?)
.item(&PredefinedMenuItem::hide_others(app, None)?)
.item(&PredefinedMenuItem::show_all(app, None)?)
.separator()
.item(&PredefinedMenuItem::quit(app, None)?)
.build()?;
#[allow(unused_mut)]
let mut file_menu_builder = SubmenuBuilder::new(app, "File")
.item(
&MenuItemBuilder::new("New")
.id("new")
.accelerator("CmdOrCtrl+N")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Open…")
.id("open")
.accelerator("CmdOrCtrl+O")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Save")
.id("save")
.accelerator("CmdOrCtrl+S")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Save As…")
.id("save-as")
.accelerator("CmdOrCtrl+Shift+S")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Export…")
.id("export")
.accelerator("CmdOrCtrl+Shift+E")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Close Tab")
.id("close")
.accelerator("CmdOrCtrl+W")
.build(app)?,
);
#[cfg(not(target_os = "macos"))]
{
file_menu_builder = file_menu_builder
.separator()
.item(&PredefinedMenuItem::quit(app, None)?);
}
let file_menu = file_menu_builder.build()?;
let edit_menu = SubmenuBuilder::new(app, "Edit")
.item(&PredefinedMenuItem::undo(app, None)?)
.item(&PredefinedMenuItem::redo(app, None)?)
.separator()
.item(&PredefinedMenuItem::cut(app, None)?)
.item(&PredefinedMenuItem::copy(app, None)?)
.item(&PredefinedMenuItem::paste(app, None)?)
.item(
&MenuItemBuilder::new("Paste in Place")
.id("paste-in-place")
.accelerator("CmdOrCtrl+Shift+V")
.build(app)?,
)
.item(&PredefinedMenuItem::select_all(app, None)?)
.separator()
.item(
&MenuItemBuilder::new("Duplicate")
.id("duplicate")
.accelerator("CmdOrCtrl+D")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Delete")
.id("delete")
.accelerator("Backspace")
.build(app)?,
)
.build()?;
let view_menu = SubmenuBuilder::new(app, "View")
.item(
&MenuItemBuilder::new("Zoom In")
.id("zoom-in")
.accelerator("CmdOrCtrl+=")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Zoom Out")
.id("zoom-out")
.accelerator("CmdOrCtrl+-")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Zoom to Fit")
.id("zoom-fit")
.accelerator("CmdOrCtrl+1")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Zoom to 100%")
.id("zoom-100")
.accelerator("CmdOrCtrl+0")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Toggle Rulers")
.id("toggle-rulers")
.accelerator("Shift+R")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Toggle Grid")
.id("toggle-grid")
.accelerator("CmdOrCtrl+'")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Toggle UI")
.id("toggle-ui")
.accelerator("CmdOrCtrl+\\")
.build(app)?,
)
.item(&PredefinedMenuItem::fullscreen(app, None)?)
.separator()
.item(
&MenuItemBuilder::new("Developer Tools")
.id("dev-tools")
.accelerator("CmdOrCtrl+Alt+I")
.build(app)?,
)
.build()?;
let object_menu = SubmenuBuilder::new(app, "Object")
.item(
&MenuItemBuilder::new("Group Selection")
.id("group")
.accelerator("CmdOrCtrl+G")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Ungroup Selection")
.id("ungroup")
.accelerator("CmdOrCtrl+Shift+G")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Frame Selection")
.id("frame-selection")
.accelerator("CmdOrCtrl+Alt+G")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Bring to Front")
.id("bring-front")
.accelerator("CmdOrCtrl+]")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Send to Back")
.id("send-back")
.accelerator("CmdOrCtrl+[")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Flip Horizontal")
.id("flip-h")
.accelerator("Shift+H")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Flip Vertical")
.id("flip-v")
.accelerator("Shift+V")
.build(app)?,
)
.build()?;
let window_menu = SubmenuBuilder::new(app, "Window")
.item(&PredefinedMenuItem::minimize(app, None)?)
.item(&PredefinedMenuItem::maximize(app, None)?)
.separator()
.item(&PredefinedMenuItem::close_window(app, None)?)
.build()?;
#[allow(unused_mut)]
let mut help_menu_builder = SubmenuBuilder::new(app, "Help").item(
&MenuItemBuilder::new("Keyboard Shortcuts")
.id("shortcuts")
.accelerator("CmdOrCtrl+/")
.build(app)?,
);
#[cfg(not(target_os = "macos"))]
{
help_menu_builder = help_menu_builder
.separator()
.item(&PredefinedMenuItem::about(
app,
Some("About OpenPencil"),
None,
)?);
}
let help_menu = help_menu_builder.build()?;
let mut builder = MenuBuilder::new(app);
#[cfg(target_os = "macos")]
{
builder = builder.item(&app_menu);
}
let menu = builder
.items(&[
&file_menu,
&edit_menu,
&view_menu,
&object_menu,
&window_menu,
&help_menu,
])
.build()?;
app.set_menu(menu)?;
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app, event| {
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Reopen {
has_visible_windows,
..
} = event
{
if !has_visible_windows {
show_main_window(app);
}
}
});
}