mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 23:59:13 +00:00
Canvas previews zoomed out to 50% or less draw levels of detail (crates/render/src/lod.rs): subtrees under 2 device px are splatted as their averaged paint colours (box filter, no paths or effects), text under 4 px is greeked into bars, small solid rectangles are splatted and rectangular clip layers are composited without the blend pipeline. Image paints and masks keep the exact path. Exports, thumbnails and every other render stay exact (Options::lod is off by default); render bands are assembled with a row copy instead of a pixmap draw. .figcraft documents are serialized straight into the compressor and parsed straight from the text (a header pass reads version and image index without building a value tree); layer and text fields at their default values are left out (63 -> 22 MB of JSON at 50k layers, older files still load); JSON members use deflate level 3. At 50k layers: zoomed-to-fit render ~58 -> ~8 ms, save ~1.0 s -> ~0.09 s, load ~0.86 s -> ~0.16 s. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
523 lines
22 KiB
Rust
523 lines
22 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). 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::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 VERSION_INDEX: &str = "versions/index.json";
|
||
/// 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,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 }
|
||
}
|
||
|
||
/// `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))
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
/// 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> {
|
||
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 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_json(&mut z, &name, d)?,
|
||
Snapshot::Stored { json, .. } => put(&mut 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(&mut z, VERSION_INDEX, json_options(), &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 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());
|
||
}
|
||
}
|