diff --git a/Cargo.lock b/Cargo.lock index 9a37745fb..6f0663c1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index c59188532..7690ef8e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 1a23cb275..01eb85b79 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index d61495836..30260d1d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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`). diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index fe7cca4ae..1ba0617a1 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -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 { diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index d0825c992..6fbe7e728 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -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"); + } } } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 241fd281a..664819a80 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index a8ff8bc1c..9c4dc9683 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -18,9 +18,9 @@ pub fn specs() -> Vec { 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 { 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 Result { 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)?; diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 8a65ec913..944e2bc50 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -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 { "{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 { - 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 = 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"); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1f036cdb2..c5f42fadf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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");