Opening an InDesign document says to export IDML (#21)

.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.
This commit is contained in:
aiongg
2026-10-07 00:05:20 -04:00
parent d582277cc7
commit 4a60312b80
9 changed files with 211 additions and 25 deletions
+2 -2
View File
@@ -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 {
+3 -2
View File
@@ -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}");
}
}
+52
View File
@@ -107,9 +107,23 @@ pub fn to_bytes(d: &Document) -> Vec<u8> {
}
pub fn from_bytes(b: &[u8]) -> Result<Document> {
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<Value> {
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<Value> {
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<u8> {
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");
}
}
+1 -1
View File
@@ -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" => {
+21 -4
View File
@@ -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<bool> = 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<Value, String> {
}
"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<Value, String> {
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<Value, String> {
}
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:") => {
+20
View File
@@ -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:", "更改为:"]),
+2
View File
@@ -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", "إعادة ضبط"),
+109 -15
View File
@@ -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<DownloadFn>,
/// 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<Inbox>,
}
@@ -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<Value, String> {
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<Value, String> {
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());
}
}
+1 -1
View File
@@ -698,7 +698,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("place")) else { return Some(Ok(Value::Null)) };
let l = path.to_lowercase();
if !(l.ends_with(".docx") || l.ends_with(".rtf")) {
return Some(app.run("file.place", json!({"path": path})));
return Some(app.open_file("file.place", json!({"path": path})));
}
let info = match app.run("place.styles", json!({"path": path})) {
Ok(v) => v,