INDD: open .indd files through indd-utils

InDesign documents and templates (.indd, .indt) open through File › Open,
drop, the web inbox, file.open, file.openBytes, file.openIdml and the CLI.
The engine converts them to IDML in memory with indd-utils
(github.com/aiongg/indd-utils, rev 9cf456d, MIT OR Apache-2.0, no
dependencies, builds for wasm) and imports the result through the existing
IDML path. The converter's warnings come first in the open result's
`warnings`. A file the converter can't read is an open error ("can't
convert the InDesign file: ..."), shown in the Can't Open the File alert.

Routing: a path or name ending in .indd/.indt, or bytes starting with the
INDD signature (the web inbox passes the name without its extension).
opens_as_document and the desktop and web Open dialogs list indd/indt.

Tests use synthetic bytes only: signature-bearing junk, a zero-filled
.indd and a short .indt all fail as open errors with no document added;
a dropped unreadable .indd raises the open alert.
This commit is contained in:
aiongg
2026-10-09 23:06:40 +00:00
parent 92b1a96829
commit bb8a6a23fa
10 changed files with 108 additions and 25 deletions
Generated
+6
View File
@@ -953,6 +953,7 @@ dependencies = [
"hayro-cmap",
"hayro-interpret",
"hayro-syntax",
"indd",
"kurbo",
"log",
"quick-xml 0.38.4",
@@ -2226,6 +2227,11 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]]
name = "indd"
version = "0.1.0"
source = "git+https://github.com/aiongg/indd-utils?rev=9cf456d94fea43269f4744a934a70c9842728e71#9cf456d94fea43269f4744a934a70c9842728e71"
[[package]]
name = "indexmap"
version = "2.14.2"
+2
View File
@@ -63,6 +63,8 @@ krilla = { version = "0.8", features = ["pdf"] }
hayro = "0.7"
quick-xml = "0.38"
zip = { version = "3", default-features = false, features = ["deflate"] }
# INDD/INDT → IDML conversion for File › Open (indd-utils; MIT OR Apache-2.0, no dependencies).
indd = { git = "https://github.com/aiongg/indd-utils", rev = "9cf456d94fea43269f4744a934a70c9842728e71" }
# ui
egui = "0.36"
+2 -2
View File
@@ -87,8 +87,8 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or
Line breaks are identical on screen and in PDF.
- **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo
snapshots, and cached composition.
- **Open.** A documented native format, IDML import and export, and PNG, PDF (PDF/X-4, PDF/A-2b)
and EPUB export.
- **Open.** A documented native format, IDML import and export, INDD import (through
[indd-utils](https://github.com/aiongg/indd-utils)), and PNG, PDF (PDF/X-4, PDF/A-2b) and EPUB export.
No subscription, no licence server, no telemetry.
- **Agent-native.** Every menu item, tool gesture, panel control and dialog can be driven over a
JSON control channel and an **MCP server**, so Claude and other agents can lay out and edit
+1
View File
@@ -43,6 +43,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`.
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
- INDD import through indd-utils (#114): File › Open, drop, `file.open`/`file.openBytes` and the CLI open InDesign documents and templates (.indd, .indt) by converting them to IDML in memory and importing that; the converter's warnings join the open result's `warnings`.
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands.
- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline.
- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`).
+2 -2
View File
@@ -5,7 +5,7 @@ use designcraft_engine::Session;
use designcraft_ui_egui::{DesignApp, ImportRequest, ImportedFile, Inbox, Services};
use wasm_bindgen::JsCast as _;
const DOC_EXTS: &[&str] = &["designcraft", "idml"];
const DOC_EXTS: &[&str] = &["designcraft", "idml", "indd", "indt"];
const IMAGE_EXTS: &[&str] = &[
"png",
"jpg",
@@ -126,7 +126,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
} else if request.purpose == "place" {
rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS)
} else {
rfd::AsyncFileDialog::new().add_filter("DesignCraft", DOC_EXTS)
rfd::AsyncFileDialog::new().add_filter("DesignCraft, IDML or InDesign", DOC_EXTS)
};
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = dialog.pick_file().await else {
+5 -2
View File
@@ -153,9 +153,10 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] {
OpenFilter { name: "Video and sound", extensions: &["mp4", "m4v", "mov", "webm", "mp3", "m4a", "wav", "ogg"] },
],
_ => &[
OpenFilter { name: "DesignCraft or IDML", extensions: &["designcraft", "idml"] },
OpenFilter { name: "DesignCraft, IDML or InDesign", extensions: &["designcraft", "idml", "indd", "indt"] },
OpenFilter { name: "DesignCraft", extensions: &["designcraft"] },
OpenFilter { name: "InDesign Markup (IDML)", extensions: &["idml"] },
OpenFilter { name: "InDesign document or template (INDD, INDT)", extensions: &["indd", "indt"] },
],
}
}
@@ -315,6 +316,8 @@ mod tests {
assert!(place.len() > 1, "the place dialog keeps a filter for each kind of file");
assert!(place.iter().any(|filter| filter.extensions.contains(&"png")));
let documents = super::open_filters("");
assert!(documents.iter().any(|filter| filter.extensions.contains(&"designcraft")));
for ext in ["designcraft", "idml", "indd", "indt"] {
assert!(documents.first().is_some_and(|filter| filter.extensions.contains(&ext)), "{ext} is missing from the first Open filter");
}
}
}
+1
View File
@@ -22,6 +22,7 @@ designcraft-textimport = { workspace = true }
designcraft-pdf = { workspace = true }
designcraft-tools = { workspace = true }
designcraft-idml = { workspace = true }
indd = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+6 -5
View File
@@ -18,9 +18,9 @@ pub fn specs() -> Vec<CommandSpec> {
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 or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped}",
"{path} — .designcraft, .idml, or an InDesign .indd/.indt (converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped}",
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.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt", 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!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state", has_doc, |s, p| {
@@ -159,7 +159,7 @@ 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") {
if path.to_ascii_lowercase().ends_with(".idml") || super::interchange::has_indd_extension(path) {
return super::interchange::open_idml(s, p);
}
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
@@ -201,8 +201,9 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<c
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) {
// IDML packages are recognised by their stored `mimetype` first entry, INDD files by their
// signature (or their name).
if designcraft_idml::is_idml(&b) || super::interchange::is_indd(str_param(p, "name").unwrap_or_default(), &b) {
return super::interchange::open_idml(s, p);
}
let mut d = from_bytes(&b)?;
+66 -9
View File
@@ -1,4 +1,5 @@
//! Interchange formats: IDML (InDesign Markup Language) import and export.
//! Interchange formats: IDML (InDesign Markup Language) import and export, and INDD/INDT
//! import (converted to IDML in memory by indd-utils).
use designcraft_doc::Document;
use serde_json::{Value, json};
@@ -13,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{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; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings}",
"{path | base64, name?} — opens an IDML package, or an InDesign document or template (.indd, .indt: converted to IDML first; its conversion warnings join `warnings`), as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings}",
always, open_idml),
]
}
@@ -130,33 +131,63 @@ fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(Stri
}
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()))
let (bytes, dir, file, title) = if let Some(b) = str_param(p, "base64") {
let name = str_param(p, "name").unwrap_or_default();
(base64_decode(b), None, name.to_string(), without_document_extension(name).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()))
let file = pp.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string());
(b, pp.parent().map(|d| d.to_path_buf()), file, pp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())
} else {
return Err(bad("file.openIdml", "missing `path` or `base64`"));
};
let mut warnings = Vec::new();
let bytes = if is_indd(&file, &bytes) {
let conversion = indd::convert(&bytes, &file).map_err(|e| EngineError::Other(format!("can't convert the InDesign file: {e}")))?;
warnings.extend(conversion.warnings.iter().map(|w| w.message().to_string()));
conversion.idml
} else {
bytes
};
let mut d = import(&bytes, dir.as_deref())?;
if let Some(n) = name {
d.title = n;
if !title.is_empty() {
d.title = title;
}
let (fonts, faces, warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
let (fonts, faces, font_warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
Some(path) => super::file::load_document_fonts(&mut d, path),
None => (None, 0, Vec::new()),
};
// Never save over the .idml with the native format: the document starts unsaved.
warnings.extend(font_warnings);
// Never save over the .idml or .indd with the native format: the document starts unsaved.
let mut st = DocState::new(d, None);
st.fonts = fonts;
let i = s.add_document(st);
Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings}))
}
/// An InDesign document or template (.indd, .indt): known by its signature, or by its name when
/// the bytes are something else (the converter then says why it can't read them).
pub(crate) fn is_indd(name: &str, bytes: &[u8]) -> bool {
bytes.starts_with(&indd::header::SIGNATURE) || has_indd_extension(name)
}
pub(crate) fn has_indd_extension(name: &str) -> bool {
let n = name.to_ascii_lowercase();
n.ends_with(".indd") || n.ends_with(".indt")
}
/// `name` without a trailing .idml, .indd or .indt (any case).
fn without_document_extension(name: &str) -> &str {
match name.rsplit_once('.') {
Some((stem, ext)) if ["idml", "indd", "indt"].iter().any(|e| ext.eq_ignore_ascii_case(e)) => stem,
_ => name,
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use std::sync::Arc;
@@ -255,4 +286,30 @@ mod tests {
assert_eq!(*asset.data, png);
std::fs::remove_dir_all(dir).unwrap();
}
/// INDD routing without an InDesign file: bytes with the INDD signature (or an .indd/.indt
/// name) go to the converter, and what it can't read is an open error that adds no document.
#[test]
fn indd_files_go_through_the_converter_and_its_errors_are_open_errors() {
let mut signed = indd::header::SIGNATURE.to_vec();
signed.resize(4096, 0);
let cases = [(signed, "Brochure"), (vec![0; 4096], "Brochure.indd"), (b"not an InDesign file".to_vec(), "Template.INDT")];
let mut s = Session::new();
for (bytes, name) in &cases {
let e = s.execute("file.openBytes", &json!({"name": name, "base64": base64_encode(bytes)})).unwrap_err().to_string();
assert!(e.contains("can't convert the InDesign file"), "{name}: {e}");
}
let dir = std::env::temp_dir().join(format!("dc-indd-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Brochure.indd");
std::fs::write(&path, vec![0u8; 64]).unwrap();
let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains("can't convert the InDesign file"), "{e}");
assert!(s.documents().is_empty());
std::fs::remove_dir_all(dir).unwrap();
// Anything else keeps its own route.
assert!(!is_indd("Brochure.idml", b"PK"));
assert_eq!(without_document_extension("Brochure.INDD"), "Brochure");
assert_eq!(without_document_extension("notes.txt"), "notes.txt");
}
}
+17 -5
View File
@@ -1075,11 +1075,11 @@ impl DesignApp {
}
}
/// Files that open as documents (when dropped or picked) rather than being placed: DesignCraft
/// and IDML.
/// Files that open as documents (when dropped or picked) rather than being placed: DesignCraft,
/// IDML, and InDesign documents and templates (converted to IDML on open).
pub fn opens_as_document(name: &str) -> bool {
let n = name.to_ascii_lowercase();
[".designcraft", ".idml"].iter().any(|ext| n.ends_with(ext))
[".designcraft", ".idml", ".indd", ".indt"].iter().any(|ext| n.ends_with(ext))
}
pub fn now_ms() -> f64 {
@@ -1288,10 +1288,10 @@ mod tests {
#[test]
fn dropped_documents_open_and_the_rest_is_placed() {
for name in ["a.designcraft", "B.IDML"] {
for name in ["a.designcraft", "B.IDML", "c.indd", "D.INDT"] {
assert!(opens_as_document(name), "{name}");
}
for name in ["a.png", "b.pdf", "idml.txt", "c.idml.zip"] {
for name in ["a.png", "b.pdf", "idml.txt", "c.idml.zip", "e.indd.zip"] {
assert!(!opens_as_document(name), "{name}");
}
}
@@ -1320,6 +1320,18 @@ mod tests {
assert_eq!(alert(&app), ("Can't Place the File".to_string(), e));
}
#[test]
fn a_dropped_indd_file_the_converter_rejects_alerts() {
let path = unreadable_file("brochure.indd");
let mut app = DesignApp::new(Session::new(), Services::default());
let r = app.open_dropped(&path.to_string_lossy());
let _ = std::fs::remove_file(&path);
let e = r.unwrap_err();
assert!(e.contains("InDesign file"), "{e}");
assert_eq!(alert(&app), ("Can't Open the File".to_string(), e));
assert!(app.session.documents().is_empty());
}
#[test]
fn file_open_failures_show_an_alert() {
let path = unreadable_file("open.designcraft");