From 65b2a68df0b46d987b44dd2a51f1d706c1a7c372 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 05:12:58 -0500 Subject: [PATCH 1/3] Add shared DesignCraft native format codec --- Cargo.lock | 11 ++ Cargo.toml | 1 + crates/designcraft-format/Cargo.toml | 18 ++ crates/designcraft-format/src/lib.rs | 247 +++++++++++++++++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 crates/designcraft-format/Cargo.toml create mode 100644 crates/designcraft-format/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b24081da4..75a575b7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1286,6 +1286,17 @@ dependencies = [ "proptest", ] +[[package]] +name = "craft-designcraft-format" +version = "0.1.0" +dependencies = [ + "craft-document-bundle", + "craft-eps", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-dialogs" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b86ae2fa8..7c38b6f92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ craft-dialogs = { path = "crates/dialogs" } craft-doc-binary = { path = "crates/doc-binary" } craft-docx = { path = "crates/docx" } craft-document-bundle = { path = "crates/document-bundle" } +craft-designcraft-format = { path = "crates/designcraft-format" } craft-dxf = { path = "crates/dxf" } craft-drawingml = { path = "crates/drawingml" } craft-eps = { path = "crates/eps" } diff --git a/crates/designcraft-format/Cargo.toml b/crates/designcraft-format/Cargo.toml new file mode 100644 index 000000000..19557b7ae --- /dev/null +++ b/crates/designcraft-format/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "craft-designcraft-format" +description = "Versioned DesignCraft document JSON codec and legacy migrations over craft-document-bundle" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-document-bundle = { workspace = true } +craft-eps = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/designcraft-format/src/lib.rs b/crates/designcraft-format/src/lib.rs new file mode 100644 index 000000000..b497aa4cd --- /dev/null +++ b/crates/designcraft-format/src/lib.rs @@ -0,0 +1,247 @@ +//! Versioned JSON and bundle codec for DesignCraft's native document format. +//! +//! The application owns its document model. This crate owns the on-disk schema version, +//! serialization, legacy JSON upgrades, version inference and the shared ZIP envelope. +#![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::{Read, Seek}; + +use craft_document_bundle::{Asset, Spec}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; + +pub const MIME: &str = "application/vnd.designcraft+zip"; +pub const VERSION: u32 = 4; +pub const EXTENSION: &str = "designcraft"; +pub const PREVIEW: &str = "preview.png"; +pub const MAX_PREVIEW_BYTES: u64 = 4 << 20; + +const BUNDLE: Spec<'static> = Spec { + mimetype_entry: "mimetype", + mimetype: MIME, + metadata_entry: "meta.json", + document_entry: "document.json", + preview_entry: PREVIEW, + assets_directory: "assets", + format_name: "designcraft", + legacy_assets_field: Some("assetData"), +}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("not a DesignCraft document: {0}")] + NotOurs(String), + #[error("document is from a newer DesignCraft (format {0}); please update")] + TooNew(u64), + #[error("{0}")] + Io(String), + #[error("document cannot be saved: {0}")] + Unsaveable(String), +} + +impl From for Error { + fn from(error: craft_document_bundle::Error) -> Self { + match error { + craft_document_bundle::Error::TooNew(version) => Error::TooNew(version), + craft_document_bundle::Error::NotBundle(message) => Error::NotOurs(message), + craft_document_bundle::Error::Read(message) | craft_document_bundle::Error::Write(message) => Error::Io(message), + } + } +} + +/// A decoded native document and the separately stored embedded assets. +#[derive(Clone, Debug, PartialEq)] +pub struct Loaded { + pub document: T, + pub assets: Vec<(String, Vec)>, +} + +/// The reader capability required by `document`, inferred from its neutral JSON schema and +/// embedded assets. Traversal beyond 64 levels conservatively requires version 4. +pub fn required_version(document: &Value, assets: &[Asset<'_>]) -> u32 { + if has_version_four_feature(document) { + return 4; + } + if assets.iter().any(|asset| craft_eps::is_eps(asset.data)) { 3 } else { 2 } +} + +/// Serialize a document and its external assets as a `.designcraft` ZIP bundle. +pub fn save(document: &T, generator: &str, preview: Option<&[u8]>, assets: &[Asset<'_>]) -> Result, Error> { + let value = serde_json::to_value(document).map_err(|error| Error::Io(error.to_string()))?; + let version = required_version(&value, assets); + let json = serde_json::to_vec_pretty(&value).map_err(|error| Error::Io(error.to_string()))?; + craft_document_bundle::write(&json, version, generator, preview, assets, BUNDLE).map_err(Into::into) +} + +/// Read a `.designcraft` ZIP or legacy single-file JSON, applying all versioned schema migrations +/// before deserializing the caller's model. +pub fn load(bytes: &[u8]) -> Result, Error> { + let mut package = craft_document_bundle::read(bytes, VERSION, BUNDLE)?; + upgrade_legacy_json(&mut package.document, package.version); + let document = serde_json::from_value(package.document).map_err(|error| Error::NotOurs(error.to_string()))?; + Ok(Loaded { document, assets: package.assets }) +} + +/// Whether `bytes` are a native DesignCraft ZIP package. +pub fn is_designcraft(bytes: &[u8]) -> bool { + craft_document_bundle::has_document(bytes, BUNDLE) +} + +/// Whether `prefix` begins with this format's stored mimetype entry. +pub fn starts_designcraft(prefix: &[u8]) -> bool { + craft_document_bundle::starts_with_mimetype(prefix, BUNDLE) +} + +/// Read the first-page PNG preview from a ZIP package, with a fixed 4 MiB cap. +pub fn read_preview(reader: R) -> Option> { + craft_document_bundle::read_preview_from(reader, PREVIEW) +} + +fn has_version_four_feature(document: &Value) -> bool { + let mut stack = vec![(document, 0usize)]; + let mut visited = 0usize; + while let Some((value, depth)) = stack.pop() { + if depth > 64 || visited >= 1_000_000 { + return true; + } + visited += 1; + match value { + Value::Object(object) => { + if object.get("layerOptions").is_some_and(|options| !options.is_null()) + || (object.get("pdfLayerOverride").and_then(Value::as_bool) == Some(true) + && object.get("pdfHiddenLayers").and_then(Value::as_array).is_none_or(Vec::is_empty)) + { + return true; + } + stack.extend(object.values().map(|child| (child, depth + 1))); + } + Value::Array(children) => stack.extend(children.iter().map(|child| (child, depth + 1))), + _ => {} + } + } + false +} + +fn upgrade_legacy_json(document: &mut Value, from: u64) { + let mut vertical = HashSet::new(); + vertical_frames(document, &mut vertical, 0); + if let Some(stories) = document.get_mut("stories").and_then(Value::as_object_mut) { + for story in stories.values_mut() { + let first = story.get("frames").and_then(|frames| frames.get(0)).and_then(Value::as_u64); + if let (Some(frame), Some(story)) = (first, story.as_object_mut()) + && vertical.contains(&frame) + && !story.contains_key("vertical") + { + story.insert("vertical".into(), Value::Bool(true)); + } + } + } + if from < 2 { + pin_paragraph_defaults(document); + } +} + +fn vertical_frames(value: &Value, out: &mut HashSet, depth: usize) { + if depth > 256 { + return; + } + match value { + Value::Object(object) => { + if let (Some(id), Some(content)) = (object.get("id").and_then(Value::as_u64), object.get("content")) + && content.get("type").and_then(Value::as_str) == Some("text") + && content.get("options").and_then(|options| options.get("vertical")).and_then(Value::as_bool) == Some(true) + { + out.insert(id); + } + for child in object.values() { + vertical_frames(child, out, depth + 1); + } + } + Value::Array(children) => { + for child in children { + vertical_frames(child, out, depth + 1); + } + } + _ => {} + } +} + +fn pin_paragraph_defaults(document: &mut Value) { + const MAX_STYLE_DEPTH: usize = 64; + let styles = document.pointer("/styles/paragraph").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut by_name: HashMap, bool, bool)> = HashMap::new(); + for style in &styles { + let Some(name) = style.get("name").and_then(Value::as_str) else { continue }; + let based_on = style.get("basedOn").and_then(Value::as_str).map(str::to_string); + let attrs = style.get("para"); + let missing_keep = attrs.and_then(|attrs| attrs.get("keepAllLines")).is_none(); + let missing_drop_cap = attrs.and_then(|attrs| attrs.get("dropCapAlignLeft")).is_none(); + by_name.entry(name.to_string()).or_insert((based_on, missing_keep, missing_drop_cap)); + } + + let mut roots = HashSet::new(); + for name in by_name.keys() { + let mut chain = Vec::new(); + let mut seen = HashSet::new(); + let mut current = Some(name.as_str()); + while let Some(style_name) = current { + let Some((based_on, missing_keep, missing_drop_cap)) = by_name.get(style_name) else { break }; + if chain.len() >= MAX_STYLE_DEPTH || !seen.insert(style_name.to_string()) { + break; + } + chain.push((style_name, *missing_keep, *missing_drop_cap)); + current = based_on.as_deref(); + } + if !chain.is_empty() + && chain.iter().all(|(_, keep, drop_cap)| *keep && *drop_cap) + && let Some((root, _, _)) = chain.last() + { + roots.insert((*root).to_string()); + } + } + if let Some(styles) = document.pointer_mut("/styles/paragraph").and_then(Value::as_array_mut) { + for style in styles { + if let Some(style) = style.as_object_mut() + && style.get("name").and_then(Value::as_str).is_some_and(|name| roots.contains(name)) + { + let attrs = style.entry("para").or_insert_with(|| Value::Object(Default::default())); + if let Some(attrs) = attrs.as_object_mut() { + attrs.entry("keepAllLines").or_insert(Value::Bool(true)); + attrs.entry("dropCapAlignLeft").or_insert(Value::Bool(false)); + } + } + } + } + let known_styles: HashSet = by_name.into_keys().collect(); + pin_unstyled_paragraphs(document, &known_styles, 0); +} + +fn pin_unstyled_paragraphs(value: &mut Value, known_styles: &HashSet, depth: usize) { + if depth > 256 { + return; + } + match value { + Value::Object(object) => { + if let Some(style) = object.get("style").and_then(Value::as_str) + && !known_styles.contains(style) + { + let attrs = object.entry("para").or_insert_with(|| Value::Object(Default::default())); + if let Some(attrs) = attrs.as_object_mut() { + attrs.entry("keepAllLines").or_insert(Value::Bool(true)); + attrs.entry("dropCapAlignLeft").or_insert(Value::Bool(false)); + } + } + for child in object.values_mut() { + pin_unstyled_paragraphs(child, known_styles, depth + 1); + } + } + Value::Array(children) => { + for child in children { + pin_unstyled_paragraphs(child, known_styles, depth + 1); + } + } + _ => {} + } +} From 19692b6190465671e41967c75d0cd4c439d0c84c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 05:15:16 -0500 Subject: [PATCH 2/3] Expose shared native bundle asset type --- crates/designcraft-format/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/designcraft-format/src/lib.rs b/crates/designcraft-format/src/lib.rs index b497aa4cd..21633a464 100644 --- a/crates/designcraft-format/src/lib.rs +++ b/crates/designcraft-format/src/lib.rs @@ -8,7 +8,8 @@ use std::collections::{HashMap, HashSet}; use std::io::{Read, Seek}; -use craft_document_bundle::{Asset, Spec}; +pub use craft_document_bundle::Asset; +use craft_document_bundle::Spec; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; From c82752776ad0a99f4d381ad7a0766a6015554adb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 11:55:14 +0000 Subject: [PATCH 3/3] Test DesignCraft native wire compatibility --- crates/designcraft-format/src/lib.rs | 3 ++ crates/designcraft-format/src/tests.rs | 72 ++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 crates/designcraft-format/src/tests.rs diff --git a/crates/designcraft-format/src/lib.rs b/crates/designcraft-format/src/lib.rs index 21633a464..6256eab1c 100644 --- a/crates/designcraft-format/src/lib.rs +++ b/crates/designcraft-format/src/lib.rs @@ -246,3 +246,6 @@ fn pin_unstyled_paragraphs(value: &mut Value, known_styles: &HashSet, de _ => {} } } + +#[cfg(test)] +mod tests; diff --git a/crates/designcraft-format/src/tests.rs b/crates/designcraft-format/src/tests.rs new file mode 100644 index 000000000..0ef2ca191 --- /dev/null +++ b/crates/designcraft-format/src/tests.rs @@ -0,0 +1,72 @@ +use super::*; +use serde_json::json; + +#[test] +fn native_bundle_roundtrips_document_assets_and_preview() -> Result<(), Box> { + let document = json!({"title":"A page", "assets":{"7":{"mime":"image/png"}}}); + let image = [1u8, 2, 3, 4]; + let assets = [Asset { id: "7", mime: "image/png", data: &image }]; + let preview = b"preview png"; + let bytes = save(&document, "DesignCraft test", Some(preview), &assets)?; + + assert!(is_designcraft(&bytes)); + assert!(starts_designcraft(&bytes[..bytes.len().min(80)])); + let loaded = load::(&bytes)?; + assert_eq!(loaded.document, document); + assert_eq!(loaded.assets, [("7".to_owned(), image.to_vec())]); + assert_eq!(read_preview(std::io::Cursor::new(&bytes)).as_deref(), Some(&preview[..])); + Ok(()) +} + +#[test] +fn preview_reader_rejects_an_entry_over_its_declared_read_budget() -> Result<(), Box> { + let oversized = vec![0u8; usize::try_from(MAX_PREVIEW_BYTES + 1)?]; + let bytes = save(&json!({}), "DesignCraft test", Some(&oversized), &[])?; + assert_eq!(read_preview(std::io::Cursor::new(bytes)), None); + Ok(()) +} + +#[test] +fn required_version_preserves_the_reader_capability_contract() { + assert_eq!(required_version(&json!({"title":"plain"}), &[]), 2); + assert_eq!(required_version(&json!({}), &[Asset { id: "1", mime: "application/postscript", data: b"%!PS-Adobe-3.0 EPSF-3.0" }]), 3); + assert_eq!(required_version(&json!({"layerOptions":{}}), &[]), 4); + assert_eq!(required_version(&json!({"pdfLayerOverride":true,"pdfHiddenLayers":[]}), &[]), 4); + assert_eq!(required_version(&json!({"pdfLayerOverride":true,"pdfHiddenLayers":["guide"]}), &[]), 2); + + let mut nested = json!(null); + for _ in 0..65 { + nested = json!([nested]); + } + assert_eq!(required_version(&nested, &[]), 4); +} + +#[test] +fn legacy_json_extracts_assets_and_applies_v1_migrations() -> Result<(), Box> { + let legacy = json!({ + "assetData":{"7":"AQID"}, + "frames":[{"id":8,"content":{"type":"text","options":{"vertical":true}}}], + "stories":{"vertical":{"frames":[8]},"explicit":{"frames":[8],"vertical":false}}, + "styles":{"paragraph":[{"name":"Base"},{"name":"Child","basedOn":"Base","para":{"keepAllLines":false}}]}, + "paragraphs":[{"style":"Missing"}] + }); + let loaded = load::(&serde_json::to_vec(&legacy)?)?; + + assert_eq!(loaded.assets, [("7".to_owned(), vec![1, 2, 3])]); + assert_eq!(loaded.document.get("assetData"), None); + assert_eq!(loaded.document["stories"]["vertical"]["vertical"], true); + assert_eq!(loaded.document["stories"]["explicit"]["vertical"], false); + assert_eq!(loaded.document["styles"]["paragraph"][0]["para"]["keepAllLines"], true); + assert_eq!(loaded.document["styles"]["paragraph"][0]["para"]["dropCapAlignLeft"], false); + assert_eq!(loaded.document["styles"]["paragraph"][1]["para"]["keepAllLines"], false); + assert_eq!(loaded.document["paragraphs"][0]["para"]["keepAllLines"], true); + Ok(()) +} + +#[test] +fn newer_metadata_precedes_malformed_document_json() -> Result<(), Box> { + let bytes = craft_document_bundle::write(b"{", VERSION + 1, "future", None, &[], BUNDLE)?; + assert!(matches!(load::(&bytes), Err(Error::TooNew(version)) if version == u64::from(VERSION + 1))); + assert!(matches!(load::(b"not a package"), Err(Error::NotOurs(_)))); + Ok(()) +}