diff --git a/Cargo.lock b/Cargo.lock index 600e32a75..e3634c4a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1090,6 +1090,18 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-designcraft-format" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=19692b6190465671e41967c75d0cd4c439d0c84c#19692b6190465671e41967c75d0cd4c439d0c84c" +dependencies = [ + "craft-document-bundle", + "craft-eps 0.1.0 (git+https://github.com/storytold/craft-libs?rev=19692b6190465671e41967c75d0cd4c439d0c84c)", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-dialogs" version = "0.1.0" @@ -1111,9 +1123,9 @@ source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13af [[package]] name = "craft-document-bundle" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" +source = "git+https://github.com/storytold/craft-libs?rev=19692b6190465671e41967c75d0cd4c439d0c84c#19692b6190465671e41967c75d0cd4c439d0c84c" dependencies = [ - "craft-zip 0.1.0 (git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf)", + "craft-zip 0.1.0 (git+https://github.com/storytold/craft-libs?rev=19692b6190465671e41967c75d0cd4c439d0c84c)", "serde_json", "thiserror 2.0.21", "zip 9.1.1", @@ -1161,6 +1173,11 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-eps" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=19692b6190465671e41967c75d0cd4c439d0c84c#19692b6190465671e41967c75d0cd4c439d0c84c" + [[package]] name = "craft-eps" version = "0.1.0" @@ -1660,6 +1677,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-zip" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=19692b6190465671e41967c75d0cd4c439d0c84c#19692b6190465671e41967c75d0cd4c439d0c84c" +dependencies = [ + "flate2", + "thiserror 2.0.21", + "zip 9.1.1", +] + [[package]] name = "craft-zip" version = "0.1.0" @@ -2002,12 +2029,9 @@ dependencies = [ name = "designcraft-format" version = "0.20.1" dependencies = [ - "craft-document-bundle", - "craft-eps 0.1.0 (git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf)", + "craft-designcraft-format", "designcraft-doc", - "serde", "serde_json", - "thiserror 2.0.21", "zip 3.0.0", ] diff --git a/Cargo.toml b/Cargo.toml index 9f399a2b2..c17950ebf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ repository = "https://github.com/storytold/designcraft" [workspace.dependencies] # Shared reader and writer; publish the immutable revision before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "40525913633942f84b06d4837a8d90534a55d63f", version = "=0.1.0", default-features = false } -craft-document-bundle = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } +craft-designcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "19692b6190465671e41967c75d0cd4c439d0c84c", version = "=0.1.0" } craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-epub = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-idml = { git = "https://github.com/storytold/craft-libs", rev = "fbdd905cdddd2f0de930021a3f92bcf1684973be", version = "=0.1.0" } diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 23e80faaa..a94edbf2c 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -9,12 +9,9 @@ description = "DesignCraft native file format: zip with document.json and embedd workspace = true [dependencies] -craft-document-bundle = { workspace = true } -craft-eps = { workspace = true } +craft-designcraft-format = { workspace = true } designcraft-doc = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } [dev-dependencies] +serde_json = { workspace = true } zip = { workspace = true } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index da6358771..bcfacd976 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -1,61 +1,25 @@ -//! The native `.designcraft` document format: the document model plus its shared ZIP bundle -//! envelope (`mimetype`, metadata, JSON, preview and embedded assets). -//! -//! The model stays in DesignCraft. `craft-document-bundle` owns package IO, bounded ZIP reads, -//! compression, preview extraction and legacy inline-asset decoding. +//! Thin DesignCraft model adapter for the shared native document format. #![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::collections::HashMap; use std::hash::BuildHasher; -use std::io::{Read, Seek}; use std::sync::Arc; -use designcraft_doc::{AssetId, Document, ParaAttrs, Story}; +use designcraft_doc::{AssetId, Document}; +#[cfg(test)] use serde_json::Value; +#[cfg(test)] +use std::collections::HashSet; -pub const MIME: &str = "application/vnd.designcraft+zip"; -/// Latest reader capability. Version 2 pinned paragraph defaults, version 3 adds retained EPS -/// sources, and version 4 adds placed-graphic and PDF layer options. -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: craft_document_bundle::Spec<'static> = craft_document_bundle::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"), +pub use craft_designcraft_format::{ + EXTENSION, Error as FormatError, MAX_PREVIEW_BYTES, MIME, PREVIEW, VERSION, is_designcraft, read_preview, starts_designcraft, }; -#[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(u64), - #[error("{0}")] - Io(String), - #[error("document cannot be saved: {0}")] - Unsaveable(String), -} +#[cfg(test)] +mod tests; -impl From for FormatError { - fn from(error: craft_document_bundle::Error) -> Self { - match error { - craft_document_bundle::Error::TooNew(version) => FormatError::TooNew(version), - craft_document_bundle::Error::NotBundle(message) => FormatError::NotOurs(message), - craft_document_bundle::Error::Read(message) | craft_document_bundle::Error::Write(message) => FormatError::Io(message), - } - } -} - -/// Serialize a document to `.designcraft` bytes. +/// Serialize a DesignCraft document to `.designcraft` bytes. pub fn save(doc: &Document) -> Result, FormatError> { save_with_preview(doc, None) } @@ -67,48 +31,32 @@ pub fn save_with_preview(doc: &Document, preview: Option<&[u8]>) -> Result>(); - let assets = owned_assets.iter().map(|(id, mime, data)| craft_document_bundle::Asset { id, mime, data }).collect::>(); - craft_document_bundle::write(&json, version, concat!("DesignCraft ", env!("CARGO_PKG_VERSION")), preview, &assets, BUNDLE).map_err(Into::into) + let assets = owned_assets.iter().map(|(id, mime, data)| craft_designcraft_format::Asset { id, mime, data }).collect::>(); + craft_designcraft_format::save(&compact, concat!("DesignCraft ", env!("CARGO_PKG_VERSION")), preview, &assets) } +/// Required native format version for a document. Kept for app-side compatibility; inference is +/// performed by the shared codec over the serialized neutral document schema. +#[cfg(test)] fn required_version(doc: &Document) -> u32 { - if doc.has_graphic_layer_options() || doc.has_explicit_all_on_pdf_layers() { - return 4; - } - if doc.assets.values().any(|asset| craft_eps::is_eps(&asset.data)) { 3 } else { 2 } + let Ok(value) = serde_json::to_value(doc) else { return VERSION }; + let owned_assets = doc.assets.values().map(|asset| (asset.id.0.to_string(), asset.mime.clone(), asset.data.clone())).collect::>(); + let assets = owned_assets.iter().map(|(id, mime, data)| craft_designcraft_format::Asset { id, mime, data }).collect::>(); + craft_designcraft_format::required_version(&value, &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 seekable package file, with a fixed 4 MiB cap. -pub fn read_preview(reader: R) -> Option> { - craft_document_bundle::read_preview_from(reader, PREVIEW) -} - -/// Read `.designcraft` bytes (ZIP, or the legacy single JSON file). +/// Read `.designcraft` bytes (ZIP, or legacy single JSON). pub fn load(bytes: &[u8]) -> Result { - let package = craft_document_bundle::read(bytes, VERSION, BUNDLE)?; - let mut value = package.document; - upgrade_json(&mut value); - let mut doc: Document = serde_json::from_value(value).map_err(|e| FormatError::NotOurs(e.to_string()))?; + let loaded = craft_designcraft_format::load::(bytes)?; + let mut doc = loaded.document; let fingerprint = std::collections::hash_map::RandomState::new(); - let mut loaded: HashMap>>> = HashMap::new(); - for (raw_id, bytes) in package.assets { + let mut dedup: HashMap>>> = HashMap::new(); + for (raw_id, bytes) in loaded.assets { let Ok(id) = raw_id.parse::() else { continue }; let asset_id = AssetId(id); let Some(asset) = doc.assets.get_mut(&asset_id) else { continue }; - let same = loaded.entry(fingerprint.hash_one(bytes.as_slice())).or_default(); + let same = dedup.entry(fingerprint.hash_one(bytes.as_slice())).or_default(); let data = match same.iter().find(|data| ***data == bytes) { Some(data) => data.clone(), None => { @@ -119,103 +67,9 @@ pub fn load(bytes: &[u8]) -> Result { }; Arc::make_mut(asset).data = data; } - upgrade(&mut doc, u32::try_from(package.version).unwrap_or(u32::MAX)); doc.merge_duplicate_assets(); doc.mark_differing_margins_local(); doc.renumber_duplicate_items(); - doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; + doc.check().map_err(|error| FormatError::NotOurs(error.to_string()))?; Ok(doc) } - -/// Bring a document saved in format `from` up to [`VERSION`]. -fn upgrade(doc: &mut Document, from: u32) { - if from < 2 { - pin_para_default(doc, |attrs| &mut attrs.keep_all_lines, true); - pin_para_default(doc, |attrs| &mut attrs.drop_cap_align_left, false); - } -} - -/// Pin an unset paragraph attribute to its old resolved value where an old format requires it. -fn pin_para_default(doc: &mut Document, field: fn(&mut ParaAttrs) -> &mut Option, old: T) { - let unset = |a: &ParaAttrs| field(&mut a.clone()).is_none(); - // Every style is looked up by name: through one index, not a search of all styles each time. - let index = doc.styles.para_index(); - let roots: std::collections::HashSet = doc - .styles - .paragraph - .iter() - .filter_map(|s| { - let chain = designcraft_doc::Styles::para_chain_by(&s.name, |n| index.get(n).copied()); - if chain.iter().all(|c| unset(&c.para)) { chain.first().map(|c| c.name.clone()) } else { None } - }) - .collect(); - if !roots.is_empty() { - for style in doc.styles_mut().paragraph.iter_mut().filter(|style| roots.contains(&style.name)) { - *field(&mut style.para) = Some(old.clone()); - } - } - // Borrowed names: one entry per style already in memory, so no copy and no extra bound needed. - let names: std::collections::HashSet<&str> = doc.styles.paragraph.iter().map(|s| s.name.as_str()).collect(); - let mut pin = |st: &mut Story| { - for p in st.paras.iter_mut().filter(|p| !names.contains(p.style.as_str())) { - let v = field(&mut p.para); - if v.is_none() { - *v = Some(old.clone()); - } - } - }; - for story in doc.stories.values_mut() { - let story = Arc::make_mut(story); - story.for_each_text_mut(&mut pin); - for note in &mut story.endnotes { - Arc::make_mut(note).text.for_each_text_mut(&mut pin); - } - } -} - -/// Upgrade the old vertical-text representation before deserializing the current model. -fn upgrade_json(document: &mut Value) { - let mut vertical = HashSet::new(); - vertical_frames(document, &mut vertical, 0); - if vertical.is_empty() { - return; - } - let Some(stories) = document.get_mut("stories").and_then(Value::as_object_mut) else { return }; - 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)); - } - } -} - -fn vertical_frames(value: &Value, out: &mut HashSet, depth: usize) { - if depth > 256 { - return; - } - match value { - Value::Object(map) => { - if let (Some(id), Some(content)) = (map.get("id").and_then(Value::as_u64), map.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 map.values() { - vertical_frames(child, out, depth + 1); - } - } - Value::Array(children) => { - for child in children { - vertical_frames(child, out, depth + 1); - } - } - _ => {} - } -} - -#[cfg(test)] -mod tests;