M6.2: IDML import/export

New crate designcraft-idml (L3, zip + quick-xml): export_idml/import_idml for
swatches (process/spot CMYK/RGB/Lab, tints, gradients), paragraph/character/
object styles with BasedOn and groups, fonts, preferences, layers, sections,
parent spreads, spreads/pages (spine/centre origin mapping), frames, groups,
corner options, wrap, transparency, embedded/linked images, formatted stories
with special characters, breaks and threading. Engine commands
file.exportIdml / file.openIdml, file.open/openBytes dispatch on IDML, CLI
--in/--export .idml, File > Export IDML..., open dialogs accept .idml.
Verified black-box in InDesign 2026 (exported files open with correct pages,
stories, threads, styles, swatches; InDesign-exported IDML imports).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 12:03:28 -04:00
parent 37f59f1fc8
commit b070cc343c
21 changed files with 4586 additions and 11 deletions

24
Cargo.lock generated
View File

@ -896,6 +896,7 @@ dependencies = [
"designcraft-fonts",
"designcraft-format",
"designcraft-geom",
"designcraft-idml",
"designcraft-pdf",
"designcraft-render",
"designcraft-tools",
@ -942,6 +943,18 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "designcraft-idml"
version = "0.1.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
"designcraft-geom",
"quick-xml 0.38.4",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "designcraft-mcp"
version = "0.1.0"
@ -2931,6 +2944,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@ -4123,7 +4145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.41.0",
"quote",
]

View File

@ -26,7 +26,7 @@ Every page above was laid out by DesignCraft from code (`crates/engine/src/sampl
- **Familiar.** InDesign's layout, tools, menus, panels and shortcuts: spreads and parent pages, frames and threaded stories, the Control panel, paragraph and character styles, swatches, text wrap… you already know how to use it.
- **Beautiful type.** A Knuth–Plass paragraph composer (plus single-line), hyphenation, justification, columns, baseline grid, keeps, tabs, rules and shading — and identical line breaks on screen and in export.
- **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo snapshots, cached composition.
- **Open.** A documented native format, and IDML / PDF / PNG interchange on the roadmap — no subscription, no licence server, no telemetry.
- **Open.** A documented native format, IDML import/export, PNG export, and PDF on the roadmap — no subscription, no licence server, no telemetry.
- **Agent-native.** Every menu item, tool gesture, panel control and dialog is drivable over a JSON control channel and an **MCP server**, so Claude and other agents can lay out and edit documents like a designer.
- **Everywhere.** One Rust codebase for desktop and the web.

View File

@ -13,11 +13,12 @@ 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.
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection (basic), 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 — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
- ~100 commands, all reachable through the JSON control channel; headless CLI rendering to PNG.
- MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input.
- Web build (`apps/designcraft-web`, trunk): the same UI on WebGPU with a WebGL2 fallback; open/place via the browser file picker or drag-and-drop, save/export as downloads.
**Next (in order):** IDML import/export · dictionary hyphenation · native format as zip · tables · PDF/X-4 output intent, bookmarks, tagged PDF · Links panel + relink · Preflight panel · TOC/index/footnotes · EPUB.
**Next (in order):** IDML tables/footnotes/anchored objects · dictionary hyphenation · native format as zip · tables · PDF/X-4 output intent, bookmarks, tagged PDF · Links panel + relink · Preflight panel · TOC/index/footnotes · EPUB.
## Milestones
@ -29,7 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
| M3 | Styles (nested/GREP, bullets, keeps, span columns) | started |
| M4 | Color & effects | started |
| M5 | Graphics & links | started |
| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | in progress (PDF export) |
| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | in progress (IDML ✅, PDF export) |
| M7 | Long documents (sections, TOC, index, footnotes, books) | |
| M8 | Tables | |
| M9 | Performance (MT composition, tiles) | |

View File

@ -1,7 +1,7 @@
//! Headless DesignCraft.
//!
//! ```text
//! designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft] [--pdf-options JSON] [--all-pages DIR]
//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml] [--pdf-options JSON] [--all-pages DIR]
//! designcraft-cli commands # list every command (JSON)
//! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md)
//! ```
@ -141,6 +141,11 @@ fn run(args: &[String]) -> Result<(), String> {
}
fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> {
if out.to_ascii_lowercase().ends_with(".idml") {
let r = s.execute("file.exportIdml", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} bytes)", r["bytes"]);
return Ok(());
}
if out.ends_with(".designcraft") {
s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("saved {out}");

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"];
const DOC_EXTS: &[&str] = &["designcraft", "idml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff"];
const CANVAS_ID: &str = "designcraft_canvas";
const LOADING_ID: &str = "designcraft_loading";
@ -158,6 +158,7 @@ fn mime_for(name: &str) -> &'static str {
Some("jpg" | "jpeg") => "image/jpeg",
Some("pdf") => "application/pdf",
Some("designcraft") => "application/json",
Some("idml") => "application/vnd.adobe.indesign-idml-package",
_ => "application/octet-stream",
}
}

View File

@ -84,7 +84,9 @@ fn services() -> Services {
let d = if purpose == "place" {
d.add_filter("Graphics", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff"])
} else {
d.add_filter("DesignCraft", &["designcraft"])
d.add_filter("DesignCraft or IDML", &["designcraft", "idml"])
.add_filter("DesignCraft", &["designcraft"])
.add_filter("InDesign Markup (IDML)", &["idml"])
};
d.pick_file().map(|p| p.to_string_lossy().to_string())
})),

View File

@ -18,6 +18,7 @@ designcraft-render = { workspace = true }
designcraft-format = { workspace = true }
designcraft-pdf = { workspace = true }
designcraft-tools = { workspace = true }
designcraft-idml = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View File

@ -17,8 +17,8 @@ pub fn specs() -> Vec<CommandSpec> {
always, file_new),
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 (JSON)", always, file_open),
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64}", always, file_open_bytes),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} — .designcraft or .idml", 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.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!(query "file.serialize", "Serialize", [], None, "{} → {json}", has_doc, |s, _| Ok(json!({"json": String::from_utf8_lossy(&to_bytes(&s.doc()?.doc)).to_string()}))),
@ -105,6 +105,9 @@ 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") {
return super::interchange::open_idml(s, p);
}
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let d = from_bytes(&bytes)?;
let i = s.add_document(DocState::new(d, Some(path.to_string())));
@ -119,6 +122,10 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
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) {
return super::interchange::open_idml(s, p);
}
let mut d = from_bytes(&b)?;
if let Some(n) = str_param(p, "name") {
d.title = n.to_string();

View File

@ -0,0 +1,77 @@
//! Interchange formats: IDML (InDesign Markup Language) import and export.
use designcraft_doc::Document;
use serde_json::{Value, json};
use super::file::{base64_decode, base64_encode};
use super::{CommandSpec, always, bad, cmd, has_doc, str_param};
use crate::{DocState, EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "file.exportIdml", "Export IDML…", [], None,
"{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)",
always, open_idml),
]
}
fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let opts = designcraft_idml::ExportOptions { embed_images: p.get("embedImages").and_then(Value::as_bool).unwrap_or(true) };
let bytes = designcraft_idml::export_idml_with(&st.doc, &opts);
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
}
}
/// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup).
pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
let dir = dir.map(|d| d.to_path_buf());
let read = move |link: &str| -> Option<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
{
if let Ok(b) = std::fs::read(link) {
return Some(b);
}
let name = link.rsplit(['/', '\\']).next()?;
let dir = dir.as_ref()?;
std::fs::read(dir.join(name)).or_else(|_| std::fs::read(dir.join("Links").join(name))).ok()
}
#[cfg(target_arch = "wasm32")]
{
let _ = (link, &dir);
None
}
};
designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))
}
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()))
} 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()))
} else {
return Err(bad("file.openIdml", "missing `path` or `base64`"));
};
let mut d = import(&bytes, dir.as_deref())?;
if let Some(n) = name {
d.title = n;
}
// Never save over the .idml with the native format: the document starts unsaved.
let i = s.add_document(DocState::new(d, None));
Ok(json!({"index": i}))
}

View File

@ -5,6 +5,7 @@ mod export;
mod file;
mod find;
mod inspect;
pub mod interchange;
mod layout;
mod object;
pub mod preflight;
@ -103,6 +104,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
SPECS.get_or_init(|| {
let mut v = Vec::new();
v.extend(file::specs());
v.extend(interchange::specs());
v.extend(export::specs());
v.extend(edit::specs());
v.extend(object::specs());

View File

@ -253,3 +253,5 @@ pub(crate) use push_undo as record_undo;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_idml;

View File

@ -0,0 +1,117 @@
//! IDML round-trip tests (the sample magazine) and the interchange commands.
use designcraft_doc::{Document, ItemId};
use serde_json::json;
use crate::Session;
/// Every item of a spread list in order, flattened (pre-order).
fn items(spreads: &[std::sync::Arc<designcraft_doc::Spread>]) -> Vec<(ItemId, designcraft_geom::Rect)> {
let mut v = Vec::new();
for sp in spreads {
for it in &sp.items {
it.walk(&mut |i| v.push((i.id, i.bounds())));
}
}
v
}
/// Thread order of every story as positions in the flattened item list.
fn positions(d: &Document) -> Vec<Vec<usize>> {
let all: Vec<ItemId> = items(&d.spreads).into_iter().chain(items(&d.parents)).map(|(id, _)| id).collect();
d.stories.values().map(|s| s.frames.iter().map(|f| all.iter().position(|i| i == f).expect("frame")).collect()).collect()
}
#[test]
fn magazine_round_trips_through_idml() {
let d = crate::sample::magazine();
let bytes = designcraft_idml::export_idml(&d);
let back = designcraft_idml::import_idml(&bytes).unwrap();
assert_eq!(back.page_count(), d.page_count());
assert_eq!(back.spreads.len(), d.spreads.len());
assert_eq!(back.parents.len(), d.parents.len());
// Stories: same text in the same order, same styles applied.
let ta: Vec<&str> = d.stories.values().map(|s| s.text.as_str()).collect();
let tb: Vec<&str> = back.stories.values().map(|s| s.text.as_str()).collect();
assert_eq!(ta, tb);
for (a, b) in d.stories.values().zip(back.stories.values()) {
let sa: Vec<&str> = a.paras.iter().map(|p| p.style.as_str()).collect();
let sb: Vec<&str> = b.paras.iter().map(|p| p.style.as_str()).collect();
assert_eq!(sa, sb);
let ca: Vec<(usize, &str)> = a.chars.iter().map(|r| (r.len, r.format.style.as_str())).collect();
let cb: Vec<(usize, &str)> = b.chars.iter().map(|r| (r.len, r.format.style.as_str())).collect();
assert_eq!(ca, cb);
for (ra, rb) in a.chars.iter().zip(&b.chars) {
assert_eq!(ra.format.over, rb.format.over);
}
for (pa, pb) in a.paras.iter().zip(&b.paras) {
assert_eq!(pa.para, pb.para);
}
}
// Styles and swatches by name.
for s in &d.styles.paragraph {
let b = back.styles.para(&s.name).unwrap_or_else(|| panic!("paragraph style {}", s.name));
if s.name != designcraft_doc::NO_PARA_STYLE {
assert_eq!(b.chars, s.chars, "{}", s.name);
assert_eq!(b.para, s.para, "{}", s.name);
}
}
for s in &d.styles.character {
let b = back.styles.char_style(&s.name).unwrap_or_else(|| panic!("character style {}", s.name));
assert_eq!(b.chars, s.chars);
}
for s in &d.swatches {
let b = back.swatch(&s.name).unwrap_or_else(|| panic!("swatch {}", s.name));
assert_eq!(b.value, s.value, "{}", s.name);
}
// Frame geometry (document and parent spreads), pages.
for (sa, sb) in [(&d.spreads, &back.spreads), (&d.parents, &back.parents)] {
let (ia, ib) = (items(sa), items(sb));
assert_eq!(ia.len(), ib.len());
for ((_, a), (_, b)) in ia.iter().zip(&ib) {
let close = (a.x0 - b.x0).abs() < 0.01 && (a.y0 - b.y0).abs() < 0.01 && (a.x1 - b.x1).abs() < 0.01 && (a.y1 - b.y1).abs() < 0.01;
assert!(close, "{a:?} vs {b:?}");
}
for (pa, pb) in sa.iter().flat_map(|s| s.pages.iter()).zip(sb.iter().flat_map(|s| s.pages.iter())) {
assert!((pa.x - pb.x).abs() < 0.01 && pa.side == pb.side && pa.margins == pb.margins && pa.columns == pb.columns);
assert_eq!(pa.parent.is_some(), pb.parent.is_some());
}
}
// Threads: the same frames in the same order.
assert_eq!(positions(&d), positions(&back));
assert!(back.stories.values().any(|s| s.frames.len() == 2));
// Images are embedded.
assert_eq!(back.assets.len(), d.assets.len());
for (a, b) in d.assets.values().zip(back.assets.values()) {
assert_eq!(a.data, b.data);
assert_eq!(a.pixels, b.pixels);
}
// A second round trip is stable.
let again = designcraft_idml::import_idml(&designcraft_idml::export_idml(&back)).unwrap();
assert_eq!(items(&again.spreads).len(), items(&back.spreads).len());
}
#[test]
fn idml_commands() {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let r = s.execute("file.exportIdml", &json!({})).unwrap();
let b64 = r["base64"].as_str().unwrap().to_string();
let r = s.execute("file.openIdml", &json!({"base64": b64, "name": "copy.idml"})).unwrap();
let i = r["index"].as_u64().unwrap() as usize;
assert_eq!(s.doc().unwrap().doc.title, "copy");
assert_eq!(s.doc().unwrap().doc.page_count(), 4);
assert!(s.doc().unwrap().path.is_none());
// openBytes recognises the package too.
let r = s.execute("file.openBytes", &json!({"base64": b64, "name": "again.idml"})).unwrap();
assert_eq!(r["index"].as_u64().unwrap() as usize, i + 1);
assert!(s.execute("file.openIdml", &json!({})).is_err());
let dir = std::env::temp_dir().join(format!("dc-idml-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("mag.idml").to_string_lossy().to_string();
let r = s.execute("file.exportIdml", &json!({"path": p})).unwrap();
assert!(r["bytes"].as_u64().unwrap() > 1000);
s.execute("file.open", &json!({"path": p})).unwrap();
assert_eq!(s.doc().unwrap().doc.title, "mag");
let _ = std::fs::remove_dir_all(&dir);
}

17
crates/idml/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "designcraft-idml"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DesignCraft IDML (InDesign Markup Language) import and export"
[lints]
workspace = true
[dependencies]
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
designcraft-doc = { workspace = true }
thiserror = { workspace = true }
quick-xml = { workspace = true }
zip = { workspace = true }

1524
crates/idml/src/export.rs Normal file

File diff suppressed because it is too large Load Diff

1504
crates/idml/src/import.rs Normal file

File diff suppressed because it is too large Load Diff

154
crates/idml/src/lib.rs Normal file
View File

@ -0,0 +1,154 @@
//! IDML (InDesign Markup Language) interchange: [`export_idml`] and [`import_idml`].
//!
//! Implemented clean-room from the public IDML File Format Specification and from inspecting
//! IDML packages exported from synthetic documents. An IDML package is a ZIP whose first entry is
//! an uncompressed `mimetype`, followed by `designmap.xml` (the root `Document` element that
//! includes the other parts) and the `Resources/`, `MasterSpreads/`, `Spreads/` and `Stories/`
//! parts.
//!
//! ## Coordinates
//! DesignCraft spreads have their origin at the top-left of the first page (y down). IDML spread
//! coordinates have their origin at the binding spine (facing pages) or the spread centre
//! (single-sided documents), vertically centred on the pages; pages are placed by their
//! `ItemTransform` and every page item's geometry is `PathGeometry` in inner coordinates mapped by
//! `ItemTransform` (inner → parent). Export maps `xf_idml = T(-origin) · xf`; import computes the
//! page rectangles from the page transforms and maps back.
//!
//! ## Supported subset
//! Swatches (process/spot CMYK, RGB and Lab colours, tints, linear/radial gradients), paragraph,
//! character and object styles (with `BasedOn` and style groups), fonts, document preferences
//! (page size, facing pages, bleed/slug, margins and columns, units, grids), layers, sections,
//! parent (master) spreads and applied parents, spreads and pages, page items (`TextFrame`,
//! `Rectangle`, `Oval`, `Polygon`, `GraphicLine`, `Group`) with fill/stroke, corner options, text
//! wrap, transparency (opacity, blend mode, drop shadow, feather), images (embedded `Contents` or
//! linked), stories with paragraph/character style ranges and local overrides, special
//! characters and breaks, and text threading.
//!
//! ## TODO (ignored on import / not written on export)
//! - Tables, footnotes, anchored/inline objects, notes, tracked changes, hyperlinks, index and
//! cross-reference markers, text variables, conditions, XML structure (`XML/*` parts).
//! - Guides, page-item overrides of parent items (only the page's `OverrideList` is read),
//! parent-of-parent page mapping beyond `AppliedMaster`, alternate layouts, liquid layout.
//! - Nested/GREP/line styles, bullets & numbering details (bullet glyph, number format), OpenType
//! feature flags, paragraph borders, grid alignment, column rules, custom dashed stroke styles.
//! - Mixed inks, gradient stop opacity and gradient feathers, effects other than drop shadow and
//! basic feather, EPS/PDF/AI placed graphics (imported as images when the data is available),
//! clipping paths, compound-path fill rules.
//! - Unknown elements are ignored; nothing is preserved opaquely for round-trip yet.
mod export;
mod import;
mod names;
mod xml;
#[cfg(test)]
mod tests;
pub use export::{ExportOptions, export_idml, export_idml_with};
pub use import::{import_idml, import_idml_with};
/// The IDML package mimetype (content of the first, stored `mimetype` entry).
pub const MIMETYPE: &str = "application/vnd.adobe.indesign-idml-package";
/// The DOM version written to every part.
pub const DOM_VERSION: &str = "16.0";
#[derive(Debug, thiserror::Error)]
pub enum IdmlError {
#[error("not an IDML package: {0}")]
NotIdml(String),
#[error("IDML part {part}: {msg}")]
Part { part: String, msg: String },
#[error("invalid IDML document: {0}")]
Invalid(String),
}
pub type Result<T> = std::result::Result<T, IdmlError>;
/// Does `bytes` look like an IDML package (a ZIP whose first, stored entry is the IDML `mimetype`)?
pub fn is_idml(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK\x03\x04") && bytes.get(30..38) == Some(b"mimetype") && {
let extra = bytes.get(28..30).map_or(0, |e| u16::from_le_bytes([e[0], e[1]]) as usize);
bytes.get(38 + extra..).is_some_and(|rest| rest.starts_with(MIMETYPE.as_bytes()))
}
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Base64 with line breaks every 76 characters (as embedded image `Contents` are written).
pub(crate) fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4 + data.len() / 57);
for (i, c) in data.chunks(3).enumerate() {
if i > 0 && i % 19 == 0 {
out.push('\n');
}
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
out.push(B64[(n >> 18) as usize & 63] as char);
out.push(B64[(n >> 12) as usize & 63] as char);
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
}
out
}
/// Lenient base64 decoding (whitespace and unknown characters are skipped).
pub(crate) fn base64_decode(s: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len() * 3 / 4);
let mut buf = 0u32;
let mut bits = 0;
for b in s.bytes() {
let v = match b {
b'A'..=b'Z' => b - b'A',
b'a'..=b'z' => b - b'a' + 26,
b'0'..=b'9' => b - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
_ => continue,
} as u32;
buf = buf << 6 | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
}
}
out
}
/// Sniff an image's MIME type and pixel size from its header (PNG, JPEG, GIF, WebP, TIFF).
pub(crate) fn sniff_image(b: &[u8]) -> (Option<&'static str>, Option<(u32, u32)>) {
let be32 = |i: usize| b.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]));
if b.starts_with(b"\x89PNG\r\n\x1a\n") {
return (Some("image/png"), be32(16).zip(be32(20)));
}
if b.starts_with(&[0xFF, 0xD8]) {
let mut i = 2;
while i + 9 < b.len() {
if b[i] != 0xFF {
i += 1;
continue;
}
let m = b[i + 1];
let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize;
if matches!(m, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF) {
let h = u16::from_be_bytes([b[i + 5], b[i + 6]]) as u32;
let w = u16::from_be_bytes([b[i + 7], b[i + 8]]) as u32;
return (Some("image/jpeg"), Some((w, h)));
}
i += 2 + len;
}
return (Some("image/jpeg"), None);
}
if b.starts_with(b"GIF8") && b.len() >= 10 {
return (Some("image/gif"), Some((u16::from_le_bytes([b[6], b[7]]) as u32, u16::from_le_bytes([b[8], b[9]]) as u32)));
}
if b.starts_with(b"RIFF") && b.get(8..12) == Some(b"WEBP") {
return (Some("image/webp"), None);
}
if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") {
return (Some("image/tiff"), None);
}
if b.starts_with(b"%PDF") {
return (Some("application/pdf"), None);
}
(None, None)
}

464
crates/idml/src/names.rs Normal file
View File

@ -0,0 +1,464 @@
//! Name and enumeration mappings between the DesignCraft model and IDML.
use designcraft_color::BlendMode;
use designcraft_doc::{
Align, Arrowhead, AutoSize, Cap, Capitalization, FirstBaseline, Join, Leading, NumberStyle, Position, StartParagraph, StrokeAlign, StrokeType,
TabAlign, VerticalJustification, WrapMode, WrapSide,
};
use designcraft_geom::Unit;
use designcraft_geom::corners::CornerShape;
/// `%` → `%25`, `:` → `%3a` (IDML Self-id escaping).
pub fn escape_id(s: &str) -> String {
s.replace('%', "%25").replace(':', "%3a")
}
pub fn unescape_id(s: &str) -> String {
s.replace("%3a", ":").replace("%3A", ":").replace("%25", "%")
}
/// Built-in style names: (ours, IDML `Name`).
pub const PARA_BUILTINS: &[(&str, &str)] = &[("[No Paragraph Style]", "$ID/[No paragraph style]"), ("[Basic Paragraph]", "$ID/NormalParagraphStyle")];
pub const CHAR_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[No character style]")];
pub const OBJECT_BUILTINS: &[(&str, &str)] =
&[("[None]", "$ID/[None]"), ("[Basic Graphics Frame]", "$ID/[Normal Graphics Frame]"), ("[Basic Text Frame]", "$ID/[Normal Text Frame]")];
/// Our style name → IDML `Name` (groups: `A/B` → `A:B`).
pub fn style_name_out(builtins: &[(&str, &str)], name: &str) -> String {
builtins.iter().find(|(o, _)| *o == name).map(|(_, i)| i.to_string()).unwrap_or_else(|| name.replace('/', ":"))
}
/// IDML `Name` → our style name.
pub fn style_name_in(builtins: &[(&str, &str)], name: &str) -> String {
if let Some((o, _)) = builtins.iter().find(|(_, i)| *i == name) {
return o.to_string();
}
name.strip_prefix("$ID/").unwrap_or(name).replace(':', "/")
}
/// `ParagraphStyle/…` Self id for one of our names.
pub fn style_self(kind: &str, builtins: &[(&str, &str)], name: &str) -> String {
format!("{kind}/{}", escape_id(&style_name_out(builtins, name)))
}
pub fn align_out(a: Align) -> &'static str {
match a {
Align::Left => "LeftAlign",
Align::Center => "CenterAlign",
Align::Right => "RightAlign",
Align::LeftJustified => "LeftJustified",
Align::CenterJustified => "CenterJustified",
Align::RightJustified => "RightJustified",
Align::FullyJustified => "FullyJustified",
Align::TowardsSpine => "ToBindingSide",
Align::AwayFromSpine => "AwayFromBindingSide",
}
}
pub fn align_in(s: &str) -> Option<Align> {
Align::ALL.into_iter().find(|a| align_out(*a) == s)
}
pub fn caps_out(c: Capitalization) -> &'static str {
match c {
Capitalization::Normal => "Normal",
Capitalization::AllCaps => "AllCaps",
Capitalization::SmallCaps => "SmallCaps",
Capitalization::OpenTypeAllSmallCaps => "CapToSmallCap",
}
}
pub fn caps_in(s: &str) -> Option<Capitalization> {
[Capitalization::Normal, Capitalization::AllCaps, Capitalization::SmallCaps, Capitalization::OpenTypeAllSmallCaps]
.into_iter()
.find(|c| caps_out(*c) == s)
}
pub fn position_out(p: Position) -> &'static str {
match p {
Position::Normal => "Normal",
Position::Superscript => "Superscript",
Position::Subscript => "Subscript",
Position::OtSuperscript => "OTSuperscript",
Position::OtSubscript => "OTSubscript",
Position::OtNumerator => "OTNumerator",
Position::OtDenominator => "OTDenominator",
}
}
pub fn position_in(s: &str) -> Option<Position> {
[
Position::Normal,
Position::Superscript,
Position::Subscript,
Position::OtSuperscript,
Position::OtSubscript,
Position::OtNumerator,
Position::OtDenominator,
]
.into_iter()
.find(|p| position_out(*p) == s)
}
pub fn start_para_out(s: StartParagraph) -> &'static str {
match s {
StartParagraph::Anywhere => "Anywhere",
StartParagraph::NextColumn => "NextColumn",
StartParagraph::NextFrame => "NextFrame",
StartParagraph::NextPage => "NextPage",
StartParagraph::NextOddPage => "NextOddPage",
StartParagraph::NextEvenPage => "NextEvenPage",
}
}
pub fn start_para_in(s: &str) -> Option<StartParagraph> {
[
StartParagraph::Anywhere,
StartParagraph::NextColumn,
StartParagraph::NextFrame,
StartParagraph::NextPage,
StartParagraph::NextOddPage,
StartParagraph::NextEvenPage,
]
.into_iter()
.find(|p| start_para_out(*p) == s)
}
pub fn tab_align_out(a: TabAlign) -> &'static str {
match a {
TabAlign::Left => "LeftAlign",
TabAlign::Center => "CenterAlign",
TabAlign::Right => "RightAlign",
TabAlign::Char => "CharacterAlign",
}
}
pub fn tab_align_in(s: &str) -> TabAlign {
match s {
"CenterAlign" => TabAlign::Center,
"RightAlign" => TabAlign::Right,
"CharacterAlign" => TabAlign::Char,
_ => TabAlign::Left,
}
}
pub fn leading_out(l: Leading) -> (String, &'static str) {
match l {
Leading::Auto => ("Auto".into(), "enumeration"),
Leading::Points(v) => (num(v), "unit"),
}
}
pub fn leading_in(s: &str) -> Option<Leading> {
if s == "Auto" { Some(Leading::Auto) } else { s.trim().parse().ok().map(Leading::Points) }
}
pub fn stroke_align_out(a: StrokeAlign) -> &'static str {
match a {
StrokeAlign::Center => "CenterAlignment",
StrokeAlign::Inside => "InsideAlignment",
StrokeAlign::Outside => "OutsideAlignment",
}
}
pub fn stroke_align_in(s: &str) -> StrokeAlign {
match s {
"InsideAlignment" => StrokeAlign::Inside,
"OutsideAlignment" => StrokeAlign::Outside,
_ => StrokeAlign::Center,
}
}
pub fn cap_out(c: Cap) -> &'static str {
match c {
Cap::Butt => "ButtEndCap",
Cap::Round => "RoundEndCap",
Cap::Projecting => "ProjectingEndCap",
}
}
pub fn cap_in(s: &str) -> Cap {
match s {
"RoundEndCap" => Cap::Round,
"ProjectingEndCap" => Cap::Projecting,
_ => Cap::Butt,
}
}
pub fn join_out(j: Join) -> &'static str {
match j {
Join::Miter => "MiterEndJoin",
Join::Round => "RoundEndJoin",
Join::Bevel => "BevelEndJoin",
}
}
pub fn join_in(s: &str) -> Join {
match s {
"RoundEndJoin" => Join::Round,
"BevelEndJoin" => Join::Bevel,
_ => Join::Miter,
}
}
/// Stroke style Self ids for our stroke types (built-in InDesign stroke styles).
pub fn stroke_type_out(t: &StrokeType) -> &'static str {
match t {
StrokeType::Solid => "StrokeStyle/$ID/Solid",
StrokeType::Dashed { .. } => "StrokeStyle/$ID/Dashed",
StrokeType::Dotted => "StrokeStyle/$ID/Canned Dotted",
StrokeType::ThickThin => "StrokeStyle/$ID/ThickThin",
StrokeType::ThinThick => "StrokeStyle/$ID/ThinThick",
StrokeType::ThinThin => "StrokeStyle/$ID/ThinThin",
StrokeType::ThickThick => "StrokeStyle/$ID/ThickThick",
StrokeType::ThinThickThin => "StrokeStyle/$ID/ThinThickThin",
StrokeType::ThickThinThick => "StrokeStyle/$ID/ThickThinThick",
StrokeType::Wavy => "StrokeStyle/$ID/Wavy",
StrokeType::Hashed => "StrokeStyle/$ID/Straight Hash",
}
}
pub fn stroke_type_in(s: &str) -> StrokeType {
let n = s.rsplit('/').next().unwrap_or(s).replace([' ', '-'], "").to_ascii_lowercase();
match n.as_str() {
"dashed" | "dashed(3and2)" | "dashed(4and4)" | "canneddashed3x2" | "canneddashed4x4" => StrokeType::Dashed { pattern: vec![] },
"dotted" | "japanesedots" | "canneddotted" => StrokeType::Dotted,
"thickthin" => StrokeType::ThickThin,
"thinthick" => StrokeType::ThinThick,
"thinthin" => StrokeType::ThinThin,
"thickthick" => StrokeType::ThickThick,
"thinthickthin" => StrokeType::ThinThickThin,
"thickthinthick" => StrokeType::ThickThinThick,
"wavy" => StrokeType::Wavy,
"straighthash" | "leftslanthash" | "rightslanthash" => StrokeType::Hashed,
_ => StrokeType::Solid,
}
}
pub fn arrow_out(a: Arrowhead) -> &'static str {
match a {
Arrowhead::None => "None",
Arrowhead::SimpleWide => "SimpleWideArrowHead",
Arrowhead::Simple => "SimpleArrowHead",
Arrowhead::Triangle => "TriangleArrowHead",
Arrowhead::TriangleWide => "TriangleWideArrowHead",
Arrowhead::Barbed => "BarbedArrowHead",
Arrowhead::Curved => "CurvedArrowHead",
Arrowhead::Circle => "CircleArrowHead",
Arrowhead::CircleSolid => "CircleSolidArrowHead",
Arrowhead::Square => "SquareArrowHead",
Arrowhead::SquareSolid => "SquareSolidArrowHead",
Arrowhead::Bar => "BarArrowHead",
}
}
pub fn arrow_in(s: &str) -> Arrowhead {
[
Arrowhead::SimpleWide,
Arrowhead::Simple,
Arrowhead::Triangle,
Arrowhead::TriangleWide,
Arrowhead::Barbed,
Arrowhead::Curved,
Arrowhead::Circle,
Arrowhead::CircleSolid,
Arrowhead::Square,
Arrowhead::SquareSolid,
Arrowhead::Bar,
]
.into_iter()
.find(|a| arrow_out(*a) == s)
.unwrap_or(Arrowhead::None)
}
pub fn corner_out(c: CornerShape) -> &'static str {
match c {
CornerShape::None => "None",
CornerShape::Rounded => "RoundedCorner",
CornerShape::InverseRounded => "InverseRoundedCorner",
CornerShape::Inset => "InsetCorner",
CornerShape::Bevel => "BevelCorner",
CornerShape::Fancy => "FancyCorner",
}
}
pub fn corner_in(s: &str) -> CornerShape {
CornerShape::ALL.into_iter().find(|c| corner_out(*c) == s).unwrap_or(CornerShape::None)
}
pub fn blend_out(b: BlendMode) -> &'static str {
match b {
BlendMode::Normal => "Normal",
BlendMode::Darken => "Darken",
BlendMode::Multiply => "Multiply",
BlendMode::ColorBurn => "ColorBurn",
BlendMode::Lighten => "Lighten",
BlendMode::Screen => "Screen",
BlendMode::ColorDodge => "ColorDodge",
BlendMode::Overlay => "Overlay",
BlendMode::SoftLight => "SoftLight",
BlendMode::HardLight => "HardLight",
BlendMode::Difference => "Difference",
BlendMode::Exclusion => "Exclusion",
BlendMode::Hue => "Hue",
BlendMode::Saturation => "Saturation",
BlendMode::Color => "Color",
BlendMode::Luminosity => "Luminosity",
}
}
pub fn blend_in(s: &str) -> BlendMode {
BlendMode::ALL.into_iter().find(|b| blend_out(*b) == s).unwrap_or_default()
}
pub fn wrap_mode_out(m: WrapMode) -> &'static str {
match m {
WrapMode::None => "None",
WrapMode::BoundingBox => "BoundingBoxTextWrap",
WrapMode::Contour => "Contour",
WrapMode::JumpObject => "JumpObjectTextWrap",
WrapMode::JumpToNextColumn => "NextColumnTextWrap",
}
}
pub fn wrap_mode_in(s: &str) -> WrapMode {
[WrapMode::BoundingBox, WrapMode::Contour, WrapMode::JumpObject, WrapMode::JumpToNextColumn]
.into_iter()
.find(|m| wrap_mode_out(*m) == s)
.unwrap_or(WrapMode::None)
}
pub fn wrap_side_out(s: WrapSide) -> &'static str {
match s {
WrapSide::BothSides => "BothSides",
WrapSide::RightSide => "RightSide",
WrapSide::LeftSide => "LeftSide",
WrapSide::TowardsSpine => "SideTowardsSpine",
WrapSide::AwayFromSpine => "SideAwayFromSpine",
WrapSide::LargestArea => "LargestArea",
}
}
pub fn wrap_side_in(s: &str) -> WrapSide {
[WrapSide::RightSide, WrapSide::LeftSide, WrapSide::TowardsSpine, WrapSide::AwayFromSpine, WrapSide::LargestArea]
.into_iter()
.find(|m| wrap_side_out(*m) == s)
.unwrap_or(WrapSide::BothSides)
}
pub fn vj_out(v: VerticalJustification) -> &'static str {
match v {
VerticalJustification::Top => "TopAlign",
VerticalJustification::Center => "CenterAlign",
VerticalJustification::Bottom => "BottomAlign",
VerticalJustification::Justify => "JustifyAlign",
}
}
pub fn vj_in(s: &str) -> VerticalJustification {
match s {
"CenterAlign" => VerticalJustification::Center,
"BottomAlign" => VerticalJustification::Bottom,
"JustifyAlign" => VerticalJustification::Justify,
_ => VerticalJustification::Top,
}
}
pub fn first_baseline_out(f: FirstBaseline) -> &'static str {
match f {
FirstBaseline::Ascent => "AscentOffset",
FirstBaseline::CapHeight => "CapHeight",
FirstBaseline::Leading => "LeadingOffset",
FirstBaseline::XHeight => "XHeight",
FirstBaseline::Fixed => "FixedHeight",
}
}
pub fn first_baseline_in(s: &str) -> FirstBaseline {
[FirstBaseline::CapHeight, FirstBaseline::Leading, FirstBaseline::XHeight, FirstBaseline::Fixed]
.into_iter()
.find(|m| first_baseline_out(*m) == s)
.unwrap_or(FirstBaseline::Ascent)
}
pub fn auto_size_out(a: AutoSize) -> &'static str {
match a {
AutoSize::Off => "Off",
AutoSize::HeightOnly => "HeightOnly",
AutoSize::WidthOnly => "WidthOnly",
AutoSize::HeightAndWidth => "HeightAndWidth",
AutoSize::HeightAndWidthProportionally => "HeightAndWidthProportionally",
}
}
pub fn auto_size_in(s: &str) -> AutoSize {
[AutoSize::HeightOnly, AutoSize::WidthOnly, AutoSize::HeightAndWidth, AutoSize::HeightAndWidthProportionally]
.into_iter()
.find(|m| auto_size_out(*m) == s)
.unwrap_or(AutoSize::Off)
}
/// Auto-size reference points in our 0..9 order (row-major, top-left first).
pub const REF_POINTS: [&str; 9] = [
"TopLeftPoint",
"TopCenterPoint",
"TopRightPoint",
"LeftCenterPoint",
"CenterPoint",
"RightCenterPoint",
"BottomLeftPoint",
"BottomCenterPoint",
"BottomRightPoint",
];
pub fn number_style_out(n: NumberStyle) -> &'static str {
match n {
NumberStyle::Arabic | NumberStyle::ArabicLeadingZero => "Arabic",
NumberStyle::UpperRoman => "UpperRoman",
NumberStyle::LowerRoman => "LowerRoman",
NumberStyle::UpperLetters => "UpperLetters",
NumberStyle::LowerLetters => "LowerLetters",
}
}
pub fn number_style_in(s: &str) -> NumberStyle {
match s {
"UpperRoman" => NumberStyle::UpperRoman,
"LowerRoman" => NumberStyle::LowerRoman,
"UpperLetters" => NumberStyle::UpperLetters,
"LowerLetters" => NumberStyle::LowerLetters,
_ => NumberStyle::Arabic,
}
}
pub fn unit_out(u: Unit) -> &'static str {
match u {
Unit::Points => "Points",
Unit::Picas => "Picas",
Unit::Inches => "Inches",
Unit::InchesDecimal => "InchesDecimal",
Unit::Millimeters => "Millimeters",
Unit::Centimeters => "Centimeters",
Unit::Ciceros => "Ciceros",
Unit::Agates => "Agates",
Unit::Pixels => "Pixels",
Unit::Q => "U",
Unit::Ha => "Ha",
}
}
pub fn unit_in(s: &str) -> Unit {
[
Unit::Points,
Unit::Picas,
Unit::Inches,
Unit::InchesDecimal,
Unit::Millimeters,
Unit::Centimeters,
Unit::Ciceros,
Unit::Agates,
Unit::Pixels,
Unit::Q,
Unit::Ha,
]
.into_iter()
.find(|u| unit_out(*u) == s)
.unwrap_or(Unit::Points)
}
/// IDML layer colour enumeration names for our layer colour table.
pub fn layer_color_out(c: [u8; 3]) -> Option<String> {
designcraft_doc::LAYER_COLORS.iter().find(|(_, v)| *v == c).map(|(n, _)| n.replace(' ', ""))
}
pub fn layer_color_in(s: &str) -> Option<[u8; 3]> {
designcraft_doc::LAYER_COLORS.iter().find(|(n, _)| n.replace(' ', "") == s).map(|(_, v)| *v)
}
/// Format a number compactly (no trailing zeros, no `-0`).
pub fn num(v: f64) -> String {
if !v.is_finite() {
return "0".into();
}
let r = (v * 1e9).round() / 1e9;
if r == r.trunc() && r.abs() < 1e15 {
let i = r as i64;
return i.to_string();
}
let s = format!("{r}");
if s == "-0" { "0".into() } else { s }
}
pub fn pt(x: f64, y: f64) -> String {
format!("{} {}", num(x), num(y))
}

331
crates/idml/src/tests.rs Normal file
View File

@ -0,0 +1,331 @@
use std::io::Write;
use designcraft_color::{Color, Swatch, SwatchValue};
use designcraft_doc::build::NewDocument;
use designcraft_doc::{CharAttrs, Document, Fill, ParaFormat, Shape, SpreadRef, story as st};
use designcraft_geom::{Rect, shapes};
use super::*;
fn zip_files(files: &[(&str, &str)]) -> Vec<u8> {
use zip::write::SimpleFileOptions;
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
w.start_file("mimetype", stored).unwrap();
w.write_all(MIMETYPE.as_bytes()).unwrap();
for (n, c) in files {
w.start_file(*n, SimpleFileOptions::default()).unwrap();
w.write_all(c.as_bytes()).unwrap();
}
w.finish().unwrap().into_inner()
}
fn zip_files_plain() -> Vec<u8> {
use zip::write::SimpleFileOptions;
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.start_file("document.json", SimpleFileOptions::default()).unwrap();
w.write_all(b"{}").unwrap();
w.finish().unwrap().into_inner()
}
/// A hand-written, minimal IDML document: one facing-pages spread with a right page, a
/// threaded story across two frames, a tint and a grouped paragraph style.
const DESIGNMAP: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<?aid style="50" type="document" readerVersion="6.0" featureSet="257" product="16.0(1)" ?>
<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0" Self="d" StoryList="s1" Name="Fixture.indd">
<idPkg:Graphic src="Resources/Graphic.xml"/>
<idPkg:Styles src="Resources/Styles.xml"/>
<idPkg:Preferences src="Resources/Preferences.xml"/>
<Layer Self="L1" Name="Art" Visible="true" Locked="false"><Properties><LayerColor type="enumeration">Red</LayerColor></Properties></Layer>
<idPkg:MasterSpread src="MasterSpreads/MasterSpread_m1.xml"/>
<idPkg:Spread src="Spreads/Spread_sp1.xml"/>
<Section Self="sec" Length="2" ContinueNumbering="false" PageNumberStart="5" PageStart="p1" SectionPrefix="" Marker="">
<Properties><PageNumberStyle type="enumeration">LowerRoman</PageNumberStyle></Properties>
</Section>
<idPkg:Story src="Stories/Story_s1.xml"/>
</Document>"#;
const GRAPHIC: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Graphic xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Color Self="Color/Black" Model="Process" Space="CMYK" ColorValue="0 0 0 100" Name="Black"/>
<Color Self="Color/Brand" Model="Spot" Space="RGB" ColorValue="255 0 0" Name="Brand" Visible="true"/>
<Color Self="Color/u9" Model="Process" Space="CMYK" ColorValue="10 20 30 40" Name="$ID/" Visible="false"/>
<Tint Self="Tint/Brand 50%25" BaseColor="Color/Brand" TintValue="50" Name="Brand 50%"/>
<Swatch Self="Swatch/None" Name="None"/>
<Gradient Self="Gradient/Fade" Type="Radial" Name="Fade">
<GradientStop Self="g0" StopColor="Color/Brand" Location="0"/>
<GradientStop Self="g1" StopColor="Color/u9" Location="100" Midpoint="30"/>
</Gradient>
</idPkg:Graphic>"#;
const STYLES: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<RootCharacterStyleGroup Self="rc">
<CharacterStyle Self="CharacterStyle/$ID/[No character style]" Name="$ID/[No character style]"/>
<CharacterStyle Self="CharacterStyle/Strong" Name="Strong" FontStyle="Bold">
<Properties><BasedOn type="string">$ID/[No character style]</BasedOn></Properties>
</CharacterStyle>
</RootCharacterStyleGroup>
<RootParagraphStyleGroup Self="rp">
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12">
<Properties><AppliedFont type="string">Source Serif 4</AppliedFont><Leading type="enumeration">Auto</Leading></Properties>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/$ID/NormalParagraphStyle" Name="$ID/NormalParagraphStyle">
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
</ParagraphStyle>
<ParagraphStyleGroup Self="ParagraphStyleGroup/Text" Name="Text">
<ParagraphStyle Self="ParagraphStyle/Text%3aBody" Name="Text:Body" PointSize="10" Justification="LeftJustified" SpaceAfter="4">
<Properties><BasedOn type="object">ParagraphStyle/$ID/NormalParagraphStyle</BasedOn><Leading type="unit">13</Leading></Properties>
</ParagraphStyle>
</ParagraphStyleGroup>
</RootParagraphStyleGroup>
<RootObjectStyleGroup Self="ro">
<ObjectStyle Self="ObjectStyle/$ID/[None]" Name="$ID/[None]"/>
<ObjectStyle Self="ObjectStyle/$ID/[Normal Graphics Frame]" Name="$ID/[Normal Graphics Frame]" EnableStroke="true" StrokeColor="Color/Black" StrokeWeight="1"/>
</RootObjectStyleGroup>
</idPkg:Styles>"#;
const PREFS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Preferences xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<DocumentPreference PageWidth="500" PageHeight="700" FacingPages="true" DocumentBleedTopOffset="9"/>
<MarginPreference ColumnCount="2" ColumnGutter="10" Top="20" Bottom="30" Left="40" Right="50"/>
</idPkg:Preferences>"#;
const MASTER: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:MasterSpread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<MasterSpread Self="m1" Name="A-Parent" NamePrefix="A" BaseName="Parent" PageCount="2">
<Page Self="mp1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350"/>
<Page Self="mp2" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 -350"/>
<Rectangle Self="mr" ItemLayer="L1" ItemTransform="1 0 0 1 0 0" FillColor="Color/u9">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="-480 -330" LeftDirection="-480 -330" RightDirection="-480 -330"/>
<PathPointType Anchor="-480 -300" LeftDirection="-480 -300" RightDirection="-480 -300"/>
<PathPointType Anchor="-400 -300" LeftDirection="-400 -300" RightDirection="-400 -300"/>
<PathPointType Anchor="-400 -330" LeftDirection="-400 -330" RightDirection="-400 -330"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
</Rectangle>
</MasterSpread>
</idPkg:MasterSpread>"#;
const SPREAD: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Spread Self="sp1" PageCount="2" ItemTransform="1 0 0 1 0 0">
<Page Self="p1" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350">
<MarginPreference ColumnCount="3" ColumnGutter="12" Top="10" Bottom="10" Left="20" Right="30"/>
</Page>
<Page Self="p2" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 -350"/>
<TextFrame Self="t1" ParentStory="s1" PreviousTextFrame="n" NextTextFrame="t2" ItemLayer="L1" ItemTransform="1 0 0 1 -450 -300" FillColor="Tint/Brand 50%25">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0" LeftDirection="0 0" RightDirection="0 0"/>
<PathPointType Anchor="0 200" LeftDirection="0 200" RightDirection="0 200"/>
<PathPointType Anchor="300 200" LeftDirection="300 200" RightDirection="300 200"/>
<PathPointType Anchor="300 0" LeftDirection="300 0" RightDirection="300 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
<TextFramePreference TextColumnCount="2" TextColumnGutter="8"><Properties><InsetSpacing type="list"><ListItem type="unit">1</ListItem><ListItem type="unit">2</ListItem><ListItem type="unit">3</ListItem><ListItem type="unit">4</ListItem></InsetSpacing></Properties></TextFramePreference>
<UnknownThing Foo="bar"><Nested/></UnknownThing>
</TextFrame>
<TextFrame Self="t2" ParentStory="s1" PreviousTextFrame="t1" NextTextFrame="n" ItemLayer="L1" ItemTransform="1 0 0 1 50 -300">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 100"/><PathPointType Anchor="100 100"/><PathPointType Anchor="100 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
</TextFrame>
<Group Self="g" ItemTransform="1 0 0 1 10 20">
<Oval Self="o" ItemTransform="1 0 0 1 0 0" FillColor="Gradient/Fade" StrokeWeight="2" StrokeColor="Color/Brand">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 50"/><PathPointType Anchor="50 50"/><PathPointType Anchor="50 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
</Oval>
</Group>
<Rectangle Self="img" ItemLayer="L1" ItemTransform="1 0 0 1 100 100" ContentType="GraphicType" AppliedObjectStyle="ObjectStyle/$ID/[Normal Graphics Frame]">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 30"/><PathPointType Anchor="40 30"/><PathPointType Anchor="40 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
<Image Self="im" ItemTransform="1 0 0 1 0 0">
<Properties><GraphicBounds Left="0" Top="0" Right="40" Bottom="30"/></Properties>
<Link Self="lk" LinkResourceURI="file:/definitely/missing%20dir/photo.jpg" LinkResourceFormat="$ID/JPEG" StoredState="Normal"/>
</Image>
</Rectangle>
</Spread>
</idPkg:Spread>"#;
const STORY: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1">
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Text%3aBody">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]">
<Content>Hello &amp; </Content>
</CharacterStyleRange>
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/Strong" PointSize="14">
<Content>bold</Content>
<Br/>
</CharacterStyleRange>
</ParagraphStyleRange>
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle" Justification="CenterAlign">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]">
<Content>Page <?ACE 18?> end</Content>
</CharacterStyleRange>
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" ParagraphBreakType="NextFrame"><Br/></CharacterStyleRange>
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>after</Content></CharacterStyleRange>
</ParagraphStyleRange>
</Story>
</idPkg:Story>"#;
fn fixture() -> Vec<u8> {
zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", STORY),
])
}
#[test]
fn imports_hand_written_fixture() {
let d = import_idml_with(&fixture(), &|_| None).unwrap();
assert_eq!(d.title, "Fixture");
assert!(d.settings.facing_pages);
assert_eq!((d.settings.page_width, d.settings.page_height), (500.0, 700.0));
assert_eq!(d.settings.bleed[0], 9.0);
assert_eq!(d.page_count(), 2);
let sp = &d.spreads[0];
assert_eq!(sp.pages[0].side, designcraft_doc::PageSide::Left);
assert_eq!(sp.pages[1].side, designcraft_doc::PageSide::Right);
assert_eq!(sp.pages[1].x, 500.0);
assert_eq!(sp.pages[0].margins.inside, 20.0);
assert_eq!(sp.pages[0].columns.count, 3);
assert_eq!(sp.pages[1].columns.count, 2, "falls back to the default margin preference");
assert_eq!(sp.pages[0].parent, Some(d.parents[0].id));
let info = d.parents[0].parent.as_ref().unwrap();
assert_eq!((info.prefix.as_str(), info.name.as_str()), ("A", "Parent"));
// Sections.
assert_eq!(d.page_name(0), "v");
assert_eq!(d.page_name(1), "vi");
// Layers.
assert_eq!(d.layers[0].name, "Art");
assert_eq!(d.layers[0].color, [255, 0, 0]);
// Swatches.
assert!(matches!(d.swatch("Brand").unwrap().value, SwatchValue::Color { color_type: designcraft_color::ColorType::Spot, .. }));
assert!(matches!(&d.swatch("Brand 50%").unwrap().value, SwatchValue::Tint { base, tint } if base == "Brand" && *tint == 0.5));
let SwatchValue::Gradient { gradient } = &d.swatch("Fade").unwrap().value else { panic!("gradient") };
assert_eq!(gradient.kind, designcraft_color::GradientKind::Radial);
assert_eq!(gradient.stops[1].color, Color::cmyk(0.1, 0.2, 0.3, 0.4));
assert!((gradient.stops[0].midpoint - 0.3).abs() < 1e-6);
// The parent rectangle used an unnamed colour → value-named swatch.
let mr = &d.parents[0].items[0];
assert_eq!(mr.fill.swatch, "C=10 M=20 Y=30 K=40");
assert_eq!(mr.bounds(), Rect::new(20.0, 20.0, 100.0, 50.0));
// Styles.
let body = d.styles.para("Text/Body").expect("grouped style");
assert_eq!(body.based_on.as_deref(), Some(st::BASIC_PARAGRAPH));
assert_eq!(body.chars.size, Some(10.0));
assert_eq!(body.chars.leading, Some(designcraft_doc::Leading::Points(13.0)));
assert_eq!(body.para.align, Some(designcraft_doc::Align::LeftJustified));
assert_eq!(d.styles.char_style("Strong").unwrap().chars.font_style.as_deref(), Some("Bold"));
// Frames, threads, text.
let story = d.stories.values().next().unwrap();
assert_eq!(story.text, format!("Hello & bold\nPage {}\tend{}after", st::PAGE_NUMBER, st::FRAME_BREAK));
assert_eq!(story.paras.len(), 2);
assert_eq!(story.paras[0].style, "Text/Body");
assert_eq!(story.paras[1].style, st::BASIC_PARAGRAPH);
assert_eq!(story.paras[1].para.align, Some(designcraft_doc::Align::Center));
let bold = story.runs().find(|(r, _)| story.text[r.clone()].starts_with("bold")).unwrap().1;
assert_eq!(bold.style, "Strong");
assert_eq!(bold.over.size, Some(14.0));
assert_eq!(story.frames.len(), 2);
let f1 = d.item(story.frames[0]).unwrap();
assert_eq!(f1.bounds(), Rect::new(50.0, 50.0, 350.0, 250.0));
assert_eq!(f1.fill.swatch, "Brand 50%");
let o = &f1.text_frame().unwrap().options;
assert_eq!((o.columns, o.gutter, o.inset), (2, 8.0, [1.0, 2.0, 3.0, 4.0]));
assert_eq!(d.item(story.frames[1]).unwrap().bounds(), Rect::new(550.0, 50.0, 650.0, 150.0));
// Group child keeps its own transform relative to the group.
let g = sp.items.iter().find(|i| i.shape == Shape::Group).unwrap();
assert_eq!(g.bounds(), Rect::new(510.0, 370.0, 560.0, 420.0));
let oval = &g.children()[0];
assert_eq!(oval.fill.swatch, "Fade");
assert_eq!((oval.stroke.swatch.as_str(), oval.stroke.weight), ("Brand", 2.0));
// Linked image with a missing file: link recorded, no data; stroke from the object style.
let img = sp.items.iter().find(|i| i.graphic().is_some()).unwrap();
assert_eq!(img.stroke.swatch, "[Black]");
let a = &d.assets[&img.graphic().unwrap().asset];
assert_eq!(a.link.as_deref(), Some("/definitely/missing dir/photo.jpg"));
assert!(a.data.is_empty());
assert_eq!(a.mime, "image/jpeg");
assert_eq!(a.name, "photo.jpg");
}
#[test]
fn rejects_non_idml() {
assert!(import_idml(b"not a zip").is_err());
let z = zip_files(&[("hello.txt", "x")]);
assert!(matches!(import_idml(&z), Err(IdmlError::NotIdml(_))));
}
fn small_doc() -> Document {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
d.swatches.push(Swatch::color("Brand", Color::rgb(1.0, 0.0, 0.0)));
d.swatches.push(Swatch { name: "Brand 30".into(), value: SwatchValue::Tint { base: "Brand".into(), tint: 0.3 }, locked: false, named: true });
let lid = d.default_layer();
let (f1, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 300.0, 200.0), lid, "One\ttwo\nThree", ParaFormat::default()).unwrap();
let (f2, _) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(700.0, 40.0, 900.0, 200.0), lid, "", ParaFormat::default()).unwrap();
d.thread(f1, f2).unwrap();
if let Some(s) = d.story_mut(sid) {
s.format_chars(0..3, |f| f.over = CharAttrs { size: Some(20.0), fill: Some("Brand".into()), ..Default::default() });
let end = s.len();
s.insert(end, &format!(" {}", st::COLUMN_BREAK));
}
let id = designcraft_doc::ItemId(d.alloc());
let mut it = designcraft_doc::Item::new(id, lid, Shape::Oval, shapes::ellipse(Rect::new(650.0, 300.0, 750.0, 380.0)));
it.fill = Fill::swatch("Brand 30");
it.xf = designcraft_geom::Affine::rotate_about(0.3, designcraft_geom::Point::new(700.0, 340.0));
it.opacity = 0.5;
d.insert_item(SpreadRef::Doc(1), it, None).unwrap();
d
}
#[test]
fn round_trips_small_document() {
let d = small_doc();
let bytes = export_idml(&d);
// The mimetype is the first, stored entry.
assert_eq!(&bytes[30..38], b"mimetype");
assert_eq!(&bytes[38..38 + MIMETYPE.len()], MIMETYPE.as_bytes());
assert!(is_idml(&bytes));
assert!(!is_idml(&zip_files_plain()));
let back = import_idml(&bytes).unwrap();
assert_eq!(back.page_count(), d.page_count());
assert_eq!(back.spreads.len(), d.spreads.len());
for (a, b) in d.spreads.iter().zip(&back.spreads) {
for (pa, pb) in a.pages.iter().zip(&b.pages) {
assert!((pa.x - pb.x).abs() < 0.01 && pa.side == pb.side);
}
assert_eq!(a.items.len(), b.items.len());
for (ia, ib) in a.items.iter().zip(&b.items) {
let (ra, rb) = (ia.bounds(), ib.bounds());
assert!((ra.x0 - rb.x0).abs() < 0.01 && (ra.y1 - rb.y1).abs() < 0.01, "{ra:?} vs {rb:?}");
}
}
let sa: Vec<&str> = d.stories.values().map(|s| s.text.as_str()).collect();
let sb: Vec<&str> = back.stories.values().map(|s| s.text.as_str()).collect();
assert_eq!(sa, sb);
let s = back.stories.values().next().unwrap();
assert_eq!(s.frames.len(), 2);
assert_eq!(s.runs().next().unwrap().1.over.size, Some(20.0));
assert_eq!(s.runs().next().unwrap().1.over.fill.as_deref(), Some("Brand"));
let oval = back.spreads[1].items.iter().find(|i| i.shape == Shape::Oval).unwrap();
assert_eq!(oval.fill.swatch, "Brand 30");
assert!((oval.opacity - 0.5).abs() < 1e-6);
assert!(back.swatch("Brand 30").is_some());
}
#[test]
fn uri_and_base64_helpers() {
assert_eq!(import::uri_to_path("file:/a%20b/c.png"), "/a b/c.png");
assert_eq!(import::uri_to_path("file:///C:/x/y.jpg"), "C:/x/y.jpg");
assert_eq!(export::path_to_uri("/a b/c.png"), "file:/a%20b/c.png");
let data: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
assert_eq!(base64_decode(&base64_encode(&data)), data);
}

319
crates/idml/src/xml.rs Normal file
View File

@ -0,0 +1,319 @@
//! A tiny XML DOM on top of `quick-xml`: enough for IDML parts (elements, attributes, text,
//! CDATA and processing instructions such as `<?ACE 18?>`).
use quick_xml::events::Event;
#[derive(Clone, Debug, PartialEq)]
pub enum Node {
El(El),
Text(String),
/// Written as `<![CDATA[…]]>`; the parser returns CDATA as `Text`.
CData(String),
/// Processing instruction: (target, content).
Pi(String, String),
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct El {
pub name: String,
pub attrs: Vec<(String, String)>,
pub children: Vec<Node>,
}
impl El {
pub fn new(name: &str) -> Self {
El { name: name.into(), attrs: Vec::new(), children: Vec::new() }
}
// ---------- building ----------
pub fn attr(mut self, k: &str, v: impl ToString) -> Self {
self.set(k, v);
self
}
pub fn set(&mut self, k: &str, v: impl ToString) {
let v = v.to_string();
match self.attrs.iter_mut().find(|(n, _)| n == k) {
Some(slot) => slot.1 = v,
None => self.attrs.push((k.into(), v)),
}
}
pub fn child(mut self, c: El) -> Self {
self.children.push(Node::El(c));
self
}
pub fn push(&mut self, c: El) {
self.children.push(Node::El(c));
}
pub fn text(mut self, t: impl Into<String>) -> Self {
self.children.push(Node::Text(t.into()));
self
}
// ---------- reading ----------
/// Local name (namespace prefix stripped).
pub fn local(&self) -> &str {
self.name.rsplit(':').next().unwrap_or(&self.name)
}
pub fn get(&self, k: &str) -> Option<&str> {
self.attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str())
}
pub fn elements(&self) -> impl Iterator<Item = &El> {
self.children.iter().filter_map(|n| match n {
Node::El(e) => Some(e),
_ => None,
})
}
pub fn find(&self, local: &str) -> Option<&El> {
self.elements().find(|e| e.local() == local)
}
pub fn find_all<'a>(&'a self, local: &'a str) -> impl Iterator<Item = &'a El> + 'a {
self.elements().filter(move |e| e.local() == local)
}
/// Concatenated text content of direct text children.
pub fn text_content(&self) -> String {
let mut s = String::new();
for c in &self.children {
match c {
Node::Text(t) | Node::CData(t) => s.push_str(t),
Node::El(e) => s.push_str(&e.text_content()),
Node::Pi(..) => {}
}
}
s
}
/// A property in either form: an attribute, or a `<Properties><Name …>value</Name>` child.
pub fn prop(&self, k: &str) -> Option<String> {
if let Some(v) = self.get(k) {
return Some(v.to_string());
}
self.prop_el(k).map(|e| e.text_content())
}
pub fn prop_el(&self, k: &str) -> Option<&El> {
self.find("Properties").and_then(|p| p.find(k))
}
pub fn num(&self, k: &str) -> Option<f64> {
self.prop(k).and_then(|v| v.trim().parse().ok())
}
pub fn boolean(&self, k: &str) -> Option<bool> {
match self.prop(k)?.trim() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
// ---------- writing ----------
pub fn write(&self, out: &mut String, depth: usize) {
indent(out, depth);
out.push('<');
out.push_str(&self.name);
for (k, v) in &self.attrs {
out.push(' ');
out.push_str(k);
out.push_str("=\"");
escape_into(out, v, true);
out.push('"');
}
if self.children.is_empty() {
out.push_str("/>\n");
return;
}
out.push('>');
let inline = self.children.iter().all(|c| !matches!(c, Node::El(_)));
if !inline {
out.push('\n');
}
for c in &self.children {
match c {
Node::El(e) => e.write(out, depth + 1),
Node::Text(t) => escape_into(out, t, false),
Node::CData(t) => {
out.push_str("<![CDATA[");
out.push_str(&t.replace("]]>", "]]]]><![CDATA[>"));
out.push_str("]]>");
}
Node::Pi(t, c) => {
out.push_str("<?");
out.push_str(t);
if !c.is_empty() {
out.push(' ');
out.push_str(c);
}
out.push_str("?>");
}
}
}
if !inline {
indent(out, depth);
}
out.push_str("</");
out.push_str(&self.name);
out.push_str(">\n");
}
}
fn indent(out: &mut String, depth: usize) {
for _ in 0..depth {
out.push('\t');
}
}
/// XML-escape text. Control characters that XML 1.0 forbids are dropped; tab/CR/LF in
/// attributes become character references so they survive attribute-value normalisation.
pub fn escape_into(out: &mut String, s: &str, attr: bool) {
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' if attr => out.push_str("&quot;"),
'\t' | '\n' | '\r' if attr => out.push_str(&format!("&#x{:x};", c as u32)),
'\t' | '\n' | '\r' => out.push(c),
c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {}
c => out.push(c),
}
}
}
/// Serialize a part: XML declaration + root element.
pub fn document(root: &El) -> Vec<u8> {
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
root.write(&mut s, 0);
s.into_bytes()
}
/// Serialize with extra processing instructions after the declaration (designmap's `<?aid …?>`).
pub fn document_with_pi(root: &El, pi: &str) -> Vec<u8> {
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
s.push_str(pi);
s.push('\n');
root.write(&mut s, 0);
s.into_bytes()
}
/// Parse a document into its root element.
pub fn parse(bytes: &[u8]) -> Result<El, String> {
let mut r = quick_xml::Reader::from_reader(bytes);
r.config_mut().trim_text(false);
let mut stack: Vec<El> = vec![El::new("#document")];
let mut buf = Vec::new();
let pos = |r: &quick_xml::Reader<&[u8]>| r.buffer_position();
loop {
let ev = r.read_event_into(&mut buf).map_err(|e| format!("XML error at {}: {e}", pos(&r)))?;
match ev {
Event::Start(e) => {
let el = start_el(&e)?;
stack.push(el);
}
Event::Empty(e) => {
let el = start_el(&e)?;
stack.last_mut().ok_or("unbalanced")?.push(el);
}
Event::End(_) => {
let el = stack.pop().ok_or("unbalanced end tag")?;
if stack.is_empty() {
return Err("unbalanced end tag".into());
}
stack.last_mut().ok_or("unbalanced")?.push(el);
}
Event::Text(t) => {
let s = t.decode().map_err(|e| e.to_string())?;
push_text(stack.last_mut().ok_or("unbalanced")?, &s);
}
Event::CData(t) => {
let s = String::from_utf8_lossy(&t).to_string();
push_text(stack.last_mut().ok_or("unbalanced")?, &s);
}
Event::GeneralRef(g) => {
let s = match g.resolve_char_ref().map_err(|e| e.to_string())? {
Some(c) => c.to_string(),
None => match g.decode().map_err(|e| e.to_string())?.as_ref() {
"amp" => "&".into(),
"lt" => "<".into(),
"gt" => ">".into(),
"quot" => "\"".into(),
"apos" => "'".into(),
_ => String::new(),
},
};
push_text(stack.last_mut().ok_or("unbalanced")?, &s);
}
Event::PI(p) => {
let target = String::from_utf8_lossy(p.target()).to_string();
let content = String::from_utf8_lossy(p.content()).trim().to_string();
if let Some(top) = stack.last_mut() {
top.children.push(Node::Pi(target, content));
}
}
Event::Eof => break,
_ => {}
}
buf.clear();
}
if stack.len() != 1 {
return Err("unexpected end of document".into());
}
let doc = stack.pop().unwrap_or_default();
doc.children
.into_iter()
.find_map(|n| match n {
Node::El(e) => Some(e),
_ => None,
})
.ok_or_else(|| "empty document".into())
}
fn push_text(el: &mut El, s: &str) {
if s.is_empty() {
return;
}
if let Some(Node::Text(t)) = el.children.last_mut() {
t.push_str(s);
} else {
el.children.push(Node::Text(s.to_string()));
}
}
fn start_el(e: &quick_xml::events::BytesStart) -> Result<El, String> {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
let mut el = El::new(&name);
for a in e.attributes().with_checks(false) {
let a = a.map_err(|e| e.to_string())?;
let k = String::from_utf8_lossy(a.key.as_ref()).to_string();
let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
el.attrs.push((k, v));
}
Ok(el)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_text_pi_and_entities() {
let root = El::new("Root").attr("A", "x\"<&\t").child(El::new("Content").text("a\tb & <c>"));
let mut c = El::new("Content");
c.children.push(Node::Text("Page ".into()));
c.children.push(Node::Pi("ACE".into(), "18".into()));
let root = root.child(c);
let bytes = document(&root);
let back = parse(&bytes).unwrap();
assert_eq!(back.get("A"), Some("x\"<&\t"));
let contents: Vec<&El> = back.find_all("Content").collect();
assert_eq!(contents[0].text_content(), "a\tb & <c>");
assert_eq!(contents[1].children[1], Node::Pi("ACE".into(), "18".into()));
}
#[test]
fn properties_form() {
let x = br#"<A PointSize="9"><Properties><Leading type="unit">14</Leading></Properties></A>"#;
let e = parse(x).unwrap();
assert_eq!(e.num("PointSize"), Some(9.0));
assert_eq!(e.num("Leading"), Some(14.0));
assert_eq!(e.prop("Missing"), None);
}
}

View File

@ -313,7 +313,8 @@ impl DesignApp {
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
for (name, bytes) in files {
let b64 = designcraft_engine::cmd::base64_encode(&bytes);
let r = if name.to_ascii_lowercase().ends_with(".designcraft") {
let lower = name.to_ascii_lowercase();
let r = if lower.ends_with(".designcraft") || lower.ends_with(".idml") {
let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem);
self.run("file.openBytes", json!({"name": title, "base64": b64}))
} else {
@ -368,7 +369,8 @@ impl DesignApp {
if p.is_empty() {
continue;
}
let cmd = if p.ends_with(".designcraft") { "file.open" } else { "file.place" };
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}));
}
}

View File

@ -12,6 +12,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"),
("app.save", "Save", Some("Cmd+S"), "{}"),
("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"),
("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"),
("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"),
("app.palette", "Command Palette…", Some("Cmd+K"), "{}"),
("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"),
@ -65,6 +66,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"-",
"ui:app.exportPdf",
"ui:app.exportPng",
"ui:app.exportIdml",
"-",
"cmd:layout.documentSetup",
],
@ -202,6 +204,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
Ok(Value::Null)
}
"app.exportPng" => export_png(app, p),
"app.exportIdml" => export_idml(app, p),
"app.storyEditor" => {
let sid = p.get("story").and_then(Value::as_u64).map(designcraft_doc::StoryId).or_else(|| {
let st = app.session.active()?;
@ -359,6 +362,26 @@ fn export_png(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
}
}
/// File › Export IDML…: ask for a path, export through `file.exportIdml` and write the bytes with
/// the platform writer (a download on the web).
fn export_idml(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let path = match p.get("path").and_then(Value::as_str) {
Some(s) => Some(s.to_string()),
None => {
let name = format!("{}.idml", st.doc.title);
app.services.pick_save.as_mut().and_then(|f| f(&name))
}
};
let Some(path) = path else { return Ok(Value::Null) };
let r = app.run("file.exportIdml", json!({}))?;
let bytes = designcraft_engine::cmd::base64_decode(r["base64"].as_str().unwrap_or_default());
match app.services.write.as_mut() {
Some(w) => w(&path, &bytes).map(|_| json!({"path": path, "bytes": bytes.len()})),
None => Err("no writer".into()),
}
}
/// File › Export PDF…: ask for a path, export through `file.exportPdf` and write the bytes with the
/// platform writer (a download on the web).
fn export_pdf(app: &mut DesignApp, p: &Value) -> Result<Value, String> {