M6.2: IDML import/export
New crate designcraft-idml (L3, zip + quick-xml): export_idml/import_idml for swatches (process/spot CMYK/RGB/Lab, tints, gradients), paragraph/character/ object styles with BasedOn and groups, fonts, preferences, layers, sections, parent spreads, spreads/pages (spine/centre origin mapping), frames, groups, corner options, wrap, transparency, embedded/linked images, formatted stories with special characters, breaks and threading. Engine commands file.exportIdml / file.openIdml, file.open/openBytes dispatch on IDML, CLI --in/--export .idml, File > Export IDML..., open dialogs accept .idml. Verified black-box in InDesign 2026 (exported files open with correct pages, stories, threads, styles, swatches; InDesign-exported IDML imports). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
37f59f1fc8
commit
b070cc343c
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) | |
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
always, file_new),
|
||||
cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample),
|
||||
cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} — .designcraft (JSON)", always, file_open),
|
||||
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64}", always, file_open_bytes),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} — .designcraft or .idml", always, file_open),
|
||||
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package", always, file_open_bytes),
|
||||
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save),
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save),
|
||||
cmd!(query "file.serialize", "Serialize", [], None, "{} → {json}", has_doc, |s, _| Ok(json!({"json": String::from_utf8_lossy(&to_bytes(&s.doc()?.doc)).to_string()}))),
|
||||
|
|
@ -105,6 +105,9 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if path.to_ascii_lowercase().ends_with(".idml") {
|
||||
return super::interchange::open_idml(s, p);
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let d = from_bytes(&bytes)?;
|
||||
let i = s.add_document(DocState::new(d, Some(path.to_string())));
|
||||
|
|
@ -119,6 +122,10 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = base64_decode(str_param(p, "base64").unwrap_or(""));
|
||||
// IDML packages are recognised by their stored `mimetype` first entry.
|
||||
if designcraft_idml::is_idml(&b) {
|
||||
return super::interchange::open_idml(s, p);
|
||||
}
|
||||
let mut d = from_bytes(&b)?;
|
||||
if let Some(n) = str_param(p, "name") {
|
||||
d.title = n.to_string();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
//! Interchange formats: IDML (InDesign Markup Language) import and export.
|
||||
|
||||
use designcraft_doc::Document;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::file::{base64_decode, base64_encode};
|
||||
use super::{CommandSpec, always, bad, cmd, has_doc, str_param};
|
||||
use crate::{DocState, EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "file.exportIdml", "Export IDML…", [], None,
|
||||
"{path?, embedImages?: true} — writes an IDML package to `path`, or returns {base64} without a path",
|
||||
has_doc, export_idml),
|
||||
cmd!(noundo "file.openIdml", "Open IDML", [], None,
|
||||
"{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder)",
|
||||
always, open_idml),
|
||||
]
|
||||
}
|
||||
|
||||
fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let opts = designcraft_idml::ExportOptions { embed_images: p.get("embedImages").and_then(Value::as_bool).unwrap_or(true) };
|
||||
let bytes = designcraft_idml::export_idml_with(&st.doc, &opts);
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup).
|
||||
pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
|
||||
let dir = dir.map(|d| d.to_path_buf());
|
||||
let read = move |link: &str| -> Option<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Ok(b) = std::fs::read(link) {
|
||||
return Some(b);
|
||||
}
|
||||
let name = link.rsplit(['/', '\\']).next()?;
|
||||
let dir = dir.as_ref()?;
|
||||
std::fs::read(dir.join(name)).or_else(|_| std::fs::read(dir.join("Links").join(name))).ok()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (link, &dir);
|
||||
None
|
||||
}
|
||||
};
|
||||
designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, dir, name) = if let Some(b) = str_param(p, "base64") {
|
||||
(base64_decode(b), None, str_param(p, "name").map(|n| n.trim_end_matches(".idml").to_string()))
|
||||
} else if let Some(path) = str_param(p, "path") {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let b: Vec<u8> = Vec::new();
|
||||
let pp = std::path::Path::new(path);
|
||||
(b, pp.parent().map(|d| d.to_path_buf()), pp.file_stem().map(|n| n.to_string_lossy().to_string()))
|
||||
} else {
|
||||
return Err(bad("file.openIdml", "missing `path` or `base64`"));
|
||||
};
|
||||
let mut d = import(&bytes, dir.as_deref())?;
|
||||
if let Some(n) = name {
|
||||
d.title = n;
|
||||
}
|
||||
// Never save over the .idml with the native format: the document starts unsaved.
|
||||
let i = s.add_document(DocState::new(d, None));
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -253,3 +253,5 @@ pub(crate) use push_undo as record_undo;
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_idml;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
//! IDML round-trip tests (the sample magazine) and the interchange commands.
|
||||
|
||||
use designcraft_doc::{Document, ItemId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Every item of a spread list in order, flattened (pre-order).
|
||||
fn items(spreads: &[std::sync::Arc<designcraft_doc::Spread>]) -> Vec<(ItemId, designcraft_geom::Rect)> {
|
||||
let mut v = Vec::new();
|
||||
for sp in spreads {
|
||||
for it in &sp.items {
|
||||
it.walk(&mut |i| v.push((i.id, i.bounds())));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Thread order of every story as positions in the flattened item list.
|
||||
fn positions(d: &Document) -> Vec<Vec<usize>> {
|
||||
let all: Vec<ItemId> = items(&d.spreads).into_iter().chain(items(&d.parents)).map(|(id, _)| id).collect();
|
||||
d.stories.values().map(|s| s.frames.iter().map(|f| all.iter().position(|i| i == f).expect("frame")).collect()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magazine_round_trips_through_idml() {
|
||||
let d = crate::sample::magazine();
|
||||
let bytes = designcraft_idml::export_idml(&d);
|
||||
let back = designcraft_idml::import_idml(&bytes).unwrap();
|
||||
assert_eq!(back.page_count(), d.page_count());
|
||||
assert_eq!(back.spreads.len(), d.spreads.len());
|
||||
assert_eq!(back.parents.len(), d.parents.len());
|
||||
// Stories: same text in the same order, same styles applied.
|
||||
let ta: Vec<&str> = d.stories.values().map(|s| s.text.as_str()).collect();
|
||||
let tb: Vec<&str> = back.stories.values().map(|s| s.text.as_str()).collect();
|
||||
assert_eq!(ta, tb);
|
||||
for (a, b) in d.stories.values().zip(back.stories.values()) {
|
||||
let sa: Vec<&str> = a.paras.iter().map(|p| p.style.as_str()).collect();
|
||||
let sb: Vec<&str> = b.paras.iter().map(|p| p.style.as_str()).collect();
|
||||
assert_eq!(sa, sb);
|
||||
let ca: Vec<(usize, &str)> = a.chars.iter().map(|r| (r.len, r.format.style.as_str())).collect();
|
||||
let cb: Vec<(usize, &str)> = b.chars.iter().map(|r| (r.len, r.format.style.as_str())).collect();
|
||||
assert_eq!(ca, cb);
|
||||
for (ra, rb) in a.chars.iter().zip(&b.chars) {
|
||||
assert_eq!(ra.format.over, rb.format.over);
|
||||
}
|
||||
for (pa, pb) in a.paras.iter().zip(&b.paras) {
|
||||
assert_eq!(pa.para, pb.para);
|
||||
}
|
||||
}
|
||||
// Styles and swatches by name.
|
||||
for s in &d.styles.paragraph {
|
||||
let b = back.styles.para(&s.name).unwrap_or_else(|| panic!("paragraph style {}", s.name));
|
||||
if s.name != designcraft_doc::NO_PARA_STYLE {
|
||||
assert_eq!(b.chars, s.chars, "{}", s.name);
|
||||
assert_eq!(b.para, s.para, "{}", s.name);
|
||||
}
|
||||
}
|
||||
for s in &d.styles.character {
|
||||
let b = back.styles.char_style(&s.name).unwrap_or_else(|| panic!("character style {}", s.name));
|
||||
assert_eq!(b.chars, s.chars);
|
||||
}
|
||||
for s in &d.swatches {
|
||||
let b = back.swatch(&s.name).unwrap_or_else(|| panic!("swatch {}", s.name));
|
||||
assert_eq!(b.value, s.value, "{}", s.name);
|
||||
}
|
||||
// Frame geometry (document and parent spreads), pages.
|
||||
for (sa, sb) in [(&d.spreads, &back.spreads), (&d.parents, &back.parents)] {
|
||||
let (ia, ib) = (items(sa), items(sb));
|
||||
assert_eq!(ia.len(), ib.len());
|
||||
for ((_, a), (_, b)) in ia.iter().zip(&ib) {
|
||||
let close = (a.x0 - b.x0).abs() < 0.01 && (a.y0 - b.y0).abs() < 0.01 && (a.x1 - b.x1).abs() < 0.01 && (a.y1 - b.y1).abs() < 0.01;
|
||||
assert!(close, "{a:?} vs {b:?}");
|
||||
}
|
||||
for (pa, pb) in sa.iter().flat_map(|s| s.pages.iter()).zip(sb.iter().flat_map(|s| s.pages.iter())) {
|
||||
assert!((pa.x - pb.x).abs() < 0.01 && pa.side == pb.side && pa.margins == pb.margins && pa.columns == pb.columns);
|
||||
assert_eq!(pa.parent.is_some(), pb.parent.is_some());
|
||||
}
|
||||
}
|
||||
// Threads: the same frames in the same order.
|
||||
assert_eq!(positions(&d), positions(&back));
|
||||
assert!(back.stories.values().any(|s| s.frames.len() == 2));
|
||||
// Images are embedded.
|
||||
assert_eq!(back.assets.len(), d.assets.len());
|
||||
for (a, b) in d.assets.values().zip(back.assets.values()) {
|
||||
assert_eq!(a.data, b.data);
|
||||
assert_eq!(a.pixels, b.pixels);
|
||||
}
|
||||
// A second round trip is stable.
|
||||
let again = designcraft_idml::import_idml(&designcraft_idml::export_idml(&back)).unwrap();
|
||||
assert_eq!(items(&again.spreads).len(), items(&back.spreads).len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idml_commands() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newSample", &json!({})).unwrap();
|
||||
let r = s.execute("file.exportIdml", &json!({})).unwrap();
|
||||
let b64 = r["base64"].as_str().unwrap().to_string();
|
||||
let r = s.execute("file.openIdml", &json!({"base64": b64, "name": "copy.idml"})).unwrap();
|
||||
let i = r["index"].as_u64().unwrap() as usize;
|
||||
assert_eq!(s.doc().unwrap().doc.title, "copy");
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 4);
|
||||
assert!(s.doc().unwrap().path.is_none());
|
||||
// openBytes recognises the package too.
|
||||
let r = s.execute("file.openBytes", &json!({"base64": b64, "name": "again.idml"})).unwrap();
|
||||
assert_eq!(r["index"].as_u64().unwrap() as usize, i + 1);
|
||||
assert!(s.execute("file.openIdml", &json!({})).is_err());
|
||||
let dir = std::env::temp_dir().join(format!("dc-idml-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("mag.idml").to_string_lossy().to_string();
|
||||
let r = s.execute("file.exportIdml", &json!({"path": p})).unwrap();
|
||||
assert!(r["bytes"].as_u64().unwrap() > 1000);
|
||||
s.execute("file.open", &json!({"path": p})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.title, "mag");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,154 @@
|
|||
//! IDML (InDesign Markup Language) interchange: [`export_idml`] and [`import_idml`].
|
||||
//!
|
||||
//! Implemented clean-room from the public IDML File Format Specification and from inspecting
|
||||
//! IDML packages exported from synthetic documents. An IDML package is a ZIP whose first entry is
|
||||
//! an uncompressed `mimetype`, followed by `designmap.xml` (the root `Document` element that
|
||||
//! includes the other parts) and the `Resources/`, `MasterSpreads/`, `Spreads/` and `Stories/`
|
||||
//! parts.
|
||||
//!
|
||||
//! ## Coordinates
|
||||
//! DesignCraft spreads have their origin at the top-left of the first page (y down). IDML spread
|
||||
//! coordinates have their origin at the binding spine (facing pages) or the spread centre
|
||||
//! (single-sided documents), vertically centred on the pages; pages are placed by their
|
||||
//! `ItemTransform` and every page item's geometry is `PathGeometry` in inner coordinates mapped by
|
||||
//! `ItemTransform` (inner → parent). Export maps `xf_idml = T(-origin) · xf`; import computes the
|
||||
//! page rectangles from the page transforms and maps back.
|
||||
//!
|
||||
//! ## Supported subset
|
||||
//! Swatches (process/spot CMYK, RGB and Lab colours, tints, linear/radial gradients), paragraph,
|
||||
//! character and object styles (with `BasedOn` and style groups), fonts, document preferences
|
||||
//! (page size, facing pages, bleed/slug, margins and columns, units, grids), layers, sections,
|
||||
//! parent (master) spreads and applied parents, spreads and pages, page items (`TextFrame`,
|
||||
//! `Rectangle`, `Oval`, `Polygon`, `GraphicLine`, `Group`) with fill/stroke, corner options, text
|
||||
//! wrap, transparency (opacity, blend mode, drop shadow, feather), images (embedded `Contents` or
|
||||
//! linked), stories with paragraph/character style ranges and local overrides, special
|
||||
//! characters and breaks, and text threading.
|
||||
//!
|
||||
//! ## TODO (ignored on import / not written on export)
|
||||
//! - Tables, footnotes, anchored/inline objects, notes, tracked changes, hyperlinks, index and
|
||||
//! cross-reference markers, text variables, conditions, XML structure (`XML/*` parts).
|
||||
//! - Guides, page-item overrides of parent items (only the page's `OverrideList` is read),
|
||||
//! parent-of-parent page mapping beyond `AppliedMaster`, alternate layouts, liquid layout.
|
||||
//! - Nested/GREP/line styles, bullets & numbering details (bullet glyph, number format), OpenType
|
||||
//! feature flags, paragraph borders, grid alignment, column rules, custom dashed stroke styles.
|
||||
//! - Mixed inks, gradient stop opacity and gradient feathers, effects other than drop shadow and
|
||||
//! basic feather, EPS/PDF/AI placed graphics (imported as images when the data is available),
|
||||
//! clipping paths, compound-path fill rules.
|
||||
//! - Unknown elements are ignored; nothing is preserved opaquely for round-trip yet.
|
||||
|
||||
mod export;
|
||||
mod import;
|
||||
mod names;
|
||||
mod xml;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use export::{ExportOptions, export_idml, export_idml_with};
|
||||
pub use import::{import_idml, import_idml_with};
|
||||
|
||||
/// The IDML package mimetype (content of the first, stored `mimetype` entry).
|
||||
pub const MIMETYPE: &str = "application/vnd.adobe.indesign-idml-package";
|
||||
/// The DOM version written to every part.
|
||||
pub const DOM_VERSION: &str = "16.0";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IdmlError {
|
||||
#[error("not an IDML package: {0}")]
|
||||
NotIdml(String),
|
||||
#[error("IDML part {part}: {msg}")]
|
||||
Part { part: String, msg: String },
|
||||
#[error("invalid IDML document: {0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, IdmlError>;
|
||||
|
||||
/// Does `bytes` look like an IDML package (a ZIP whose first, stored entry is the IDML `mimetype`)?
|
||||
pub fn is_idml(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"PK\x03\x04") && bytes.get(30..38) == Some(b"mimetype") && {
|
||||
let extra = bytes.get(28..30).map_or(0, |e| u16::from_le_bytes([e[0], e[1]]) as usize);
|
||||
bytes.get(38 + extra..).is_some_and(|rest| rest.starts_with(MIMETYPE.as_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/// Base64 with line breaks every 76 characters (as embedded image `Contents` are written).
|
||||
pub(crate) fn base64_encode(data: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(data.len().div_ceil(3) * 4 + data.len() / 57);
|
||||
for (i, c) in data.chunks(3).enumerate() {
|
||||
if i > 0 && i % 19 == 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
|
||||
out.push(B64[(n >> 18) as usize & 63] as char);
|
||||
out.push(B64[(n >> 12) as usize & 63] as char);
|
||||
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
|
||||
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Lenient base64 decoding (whitespace and unknown characters are skipped).
|
||||
pub(crate) fn base64_decode(s: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(s.len() * 3 / 4);
|
||||
let mut buf = 0u32;
|
||||
let mut bits = 0;
|
||||
for b in s.bytes() {
|
||||
let v = match b {
|
||||
b'A'..=b'Z' => b - b'A',
|
||||
b'a'..=b'z' => b - b'a' + 26,
|
||||
b'0'..=b'9' => b - b'0' + 52,
|
||||
b'+' | b'-' => 62,
|
||||
b'/' | b'_' => 63,
|
||||
_ => continue,
|
||||
} as u32;
|
||||
buf = buf << 6 | v;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((buf >> bits) as u8);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Sniff an image's MIME type and pixel size from its header (PNG, JPEG, GIF, WebP, TIFF).
|
||||
pub(crate) fn sniff_image(b: &[u8]) -> (Option<&'static str>, Option<(u32, u32)>) {
|
||||
let be32 = |i: usize| b.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]));
|
||||
if b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return (Some("image/png"), be32(16).zip(be32(20)));
|
||||
}
|
||||
if b.starts_with(&[0xFF, 0xD8]) {
|
||||
let mut i = 2;
|
||||
while i + 9 < b.len() {
|
||||
if b[i] != 0xFF {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let m = b[i + 1];
|
||||
let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize;
|
||||
if matches!(m, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF) {
|
||||
let h = u16::from_be_bytes([b[i + 5], b[i + 6]]) as u32;
|
||||
let w = u16::from_be_bytes([b[i + 7], b[i + 8]]) as u32;
|
||||
return (Some("image/jpeg"), Some((w, h)));
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
return (Some("image/jpeg"), None);
|
||||
}
|
||||
if b.starts_with(b"GIF8") && b.len() >= 10 {
|
||||
return (Some("image/gif"), Some((u16::from_le_bytes([b[6], b[7]]) as u32, u16::from_le_bytes([b[8], b[9]]) as u32)));
|
||||
}
|
||||
if b.starts_with(b"RIFF") && b.get(8..12) == Some(b"WEBP") {
|
||||
return (Some("image/webp"), None);
|
||||
}
|
||||
if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") {
|
||||
return (Some("image/tiff"), None);
|
||||
}
|
||||
if b.starts_with(b"%PDF") {
|
||||
return (Some("application/pdf"), None);
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
|
|
@ -0,0 +1,464 @@
|
|||
//! Name and enumeration mappings between the DesignCraft model and IDML.
|
||||
|
||||
use designcraft_color::BlendMode;
|
||||
use designcraft_doc::{
|
||||
Align, Arrowhead, AutoSize, Cap, Capitalization, FirstBaseline, Join, Leading, NumberStyle, Position, StartParagraph, StrokeAlign, StrokeType,
|
||||
TabAlign, VerticalJustification, WrapMode, WrapSide,
|
||||
};
|
||||
use designcraft_geom::Unit;
|
||||
use designcraft_geom::corners::CornerShape;
|
||||
|
||||
/// `%` → `%25`, `:` → `%3a` (IDML Self-id escaping).
|
||||
pub fn escape_id(s: &str) -> String {
|
||||
s.replace('%', "%25").replace(':', "%3a")
|
||||
}
|
||||
pub fn unescape_id(s: &str) -> String {
|
||||
s.replace("%3a", ":").replace("%3A", ":").replace("%25", "%")
|
||||
}
|
||||
|
||||
/// Built-in style names: (ours, IDML `Name`).
|
||||
pub const PARA_BUILTINS: &[(&str, &str)] = &[("[No Paragraph Style]", "$ID/[No paragraph style]"), ("[Basic Paragraph]", "$ID/NormalParagraphStyle")];
|
||||
pub const CHAR_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[No character style]")];
|
||||
pub const OBJECT_BUILTINS: &[(&str, &str)] =
|
||||
&[("[None]", "$ID/[None]"), ("[Basic Graphics Frame]", "$ID/[Normal Graphics Frame]"), ("[Basic Text Frame]", "$ID/[Normal Text Frame]")];
|
||||
|
||||
/// Our style name → IDML `Name` (groups: `A/B` → `A:B`).
|
||||
pub fn style_name_out(builtins: &[(&str, &str)], name: &str) -> String {
|
||||
builtins.iter().find(|(o, _)| *o == name).map(|(_, i)| i.to_string()).unwrap_or_else(|| name.replace('/', ":"))
|
||||
}
|
||||
/// IDML `Name` → our style name.
|
||||
pub fn style_name_in(builtins: &[(&str, &str)], name: &str) -> String {
|
||||
if let Some((o, _)) = builtins.iter().find(|(_, i)| *i == name) {
|
||||
return o.to_string();
|
||||
}
|
||||
name.strip_prefix("$ID/").unwrap_or(name).replace(':', "/")
|
||||
}
|
||||
/// `ParagraphStyle/…` Self id for one of our names.
|
||||
pub fn style_self(kind: &str, builtins: &[(&str, &str)], name: &str) -> String {
|
||||
format!("{kind}/{}", escape_id(&style_name_out(builtins, name)))
|
||||
}
|
||||
|
||||
pub fn align_out(a: Align) -> &'static str {
|
||||
match a {
|
||||
Align::Left => "LeftAlign",
|
||||
Align::Center => "CenterAlign",
|
||||
Align::Right => "RightAlign",
|
||||
Align::LeftJustified => "LeftJustified",
|
||||
Align::CenterJustified => "CenterJustified",
|
||||
Align::RightJustified => "RightJustified",
|
||||
Align::FullyJustified => "FullyJustified",
|
||||
Align::TowardsSpine => "ToBindingSide",
|
||||
Align::AwayFromSpine => "AwayFromBindingSide",
|
||||
}
|
||||
}
|
||||
pub fn align_in(s: &str) -> Option<Align> {
|
||||
Align::ALL.into_iter().find(|a| align_out(*a) == s)
|
||||
}
|
||||
|
||||
pub fn caps_out(c: Capitalization) -> &'static str {
|
||||
match c {
|
||||
Capitalization::Normal => "Normal",
|
||||
Capitalization::AllCaps => "AllCaps",
|
||||
Capitalization::SmallCaps => "SmallCaps",
|
||||
Capitalization::OpenTypeAllSmallCaps => "CapToSmallCap",
|
||||
}
|
||||
}
|
||||
pub fn caps_in(s: &str) -> Option<Capitalization> {
|
||||
[Capitalization::Normal, Capitalization::AllCaps, Capitalization::SmallCaps, Capitalization::OpenTypeAllSmallCaps]
|
||||
.into_iter()
|
||||
.find(|c| caps_out(*c) == s)
|
||||
}
|
||||
|
||||
pub fn position_out(p: Position) -> &'static str {
|
||||
match p {
|
||||
Position::Normal => "Normal",
|
||||
Position::Superscript => "Superscript",
|
||||
Position::Subscript => "Subscript",
|
||||
Position::OtSuperscript => "OTSuperscript",
|
||||
Position::OtSubscript => "OTSubscript",
|
||||
Position::OtNumerator => "OTNumerator",
|
||||
Position::OtDenominator => "OTDenominator",
|
||||
}
|
||||
}
|
||||
pub fn position_in(s: &str) -> Option<Position> {
|
||||
[
|
||||
Position::Normal,
|
||||
Position::Superscript,
|
||||
Position::Subscript,
|
||||
Position::OtSuperscript,
|
||||
Position::OtSubscript,
|
||||
Position::OtNumerator,
|
||||
Position::OtDenominator,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| position_out(*p) == s)
|
||||
}
|
||||
|
||||
pub fn start_para_out(s: StartParagraph) -> &'static str {
|
||||
match s {
|
||||
StartParagraph::Anywhere => "Anywhere",
|
||||
StartParagraph::NextColumn => "NextColumn",
|
||||
StartParagraph::NextFrame => "NextFrame",
|
||||
StartParagraph::NextPage => "NextPage",
|
||||
StartParagraph::NextOddPage => "NextOddPage",
|
||||
StartParagraph::NextEvenPage => "NextEvenPage",
|
||||
}
|
||||
}
|
||||
pub fn start_para_in(s: &str) -> Option<StartParagraph> {
|
||||
[
|
||||
StartParagraph::Anywhere,
|
||||
StartParagraph::NextColumn,
|
||||
StartParagraph::NextFrame,
|
||||
StartParagraph::NextPage,
|
||||
StartParagraph::NextOddPage,
|
||||
StartParagraph::NextEvenPage,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| start_para_out(*p) == s)
|
||||
}
|
||||
|
||||
pub fn tab_align_out(a: TabAlign) -> &'static str {
|
||||
match a {
|
||||
TabAlign::Left => "LeftAlign",
|
||||
TabAlign::Center => "CenterAlign",
|
||||
TabAlign::Right => "RightAlign",
|
||||
TabAlign::Char => "CharacterAlign",
|
||||
}
|
||||
}
|
||||
pub fn tab_align_in(s: &str) -> TabAlign {
|
||||
match s {
|
||||
"CenterAlign" => TabAlign::Center,
|
||||
"RightAlign" => TabAlign::Right,
|
||||
"CharacterAlign" => TabAlign::Char,
|
||||
_ => TabAlign::Left,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn leading_out(l: Leading) -> (String, &'static str) {
|
||||
match l {
|
||||
Leading::Auto => ("Auto".into(), "enumeration"),
|
||||
Leading::Points(v) => (num(v), "unit"),
|
||||
}
|
||||
}
|
||||
pub fn leading_in(s: &str) -> Option<Leading> {
|
||||
if s == "Auto" { Some(Leading::Auto) } else { s.trim().parse().ok().map(Leading::Points) }
|
||||
}
|
||||
|
||||
pub fn stroke_align_out(a: StrokeAlign) -> &'static str {
|
||||
match a {
|
||||
StrokeAlign::Center => "CenterAlignment",
|
||||
StrokeAlign::Inside => "InsideAlignment",
|
||||
StrokeAlign::Outside => "OutsideAlignment",
|
||||
}
|
||||
}
|
||||
pub fn stroke_align_in(s: &str) -> StrokeAlign {
|
||||
match s {
|
||||
"InsideAlignment" => StrokeAlign::Inside,
|
||||
"OutsideAlignment" => StrokeAlign::Outside,
|
||||
_ => StrokeAlign::Center,
|
||||
}
|
||||
}
|
||||
pub fn cap_out(c: Cap) -> &'static str {
|
||||
match c {
|
||||
Cap::Butt => "ButtEndCap",
|
||||
Cap::Round => "RoundEndCap",
|
||||
Cap::Projecting => "ProjectingEndCap",
|
||||
}
|
||||
}
|
||||
pub fn cap_in(s: &str) -> Cap {
|
||||
match s {
|
||||
"RoundEndCap" => Cap::Round,
|
||||
"ProjectingEndCap" => Cap::Projecting,
|
||||
_ => Cap::Butt,
|
||||
}
|
||||
}
|
||||
pub fn join_out(j: Join) -> &'static str {
|
||||
match j {
|
||||
Join::Miter => "MiterEndJoin",
|
||||
Join::Round => "RoundEndJoin",
|
||||
Join::Bevel => "BevelEndJoin",
|
||||
}
|
||||
}
|
||||
pub fn join_in(s: &str) -> Join {
|
||||
match s {
|
||||
"RoundEndJoin" => Join::Round,
|
||||
"BevelEndJoin" => Join::Bevel,
|
||||
_ => Join::Miter,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stroke style Self ids for our stroke types (built-in InDesign stroke styles).
|
||||
pub fn stroke_type_out(t: &StrokeType) -> &'static str {
|
||||
match t {
|
||||
StrokeType::Solid => "StrokeStyle/$ID/Solid",
|
||||
StrokeType::Dashed { .. } => "StrokeStyle/$ID/Dashed",
|
||||
StrokeType::Dotted => "StrokeStyle/$ID/Canned Dotted",
|
||||
StrokeType::ThickThin => "StrokeStyle/$ID/ThickThin",
|
||||
StrokeType::ThinThick => "StrokeStyle/$ID/ThinThick",
|
||||
StrokeType::ThinThin => "StrokeStyle/$ID/ThinThin",
|
||||
StrokeType::ThickThick => "StrokeStyle/$ID/ThickThick",
|
||||
StrokeType::ThinThickThin => "StrokeStyle/$ID/ThinThickThin",
|
||||
StrokeType::ThickThinThick => "StrokeStyle/$ID/ThickThinThick",
|
||||
StrokeType::Wavy => "StrokeStyle/$ID/Wavy",
|
||||
StrokeType::Hashed => "StrokeStyle/$ID/Straight Hash",
|
||||
}
|
||||
}
|
||||
pub fn stroke_type_in(s: &str) -> StrokeType {
|
||||
let n = s.rsplit('/').next().unwrap_or(s).replace([' ', '-'], "").to_ascii_lowercase();
|
||||
match n.as_str() {
|
||||
"dashed" | "dashed(3and2)" | "dashed(4and4)" | "canneddashed3x2" | "canneddashed4x4" => StrokeType::Dashed { pattern: vec![] },
|
||||
"dotted" | "japanesedots" | "canneddotted" => StrokeType::Dotted,
|
||||
"thickthin" => StrokeType::ThickThin,
|
||||
"thinthick" => StrokeType::ThinThick,
|
||||
"thinthin" => StrokeType::ThinThin,
|
||||
"thickthick" => StrokeType::ThickThick,
|
||||
"thinthickthin" => StrokeType::ThinThickThin,
|
||||
"thickthinthick" => StrokeType::ThickThinThick,
|
||||
"wavy" => StrokeType::Wavy,
|
||||
"straighthash" | "leftslanthash" | "rightslanthash" => StrokeType::Hashed,
|
||||
_ => StrokeType::Solid,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn arrow_out(a: Arrowhead) -> &'static str {
|
||||
match a {
|
||||
Arrowhead::None => "None",
|
||||
Arrowhead::SimpleWide => "SimpleWideArrowHead",
|
||||
Arrowhead::Simple => "SimpleArrowHead",
|
||||
Arrowhead::Triangle => "TriangleArrowHead",
|
||||
Arrowhead::TriangleWide => "TriangleWideArrowHead",
|
||||
Arrowhead::Barbed => "BarbedArrowHead",
|
||||
Arrowhead::Curved => "CurvedArrowHead",
|
||||
Arrowhead::Circle => "CircleArrowHead",
|
||||
Arrowhead::CircleSolid => "CircleSolidArrowHead",
|
||||
Arrowhead::Square => "SquareArrowHead",
|
||||
Arrowhead::SquareSolid => "SquareSolidArrowHead",
|
||||
Arrowhead::Bar => "BarArrowHead",
|
||||
}
|
||||
}
|
||||
pub fn arrow_in(s: &str) -> Arrowhead {
|
||||
[
|
||||
Arrowhead::SimpleWide,
|
||||
Arrowhead::Simple,
|
||||
Arrowhead::Triangle,
|
||||
Arrowhead::TriangleWide,
|
||||
Arrowhead::Barbed,
|
||||
Arrowhead::Curved,
|
||||
Arrowhead::Circle,
|
||||
Arrowhead::CircleSolid,
|
||||
Arrowhead::Square,
|
||||
Arrowhead::SquareSolid,
|
||||
Arrowhead::Bar,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|a| arrow_out(*a) == s)
|
||||
.unwrap_or(Arrowhead::None)
|
||||
}
|
||||
|
||||
pub fn corner_out(c: CornerShape) -> &'static str {
|
||||
match c {
|
||||
CornerShape::None => "None",
|
||||
CornerShape::Rounded => "RoundedCorner",
|
||||
CornerShape::InverseRounded => "InverseRoundedCorner",
|
||||
CornerShape::Inset => "InsetCorner",
|
||||
CornerShape::Bevel => "BevelCorner",
|
||||
CornerShape::Fancy => "FancyCorner",
|
||||
}
|
||||
}
|
||||
pub fn corner_in(s: &str) -> CornerShape {
|
||||
CornerShape::ALL.into_iter().find(|c| corner_out(*c) == s).unwrap_or(CornerShape::None)
|
||||
}
|
||||
|
||||
pub fn blend_out(b: BlendMode) -> &'static str {
|
||||
match b {
|
||||
BlendMode::Normal => "Normal",
|
||||
BlendMode::Darken => "Darken",
|
||||
BlendMode::Multiply => "Multiply",
|
||||
BlendMode::ColorBurn => "ColorBurn",
|
||||
BlendMode::Lighten => "Lighten",
|
||||
BlendMode::Screen => "Screen",
|
||||
BlendMode::ColorDodge => "ColorDodge",
|
||||
BlendMode::Overlay => "Overlay",
|
||||
BlendMode::SoftLight => "SoftLight",
|
||||
BlendMode::HardLight => "HardLight",
|
||||
BlendMode::Difference => "Difference",
|
||||
BlendMode::Exclusion => "Exclusion",
|
||||
BlendMode::Hue => "Hue",
|
||||
BlendMode::Saturation => "Saturation",
|
||||
BlendMode::Color => "Color",
|
||||
BlendMode::Luminosity => "Luminosity",
|
||||
}
|
||||
}
|
||||
pub fn blend_in(s: &str) -> BlendMode {
|
||||
BlendMode::ALL.into_iter().find(|b| blend_out(*b) == s).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn wrap_mode_out(m: WrapMode) -> &'static str {
|
||||
match m {
|
||||
WrapMode::None => "None",
|
||||
WrapMode::BoundingBox => "BoundingBoxTextWrap",
|
||||
WrapMode::Contour => "Contour",
|
||||
WrapMode::JumpObject => "JumpObjectTextWrap",
|
||||
WrapMode::JumpToNextColumn => "NextColumnTextWrap",
|
||||
}
|
||||
}
|
||||
pub fn wrap_mode_in(s: &str) -> WrapMode {
|
||||
[WrapMode::BoundingBox, WrapMode::Contour, WrapMode::JumpObject, WrapMode::JumpToNextColumn]
|
||||
.into_iter()
|
||||
.find(|m| wrap_mode_out(*m) == s)
|
||||
.unwrap_or(WrapMode::None)
|
||||
}
|
||||
pub fn wrap_side_out(s: WrapSide) -> &'static str {
|
||||
match s {
|
||||
WrapSide::BothSides => "BothSides",
|
||||
WrapSide::RightSide => "RightSide",
|
||||
WrapSide::LeftSide => "LeftSide",
|
||||
WrapSide::TowardsSpine => "SideTowardsSpine",
|
||||
WrapSide::AwayFromSpine => "SideAwayFromSpine",
|
||||
WrapSide::LargestArea => "LargestArea",
|
||||
}
|
||||
}
|
||||
pub fn wrap_side_in(s: &str) -> WrapSide {
|
||||
[WrapSide::RightSide, WrapSide::LeftSide, WrapSide::TowardsSpine, WrapSide::AwayFromSpine, WrapSide::LargestArea]
|
||||
.into_iter()
|
||||
.find(|m| wrap_side_out(*m) == s)
|
||||
.unwrap_or(WrapSide::BothSides)
|
||||
}
|
||||
|
||||
pub fn vj_out(v: VerticalJustification) -> &'static str {
|
||||
match v {
|
||||
VerticalJustification::Top => "TopAlign",
|
||||
VerticalJustification::Center => "CenterAlign",
|
||||
VerticalJustification::Bottom => "BottomAlign",
|
||||
VerticalJustification::Justify => "JustifyAlign",
|
||||
}
|
||||
}
|
||||
pub fn vj_in(s: &str) -> VerticalJustification {
|
||||
match s {
|
||||
"CenterAlign" => VerticalJustification::Center,
|
||||
"BottomAlign" => VerticalJustification::Bottom,
|
||||
"JustifyAlign" => VerticalJustification::Justify,
|
||||
_ => VerticalJustification::Top,
|
||||
}
|
||||
}
|
||||
pub fn first_baseline_out(f: FirstBaseline) -> &'static str {
|
||||
match f {
|
||||
FirstBaseline::Ascent => "AscentOffset",
|
||||
FirstBaseline::CapHeight => "CapHeight",
|
||||
FirstBaseline::Leading => "LeadingOffset",
|
||||
FirstBaseline::XHeight => "XHeight",
|
||||
FirstBaseline::Fixed => "FixedHeight",
|
||||
}
|
||||
}
|
||||
pub fn first_baseline_in(s: &str) -> FirstBaseline {
|
||||
[FirstBaseline::CapHeight, FirstBaseline::Leading, FirstBaseline::XHeight, FirstBaseline::Fixed]
|
||||
.into_iter()
|
||||
.find(|m| first_baseline_out(*m) == s)
|
||||
.unwrap_or(FirstBaseline::Ascent)
|
||||
}
|
||||
pub fn auto_size_out(a: AutoSize) -> &'static str {
|
||||
match a {
|
||||
AutoSize::Off => "Off",
|
||||
AutoSize::HeightOnly => "HeightOnly",
|
||||
AutoSize::WidthOnly => "WidthOnly",
|
||||
AutoSize::HeightAndWidth => "HeightAndWidth",
|
||||
AutoSize::HeightAndWidthProportionally => "HeightAndWidthProportionally",
|
||||
}
|
||||
}
|
||||
pub fn auto_size_in(s: &str) -> AutoSize {
|
||||
[AutoSize::HeightOnly, AutoSize::WidthOnly, AutoSize::HeightAndWidth, AutoSize::HeightAndWidthProportionally]
|
||||
.into_iter()
|
||||
.find(|m| auto_size_out(*m) == s)
|
||||
.unwrap_or(AutoSize::Off)
|
||||
}
|
||||
/// Auto-size reference points in our 0..9 order (row-major, top-left first).
|
||||
pub const REF_POINTS: [&str; 9] = [
|
||||
"TopLeftPoint",
|
||||
"TopCenterPoint",
|
||||
"TopRightPoint",
|
||||
"LeftCenterPoint",
|
||||
"CenterPoint",
|
||||
"RightCenterPoint",
|
||||
"BottomLeftPoint",
|
||||
"BottomCenterPoint",
|
||||
"BottomRightPoint",
|
||||
];
|
||||
|
||||
pub fn number_style_out(n: NumberStyle) -> &'static str {
|
||||
match n {
|
||||
NumberStyle::Arabic | NumberStyle::ArabicLeadingZero => "Arabic",
|
||||
NumberStyle::UpperRoman => "UpperRoman",
|
||||
NumberStyle::LowerRoman => "LowerRoman",
|
||||
NumberStyle::UpperLetters => "UpperLetters",
|
||||
NumberStyle::LowerLetters => "LowerLetters",
|
||||
}
|
||||
}
|
||||
pub fn number_style_in(s: &str) -> NumberStyle {
|
||||
match s {
|
||||
"UpperRoman" => NumberStyle::UpperRoman,
|
||||
"LowerRoman" => NumberStyle::LowerRoman,
|
||||
"UpperLetters" => NumberStyle::UpperLetters,
|
||||
"LowerLetters" => NumberStyle::LowerLetters,
|
||||
_ => NumberStyle::Arabic,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unit_out(u: Unit) -> &'static str {
|
||||
match u {
|
||||
Unit::Points => "Points",
|
||||
Unit::Picas => "Picas",
|
||||
Unit::Inches => "Inches",
|
||||
Unit::InchesDecimal => "InchesDecimal",
|
||||
Unit::Millimeters => "Millimeters",
|
||||
Unit::Centimeters => "Centimeters",
|
||||
Unit::Ciceros => "Ciceros",
|
||||
Unit::Agates => "Agates",
|
||||
Unit::Pixels => "Pixels",
|
||||
Unit::Q => "U",
|
||||
Unit::Ha => "Ha",
|
||||
}
|
||||
}
|
||||
pub fn unit_in(s: &str) -> Unit {
|
||||
[
|
||||
Unit::Points,
|
||||
Unit::Picas,
|
||||
Unit::Inches,
|
||||
Unit::InchesDecimal,
|
||||
Unit::Millimeters,
|
||||
Unit::Centimeters,
|
||||
Unit::Ciceros,
|
||||
Unit::Agates,
|
||||
Unit::Pixels,
|
||||
Unit::Q,
|
||||
Unit::Ha,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|u| unit_out(*u) == s)
|
||||
.unwrap_or(Unit::Points)
|
||||
}
|
||||
|
||||
/// IDML layer colour enumeration names for our layer colour table.
|
||||
pub fn layer_color_out(c: [u8; 3]) -> Option<String> {
|
||||
designcraft_doc::LAYER_COLORS.iter().find(|(_, v)| *v == c).map(|(n, _)| n.replace(' ', ""))
|
||||
}
|
||||
pub fn layer_color_in(s: &str) -> Option<[u8; 3]> {
|
||||
designcraft_doc::LAYER_COLORS.iter().find(|(n, _)| n.replace(' ', "") == s).map(|(_, v)| *v)
|
||||
}
|
||||
|
||||
/// Format a number compactly (no trailing zeros, no `-0`).
|
||||
pub fn num(v: f64) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
}
|
||||
let r = (v * 1e9).round() / 1e9;
|
||||
if r == r.trunc() && r.abs() < 1e15 {
|
||||
let i = r as i64;
|
||||
return i.to_string();
|
||||
}
|
||||
let s = format!("{r}");
|
||||
if s == "-0" { "0".into() } else { s }
|
||||
}
|
||||
|
||||
pub fn pt(x: f64, y: f64) -> String {
|
||||
format!("{} {}", num(x), num(y))
|
||||
}
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
use std::io::Write;
|
||||
|
||||
use designcraft_color::{Color, Swatch, SwatchValue};
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use designcraft_doc::{CharAttrs, Document, Fill, ParaFormat, Shape, SpreadRef, story as st};
|
||||
use designcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn zip_files(files: &[(&str, &str)]) -> Vec<u8> {
|
||||
use zip::write::SimpleFileOptions;
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
w.start_file("mimetype", stored).unwrap();
|
||||
w.write_all(MIMETYPE.as_bytes()).unwrap();
|
||||
for (n, c) in files {
|
||||
w.start_file(*n, SimpleFileOptions::default()).unwrap();
|
||||
w.write_all(c.as_bytes()).unwrap();
|
||||
}
|
||||
w.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn zip_files_plain() -> Vec<u8> {
|
||||
use zip::write::SimpleFileOptions;
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
w.start_file("document.json", SimpleFileOptions::default()).unwrap();
|
||||
w.write_all(b"{}").unwrap();
|
||||
w.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// A hand-written, minimal IDML document: one facing-pages spread with a right page, a
|
||||
/// threaded story across two frames, a tint and a grouped paragraph style.
|
||||
const DESIGNMAP: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<?aid style="50" type="document" readerVersion="6.0" featureSet="257" product="16.0(1)" ?>
|
||||
<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0" Self="d" StoryList="s1" Name="Fixture.indd">
|
||||
<idPkg:Graphic src="Resources/Graphic.xml"/>
|
||||
<idPkg:Styles src="Resources/Styles.xml"/>
|
||||
<idPkg:Preferences src="Resources/Preferences.xml"/>
|
||||
<Layer Self="L1" Name="Art" Visible="true" Locked="false"><Properties><LayerColor type="enumeration">Red</LayerColor></Properties></Layer>
|
||||
<idPkg:MasterSpread src="MasterSpreads/MasterSpread_m1.xml"/>
|
||||
<idPkg:Spread src="Spreads/Spread_sp1.xml"/>
|
||||
<Section Self="sec" Length="2" ContinueNumbering="false" PageNumberStart="5" PageStart="p1" SectionPrefix="" Marker="">
|
||||
<Properties><PageNumberStyle type="enumeration">LowerRoman</PageNumberStyle></Properties>
|
||||
</Section>
|
||||
<idPkg:Story src="Stories/Story_s1.xml"/>
|
||||
</Document>"#;
|
||||
|
||||
const GRAPHIC: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idPkg:Graphic xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
|
||||
<Color Self="Color/Black" Model="Process" Space="CMYK" ColorValue="0 0 0 100" Name="Black"/>
|
||||
<Color Self="Color/Brand" Model="Spot" Space="RGB" ColorValue="255 0 0" Name="Brand" Visible="true"/>
|
||||
<Color Self="Color/u9" Model="Process" Space="CMYK" ColorValue="10 20 30 40" Name="$ID/" Visible="false"/>
|
||||
<Tint Self="Tint/Brand 50%25" BaseColor="Color/Brand" TintValue="50" Name="Brand 50%"/>
|
||||
<Swatch Self="Swatch/None" Name="None"/>
|
||||
<Gradient Self="Gradient/Fade" Type="Radial" Name="Fade">
|
||||
<GradientStop Self="g0" StopColor="Color/Brand" Location="0"/>
|
||||
<GradientStop Self="g1" StopColor="Color/u9" Location="100" Midpoint="30"/>
|
||||
</Gradient>
|
||||
</idPkg:Graphic>"#;
|
||||
|
||||
const STYLES: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
|
||||
<RootCharacterStyleGroup Self="rc">
|
||||
<CharacterStyle Self="CharacterStyle/$ID/[No character style]" Name="$ID/[No character style]"/>
|
||||
<CharacterStyle Self="CharacterStyle/Strong" Name="Strong" FontStyle="Bold">
|
||||
<Properties><BasedOn type="string">$ID/[No character style]</BasedOn></Properties>
|
||||
</CharacterStyle>
|
||||
</RootCharacterStyleGroup>
|
||||
<RootParagraphStyleGroup Self="rp">
|
||||
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12">
|
||||
<Properties><AppliedFont type="string">Source Serif 4</AppliedFont><Leading type="enumeration">Auto</Leading></Properties>
|
||||
</ParagraphStyle>
|
||||
<ParagraphStyle Self="ParagraphStyle/$ID/NormalParagraphStyle" Name="$ID/NormalParagraphStyle">
|
||||
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
|
||||
</ParagraphStyle>
|
||||
<ParagraphStyleGroup Self="ParagraphStyleGroup/Text" Name="Text">
|
||||
<ParagraphStyle Self="ParagraphStyle/Text%3aBody" Name="Text:Body" PointSize="10" Justification="LeftJustified" SpaceAfter="4">
|
||||
<Properties><BasedOn type="object">ParagraphStyle/$ID/NormalParagraphStyle</BasedOn><Leading type="unit">13</Leading></Properties>
|
||||
</ParagraphStyle>
|
||||
</ParagraphStyleGroup>
|
||||
</RootParagraphStyleGroup>
|
||||
<RootObjectStyleGroup Self="ro">
|
||||
<ObjectStyle Self="ObjectStyle/$ID/[None]" Name="$ID/[None]"/>
|
||||
<ObjectStyle Self="ObjectStyle/$ID/[Normal Graphics Frame]" Name="$ID/[Normal Graphics Frame]" EnableStroke="true" StrokeColor="Color/Black" StrokeWeight="1"/>
|
||||
</RootObjectStyleGroup>
|
||||
</idPkg:Styles>"#;
|
||||
|
||||
const PREFS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idPkg:Preferences xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
|
||||
<DocumentPreference PageWidth="500" PageHeight="700" FacingPages="true" DocumentBleedTopOffset="9"/>
|
||||
<MarginPreference ColumnCount="2" ColumnGutter="10" Top="20" Bottom="30" Left="40" Right="50"/>
|
||||
</idPkg:Preferences>"#;
|
||||
|
||||
const MASTER: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idPkg:MasterSpread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
|
||||
<MasterSpread Self="m1" Name="A-Parent" NamePrefix="A" BaseName="Parent" PageCount="2">
|
||||
<Page Self="mp1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350"/>
|
||||
<Page Self="mp2" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 -350"/>
|
||||
<Rectangle Self="mr" ItemLayer="L1" ItemTransform="1 0 0 1 0 0" FillColor="Color/u9">
|
||||
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
|
||||
<PathPointType Anchor="-480 -330" LeftDirection="-480 -330" RightDirection="-480 -330"/>
|
||||
<PathPointType Anchor="-480 -300" LeftDirection="-480 -300" RightDirection="-480 -300"/>
|
||||
<PathPointType Anchor="-400 -300" LeftDirection="-400 -300" RightDirection="-400 -300"/>
|
||||
<PathPointType Anchor="-400 -330" LeftDirection="-400 -330" RightDirection="-400 -330"/>
|
||||
</PathPointArray></GeometryPathType></PathGeometry></Properties>
|
||||
</Rectangle>
|
||||
</MasterSpread>
|
||||
</idPkg:MasterSpread>"#;
|
||||
|
||||
const SPREAD: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
|
||||
<Spread Self="sp1" PageCount="2" ItemTransform="1 0 0 1 0 0">
|
||||
<Page Self="p1" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350">
|
||||
<MarginPreference ColumnCount="3" ColumnGutter="12" Top="10" Bottom="10" Left="20" Right="30"/>
|
||||
</Page>
|
||||
<Page Self="p2" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 -350"/>
|
||||
<TextFrame Self="t1" ParentStory="s1" PreviousTextFrame="n" NextTextFrame="t2" ItemLayer="L1" ItemTransform="1 0 0 1 -450 -300" FillColor="Tint/Brand 50%25">
|
||||
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
|
||||
<PathPointType Anchor="0 0" LeftDirection="0 0" RightDirection="0 0"/>
|
||||
<PathPointType Anchor="0 200" LeftDirection="0 200" RightDirection="0 200"/>
|
||||
<PathPointType Anchor="300 200" LeftDirection="300 200" RightDirection="300 200"/>
|
||||
<PathPointType Anchor="300 0" LeftDirection="300 0" RightDirection="300 0"/>
|
||||
</PathPointArray></GeometryPathType></PathGeometry></Properties>
|
||||
<TextFramePreference TextColumnCount="2" TextColumnGutter="8"><Properties><InsetSpacing type="list"><ListItem type="unit">1</ListItem><ListItem type="unit">2</ListItem><ListItem type="unit">3</ListItem><ListItem type="unit">4</ListItem></InsetSpacing></Properties></TextFramePreference>
|
||||
<UnknownThing Foo="bar"><Nested/></UnknownThing>
|
||||
</TextFrame>
|
||||
<TextFrame Self="t2" ParentStory="s1" PreviousTextFrame="t1" NextTextFrame="n" ItemLayer="L1" ItemTransform="1 0 0 1 50 -300">
|
||||
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
|
||||
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 100"/><PathPointType Anchor="100 100"/><PathPointType Anchor="100 0"/>
|
||||
</PathPointArray></GeometryPathType></PathGeometry></Properties>
|
||||
</TextFrame>
|
||||
<Group Self="g" ItemTransform="1 0 0 1 10 20">
|
||||
<Oval Self="o" ItemTransform="1 0 0 1 0 0" FillColor="Gradient/Fade" StrokeWeight="2" StrokeColor="Color/Brand">
|
||||
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
|
||||
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 50"/><PathPointType Anchor="50 50"/><PathPointType Anchor="50 0"/>
|
||||
</PathPointArray></GeometryPathType></PathGeometry></Properties>
|
||||
</Oval>
|
||||
</Group>
|
||||
<Rectangle Self="img" ItemLayer="L1" ItemTransform="1 0 0 1 100 100" ContentType="GraphicType" AppliedObjectStyle="ObjectStyle/$ID/[Normal Graphics Frame]">
|
||||
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
|
||||
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 30"/><PathPointType Anchor="40 30"/><PathPointType Anchor="40 0"/>
|
||||
</PathPointArray></GeometryPathType></PathGeometry></Properties>
|
||||
<Image Self="im" ItemTransform="1 0 0 1 0 0">
|
||||
<Properties><GraphicBounds Left="0" Top="0" Right="40" Bottom="30"/></Properties>
|
||||
<Link Self="lk" LinkResourceURI="file:/definitely/missing%20dir/photo.jpg" LinkResourceFormat="$ID/JPEG" StoredState="Normal"/>
|
||||
</Image>
|
||||
</Rectangle>
|
||||
</Spread>
|
||||
</idPkg:Spread>"#;
|
||||
|
||||
const STORY: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
|
||||
<Story Self="s1">
|
||||
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Text%3aBody">
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]">
|
||||
<Content>Hello & </Content>
|
||||
</CharacterStyleRange>
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/Strong" PointSize="14">
|
||||
<Content>bold</Content>
|
||||
<Br/>
|
||||
</CharacterStyleRange>
|
||||
</ParagraphStyleRange>
|
||||
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle" Justification="CenterAlign">
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]">
|
||||
<Content>Page <?ACE 18?> end</Content>
|
||||
</CharacterStyleRange>
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" ParagraphBreakType="NextFrame"><Br/></CharacterStyleRange>
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>after</Content></CharacterStyleRange>
|
||||
</ParagraphStyleRange>
|
||||
</Story>
|
||||
</idPkg:Story>"#;
|
||||
|
||||
fn fixture() -> Vec<u8> {
|
||||
zip_files(&[
|
||||
("designmap.xml", DESIGNMAP),
|
||||
("Resources/Graphic.xml", GRAPHIC),
|
||||
("Resources/Styles.xml", STYLES),
|
||||
("Resources/Preferences.xml", PREFS),
|
||||
("MasterSpreads/MasterSpread_m1.xml", MASTER),
|
||||
("Spreads/Spread_sp1.xml", SPREAD),
|
||||
("Stories/Story_s1.xml", STORY),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_hand_written_fixture() {
|
||||
let d = import_idml_with(&fixture(), &|_| None).unwrap();
|
||||
assert_eq!(d.title, "Fixture");
|
||||
assert!(d.settings.facing_pages);
|
||||
assert_eq!((d.settings.page_width, d.settings.page_height), (500.0, 700.0));
|
||||
assert_eq!(d.settings.bleed[0], 9.0);
|
||||
assert_eq!(d.page_count(), 2);
|
||||
let sp = &d.spreads[0];
|
||||
assert_eq!(sp.pages[0].side, designcraft_doc::PageSide::Left);
|
||||
assert_eq!(sp.pages[1].side, designcraft_doc::PageSide::Right);
|
||||
assert_eq!(sp.pages[1].x, 500.0);
|
||||
assert_eq!(sp.pages[0].margins.inside, 20.0);
|
||||
assert_eq!(sp.pages[0].columns.count, 3);
|
||||
assert_eq!(sp.pages[1].columns.count, 2, "falls back to the default margin preference");
|
||||
assert_eq!(sp.pages[0].parent, Some(d.parents[0].id));
|
||||
let info = d.parents[0].parent.as_ref().unwrap();
|
||||
assert_eq!((info.prefix.as_str(), info.name.as_str()), ("A", "Parent"));
|
||||
// Sections.
|
||||
assert_eq!(d.page_name(0), "v");
|
||||
assert_eq!(d.page_name(1), "vi");
|
||||
// Layers.
|
||||
assert_eq!(d.layers[0].name, "Art");
|
||||
assert_eq!(d.layers[0].color, [255, 0, 0]);
|
||||
// Swatches.
|
||||
assert!(matches!(d.swatch("Brand").unwrap().value, SwatchValue::Color { color_type: designcraft_color::ColorType::Spot, .. }));
|
||||
assert!(matches!(&d.swatch("Brand 50%").unwrap().value, SwatchValue::Tint { base, tint } if base == "Brand" && *tint == 0.5));
|
||||
let SwatchValue::Gradient { gradient } = &d.swatch("Fade").unwrap().value else { panic!("gradient") };
|
||||
assert_eq!(gradient.kind, designcraft_color::GradientKind::Radial);
|
||||
assert_eq!(gradient.stops[1].color, Color::cmyk(0.1, 0.2, 0.3, 0.4));
|
||||
assert!((gradient.stops[0].midpoint - 0.3).abs() < 1e-6);
|
||||
// The parent rectangle used an unnamed colour → value-named swatch.
|
||||
let mr = &d.parents[0].items[0];
|
||||
assert_eq!(mr.fill.swatch, "C=10 M=20 Y=30 K=40");
|
||||
assert_eq!(mr.bounds(), Rect::new(20.0, 20.0, 100.0, 50.0));
|
||||
// Styles.
|
||||
let body = d.styles.para("Text/Body").expect("grouped style");
|
||||
assert_eq!(body.based_on.as_deref(), Some(st::BASIC_PARAGRAPH));
|
||||
assert_eq!(body.chars.size, Some(10.0));
|
||||
assert_eq!(body.chars.leading, Some(designcraft_doc::Leading::Points(13.0)));
|
||||
assert_eq!(body.para.align, Some(designcraft_doc::Align::LeftJustified));
|
||||
assert_eq!(d.styles.char_style("Strong").unwrap().chars.font_style.as_deref(), Some("Bold"));
|
||||
// Frames, threads, text.
|
||||
let story = d.stories.values().next().unwrap();
|
||||
assert_eq!(story.text, format!("Hello & bold\nPage {}\tend{}after", st::PAGE_NUMBER, st::FRAME_BREAK));
|
||||
assert_eq!(story.paras.len(), 2);
|
||||
assert_eq!(story.paras[0].style, "Text/Body");
|
||||
assert_eq!(story.paras[1].style, st::BASIC_PARAGRAPH);
|
||||
assert_eq!(story.paras[1].para.align, Some(designcraft_doc::Align::Center));
|
||||
let bold = story.runs().find(|(r, _)| story.text[r.clone()].starts_with("bold")).unwrap().1;
|
||||
assert_eq!(bold.style, "Strong");
|
||||
assert_eq!(bold.over.size, Some(14.0));
|
||||
assert_eq!(story.frames.len(), 2);
|
||||
let f1 = d.item(story.frames[0]).unwrap();
|
||||
assert_eq!(f1.bounds(), Rect::new(50.0, 50.0, 350.0, 250.0));
|
||||
assert_eq!(f1.fill.swatch, "Brand 50%");
|
||||
let o = &f1.text_frame().unwrap().options;
|
||||
assert_eq!((o.columns, o.gutter, o.inset), (2, 8.0, [1.0, 2.0, 3.0, 4.0]));
|
||||
assert_eq!(d.item(story.frames[1]).unwrap().bounds(), Rect::new(550.0, 50.0, 650.0, 150.0));
|
||||
// Group child keeps its own transform relative to the group.
|
||||
let g = sp.items.iter().find(|i| i.shape == Shape::Group).unwrap();
|
||||
assert_eq!(g.bounds(), Rect::new(510.0, 370.0, 560.0, 420.0));
|
||||
let oval = &g.children()[0];
|
||||
assert_eq!(oval.fill.swatch, "Fade");
|
||||
assert_eq!((oval.stroke.swatch.as_str(), oval.stroke.weight), ("Brand", 2.0));
|
||||
// Linked image with a missing file: link recorded, no data; stroke from the object style.
|
||||
let img = sp.items.iter().find(|i| i.graphic().is_some()).unwrap();
|
||||
assert_eq!(img.stroke.swatch, "[Black]");
|
||||
let a = &d.assets[&img.graphic().unwrap().asset];
|
||||
assert_eq!(a.link.as_deref(), Some("/definitely/missing dir/photo.jpg"));
|
||||
assert!(a.data.is_empty());
|
||||
assert_eq!(a.mime, "image/jpeg");
|
||||
assert_eq!(a.name, "photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_idml() {
|
||||
assert!(import_idml(b"not a zip").is_err());
|
||||
let z = zip_files(&[("hello.txt", "x")]);
|
||||
assert!(matches!(import_idml(&z), Err(IdmlError::NotIdml(_))));
|
||||
}
|
||||
|
||||
fn small_doc() -> Document {
|
||||
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
|
||||
d.swatches.push(Swatch::color("Brand", Color::rgb(1.0, 0.0, 0.0)));
|
||||
d.swatches.push(Swatch { name: "Brand 30".into(), value: SwatchValue::Tint { base: "Brand".into(), tint: 0.3 }, locked: false, named: true });
|
||||
let lid = d.default_layer();
|
||||
let (f1, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 300.0, 200.0), lid, "One\ttwo\nThree", ParaFormat::default()).unwrap();
|
||||
let (f2, _) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(700.0, 40.0, 900.0, 200.0), lid, "", ParaFormat::default()).unwrap();
|
||||
d.thread(f1, f2).unwrap();
|
||||
if let Some(s) = d.story_mut(sid) {
|
||||
s.format_chars(0..3, |f| f.over = CharAttrs { size: Some(20.0), fill: Some("Brand".into()), ..Default::default() });
|
||||
let end = s.len();
|
||||
s.insert(end, &format!(" {}", st::COLUMN_BREAK));
|
||||
}
|
||||
let id = designcraft_doc::ItemId(d.alloc());
|
||||
let mut it = designcraft_doc::Item::new(id, lid, Shape::Oval, shapes::ellipse(Rect::new(650.0, 300.0, 750.0, 380.0)));
|
||||
it.fill = Fill::swatch("Brand 30");
|
||||
it.xf = designcraft_geom::Affine::rotate_about(0.3, designcraft_geom::Point::new(700.0, 340.0));
|
||||
it.opacity = 0.5;
|
||||
d.insert_item(SpreadRef::Doc(1), it, None).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_small_document() {
|
||||
let d = small_doc();
|
||||
let bytes = export_idml(&d);
|
||||
// The mimetype is the first, stored entry.
|
||||
assert_eq!(&bytes[30..38], b"mimetype");
|
||||
assert_eq!(&bytes[38..38 + MIMETYPE.len()], MIMETYPE.as_bytes());
|
||||
assert!(is_idml(&bytes));
|
||||
assert!(!is_idml(&zip_files_plain()));
|
||||
let back = import_idml(&bytes).unwrap();
|
||||
assert_eq!(back.page_count(), d.page_count());
|
||||
assert_eq!(back.spreads.len(), d.spreads.len());
|
||||
for (a, b) in d.spreads.iter().zip(&back.spreads) {
|
||||
for (pa, pb) in a.pages.iter().zip(&b.pages) {
|
||||
assert!((pa.x - pb.x).abs() < 0.01 && pa.side == pb.side);
|
||||
}
|
||||
assert_eq!(a.items.len(), b.items.len());
|
||||
for (ia, ib) in a.items.iter().zip(&b.items) {
|
||||
let (ra, rb) = (ia.bounds(), ib.bounds());
|
||||
assert!((ra.x0 - rb.x0).abs() < 0.01 && (ra.y1 - rb.y1).abs() < 0.01, "{ra:?} vs {rb:?}");
|
||||
}
|
||||
}
|
||||
let sa: Vec<&str> = d.stories.values().map(|s| s.text.as_str()).collect();
|
||||
let sb: Vec<&str> = back.stories.values().map(|s| s.text.as_str()).collect();
|
||||
assert_eq!(sa, sb);
|
||||
let s = back.stories.values().next().unwrap();
|
||||
assert_eq!(s.frames.len(), 2);
|
||||
assert_eq!(s.runs().next().unwrap().1.over.size, Some(20.0));
|
||||
assert_eq!(s.runs().next().unwrap().1.over.fill.as_deref(), Some("Brand"));
|
||||
let oval = back.spreads[1].items.iter().find(|i| i.shape == Shape::Oval).unwrap();
|
||||
assert_eq!(oval.fill.swatch, "Brand 30");
|
||||
assert!((oval.opacity - 0.5).abs() < 1e-6);
|
||||
assert!(back.swatch("Brand 30").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_and_base64_helpers() {
|
||||
assert_eq!(import::uri_to_path("file:/a%20b/c.png"), "/a b/c.png");
|
||||
assert_eq!(import::uri_to_path("file:///C:/x/y.jpg"), "C:/x/y.jpg");
|
||||
assert_eq!(export::path_to_uri("/a b/c.png"), "file:/a%20b/c.png");
|
||||
let data: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
|
||||
assert_eq!(base64_decode(&base64_encode(&data)), data);
|
||||
}
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
//! A tiny XML DOM on top of `quick-xml`: enough for IDML parts (elements, attributes, text,
|
||||
//! CDATA and processing instructions such as `<?ACE 18?>`).
|
||||
|
||||
use quick_xml::events::Event;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Node {
|
||||
El(El),
|
||||
Text(String),
|
||||
/// Written as `<![CDATA[…]]>`; the parser returns CDATA as `Text`.
|
||||
CData(String),
|
||||
/// Processing instruction: (target, content).
|
||||
Pi(String, String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct El {
|
||||
pub name: String,
|
||||
pub attrs: Vec<(String, String)>,
|
||||
pub children: Vec<Node>,
|
||||
}
|
||||
|
||||
impl El {
|
||||
pub fn new(name: &str) -> Self {
|
||||
El { name: name.into(), attrs: Vec::new(), children: Vec::new() }
|
||||
}
|
||||
|
||||
// ---------- building ----------
|
||||
|
||||
pub fn attr(mut self, k: &str, v: impl ToString) -> Self {
|
||||
self.set(k, v);
|
||||
self
|
||||
}
|
||||
pub fn set(&mut self, k: &str, v: impl ToString) {
|
||||
let v = v.to_string();
|
||||
match self.attrs.iter_mut().find(|(n, _)| n == k) {
|
||||
Some(slot) => slot.1 = v,
|
||||
None => self.attrs.push((k.into(), v)),
|
||||
}
|
||||
}
|
||||
pub fn child(mut self, c: El) -> Self {
|
||||
self.children.push(Node::El(c));
|
||||
self
|
||||
}
|
||||
pub fn push(&mut self, c: El) {
|
||||
self.children.push(Node::El(c));
|
||||
}
|
||||
pub fn text(mut self, t: impl Into<String>) -> Self {
|
||||
self.children.push(Node::Text(t.into()));
|
||||
self
|
||||
}
|
||||
|
||||
// ---------- reading ----------
|
||||
|
||||
/// Local name (namespace prefix stripped).
|
||||
pub fn local(&self) -> &str {
|
||||
self.name.rsplit(':').next().unwrap_or(&self.name)
|
||||
}
|
||||
pub fn get(&self, k: &str) -> Option<&str> {
|
||||
self.attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str())
|
||||
}
|
||||
pub fn elements(&self) -> impl Iterator<Item = &El> {
|
||||
self.children.iter().filter_map(|n| match n {
|
||||
Node::El(e) => Some(e),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
pub fn find(&self, local: &str) -> Option<&El> {
|
||||
self.elements().find(|e| e.local() == local)
|
||||
}
|
||||
pub fn find_all<'a>(&'a self, local: &'a str) -> impl Iterator<Item = &'a El> + 'a {
|
||||
self.elements().filter(move |e| e.local() == local)
|
||||
}
|
||||
/// Concatenated text content of direct text children.
|
||||
pub fn text_content(&self) -> String {
|
||||
let mut s = String::new();
|
||||
for c in &self.children {
|
||||
match c {
|
||||
Node::Text(t) | Node::CData(t) => s.push_str(t),
|
||||
Node::El(e) => s.push_str(&e.text_content()),
|
||||
Node::Pi(..) => {}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
/// A property in either form: an attribute, or a `<Properties><Name …>value</Name>` child.
|
||||
pub fn prop(&self, k: &str) -> Option<String> {
|
||||
if let Some(v) = self.get(k) {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
self.prop_el(k).map(|e| e.text_content())
|
||||
}
|
||||
pub fn prop_el(&self, k: &str) -> Option<&El> {
|
||||
self.find("Properties").and_then(|p| p.find(k))
|
||||
}
|
||||
pub fn num(&self, k: &str) -> Option<f64> {
|
||||
self.prop(k).and_then(|v| v.trim().parse().ok())
|
||||
}
|
||||
pub fn boolean(&self, k: &str) -> Option<bool> {
|
||||
match self.prop(k)?.trim() {
|
||||
"true" => Some(true),
|
||||
"false" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- writing ----------
|
||||
|
||||
pub fn write(&self, out: &mut String, depth: usize) {
|
||||
indent(out, depth);
|
||||
out.push('<');
|
||||
out.push_str(&self.name);
|
||||
for (k, v) in &self.attrs {
|
||||
out.push(' ');
|
||||
out.push_str(k);
|
||||
out.push_str("=\"");
|
||||
escape_into(out, v, true);
|
||||
out.push('"');
|
||||
}
|
||||
if self.children.is_empty() {
|
||||
out.push_str("/>\n");
|
||||
return;
|
||||
}
|
||||
out.push('>');
|
||||
let inline = self.children.iter().all(|c| !matches!(c, Node::El(_)));
|
||||
if !inline {
|
||||
out.push('\n');
|
||||
}
|
||||
for c in &self.children {
|
||||
match c {
|
||||
Node::El(e) => e.write(out, depth + 1),
|
||||
Node::Text(t) => escape_into(out, t, false),
|
||||
Node::CData(t) => {
|
||||
out.push_str("<![CDATA[");
|
||||
out.push_str(&t.replace("]]>", "]]]]><![CDATA[>"));
|
||||
out.push_str("]]>");
|
||||
}
|
||||
Node::Pi(t, c) => {
|
||||
out.push_str("<?");
|
||||
out.push_str(t);
|
||||
if !c.is_empty() {
|
||||
out.push(' ');
|
||||
out.push_str(c);
|
||||
}
|
||||
out.push_str("?>");
|
||||
}
|
||||
}
|
||||
}
|
||||
if !inline {
|
||||
indent(out, depth);
|
||||
}
|
||||
out.push_str("</");
|
||||
out.push_str(&self.name);
|
||||
out.push_str(">\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn indent(out: &mut String, depth: usize) {
|
||||
for _ in 0..depth {
|
||||
out.push('\t');
|
||||
}
|
||||
}
|
||||
|
||||
/// XML-escape text. Control characters that XML 1.0 forbids are dropped; tab/CR/LF in
|
||||
/// attributes become character references so they survive attribute-value normalisation.
|
||||
pub fn escape_into(out: &mut String, s: &str, attr: bool) {
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => 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<u8> {
|
||||
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
|
||||
root.write(&mut s, 0);
|
||||
s.into_bytes()
|
||||
}
|
||||
|
||||
/// Serialize with extra processing instructions after the declaration (designmap's `<?aid …?>`).
|
||||
pub fn document_with_pi(root: &El, pi: &str) -> Vec<u8> {
|
||||
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
|
||||
s.push_str(pi);
|
||||
s.push('\n');
|
||||
root.write(&mut s, 0);
|
||||
s.into_bytes()
|
||||
}
|
||||
|
||||
/// Parse a document into its root element.
|
||||
pub fn parse(bytes: &[u8]) -> Result<El, String> {
|
||||
let mut r = quick_xml::Reader::from_reader(bytes);
|
||||
r.config_mut().trim_text(false);
|
||||
let mut stack: Vec<El> = vec![El::new("#document")];
|
||||
let mut buf = Vec::new();
|
||||
let pos = |r: &quick_xml::Reader<&[u8]>| r.buffer_position();
|
||||
loop {
|
||||
let ev = r.read_event_into(&mut buf).map_err(|e| format!("XML error at {}: {e}", pos(&r)))?;
|
||||
match ev {
|
||||
Event::Start(e) => {
|
||||
let el = start_el(&e)?;
|
||||
stack.push(el);
|
||||
}
|
||||
Event::Empty(e) => {
|
||||
let el = start_el(&e)?;
|
||||
stack.last_mut().ok_or("unbalanced")?.push(el);
|
||||
}
|
||||
Event::End(_) => {
|
||||
let el = stack.pop().ok_or("unbalanced end tag")?;
|
||||
if stack.is_empty() {
|
||||
return Err("unbalanced end tag".into());
|
||||
}
|
||||
stack.last_mut().ok_or("unbalanced")?.push(el);
|
||||
}
|
||||
Event::Text(t) => {
|
||||
let s = t.decode().map_err(|e| e.to_string())?;
|
||||
push_text(stack.last_mut().ok_or("unbalanced")?, &s);
|
||||
}
|
||||
Event::CData(t) => {
|
||||
let s = String::from_utf8_lossy(&t).to_string();
|
||||
push_text(stack.last_mut().ok_or("unbalanced")?, &s);
|
||||
}
|
||||
Event::GeneralRef(g) => {
|
||||
let s = match g.resolve_char_ref().map_err(|e| e.to_string())? {
|
||||
Some(c) => c.to_string(),
|
||||
None => match g.decode().map_err(|e| e.to_string())?.as_ref() {
|
||||
"amp" => "&".into(),
|
||||
"lt" => "<".into(),
|
||||
"gt" => ">".into(),
|
||||
"quot" => "\"".into(),
|
||||
"apos" => "'".into(),
|
||||
_ => String::new(),
|
||||
},
|
||||
};
|
||||
push_text(stack.last_mut().ok_or("unbalanced")?, &s);
|
||||
}
|
||||
Event::PI(p) => {
|
||||
let target = String::from_utf8_lossy(p.target()).to_string();
|
||||
let content = String::from_utf8_lossy(p.content()).trim().to_string();
|
||||
if let Some(top) = stack.last_mut() {
|
||||
top.children.push(Node::Pi(target, content));
|
||||
}
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
if stack.len() != 1 {
|
||||
return Err("unexpected end of document".into());
|
||||
}
|
||||
let doc = stack.pop().unwrap_or_default();
|
||||
doc.children
|
||||
.into_iter()
|
||||
.find_map(|n| match n {
|
||||
Node::El(e) => Some(e),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| "empty document".into())
|
||||
}
|
||||
|
||||
fn push_text(el: &mut El, s: &str) {
|
||||
if s.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(Node::Text(t)) = el.children.last_mut() {
|
||||
t.push_str(s);
|
||||
} else {
|
||||
el.children.push(Node::Text(s.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn start_el(e: &quick_xml::events::BytesStart) -> Result<El, String> {
|
||||
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
||||
let mut el = El::new(&name);
|
||||
for a in e.attributes().with_checks(false) {
|
||||
let a = a.map_err(|e| e.to_string())?;
|
||||
let k = String::from_utf8_lossy(a.key.as_ref()).to_string();
|
||||
let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
|
||||
el.attrs.push((k, v));
|
||||
}
|
||||
Ok(el)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_text_pi_and_entities() {
|
||||
let root = El::new("Root").attr("A", "x\"<&\t").child(El::new("Content").text("a\tb & <c>"));
|
||||
let mut c = El::new("Content");
|
||||
c.children.push(Node::Text("Page ".into()));
|
||||
c.children.push(Node::Pi("ACE".into(), "18".into()));
|
||||
let root = root.child(c);
|
||||
let bytes = document(&root);
|
||||
let back = parse(&bytes).unwrap();
|
||||
assert_eq!(back.get("A"), Some("x\"<&\t"));
|
||||
let contents: Vec<&El> = back.find_all("Content").collect();
|
||||
assert_eq!(contents[0].text_content(), "a\tb & <c>");
|
||||
assert_eq!(contents[1].children[1], Node::Pi("ACE".into(), "18".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_form() {
|
||||
let x = br#"<A PointSize="9"><Properties><Leading type="unit">14</Leading></Properties></A>"#;
|
||||
let e = parse(x).unwrap();
|
||||
assert_eq!(e.num("PointSize"), Some(9.0));
|
||||
assert_eq!(e.num("Leading"), Some(14.0));
|
||||
assert_eq!(e.prop("Missing"), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
|||
("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"),
|
||||
("app.save", "Save", Some("Cmd+S"), "{}"),
|
||||
("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"),
|
||||
("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"),
|
||||
("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"),
|
||||
("app.palette", "Command Palette…", Some("Cmd+K"), "{}"),
|
||||
("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"),
|
||||
|
|
@ -65,6 +66,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
|||
"-",
|
||||
"ui:app.exportPdf",
|
||||
"ui:app.exportPng",
|
||||
"ui:app.exportIdml",
|
||||
"-",
|
||||
"cmd:layout.documentSetup",
|
||||
],
|
||||
|
|
@ -202,6 +204,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
|||
Ok(Value::Null)
|
||||
}
|
||||
"app.exportPng" => export_png(app, p),
|
||||
"app.exportIdml" => export_idml(app, p),
|
||||
"app.storyEditor" => {
|
||||
let sid = p.get("story").and_then(Value::as_u64).map(designcraft_doc::StoryId).or_else(|| {
|
||||
let st = app.session.active()?;
|
||||
|
|
@ -359,6 +362,26 @@ fn export_png(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// File › Export IDML…: ask for a path, export through `file.exportIdml` and write the bytes with
|
||||
/// the platform writer (a download on the web).
|
||||
fn export_idml(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(s) => Some(s.to_string()),
|
||||
None => {
|
||||
let name = format!("{}.idml", st.doc.title);
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&name))
|
||||
}
|
||||
};
|
||||
let Some(path) = path else { return Ok(Value::Null) };
|
||||
let r = app.run("file.exportIdml", json!({}))?;
|
||||
let bytes = designcraft_engine::cmd::base64_decode(r["base64"].as_str().unwrap_or_default());
|
||||
match app.services.write.as_mut() {
|
||||
Some(w) => w(&path, &bytes).map(|_| json!({"path": path, "bytes": bytes.len()})),
|
||||
None => Err("no writer".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Export PDF…: ask for a path, export through `file.exportPdf` and write the bytes with the
|
||||
/// platform writer (a download on the web).
|
||||
fn export_pdf(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue