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.
This commit is contained in:
Danila Poyarkov 2026-02-28 16:16:49 +03:00
parent 5aae8d01fc
commit edf6da6738
4 changed files with 111 additions and 29 deletions

36
desktop/Cargo.lock generated
View file

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

View file

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

View file

@ -4,20 +4,66 @@ use tauri::{
};
#[tauri::command]
fn zstd_compress(data: Vec<u8>) -> Result<Vec<u8>, String> {
use std::io::Write;
fn build_fig_file(
schema_deflated: Vec<u8>,
kiwi_data: Vec<u8>,
thumbnail_png: Vec<u8>,
meta_json: String,
) -> 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.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())

View file

@ -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<Uint8Array> {
if (IS_TAURI) {
const { invoke } = await import('@tauri-apps/api/core')
return new Uint8Array(await invoke<number[]>('zstd_compress', { data: Array.from(data) }))
}
return deflateSync(data)
}
async function buildFigKiwi(schemaDeflated: Uint8Array, dataRaw: Uint8Array): Promise<Uint8Array> {
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<number[]>('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)
})
}