From 4a60312b8032d22fecc226f4f548c230f36d13ee Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 13:14:25 -0400 Subject: [PATCH] Opening an InDesign document says to export IDML (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .indd and .indt files (recognised by their published 16-byte signature) failed to open or place with a confusing format error. They now explain that the format is closed and how to export IDML from InDesign instead. File › Open offers .indd and .indt files (desktop and web), and dropping one on the window opens it like a .designcraft or .idml file. A file that can't be opened or placed from File › Open, Place, a drop, the command line or the control channel's app.open shows an alert with the reason, as well as the status line. --- apps/designcraft-web/src/web.rs | 4 +- apps/designcraft/src/main.rs | 5 +- crates/engine/src/cmd/file.rs | 52 ++++++++++++++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/dialogs.rs | 25 +++++-- crates/ui-egui/src/i18n.rs | 20 ++++++ crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/lib.rs | 124 ++++++++++++++++++++++++++++---- crates/ui-egui/src/menus.rs | 2 +- 9 files changed, 211 insertions(+), 25 deletions(-) diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index c0466b691..c1c48a9b8 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -4,7 +4,7 @@ use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, 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", @@ -123,7 +123,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { } else if 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 2fb3a1fb9..7ef81106b 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -143,9 +143,10 @@ fn services() -> Services { .add_filter("Text (Word, RTF, plain, Excel)", &["docx", "rtf", "txt", "md", "xlsx"]) .add_filter("Video and sound", &["mp4", "m4v", "mov", "webm", "mp3", "m4a", "wav", "ogg"]) } else { - d.add_filter("DesignCraft or IDML", &["designcraft", "idml"]) + d.add_filter("DesignCraft, IDML or InDesign", &["designcraft", "idml", "indd", "indt"]) .add_filter("DesignCraft", &["designcraft"]) .add_filter("InDesign Markup (IDML)", &["idml"]) + .add_filter("InDesign Document or Template (INDD, INDT)", &["indd", "indt"]) }; d.pick_file().map(|p| p.to_string_lossy().to_string()) })), @@ -232,7 +233,7 @@ fn main() -> eframe::Result { let _ = app.run("file.newSample", serde_json::json!({})); } for f in files { - if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) { + if let Err(e) = app.open_file("file.open", serde_json::json!({"path": f})) { eprintln!("designcraft: {f}: {e}"); } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 98feab48a..a6859b0c2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -107,9 +107,23 @@ pub fn to_bytes(d: &Document) -> Vec { } pub fn from_bytes(b: &[u8]) -> Result { + if is_indd(b) { + return Err(EngineError::Other(INDD_UNREADABLE.into())); + } designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string())) } +/// InDesign documents and templates (.indd, .indt) begin with this 16-byte class id. +const INDD_SIGNATURE: [u8; 16] = [0x06, 0x06, 0xED, 0xF5, 0xD8, 0x1D, 0x46, 0xE5, 0xBD, 0x31, 0xEF, 0xE7, 0xFE, 0x74, 0xB7, 0x1D]; + +const INDD_UNREADABLE: &str = "This is an InDesign document (.indd). Its format is closed and can't be read: in InDesign, \ + choose File › Export… › InDesign Markup (IDML), then open the .idml file here."; + +/// An InDesign document or template, recognised by its signature. +pub fn is_indd(b: &[u8]) -> bool { + b.starts_with(&INDD_SIGNATURE) +} + fn file_new(s: &mut Session, p: &Value) -> Result { let mut nd = match str_param(p, "preset") { Some(name) => NewDocument::from_preset(name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, @@ -217,6 +231,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result { s.ui_requests.push(crate::UiRequest::Pick { purpose: "place".into(), params: json!({}) }); return ok(); }; + if is_indd(&bytes) { + return Err(EngineError::Other(INDD_UNREADABLE.into())); + } // A page of another layout (IDML or DesignCraft): its objects, as one group. let lname = name.to_lowercase(); if lname.ends_with(".idml") || lname.ends_with(".designcraft") { @@ -656,3 +673,38 @@ mod layout_place_tests { assert!(b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 2})).is_err(), "page 2 is empty"); } } + +#[cfg(test)] +mod indd_tests { + use serde_json::json; + + use crate::Session; + + /// The start of an InDesign document: its signature, then the `DOCUMENT` class name. + fn indd() -> Vec { + let mut b = super::INDD_SIGNATURE.to_vec(); + b.extend_from_slice(b"DOCUMENT"); + b.resize(4096, 0); + b + } + + #[test] + fn indd_files_say_to_export_idml() { + let b64 = super::base64_encode(&indd()); + let mut s = Session::new(); + let open = s.execute("file.openBytes", &json!({"base64": b64, "name": "Brochure"})).unwrap_err().to_string(); + assert!(open.contains("InDesign document") && open.contains("IDML"), "{open}"); + s.execute("file.new", &json!({})).unwrap(); + let place = s.execute("file.place", &json!({"base64": b64, "name": "Brochure.indd"})).unwrap_err().to_string(); + assert!(place.contains("InDesign document") && place.contains("IDML"), "{place}"); + #[cfg(not(target_arch = "wasm32"))] + { + let path = std::env::temp_dir().join(format!("designcraft-indd-{}.indd", std::process::id())); + std::fs::write(&path, indd()).unwrap(); + let open = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); + let _ = std::fs::remove_file(&path); + assert!(open.contains("InDesign document"), "{open}"); + } + assert_eq!(s.documents().len(), 1, "nothing was opened"); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7ff3d79c8..68fe9f225 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -333,7 +333,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> None => ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})), } } - "app.open" => wrap(app.run("file.open", json!({"path": s("path")}))), + "app.open" => wrap(app.open_file("file.open", json!({"path": s("path")}))), "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), "app.export" => wrap(app.run("app.exportPng", json!({"path": s("path"), "page": p.get("page"), "scale": p.get("scale")}))), "app.quit" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bfa83d4a9..3f1eba8fc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -797,7 +797,9 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; let mut result: Option = None; + let alert_title = d.s("title"); let title = match d.id.as_str() { + "alert" => crate::i18n::tr(&app.ui.language, &alert_title), "ruby" => crate::i18n::tr(&app.ui.language, "Ruby"), "newDocument" => crate::i18n::tr(&app.ui.language, "New Document"), "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), @@ -831,7 +833,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }; egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| { ui.set_min_width(380.0); - ui.set_max_width(if d.id == "newDocument" && crate::i18n::is_rtl(&app.ui.language) { 380.0 } else { 640.0 }); + ui.set_max_width(match d.id.as_str() { + "alert" => 440.0, + "newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0, + _ => 640.0, + }); if crate::i18n::is_rtl(&app.ui.language) { ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -1360,6 +1366,14 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } "documentSetup" => document_setup(app, ui, &mut d), + "alert" => { + let file = d.s("file"); + if !file.is_empty() { + crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0))); + ui.add_space(4.0); + } + crate::rtl::label(ui, d.s("message")); + } _ => {} } ui.add_space(12.0); @@ -1375,7 +1389,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { { result = Some(true); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // An alert only has OK. + let cancel = d.id != "alert" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked(); + if cancel || ui.input(|i| i.key_pressed(egui::Key::Escape)) { result = Some(false); } }); @@ -1492,7 +1508,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "pdfImport" => { let crop = d.s("crop"); - app.run("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) + app.open_file("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) } "print" => { let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) }; @@ -1594,7 +1610,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("document.preferences", doc) } "newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})), - "importOptions" => app.run( + "importOptions" => app.open_file( "file.place", json!({"path": d.s("path"), "removeStyles": d.b("removeStyles"), "styleConflicts": d.s("styleConflicts"), "styleMap": d.fields.get("map").cloned().unwrap_or(json!({}))}), ), @@ -1662,6 +1678,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { } app.run("footnote.options", p) } + "alert" => Ok(Value::Null), "findChange" if d.b("objectMode") => Ok(Value::Null), "findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})), id if id.starts_with("cmd:") => { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 60fae75f3..fa4f5cafa 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -219,6 +219,26 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Business Card", ["Business Card", "Business Card", "Business Card", "Business Card", "名片"]), ("CMYK", ["CMYK", "CMYK", "CMYK", "CMYK", "CMYK"]), ("Cancel", ["Cancel", "Cancel", "Cancel", "Cancel", "取消"]), + ( + "Can't Open the File", + [ + "Datei kann nicht geöffnet werden", + "Impossible d'ouvrir le fichier", + "No se puede abrir el archivo", + "ファイルを開けません", + "无法打开文件", + ], + ), + ( + "Can't Place the File", + [ + "Datei kann nicht platziert werden", + "Impossible d'importer le fichier", + "No se puede colocar el archivo", + "ファイルを配置できません", + "无法置入文件", + ], + ), ("Center Content", ["Center Content", "Center Content", "Center Content", "Center Content", "内容居中"]), ("Change All", ["Change All", "Change All", "Change All", "Change All", "全部更改"]), ("Change to:", ["Change to:", "Change to:", "Change to:", "Change to:", "更改为:"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 03f97a62b..d0ec27372 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -166,6 +166,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Dock panel", "إرساء اللوحة"), ("OK", "موافق"), ("Cancel", "إلغاء"), + ("Can't Open the File", "تعذّر فتح الملف"), + ("Can't Place the File", "تعذّر إدراج الملف"), ("Apply", "تطبيق"), ("Done", "تم"), ("Reset", "إعادة ضبط"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 10a600089..fac70cb9b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -57,8 +57,8 @@ pub struct Services { /// Hand bytes to the user as a named file (browser download). When set, Save uses it /// instead of writing to a path. pub download: Option, - /// Files delivered asynchronously, drained every frame: `.designcraft` → `file.openBytes`, - /// anything else → `file.place`. + /// Files delivered asynchronously, drained every frame: documents ([`opens_as_document`]) → + /// `file.openBytes`, `.ase` → `swatch.load`, anything else → `file.place`. pub inbox: Option, } @@ -534,10 +534,10 @@ impl DesignApp { self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages}))); Ok(Value::Null) } else { - self.run(cmd, json!({"path": path})) + self.open_file(cmd, json!({"path": path})) } } - Some(path) => self.run(cmd, json!({"path": path})), + Some(path) => self.open_file(cmd, json!({"path": path})), None => Ok(Value::Null), }; } @@ -556,11 +556,11 @@ impl DesignApp { let lower = name.to_ascii_lowercase(); let r = if lower.ends_with(".ase") { self.run("swatch.load", json!({"base64": b64})) - } else if lower.ends_with(".designcraft") || lower.ends_with(".idml") { + } else if opens_as_document(&name) { let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem); - self.run("file.openBytes", json!({"name": title, "base64": b64})) + self.open_file("file.openBytes", json!({"name": title, "base64": b64})) } else { - self.run("file.place", json!({"name": name, "base64": b64})) + self.open_file("file.place", json!({"name": name, "base64": b64})) }; if let Err(e) = r { self.status(format!("{name}: {e}")); @@ -631,18 +631,34 @@ impl DesignApp { } #[cfg(not(target_arch = "wasm32"))] for f in ctx.input(|i| i.raw.dropped_files.clone()) { - { - let p = f.path().to_string_lossy().to_string(); - if p.is_empty() { - continue; - } - let lp = p.to_ascii_lowercase(); - let cmd = if lp.ends_with(".designcraft") || lp.ends_with(".idml") { "file.open" } else { "file.place" }; - let _ = self.run(cmd, json!({"path": p})); + let p = f.path().to_string_lossy().to_string(); + if !p.is_empty() { + let _ = self.open_dropped(&p); } } } + /// A file dropped on the window: documents open, anything else is placed. + pub fn open_dropped(&mut self, path: &str) -> Result { + let cmd = if opens_as_document(path) { "file.open" } else { "file.place" }; + self.open_file(cmd, json!({"path": path})) + } + + /// Open (`file.open`, `file.openBytes`) or place (`file.place`) a file the user chose. A file + /// that can't be opened or placed gets an alert with the reason, as well as the status line. + pub fn open_file(&mut self, cmd: &str, params: Value) -> Result { + let file = match params.get("path").and_then(Value::as_str) { + Some(path) => std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()), + None => params.get("name").and_then(Value::as_str).unwrap_or_default().to_string(), + }; + let r = self.run(cmd, params); + if let Err(e) = &r { + let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" }; + self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e}))); + } + r + } + /// Inject synthetic events (one pointer event per frame). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { if self.synthetic.is_empty() { @@ -851,6 +867,13 @@ impl DesignApp { } } +/// Files that open as documents (when dropped or picked) rather than being placed: DesignCraft, +/// IDML, and InDesign documents and templates (which explain how to export IDML). +pub fn opens_as_document(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + [".designcraft", ".idml", ".indd", ".indt"].iter().any(|ext| n.ends_with(ext)) +} + pub fn now_ms() -> f64 { #[cfg(not(target_arch = "wasm32"))] { @@ -878,4 +901,75 @@ mod tests { assert!(!ui.snap_view().snap_to_guides); assert_eq!(ui.snap_view().zone_px, 0.0); } + + /// A temporary file that starts like an InDesign document (its 16-byte signature). + fn indd_file(tag: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("designcraft-ui-{tag}-{}.indd", std::process::id())); + let mut b = vec![0x06, 0x06, 0xED, 0xF5, 0xD8, 0x1D, 0x46, 0xE5, 0xBD, 0x31, 0xEF, 0xE7, 0xFE, 0x74, 0xB7, 0x1D]; + b.resize(4096, 0); + std::fs::write(&path, b).unwrap(); + path + } + + /// The open alert dialog: (title, message). + fn alert(app: &DesignApp) -> (String, String) { + let d = app.ui.dialog.as_ref().expect("an alert is open"); + assert_eq!(d.id, "alert"); + (d.fields["title"].as_str().unwrap().to_string(), d.fields["message"].as_str().unwrap().to_string()) + } + + #[test] + fn dropped_documents_open_and_the_rest_is_placed() { + for name in ["a.designcraft", "B.IDML", "c.indd", "D.INDT"] { + assert!(opens_as_document(name), "{name}"); + } + for name in ["a.png", "b.pdf", "indd.txt", "c.indd.zip"] { + assert!(!opens_as_document(name), "{name}"); + } + } + + #[test] + fn dropping_an_indesign_document_opens_it_and_alerts() { + let path = indd_file("drop"); + let mut app = DesignApp::new(Session::new(), Services::default()); + let r = app.open_dropped(&path.to_string_lossy()); + let _ = std::fs::remove_file(&path); + assert!(r.is_err()); + let (title, message) = alert(&app); + assert_eq!(title, "Can't Open the File", "an .indd opens, it isn't placed"); + assert!(message.contains("InDesign document") && message.contains("IDML"), "{message}"); + assert!(app.ui.status.contains("InDesign document"), "the status line says it too"); + assert!(app.session.documents().is_empty()); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "OK closes the alert"); + } + + #[test] + fn file_open_failures_show_an_alert() { + let path = indd_file("open"); + let picked = path.to_string_lossy().to_string(); + let services = Services { pick_open: Some(Box::new(move |_| Some(picked.clone()))), ..Default::default() }; + let mut app = DesignApp::new(Session::new(), services); + let r = app.run("app.openDialog", json!({})); + assert!(r.is_err()); + let (title, message) = alert(&app); + assert_eq!(title, "Can't Open the File"); + assert!(message.contains("InDesign document"), "{message}"); + // Placing one says so too. + app.ui.dialog = None; + app.run("file.new", json!({})).unwrap(); + assert!(app.run("app.placeDialog", json!({})).is_err()); + let _ = std::fs::remove_file(&path); + let (title, message) = alert(&app); + assert_eq!(title, "Can't Place the File"); + assert!(message.contains("InDesign document"), "{message}"); + } + + #[test] + fn other_command_errors_stay_in_the_status_line() { + let mut app = DesignApp::new(Session::new(), Services::default()); + assert!(app.run("file.open", json!({"path": "/nonexistent/x.designcraft"})).is_err()); + assert!(app.ui.dialog.is_none(), "a scripted file.open doesn't raise an alert"); + assert!(!app.ui.status.is_empty()); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c7b9f1928..60ea2ac94 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -698,7 +698,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option v,