mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 20:56:47 -06:00
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:
Generated
+6
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user