mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-02 22:50:27 -06:00
M6.2: IDML import/export
New crate designcraft-idml (L3, zip + quick-xml): export_idml/import_idml for swatches (process/spot CMYK/RGB/Lab, tints, gradients), paragraph/character/ object styles with BasedOn and groups, fonts, preferences, layers, sections, parent spreads, spreads/pages (spine/centre origin mapping), frames, groups, corner options, wrap, transparency, embedded/linked images, formatted stories with special characters, breaks and threading. Engine commands file.exportIdml / file.openIdml, file.open/openBytes dispatch on IDML, CLI --in/--export .idml, File > Export IDML..., open dialogs accept .idml. Verified black-box in InDesign 2026 (exported files open with correct pages, stories, threads, styles, swatches; InDesign-exported IDML imports). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
37f59f1fc8
commit
b070cc343c
@@ -17,8 +17,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always, file_new),
|
||||
cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample),
|
||||
cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} — .designcraft (JSON)", always, file_open),
|
||||
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64}", always, file_open_bytes),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} — .designcraft or .idml", always, file_open),
|
||||
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package", always, file_open_bytes),
|
||||
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save),
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save),
|
||||
cmd!(query "file.serialize", "Serialize", [], None, "{} → {json}", has_doc, |s, _| Ok(json!({"json": String::from_utf8_lossy(&to_bytes(&s.doc()?.doc)).to_string()}))),
|
||||
@@ -105,6 +105,9 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if path.to_ascii_lowercase().ends_with(".idml") {
|
||||
return super::interchange::open_idml(s, p);
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let d = from_bytes(&bytes)?;
|
||||
let i = s.add_document(DocState::new(d, Some(path.to_string())));
|
||||
@@ -119,6 +122,10 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = base64_decode(str_param(p, "base64").unwrap_or(""));
|
||||
// IDML packages are recognised by their stored `mimetype` first entry.
|
||||
if designcraft_idml::is_idml(&b) {
|
||||
return super::interchange::open_idml(s, p);
|
||||
}
|
||||
let mut d = from_bytes(&b)?;
|
||||
if let Some(n) = str_param(p, "name") {
|
||||
d.title = n.to_string();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Interchange formats: IDML (InDesign Markup Language) import and export.
|
||||
|
||||
use designcraft_doc::Document;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::file::{base64_decode, base64_encode};
|
||||
use super::{CommandSpec, always, bad, cmd, has_doc, str_param};
|
||||
use crate::{DocState, EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "file.exportIdml", "Export IDML…", [], None,
|
||||
"{path?, embedImages?: true} — writes an IDML package to `path`, or returns {base64} without a path",
|
||||
has_doc, export_idml),
|
||||
cmd!(noundo "file.openIdml", "Open IDML", [], None,
|
||||
"{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder)",
|
||||
always, open_idml),
|
||||
]
|
||||
}
|
||||
|
||||
fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let opts = designcraft_idml::ExportOptions { embed_images: p.get("embedImages").and_then(Value::as_bool).unwrap_or(true) };
|
||||
let bytes = designcraft_idml::export_idml_with(&st.doc, &opts);
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup).
|
||||
pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
|
||||
let dir = dir.map(|d| d.to_path_buf());
|
||||
let read = move |link: &str| -> Option<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Ok(b) = std::fs::read(link) {
|
||||
return Some(b);
|
||||
}
|
||||
let name = link.rsplit(['/', '\\']).next()?;
|
||||
let dir = dir.as_ref()?;
|
||||
std::fs::read(dir.join(name)).or_else(|_| std::fs::read(dir.join("Links").join(name))).ok()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (link, &dir);
|
||||
None
|
||||
}
|
||||
};
|
||||
designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, dir, name) = if let Some(b) = str_param(p, "base64") {
|
||||
(base64_decode(b), None, str_param(p, "name").map(|n| n.trim_end_matches(".idml").to_string()))
|
||||
} else if let Some(path) = str_param(p, "path") {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let b: Vec<u8> = Vec::new();
|
||||
let pp = std::path::Path::new(path);
|
||||
(b, pp.parent().map(|d| d.to_path_buf()), pp.file_stem().map(|n| n.to_string_lossy().to_string()))
|
||||
} else {
|
||||
return Err(bad("file.openIdml", "missing `path` or `base64`"));
|
||||
};
|
||||
let mut d = import(&bytes, dir.as_deref())?;
|
||||
if let Some(n) = name {
|
||||
d.title = n;
|
||||
}
|
||||
// Never save over the .idml with the native format: the document starts unsaved.
|
||||
let i = s.add_document(DocState::new(d, None));
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod export;
|
||||
mod file;
|
||||
mod find;
|
||||
mod inspect;
|
||||
pub mod interchange;
|
||||
mod layout;
|
||||
mod object;
|
||||
pub mod preflight;
|
||||
@@ -103,6 +104,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
SPECS.get_or_init(|| {
|
||||
let mut v = Vec::new();
|
||||
v.extend(file::specs());
|
||||
v.extend(interchange::specs());
|
||||
v.extend(export::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(object::specs());
|
||||
|
||||
@@ -253,3 +253,5 @@ pub(crate) use push_undo as record_undo;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_idml;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! IDML round-trip tests (the sample magazine) and the interchange commands.
|
||||
|
||||
use designcraft_doc::{Document, ItemId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Every item of a spread list in order, flattened (pre-order).
|
||||
fn items(spreads: &[std::sync::Arc<designcraft_doc::Spread>]) -> Vec<(ItemId, designcraft_geom::Rect)> {
|
||||
let mut v = Vec::new();
|
||||
for sp in spreads {
|
||||
for it in &sp.items {
|
||||
it.walk(&mut |i| v.push((i.id, i.bounds())));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Thread order of every story as positions in the flattened item list.
|
||||
fn positions(d: &Document) -> Vec<Vec<usize>> {
|
||||
let all: Vec<ItemId> = items(&d.spreads).into_iter().chain(items(&d.parents)).map(|(id, _)| id).collect();
|
||||
d.stories.values().map(|s| s.frames.iter().map(|f| all.iter().position(|i| i == f).expect("frame")).collect()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magazine_round_trips_through_idml() {
|
||||
let d = crate::sample::magazine();
|
||||
let bytes = designcraft_idml::export_idml(&d);
|
||||
let back = designcraft_idml::import_idml(&bytes).unwrap();
|
||||
assert_eq!(back.page_count(), d.page_count());
|
||||
assert_eq!(back.spreads.len(), d.spreads.len());
|
||||
assert_eq!(back.parents.len(), d.parents.len());
|
||||
// Stories: same text in the same order, same styles applied.
|
||||
let ta: Vec<&str> = d.stories.values().map(|s| s.text.as_str()).collect();
|
||||
let tb: Vec<&str> = back.stories.values().map(|s| s.text.as_str()).collect();
|
||||
assert_eq!(ta, tb);
|
||||
for (a, b) in d.stories.values().zip(back.stories.values()) {
|
||||
let sa: Vec<&str> = a.paras.iter().map(|p| p.style.as_str()).collect();
|
||||
let sb: Vec<&str> = b.paras.iter().map(|p| p.style.as_str()).collect();
|
||||
assert_eq!(sa, sb);
|
||||
let ca: Vec<(usize, &str)> = a.chars.iter().map(|r| (r.len, r.format.style.as_str())).collect();
|
||||
let cb: Vec<(usize, &str)> = b.chars.iter().map(|r| (r.len, r.format.style.as_str())).collect();
|
||||
assert_eq!(ca, cb);
|
||||
for (ra, rb) in a.chars.iter().zip(&b.chars) {
|
||||
assert_eq!(ra.format.over, rb.format.over);
|
||||
}
|
||||
for (pa, pb) in a.paras.iter().zip(&b.paras) {
|
||||
assert_eq!(pa.para, pb.para);
|
||||
}
|
||||
}
|
||||
// Styles and swatches by name.
|
||||
for s in &d.styles.paragraph {
|
||||
let b = back.styles.para(&s.name).unwrap_or_else(|| panic!("paragraph style {}", s.name));
|
||||
if s.name != designcraft_doc::NO_PARA_STYLE {
|
||||
assert_eq!(b.chars, s.chars, "{}", s.name);
|
||||
assert_eq!(b.para, s.para, "{}", s.name);
|
||||
}
|
||||
}
|
||||
for s in &d.styles.character {
|
||||
let b = back.styles.char_style(&s.name).unwrap_or_else(|| panic!("character style {}", s.name));
|
||||
assert_eq!(b.chars, s.chars);
|
||||
}
|
||||
for s in &d.swatches {
|
||||
let b = back.swatch(&s.name).unwrap_or_else(|| panic!("swatch {}", s.name));
|
||||
assert_eq!(b.value, s.value, "{}", s.name);
|
||||
}
|
||||
// Frame geometry (document and parent spreads), pages.
|
||||
for (sa, sb) in [(&d.spreads, &back.spreads), (&d.parents, &back.parents)] {
|
||||
let (ia, ib) = (items(sa), items(sb));
|
||||
assert_eq!(ia.len(), ib.len());
|
||||
for ((_, a), (_, b)) in ia.iter().zip(&ib) {
|
||||
let close = (a.x0 - b.x0).abs() < 0.01 && (a.y0 - b.y0).abs() < 0.01 && (a.x1 - b.x1).abs() < 0.01 && (a.y1 - b.y1).abs() < 0.01;
|
||||
assert!(close, "{a:?} vs {b:?}");
|
||||
}
|
||||
for (pa, pb) in sa.iter().flat_map(|s| s.pages.iter()).zip(sb.iter().flat_map(|s| s.pages.iter())) {
|
||||
assert!((pa.x - pb.x).abs() < 0.01 && pa.side == pb.side && pa.margins == pb.margins && pa.columns == pb.columns);
|
||||
assert_eq!(pa.parent.is_some(), pb.parent.is_some());
|
||||
}
|
||||
}
|
||||
// Threads: the same frames in the same order.
|
||||
assert_eq!(positions(&d), positions(&back));
|
||||
assert!(back.stories.values().any(|s| s.frames.len() == 2));
|
||||
// Images are embedded.
|
||||
assert_eq!(back.assets.len(), d.assets.len());
|
||||
for (a, b) in d.assets.values().zip(back.assets.values()) {
|
||||
assert_eq!(a.data, b.data);
|
||||
assert_eq!(a.pixels, b.pixels);
|
||||
}
|
||||
// A second round trip is stable.
|
||||
let again = designcraft_idml::import_idml(&designcraft_idml::export_idml(&back)).unwrap();
|
||||
assert_eq!(items(&again.spreads).len(), items(&back.spreads).len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idml_commands() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newSample", &json!({})).unwrap();
|
||||
let r = s.execute("file.exportIdml", &json!({})).unwrap();
|
||||
let b64 = r["base64"].as_str().unwrap().to_string();
|
||||
let r = s.execute("file.openIdml", &json!({"base64": b64, "name": "copy.idml"})).unwrap();
|
||||
let i = r["index"].as_u64().unwrap() as usize;
|
||||
assert_eq!(s.doc().unwrap().doc.title, "copy");
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 4);
|
||||
assert!(s.doc().unwrap().path.is_none());
|
||||
// openBytes recognises the package too.
|
||||
let r = s.execute("file.openBytes", &json!({"base64": b64, "name": "again.idml"})).unwrap();
|
||||
assert_eq!(r["index"].as_u64().unwrap() as usize, i + 1);
|
||||
assert!(s.execute("file.openIdml", &json!({})).is_err());
|
||||
let dir = std::env::temp_dir().join(format!("dc-idml-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("mag.idml").to_string_lossy().to_string();
|
||||
let r = s.execute("file.exportIdml", &json!({"path": p})).unwrap();
|
||||
assert!(r["bytes"].as_u64().unwrap() > 1000);
|
||||
s.execute("file.open", &json!({"path": p})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.title, "mag");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Reference in New Issue
Block a user