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:
parent
c2f80260a3
commit
e3c5df2212
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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))));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue