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:
parent
5aae8d01fc
commit
edf6da6738
36
desktop/Cargo.lock
generated
36
desktop/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue