mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-03 07:01:09 -06:00
Paragraph rules can be in Text Color: they take the colour of the paragraph's first character (rule above) or last character (rule below). That is the default, and IDML maps RuleAboveColor / RuleBelowColor "Text Color" both ways. Defaults for what a file leaves unset, in the model and in IDML import: text frames auto-size from the centre, numbered lists don't continue across stories, and alternating table and table style fills start Black 20 % then None 100 %. Kashidas (DefaultKashidas / KashidasOff) import and export. A tint of -1 is the colour's own tint everywhere tints are imported (underline and strikethrough, footnote rules included), and tints are capped at 100 %. Format 1 documents load as saved: they wrote rules, text frame options, lists and table fills in full, so nothing relied on the old defaults.
325 lines
14 KiB
Rust
325 lines
14 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
|
|
//! - `meta.json`: format version and generator
|
|
//!
|
|
//! Older single-file JSON documents (assets as base64 in `assetData`) still open. Documents
|
|
//! from older format versions are upgraded on load so they lay out as they did.
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
#![forbid(unsafe_code)]
|
|
|
|
use std::io::{Cursor, Read, Write};
|
|
use std::sync::Arc;
|
|
|
|
use designcraft_doc::{AssetId, Document, Story};
|
|
use serde_json::{Value, json};
|
|
|
|
pub const MIME: &str = "application/vnd.designcraft+zip";
|
|
pub const VERSION: u32 = 2;
|
|
|
|
#[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.
|
|
pub fn save(doc: &Document) -> Result<Vec<u8>, FormatError> {
|
|
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)
|
|
};
|
|
let version = read(&mut z, "meta.json")
|
|
.and_then(|meta| serde_json::from_slice::<Value>(&meta).ok())
|
|
.and_then(|m| m.get("version").and_then(Value::as_u64))
|
|
.map_or(1, |v| u32::try_from(v).unwrap_or(u32::MAX));
|
|
if version > VERSION {
|
|
return Err(FormatError::TooNew(version));
|
|
}
|
|
let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?;
|
|
let mut doc: Document = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
|
let names: Vec<String> = z.file_names().map(str::to_string).collect();
|
|
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))) {
|
|
Arc::make_mut(a).data = Arc::new(data);
|
|
}
|
|
}
|
|
upgrade(&mut doc, version);
|
|
doc.check().map_err(|e| FormatError::NotOurs(e.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_keep_all_lines(doc);
|
|
}
|
|
}
|
|
|
|
/// Format 1 laid out an unset Keep Lines Together mode as all lines in paragraph. Pin that where
|
|
/// nothing set it: at the start of each paragraph style chain, and on paragraphs whose style is
|
|
/// missing.
|
|
fn pin_keep_all_lines(doc: &mut Document) {
|
|
let roots: std::collections::HashSet<String> = doc
|
|
.styles
|
|
.paragraph
|
|
.iter()
|
|
.filter_map(|s| {
|
|
let chain = doc.styles.para_chain(&s.name);
|
|
if chain.iter().any(|c| c.para.keep_all_lines.is_some()) { None } else { chain.first().map(|c| c.name.clone()) }
|
|
})
|
|
.collect();
|
|
if !roots.is_empty() {
|
|
for s in doc.styles_mut().paragraph.iter_mut().filter(|s| roots.contains(&s.name)) {
|
|
s.para.keep_all_lines = Some(true);
|
|
}
|
|
}
|
|
let styles = doc.styles.clone();
|
|
let mut pin = |st: &mut Story| {
|
|
for p in st.paras.iter_mut().filter(|p| p.para.keep_all_lines.is_none() && styles.para(&p.style).is_none()) {
|
|
p.para.keep_all_lines = Some(true);
|
|
}
|
|
};
|
|
for st in doc.stories.values_mut() {
|
|
let st = Arc::make_mut(st);
|
|
st.for_each_text_mut(&mut pin);
|
|
for n in &mut st.endnotes {
|
|
Arc::make_mut(n).text.for_each_text_mut(&mut pin);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
|
|
let v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
upgrade(&mut doc, 1);
|
|
doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
|
Ok(doc)
|
|
}
|
|
|
|
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};
|
|
|
|
#[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 = AssetId(d.alloc());
|
|
d.assets.insert(
|
|
aid,
|
|
Arc::new(Asset {
|
|
page: 0,
|
|
id: aid,
|
|
name: "x.png".into(),
|
|
mime: "image/png".into(),
|
|
link: None,
|
|
data: Arc::new(vec![1, 2, 3, 4]),
|
|
pixels: Some((1, 1)),
|
|
}),
|
|
);
|
|
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);
|
|
}
|
|
|
|
/// `doc` as format 1 wrote it.
|
|
fn save_v1(doc: &Document) -> Vec<u8> {
|
|
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
|
z.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap();
|
|
z.write_all(br#"{"format": "designcraft", "version": 1}"#).unwrap();
|
|
z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
|
|
z.write_all(&serde_json::to_vec(doc).unwrap()).unwrap();
|
|
z.finish().unwrap().into_inner()
|
|
}
|
|
|
|
fn para_style(name: &str, based_on: Option<&str>, keep_all_lines: Option<bool>) -> designcraft_doc::ParagraphStyle {
|
|
designcraft_doc::ParagraphStyle {
|
|
name: name.into(),
|
|
based_on: based_on.map(str::to_string),
|
|
next_style: None,
|
|
para: designcraft_doc::ParaAttrs { keep_lines_together: Some(true), keep_all_lines, ..Default::default() },
|
|
chars: Default::default(),
|
|
shortcut: String::new(),
|
|
}
|
|
}
|
|
|
|
/// Format 1 resolved an unset Keep Lines Together mode as all lines: those documents still
|
|
/// do after loading, wherever the mode comes from. New documents keep lines at start/end.
|
|
#[test]
|
|
fn format_1_keep_lines_without_mode_stays_all_lines() {
|
|
let mut d = Document::new(&NewDocument::default());
|
|
let st = d.styles_mut();
|
|
st.paragraph.push(para_style("Root", None, None));
|
|
st.paragraph.push(para_style("Child", Some("Root"), None));
|
|
st.paragraph.push(para_style("Explicit", None, Some(false)));
|
|
st.paragraph.push(para_style("Under explicit", Some("Explicit"), None));
|
|
st.paragraph.push(para_style("Loop A", Some("Loop B"), None));
|
|
st.paragraph.push(para_style("Loop B", Some("Loop A"), None));
|
|
let lid = d.default_layer();
|
|
let names = [designcraft_doc::story::BASIC_PARAGRAPH, "Root", "Child", "Explicit", "Under explicit", "Loop A", "Loop B", "Missing"];
|
|
let text = names.join("\n");
|
|
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, &text, ParaFormat::default()).unwrap();
|
|
{
|
|
let s = d.story_mut(sid).unwrap();
|
|
for (p, name) in s.paras.iter_mut().zip(names) {
|
|
p.style = name.into();
|
|
p.para.keep_lines_together = Some(true);
|
|
}
|
|
s.insert_note(1, "Note", ParaFormat { style: "Missing".into(), ..Default::default() });
|
|
}
|
|
let back = load(&save_v1(&d)).unwrap();
|
|
let resolve = |f: &ParaFormat| back.styles.resolve_para(f).0;
|
|
let story = back.story(sid).unwrap();
|
|
for (p, name) in story.paras.iter().zip(names) {
|
|
let pp = resolve(p);
|
|
assert!(pp.keep_lines_together);
|
|
assert_eq!(pp.keep_all_lines, !name.contains("xplicit"), "{name}");
|
|
}
|
|
assert!(resolve(&story.notes[0].text.paras[0]).keep_all_lines, "footnote with a missing style");
|
|
assert!(back.styles.resolve_para_style("Loop A").0.keep_all_lines && back.styles.resolve_para_style("Loop B").0.keep_all_lines);
|
|
// Pinned where the chains start; explicit and inheriting styles are left as they were.
|
|
assert_eq!(back.styles.para("Root").unwrap().para.keep_all_lines, Some(true));
|
|
assert_eq!(back.styles.para("Child").unwrap().para.keep_all_lines, None);
|
|
assert_eq!(back.styles.para("Under explicit").unwrap().para.keep_all_lines, None);
|
|
// The current format keeps the new default: at start/end of paragraph.
|
|
let now = load(&save(&d).unwrap()).unwrap();
|
|
let p = &now.story(sid).unwrap().paras[0];
|
|
assert!(!now.styles.resolve_para(p).0.keep_all_lines);
|
|
assert!(!designcraft_doc::ParaProps::default().keep_all_lines);
|
|
}
|
|
|
|
/// Format 1 wrote paragraph rules, text frame options and numbered lists in full, so their
|
|
/// old defaults (black rules, auto-size from the top centre, lists continuing across stories)
|
|
/// load as saved. New documents take InDesign's.
|
|
#[test]
|
|
fn format_1_rules_frames_and_lists_load_as_saved() {
|
|
let mut d = Document::new(&NewDocument::default());
|
|
let lid = d.default_layer();
|
|
let old_rule = designcraft_doc::Rule { on: true, color: designcraft_doc::color::swatch::BLACK.into(), ..Default::default() };
|
|
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "Ruled", ParaFormat::default()).unwrap();
|
|
d.story_mut(sid).unwrap().paras[0].para.rule_above = Some(old_rule.clone());
|
|
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.auto_size_ref = 1;
|
|
d.settings.lists.push(designcraft_doc::NumberedList { name: "Steps".into(), continue_across_stories: true });
|
|
let back = load(&save_v1(&d)).unwrap();
|
|
assert_eq!(back.story(sid).unwrap().paras[0].para.rule_above, Some(old_rule));
|
|
assert_eq!(back.item(fid).unwrap().text_frame().unwrap().options.auto_size_ref, 1);
|
|
assert!(back.settings.lists[0].continue_across_stories);
|
|
assert_eq!(designcraft_doc::Rule::default().color, designcraft_doc::TEXT_COLOR);
|
|
assert_eq!(designcraft_doc::TextFrameOptions::default().auto_size_ref, 4);
|
|
assert!(!designcraft_doc::NumberedList::default().continue_across_stories);
|
|
}
|
|
|
|
#[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))));
|
|
}
|
|
}
|