//! The native `.designcraft` format: a zip archive holding //! //! - `mimetype` (stored first, uncompressed): `application/vnd.designcraft+zip` //! - `document.json`: the [`Document`] (pretty JSON, documented by the serde model in `designcraft-doc`) //! - `assets/.`: embedded placed files, byte-for-byte //! - `meta.json`: format version and generator //! //! Older single-file JSON documents (assets as base64 in `assetData`) still open. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] use std::io::{Cursor, Read, Write}; use std::sync::Arc; use designcraft_doc::{AssetId, Document}; use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; pub const VERSION: u32 = 1; #[derive(Debug, thiserror::Error)] pub enum FormatError { #[error("not a DesignCraft document: {0}")] NotOurs(String), #[error("document is from a newer DesignCraft (format {0}); please update")] TooNew(u32), #[error("{0}")] Io(String), } fn ext_for(mime: &str) -> &'static str { match mime { "image/png" => "png", "image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp", "image/tiff" => "tif", "application/pdf" => "pdf", "image/svg+xml" => "svg", _ => "bin", } } /// Serialize a document to `.designcraft` bytes. pub fn save(doc: &Document) -> Result, FormatError> { let io = |e: zip::result::ZipError| FormatError::Io(e.to_string()); let mut buf = Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); z.start_file("mimetype", stored).map_err(io)?; z.write_all(MIME.as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; z.start_file("meta.json", deflate).map_err(io)?; let meta = json!({"format": "designcraft", "version": VERSION, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))}); z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; z.start_file("document.json", deflate).map_err(io)?; let json = serde_json::to_vec_pretty(doc).map_err(|e| FormatError::Io(e.to_string()))?; z.write_all(&json).map_err(|e| FormatError::Io(e.to_string()))?; for (id, a) in &doc.assets { if a.data.is_empty() { continue; } // Already-compressed images are stored as-is. let opts = if matches!(a.mime.as_str(), "image/png" | "image/jpeg" | "image/gif" | "image/webp") { stored } else { deflate }; z.start_file(format!("assets/{}.{}", id.0, ext_for(&a.mime)), opts).map_err(io)?; z.write_all(&a.data).map_err(|e| FormatError::Io(e.to_string()))?; } z.finish().map_err(io)?; } Ok(buf.into_inner()) } /// Read `.designcraft` bytes (zip, or the legacy single JSON file). pub fn load(bytes: &[u8]) -> Result { if bytes.starts_with(b"PK") { return load_zip(bytes); } load_legacy_json(bytes) } fn load_zip(bytes: &[u8]) -> Result { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?; let read = |z: &mut zip::ZipArchive>, name: &str| -> Option> { let mut f = z.by_name(name).ok()?; let mut v = Vec::new(); f.read_to_end(&mut v).ok()?; Some(v) }; if let Some(meta) = read(&mut z, "meta.json") && let Ok(m) = serde_json::from_slice::(&meta) && let Some(v) = m.get("version").and_then(Value::as_u64) && v as u32 > VERSION { return Err(FormatError::TooNew(v as u32)); } let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?; let mut doc: Document = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; let names: Vec = z.file_names().map(str::to_string).collect(); for name in names { let Some(stem) = name.strip_prefix("assets/") else { continue }; let Some(id) = stem.split('.').next().and_then(|s| s.parse::().ok()) else { continue }; if let (Some(data), Some(a)) = (read(&mut z, &name), doc.assets.get_mut(&AssetId(id))) { Arc::make_mut(a).data = Arc::new(data); } } doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } fn load_legacy_json(bytes: &[u8]) -> Result { let v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?; let mut doc: Document = serde_json::from_value(v.clone()).map_err(|e| FormatError::NotOurs(e.to_string()))?; if let Some(data) = v.get("assetData").and_then(Value::as_object) { for (k, s) in data { if let (Ok(id), Some(s)) = (k.parse::(), s.as_str()) && let Some(a) = doc.assets.get_mut(&AssetId(id)) { Arc::make_mut(a).data = Arc::new(base64_decode(s)); } } } doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } fn base64_decode(s: &str) -> Vec { let mut out = Vec::with_capacity(s.len() * 3 / 4); let (mut buf, mut bits) = (0u32, 0); for b in s.bytes() { let v = match b { b'A'..=b'Z' => b - b'A', b'a'..=b'z' => b - b'a' + 26, b'0'..=b'9' => b - b'0' + 52, b'+' | b'-' => 62, b'/' | b'_' => 63, _ => continue, } as u32; buf = buf << 6 | v; bits += 6; if bits >= 8 { bits -= 8; out.push((buf >> bits) as u8); } } out } #[cfg(test)] mod tests { use super::*; use designcraft_doc::build::NewDocument; use designcraft_doc::geom::Rect; use designcraft_doc::{Asset, ParaFormat, SpreadRef}; #[test] fn roundtrip_with_assets() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); let lid = d.default_layer(); d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "héllo\nworld", ParaFormat::default()).unwrap(); let aid = AssetId(d.alloc()); d.assets.insert( aid, Arc::new(Asset { page: 0, id: aid, name: "x.png".into(), mime: "image/png".into(), link: None, data: Arc::new(vec![1, 2, 3, 4]), pixels: Some((1, 1)), }), ); let bytes = save(&d).unwrap(); assert!(bytes.starts_with(b"PK")); let back = load(&bytes).unwrap(); assert_eq!(back.page_count(), 3); assert_eq!(*back.assets[&aid].data, vec![1, 2, 3, 4]); assert_eq!(back.stories, d.stories); } #[test] fn rejects_garbage_and_newer_versions() { assert!(load(b"nope").is_err()); let mut buf = Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); z.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap(); z.write_all(br#"{"version": 99}"#).unwrap(); z.finish().unwrap(); } assert!(matches!(load(&buf.into_inner()), Err(FormatError::TooNew(99)))); } }