mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 21:02:50 +00:00
An experimental, best-effort `.fig` export, so work done in FigCraft can be handed back as a `.fig` file. It is written from the same public format write-ups the importer uses and is the inverse of the importer's mapping. Only round trips through FigCraft's own importer are verified; other apps may not open every file.
- **Encoder** (`crates/format/src/fig/kiwi.rs`): the kiwi encoder that test fixtures used is now shipped code. It never panics and checks every value against the schema: unknown fields or enum names, missing struct fields, wrong types, out-of-range numbers and non-finite floats are errors, and NULs inside strings are dropped. Varints and floats are written exactly the way the decoder reads them. `Schema::parse` reads the compact text schema form, and the importer's tests use it too.
- **Writer** (`crates/format/src/fig/export.rs`): writes the zip (`canvas.fig`, `meta.json`, `thumbnail.png`, `images/<hash>`). `canvas.fig` holds the fig-kiwi header, then a raw-deflate binary schema and a raw-deflate message (flate2's pure-Rust backend, already in the tree; no new dependencies). The schema covers only the fields we write. The canvas, images, blobs, node count and nesting depth are capped, and floats are made finite before they are written.
- **Mapping:** pages; frames, groups, sections, rectangles, ellipses (arcs), polygons, stars, lines and vectors (vector network blobs with per-point radius, mirroring and caps, plus fill outlines); text with styled runs (UTF-16 indexed) and list types; slices; boolean groups; solid, gradient and image paints (image bytes included); strokes; effects; corners; constraints; masks; auto layout, including grids with tracks and cells; child layout; layout grids; export settings; components, component sets and their properties. Instances are written as their main plus the fields their layers change, by id path. This covers nested instances and swaps, and fields the main sets but the instance clears. Prototype interactions (triggers, navigation and overlay actions, transitions, easing), scrolling and flow starts are included. Everything skipped is reported in a warnings list: style links, variables, comments, Dev Mode data, plugin data, guides, slots, brush and width-profile strokes, pattern fills, image adjustments, variable and conditional actions, and more.
- **Import fixes found by the round trips** (`map.rs`): ellipse arcs, boolean operation types and component set descriptions are now read. Durations are read as whole milliseconds, because seconds stored as 32-bit floats used to truncate by 1 ms.
- **File writes:** a failed write no longer leaves its `.tmp-figcraft` file behind. The junk-parameter sweep found this.
- **Commands and UI:**
- `file.export {format: "fig"}`, or any path ending in `.fig`, writes the whole file and returns `warnings`. Without a path it returns base64 `data`.
- File > Export... has a new "The whole file" section with a ".fig (experimental)" row. A note under it says other apps may not open every exported file. After export, a toast reports how many kinds of content were left out.
- `figcraft-cli export --format fig` (or `-o x.fig`) and `convert` to `.fig` print the warnings as `note:` lines.
- MCP's `export` accepts `format: "fig"` and passes the warnings through.
- New UI strings have English and Spanish entries.
- **Docs:** file-format-parity.md has a new export section covering what is written, what is skipped, and that only round trips through our own importer are verified. Also updated: mcp.md, cli.md, gaps.md (gap 1), target-app-parity.md and ROADMAP.md. Grading is kept low. Import/export goes to 86%/58% and file formats to 60%. The file-exchange discount stays at x0.85, because nothing shows that another app opens these files.
675 lines
28 KiB
Rust
675 lines
28 KiB
Rust
//! The native `.figcraft` format.
|
||
//!
|
||
//! A zip archive holding `document.json` (the [`Document`] as JSON, with a `format`/`version`
|
||
//! header; layer fields at their default values are left out and read back as those defaults),
|
||
//! `images/<sha256>` for every image the document (or a saved version) uses, an optional
|
||
//! `thumbnail.png` and an optional version history: `versions/index.json` (`{"versions": [{id,
|
||
//! name, description, time, images}]}`, oldest first) plus one `versions/<id>.json` snapshot per
|
||
//! version (a `document.json` of its own). Branches are kept the same way: `branches/index.json`
|
||
//! (`{"branches": [{id, name, time, client, state, baseImages, headImages}]}`) plus
|
||
//! `branches/<id>/base.json` and `branches/<id>/head.json`; a branch's own version history is
|
||
//! kept like the file's, under `branches/<id>/versions/`. A published file also holds its
|
||
//! library (see [`library`]). Readers that don't know about versions, branches or libraries
|
||
//! ignore those entries. Plain
|
||
//! JSON (the `document.json` alone) also opens. Reading is lenient: structural problems are
|
||
//! repaired (see [`Document::repair`]) and reported, damaged versions and libraries are skipped.
|
||
//!
|
||
//! [`rest`] imports the public REST API file JSON; [`fig`] imports `.fig` files (best effort).
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
|
||
pub mod fig;
|
||
pub mod library;
|
||
pub mod rest;
|
||
|
||
pub use library::{is_valid_library_id, read_library};
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use std::io::{Read, Seek, Write};
|
||
use std::sync::Arc;
|
||
|
||
use figcraft_doc::{Document, ImageData};
|
||
use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor};
|
||
use serde::{Deserialize, Serialize, Serializer};
|
||
use serde_json::{Value, json};
|
||
use sha2::Digest;
|
||
|
||
pub const EXTENSION: &str = "figcraft";
|
||
pub const FORMAT: &str = "figcraft";
|
||
pub const VERSION: u32 = 1;
|
||
/// Largest archive member we read (guards against zip bombs).
|
||
const MAX_MEMBER: u64 = 512 * 1024 * 1024;
|
||
/// Most versions a file keeps: saving more is refused, extra entries in a file are skipped.
|
||
pub const MAX_VERSIONS: usize = 100;
|
||
/// Longest version name and description kept (chars).
|
||
pub const MAX_VERSION_NAME: usize = 200;
|
||
pub const MAX_VERSION_DESCRIPTION: usize = 2000;
|
||
/// Largest version index and total size of the version snapshots read from one file.
|
||
const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024;
|
||
const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024;
|
||
const BRANCH_INDEX: &str = "branches/index.json";
|
||
/// Most branches a file keeps: making more is refused, extra entries in a file are skipped.
|
||
pub const MAX_BRANCHES: usize = 100;
|
||
/// Deflate level of JSON members (see [`json_options`]).
|
||
const JSON_LEVEL: i64 = 3;
|
||
|
||
#[derive(Debug)]
|
||
pub enum FormatError {
|
||
Io(String),
|
||
Corrupt(String),
|
||
Newer(u32),
|
||
}
|
||
|
||
impl std::fmt::Display for FormatError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
FormatError::Io(e) => write!(f, "{e}"),
|
||
FormatError::Corrupt(e) => write!(f, "not a valid FigCraft file: {e}"),
|
||
FormatError::Newer(v) => write!(f, "this file was saved by a newer FigCraft (format version {v})"),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for FormatError {}
|
||
|
||
/// One entry of a file's version history: a named snapshot of the document.
|
||
#[derive(Clone, Debug)]
|
||
pub struct Version {
|
||
/// Unique within the file; ASCII letters, digits, `-` and `_` (see [`is_valid_version_id`]).
|
||
pub id: String,
|
||
pub name: String,
|
||
pub description: String,
|
||
/// When it was saved (Unix seconds).
|
||
pub time: u64,
|
||
/// Saved automatically (a checkpoint) rather than by the user.
|
||
pub auto: bool,
|
||
snapshot: Snapshot,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
enum Snapshot {
|
||
/// Saved in this session (or already read).
|
||
Doc(Arc<Document>),
|
||
/// As read from a file: its `document.json`-style JSON, parsed on first use, and the images
|
||
/// it uses.
|
||
Stored { json: Arc<Vec<u8>>, images: BTreeMap<String, ImageData> },
|
||
}
|
||
|
||
impl Version {
|
||
/// A version of `doc`. The name and description are clipped to their maximum lengths.
|
||
pub fn new(id: impl Into<String>, name: &str, description: &str, time: u64, doc: Arc<Document>) -> Self {
|
||
Self {
|
||
id: id.into(),
|
||
name: clip(name, MAX_VERSION_NAME),
|
||
description: clip(description, MAX_VERSION_DESCRIPTION),
|
||
time,
|
||
auto: false,
|
||
snapshot: Snapshot::Doc(doc),
|
||
}
|
||
}
|
||
|
||
/// The snapshot (with its images). Versions read from a file are parsed here, so a damaged
|
||
/// one fails now rather than when the file opens.
|
||
pub fn document(&self) -> Result<Arc<Document>, FormatError> {
|
||
match &self.snapshot {
|
||
Snapshot::Doc(d) => Ok(d.clone()),
|
||
Snapshot::Stored { json, images } => {
|
||
let text = std::str::from_utf8(json).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||
let (mut doc, _) = from_json(text)?;
|
||
doc.images = images.clone();
|
||
Ok(Arc::new(doc))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The images the snapshot uses, by hash.
|
||
pub fn images(&self) -> &BTreeMap<String, ImageData> {
|
||
match &self.snapshot {
|
||
Snapshot::Doc(d) => &d.images,
|
||
Snapshot::Stored { images, .. } => images,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Where a branch is in its life.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum BranchState {
|
||
Active,
|
||
/// Merged into the main file (and archived).
|
||
Merged,
|
||
/// Archived without merging.
|
||
Archived,
|
||
}
|
||
|
||
/// A branch of the file: a copy of the document to work on separately, and the main document as
|
||
/// it was when the branch was made or last updated from it (its base).
|
||
#[derive(Clone, Debug)]
|
||
pub struct Branch {
|
||
/// Unique among the file's branches; a valid version id ([`is_valid_version_id`]).
|
||
pub id: String,
|
||
pub name: String,
|
||
/// When it was made (Unix seconds).
|
||
pub time: u64,
|
||
/// The client id space the branch makes ids in ([`Document::set_client_id`]), so they never
|
||
/// clash with ids made on the main file or on other branches.
|
||
pub client: u32,
|
||
pub state: BranchState,
|
||
pub base: Arc<Document>,
|
||
pub head: Arc<Document>,
|
||
/// The branch's own version history, oldest first.
|
||
pub versions: Vec<Version>,
|
||
}
|
||
|
||
/// Whether `id` can name a version (and its archive entry).
|
||
pub fn is_valid_version_id(id: &str) -> bool {
|
||
(1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||
}
|
||
|
||
/// `s` cut to at most `max` chars.
|
||
pub fn clip(s: &str, max: usize) -> String {
|
||
s.chars().take(max).collect()
|
||
}
|
||
|
||
/// A file as read: the document, the repairs made (and versions skipped), and its version
|
||
/// history, oldest first.
|
||
#[derive(Debug)]
|
||
pub struct Loaded {
|
||
pub doc: Document,
|
||
pub issues: Vec<String>,
|
||
pub versions: Vec<Version>,
|
||
pub branches: Vec<Branch>,
|
||
}
|
||
|
||
impl From<Document> for Loaded {
|
||
fn from(doc: Document) -> Self {
|
||
Self { doc, issues: Vec::new(), versions: Vec::new(), branches: Vec::new() }
|
||
}
|
||
}
|
||
|
||
/// Content hash used as an image's key.
|
||
pub fn image_hash(bytes: &[u8]) -> String {
|
||
let d = sha2::Sha256::digest(bytes);
|
||
d.iter().map(|b| format!("{b:02x}")).collect()
|
||
}
|
||
|
||
/// Register encoded image bytes with the document; returns the hash and pixel size.
|
||
pub fn add_image(doc: &mut Document, bytes: Vec<u8>) -> Result<(String, u32, u32), FormatError> {
|
||
let (w, h) = image::ImageReader::new(std::io::Cursor::new(&bytes))
|
||
.with_guessed_format()
|
||
.map_err(|e| FormatError::Corrupt(e.to_string()))?
|
||
.into_dimensions()
|
||
.map_err(|e| FormatError::Corrupt(format!("unsupported image: {e}")))?;
|
||
let hash = image_hash(&bytes);
|
||
doc.images.insert(hash.clone(), ImageData { bytes: Arc::new(bytes), width: w, height: h });
|
||
Ok((hash, w, h))
|
||
}
|
||
|
||
/// Image bytes with their pixel size (0×0 when they can't be decoded).
|
||
pub(crate) fn image_data(data: Vec<u8>) -> ImageData {
|
||
let (w, h) =
|
||
image::ImageReader::new(std::io::Cursor::new(&data)).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()).unwrap_or((0, 0));
|
||
ImageData { bytes: Arc::new(data), width: w, height: h }
|
||
}
|
||
|
||
/// `document.json`: the format header, the image index and the document's own fields, written
|
||
/// straight from the document (no intermediate JSON tree).
|
||
#[derive(Serialize)]
|
||
struct DocumentJson<'a> {
|
||
format: &'static str,
|
||
version: u32,
|
||
#[serde(rename = "imageIndex", serialize_with = "image_index")]
|
||
images: &'a BTreeMap<String, ImageData>,
|
||
#[serde(flatten)]
|
||
doc: &'a Document,
|
||
}
|
||
|
||
impl<'a> DocumentJson<'a> {
|
||
fn new(doc: &'a Document) -> Self {
|
||
Self { format: FORMAT, version: VERSION, images: &doc.images, doc }
|
||
}
|
||
}
|
||
|
||
/// The `imageIndex`: `[{hash, width, height}]` for every image the document holds.
|
||
fn image_index<S: Serializer>(images: &&BTreeMap<String, ImageData>, s: S) -> Result<S::Ok, S::Error> {
|
||
#[derive(Serialize)]
|
||
struct Entry<'a> {
|
||
hash: &'a str,
|
||
width: u32,
|
||
height: u32,
|
||
}
|
||
s.collect_seq(images.iter().map(|(hash, i)| Entry { hash, width: i.width, height: i.height }))
|
||
}
|
||
|
||
/// Serialize the document (without images) to the JSON value stored in `document.json`.
|
||
pub fn to_json(doc: &Document) -> Result<Value, FormatError> {
|
||
serde_json::to_value(DocumentJson::new(doc)).map_err(io_err)
|
||
}
|
||
|
||
/// Write the document (without images) as `document.json` content to `w`.
|
||
pub fn write_json(doc: &Document, w: impl Write) -> Result<(), FormatError> {
|
||
serde_json::to_writer(w, &DocumentJson::new(doc)).map_err(io_err)
|
||
}
|
||
|
||
/// Parse `document.json` content.
|
||
pub fn from_json(text: &str) -> Result<(Document, Vec<String>), FormatError> {
|
||
parse_json(text).map(|(doc, issues, _)| (doc, issues))
|
||
}
|
||
|
||
/// The image hashes a `document.json` lists in its `imageIndex` (`None` without one).
|
||
type ImageIndex = Option<BTreeSet<String>>;
|
||
|
||
/// The header of a `document.json` (its format version and image index), read in a pass that
|
||
/// skips over everything else without building it.
|
||
#[derive(Default)]
|
||
struct Header {
|
||
version: Option<u64>,
|
||
images: ImageIndex,
|
||
}
|
||
|
||
impl<'de> Deserialize<'de> for Header {
|
||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||
struct HeaderVisitor;
|
||
impl<'de> Visitor<'de> for HeaderVisitor {
|
||
type Value = Header;
|
||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||
f.write_str("a FigCraft document object")
|
||
}
|
||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Header, A::Error> {
|
||
let mut h = Header::default();
|
||
while let Some(key) = map.next_key::<String>()? {
|
||
match key.as_str() {
|
||
"version" => h.version = map.next_value::<Value>()?.as_u64(),
|
||
"imageIndex" => {
|
||
h.images = map
|
||
.next_value::<Value>()?
|
||
.as_array()
|
||
.map(|a| a.iter().filter_map(|i| i.get("hash").and_then(Value::as_str).map(str::to_string)).collect());
|
||
}
|
||
_ => {
|
||
map.next_value::<IgnoredAny>()?;
|
||
}
|
||
}
|
||
}
|
||
Ok(h)
|
||
}
|
||
}
|
||
d.deserialize_map(HeaderVisitor)
|
||
}
|
||
}
|
||
|
||
/// Parse `document.json` content; also returns its [`ImageIndex`]. The header is checked first
|
||
/// (a newer file is refused before its document is read), then the document is deserialized
|
||
/// straight from the text.
|
||
fn parse_json(text: &str) -> Result<(Document, Vec<String>, ImageIndex), FormatError> {
|
||
let corrupt = |e: serde_json::Error| FormatError::Corrupt(e.to_string());
|
||
let header: Header = serde_json::from_str(text).map_err(corrupt)?;
|
||
if let Some(ver) = header.version
|
||
&& ver > u64::from(VERSION)
|
||
{
|
||
return Err(FormatError::Newer(u32::try_from(ver).unwrap_or(u32::MAX)));
|
||
}
|
||
let mut doc: Document = serde_json::from_str(text).map_err(corrupt)?;
|
||
let issues = doc.validate();
|
||
if !issues.is_empty() {
|
||
doc.repair();
|
||
}
|
||
Ok((doc, issues, header.images))
|
||
}
|
||
|
||
pub(crate) fn io_err(e: impl std::fmt::Display) -> FormatError {
|
||
FormatError::Io(e.to_string())
|
||
}
|
||
|
||
/// Add one member to the archive.
|
||
pub(crate) fn put<W: Write + Seek>(
|
||
z: &mut zip::ZipWriter<W>,
|
||
name: &str,
|
||
opts: zip::write::SimpleFileOptions,
|
||
bytes: &[u8],
|
||
) -> Result<(), FormatError> {
|
||
z.start_file(name, opts).map_err(io_err)?;
|
||
z.write_all(bytes).map_err(io_err)
|
||
}
|
||
|
||
/// Add `doc` as a `document.json`-style member, serialized straight into the compressor.
|
||
fn put_json<W: Write + Seek>(z: &mut zip::ZipWriter<W>, name: &str, doc: &Document) -> Result<(), FormatError> {
|
||
z.start_file(name, json_options()).map_err(io_err)?;
|
||
// The serializer writes many small pieces: batch them before they reach the compressor.
|
||
let mut w = std::io::BufWriter::with_capacity(1 << 16, z);
|
||
write_json(doc, &mut w)?;
|
||
w.flush().map_err(io_err)
|
||
}
|
||
|
||
/// How JSON members are compressed. Document JSON is very repetitive: a fast level compresses it
|
||
/// nearly as well as the default one (about 15% larger) in half the time.
|
||
pub(crate) fn json_options() -> zip::write::SimpleFileOptions {
|
||
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated).compression_level(Some(JSON_LEVEL))
|
||
}
|
||
|
||
/// Write a `.figcraft` archive to bytes.
|
||
pub fn save_bytes(doc: &Document, thumbnail_png: Option<&[u8]>) -> Result<Vec<u8>, FormatError> {
|
||
save_bytes_with_versions(doc, thumbnail_png, &[])
|
||
}
|
||
|
||
/// Write a `.figcraft` archive with a version history (oldest first) to bytes.
|
||
pub fn save_bytes_with_versions(doc: &Document, thumbnail_png: Option<&[u8]>, versions: &[Version]) -> Result<Vec<u8>, FormatError> {
|
||
save_bytes_with_history(doc, thumbnail_png, versions, &[])
|
||
}
|
||
|
||
/// Write a `.figcraft` archive with a version history (oldest first) and branches to bytes.
|
||
pub fn save_bytes_with_history(
|
||
doc: &Document,
|
||
thumbnail_png: Option<&[u8]>,
|
||
versions: &[Version],
|
||
branches: &[Branch],
|
||
) -> Result<Vec<u8>, FormatError> {
|
||
let mut buf = std::io::Cursor::new(Vec::new());
|
||
{
|
||
let mut z = zip::ZipWriter::new(&mut buf);
|
||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||
put_json(&mut z, "document.json", doc)?;
|
||
// Every image once, whether the document, a version or a branch uses it.
|
||
let mut written = BTreeSet::new();
|
||
let branch_images = branches.iter().flat_map(|b| [&b.base.images, &b.head.images].into_iter().chain(b.versions.iter().map(Version::images)));
|
||
for (hash, img) in std::iter::once(&doc.images).chain(versions.iter().map(Version::images)).chain(branch_images).flatten() {
|
||
if written.insert(hash) {
|
||
put(&mut z, &format!("images/{hash}"), stored, &img.bytes)?;
|
||
}
|
||
}
|
||
if let Some(t) = thumbnail_png {
|
||
put(&mut z, "thumbnail.png", stored, t)?;
|
||
}
|
||
put_versions(&mut z, "versions", versions)?;
|
||
if !branches.is_empty() {
|
||
let mut ids = BTreeSet::new();
|
||
let mut index = Vec::with_capacity(branches.len());
|
||
for b in branches {
|
||
if !is_valid_version_id(&b.id) || !ids.insert(b.id.as_str()) {
|
||
return Err(FormatError::Io(format!("invalid or duplicate branch id `{}`", b.id)));
|
||
}
|
||
put_json(&mut z, &format!("branches/{}/base.json", b.id), &b.base)?;
|
||
put_json(&mut z, &format!("branches/{}/head.json", b.id), &b.head)?;
|
||
put_versions(&mut z, &format!("branches/{}/versions", b.id), &b.versions)?;
|
||
let images = |d: &Document| d.images.keys().cloned().collect::<Vec<String>>();
|
||
index.push(json!({"id": b.id, "name": b.name, "time": b.time, "client": b.client, "state": b.state,
|
||
"baseImages": images(&b.base), "headImages": images(&b.head)}));
|
||
}
|
||
put(&mut z, BRANCH_INDEX, json_options(), &serde_json::to_vec(&json!({"branches": index})).map_err(io_err)?)?;
|
||
}
|
||
if let Some(lib) = &doc.published {
|
||
library::write(&mut z, lib, &written)?;
|
||
}
|
||
z.finish().map_err(io_err)?;
|
||
}
|
||
Ok(buf.into_inner())
|
||
}
|
||
|
||
/// Write version history `versions` (oldest first) under `dir`: `<dir>/index.json` and one
|
||
/// `<dir>/<id>.json` snapshot per version. Nothing when there are none.
|
||
fn put_versions<W: Write + Seek>(z: &mut zip::ZipWriter<W>, dir: &str, versions: &[Version]) -> Result<(), FormatError> {
|
||
if versions.is_empty() {
|
||
return Ok(());
|
||
}
|
||
let mut ids = BTreeSet::new();
|
||
let mut index = Vec::with_capacity(versions.len());
|
||
for v in versions {
|
||
if !is_valid_version_id(&v.id) || !ids.insert(v.id.as_str()) {
|
||
return Err(FormatError::Io(format!("invalid or duplicate version id `{}`", v.id)));
|
||
}
|
||
let name = format!("{dir}/{}.json", v.id);
|
||
match &v.snapshot {
|
||
Snapshot::Doc(d) => put_json(z, &name, d)?,
|
||
Snapshot::Stored { json, .. } => put(z, &name, json_options(), json)?,
|
||
}
|
||
let images: Vec<&String> = v.images().keys().collect();
|
||
let mut entry = json!({"id": v.id, "name": v.name, "description": v.description, "time": v.time, "images": images});
|
||
if v.auto {
|
||
entry["auto"] = json!(true);
|
||
}
|
||
index.push(entry);
|
||
}
|
||
put(z, &format!("{dir}/index.json"), json_options(), &serde_json::to_vec(&json!({"versions": index})).map_err(io_err)?)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Read a `.figcraft` archive (or bare JSON) from bytes. Returns the document and any repairs made.
|
||
pub fn load_bytes(bytes: &[u8]) -> Result<(Document, Vec<String>), FormatError> {
|
||
load_bytes_with_versions(bytes).map(|l| (l.doc, l.issues))
|
||
}
|
||
|
||
/// Read archive member `name`, refusing members larger than `limit` bytes.
|
||
pub(crate) fn read_member<R: Read + Seek>(z: &mut zip::ZipArchive<R>, name: &str, limit: u64) -> Result<Vec<u8>, FormatError> {
|
||
let f = z.by_name(name).map_err(|e| FormatError::Corrupt(format!("{name}: {e}")))?;
|
||
let too_large = || FormatError::Corrupt(format!("{name} is too large"));
|
||
if f.size() > limit {
|
||
return Err(too_large());
|
||
}
|
||
let mut data = Vec::new();
|
||
f.take(limit.saturating_add(1)).read_to_end(&mut data).map_err(|e| FormatError::Corrupt(format!("{name}: {e}")))?;
|
||
if data.len() as u64 > limit {
|
||
return Err(too_large());
|
||
}
|
||
Ok(data)
|
||
}
|
||
|
||
/// Read a `.figcraft` archive (or bare JSON) with its version history.
|
||
pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
||
let trimmed = bytes.iter().position(|b| !b.is_ascii_whitespace()).and_then(|i| bytes.get(i));
|
||
if trimmed == Some(&b'{') {
|
||
let text = std::str::from_utf8(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||
let (doc, issues) = from_json(text)?;
|
||
return Ok(Loaded { issues, ..Loaded::from(doc) });
|
||
}
|
||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||
let text = String::from_utf8(read_member(&mut z, "document.json", MAX_MEMBER)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||
let (mut doc, mut issues, own_images) = parse_json(&text)?;
|
||
let names: Vec<String> = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect();
|
||
let mut images = BTreeMap::new();
|
||
for name in names {
|
||
let Ok(data) = read_member(&mut z, &name, MAX_MEMBER) else { continue };
|
||
let hash = name.trim_start_matches("images/").to_string();
|
||
images.insert(hash, image_data(data));
|
||
}
|
||
match library::read(&mut z, &images) {
|
||
Ok(lib) => doc.published = lib.map(Arc::new),
|
||
Err(e) => issues.push(format!("the published library was skipped: {e}")),
|
||
}
|
||
let mut budget = MAX_VERSION_BYTES;
|
||
let versions = read_versions(&mut z, "versions", &images, &mut issues, &mut budget);
|
||
let branches = read_branches(&mut z, &images, &mut issues);
|
||
// Images only the version history or branches use stay with them.
|
||
let branch_images =
|
||
branches.iter().flat_map(|b| b.base.images.keys().chain(b.head.images.keys()).chain(b.versions.iter().flat_map(|v| v.images().keys())));
|
||
let history_only: BTreeSet<&String> = versions
|
||
.iter()
|
||
.flat_map(|v| v.images().keys())
|
||
.chain(branch_images)
|
||
.filter(|h| own_images.as_ref().is_some_and(|o| !o.contains(*h)))
|
||
.collect();
|
||
doc.images = images.into_iter().filter(|(h, _)| !history_only.contains(h)).collect();
|
||
Ok(Loaded { doc, issues, versions, branches })
|
||
}
|
||
|
||
/// The version history kept under `dir` in an archive (none when it has no index), reading at
|
||
/// most `budget` bytes of snapshots. Damaged entries are skipped and reported in `issues`; nothing
|
||
/// here fails the load.
|
||
fn read_versions<R: Read + Seek>(
|
||
z: &mut zip::ZipArchive<R>,
|
||
dir: &str,
|
||
images: &BTreeMap<String, ImageData>,
|
||
issues: &mut Vec<String>,
|
||
budget: &mut u64,
|
||
) -> Vec<Version> {
|
||
let index_name = format!("{dir}/index.json");
|
||
if z.index_for_name(&index_name).is_none() {
|
||
return Vec::new();
|
||
}
|
||
let index = match read_member(z, &index_name, MAX_VERSION_INDEX)
|
||
.and_then(|b| serde_json::from_slice::<Value>(&b).map_err(|e| FormatError::Corrupt(e.to_string())))
|
||
{
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
issues.push(format!("the version history was skipped: {e}"));
|
||
return Vec::new();
|
||
}
|
||
};
|
||
let entries = index.get("versions").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||
let mut out: Vec<Version> = Vec::new();
|
||
for e in entries {
|
||
if out.len() >= MAX_VERSIONS {
|
||
issues.push(format!("only the first {MAX_VERSIONS} versions were kept"));
|
||
break;
|
||
}
|
||
let id = e.get("id").and_then(Value::as_str).unwrap_or_default();
|
||
if !is_valid_version_id(id) || out.iter().any(|v| v.id == id) {
|
||
issues.push("a version with an invalid id was skipped".into());
|
||
continue;
|
||
}
|
||
let json = match read_member(z, &format!("{dir}/{id}.json"), (*budget).min(MAX_MEMBER)) {
|
||
Ok(j) => j,
|
||
Err(err) => {
|
||
issues.push(format!("version `{id}` was skipped: {err}"));
|
||
continue;
|
||
}
|
||
};
|
||
*budget = budget.saturating_sub(json.len() as u64);
|
||
let text = |k: &str, max: usize| e.get(k).and_then(Value::as_str).map(|s| clip(s, max)).unwrap_or_default();
|
||
let images = e
|
||
.get("images")
|
||
.and_then(Value::as_array)
|
||
.into_iter()
|
||
.flatten()
|
||
.filter_map(Value::as_str)
|
||
.filter_map(|h| images.get(h).map(|i| (h.to_string(), i.clone())))
|
||
.collect();
|
||
out.push(Version {
|
||
id: id.to_string(),
|
||
name: text("name", MAX_VERSION_NAME),
|
||
description: text("description", MAX_VERSION_DESCRIPTION),
|
||
time: e.get("time").and_then(Value::as_u64).unwrap_or(0),
|
||
auto: e.get("auto").and_then(Value::as_bool).unwrap_or(false),
|
||
snapshot: Snapshot::Stored { json: Arc::new(json), images },
|
||
});
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The branches of an archive (none when it has no index), parsed. Damaged entries are skipped and
|
||
/// reported in `issues`; nothing here fails the load.
|
||
fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<String, ImageData>, issues: &mut Vec<String>) -> Vec<Branch> {
|
||
if z.index_for_name(BRANCH_INDEX).is_none() {
|
||
return Vec::new();
|
||
}
|
||
let index = match read_member(z, BRANCH_INDEX, MAX_VERSION_INDEX)
|
||
.and_then(|b| serde_json::from_slice::<Value>(&b).map_err(|e| FormatError::Corrupt(e.to_string())))
|
||
{
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
issues.push(format!("the branches were skipped: {e}"));
|
||
return Vec::new();
|
||
}
|
||
};
|
||
let entries = index.get("branches").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||
let mut out: Vec<Branch> = Vec::new();
|
||
let mut budget = MAX_VERSION_BYTES;
|
||
for e in entries {
|
||
if out.len() >= MAX_BRANCHES {
|
||
issues.push(format!("only the first {MAX_BRANCHES} branches were kept"));
|
||
break;
|
||
}
|
||
let id = e.get("id").and_then(Value::as_str).unwrap_or_default();
|
||
if !is_valid_version_id(id) || out.iter().any(|b| b.id == id) {
|
||
issues.push("a branch with an invalid id was skipped".into());
|
||
continue;
|
||
}
|
||
let mut snapshot = |part: &str, list: &str| -> Result<Arc<Document>, FormatError> {
|
||
let json = read_member(z, &format!("branches/{id}/{part}.json"), budget.min(MAX_MEMBER))?;
|
||
budget = budget.saturating_sub(json.len() as u64);
|
||
let (mut doc, _) = from_json(std::str::from_utf8(&json).map_err(|e| FormatError::Corrupt(e.to_string()))?)?;
|
||
let hashes = e.get(list).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str);
|
||
doc.images = hashes.filter_map(|h| images.get(h).map(|i| (h.to_string(), i.clone()))).collect();
|
||
Ok(Arc::new(doc))
|
||
};
|
||
let (base, head) = match (snapshot("base", "baseImages"), snapshot("head", "headImages")) {
|
||
(Ok(b), Ok(h)) => (b, h),
|
||
(Err(err), _) | (_, Err(err)) => {
|
||
issues.push(format!("branch `{id}` was skipped: {err}"));
|
||
continue;
|
||
}
|
||
};
|
||
out.push(Branch {
|
||
id: id.to_string(),
|
||
name: e.get("name").and_then(Value::as_str).map(|s| clip(s, MAX_VERSION_NAME)).unwrap_or_default(),
|
||
time: e.get("time").and_then(Value::as_u64).unwrap_or(0),
|
||
client: e.get("client").and_then(Value::as_u64).and_then(|c| u32::try_from(c).ok()).unwrap_or(0),
|
||
state: e.get("state").and_then(|s| BranchState::deserialize(s).ok()).unwrap_or(BranchState::Active),
|
||
versions: read_versions(z, &format!("branches/{id}/versions"), images, issues, &mut budget),
|
||
base,
|
||
head,
|
||
});
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Save atomically: write a temp file next to `path`, then rename over it.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub fn save_file(doc: &Document, path: &std::path::Path, thumbnail_png: Option<&[u8]>) -> Result<(), FormatError> {
|
||
let bytes = save_bytes(doc, thumbnail_png)?;
|
||
let tmp = path.with_extension("figcraft.tmp");
|
||
std::fs::write(&tmp, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", tmp.display())))?;
|
||
std::fs::rename(&tmp, path).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))?;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), FormatError> {
|
||
let bytes = std::fs::read(path).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))?;
|
||
load_bytes(&bytes)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod branch_tests;
|
||
#[cfg(test)]
|
||
mod large_tests;
|
||
#[cfg(test)]
|
||
mod version_tests;
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use figcraft_doc::NodeKind;
|
||
|
||
fn png() -> Vec<u8> {
|
||
let img = image::RgbaImage::from_pixel(3, 2, image::Rgba([255, 0, 0, 255]));
|
||
let mut out = Vec::new();
|
||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn roundtrip_with_images() {
|
||
let mut d = Document::new("My file");
|
||
let p = d.first_page().unwrap();
|
||
d.create(p, NodeKind::Rectangle, "R", 1.0, 2.0, 3.0, 4.0).unwrap();
|
||
let (hash, w, h) = add_image(&mut d, png()).unwrap();
|
||
assert_eq!((w, h), (3, 2));
|
||
let bytes = save_bytes(&d, None).unwrap();
|
||
let (back, issues) = load_bytes(&bytes).unwrap();
|
||
assert!(issues.is_empty());
|
||
assert_eq!(back.name, "My file");
|
||
assert_eq!(back.node_count(), d.node_count());
|
||
assert!(back.images.contains_key(&hash));
|
||
// Bare JSON opens too.
|
||
let json = serde_json::to_vec(&to_json(&d).unwrap()).unwrap();
|
||
assert!(load_bytes(&json).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn garbage_is_an_error() {
|
||
assert!(load_bytes(b"not a zip").is_err());
|
||
assert!(load_bytes(b"{\"nope\":1}").is_err());
|
||
assert!(matches!(load_bytes(b"{\"version\":999}"), Err(FormatError::Newer(999))));
|
||
assert!(add_image(&mut Document::new("x"), b"zzz".to_vec()).is_err());
|
||
}
|
||
}
|