Files
designcraft/crates/format/src/lib.rs
T
aiongg 7a72faf740 IDML: rules follow the text colour; defaults for auto-size, numbering and table fills
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.
2026-10-07 09:05:00 -04:00

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))));
}
}