Files
figcraft/crates/format/src/lib.rs
T
BFlatandClaude Opus 5.5 e0200acbae M6: autosave checkpoints in version history (#38)
Every 30 minutes, each open document with unsaved changes since its last
version gets an automatic "Autosave" version (flagged `auto` in the file).
The newest 20 autosaves are kept; older ones make room and user versions
are never dropped for them. Runs of autosaves fold into one line in the
Version history panel. New command version.autosave.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:48:21 +07:00

437 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The native `.figcraft` format.
//!
//! A zip archive holding `document.json` (the [`Document`] as JSON, with a `format`/`version`
//! header), `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). A published file also holds its library (see
//! [`library`]). Readers that don't know about versions 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.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
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_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 VERSION_INDEX: &str = "versions/index.json";
#[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,
}
}
}
/// 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>,
}
impl From<Document> for Loaded {
fn from(doc: Document) -> Self {
Self { doc, issues: Vec::new(), versions: 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 }
}
/// Serialize the document (without images) to the JSON value stored in `document.json`.
pub fn to_json(doc: &Document) -> Result<Value, FormatError> {
let mut v = serde_json::to_value(doc).map_err(|e| FormatError::Io(e.to_string()))?;
if let Some(o) = v.as_object_mut() {
o.insert("format".into(), json!(FORMAT));
o.insert("version".into(), json!(VERSION));
let images: Vec<Value> = doc.images.iter().map(|(k, i)| json!({"hash": k, "width": i.width, "height": i.height})).collect();
o.insert("imageIndex".into(), Value::Array(images));
}
Ok(v)
}
/// 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>>;
/// Parse `document.json` content; also returns its [`ImageIndex`].
fn parse_json(text: &str) -> Result<(Document, Vec<String>, ImageIndex), FormatError> {
let v: Value = serde_json::from_str(text).map_err(|e| FormatError::Corrupt(e.to_string()))?;
if let Some(ver) = v.get("version").and_then(Value::as_u64)
&& ver > u64::from(VERSION)
{
return Err(FormatError::Newer(ver as u32));
}
let index = v
.get("imageIndex")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|i| i.get("hash").and_then(Value::as_str).map(str::to_string)).collect());
let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let issues = doc.validate();
if !issues.is_empty() {
doc.repair();
}
Ok((doc, issues, index))
}
fn io_err(e: impl std::fmt::Display) -> FormatError {
FormatError::Io(e.to_string())
}
/// Add one member to the archive.
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)
}
/// 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> {
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
put(&mut z, "document.json", deflated, &serde_json::to_vec(&to_json(doc)?).map_err(io_err)?)?;
// Every image once, whether the document or a version uses it.
let mut written = BTreeSet::new();
for (hash, img) in std::iter::once(&doc.images).chain(versions.iter().map(Version::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)?;
}
if !versions.is_empty() {
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!("versions/{}.json", v.id);
match &v.snapshot {
Snapshot::Doc(d) => put(&mut z, &name, deflated, &serde_json::to_vec(&to_json(d)?).map_err(io_err)?)?,
Snapshot::Stored { json, .. } => put(&mut z, &name, deflated, 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(&mut z, VERSION_INDEX, deflated, &serde_json::to_vec(&json!({"versions": 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())
}
/// 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.
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 versions = read_versions(&mut z, &images, &mut issues);
// Images only the version history uses stay with the versions.
let version_only: BTreeSet<&String> =
versions.iter().flat_map(|v| v.images().keys()).filter(|h| own_images.as_ref().is_some_and(|o| !o.contains(*h))).collect();
doc.images = images.into_iter().filter(|(h, _)| !version_only.contains(h)).collect();
Ok(Loaded { doc, issues, versions })
}
/// The version history of an archive (none when it has no index). Damaged entries are skipped and
/// reported in `issues`; nothing here fails the load.
fn read_versions<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<String, ImageData>, issues: &mut Vec<String>) -> Vec<Version> {
if z.index_for_name(VERSION_INDEX).is_none() {
return Vec::new();
}
let index = match read_member(z, VERSION_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 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();
let mut budget = MAX_VERSION_BYTES;
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!("versions/{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
}
/// 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 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());
}
}