designcraft/crates/format/src/lib.rs

422 lines
18 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: one per distinct file, and only
//! the ones the document references (see [`Document::compact_assets`])
//! - `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::{HashMap, 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. The file holds each distinct placed file once
/// and leaves out assets nothing references; `doc` itself keeps them (undo can bring them back).
pub fn save(doc: &Document) -> Result<Vec<u8>, FormatError> {
let mut compact = doc.clone();
compact.compact_assets();
let doc = &compact;
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();
// Files saved with one copy per placement: identical copies share one buffer as they are
// read, so memory holds each distinct file once.
let mut loaded: HashMap<usize, Vec<Arc<Vec<u8>>>> = HashMap::new();
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))) {
let same = loaded.entry(data.len()).or_default();
let data = match same.iter().find(|d| ***d == data) {
Some(d) => d.clone(),
None => {
let d = Arc::new(data);
same.push(d.clone());
d
}
};
Arc::make_mut(a).data = data;
}
}
doc.merge_duplicate_assets();
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.merge_duplicate_assets();
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};
fn png_asset(d: &mut Document, data: &[u8]) -> AssetId {
let aid = AssetId(d.alloc());
let a = Asset {
page: 0,
id: aid,
name: "x.png".into(),
mime: "image/png".into(),
link: None,
data: Arc::new(data.to_vec()),
pixels: Some((1, 1)),
};
d.assets.insert(aid, Arc::new(a));
aid
}
fn place(d: &mut Document, asset: AssetId, crop: f64) -> designcraft_doc::ItemId {
use designcraft_doc::geom::{Affine, shapes};
use designcraft_doc::{Content, Graphic, Item, ItemId, Shape};
let id = ItemId(d.alloc());
let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)));
it.content = Content::Graphic(Graphic {
asset,
size: (1.0, 1.0),
xf: Affine::translate((crop, crop)),
auto_fit: Default::default(),
fit_align: 4,
crop: [crop; 4],
});
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
id
}
fn asset_entries(bytes: &[u8]) -> usize {
let z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
z.file_names().filter(|n| n.starts_with("assets/")).count()
}
/// Zip `d` the way files were saved with one asset per placement (nothing merged or pruned).
fn save_uncompacted(d: &Document) -> Vec<u8> {
let mut buf = Cursor::new(Vec::new());
let mut z = zip::ZipWriter::new(&mut buf);
let opts = zip::write::SimpleFileOptions::default();
z.start_file("mimetype", opts).unwrap();
z.write_all(MIME.as_bytes()).unwrap();
z.start_file("document.json", opts).unwrap();
z.write_all(&serde_json::to_vec(d).unwrap()).unwrap();
for (id, a) in &d.assets {
z.start_file(format!("assets/{}.png", id.0), opts).unwrap();
z.write_all(&a.data).unwrap();
}
z.finish().unwrap();
buf.into_inner()
}
#[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 = png_asset(&mut d, &[1, 2, 3, 4]);
place(&mut d, aid, 0.0);
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 saves_hold_each_used_file_once() {
let mut d = Document::new(&NewDocument::default());
let first = png_asset(&mut d, &[7; 64]);
let copy = png_asset(&mut d, &[7; 64]);
let replaced = png_asset(&mut d, &[9; 64]);
let a = place(&mut d, first, 1.0);
let b = place(&mut d, copy, 2.0);
assert_eq!(d.assets.len(), 3);
// Duplicates merge into the first asset, the unreferenced one is left out, and every
// placement keeps its own crop and transform.
let bytes = save(&d).unwrap();
assert_eq!(asset_entries(&bytes), 1);
assert_eq!(d.assets.len(), 3, "the open document keeps its assets");
let back = load(&bytes).unwrap();
assert_eq!(back.assets.keys().copied().collect::<Vec<_>>(), vec![first]);
assert!(!back.assets.contains_key(&replaced));
for (id, crop) in [(a, 1.0), (b, 2.0)] {
let g = back.item(id).and_then(|i| i.graphic()).unwrap();
assert_eq!((g.asset, g.crop, g.xf), (first, [crop; 4], d.item(id).unwrap().graphic().unwrap().xf));
}
}
#[test]
fn files_with_identical_bytes_at_different_paths_keep_their_paths() {
let mut d = Document::new(&NewDocument::default());
let ids: Vec<AssetId> = (0..3).map(|_| png_asset(&mut d, &[3; 32])).collect();
for (id, link) in ids.iter().zip(["/p/a.png", "/q/b.png"]) {
if let Some(a) = d.assets.get_mut(id) {
Arc::make_mut(a).link = Some(link.into());
}
}
let items: Vec<_> = ids.iter().enumerate().map(|(k, id)| place(&mut d, *id, k as f64)).collect();
for bytes in [save(&d).unwrap(), save_uncompacted(&d)] {
let back = load(&bytes).unwrap();
assert_eq!(back.assets.len(), 3);
let links: Vec<Option<&str>> = items
.iter()
.map(|i| back.item(*i).and_then(|i| i.graphic()).and_then(|g| back.assets.get(&g.asset)).unwrap().link.as_deref())
.collect();
assert_eq!(links, [Some("/p/a.png"), Some("/q/b.png"), None]);
}
}
#[test]
fn older_files_with_one_asset_per_placement_open_with_one() {
let mut d = Document::new(&NewDocument::default());
let ids: Vec<AssetId> = (0..3).map(|_| png_asset(&mut d, &[5; 32])).collect();
for (k, id) in ids.iter().enumerate() {
place(&mut d, *id, k as f64);
}
let back = load(&save_uncompacted(&d)).unwrap();
assert_eq!(back.assets.len(), 1);
assert_eq!(asset_entries(&save(&back).unwrap()), 1);
}
/// 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 documents_saved_before_glyph_fallback_keep_their_fallback_fonts() {
let d = Document::new(&NewDocument::default());
assert!(!d.settings.glyph_fallback);
assert!(!load(&save(&d).unwrap()).unwrap().settings.glyph_fallback, "saved and read as off");
let mut on = d.clone();
on.settings.glyph_fallback = true;
assert!(load(&save(&on).unwrap()).unwrap().settings.glyph_fallback);
// Written before the setting existed: drawn from fallback fonts, as they were.
let mut v = serde_json::to_value(&d).unwrap();
v["settings"].as_object_mut().unwrap().remove("glyphFallback").unwrap();
assert!(load(&serde_json::to_vec(&v).unwrap()).unwrap().settings.glyph_fallback);
}
#[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))));
}
}