Delegate native DesignCraft format codec

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 05:17:19 -05:00
1 parent 045fb22e8b
commit bd757a98ac
4 files changed
+60 -185

No files matched your search

Generated
+30 -6
View File
@@ -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",
]
+1 -1
View File
@@ -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" }
+2 -5
View File
@@ -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 }
+27 -173
View File
@@ -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<craft_document_bundle::Error> 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<Vec<u8>, FormatError> {
save_with_preview(doc, None)
}
@@ -67,48 +31,32 @@ pub fn save_with_preview(doc: &Document, preview: Option<&[u8]>) -> Result<Vec<u
}
let mut compact = doc.clone();
compact.compact_assets();
let version = required_version(&compact);
let json = serde_json::to_vec_pretty(&compact).map_err(|e| FormatError::Io(e.to_string()))?;
let owned_assets = compact.assets.values().map(|asset| (asset.id.0.to_string(), asset.mime.clone(), asset.data.clone())).collect::<Vec<_>>();
let assets = owned_assets.iter().map(|(id, mime, data)| craft_document_bundle::Asset { id, mime, data }).collect::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
let assets = owned_assets.iter().map(|(id, mime, data)| craft_designcraft_format::Asset { id, mime, data }).collect::<Vec<_>>();
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<R: Read + Seek>(reader: R) -> Option<Vec<u8>> {
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<Document, FormatError> {
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::<Document>(bytes)?;
let mut doc = loaded.document;
let fingerprint = std::collections::hash_map::RandomState::new();
let mut loaded: HashMap<u64, Vec<Arc<Vec<u8>>>> = HashMap::new();
for (raw_id, bytes) in package.assets {
let mut dedup: HashMap<u64, Vec<Arc<Vec<u8>>>> = HashMap::new();
for (raw_id, bytes) in loaded.assets {
let Ok(id) = raw_id.parse::<u64>() 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<Document, FormatError> {
};
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<T: Clone>(doc: &mut Document, field: fn(&mut ParaAttrs) -> &mut Option<T>, 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<String> = 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<u64>, 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;