289 lines
12 KiB
Rust
289 lines
12 KiB
Rust
//! 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.
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
#![forbid(unsafe_code)]
|
|
|
|
use std::collections::HashSet;
|
|
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 v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
|
upgrade(&mut v);
|
|
let mut doc: Document = serde_json::from_value(v).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 mut v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
|
upgrade(&mut v);
|
|
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)
|
|
}
|
|
|
|
/// Bring older documents' JSON up to the current model. Vertical Type was a text frame option
|
|
/// (`content.options.vertical`); it is the story's direction, which a story takes from its first
|
|
/// frame.
|
|
fn upgrade(doc: &mut Value) {
|
|
let mut vertical = HashSet::new();
|
|
vertical_frames(doc, &mut vertical, 0);
|
|
if vertical.is_empty() {
|
|
return;
|
|
}
|
|
let Some(stories) = doc.get_mut("stories").and_then(Value::as_object_mut) else { return };
|
|
for st in stories.values_mut() {
|
|
let first = st.get("frames").and_then(|f| f.get(0)).and_then(Value::as_u64);
|
|
if let (Some(f), Some(st)) = (first, st.as_object_mut())
|
|
&& vertical.contains(&f)
|
|
&& !st.contains_key("vertical")
|
|
{
|
|
st.insert("vertical".into(), Value::Bool(true));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ids of the text frames in `v` (any depth: groups, anchored objects) whose options say vertical.
|
|
fn vertical_frames(v: &Value, out: &mut HashSet<u64>, depth: usize) {
|
|
// serde_json already refuses JSON nested deeper than 128 levels.
|
|
if depth > 256 {
|
|
return;
|
|
}
|
|
match v {
|
|
Value::Object(m) => {
|
|
if let (Some(id), Some(c)) = (m.get("id").and_then(Value::as_u64), m.get("content"))
|
|
&& c.get("type").and_then(Value::as_str) == Some("text")
|
|
&& c.get("options").and_then(|o| o.get("vertical")).and_then(Value::as_bool) == Some(true)
|
|
{
|
|
out.insert(id);
|
|
}
|
|
for c in m.values() {
|
|
vertical_frames(c, out, depth + 1);
|
|
}
|
|
}
|
|
Value::Array(a) => {
|
|
for c in a {
|
|
vertical_frames(c, out, depth + 1);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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);
|
|
}
|
|
|
|
/// The object of the item `id` in a document's JSON.
|
|
fn item_json(v: &mut Value, id: u64) -> Option<&mut Value> {
|
|
if v.get("id").and_then(Value::as_u64) == Some(id) && v.get("content").is_some() {
|
|
return Some(v);
|
|
}
|
|
match v {
|
|
Value::Object(m) => m.values_mut().find_map(|c| item_json(c, id)),
|
|
Value::Array(a) => a.iter_mut().find_map(|c| item_json(c, id)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn frame_level_vertical_text_opens_as_a_vertical_story() {
|
|
// Documents that set Vertical on the frame: the story takes its first frame's direction.
|
|
let mut d = Document::new(&NewDocument::default());
|
|
let lid = d.default_layer();
|
|
let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 200.0, 400.0), lid, "縦書き", ParaFormat::default()).unwrap();
|
|
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 72.0, 428.0, 400.0), lid, "", ParaFormat::default()).unwrap();
|
|
d.thread(a, b).unwrap();
|
|
let (c, other) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(450.0, 72.0, 550.0, 400.0), lid, "横", ParaFormat::default()).unwrap();
|
|
let mut v = serde_json::to_value(&d).unwrap();
|
|
item_json(&mut v, a.0).unwrap()["content"]["options"]["vertical"] = json!(true);
|
|
item_json(&mut v, c.0).unwrap()["content"]["options"]["vertical"] = json!(false);
|
|
let json = serde_json::to_vec(&v).unwrap();
|
|
let mut zipped = Cursor::new(Vec::new());
|
|
{
|
|
let mut z = zip::ZipWriter::new(&mut zipped);
|
|
z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
|
|
z.write_all(&json).unwrap();
|
|
z.finish().unwrap();
|
|
}
|
|
for bytes in [json.clone(), zipped.into_inner()] {
|
|
let back = load(&bytes).unwrap();
|
|
assert!(back.stories[&sid].vertical);
|
|
assert!(!back.stories[&other].vertical);
|
|
// Saved again, the direction is the story's.
|
|
let again = load(&save(&back).unwrap()).unwrap();
|
|
assert!(again.stories[&sid].vertical && !again.stories[&other].vertical);
|
|
}
|
|
}
|
|
|
|
#[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))));
|
|
}
|
|
}
|