mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Delegate native DesignCraft format codec
This commit is contained in:
4 files changed
+60
-185
No files matched your search
Generated
+30
-6
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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;
|
||||
Reference in new issue
Block a user