diff --git a/Cargo.lock b/Cargo.lock index 51da976..6bced66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/README.md b/README.md index 3a1d52c..2f3b7de 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 3e3fa0c..9177b8c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | | diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 3496da1..22dc1d6 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -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}"); diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index b440ef3..acd26d0 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -4,7 +4,7 @@ use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, Inbox, Services}; use wasm_bindgen::JsCast as _; -const DOC_EXTS: &[&str] = &["designcraft"]; +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", } } diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index f5c58f7..b8d4b52 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -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()) })), diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index d27a74a..94dc6c4 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index cbfa3ea..e15d6ac 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -17,8 +17,8 @@ pub fn specs() -> Vec { 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 { 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 { fn file_open_bytes(s: &mut Session, p: &Value) -> Result { let b = base64_decode(str_param(p, "base64").unwrap_or("")); + // IDML packages are recognised by their stored `mimetype` first entry. + if designcraft_idml::is_idml(&b) { + 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(); diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs new file mode 100644 index 0000000..8bc294f --- /dev/null +++ b/crates/engine/src/cmd/interchange.rs @@ -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 { + 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 { + 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 { + let dir = dir.map(|d| d.to_path_buf()); + let read = move |link: &str| -> Option> { + #[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 { + 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 = 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})) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6c1944a..bd98f41 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4396594..79de81f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -253,3 +253,5 @@ pub(crate) use push_undo as record_undo; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_idml; diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs new file mode 100644 index 0000000..9763144 --- /dev/null +++ b/crates/engine/src/tests_idml.rs @@ -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]) -> 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> { + let all: Vec = 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); +} diff --git a/crates/idml/Cargo.toml b/crates/idml/Cargo.toml new file mode 100644 index 0000000..9ee6905 --- /dev/null +++ b/crates/idml/Cargo.toml @@ -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 } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs new file mode 100644 index 0000000..f5bc1ed --- /dev/null +++ b/crates/idml/src/export.rs @@ -0,0 +1,1524 @@ +//! Document → IDML package. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::io::Write; + +use designcraft_color::{Color, ColorType, GradientKind, SwatchValue, swatch}; +use designcraft_doc::{ + CharAttrs, CharFormat, ColumnsKind, Content, Document, Item, ListType, PageSide, ParaAttrs, ParaFormat, Rule, Shape, SpanColumns, Spread, Story, + TextFrameOptions, story as st, +}; +use designcraft_geom::{Affine, PathData, Rect}; + +use crate::names::{self, CHAR_BUILTINS, OBJECT_BUILTINS, PARA_BUILTINS, escape_id, num, pt}; +use crate::xml::{El, Node, document, document_with_pi}; +use crate::{DOM_VERSION, MIMETYPE, base64_encode}; + +const PKG_NS: &str = "http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"; +const NO_PARA_ID: &str = "ParagraphStyle/$ID/[No paragraph style]"; +const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]"; + +/// Export options. +#[derive(Clone, Debug)] +pub struct ExportOptions { + /// Embed image data in the package (``). When false, images with a known link path + /// are written as links only. + pub embed_images: bool, +} + +impl Default for ExportOptions { + fn default() -> Self { + ExportOptions { embed_images: true } + } +} + +/// Export a document as an IDML package (ZIP bytes). +pub fn export_idml(doc: &Document) -> Vec { + export_idml_with(doc, &ExportOptions::default()) +} + +pub fn export_idml_with(doc: &Document, opts: &ExportOptions) -> Vec { + let mut ex = Ex::new(doc, opts); + let parts = ex.build(); + zip_parts(&parts) +} + +fn zip_parts(parts: &[(String, Vec)]) -> Vec { + 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); + let deflated = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + let mut write = || -> zip::result::ZipResult<()> { + w.start_file("mimetype", stored)?; + w.write_all(MIMETYPE.as_bytes())?; + for (name, data) in parts { + w.start_file(name.as_str(), deflated)?; + w.write_all(data)?; + } + Ok(()) + }; + // Writing into memory cannot fail except on internal errors; return what we have then. + let _ = write(); + w.finish().map(|c| c.into_inner()).unwrap_or_default() +} + +pub(crate) fn uid(n: u64) -> String { + format!("u{n:x}") +} + +fn p(name: &str, ty: &str, val: impl Into) -> El { + El::new(name).attr("type", ty).text(val) +} + +/// Insert a `` element as the first child when there are any. +fn with_props(mut el: El, props: Vec) -> El { + if !props.is_empty() { + let mut pe = El::new("Properties"); + pe.children = props.into_iter().map(Node::El).collect(); + el.children.insert(0, Node::El(pe)); + } + el +} + +fn pkg_root(kind: &str) -> El { + El::new(&format!("idPkg:{kind}")).attr("xmlns:idPkg", PKG_NS).attr("DOMVersion", DOM_VERSION) +} + +fn bool_s(b: bool) -> &'static str { + if b { "true" } else { "false" } +} + +fn pct(v: f64) -> String { + num(v * 100.0) +} + +/// (model, space, value) for a colour. +fn color_value(c: &Color) -> (&'static str, String) { + match *c { + Color::Cmyk { c, m, y, k } => ("CMYK", format!("{} {} {} {}", pct(c as f64), pct(m as f64), pct(y as f64), pct(k as f64))), + Color::Rgb { r, g, b } => ("RGB", format!("{} {} {}", num(r as f64 * 255.0), num(g as f64 * 255.0), num(b as f64 * 255.0))), + Color::Gray { k } => ("CMYK", format!("0 0 0 {}", pct(k as f64))), + } +} + +/// The IDML spread origin in our spread coordinates. +pub(crate) fn spread_origin(sp: &Spread, facing: bool) -> (f64, f64) { + let h = sp.pages.iter().map(|p| p.height).fold(0.0, f64::max); + let b = sp.bounds(); + let ox = if facing { + if let Some(r) = sp.pages.iter().find(|p| p.side == PageSide::Right) { + r.x + } else if let Some(l) = sp.pages.iter().rev().find(|p| p.side == PageSide::Left) { + l.x + l.width + } else { + b.center().x + } + } else { + b.center().x + }; + (ox, h / 2.0) +} + +fn affine_s(a: Affine) -> String { + a.as_coeffs().iter().map(|v| num(*v)).collect::>().join(" ") +} + +struct Ex<'a> { + d: &'a Document, + opts: &'a ExportOptions, + next: u64, + swatch_ids: HashMap, + fonts: BTreeMap>, + /// Item id → (prev frame, next frame) for threaded text frames. + threads: HashMap, Option)>, +} + +impl<'a> Ex<'a> { + fn new(d: &'a Document, opts: &'a ExportOptions) -> Self { + let mut ex = Ex { d, opts, next: d.next_id + 0x100, swatch_ids: HashMap::new(), fonts: BTreeMap::new(), threads: HashMap::new() }; + ex.assign_swatch_ids(); + for s in d.stories.values() { + for (i, f) in s.frames.iter().enumerate() { + let prev = if i > 0 { Some(s.frames[i - 1].0) } else { None }; + let next = s.frames.get(i + 1).map(|f| f.0); + ex.threads.insert(f.0, (prev, next)); + } + } + ex + } + + fn fresh(&mut self) -> String { + self.next += 1; + uid(self.next) + } + + fn assign_swatch_ids(&mut self) { + let mut used: HashSet = ["Swatch/None", "Color/Paper", "Color/Black", "Color/Registration"].iter().map(|s| s.to_string()).collect(); + self.swatch_ids.insert(swatch::NONE.into(), "Swatch/None".into()); + self.swatch_ids.insert(swatch::PAPER.into(), "Color/Paper".into()); + self.swatch_ids.insert(swatch::BLACK.into(), "Color/Black".into()); + self.swatch_ids.insert(swatch::REGISTRATION.into(), "Color/Registration".into()); + for s in &self.d.swatches { + if self.swatch_ids.contains_key(&s.name) { + continue; + } + let kind = match s.value { + SwatchValue::Tint { .. } => "Tint", + SwatchValue::Gradient { .. } => "Gradient", + SwatchValue::None => "Swatch", + _ => "Color", + }; + let base = format!("{kind}/{}", escape_id(&s.name)); + let mut id = base.clone(); + let mut n = 2; + while used.contains(&id) { + id = format!("{base} {n}"); + n += 1; + } + used.insert(id.clone()); + self.swatch_ids.insert(s.name.clone(), id); + } + } + + fn sw(&self, name: &str) -> String { + self.swatch_ids.get(name).cloned().unwrap_or_else(|| "Swatch/None".into()) + } + + fn build(&mut self) -> Vec<(String, Vec)> { + let d = self.d; + let mut parts = Vec::new(); + // Stories first (they register fonts). + let mut story_parts = Vec::new(); + for s in d.stories.values() { + let name = format!("Stories/Story_{}.xml", uid(s.id.0)); + let el = self.story_el(s); + story_parts.push((name, document(&pkg_root("Story").child(el)))); + } + let styles = self.styles_part(); + let mut masters = Vec::new(); + for sp in &d.parents { + let name = format!("MasterSpreads/MasterSpread_{}.xml", uid(sp.id.0)); + let el = self.spread_el(sp, true); + masters.push((name, document(&pkg_root("MasterSpread").child(el)))); + } + let mut spreads = Vec::new(); + for (si, sp) in d.spreads.iter().enumerate() { + let name = format!("Spreads/Spread_{}.xml", uid(sp.id.0)); + let el = self.spread_el_doc(sp, si); + spreads.push((name, document(&pkg_root("Spread").child(el)))); + } + let graphic = self.graphic_part(); + let fonts = self.fonts_part(); + let prefs = self.prefs_part(); + let designmap = self.designmap(&masters, &spreads, &story_parts); + + parts.push(("META-INF/container.xml".to_string(), CONTAINER.as_bytes().to_vec())); + parts.push(("designmap.xml".to_string(), designmap)); + parts.push(("Resources/Graphic.xml".to_string(), graphic)); + parts.push(("Resources/Fonts.xml".to_string(), fonts)); + parts.push(("Resources/Styles.xml".to_string(), styles)); + parts.push(("Resources/Preferences.xml".to_string(), prefs)); + parts.extend(masters); + parts.extend(spreads); + parts.extend(story_parts); + parts + } + + // ---------- designmap ---------- + + fn designmap(&mut self, masters: &[(String, Vec)], spreads: &[(String, Vec)], stories: &[(String, Vec)]) -> Vec { + let d = self.d; + let story_list: Vec = d.stories.keys().map(|k| uid(k.0)).collect(); + let mut root = El::new("Document") + .attr("xmlns:idPkg", PKG_NS) + .attr("DOMVersion", DOM_VERSION) + .attr("Self", "d") + .attr("StoryList", story_list.join(" ")) + .attr("Name", format!("{}.indd", d.title)) + .attr("ZeroPoint", "0 0") + .attr("ActiveLayer", d.layers.first().map(|l| uid(l.id.0)).unwrap_or_else(|| "n".into())); + root.push( + El::new("Language") + .attr("Self", "Language/$ID/[No Language]") + .attr("Name", "$ID/[No Language]") + .attr("SingleQuotes", "''") + .attr("DoubleQuotes", "\"\"") + .attr("PrimaryLanguageName", "$ID/[No Language]") + .attr("SublanguageName", "$ID/[No Language]") + .attr("Id", "0") + .attr("HyphenationVendor", "$ID/") + .attr("SpellingVendor", "$ID/"), + ); + root.push( + El::new("Language") + .attr("Self", "Language/$ID/English%3a USA") + .attr("Name", "$ID/English: USA") + .attr("SingleQuotes", "\u{2018}\u{2019}") + .attr("DoubleQuotes", "\u{201C}\u{201D}") + .attr("PrimaryLanguageName", "$ID/English") + .attr("SublanguageName", "$ID/USA") + .attr("Id", "269") + .attr("HyphenationVendor", "Hunspell") + .attr("SpellingVendor", "Hunspell"), + ); + root.push(El::new("idPkg:Graphic").attr("src", "Resources/Graphic.xml")); + root.push(El::new("idPkg:Fonts").attr("src", "Resources/Fonts.xml")); + root.push(El::new("idPkg:Styles").attr("src", "Resources/Styles.xml")); + root.push( + El::new("NumberingList") + .attr("Self", "NumberingList/$ID/[Default]") + .attr("Name", "$ID/[Default]") + .attr("ContinueNumbersAcrossStories", "false") + .attr("ContinueNumbersAcrossDocuments", "false"), + ); + root.push(El::new("idPkg:Preferences").attr("src", "Resources/Preferences.xml")); + for l in &d.layers { + let color = match names::layer_color_out(l.color) { + Some(n) => p("LayerColor", "enumeration", n), + None => { + let mut e = El::new("LayerColor").attr("type", "list"); + for c in l.color { + e.push(p("ListItem", "double", num(c as f64))); + } + e + } + }; + let el = El::new("Layer") + .attr("Self", uid(l.id.0)) + .attr("Name", &l.name) + .attr("Visible", bool_s(l.visible)) + .attr("Locked", bool_s(l.locked)) + .attr("IgnoreWrap", bool_s(l.suppress_wrap_when_hidden)) + .attr("ShowGuides", bool_s(l.show_guides)) + .attr("LockGuides", "false") + .attr("UI", "true") + .attr("Expendable", "true") + .attr("Printable", bool_s(l.printable)); + root.push(with_props(el, vec![color])); + } + for (name, _) in masters { + root.push(El::new("idPkg:MasterSpread").attr("src", name)); + } + for (name, _) in spreads { + root.push(El::new("idPkg:Spread").attr("src", name)); + } + // Sections. + let mut secs = d.sections.clone(); + secs.sort_by_key(|s| s.start); + if secs.is_empty() || secs[0].start != 0 { + secs.insert( + 0, + designcraft_doc::Section { + start: 0, + start_number: Some(1), + style: Default::default(), + prefix: String::new(), + marker: String::new(), + include_prefix: false, + }, + ); + } + let total = d.page_count(); + for (i, s) in secs.iter().enumerate() { + if s.start >= total { + continue; + } + let end = secs.get(i + 1).map(|n| n.start).unwrap_or(total).min(total); + let Some(page) = d.page(s.start) else { continue }; + let mut el = El::new("Section") + .attr("Self", self.fresh()) + .attr("Length", end.saturating_sub(s.start).max(1)) + .attr("Name", &s.prefix) + .attr("ContinueNumbering", bool_s(s.start_number.is_none())) + .attr("IncludeSectionPrefix", bool_s(s.include_prefix)); + if let Some(n) = s.start_number { + el.set("PageNumberStart", n); + } + el = el.attr("Marker", &s.marker).attr("PageStart", uid(page.id.0)).attr("SectionPrefix", &s.prefix); + root.push(with_props(el, vec![p("PageNumberStyle", "enumeration", names::number_style_out(s.style))])); + } + for (name, _) in stories { + root.push(El::new("idPkg:Story").attr("src", name)); + } + // Root colour group: the Swatches panel order. + let mut cg = + El::new("ColorGroup").attr("Self", "ColorGroup/[Root Color Group]").attr("Name", "[Root Color Group]").attr("IsRootColorGroup", "true"); + let mut seen = HashSet::new(); + for s in &d.swatches { + let id = self.sw(&s.name); + if seen.insert(id.clone()) { + let n = seen.len() - 1; + cg.push(El::new("ColorGroupSwatch").attr("Self", format!("u{:x}ColorGroupSwatch{n:x}", self.next + 1)).attr("SwatchItemRef", id)); + } + } + self.next += 1; + root.push(cg); + document_with_pi( + &root, + &format!(""), + ) + } + + // ---------- Graphic.xml ---------- + + fn graphic_part(&mut self) -> Vec { + let d = self.d; + let mut root = pkg_root("Graphic"); + let mut colors: Vec = Vec::new(); + let mut tints: Vec = Vec::new(); + let mut gradients: Vec = Vec::new(); + let mut inks: Vec = Vec::new(); + let color_el = |self_id: &str, name: &str, model: &str, c: &Color, over: &str, editable: bool, removable: bool, visible: bool| { + let (space, val) = color_value(c); + El::new("Color") + .attr("Self", self_id) + .attr("Model", model) + .attr("Space", space) + .attr("ColorValue", val) + .attr("ColorOverride", over) + .attr("AlternateSpace", "NoAlternateColor") + .attr("AlternateColorValue", "") + .attr("Name", name) + .attr("ColorEditable", bool_s(editable)) + .attr("ColorRemovable", bool_s(removable)) + .attr("Visible", bool_s(visible)) + .attr("SwatchCreatorID", "7937") + }; + // Specials (always present). + colors.push(color_el("Color/Black", "Black", "Process", &Color::cmyk(0.0, 0.0, 0.0, 1.0), "Specialblack", false, false, true)); + let paper = match d.swatch(swatch::PAPER).map(|s| &s.value) { + Some(SwatchValue::Paper { color }) => *color, + _ => Color::cmyk(0.0, 0.0, 0.0, 0.0), + }; + let paper = if paper == Color::WHITE { Color::cmyk(0.0, 0.0, 0.0, 0.0) } else { paper }; + colors.push(color_el("Color/Paper", "Paper", "Process", &paper, "Specialpaper", true, false, true)); + colors.push(color_el( + "Color/Registration", + "Registration", + "Registration", + &Color::cmyk(1.0, 1.0, 1.0, 1.0), + "Specialregistration", + false, + false, + true, + )); + for (i, ink) in + [("Process Cyan", 75, 0.61), ("Process Magenta", 15, 0.76), ("Process Yellow", 0, 0.16), ("Process Black", 45, 1.7)].iter().enumerate() + { + inks.push( + El::new("Ink") + .attr("Self", format!("Ink/$ID/{}", ink.0)) + .attr("Name", format!("$ID/{}", ink.0)) + .attr("Angle", ink.1) + .attr("ConvertToProcess", "false") + .attr("Frequency", "70") + .attr("NeutralDensity", num(ink.2)) + .attr("PrintInk", "true") + .attr("TrapOrder", i + 1) + .attr("InkType", "Normal"), + ); + } + // Colour swatches, by value, for gradient stop lookup. + let mut by_value: Vec<(Color, String)> = Vec::new(); + for s in &d.swatches { + if s.is_special() { + continue; + } + let id = self.sw(&s.name); + match &s.value { + SwatchValue::Color { color, color_type } => { + let model = if *color_type == ColorType::Spot { "Spot" } else { "Process" }; + colors.push(color_el(&id, &s.name, model, color, "Normal", true, true, true)); + by_value.push((*color, id.clone())); + if *color_type == ColorType::Spot { + inks.push( + El::new("Ink") + .attr("Self", format!("Ink/{}", escape_id(&s.name))) + .attr("Name", &s.name) + .attr("Angle", "45") + .attr("ConvertToProcess", "false") + .attr("Frequency", "70") + .attr("NeutralDensity", "0.5") + .attr("PrintInk", "true") + .attr("TrapOrder", inks.len() + 1) + .attr("InkType", "Normal"), + ); + } + } + SwatchValue::Tint { base, tint } => { + tints.push( + El::new("Tint") + .attr("Self", &id) + .attr("TintValue", pct(*tint as f64)) + .attr("BaseColor", self.sw(base)) + .attr("Name", &s.name) + .attr("ColorOverride", "Normal") + .attr("AlternateSpace", "NoAlternateColor") + .attr("AlternateColorValue", "") + .attr("ColorEditable", "true") + .attr("ColorRemovable", "true") + .attr("Visible", "true") + .attr("SwatchCreatorID", "7937"), + ); + } + _ => {} + } + } + for s in &d.swatches { + let SwatchValue::Gradient { gradient } = &s.value else { continue }; + let id = self.sw(&s.name); + let mut g = El::new("Gradient") + .attr("Self", &id) + .attr("Type", if gradient.kind == GradientKind::Radial { "Radial" } else { "Linear" }) + .attr("Name", &s.name) + .attr("ColorEditable", "true") + .attr("ColorRemovable", "true") + .attr("Visible", "true") + .attr("SwatchCreatorID", "7937"); + for (i, stop) in gradient.stops.iter().enumerate() { + let cid = match by_value.iter().find(|(c, _)| *c == stop.color) { + Some((_, id)) => id.clone(), + None => { + let cid = format!("Color/{}", self.fresh()); + colors.push(color_el(&cid, "$ID/", "Process", &stop.color, "Normal", true, true, false)); + by_value.push((stop.color, cid.clone())); + cid + } + }; + let mut e = El::new("GradientStop") + .attr("Self", format!("{}GradientStop{i}", id.replace(['/', ' '], "_"))) + .attr("StopColor", cid) + .attr("Location", pct(stop.offset as f64)); + if i > 0 { + e.set("Midpoint", pct(gradient.stops[i - 1].midpoint as f64)); + } + g.push(e); + } + gradients.push(g); + } + for c in colors { + root.push(c); + } + for i in inks { + root.push(i); + } + for t in tints { + root.push(t); + } + root.push( + El::new("Swatch") + .attr("Self", "Swatch/None") + .attr("Name", "None") + .attr("ColorEditable", "false") + .attr("ColorRemovable", "false") + .attr("Visible", "true") + .attr("SwatchCreatorID", "7937"), + ); + for g in gradients { + root.push(g); + } + for s in STROKE_STYLES { + root.push(El::new("StrokeStyle").attr("Self", format!("StrokeStyle/$ID/{s}")).attr("Name", format!("$ID/{s}"))); + } + document(&root) + } + + // ---------- Fonts.xml ---------- + + fn note_font(&mut self, family: &str, style: &str) { + self.fonts.entry(family.to_string()).or_default().insert(style.to_string()); + } + + fn fonts_part(&mut self) -> Vec { + let mut root = pkg_root("Fonts"); + let fonts = std::mem::take(&mut self.fonts); + for (fam, styles) in &fonts { + let fid = format!("di{:x}", { + self.next += 1; + self.next + }); + let mut fe = El::new("FontFamily").attr("Self", &fid).attr("Name", fam); + for s in styles { + fe.push( + El::new("Font") + .attr("Self", format!("{fid}Fontn{fam} {s}")) + .attr("FontFamily", fam) + .attr("Name", format!("{fam} {s}")) + .attr("PostScriptName", format!("$ID/{}-{}", fam.replace(' ', ""), s.replace(' ', ""))) + .attr("Status", "NotAvailable") + .attr("FontStyleName", s) + .attr("FontType", "OpenTypeTT") + .attr("WritingScript", "0") + .attr("FullName", format!("{fam} {s}")) + .attr("FullNameNative", format!("{fam} {s}")) + .attr("FontStyleNameNative", s) + .attr("PlatformName", "$ID/") + .attr("Version", "$ID/") + .attr("TypekitID", "$ID/"), + ); + } + root.push(fe); + } + self.fonts = fonts; + document(&root) + } + + // ---------- Preferences.xml ---------- + + fn prefs_part(&mut self) -> Vec { + let d = self.d; + let s = &d.settings; + let mut root = pkg_root("Preferences"); + root.push( + El::new("DocumentPreference") + .attr("PageHeight", num(s.page_height)) + .attr("PageWidth", num(s.page_width)) + // InDesign creates this many pages before reading the spreads and replaces only the first + // spread, so (as InDesign itself writes) the value must be 1. + .attr("PagesPerDocument", 1) + .attr("FacingPages", bool_s(s.facing_pages)) + .attr("DocumentBleedTopOffset", num(s.bleed[0])) + .attr("DocumentBleedBottomOffset", num(s.bleed[1])) + .attr("DocumentBleedInsideOrLeftOffset", num(s.bleed[2])) + .attr("DocumentBleedOutsideOrRightOffset", num(s.bleed[3])) + .attr("DocumentBleedUniformSize", bool_s(s.bleed.iter().all(|b| *b == s.bleed[0]))) + .attr("SlugTopOffset", num(s.slug[0])) + .attr("SlugBottomOffset", num(s.slug[1])) + .attr("SlugInsideOrLeftOffset", num(s.slug[2])) + .attr("SlugRightOrOutsideOffset", num(s.slug[3])) + .attr("DocumentSlugUniformSize", bool_s(s.slug.iter().all(|b| *b == s.slug[0]))) + .attr("PreserveLayoutWhenShuffling", "true") + .attr("AllowPageShuffle", "true") + .attr("OverprintBlack", "true") + .attr("PageBinding", "LeftToRight") + .attr("ColumnDirection", "Horizontal") + .attr( + "Intent", + match s.intent { + designcraft_doc::Intent::Print => "PrintIntent", + designcraft_doc::Intent::Web => "WebIntent", + designcraft_doc::Intent::Mobile => "MobileIntent", + }, + ) + .attr("CreatePrimaryTextFrame", bool_s(s.primary_text_frame)) + .attr("ColumnGuideLocked", "true") + .attr("MasterTextFrame", bool_s(s.primary_text_frame)) + .attr("SnippetImportUsesOriginalLocation", "false"), + ); + let page = d.parents.first().and_then(|p| p.pages.first()).or_else(|| d.page(0)); + if let Some(pg) = page { + root.push(margin_el(pg)); + } + root.push( + El::new("ViewPreference") + .attr("HorizontalMeasurementUnits", names::unit_out(s.horizontal_units)) + .attr("VerticalMeasurementUnits", names::unit_out(s.vertical_units)) + .attr("RulerOrigin", "SpreadOrigin") + .attr("CursorKeyIncrement", num(s.keyboard_increment)) + .attr("PointsPerInch", "72"), + ); + let bg = &s.baseline_grid; + root.push( + El::new("GridPreference") + .attr("HorizontalGridlineDivision", num(s.grid.horizontal)) + .attr("VerticalGridlineDivision", num(s.grid.vertical)) + .attr("HorizontalGridSubdivision", s.grid.subdivisions) + .attr("VerticalGridSubdivision", s.grid.subdivisions) + .attr("GridsInBack", bool_s(s.grid.in_back)) + .attr("BaselineStart", num(bg.start)) + .attr("BaselineDivision", num(bg.increment)) + .attr("BaselineViewThreshold", pct(bg.view_threshold)) + .attr( + "BaselineGridRelativeOption", + match bg.relative_to { + designcraft_doc::GridRelative::TopOfPage => "TopOfPageOfBaselineGridRelativeOption", + designcraft_doc::GridRelative::TopMargin => "TopOfMarginOfBaselineGridRelativeOption", + }, + ), + ); + root.push(El::new("PasteboardPreference").attr("PasteboardMargins", pt(s.pasteboard.0, s.pasteboard.1))); + document(&root) + } + + // ---------- Styles.xml ---------- + + fn styles_part(&mut self) -> Vec { + let d = self.d; + let styles = d.styles.clone(); + let mut root = pkg_root("Styles"); + + // Character styles. + let mut cg = Group::default(); + for cs in &styles.character { + let id = names::style_self("CharacterStyle", CHAR_BUILTINS, &cs.name); + let mut el = + El::new("CharacterStyle").attr("Self", &id).attr("Imported", "false").attr("Name", names::style_name_out(CHAR_BUILTINS, &cs.name)); + let mut props = Vec::new(); + if cs.name != st::NO_CHAR_STYLE { + let based = cs.based_on.as_deref().filter(|b| *b != st::NO_CHAR_STYLE); + match based { + Some(b) => props.push(p("BasedOn", "object", names::style_self("CharacterStyle", CHAR_BUILTINS, b))), + None => props.push(p("BasedOn", "string", "$ID/[No character style]")), + } + self.char_attrs(&mut el, &mut props, &cs.chars); + } + cg.insert(&cs.name, with_props(el, props)); + } + let mut rc = El::new("RootCharacterStyleGroup").attr("Self", self.fresh()); + cg.emit(&mut rc, "CharacterStyleGroup", ""); + root.push(rc); + + // Paragraph styles. + let mut pg = Group::default(); + for ps in &styles.paragraph { + let id = names::style_self("ParagraphStyle", PARA_BUILTINS, &ps.name); + let mut el = + El::new("ParagraphStyle").attr("Self", &id).attr("Name", names::style_name_out(PARA_BUILTINS, &ps.name)).attr("Imported", "false"); + let mut props = Vec::new(); + let (pa, ca) = if ps.name == designcraft_doc::NO_PARA_STYLE { + // The root style carries the complete attribute set. + let mut pa = designcraft_doc::ParaProps::common([&designcraft_doc::ParaProps::default()]); + pa.merge(&ps.para); + let mut ca = designcraft_doc::CharProps::common([&designcraft_doc::CharProps::default()]); + ca.merge(&ps.chars); + (pa, ca) + } else { + let next = ps.next_style.as_deref().unwrap_or(&ps.name); + el.set("NextStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, next)); + match ps.based_on.as_deref().filter(|b| *b != designcraft_doc::NO_PARA_STYLE) { + Some(b) => props.push(p("BasedOn", "object", names::style_self("ParagraphStyle", PARA_BUILTINS, b))), + None => props.push(p("BasedOn", "string", "$ID/[No paragraph style]")), + } + (ps.para.clone(), ps.chars.clone()) + }; + self.para_attrs(&mut el, &mut props, &pa); + self.char_attrs(&mut el, &mut props, &ca); + pg.insert(&ps.name, with_props(el, props)); + } + let mut rp = El::new("RootParagraphStyleGroup").attr("Self", self.fresh()); + pg.emit(&mut rp, "ParagraphStyleGroup", ""); + root.push(rp); + + // Cell and table styles (built-ins only). + root.push( + El::new("RootCellStyleGroup").attr("Self", self.fresh()).child( + El::new("CellStyle").attr("Self", "CellStyle/$ID/[None]").attr("AppliedParagraphStyle", NO_PARA_ID).attr("Name", "$ID/[None]"), + ), + ); + root.push( + El::new("RootTableStyleGroup") + .attr("Self", self.fresh()) + .child(El::new("TableStyle").attr("Self", "TableStyle/$ID/[No table style]").attr("Name", "$ID/[No table style]")) + .child(with_props( + El::new("TableStyle").attr("Self", "TableStyle/$ID/[Basic Table]").attr("Name", "$ID/[Basic Table]"), + vec![p("BasedOn", "string", "$ID/[No table style]")], + )), + ); + + // Object styles. + let mut og = Group::default(); + let mut have: Vec<&str> = styles.object.iter().map(|o| o.name.as_str()).collect(); + let defaults = designcraft_doc::Styles::default(); + let mut all: Vec = styles.object.clone(); + for b in &defaults.object { + if !have.contains(&b.name.as_str()) { + all.push(b.clone()); + have.push(&b.name); + } + } + for os in &all { + let id = names::style_self("ObjectStyle", OBJECT_BUILTINS, &os.name); + let mut el = El::new("ObjectStyle").attr("Self", &id).attr("Name", names::style_name_out(OBJECT_BUILTINS, &os.name)); + let mut props = Vec::new(); + if os.name != designcraft_doc::NO_OBJECT_STYLE { + match os.based_on.as_deref().filter(|b| *b != designcraft_doc::NO_OBJECT_STYLE) { + Some(b) => props.push(p("BasedOn", "object", names::style_self("ObjectStyle", OBJECT_BUILTINS, b))), + None => props.push(p("BasedOn", "string", "$ID/[None]")), + } + } + el.set("EnableFill", bool_s(os.fill.is_some())); + el.set("EnableStroke", bool_s(os.stroke.is_some())); + el.set("EnableParagraphStyle", bool_s(os.paragraph_style.is_some())); + el.set("EnableTextFrameGeneralOptions", bool_s(os.text_frame.is_some())); + if let Some(f) = &os.fill { + el.set("FillColor", self.sw(&f.swatch)); + el.set("FillTint", pct(f.tint as f64)); + } + if let Some(s) = &os.stroke { + self.stroke_attrs(&mut el, s); + } + if let Some(ps) = &os.paragraph_style { + el.set("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, ps)); + } + let mut el = with_props(el, props); + if let Some(tf) = &os.text_frame { + el.push(text_frame_pref(tf)); + } + og.insert(&os.name, el); + } + let mut ro = El::new("RootObjectStyleGroup").attr("Self", self.fresh()); + og.emit(&mut ro, "ObjectStyleGroup", ""); + root.push(ro); + document(&root) + } + + fn char_attrs(&mut self, el: &mut El, props: &mut Vec, a: &CharAttrs) { + if let Some(f) = &a.font_family { + props.push(p("AppliedFont", "string", f.clone())); + let style = a.font_style.clone().unwrap_or_else(|| "Regular".into()); + self.note_font(f, &style); + } + if let Some(v) = &a.font_style { + el.set("FontStyle", v); + } + if let Some(v) = a.size { + el.set("PointSize", num(v)); + } + if let Some(l) = a.leading { + let (v, ty) = names::leading_out(l); + props.push(p("Leading", ty, v)); + } + if let Some(k) = a.kerning { + match k { + designcraft_doc::Kerning::Metrics => el.set("KerningMethod", "$ID/Metrics"), + designcraft_doc::Kerning::Optical => el.set("KerningMethod", "$ID/Optical"), + designcraft_doc::Kerning::None => el.set("KerningMethod", "None"), + designcraft_doc::Kerning::Manual(v) => el.set("KerningValue", num(v)), + } + } + if let Some(v) = a.tracking { + el.set("Tracking", num(v)); + } + if let Some(v) = a.h_scale { + el.set("HorizontalScale", pct(v)); + } + if let Some(v) = a.v_scale { + el.set("VerticalScale", pct(v)); + } + if let Some(v) = a.baseline_shift { + el.set("BaselineShift", num(v)); + } + if let Some(v) = a.skew { + el.set("Skew", num(v)); + } + if let Some(v) = &a.fill { + el.set("FillColor", self.sw(v)); + } + if let Some(v) = a.fill_tint { + el.set("FillTint", pct(v as f64)); + } + if let Some(v) = &a.stroke { + el.set("StrokeColor", self.sw(v)); + } + if let Some(v) = a.stroke_tint { + el.set("StrokeTint", pct(v as f64)); + } + if let Some(v) = a.stroke_weight { + el.set("StrokeWeight", num(v)); + } + if let Some(v) = a.capitalization { + el.set("Capitalization", names::caps_out(v)); + } + if let Some(v) = a.position { + el.set("Position", names::position_out(v)); + } + if let Some(v) = a.underline { + el.set("Underline", bool_s(v)); + } + if let Some(v) = a.strikethrough { + el.set("StrikeThru", bool_s(v)); + } + if let Some(v) = a.ligatures { + el.set("Ligatures", bool_s(v)); + } + if let Some(v) = a.no_break { + el.set("NoBreak", bool_s(v)); + } + if let Some(v) = &a.language { + el.set("AppliedLanguage", format!("$ID/{v}")); + } + } + + fn para_attrs(&mut self, el: &mut El, props: &mut Vec, a: &ParaAttrs) { + macro_rules! n { + ($f:ident, $k:literal) => { + if let Some(v) = a.$f { + el.set($k, num(v as f64)); + } + }; + ($f:ident, $k:literal, pct) => { + if let Some(v) = a.$f { + el.set($k, pct(v as f64)); + } + }; + } + macro_rules! b { + ($f:ident, $k:literal) => { + if let Some(v) = a.$f { + el.set($k, bool_s(v)); + } + }; + } + if let Some(v) = a.align { + el.set("Justification", names::align_out(v)); + } + n!(left_indent, "LeftIndent"); + n!(right_indent, "RightIndent"); + n!(first_line_indent, "FirstLineIndent"); + n!(last_line_indent, "LastLineIndent"); + n!(space_before, "SpaceBefore"); + n!(space_after, "SpaceAfter"); + n!(drop_cap_lines, "DropCapLines"); + n!(drop_cap_chars, "DropCapCharacters"); + if let Some(g) = a.grid_align { + match g { + designcraft_doc::GridAlign::None => el.set("GridAlignment", "None"), + designcraft_doc::GridAlign::AllLines => { + el.set("GridAlignment", "AlignToBaseline"); + el.set("GridAlignFirstLineOnly", "false"); + } + designcraft_doc::GridAlign::FirstLineOnly => { + el.set("GridAlignment", "AlignToBaseline"); + el.set("GridAlignFirstLineOnly", "true"); + } + } + } + if let Some(c) = a.composer { + el.set( + "Composer", + match c { + designcraft_doc::Composer::Paragraph => "HL Composer", + designcraft_doc::Composer::SingleLine => "HL Single", + }, + ); + } + b!(hyphenate, "Hyphenation"); + n!(hyph_min_word, "HyphenateWordsLongerThan"); + n!(hyph_after_first, "HyphenateAfterFirst"); + n!(hyph_before_last, "HyphenateBeforeLast"); + n!(hyph_limit, "HyphenateLadderLimit"); + n!(hyph_zone, "HyphenationZone"); + b!(hyph_capitalized, "HyphenateCapitalizedWords"); + b!(hyph_last_word, "HyphenateLastWord"); + b!(hyph_across_column, "HyphenateAcrossColumns"); + if let Some(v) = a.hyph_weight { + el.set("HyphenWeight", num((v * 10.0).round())); + } + n!(word_space_min, "MinimumWordSpacing", pct); + n!(word_space_desired, "DesiredWordSpacing", pct); + n!(word_space_max, "MaximumWordSpacing", pct); + n!(letter_space_min, "MinimumLetterSpacing", pct); + n!(letter_space_desired, "DesiredLetterSpacing", pct); + n!(letter_space_max, "MaximumLetterSpacing", pct); + n!(glyph_scale_min, "MinimumGlyphScaling", pct); + n!(glyph_scale_desired, "DesiredGlyphScaling", pct); + n!(glyph_scale_max, "MaximumGlyphScaling", pct); + n!(auto_leading, "AutoLeading", pct); + if let Some(v) = a.single_word_justify { + el.set("SingleWordJustification", names::align_out(v)); + } + n!(keep_with_next, "KeepWithNext"); + b!(keep_lines_together, "KeepLinesTogether"); + b!(keep_all_lines, "KeepAllLinesTogether"); + n!(keep_first, "KeepFirstLines"); + n!(keep_last, "KeepLastLines"); + if let Some(v) = a.start_paragraph { + el.set("StartParagraph", names::start_para_out(v)); + } + if let Some(sc) = a.span_columns { + match sc { + SpanColumns::Single => el.set("SpanColumnType", "SingleColumn"), + SpanColumns::Span(n) | SpanColumns::Split(n) => { + el.set("SpanColumnType", if matches!(sc, SpanColumns::Span(_)) { "SpanColumns" } else { "SplitColumns" }); + props.push(if n == 0 { + p("SpanSplitColumnCount", "enumeration", "All") + } else { + p("SpanSplitColumnCount", "long", n.to_string()) + }); + } + } + } + if let Some(r) = &a.rule_above { + self.rule(el, props, "RuleAbove", r); + } + if let Some(r) = &a.rule_below { + self.rule(el, props, "RuleBelow", r); + } + if let Some(tabs) = &a.tabs { + let mut tl = El::new("TabList").attr("type", "list"); + for t in tabs { + tl.push( + El::new("ListItem") + .attr("type", "record") + .child(p("Alignment", "enumeration", names::tab_align_out(t.align))) + .child(p("AlignmentCharacter", "string", if t.align_on.is_empty() { ".".to_string() } else { t.align_on.clone() })) + .child(p("Leader", "string", t.leader.clone())) + .child(p("Position", "unit", num(t.position))), + ); + } + props.push(tl); + } + if let Some(l) = a.list_type { + el.set( + "BulletsAndNumberingListType", + match l { + ListType::None => "NoList", + ListType::Bullets => "BulletList", + ListType::Numbers => "NumberedList", + }, + ); + } + if let Some(b) = a.balance_ragged { + props.push(p("BalanceRaggedLines", "enumeration", if b { "FullyBalanced" } else { "NoBalancing" })); + } + b!(shading_on, "ParagraphShadingOn"); + if let Some(c) = &a.shading_color { + props.push(p("ParagraphShadingColor", "object", self.sw(c))); + } + n!(shading_tint, "ParagraphShadingTint", pct); + } + + fn rule(&mut self, el: &mut El, props: &mut Vec, k: &str, r: &Rule) { + el.set(k, bool_s(r.on)); + el.set(&format!("{k}LineWeight"), num(r.weight)); + el.set(&format!("{k}Tint"), pct(r.tint as f64)); + el.set(&format!("{k}Width"), if r.column_width { "ColumnWidth" } else { "TextWidth" }); + el.set(&format!("{k}Offset"), num(r.offset)); + el.set(&format!("{k}LeftIndent"), num(r.left_indent)); + el.set(&format!("{k}RightIndent"), num(r.right_indent)); + props.push(p(&format!("{k}Color"), "object", self.sw(&r.color))); + } + + fn stroke_attrs(&self, el: &mut El, s: &designcraft_doc::Stroke) { + el.set("StrokeColor", self.sw(&s.swatch)); + el.set("StrokeWeight", num(if s.is_none() { 0.0 } else { s.weight })); + el.set("StrokeTint", pct(s.tint as f64)); + el.set("StrokeType", names::stroke_type_out(&s.kind)); + el.set("StrokeAlignment", names::stroke_align_out(s.align)); + el.set("EndCap", names::cap_out(s.cap)); + el.set("EndJoin", names::join_out(s.join)); + el.set("MiterLimit", num(s.miter_limit)); + el.set("LeftLineEnd", names::arrow_out(s.start)); + el.set("RightLineEnd", names::arrow_out(s.end)); + el.set("GapColor", self.sw(&s.gap_swatch)); + el.set("GapTint", pct(s.gap_tint as f64)); + el.set("OverprintStroke", bool_s(s.overprint)); + } + + // ---------- spreads ---------- + + fn spread_el_doc(&mut self, sp: &Spread, si: usize) -> El { + let first = self.d.first_page_of_spread(si); + let mut el = self.spread_common(sp, false, first); + el.name = "Spread".into(); + el + } + + fn spread_el(&mut self, sp: &Spread, _master: bool) -> El { + let mut el = self.spread_common(sp, true, 0); + el.name = "MasterSpread".into(); + el + } + + fn spread_common(&mut self, sp: &Spread, master: bool, first_page: usize) -> El { + let d = self.d; + let facing = d.settings.facing_pages; + let (ox, oy) = spread_origin(sp, facing); + let mut el = El::new("Spread").attr("Self", uid(sp.id.0)); + if let (true, Some(info)) = (master, &sp.parent) { + el.set("Name", info.label()); + el.set("NamePrefix", &info.prefix); + el.set("BaseName", &info.name); + } + el.set("ShowMasterItems", bool_s(sp.pages.iter().all(|p| p.show_parent_items))); + el.set("PageCount", sp.pages.len()); + if !master { + el.set("BindingLocation", sp.pages.iter().filter(|p| p.side == PageSide::Left).count()); + el.set("AllowPageShuffle", bool_s(sp.allow_shuffle)); + } + el.set("ItemTransform", "1 0 0 1 0 0"); + let based_on = sp.parent.as_ref().and_then(|i| i.based_on); + for (i, pg) in sp.pages.iter().enumerate() { + let name = if master { sp.parent.as_ref().map(|i| i.prefix.clone()).unwrap_or_default() } else { d.page_name(first_page + i) }; + let applied = if master { based_on } else { pg.parent }; + let pe = El::new("Page") + .attr("Self", uid(pg.id.0)) + .attr("Name", name) + .attr("AppliedMaster", applied.map(|s| uid(s.0)).unwrap_or_else(|| "n".into())) + .attr("OverrideList", "") + .attr("GeometricBounds", format!("0 0 {} {}", num(pg.height), num(pg.width))) + .attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy))) + .child(margin_el(pg)); + el.push(pe); + } + let origin = Affine::translate((-ox, -oy)); + for it in &sp.items { + let e = self.item_el(it, origin * it.xf); + el.push(e); + } + el + } + + fn item_el(&mut self, it: &Item, xf: Affine) -> El { + let d = self.d; + let tag = match (&it.content, it.shape) { + (Content::Text(_), _) => "TextFrame", + (Content::Group { .. }, _) => "Group", + (_, Shape::Oval) => "Oval", + (_, Shape::Polygon) | (_, Shape::Path) => "Polygon", + (_, Shape::GraphicLine) => "GraphicLine", + _ => "Rectangle", + }; + let mut el = El::new(tag).attr("Self", uid(it.id.0)); + if let Content::Text(t) = &it.content { + let (prev, next) = self.threads.get(&it.id.0).copied().unwrap_or((None, None)); + el.set("ParentStory", uid(t.story.0)); + el.set("PreviousTextFrame", prev.map(uid).unwrap_or_else(|| "n".into())); + el.set("NextTextFrame", next.map(uid).unwrap_or_else(|| "n".into())); + } + if tag != "Group" { + el.set( + "ContentType", + match it.content { + Content::Text(_) => "TextType", + Content::Graphic(_) => "GraphicType", + _ => "Unassigned", + }, + ); + } + el.set("Name", if it.name.is_empty() { "$ID/".to_string() } else { it.name.clone() }); + el.set("ItemLayer", uid(it.layer.0)); + el.set("Visible", bool_s(!it.hidden)); + el.set("Locked", bool_s(it.locked)); + el.set("Nonprinting", bool_s(it.nonprinting)); + let os = if it.object_style.is_empty() { designcraft_doc::NO_OBJECT_STYLE } else { &it.object_style }; + el.set("AppliedObjectStyle", names::style_self("ObjectStyle", OBJECT_BUILTINS, os)); + el.set("ItemTransform", affine_s(xf)); + el.set("FillColor", self.sw(&it.fill.swatch)); + el.set("FillTint", pct(it.fill.tint as f64)); + el.set("OverprintFill", bool_s(it.fill.overprint)); + if let Some(a) = it.fill.gradient_angle { + el.set("GradientFillAngle", num(a)); + } + self.stroke_attrs(&mut el, &it.stroke); + // Corner options: map path-order corners to the named corners by geometry. + if !it.corners.is_none() { + let names_by_index = corner_names(&it.path); + for (i, c) in it.corners.corners.iter().enumerate() { + let n = names_by_index[i]; + el.set(&format!("{n}CornerOption"), names::corner_out(c.shape)); + el.set(&format!("{n}CornerRadius"), num(c.size)); + } + } + let mut props = Vec::new(); + if tag != "Group" && !it.path.is_empty() { + props.push(path_geometry(&it.path)); + } + let mut el = with_props(el, props); + // Transparency. + let ds = &it.effects.drop_shadow; + if it.opacity < 1.0 || it.blend != designcraft_color::BlendMode::Normal || ds.on || it.effects.feather > 0.0 { + let mut t = El::new("TransparencySetting"); + t.push(El::new("BlendingSetting").attr("BlendMode", names::blend_out(it.blend)).attr("Opacity", pct(it.opacity as f64))); + if ds.on { + let a = ds.angle.to_radians(); + t.push( + El::new("DropShadowSetting") + .attr("Mode", "Drop") + .attr("XOffset", num(-ds.distance * a.cos())) + .attr("YOffset", num(ds.distance * a.sin())) + .attr("Size", num(ds.size)) + .attr("Spread", num(ds.spread)) + .attr("Opacity", pct(ds.opacity as f64)) + .attr("EffectColor", self.sw(&ds.color)), + ); + } + if it.effects.feather > 0.0 { + t.push(El::new("FeatherSetting").attr("Mode", "Standard").attr("Width", num(it.effects.feather))); + } + el.push(t); + } + if let Content::Text(t) = &it.content { + el.push(text_frame_pref(&t.options)); + if let Some((start, inc)) = t.options.baseline_grid { + el.push( + El::new("BaselineFrameGridOption") + .attr("UseCustomBaselineFrameGrid", "true") + .attr("StartingOffsetForBaselineFrameGrid", num(start)) + .attr("BaselineFrameGridIncrement", num(inc)), + ); + } + } + let w = &it.wrap; + el.push(with_props( + El::new("TextWrapPreference") + .attr("Inverse", bool_s(w.invert)) + .attr("ApplyToMasterPageOnly", "false") + .attr("TextWrapSide", names::wrap_side_out(w.side)) + .attr("TextWrapMode", names::wrap_mode_out(w.mode)), + vec![ + El::new("TextWrapOffset") + .attr("Top", num(w.offsets[0])) + .attr("Left", num(w.offsets[1])) + .attr("Bottom", num(w.offsets[2])) + .attr("Right", num(w.offsets[3])), + ], + )); + match &it.content { + Content::Group { items } => { + for c in items { + let e = self.item_el(c, c.xf); + el.push(e); + } + } + Content::Graphic(g) => { + if let Some(asset) = d.assets.get(&g.asset) { + let e = self.image_el(asset, g); + el.push(e); + } + } + _ => {} + } + el + } + + fn image_el(&mut self, asset: &designcraft_doc::Asset, g: &designcraft_doc::Graphic) -> El { + let is_pdf = asset.mime == "application/pdf"; + let mut el = El::new(if is_pdf { "PDF" } else { "Image" }).attr("Self", self.fresh()); + if let Some((pw, ph)) = asset.pixels + && g.size.0 > 0.0 + && g.size.1 > 0.0 + { + el.set("ActualPpi", pt(pw as f64 * 72.0 / g.size.0, ph as f64 * 72.0 / g.size.1)); + } + el.set("ItemTransform", affine_s(g.xf)); + let embed = !asset.data.is_empty() && (self.opts.embed_images || asset.link.is_none()); + let mut props = Vec::new(); + if embed { + let mut c = El::new("Contents"); + c.children.push(Node::CData(base64_encode(&asset.data))); + props.push(c); + } + props.push(El::new("GraphicBounds").attr("Left", "0").attr("Top", "0").attr("Right", num(g.size.0)).attr("Bottom", num(g.size.1))); + let mut el = with_props(el, props); + let uri = match &asset.link { + Some(l) => path_to_uri(l), + None => format!("file:{}", asset.name), + }; + el.push( + El::new("Link") + .attr("Self", self.fresh()) + .attr("AssetURL", "$ID/") + .attr("AssetID", "$ID/") + .attr("LinkResourceURI", uri) + .attr("LinkResourceFormat", format!("$ID/{}", format_name(&asset.mime))) + .attr("StoredState", if embed { "Embedded" } else { "Normal" }) + .attr("LinkClassID", "35906") + .attr("LinkClientID", "257") + .attr("LinkResourceModified", "false") + .attr("LinkObjectModified", "false") + .attr("ShowInUI", "true") + .attr("CanEmbed", "true") + .attr("CanUnembed", "true") + .attr("CanPackage", "true") + .attr("ImportPolicy", "NoAutoImport") + .attr("ExportPolicy", "NoAutoExport"), + ); + el + } + + // ---------- stories ---------- + + fn story_el(&mut self, s: &Story) -> El { + let mut el = El::new("Story").attr("Self", uid(s.id.0)).attr("AppliedTOCStyle", "n").attr("TrackChanges", "false").attr("StoryTitle", "$ID/"); + el.push( + El::new("StoryPreference") + .attr("OpticalMarginAlignment", "false") + .attr("OpticalMarginSize", "12") + .attr("FrameType", "TextFrameType") + .attr("StoryOrientation", "Horizontal") + .attr("StoryDirection", "LeftToRightDirection"), + ); + let ranges = s.para_ranges(); + let n = ranges.len(); + for (pi, r) in ranges.into_iter().enumerate() { + let pf = s.paras.get(pi).cloned().unwrap_or_default(); + let mut psr = self.psr_el(&pf); + let last = pi + 1 == n; + // Character runs intersecting the paragraph. + let mut segs: Vec<(std::ops::Range, CharFormat)> = Vec::new(); + for (rr, f) in s.runs() { + let a = rr.start.max(r.start); + let b = rr.end.min(r.end); + if a < b { + segs.push((a..b, f.clone())); + } + } + if segs.is_empty() { + let f = s.char_format_at(r.start).clone(); + segs.push((r.start..r.start, f)); + } + let nseg = segs.len(); + for (k, (rr, f)) in segs.into_iter().enumerate() { + let text = &s.text[rr]; + // Split at break characters. + let mut cur = String::new(); + let mut pending: Vec = Vec::new(); + let mut csrs: Vec = Vec::new(); + let flush_text = |cur: &mut String, pending: &mut Vec| { + if !cur.is_empty() { + pending.push(Node::Text(std::mem::take(cur))); + } + }; + let flush_content = |pending: &mut Vec, out: &mut Vec| { + if !pending.is_empty() { + let mut c = El::new("Content"); + c.children = std::mem::take(pending); + out.push(Node::El(c)); + } + }; + let mut out: Vec = Vec::new(); + for ch in text.chars() { + let ace = match ch { + st::PAGE_NUMBER | st::NEXT_PAGE_NUMBER | st::PREV_PAGE_NUMBER => Some("18"), + st::SECTION_MARKER => Some("19"), + st::INDENT_HERE => Some("7"), + st::RIGHT_INDENT_TAB => Some("8"), + _ => None, + }; + let brk = match ch { + st::COLUMN_BREAK => Some("NextColumn"), + st::FRAME_BREAK => Some("NextFrame"), + st::PAGE_BREAK => Some("NextPage"), + _ => None, + }; + if let Some(code) = ace { + flush_text(&mut cur, &mut pending); + pending.push(Node::Pi("ACE".into(), code.into())); + } else if let Some(bt) = brk { + flush_text(&mut cur, &mut pending); + flush_content(&mut pending, &mut out); + if !out.is_empty() { + let mut c = self.csr_el(&f); + c.children.append(&mut out); + csrs.push(c); + } + let mut c = self.csr_el(&f); + c.set("ParagraphBreakType", bt); + c.push(El::new("Br")); + csrs.push(c); + } else { + cur.push(ch); + } + } + flush_text(&mut cur, &mut pending); + flush_content(&mut pending, &mut out); + let is_last_seg = k + 1 == nseg; + if is_last_seg && !last { + out.push(Node::El(El::new("Br"))); + } + if !out.is_empty() || csrs.is_empty() { + let mut c = self.csr_el(&f); + c.children.extend(out); + csrs.push(c); + } + for c in csrs { + psr.push(c); + } + } + el.push(psr); + } + el + } + + fn psr_el(&mut self, pf: &ParaFormat) -> El { + let mut el = El::new("ParagraphStyleRange").attr("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, &pf.style)); + let mut props = Vec::new(); + self.para_attrs(&mut el, &mut props, &pf.para); + self.char_attrs(&mut el, &mut props, &pf.chars); + with_props(el, props) + } + + fn csr_el(&mut self, f: &CharFormat) -> El { + let id = if f.style.is_empty() || f.style == st::NO_CHAR_STYLE { + NO_CHAR_ID.to_string() + } else { + names::style_self("CharacterStyle", CHAR_BUILTINS, &f.style) + }; + let mut el = El::new("CharacterStyleRange").attr("AppliedCharacterStyle", id); + let mut props = Vec::new(); + self.char_attrs(&mut el, &mut props, &f.over); + with_props(el, props) + } +} + +/// Style folder tree (names `A/B/C` → groups A, B). +#[derive(Default)] +struct Group { + styles: Vec, + groups: Vec<(String, Group)>, +} + +impl Group { + fn insert(&mut self, name: &str, el: El) { + let builtin = name.starts_with('['); + let mut segs: Vec<&str> = if builtin { vec![name] } else { name.split('/').collect() }; + let _leaf = segs.pop(); + let mut g = self; + for s in segs { + let i = match g.groups.iter().position(|(n, _)| n == s) { + Some(i) => i, + None => { + g.groups.push((s.to_string(), Group::default())); + g.groups.len() - 1 + } + }; + g = &mut g.groups[i].1; + } + g.styles.push(el); + } + + fn emit(self, parent: &mut El, kind: &str, path: &str) { + for s in self.styles { + parent.push(s); + } + for (name, g) in self.groups { + let full = if path.is_empty() { name.clone() } else { format!("{path}:{name}") }; + let mut ge = El::new(kind).attr("Self", format!("{kind}/{}", escape_id(&full))).attr("Name", &name); + g.emit(&mut ge, kind, &full); + parent.push(ge); + } + } +} + +fn margin_el(pg: &designcraft_doc::Page) -> El { + let m = pg.margin_rect(); + let cols = designcraft_doc::column_rects(Rect::new(0.0, 0.0, m.width(), m.height()), pg.columns.count.max(1), pg.columns.gutter); + let pos: Vec = cols.iter().flat_map(|c| [num(c.x0), num(c.x1)]).collect(); + El::new("MarginPreference") + .attr("ColumnCount", pg.columns.count.max(1)) + .attr("ColumnGutter", num(pg.columns.gutter)) + .attr("Top", num(pg.margins.top)) + .attr("Bottom", num(pg.margins.bottom)) + .attr("Left", num(pg.margins.inside)) + .attr("Right", num(pg.margins.outside)) + .attr("ColumnDirection", "Horizontal") + .attr("ColumnsPositions", pos.join(" ")) +} + +fn text_frame_pref(o: &TextFrameOptions) -> El { + let mut inset = El::new("InsetSpacing").attr("type", "list"); + for v in o.inset { + inset.push(p("ListItem", "unit", num(v))); + } + let el = El::new("TextFramePreference") + .attr("TextColumnCount", o.columns.max(1)) + .attr("TextColumnGutter", num(o.gutter)) + .attr("UseFixedColumnWidth", bool_s(o.columns_kind == ColumnsKind::FixedWidth)) + .attr("UseFlexibleColumnWidth", bool_s(o.columns_kind == ColumnsKind::FlexibleWidth)) + .attr("VerticalBalanceColumns", bool_s(o.balance_columns)) + .attr("FirstBaselineOffset", names::first_baseline_out(o.first_baseline)) + .attr("MinimumFirstBaselineOffset", num(o.first_baseline_min)) + .attr("VerticalJustification", names::vj_out(o.vertical_justification)) + .attr("VerticalThreshold", num(o.vj_paragraph_spacing_limit)) + .attr("IgnoreWrap", bool_s(o.ignore_wrap)) + .attr("AutoSizingType", names::auto_size_out(o.auto_size)) + .attr("AutoSizingReferencePoint", names::REF_POINTS[(o.auto_size_ref as usize).min(8)]); + let el = if o.column_width > 0.0 { el.attr("TextColumnFixedWidth", num(o.column_width)) } else { el }; + with_props(el, vec![inset]) +} + +fn path_geometry(path: &PathData) -> El { + let mut pg = El::new("PathGeometry"); + for sp in &path.subpaths { + let mut arr = El::new("PathPointArray"); + for a in &sp.anchors { + arr.push( + El::new("PathPointType") + .attr("Anchor", pt(a.p.x, a.p.y)) + .attr("LeftDirection", pt(a.h_in.x, a.h_in.y)) + .attr("RightDirection", pt(a.h_out.x, a.h_out.y)), + ); + } + pg.push(El::new("GeometryPathType").attr("PathOpen", bool_s(!sp.closed)).child(arr)); + } + pg +} + +/// IDML corner names for anchors 0..4 of a path (by position on its bounding box). +pub(crate) fn corner_names(path: &PathData) -> [&'static str; 4] { + let default = ["TopLeft", "TopRight", "BottomRight", "BottomLeft"]; + let Some(sp) = path.subpaths.first() else { return default }; + if sp.anchors.len() != 4 { + return default; + } + let Some(b) = path.bounds() else { return default }; + let c = b.center(); + let mut out = default; + let mut seen = HashSet::new(); + for (i, a) in sp.anchors.iter().enumerate() { + let n = match (a.p.y < c.y, a.p.x < c.x) { + (true, true) => "TopLeft", + (true, false) => "TopRight", + (false, false) => "BottomRight", + (false, true) => "BottomLeft", + }; + if !seen.insert(n) { + return default; + } + out[i] = n; + } + out +} + +fn format_name(mime: &str) -> &'static str { + match mime { + "image/png" => "Portable Network Graphics (PNG)", + "image/jpeg" => "JPEG", + "image/gif" => "GIF", + "image/tiff" => "TIFF", + "application/pdf" => "Adobe Portable Document Format (PDF)", + _ => "Portable Network Graphics (PNG)", + } +} + +/// File path → IDML link URI (`file:/abs/path` with spaces percent-encoded). +pub(crate) fn path_to_uri(path: &str) -> String { + if path.contains(':') && !path.starts_with('/') && path.chars().nth(1) != Some(':') { + return path.to_string(); // already a URI + } + let mut out = String::from("file:"); + let p = path.replace('\\', "/"); + for c in p.chars() { + match c { + ' ' => out.push_str("%20"), + '%' => out.push_str("%25"), + '#' => out.push_str("%23"), + c => out.push(c), + } + } + out +} + +const CONTAINER: &str = "\n\n\t\n\t\t\n\t\n\n"; + +const STROKE_STYLES: &[&str] = &[ + "Triple_Stroke", + "ThickThinThick", + "ThinThickThin", + "ThickThick", + "ThickThin", + "ThinThick", + "ThinThin", + "Japanese Dots", + "White Diamond", + "Left Slant Hash", + "Right Slant Hash", + "Straight Hash", + "Wavy", + "Canned Dotted", + "Canned Dashed 3x2", + "Canned Dashed 4x4", + "Dashed", + "Solid", +]; diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs new file mode 100644 index 0000000..f976f50 --- /dev/null +++ b/crates/idml/src/import.rs @@ -0,0 +1,1504 @@ +//! IDML package → Document. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::Read; +use std::sync::Arc; + +use designcraft_color::cms::lab; +use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, Swatch, SwatchValue, swatch}; +use designcraft_doc::{ + Asset, AssetId, BaselineGrid, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, Content, DocSettings, Document, DropShadow, + Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, ListType, Margins, ObjectStyle, Page, PageId, + PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, Rule, Section, Shape, SpanColumns, Spread, SpreadId, Story, StoryId, Stroke, Styles, + TabStop, TextFrame, TextFrameOptions, TextWrap, story as st, +}; +use designcraft_geom::corners::{Corner, CornerOptions}; +use designcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath}; + +use crate::names::{self, CHAR_BUILTINS, OBJECT_BUILTINS, PARA_BUILTINS, unescape_id}; +use crate::xml::{El, Node, parse}; +use crate::{IdmlError, MIMETYPE, Result, base64_decode, sniff_image}; + +/// Import an IDML package. Linked images are read from disk when available (not on wasm). +pub fn import_idml(bytes: &[u8]) -> Result { + import_idml_with(bytes, &default_reader) +} + +fn default_reader(path: &str) -> Option> { + #[cfg(not(target_arch = "wasm32"))] + { + std::fs::read(path).ok() + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + None + } +} + +/// Import with a custom reader for linked files (`path` → bytes). +pub fn import_idml_with(bytes: &[u8], read_link: &dyn Fn(&str) -> Option>) -> Result { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?; + let mut files: HashMap> = HashMap::new(); + for i in 0..zip.len() { + let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?; + if f.is_dir() { + continue; + } + let name = f.name().to_string(); + let mut buf = Vec::with_capacity(f.size() as usize); + f.read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: name.clone(), msg: e.to_string() })?; + files.insert(name, buf); + } + if let Some(m) = files.get("mimetype") + && String::from_utf8_lossy(m).trim() != MIMETYPE + { + return Err(IdmlError::NotIdml(format!("unexpected mimetype `{}`", String::from_utf8_lossy(m).trim()))); + } + let dm = files.get("designmap.xml").ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?; + let root = parse(dm).map_err(|msg| IdmlError::Part { part: "designmap.xml".into(), msg })?; + if root.local() != "Document" { + return Err(IdmlError::NotIdml(format!("designmap root is <{}>", root.name))); + } + // Flatten includes: every `idPkg:*` child is replaced by the children of its part's root. + let mut top: Vec = Vec::new(); + for c in root.elements() { + if c.name.starts_with("idPkg:") { + let Some(src) = c.get("src") else { continue }; + let Some(data) = files.get(src) else { continue }; + let part = parse(data).map_err(|msg| IdmlError::Part { part: src.into(), msg })?; + top.extend(part.elements().cloned()); + } else { + top.push(c.clone()); + } + } + let mut im = Importer::new(read_link); + im.run(&root, &top)?; + let d = im.finish(&root)?; + d.check().map_err(|e| IdmlError::Invalid(e.to_string()))?; + Ok(d) +} + +struct ItemCtx { + /// Self → (story self, prev self, next self) + threads: Vec<(ItemId, String, String, String)>, +} + +struct Importer<'r> { + read_link: &'r dyn Fn(&str) -> Option>, + next_id: u64, + settings: DocSettings, + default_margins: Option<(Margins, Columns)>, + swatches: Vec, + /// Swatch Self → our swatch name. + swatch_names: HashMap, + /// Hidden (unnamed) colours by Self. + hidden_colors: HashMap, + /// Unnamed swatches (gradients) added on first use. + hidden_swatches: HashMap, + styles: Styles, + para_names: HashMap, + char_names: HashMap, + object_names: HashMap, + /// Object style Self → element (attribute fallback for page items). + object_els: HashMap, + layers: Vec, + layer_ids: HashMap, + stories: BTreeMap, + story_ids: HashMap, + parents: Vec, + parent_ids: HashMap, + spreads: Vec, + page_index: HashMap, + item_ids: HashMap, + assets: BTreeMap>, + ctx: ItemCtx, + sections: Vec
, +} + +fn lab_to_color(l: f32, a: f32, b: f32) -> Color { + let rgb = lab::xyz_to_srgb(lab::lab_to_xyz(lab::Lab::new(l, a, b))); + Color::rgb(rgb[0], rgb[1], rgb[2]) +} + +fn nums(s: &str) -> Vec { + s.split_whitespace().filter_map(|v| v.parse().ok()).collect() +} + +fn parse_affine(s: Option<&str>) -> Affine { + match s.map(nums) { + Some(v) if v.len() == 6 => Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]]), + _ => Affine::IDENTITY, + } +} + +fn parse_point(s: Option<&str>) -> Option { + let v = nums(s?); + (v.len() >= 2).then(|| Point::new(v[0], v[1])) +} + +/// IDML tint (percent, `-1` = default) → 0..1. +fn tint(v: Option) -> Option { + v.map(|t| if t < 0.0 { 1.0 } else { (t / 100.0) as f32 }) +} + +impl<'r> Importer<'r> { + fn new(read_link: &'r dyn Fn(&str) -> Option>) -> Self { + let specials: Vec = designcraft_color::default_swatches().into_iter().filter(|s| s.locked).collect(); + let mut swatch_names = HashMap::new(); + swatch_names.insert("Swatch/None".to_string(), swatch::NONE.to_string()); + swatch_names.insert("Color/Paper".to_string(), swatch::PAPER.to_string()); + swatch_names.insert("Color/Black".to_string(), swatch::BLACK.to_string()); + swatch_names.insert("Color/Registration".to_string(), swatch::REGISTRATION.to_string()); + let mut styles = Styles::default(); + // Built-ins are replaced by the file's definitions when present. + styles.paragraph.iter_mut().for_each(|s| s.based_on = None); + Importer { + read_link, + next_id: 0, + settings: DocSettings::default(), + default_margins: None, + swatches: specials, + swatch_names, + hidden_colors: HashMap::new(), + hidden_swatches: HashMap::new(), + styles, + para_names: HashMap::new(), + char_names: HashMap::new(), + object_names: HashMap::new(), + object_els: HashMap::new(), + layers: Vec::new(), + layer_ids: HashMap::new(), + stories: BTreeMap::new(), + story_ids: HashMap::new(), + parents: Vec::new(), + parent_ids: HashMap::new(), + spreads: Vec::new(), + page_index: HashMap::new(), + item_ids: HashMap::new(), + assets: BTreeMap::new(), + ctx: ItemCtx { threads: Vec::new() }, + sections: Vec::new(), + } + } + + fn alloc(&mut self) -> u64 { + self.next_id += 1; + self.next_id + } + + fn run(&mut self, _root: &El, top: &[El]) -> Result<()> { + // Preferences first (page size, facing pages). + for e in top { + match e.local() { + "DocumentPreference" => self.doc_prefs(e), + "MarginPreference" => { + let (m, c) = margins_of(e); + self.default_margins = Some((m, c)); + } + "ViewPreference" => { + if let Some(u) = e.get("HorizontalMeasurementUnits") { + self.settings.horizontal_units = names::unit_in(u); + } + if let Some(u) = e.get("VerticalMeasurementUnits") { + self.settings.vertical_units = names::unit_in(u); + } + if let Some(v) = e.num("CursorKeyIncrement") { + self.settings.keyboard_increment = v; + } + } + "GridPreference" => self.grid_prefs(e), + "PasteboardPreference" => { + if let Some(v) = e.get("PasteboardMargins").map(nums) + && v.len() == 2 + && v[0] >= 0.0 + && v[1] >= 0.0 + { + self.settings.pasteboard = (v[0], v[1]); + } + } + _ => {} + } + } + self.graphics(top); + self.styles(top); + for e in top.iter().filter(|e| e.local() == "Layer") { + let id = LayerId(self.alloc()); + let color = match e.prop_el("LayerColor") { + Some(c) if c.get("type") == Some("list") => { + let v: Vec = c.find_all("ListItem").filter_map(|i| i.text_content().trim().parse().ok()).collect(); + if v.len() == 3 { [v[0] as u8, v[1] as u8, v[2] as u8] } else { LAYER_COLORS[0].1 } + } + Some(c) => names::layer_color_in(c.text_content().trim()).unwrap_or(LAYER_COLORS[self.layers.len() % LAYER_COLORS.len()].1), + None => LAYER_COLORS[self.layers.len() % LAYER_COLORS.len()].1, + }; + self.layers.push(Layer { + id, + name: e.get("Name").unwrap_or("Layer").to_string(), + color, + visible: e.get("Visible") != Some("false"), + locked: e.get("Locked") == Some("true"), + printable: e.get("Printable") != Some("false"), + show_guides: e.get("ShowGuides") != Some("false"), + suppress_wrap_when_hidden: e.get("IgnoreWrap") == Some("true"), + }); + if let Some(s) = e.get("Self") { + self.layer_ids.insert(s.to_string(), id); + } + } + if self.layers.is_empty() { + let id = LayerId(self.alloc()); + self.layers.push(Layer { + id, + name: "Layer 1".into(), + color: LAYER_COLORS[0].1, + visible: true, + locked: false, + printable: true, + show_guides: true, + suppress_wrap_when_hidden: false, + }); + } + // Stories (ids first so frames can reference them). + for e in top.iter().filter(|e| e.local() == "Story") { + let id = StoryId(self.alloc()); + if let Some(s) = e.get("Self") { + self.story_ids.insert(s.to_string(), id); + } + let story = self.story(id, e); + self.stories.insert(id, story); + } + // Parent spreads: ids first (pages reference other parents). + let masters: Vec<&El> = top.iter().filter(|e| e.local() == "MasterSpread").collect(); + let mut master_ids = Vec::new(); + for m in &masters { + let id = SpreadId(self.alloc()); + if let Some(s) = m.get("Self") { + self.parent_ids.insert(s.to_string(), id); + } + master_ids.push(id); + } + for (m, id) in masters.iter().zip(master_ids) { + let sp = self.spread(m, id, true); + self.parents.push(sp); + } + for e in top.iter().filter(|e| e.local() == "Spread") { + let id = SpreadId(self.alloc()); + let sp = self.spread(e, id, false); + self.spreads.push(sp); + } + // Sections. + for e in top.iter().filter(|e| e.local() == "Section") { + let Some(start) = e.get("PageStart").and_then(|p| self.page_index.get(p)).copied() else { continue }; + let cont = e.get("ContinueNumbering") != Some("false"); + self.sections.push(Section { + start, + start_number: if cont && start > 0 { None } else { Some(e.num("PageNumberStart").unwrap_or(1.0).max(1.0) as u32) }, + style: e.prop("PageNumberStyle").map(|s| names::number_style_in(s.trim())).unwrap_or_default(), + prefix: e.get("SectionPrefix").unwrap_or("").to_string(), + marker: e.get("Marker").unwrap_or("").to_string(), + include_prefix: e.get("IncludeSectionPrefix") == Some("true"), + }); + } + self.sections.sort_by_key(|s| s.start); + self.sections.dedup_by_key(|s| s.start); + if self.sections.first().is_none_or(|s| s.start != 0) { + self.sections.insert( + 0, + Section { + start: 0, + start_number: Some(1), + style: Default::default(), + prefix: String::new(), + marker: String::new(), + include_prefix: false, + }, + ); + } + self.link_threads(); + Ok(()) + } + + fn doc_prefs(&mut self, e: &El) { + let s = &mut self.settings; + if let Some(v) = e.num("PageWidth") { + s.page_width = v; + } + if let Some(v) = e.num("PageHeight") { + s.page_height = v; + } + if let Some(v) = e.boolean("FacingPages") { + s.facing_pages = v; + } + for (i, k) in ["DocumentBleedTopOffset", "DocumentBleedBottomOffset", "DocumentBleedInsideOrLeftOffset", "DocumentBleedOutsideOrRightOffset"] + .iter() + .enumerate() + { + if let Some(v) = e.num(k) { + s.bleed[i] = v; + } + } + for (i, k) in ["SlugTopOffset", "SlugBottomOffset", "SlugInsideOrLeftOffset", "SlugRightOrOutsideOffset"].iter().enumerate() { + if let Some(v) = e.num(k) { + s.slug[i] = v; + } + } + if let Some(i) = e.get("Intent") { + s.intent = match i { + "WebIntent" => designcraft_doc::Intent::Web, + "MobileIntent" => designcraft_doc::Intent::Mobile, + _ => designcraft_doc::Intent::Print, + }; + } + if let Some(v) = e.boolean("CreatePrimaryTextFrame") { + s.primary_text_frame = v; + } + } + + fn grid_prefs(&mut self, e: &El) { + let s = &mut self.settings; + let bg: &mut BaselineGrid = &mut s.baseline_grid; + if let Some(v) = e.num("BaselineStart") { + bg.start = v; + } + if let Some(v) = e.num("BaselineDivision") { + bg.increment = v; + } + if let Some(v) = e.num("BaselineViewThreshold") { + bg.view_threshold = v / 100.0; + } + if let Some(v) = e.get("BaselineGridRelativeOption") { + bg.relative_to = if v.contains("Margin") { GridRelative::TopMargin } else { GridRelative::TopOfPage }; + } + if let Some(v) = e.num("HorizontalGridlineDivision") { + s.grid.horizontal = v; + } + if let Some(v) = e.num("VerticalGridlineDivision") { + s.grid.vertical = v; + } + if let Some(v) = e.num("HorizontalGridSubdivision") { + s.grid.subdivisions = v.max(1.0) as u32; + } + if let Some(v) = e.boolean("GridsInBack") { + s.grid.in_back = v; + } + } + + // ---------- swatches ---------- + + fn color_of(e: &El) -> Option<(Color, ColorType)> { + let v = nums(e.get("ColorValue").unwrap_or("")); + let space = e.get("Space").unwrap_or("CMYK"); + let c = match space { + "RGB" if v.len() >= 3 => Color::rgb((v[0] / 255.0) as f32, (v[1] / 255.0) as f32, (v[2] / 255.0) as f32), + "LAB" if v.len() >= 3 => lab_to_color(v[0] as f32, v[1] as f32, v[2] as f32), + _ if v.len() >= 4 => Color::cmyk((v[0] / 100.0) as f32, (v[1] / 100.0) as f32, (v[2] / 100.0) as f32, (v[3] / 100.0) as f32), + _ => return None, + }; + let ty = if e.get("Model") == Some("Spot") { ColorType::Spot } else { ColorType::Process }; + Some((c, ty)) + } + + fn graphics(&mut self, top: &[El]) { + let mut order: Vec<(String, Swatch)> = Vec::new(); + // Colours. + for e in top.iter().filter(|e| e.local() == "Color") { + let Some(id) = e.get("Self") else { continue }; + let Some((c, ty)) = Self::color_of(e) else { continue }; + match id { + "Color/Paper" => { + if let Some(s) = self.swatches.iter_mut().find(|s| s.name == swatch::PAPER) { + s.value = SwatchValue::Paper { color: if c == Color::cmyk(0.0, 0.0, 0.0, 0.0) { Color::WHITE } else { c } }; + } + continue; + } + "Color/Black" | "Color/Registration" => continue, + _ => {} + } + let name = e.get("Name").unwrap_or(""); + if e.get("Visible") == Some("false") || name.is_empty() || name == "$ID/" || e.get("Model") == Some("Registration") { + self.hidden_colors.insert(id.to_string(), c); + continue; + } + let named = !name.starts_with("C=") && !name.starts_with("R="); + self.swatch_names.insert(id.to_string(), name.to_string()); + order.push(( + id.to_string(), + Swatch { name: name.to_string(), value: SwatchValue::Color { color: c, color_type: ty }, locked: false, named }, + )); + } + // Tints (after colours: they reference them). + for e in top.iter().filter(|e| e.local() == "Tint") { + let Some(id) = e.get("Self") else { continue }; + let base = e.get("BaseColor").map(|b| self.swatch_ref(b)).unwrap_or_else(|| swatch::BLACK.to_string()); + let t = e.num("TintValue").unwrap_or(100.0); + let name = match e.get("Name") { + Some(n) if !n.is_empty() && n != "$ID/" => n.to_string(), + _ => format!("{base} {}%", names::num(t)), + }; + self.swatch_names.insert(id.to_string(), name.clone()); + order.push((id.to_string(), Swatch { name, value: SwatchValue::Tint { base, tint: (t / 100.0) as f32 }, locked: false, named: true })); + } + for e in top.iter().filter(|e| e.local() == "Gradient") { + let Some(id) = e.get("Self") else { continue }; + let mut stops: Vec = Vec::new(); + for s in e.find_all("GradientStop") { + let c = s.get("StopColor").map(|r| self.color_for_ref(r, &order)).unwrap_or(Color::BLACK); + let loc = s.num("Location").unwrap_or(0.0) / 100.0; + if let (Some(prev), Some(m)) = (stops.last_mut(), s.num("Midpoint")) { + prev.midpoint = (m / 100.0) as f32; + } + stops.push(GradientStop { offset: loc as f32, color: c, opacity: 1.0, midpoint: 0.5 }); + } + let kind = if e.get("Type") == Some("Radial") { GradientKind::Radial } else { GradientKind::Linear }; + let unnamed = e.get("Visible") == Some("false") || e.get("Name").is_none_or(|n| n.is_empty() || n == "$ID/"); + let name = if unnamed { "Gradient".to_string() } else { e.get("Name").unwrap_or_default().to_string() }; + let sw = Swatch { name, value: SwatchValue::Gradient { gradient: Gradient { kind, stops } }, locked: false, named: !unnamed }; + if unnamed { + // Unnamed gradients become swatches only when something uses them. + self.hidden_swatches.insert(id.to_string(), sw); + } else { + self.swatch_names.insert(id.to_string(), sw.name.clone()); + order.push((id.to_string(), sw)); + } + } + // Panel order from the root colour group. + let group_order: Vec = top + .iter() + .filter(|e| e.local() == "ColorGroup") + .flat_map(|g| g.find_all("ColorGroupSwatch").filter_map(|s| s.get("SwatchItemRef").map(str::to_string)).collect::>()) + .collect(); + if !group_order.is_empty() { + order.sort_by_key(|(id, _)| group_order.iter().position(|g| g == id).unwrap_or(usize::MAX)); + } + let mut names: HashSet = self.swatches.iter().map(|s| s.name.clone()).collect(); + for (id, mut s) in order { + if names.contains(&s.name) { + let base = s.name.clone(); + s.name = Styles::unique_name(|n| names.contains(n), &base); + self.swatch_names.insert(id, s.name.clone()); + } + names.insert(s.name.clone()); + self.swatches.push(s); + } + } + + fn color_for_ref(&self, r: &str, pending: &[(String, Swatch)]) -> Color { + if let Some(c) = self.hidden_colors.get(r) { + return *c; + } + match r { + "Color/Black" => return Color::cmyk(0.0, 0.0, 0.0, 1.0), + "Color/Paper" => return Color::WHITE, + "Color/Registration" => return Color::cmyk(1.0, 1.0, 1.0, 1.0), + _ => {} + } + if let Some((_, s)) = pending.iter().find(|(id, _)| id == r) { + match &s.value { + SwatchValue::Color { color, .. } => return *color, + SwatchValue::Tint { base, tint } => { + let mut all: Vec = self.swatches.clone(); + all.extend(pending.iter().map(|(_, s)| s.clone())); + return designcraft_color::swatch::resolve(&all, base, *tint).unwrap_or(Color::BLACK); + } + _ => {} + } + } + Color::BLACK + } + + /// A swatch reference (Self id) → our swatch name; unnamed colours become value-named swatches. + fn swatch_ref(&mut self, r: &str) -> String { + if let Some(n) = self.swatch_names.get(r) { + return n.clone(); + } + if r == "n" || r.is_empty() { + return swatch::NONE.into(); + } + if let Some(mut sw) = self.hidden_swatches.remove(r) { + sw.name = Styles::unique_name(|n| self.swatches.iter().any(|s| s.name == n), &sw.name); + self.swatch_names.insert(r.to_string(), sw.name.clone()); + let name = sw.name.clone(); + self.swatches.push(sw); + return name; + } + if let Some(c) = self.hidden_colors.get(r).copied() { + let name = match c { + Color::Cmyk { c, m, y, k } => designcraft_color::swatch::cmyk_name(c, m, y, k), + Color::Rgb { r, g, b } => format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round()), + Color::Gray { k } => format!("K={}", (k * 100.0).round()), + }; + if !self.swatches.iter().any(|s| s.name == name) { + self.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Color { color: c, color_type: ColorType::Process }, + locked: false, + named: false, + }); + } + self.swatch_names.insert(r.to_string(), name.clone()); + return name; + } + swatch::NONE.into() + } + + // ---------- styles ---------- + + fn styles(&mut self, top: &[El]) { + let mut paras: Vec<(String, El)> = Vec::new(); + let mut chars: Vec<(String, El)> = Vec::new(); + let mut objects: Vec<(String, El)> = Vec::new(); + fn walk(e: &El, style: &str, group: &str, out: &mut Vec<(String, El)>) { + for c in e.elements() { + if c.local() == style { + out.push((c.get("Self").unwrap_or("").to_string(), c.clone())); + } else if c.local() == group { + walk(c, style, group, out); + } + } + } + for e in top { + match e.local() { + "RootParagraphStyleGroup" => walk(e, "ParagraphStyle", "ParagraphStyleGroup", &mut paras), + "RootCharacterStyleGroup" => walk(e, "CharacterStyle", "CharacterStyleGroup", &mut chars), + "RootObjectStyleGroup" => walk(e, "ObjectStyle", "ObjectStyleGroup", &mut objects), + _ => {} + } + } + for (id, e) in ¶s { + let n = names::style_name_in(PARA_BUILTINS, e.get("Name").unwrap_or("")); + self.para_names.insert(id.clone(), n); + } + for (id, e) in &chars { + let n = names::style_name_in(CHAR_BUILTINS, e.get("Name").unwrap_or("")); + self.char_names.insert(id.clone(), n); + } + for (id, e) in &objects { + let n = names::style_name_in(OBJECT_BUILTINS, e.get("Name").unwrap_or("")); + self.object_names.insert(id.clone(), n); + self.object_els.insert(id.clone(), e.clone()); + } + for (_, e) in ¶s { + let name = names::style_name_in(PARA_BUILTINS, e.get("Name").unwrap_or("")); + let based_on = if name == designcraft_doc::NO_PARA_STYLE { None } else { self.based_on(e, true) }; + let next_style = e.get("NextStyle").map(|n| self.para_style_ref(n)).filter(|n| *n != name); + let para = self.para_attrs(e); + let chars = self.char_attrs(e); + let s = ParagraphStyle { name: name.clone(), based_on, next_style, para, chars, shortcut: String::new() }; + match self.styles.para_mut(&name) { + Some(slot) => *slot = s, + None => self.styles.paragraph.push(s), + } + } + for (_, e) in &chars { + let name = names::style_name_in(CHAR_BUILTINS, e.get("Name").unwrap_or("")); + let based_on = if name == st::NO_CHAR_STYLE { None } else { self.based_on(e, false) }; + let chars = self.char_attrs(e); + let s = CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() }; + match self.styles.char_style_mut(&name) { + Some(slot) => *slot = s, + None => self.styles.character.push(s), + } + } + for (_, e) in &objects { + let name = names::style_name_in(OBJECT_BUILTINS, e.get("Name").unwrap_or("")); + let based_on = e.prop("BasedOn").map(|b| { + self.object_names + .get(&b) + .cloned() + .unwrap_or_else(|| names::style_name_in(OBJECT_BUILTINS, &unescape_id(b.trim_start_matches("ObjectStyle/")))) + }); + let based_on = based_on.filter(|b| *b != designcraft_doc::NO_OBJECT_STYLE && *b != name); + let enabled = |k: &str, attr: &str| match e.get(k) { + Some(v) => v == "true", + None => e.get(attr).is_some(), + }; + let fill = enabled("EnableFill", "FillColor").then(|| { + let sw = e.get("FillColor").map(|r| self.swatch_ref(r)).unwrap_or_else(|| swatch::NONE.into()); + Fill { swatch: sw, tint: tint(e.num("FillTint")).unwrap_or(1.0), ..Fill::none() } + }); + let stroke = enabled("EnableStroke", "StrokeColor").then(|| self.stroke_from(e, None)); + let paragraph_style = enabled("EnableParagraphStyle", "AppliedParagraphStyle") + .then(|| e.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r))) + .flatten(); + let text_frame = + if e.get("EnableTextFrameGeneralOptions") == Some("true") { e.find("TextFramePreference").map(text_frame_options) } else { None }; + let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame }; + match self.styles.object.iter_mut().find(|o| o.name == name) { + Some(slot) => *slot = s, + None => self.styles.object.push(s), + } + } + } + + fn based_on(&self, e: &El, para: bool) -> Option { + let b = e.prop("BasedOn")?; + let b = b.trim(); + if para { + if let Some(n) = self.para_names.get(b) { + return Some(n.clone()); + } + let n = names::style_name_in(PARA_BUILTINS, &unescape_id(b.trim_start_matches("ParagraphStyle/"))); + Some(n) + } else { + if let Some(n) = self.char_names.get(b) { + return Some(n.clone()).filter(|n| n != st::NO_CHAR_STYLE); + } + let n = names::style_name_in(CHAR_BUILTINS, &unescape_id(b.trim_start_matches("CharacterStyle/"))); + Some(n).filter(|n| n != st::NO_CHAR_STYLE) + } + } + + fn para_style_ref(&self, r: &str) -> String { + self.para_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(PARA_BUILTINS, &unescape_id(r.trim_start_matches("ParagraphStyle/")))) + } + fn char_style_ref(&self, r: &str) -> String { + self.char_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(CHAR_BUILTINS, &unescape_id(r.trim_start_matches("CharacterStyle/")))) + } + + fn char_attrs(&mut self, e: &El) -> CharAttrs { + let mut a = CharAttrs::default(); + if let Some(f) = e.prop("AppliedFont") { + let f = f.trim(); + // Some writers append the style after a tab. + let fam = f.split('\t').next().unwrap_or(f); + if !fam.is_empty() && fam != "$ID/" { + a.font_family = Some(fam.to_string()); + } + } + a.font_style = e.prop("FontStyle"); + a.size = e.num("PointSize"); + a.leading = e.prop("Leading").and_then(|l| names::leading_in(l.trim())); + if let Some(v) = e.num("KerningValue") { + a.kerning = Some(Kerning::Manual(v)); + } else if let Some(k) = e.prop("KerningMethod") { + a.kerning = Some(match k.trim().trim_start_matches("$ID/") { + "Optical" => Kerning::Optical, + "None" => Kerning::None, + _ => Kerning::Metrics, + }); + } + a.tracking = e.num("Tracking"); + a.h_scale = e.num("HorizontalScale").map(|v| v / 100.0); + a.v_scale = e.num("VerticalScale").map(|v| v / 100.0); + a.baseline_shift = e.num("BaselineShift"); + a.skew = e.num("Skew"); + if let Some(r) = e.prop("FillColor") { + a.fill = Some(self.swatch_ref(r.trim())); + } + a.fill_tint = tint(e.num("FillTint")); + if let Some(r) = e.prop("StrokeColor") { + a.stroke = Some(self.swatch_ref(r.trim())); + } + a.stroke_tint = tint(e.num("StrokeTint")); + a.stroke_weight = e.num("StrokeWeight"); + a.capitalization = e.prop("Capitalization").and_then(|v| names::caps_in(v.trim())); + a.position = e.prop("Position").and_then(|v| names::position_in(v.trim())); + a.underline = e.boolean("Underline"); + a.strikethrough = e.boolean("StrikeThru"); + a.ligatures = e.boolean("Ligatures"); + a.no_break = e.boolean("NoBreak"); + a.language = e.prop("AppliedLanguage").map(|l| l.trim().trim_start_matches("$ID/").to_string()); + a + } + + fn para_attrs(&mut self, e: &El) -> ParaAttrs { + let mut a = ParaAttrs::default(); + let u = |k: &str| e.num(k).map(|v| v.max(0.0) as u32); + let frac = |k: &str| e.num(k).map(|v| v / 100.0); + a.align = e.prop("Justification").and_then(|v| names::align_in(v.trim())); + a.left_indent = e.num("LeftIndent"); + a.right_indent = e.num("RightIndent"); + a.first_line_indent = e.num("FirstLineIndent"); + a.last_line_indent = e.num("LastLineIndent"); + a.space_before = e.num("SpaceBefore"); + a.space_after = e.num("SpaceAfter"); + a.drop_cap_lines = u("DropCapLines"); + a.drop_cap_chars = u("DropCapCharacters"); + if let Some(g) = e.prop("GridAlignment") { + a.grid_align = Some(if g == "None" { + GridAlign::None + } else if e.boolean("GridAlignFirstLineOnly") == Some(true) { + GridAlign::FirstLineOnly + } else { + GridAlign::AllLines + }); + } + a.composer = e + .prop("Composer") + .map(|c| if c.contains("Single") { designcraft_doc::Composer::SingleLine } else { designcraft_doc::Composer::Paragraph }); + a.hyphenate = e.boolean("Hyphenation"); + a.hyph_min_word = u("HyphenateWordsLongerThan"); + a.hyph_after_first = u("HyphenateAfterFirst"); + a.hyph_before_last = u("HyphenateBeforeLast"); + a.hyph_limit = u("HyphenateLadderLimit"); + a.hyph_zone = e.num("HyphenationZone"); + a.hyph_capitalized = e.boolean("HyphenateCapitalizedWords"); + a.hyph_last_word = e.boolean("HyphenateLastWord"); + a.hyph_across_column = e.boolean("HyphenateAcrossColumns"); + a.hyph_weight = e.num("HyphenWeight").map(|v| v / 10.0); + a.word_space_min = frac("MinimumWordSpacing"); + a.word_space_desired = frac("DesiredWordSpacing"); + a.word_space_max = frac("MaximumWordSpacing"); + a.letter_space_min = frac("MinimumLetterSpacing"); + a.letter_space_desired = frac("DesiredLetterSpacing"); + a.letter_space_max = frac("MaximumLetterSpacing"); + a.glyph_scale_min = frac("MinimumGlyphScaling"); + a.glyph_scale_desired = frac("DesiredGlyphScaling"); + a.glyph_scale_max = frac("MaximumGlyphScaling"); + a.auto_leading = frac("AutoLeading"); + a.single_word_justify = e.prop("SingleWordJustification").and_then(|v| names::align_in(v.trim())); + a.keep_with_next = u("KeepWithNext"); + a.keep_lines_together = e.boolean("KeepLinesTogether"); + a.keep_all_lines = e.boolean("KeepAllLinesTogether"); + a.keep_first = u("KeepFirstLines"); + a.keep_last = u("KeepLastLines"); + a.start_paragraph = e.prop("StartParagraph").and_then(|v| names::start_para_in(v.trim())); + if let Some(t) = e.prop("SpanColumnType") { + let n = e.prop("SpanSplitColumnCount").and_then(|v| v.trim().parse::().ok()).unwrap_or(0); + a.span_columns = Some(match t.trim() { + "SpanColumns" => SpanColumns::Span(n), + "SplitColumns" => SpanColumns::Split(n.max(2)), + _ => SpanColumns::Single, + }); + } + a.rule_above = self.rule(e, "RuleAbove"); + a.rule_below = self.rule(e, "RuleBelow"); + if let Some(tl) = e.prop_el("TabList") { + let mut tabs = Vec::new(); + for li in tl.find_all("ListItem") { + let get = |k: &str| li.find(k).map(|x| x.text_content()); + tabs.push(TabStop { + position: get("Position").and_then(|v| v.trim().parse().ok()).unwrap_or(0.0), + align: names::tab_align_in(get("Alignment").unwrap_or_default().trim()), + leader: get("Leader").unwrap_or_default(), + align_on: get("AlignmentCharacter").filter(|c| c != ".").unwrap_or_default(), + }); + } + a.tabs = Some(tabs); + } + a.list_type = e.prop("BulletsAndNumberingListType").map(|v| match v.trim() { + "BulletList" => ListType::Bullets, + "NumberedList" => ListType::Numbers, + _ => ListType::None, + }); + a.balance_ragged = e.prop("BalanceRaggedLines").map(|v| v.trim() != "NoBalancing" && v.trim() != "false"); + a.shading_on = e.boolean("ParagraphShadingOn"); + if let Some(c) = e.prop("ParagraphShadingColor") { + a.shading_color = Some(self.swatch_ref(c.trim())); + } + a.shading_tint = tint(e.num("ParagraphShadingTint")); + a + } + + fn rule(&mut self, e: &El, k: &str) -> Option { + let on = e.boolean(k); + let weight = e.num(&format!("{k}LineWeight")); + on.or(weight.map(|_| false))?; + let mut r = Rule { on: on.unwrap_or(false), ..Rule::default() }; + if let Some(w) = weight { + r.weight = w; + } + if let Some(c) = e.prop(&format!("{k}Color")) { + let c = c.trim(); + if c.contains('/') { + r.color = self.swatch_ref(c); + } + } + if let Some(t) = tint(e.num(&format!("{k}Tint"))) { + r.tint = t; + } + r.column_width = e.prop(&format!("{k}Width")).is_none_or(|w| w.trim() != "TextWidth"); + r.offset = e.num(&format!("{k}Offset")).unwrap_or(0.0); + r.left_indent = e.num(&format!("{k}LeftIndent")).unwrap_or(0.0); + r.right_indent = e.num(&format!("{k}RightIndent")).unwrap_or(0.0); + Some(r) + } + + // ---------- stories ---------- + + fn story(&mut self, id: StoryId, e: &El) -> Story { + let mut b = + StoryBuilder { text: String::new(), paras: vec![ParaFormat::default()], runs: Vec::new(), fresh: true, last: CharFormat::default() }; + self.walk_story(e, &mut b, &ParaFormat::default(), &CharAttrs::default(), &CharFormat::default(), None); + let StoryBuilder { text, paras, runs, last, .. } = b; + let mut chars: Vec = runs.into_iter().filter(|r| r.len > 0).collect(); + if chars.is_empty() { + chars.push(CharRun { len: 0, format: last }); + } + Story { id, text, paras, chars, frames: vec![], rev: 0 } + } + + fn walk_story(&mut self, e: &El, b: &mut StoryBuilder, pf: &ParaFormat, pchars: &CharAttrs, cf: &CharFormat, brk: Option<&str>) { + for n in &e.children { + match n { + Node::El(c) => match c.local() { + "ParagraphStyleRange" => { + let style = c.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).unwrap_or_else(|| st::BASIC_PARAGRAPH.into()); + let para = self.para_attrs(c); + let chars = self.char_attrs(c); + let npf = ParaFormat { style, para, chars: CharAttrs::default() }; + if b.fresh + && let Some(last) = b.paras.last_mut() + { + *last = npf.clone(); + } + self.walk_story(c, b, &npf, &chars, cf, None); + } + "CharacterStyleRange" => { + let style = c.get("AppliedCharacterStyle").map(|r| self.char_style_ref(r)).unwrap_or_else(|| st::NO_CHAR_STYLE.into()); + let mut over = pchars.clone(); + over.merge(&self.char_attrs(c)); + let ncf = CharFormat { style, over }; + let bt = c.get("ParagraphBreakType").filter(|t| *t != "Anywhere"); + b.last = ncf.clone(); + self.walk_story(c, b, pf, pchars, &ncf, bt); + } + "Content" => { + for t in &c.children { + match t { + Node::Text(s) | Node::CData(s) => { + let s: String = s + .chars() + .filter(|c| *c != '\u{FEFF}') + .map(|c| if c == '\n' || c == '\r' || c == '\u{2029}' { st::FORCED_LINE_BREAK } else { c }) + .collect(); + b.push(&s, cf); + } + Node::Pi(t, v) if t == "ACE" => { + if let Some(ch) = ace_char(v) { + b.push(&ch.to_string(), cf); + } + } + _ => {} + } + } + } + "Br" => match brk { + Some(t) => { + let ch = match t { + "NextColumn" => st::COLUMN_BREAK, + "NextFrame" => st::FRAME_BREAK, + _ => st::PAGE_BREAK, + }; + b.push(&ch.to_string(), cf); + } + None => { + b.push("\n", cf); + b.paras.push(pf.clone()); + b.fresh = true; + } + }, + // Not supported yet: skip their content entirely. + "Properties" + | "Table" + | "Footnote" + | "Note" + | "Rectangle" + | "Oval" + | "Polygon" + | "GraphicLine" + | "TextFrame" + | "Group" + | "StoryPreference" + | "InCopyExportOption" + | "TextVariableInstance" => {} + _ => self.walk_story(c, b, pf, pchars, cf, brk), + }, + Node::Pi(t, v) if t == "ACE" => { + if let Some(ch) = ace_char(v) { + b.push(&ch.to_string(), cf); + } + } + _ => {} + } + } + } + + // ---------- spreads and items ---------- + + fn spread(&mut self, e: &El, id: SpreadId, master: bool) -> Spread { + let facing = self.settings.facing_pages; + // Page rectangles in IDML spread coordinates. + let mut pages: Vec<(Rect, &El)> = Vec::new(); + for p in e.find_all("Page") { + let gb = nums(p.get("GeometricBounds").unwrap_or("")); + let (t, l, bt, r) = + if gb.len() == 4 { (gb[0], gb[1], gb[2], gb[3]) } else { (0.0, 0.0, self.settings.page_height, self.settings.page_width) }; + let xf = parse_affine(p.get("ItemTransform")); + let a = xf * Point::new(l, t); + let c = xf * Point::new(r, bt); + pages.push((Rect::from_points(a, c), p)); + } + let min_x = pages.iter().map(|(r, _)| r.x0).fold(f64::INFINITY, f64::min); + let min_y = pages.iter().map(|(r, _)| r.y0).fold(f64::INFINITY, f64::min); + let (min_x, min_y) = if min_x.is_finite() { (min_x, min_y) } else { (0.0, 0.0) }; + let shift = Affine::translate((-min_x, -min_y)); + let show = e.get("ShowMasterItems") != Some("false"); + let first_abs: usize = self.spreads.iter().map(|s| s.pages.len()).sum(); + let mut out_pages = Vec::new(); + for (i, (r, p)) in pages.iter().enumerate() { + let pid = PageId(self.alloc()); + if let Some(s) = p.get("Self") + && !master + { + self.page_index.insert(s.to_string(), first_abs + i); + } + let (margins, columns) = match p.find("MarginPreference") { + Some(m) => margins_of(m), + None => self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default())), + }; + let side = if !facing { + PageSide::Single + } else if r.x1 <= 1e-6 { + PageSide::Left + } else { + PageSide::Right + }; + let parent = p.get("AppliedMaster").and_then(|m| self.parent_ids.get(m)).copied(); + out_pages.push(Page { + id: pid, + width: r.width(), + height: r.height(), + x: r.x0 - min_x, + margins, + columns, + parent, + side, + overridden: vec![], + guides: Vec::::new(), + show_parent_items: show, + }); + } + let overrides: Vec> = + pages.iter().map(|(_, p)| p.get("OverrideList").unwrap_or("").split_whitespace().map(str::to_string).collect()).collect(); + let mut items = Vec::new(); + for c in e.elements() { + if let Some(it) = self.item(c, shift) { + items.push(Arc::new(it)); + } + } + for (pg, ov) in out_pages.iter_mut().zip(overrides) { + pg.overridden = ov.iter().filter_map(|s| self.item_ids.get(s)).copied().filter(|i| self.is_parent_item(*i)).collect(); + } + let parent = if master { + let prefix = + e.get("NamePrefix").map(str::to_string).unwrap_or_else(|| e.get("Name").unwrap_or("A").split('-').next().unwrap_or("A").to_string()); + let name = e.get("BaseName").map(str::to_string).unwrap_or_else(|| { + let n = e.get("Name").unwrap_or("Parent"); + n.split_once('-').map(|(_, b)| b.to_string()).unwrap_or_else(|| n.to_string()) + }); + let based_on = pages.first().and_then(|(_, p)| p.get("AppliedMaster")).and_then(|m| self.parent_ids.get(m)).copied().filter(|b| *b != id); + Some(ParentInfo { prefix, name, based_on }) + } else { + None + }; + for p in &mut out_pages { + if master { + p.parent = None; + } + } + Spread { id, pages: out_pages, items, parent, allow_shuffle: e.get("AllowPageShuffle") != Some("false") } + } + + fn is_parent_item(&self, id: ItemId) -> bool { + self.parents.iter().any(|p| { + p.items.iter().any(|it| { + let mut found = false; + it.walk(&mut |i| found |= i.id == id); + found + }) + }) + } + + /// Resolve an item attribute, falling back to its object style chain. + fn attr_or_style(&self, e: &El, k: &str) -> Option { + if let Some(v) = e.get(k) { + return Some(v.to_string()); + } + let mut os = e.get("AppliedObjectStyle").map(str::to_string); + let mut depth = 0; + while let Some(id) = os { + depth += 1; + if depth > 16 { + break; + } + let Some(s) = self.object_els.get(&id) else { break }; + if let Some(v) = s.get(k) { + return Some(v.to_string()); + } + os = s.prop("BasedOn").map(|b| if b.starts_with("ObjectStyle/") { b } else { format!("ObjectStyle/{}", names::escape_id(&b)) }); + } + None + } + + fn stroke_from(&mut self, e: &El, item: Option<&El>) -> Stroke { + let g = |s: &Self, k: &str| -> Option { if item.is_some() { s.attr_or_style(e, k) } else { e.get(k).map(str::to_string) } }; + let color = g(self, "StrokeColor"); + let mut s = Stroke::none(); + if let Some(c) = color { + s.swatch = self.swatch_ref(&c); + } + if let Some(w) = g(self, "StrokeWeight").and_then(|v| v.parse().ok()) { + s.weight = w; + } + if let Some(t) = g(self, "StrokeTint").and_then(|v| v.parse().ok()) { + s.tint = tint(Some(t)).unwrap_or(1.0); + } + if let Some(t) = g(self, "StrokeType") { + s.kind = names::stroke_type_in(&t); + } + if let Some(v) = g(self, "StrokeAlignment") { + s.align = names::stroke_align_in(&v); + } + if let Some(v) = g(self, "EndCap") { + s.cap = names::cap_in(&v); + } + if let Some(v) = g(self, "EndJoin") { + s.join = names::join_in(&v); + } + if let Some(v) = g(self, "MiterLimit").and_then(|v| v.parse().ok()) { + s.miter_limit = v; + } + if let Some(v) = g(self, "LeftLineEnd") { + s.start = names::arrow_in(&v); + } + if let Some(v) = g(self, "RightLineEnd") { + s.end = names::arrow_in(&v); + } + if let Some(v) = g(self, "GapColor") { + s.gap_swatch = self.swatch_ref(&v); + } + if let Some(t) = g(self, "GapTint").and_then(|v| v.parse().ok()) { + s.gap_tint = tint(Some(t)).unwrap_or(1.0); + } + s.overprint = g(self, "OverprintStroke").as_deref() == Some("true"); + s + } + + fn item(&mut self, e: &El, parent: Affine) -> Option { + let tag = e.local(); + let shape = match tag { + "Rectangle" | "TextFrame" => Shape::Rectangle, + "Oval" => Shape::Oval, + "Polygon" => Shape::Polygon, + "GraphicLine" => Shape::GraphicLine, + "Group" => Shape::Group, + _ => return None, + }; + let id = ItemId(self.alloc()); + if let Some(s) = e.get("Self") { + self.item_ids.insert(s.to_string(), id); + } + let layer = e.get("ItemLayer").and_then(|l| self.layer_ids.get(l)).copied().unwrap_or_else(|| self.layers[0].id); + let path = e.prop_el("PathGeometry").map(path_of).unwrap_or_default(); + let mut it = Item::new(id, layer, shape, path); + it.xf = parent * parse_affine(e.get("ItemTransform")); + it.name = e.get("Name").filter(|n| *n != "$ID/").unwrap_or("").to_string(); + it.locked = e.get("Locked") == Some("true"); + it.hidden = e.get("Visible") == Some("false"); + it.nonprinting = e.get("Nonprinting") == Some("true"); + it.object_style = e + .get("AppliedObjectStyle") + .map(|r| { + self.object_names + .get(r) + .cloned() + .unwrap_or_else(|| names::style_name_in(OBJECT_BUILTINS, &unescape_id(r.trim_start_matches("ObjectStyle/")))) + }) + .unwrap_or_else(|| designcraft_doc::NO_OBJECT_STYLE.into()); + // Fill and stroke. + if tag != "Group" { + if let Some(f) = self.attr_or_style(e, "FillColor") { + it.fill.swatch = self.swatch_ref(&f); + } + if let Some(t) = self.attr_or_style(e, "FillTint").and_then(|v| v.parse().ok()) { + it.fill.tint = tint(Some(t)).unwrap_or(1.0); + } + it.fill.overprint = self.attr_or_style(e, "OverprintFill").as_deref() == Some("true"); + if let Some(a) = e.num("GradientFillAngle") + && a != 0.0 + { + it.fill.gradient_angle = Some(a); + } + it.stroke = self.stroke_from(e, Some(e)); + if it.stroke.weight <= 0.0 { + it.stroke.swatch = swatch::NONE.into(); + it.stroke.weight = 1.0; + } + } + // Corners. + let cn = crate::export::corner_names(&it.path); + // The legacy all-corners attributes apply only when no per-corner attribute is present. + let per_corner = cn.iter().any(|n| e.get(&format!("{n}CornerOption")).is_some()); + let legacy = if per_corner { (None, None) } else { (e.get("CornerOption"), e.num("CornerRadius")) }; + let mut corners = CornerOptions::default(); + for (i, n) in cn.iter().enumerate() { + let shape = e.get(&format!("{n}CornerOption")).or(legacy.0).map(names::corner_in).unwrap_or_default(); + let size = e.num(&format!("{n}CornerRadius")).or(legacy.1).unwrap_or(0.0); + corners.corners[i] = Corner { shape, size }; + } + if !corners.is_none() { + it.corners = corners; + } + // Transparency. + if let Some(t) = e.find("TransparencySetting") { + if let Some(bs) = t.find("BlendingSetting") { + if let Some(o) = bs.num("Opacity") { + it.opacity = (o / 100.0).clamp(0.0, 1.0) as f32; + } + if let Some(m) = bs.get("BlendMode") { + it.blend = names::blend_in(m); + } + } + if let Some(ds) = t.find("DropShadowSetting") + && ds.get("Mode") == Some("Drop") + { + let x = ds.num("XOffset").unwrap_or(4.95); + let y = ds.num("YOffset").unwrap_or(4.95); + let def = DropShadow::default(); + it.effects.drop_shadow = DropShadow { + on: true, + color: ds.get("EffectColor").map(|c| self.swatch_ref(c)).unwrap_or(def.color), + opacity: ds.num("Opacity").map(|o| (o / 100.0) as f32).unwrap_or(def.opacity), + angle: names_angle(x, y), + distance: x.hypot(y), + size: ds.num("Size").unwrap_or(def.size), + spread: ds.num("Spread").unwrap_or(def.spread), + }; + } + if let Some(f) = t.find("FeatherSetting") + && f.get("Mode").is_some_and(|m| m != "None") + { + it.effects = Effects { feather: f.num("Width").unwrap_or(9.0), ..it.effects.clone() }; + } + } + // Text wrap. + if let Some(w) = e.find("TextWrapPreference") { + let mut tw = TextWrap { + mode: names::wrap_mode_in(w.get("TextWrapMode").unwrap_or("None")), + invert: w.get("Inverse") == Some("true"), + side: names::wrap_side_in(w.get("TextWrapSide").unwrap_or("BothSides")), + ..TextWrap::default() + }; + if let Some(o) = w.prop_el("TextWrapOffset") { + tw.offsets = + [o.num("Top").unwrap_or(0.0), o.num("Left").unwrap_or(0.0), o.num("Bottom").unwrap_or(0.0), o.num("Right").unwrap_or(0.0)]; + } + it.wrap = tw; + } + // Content. + match tag { + "TextFrame" => { + let story_self = e.get("ParentStory").unwrap_or("n").to_string(); + let story = match self.story_ids.get(&story_self) { + Some(s) => *s, + None => { + let sid = StoryId(self.alloc()); + self.stories.insert(sid, Story::new(sid)); + self.story_ids.insert(format!("#orphan{}", sid.0), sid); + sid + } + }; + let mut options = e.find("TextFramePreference").map(text_frame_options).unwrap_or_default(); + if let Some(g) = e.find("BaselineFrameGridOption") + && g.get("UseCustomBaselineFrameGrid") == Some("true") + { + options.baseline_grid = + Some((g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0), g.num("BaselineFrameGridIncrement").unwrap_or(12.0))); + } + it.content = Content::Text(TextFrame { story, options }); + self.ctx.threads.push(( + id, + story_self, + e.get("PreviousTextFrame").unwrap_or("n").to_string(), + e.get("NextTextFrame").unwrap_or("n").to_string(), + )); + } + "Group" => { + let mut kids = Vec::new(); + for c in e.elements() { + if let Some(k) = self.item(c, Affine::IDENTITY) { + kids.push(Arc::new(k)); + } + } + it.content = Content::Group { items: kids }; + it.fill = Fill::none(); + it.stroke = Stroke::none(); + } + _ => { + if let Some(g) = e.elements().find(|c| matches!(c.local(), "Image" | "PDF" | "EPS" | "ImportedPage" | "WMF" | "PICT" | "SVG")) { + it.content = self.graphic(g); + } + } + } + Some(it) + } + + fn graphic(&mut self, g: &El) -> Content { + let gxf = parse_affine(g.get("ItemTransform")); + let (l, t, r, b) = match g.prop_el("GraphicBounds") { + Some(gb) => { + (gb.num("Left").unwrap_or(0.0), gb.num("Top").unwrap_or(0.0), gb.num("Right").unwrap_or(0.0), gb.num("Bottom").unwrap_or(0.0)) + } + None => (0.0, 0.0, 0.0, 0.0), + }; + let size = ((r - l).abs(), (b - t).abs()); + let link = g.find("Link"); + let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path); + let mut data = g.prop_el("Contents").map(|c| base64_decode(&c.text_content())).unwrap_or_default(); + if data.is_empty() + && let Some(p) = &uri + && let Some(d) = (self.read_link)(p) + { + data = d; + } + let (mime, px) = sniff_image(&data); + let mime = mime.map(str::to_string).unwrap_or_else(|| match link.and_then(|k| k.get("LinkResourceFormat")).unwrap_or("") { + f if f.contains("JPEG") => "image/jpeg".into(), + f if f.contains("TIFF") => "image/tiff".into(), + f if f.contains("PDF") || g.local() == "PDF" => "application/pdf".into(), + f if f.contains("GIF") => "image/gif".into(), + _ => "image/png".into(), + }); + let pixels = px.or_else(|| { + let ppi = nums(g.get("ActualPpi").unwrap_or("")); + (ppi.len() == 2 && size.0 > 0.0).then(|| ((size.0 * ppi[0] / 72.0).round() as u32, (size.1 * ppi[1] / 72.0).round() as u32)) + }); + let name = + uri.as_deref().and_then(|p| p.rsplit(['/', '\\']).next()).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| "image".into()); + let id = AssetId(self.alloc()); + let link_path = uri.filter(|p| p.contains('/') || p.contains('\\')); + self.assets.insert(id, Arc::new(Asset { id, name, mime, link: link_path, data: Arc::new(data), pixels })); + Content::Graphic(designcraft_doc::Graphic { asset: id, size, xf: gxf * Affine::translate((l, t)), auto_fit: Default::default() }) + } + + fn link_threads(&mut self) { + let threads = std::mem::take(&mut self.ctx.threads); + let by_self: HashMap = self.item_ids.clone(); + let mut per_story: BTreeMap> = BTreeMap::new(); + for (i, (id, _, _, _)) in threads.iter().enumerate() { + let sid = self.frame_story(*id); + if let Some(sid) = sid { + per_story.entry(sid).or_default().push(i); + } + } + for (sid, idx) in per_story { + let members: HashSet = idx.iter().map(|i| threads[*i].0).collect(); + let next_of: HashMap = + idx.iter().filter_map(|i| by_self.get(&threads[*i].3).filter(|n| members.contains(n)).map(|n| (threads[*i].0, *n))).collect(); + let has_prev: HashSet = next_of.values().copied().collect(); + let mut order = Vec::new(); + let mut seen = HashSet::new(); + for i in &idx { + let head = threads[*i].0; + if has_prev.contains(&head) || seen.contains(&head) { + continue; + } + let mut cur = Some(head); + while let Some(c) = cur { + if !seen.insert(c) { + break; + } + order.push(c); + cur = next_of.get(&c).copied(); + } + } + for i in &idx { + let f = threads[*i].0; + if seen.insert(f) { + order.push(f); + } + } + if let Some(s) = self.stories.get_mut(&sid) { + s.frames = order; + } + } + } + + fn frame_story(&self, id: ItemId) -> Option { + fn find(items: &[Arc], id: ItemId) -> Option { + for it in items { + let mut r = None; + it.walk(&mut |i| { + if i.id == id { + r = i.text_frame().map(|t| t.story); + } + }); + if r.is_some() { + return r; + } + } + None + } + self.spreads.iter().chain(self.parents.iter()).find_map(|s| find(&s.items, id)) + } + + fn finish(mut self, root: &El) -> Result { + let title = root.get("Name").map(|n| n.trim_end_matches(".indd").to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Untitled".into()); + if self.spreads.is_empty() { + return Err(IdmlError::Invalid("document has no spreads".into())); + } + // Make sure built-in paragraph styles exist and are first. + let d = Document { + title, + settings: self.settings.clone(), + spreads: std::mem::take(&mut self.spreads).into_iter().map(Arc::new).collect(), + parents: std::mem::take(&mut self.parents).into_iter().map(Arc::new).collect(), + layers: std::mem::take(&mut self.layers), + stories: std::mem::take(&mut self.stories).into_iter().map(|(k, v)| (k, Arc::new(v))).collect(), + styles: Arc::new(std::mem::take(&mut self.styles)), + swatches: std::mem::take(&mut self.swatches), + sections: std::mem::take(&mut self.sections), + assets: std::mem::take(&mut self.assets), + next_id: self.next_id, + }; + Ok(d) + } +} + +/// Drop-shadow angle (degrees) from IDML offsets (inverse of export's mapping). +fn names_angle(x: f64, y: f64) -> f64 { + if x == 0.0 && y == 0.0 { + return 135.0; + } + let a = y.atan2(-x).to_degrees(); + if a < 0.0 { a + 360.0 } else { a } +} + +struct StoryBuilder { + text: String, + paras: Vec, + runs: Vec, + /// The current paragraph has no content yet (its format can still be set by a range). + fresh: bool, + last: CharFormat, +} + +impl StoryBuilder { + fn push(&mut self, s: &str, f: &CharFormat) { + if s.is_empty() { + return; + } + self.text.push_str(s); + if !s.contains('\n') || s.len() > 1 { + self.fresh = false; + } + match self.runs.last_mut() { + Some(r) if r.format == *f => r.len += s.len(), + _ => self.runs.push(CharRun { len: s.len(), format: f.clone() }), + } + } +} + +fn ace_char(v: &str) -> Option { + match v.trim() { + "18" => Some(st::PAGE_NUMBER), + "19" => Some(st::SECTION_MARKER), + "7" => Some(st::INDENT_HERE), + "8" => Some(st::RIGHT_INDENT_TAB), + _ => None, + } +} + +fn margins_of(m: &El) -> (Margins, Columns) { + let g = |k: &str, d: f64| m.num(k).unwrap_or(d); + ( + Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) }, + Columns { count: g("ColumnCount", 1.0).max(1.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None }, + ) +} + +fn text_frame_options(e: &El) -> TextFrameOptions { + let mut o = TextFrameOptions::default(); + if let Some(v) = e.num("TextColumnCount") { + o.columns = v.max(1.0) as u32; + } + if let Some(v) = e.num("TextColumnGutter") { + o.gutter = v; + } + o.columns_kind = if e.get("UseFixedColumnWidth") == Some("true") { + ColumnsKind::FixedWidth + } else if e.get("UseFlexibleColumnWidth") == Some("true") { + ColumnsKind::FlexibleWidth + } else { + ColumnsKind::FixedNumber + }; + if o.columns_kind != ColumnsKind::FixedNumber + && let Some(v) = e.num("TextColumnFixedWidth") + { + o.column_width = v; + } + o.balance_columns = e.get("VerticalBalanceColumns") == Some("true"); + if let Some(l) = e.prop_el("InsetSpacing") { + let v: Vec = l.find_all("ListItem").filter_map(|i| i.text_content().trim().parse().ok()).collect(); + if v.len() == 4 { + o.inset = [v[0], v[1], v[2], v[3]]; + } + } else if let Some(v) = e.num("InsetSpacing") { + o.inset = [v; 4]; + } + if let Some(v) = e.get("VerticalJustification") { + o.vertical_justification = names::vj_in(v); + } + if let Some(v) = e.num("VerticalThreshold") { + o.vj_paragraph_spacing_limit = v; + } + if let Some(v) = e.get("FirstBaselineOffset") { + o.first_baseline = names::first_baseline_in(v); + } + if let Some(v) = e.num("MinimumFirstBaselineOffset") { + o.first_baseline_min = v; + } + o.ignore_wrap = e.get("IgnoreWrap") == Some("true"); + if let Some(v) = e.get("AutoSizingType") { + o.auto_size = names::auto_size_in(v); + } + if let Some(v) = e.get("AutoSizingReferencePoint") { + o.auto_size_ref = names::REF_POINTS.iter().position(|p| *p == v).unwrap_or(1) as u8; + } + o +} + +fn path_of(pg: &El) -> PathData { + let mut subs = Vec::new(); + for g in pg.find_all("GeometryPathType") { + let closed = g.get("PathOpen") != Some("true"); + let mut anchors = Vec::new(); + if let Some(arr) = g.find("PathPointArray") { + for p in arr.find_all("PathPointType") { + let Some(a) = parse_point(p.get("Anchor")) else { continue }; + let hin = parse_point(p.get("LeftDirection")).unwrap_or(a); + let hout = parse_point(p.get("RightDirection")).unwrap_or(a); + anchors.push(Anchor::with_handles(a, hin, hout)); + } + } + if !anchors.is_empty() { + subs.push(SubPath::new(anchors, closed)); + } + } + PathData::new(subs) +} + +/// IDML link URI → file system path (`file:/a%20b` → `/a b`, `file:///C:/x` → `C:/x`). +pub(crate) fn uri_to_path(uri: &str) -> String { + let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("file:")).unwrap_or(uri); + // `file:///C:/…` → `/C:/…` → `C:/…` + let rest = if rest.len() > 3 && rest.starts_with('/') && rest.as_bytes()[2] == b':' { &rest[1..] } else { rest }; + let mut out = Vec::with_capacity(rest.len()); + let b = rest.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' + && i + 2 < b.len() + && let Ok(v) = u8::from_str_radix(&rest[i + 1..i + 3], 16) + { + out.push(v); + i += 3; + continue; + } + out.push(b[i]); + i += 1; + } + String::from_utf8_lossy(&out).to_string() +} diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs new file mode 100644 index 0000000..ba86d74 --- /dev/null +++ b/crates/idml/src/lib.rs @@ -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 = std::result::Result; + +/// 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 { + 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) +} diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs new file mode 100644 index 0000000..d41a81c --- /dev/null +++ b/crates/idml/src/names.rs @@ -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::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::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::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::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 { + 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 { + 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)) +} diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs new file mode 100644 index 0000000..0640f7f --- /dev/null +++ b/crates/idml/src/tests.rs @@ -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 { + 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 { + 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#" + + + + + + Red + + +
+ LowerRoman +
+ +
"#; + +const GRAPHIC: &str = r#" + + + + + + + + + + +"#; + +const STYLES: &str = r#" + + + + + $ID/[No character style] + + + + + Source Serif 4Auto + + + $ID/[No paragraph style] + + + + ParagraphStyle/$ID/NormalParagraphStyle13 + + + + + + + +"#; + +const PREFS: &str = r#" + + + +"#; + +const MASTER: &str = r#" + + + + + + + + + + + + + +"#; + +const SPREAD: &str = r#" + + + + + + + + + + + + + + 1234 + + + + + + + + + + + + + + + + + + + + + + + + +"#; + +const STORY: &str = r#" + + + + + Hello & + + + bold +
+
+
+ + + Page end + +
+ after +
+
+
"#; + +fn fixture() -> Vec { + 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 = (0..=255u8).cycle().take(1000).collect(); + assert_eq!(base64_decode(&base64_encode(&data)), data); +} diff --git a/crates/idml/src/xml.rs b/crates/idml/src/xml.rs new file mode 100644 index 0000000..cb09249 --- /dev/null +++ b/crates/idml/src/xml.rs @@ -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 ``). + +use quick_xml::events::Event; + +#[derive(Clone, Debug, PartialEq)] +pub enum Node { + El(El), + Text(String), + /// Written as ``; 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, +} + +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) -> 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 { + 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 + '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 `value` child. + pub fn prop(&self, k: &str) -> Option { + 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 { + self.prop(k).and_then(|v| v.trim().parse().ok()) + } + pub fn boolean(&self, k: &str) -> Option { + 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("", "]]]]>")); + out.push_str("]]>"); + } + Node::Pi(t, c) => { + out.push_str(""); + } + } + } + if !inline { + indent(out, depth); + } + 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("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' if attr => out.push_str("""), + '\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 { + let mut s = String::from("\n"); + root.write(&mut s, 0); + s.into_bytes() +} + +/// Serialize with extra processing instructions after the declaration (designmap's ``). +pub fn document_with_pi(root: &El, pi: &str) -> Vec { + let mut s = String::from("\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 { + let mut r = quick_xml::Reader::from_reader(bytes); + r.config_mut().trim_text(false); + let mut stack: Vec = 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 { + 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 & ")); + 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 & "); + assert_eq!(contents[1].children[1], Node::Pi("ACE".into(), "18".into())); + } + + #[test] + fn properties_form() { + let x = br#"14"#; + 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); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e148ae2..9cdbbc6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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})); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index aa702d3..a1f6ae9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 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 { } } +/// 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 { + 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 {