//! The native `.figcraft` format. //! //! A zip archive holding `document.json` (the [`Document`] as JSON, with a `format`/`version` //! header), `images/` 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/.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), /// As read from a file: its `document.json`-style JSON, parsed on first use, and the images /// it uses. Stored { json: Arc>, images: BTreeMap }, } impl Version { /// A version of `doc`. The name and description are clipped to their maximum lengths. pub fn new(id: impl Into, name: &str, description: &str, time: u64, doc: Arc) -> 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, 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 { 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, pub versions: Vec, } impl From 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) -> 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) -> 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 { 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 = 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), 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>; /// Parse `document.json` content; also returns its [`ImageIndex`]. fn parse_json(text: &str) -> Result<(Document, Vec, 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(z: &mut zip::ZipWriter, 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, 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, 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), 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(z: &mut zip::ZipArchive, name: &str, limit: u64) -> Result, 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 { 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 = 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(z: &mut zip::ZipArchive, images: &BTreeMap, issues: &mut Vec) -> Vec { 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::(&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 = 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), 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 { 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()); } }