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:
Brandon Thomas
2026-10-01 12:03:28 -04:00
co-authored by Claude Opus 5.5
parent 37f59f1fc8
commit b070cc343c
21 changed files with 4586 additions and 11 deletions
+9 -2
View File
@@ -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();
+77
View File
@@ -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}))
}
+2
View File
@@ -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());