From edf6da6738db2c779cc8b535ddf6044e0e5b545f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 28 Feb 2026 16:16:49 +0300 Subject: [PATCH] Build .fig ZIP in Rust instead of fflate fflate's zipSync produces ZIP files that Figma rejects. Move the entire fig-kiwi container + Zstd compression + ZIP packaging into a single Rust command (build_fig_file). Zstd now includes content size via set_pledged_src_size. Web fallback still uses fflate/deflate. --- desktop/Cargo.lock | 36 ++++++++++++++++++++++++++ desktop/Cargo.toml | 1 + desktop/src/lib.rs | 56 ++++++++++++++++++++++++++++++++++++---- src/engine/fig-export.ts | 47 +++++++++++++++++---------------- 4 files changed, 111 insertions(+), 29 deletions(-) diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 3c860aedf..3d646ecf8 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -643,6 +652,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -2346,6 +2366,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-opener", + "zip", "zstd", ] @@ -5340,6 +5361,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "indexmap 2.13.0", + "memchr", + "thiserror 2.0.18", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index a7a7abf79..fb7ee4bcd 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -25,4 +25,5 @@ serde_json = "1" tauri-plugin-dialog = "2.6.0" tauri-plugin-fs = "2.4.5" zstd = "0.13" +zip = { version = "2", default-features = false } diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index b8abeb3b9..6d1a037dd 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -4,20 +4,66 @@ use tauri::{ }; #[tauri::command] -fn zstd_compress(data: Vec) -> Result, String> { - use std::io::Write; +fn build_fig_file( + schema_deflated: Vec, + kiwi_data: Vec, + thumbnail_png: Vec, + meta_json: String, +) -> Result, 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.write_all(&data).map_err(|e| e.to_string())?; - encoder.finish().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 = 106; + 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())?; + + let result = zip.finish().map_err(|e| e.to_string())?; + Ok(result.into_inner()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![zstd_compress]) + .invoke_handler(tauri::generate_handler![build_fig_file]) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) diff --git a/src/engine/fig-export.ts b/src/engine/fig-export.ts index a0540e913..d5b43d2f6 100644 --- a/src/engine/fig-export.ts +++ b/src/engine/fig-export.ts @@ -7,6 +7,10 @@ import type { CanvasKit } from 'canvaskit-wasm' import type { SceneGraph, SceneNode, Color } from './scene-graph' import type { SkiaRenderer } from './renderer' +const THUMBNAIL_1X1 = Uint8Array.from(atob( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' +), (c) => c.charCodeAt(0)) + interface KiwiNodeChange { guid: { sessionID: number; localID: number } parentIndex?: { guid: { sessionID: number; localID: number }; position: string } @@ -194,24 +198,13 @@ function sceneNodeToKiwi( return result } -async function compressData(data: Uint8Array): Promise { - if (IS_TAURI) { - const { invoke } = await import('@tauri-apps/api/core') - return new Uint8Array(await invoke('zstd_compress', { data: Array.from(data) })) - } - return deflateSync(data) -} - -async function buildFigKiwi(schemaDeflated: Uint8Array, dataRaw: Uint8Array): Promise { - const dataCompressed = await compressData(dataRaw) +function buildFigKiwi(schemaDeflated: Uint8Array, dataCompressed: Uint8Array): Uint8Array { const FIG_KIWI_VERSION = 106 - const total = 8 + 4 + 4 + schemaDeflated.length + 4 + dataCompressed.length const out = new Uint8Array(total) const view = new DataView(out.buffer) - const magic = new TextEncoder().encode('fig-kiwi') - out.set(magic, 0) + out.set(new TextEncoder().encode('fig-kiwi'), 0) view.setUint32(8, FIG_KIWI_VERSION, true) let offset = 12 @@ -351,27 +344,33 @@ export async function exportFigFile( msg.blobs = blobs.map((bytes) => ({ bytes })) } - const dataRaw = compiled.encodeMessage(msg) - const canvasData = await buildFigKiwi(schemaDeflated, dataRaw) + const kiwiData = compiled.encodeMessage(msg) const currentPageId = pageId ?? pages[0]?.id - const thumbnail = ck && renderer && currentPageId + const thumbnailPng = (ck && renderer && currentPageId ? generateThumbnail(ck, renderer, graph, currentPageId) - : null + : null) ?? THUMBNAIL_1X1 - const THUMBNAIL_1X1 = Uint8Array.from(atob( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' - ), (c) => c.charCodeAt(0)) - - const meta = JSON.stringify({ + const metaJson = JSON.stringify({ version: 1, app: 'OpenPencil', createdAt: new Date().toISOString() }) + if (IS_TAURI) { + const { invoke } = await import('@tauri-apps/api/core') + return new Uint8Array(await invoke('build_fig_file', { + schemaDeflated: Array.from(schemaDeflated), + kiwiData: Array.from(kiwiData), + thumbnailPng: Array.from(thumbnailPng), + metaJson + })) + } + + const canvasData = buildFigKiwi(schemaDeflated, deflateSync(kiwiData)) return zipSync({ 'canvas.fig': [canvasData, { level: 0 }], - 'thumbnail.png': [thumbnail ?? THUMBNAIL_1X1, { level: 0 }], - 'meta.json': new TextEncoder().encode(meta) + 'thumbnail.png': [thumbnailPng, { level: 0 }], + 'meta.json': new TextEncoder().encode(metaJson) }) }