M6.1: native .designcraft zip format (document.json + raw assets, versioned; legacy JSON still opens)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 11:49:53 -04:00
parent c2f80260a3
commit e3c5df2212
6 changed files with 270 additions and 20 deletions

58
Cargo.lock generated
View File

@ -198,6 +198,15 @@ dependencies = [
"libc",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
@ -805,6 +814,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "designcraft"
version = "0.1.0"
@ -874,6 +894,7 @@ dependencies = [
"designcraft-compose",
"designcraft-doc",
"designcraft-fonts",
"designcraft-format",
"designcraft-geom",
"designcraft-pdf",
"designcraft-render",
@ -899,6 +920,17 @@ dependencies = [
"skrifa 0.47.0",
]
[[package]]
name = "designcraft-format"
version = "0.1.0"
dependencies = [
"designcraft-doc",
"serde",
"serde_json",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "designcraft-geom"
version = "0.1.0"
@ -4925,6 +4957,20 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zip"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
@ -4937,6 +4983,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.3"

View File

@ -46,7 +46,7 @@ vello_common = { version = "0.2", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff"] }
krilla = "0.8"
quick-xml = "0.38"
zip = { version = "2.6", default-features = false, features = ["deflate"] }
zip = { version = "3", default-features = false, features = ["deflate"] }
# ui
egui = "0.36"

View File

@ -15,6 +15,7 @@ designcraft-doc = { workspace = true }
designcraft-fonts = { workspace = true }
designcraft-compose = { workspace = true }
designcraft-render = { workspace = true }
designcraft-format = { workspace = true }
designcraft-pdf = { workspace = true }
designcraft-tools = { workspace = true }
serde = { workspace = true }

View File

@ -44,28 +44,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
pub fn to_bytes(d: &Document) -> Vec<u8> {
// Assets are embedded as base64 alongside the JSON document.
let mut v = serde_json::to_value(d).unwrap_or_default();
let assets: serde_json::Map<String, Value> = d.assets.iter().map(|(k, a)| (k.0.to_string(), Value::from(base64_encode(&a.data)))).collect();
v["assetData"] = Value::Object(assets);
v["format"] = json!({"name": "designcraft", "version": 1});
serde_json::to_vec(&v).unwrap_or_default()
designcraft_format::save(d).unwrap_or_default()
}
pub fn from_bytes(b: &[u8]) -> Result<Document> {
let v: Value = serde_json::from_slice(b).map_err(|e| EngineError::Other(format!("not a DesignCraft document: {e}")))?;
let mut d: Document = serde_json::from_value(v.clone()).map_err(|e| EngineError::Other(format!("bad document: {e}")))?;
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::<u64>(), s.as_str())
&& let Some(a) = d.assets.get_mut(&AssetId(id))
{
Arc::make_mut(a).data = Arc::new(base64_decode(s));
}
}
}
d.check()?;
Ok(d)
designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string()))
}
fn file_new(s: &mut Session, p: &Value) -> Result<Value> {

16
crates/format/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "designcraft-format"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DesignCraft native file format: zip with document.json and embedded assets; versioned"
[lints]
workspace = true
[dependencies]
designcraft-doc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
zip = { workspace = true }
thiserror = { workspace = true }

192
crates/format/src/lib.rs Normal file
View File

@ -0,0 +1,192 @@
//! 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/<id>.<ext>`: embedded placed files, byte-for-byte
//! - `meta.json`: format version and generator
//!
//! Older single-file JSON documents (assets as base64 in `assetData`) still open.
#![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<Vec<u8>, 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<Document, FormatError> {
if bytes.starts_with(b"PK") {
return load_zip(bytes);
}
load_legacy_json(bytes)
}
fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?;
let read = |z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str| -> Option<Vec<u8>> {
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::<Value>(&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<String> = 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::<u64>().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<Document, FormatError> {
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::<u64>(), 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<u8> {
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 {
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))));
}
}