diff --git a/ROADMAP.md b/ROADMAP.md index cd8c4ae..d11dccf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, an IDML copy, an optional PDF and a report (fonts, links, preflight, instructions); Copy Links To (`links.copyTo`). - Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. - Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 524a1e5..60e30dd 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -18,6 +18,7 @@ mod links; mod notes; mod object; mod overrides; +mod package; mod paths; mod place_text; pub mod preflight; @@ -128,6 +129,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(object::specs()); v.extend(anchors::specs()); v.extend(paths::specs()); + v.extend(package::specs()); v.extend(text::specs()); v.extend(style::specs()); v.extend(table::specs()); diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs new file mode 100644 index 0000000..989c19d --- /dev/null +++ b/crates/engine/src/cmd/package.rs @@ -0,0 +1,173 @@ +//! File › Package and Links › Copy Links To: gather a document with the files it uses. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use designcraft_doc::{AssetId, Document}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use crate::{EngineError, Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + noundo "file.package", + "Package…", + ["File"], + None, + "{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, an IDML copy, an optional PDF and a report → {dir, files, report}", + has_doc, + package + ), + cmd!( + "links.copyTo", + "Copy Link(s) To…", + [], + None, + "{dir, assets?: [asset ids] (default: all)} — write the placed files to `dir` and relink to the copies → {copied}", + has_doc, + copy_links + ), + ] +} + +/// A file name for asset `a` in a Links folder, unique among `taken`. +fn link_name(name: &str, id: AssetId, taken: &mut Vec) -> String { + let base = + Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("asset-{}", id.0)); + let (stem, ext) = match base.rsplit_once('.') { + Some((s, e)) => (s.to_string(), format!(".{e}")), + None => (base.clone(), String::new()), + }; + let mut n = base; + let mut k = 2; + while taken.iter().any(|t| t.eq_ignore_ascii_case(&n)) { + n = format!("{stem} {k}{ext}"); + k += 1; + } + taken.push(n.clone()); + n +} + +/// Write each asset's bytes into `dir`; returns the relinked document and the paths written. +fn write_links(d: &Document, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec)> { + std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + let mut out = d.clone(); + let mut taken = Vec::new(); + let mut written = Vec::new(); + let mut ids: Vec = d.assets.keys().copied().collect(); + ids.sort_by_key(|i| i.0); + for id in ids { + if only.is_some_and(|o| !o.contains(&id)) { + continue; + } + let a = &d.assets[&id]; + let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken); + let path = dir.join(&name); + std::fs::write(&path, a.data.as_slice()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + let mut na = (**a).clone(); + na.link = Some(path.to_string_lossy().to_string()); + out.assets.insert(id, Arc::new(na)); + written.push(path); + } + Ok((out, written)) +} + +fn package(s: &mut Session, p: &Value) -> Result { + let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?); + let st = s.doc()?; + let title = st.doc.title.clone(); + let fonts = s.execute("font.list", &json!({}))?; + let links = s.execute("links.list", &json!({}))?; + let pre = s.execute("preflight.run", &json!({})).unwrap_or(Value::Null); + let d = s.doc()?.doc.clone(); + let (packed, mut files) = write_links(&d, &dir.join("Links"), None)?; + let doc_path = dir.join(format!("{title}.designcraft")); + std::fs::write(&doc_path, super::to_bytes(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", doc_path.display())))?; + files.insert(0, doc_path); + if p.get("idml").and_then(Value::as_bool).unwrap_or(true) { + let path = dir.join(format!("{title}.idml")); + std::fs::write(&path, designcraft_idml::export_idml(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + files.push(path); + } + if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) { + let path = dir.join(format!("{title}.pdf")); + let bytes = designcraft_pdf::export_pdf(&packed, &s.cache, &Default::default()).map_err(|e| EngineError::Other(e.to_string()))?; + std::fs::write(&path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + files.push(path); + } + // The report: fonts (missing first), links, preflight, the user's instructions. + let mut r = format!("Package report: {title}\n\n"); + if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) { + r += &format!("Instructions\n{t}\n\n"); + } + r += "Fonts\n"; + for f in fonts.as_array().into_iter().flatten() { + let missing = f["missing"].as_bool().unwrap_or(false) || f["styleMissing"].as_bool().unwrap_or(false); + r += &format!(" {} {}{}\n", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), if missing { " — MISSING" } else { "" }); + } + r += "\nLinks\n"; + for l in links.as_array().into_iter().flatten() { + r += &format!(" {} ({})\n", l["name"].as_str().unwrap_or(""), l["status"].as_str().unwrap_or("")); + } + if let Some(issues) = pre.as_array() { + r += &format!("\nPreflight: {} issue(s)\n", issues.len()); + for i in issues { + r += &format!(" {}\n", i.get("message").and_then(Value::as_str).unwrap_or("")); + } + } + let report = dir.join("Instructions.txt"); + std::fs::write(&report, &r).map_err(|e| EngineError::Other(format!("{}: {e}", report.display())))?; + files.push(report); + Ok(json!({"dir": dir.to_string_lossy(), "files": files.iter().map(|f| f.to_string_lossy().to_string()).collect::>(), "report": r})) +} + +fn copy_links(s: &mut Session, p: &Value) -> Result { + let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("links.copyTo", "missing `dir`"))?); + let only: Option> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(AssetId).collect()); + let d = s.doc()?.doc.clone(); + let (relinked, written) = write_links(&d, &dir, only.as_deref())?; + s.edit(|doc, _| { + doc.assets = relinked.assets; + Ok(()) + })?; + Ok(json!({"copied": written.len()})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn package_writes_document_links_and_report() { + let dir = std::env::temp_dir().join(format!("dc-package-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let svg = dir.join("logo.svg"); + std::fs::write(&svg, r##""##).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": svg.to_string_lossy(), "x": 100, "y": 100})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 300, 400, 400], "content": "text", "text": "Hello"})).unwrap(); + let out = dir.join("Pkg"); + let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": true, "instructions": "Print on matte."})).unwrap(); + let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); + assert!(files.iter().any(|f| f.ends_with(".designcraft"))); + assert!(files.iter().any(|f| f.ends_with(".idml"))); + assert!(files.iter().any(|f| f.ends_with(".pdf"))); + assert!(out.join("Links").join("logo.svg").exists()); + let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); + assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}"); + // The packaged document links to its Links folder. + let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap(); + let packed = super::super::from_bytes(&std::fs::read(doc_file).unwrap()).unwrap(); + let link = packed.assets.values().next().unwrap().link.clone().unwrap(); + assert!(link.contains("Links"), "{link}"); + // Copy Links To relinks the open document. + let r = s.execute("links.copyTo", &json!({"dir": dir.join("Copies").to_string_lossy()})).unwrap(); + assert_eq!(r["copied"], 1); + assert!(s.doc().unwrap().doc.assets.values().next().unwrap().link.as_deref().unwrap().contains("Copies")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 208d294..89d8f43 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -13,6 +13,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.save", "Save", Some("Cmd+S"), "{}"), ("app.saveCopyDialog", "Save a Copy…", Some("Cmd+Alt+S"), "{} — choose where to write a copy (the document stays as it is)"), ("app.loadSwatches", "Load Swatches…", None, "{} — pick a swatch exchange (.ase) file"), + ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"), ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"), ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"), @@ -92,6 +93,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:app.placeDialog", "-", "ui:app.exportPdf", + "ui:app.packageDialog", "ui:app.exportPng", "ui:app.exportIdml", "cmd:file.exportEpub", @@ -474,6 +476,23 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option export_png(app, p), "app.exportIdml" => export_idml(app, p), + "app.packageDialog" => { + let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); + match app.services.pick_save.as_mut().and_then(|f| f(&name)) { + Some(dir) => { + let r = app.run("file.package", json!({"dir": dir})); + if let Ok(v) = &r { + app.status(format!( + "Packaged {} files into {}", + v["files"].as_array().map_or(0, |f| f.len()), + v["dir"].as_str().unwrap_or("") + )); + } + r + } + None => Ok(Value::Null), + } + } "app.loadSwatches" => { if let Some(pick) = app.services.pick_open.as_mut() { return Some(match pick("swatches") { diff --git a/docs/parity.md b/docs/parity.md index d8b81c5..9065c88 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 44 | 23 | 30 | 57% | +| P1 | 97 | 46 | 23 | 28 | 59% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | @@ -22,14 +22,14 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Transform | 93% | | Fill, stroke, colour | 74% | | Effects & transparency | 50% | -| Placing & links | 66% | +| Placing & links | 72% | | Type & text frames | 80% | | Typography | 79% | | Styles | 71% | | Tables | 73% | | Long documents | 58% | | Interactivity & digital | 30% | -| Output & production | 48% | +| Output & production | 58% | | XML & automation | 39% | | View & navigation | 68% | | Undo, history, saving | 100% | @@ -175,7 +175,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Placing & links | Links panel | P0 | D | | Placing & links | Effective/actual PPI | P0 | D | | Placing & links | Relink to folder / extension | P1 | M | -| Placing & links | Copy Links To / Package | P1 | M | +| Placing & links | Copy Links To / Package | P1 | D | | Placing & links | Captions | P2 | M | | Placing & links | Image import options | P1 | M | | Placing & links | Linked text/stories | P2 | M | @@ -280,7 +280,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Output & production | Interactive PDF | P2 | P | | Output & production | Print dialog | P1 | M | | Output & production | Print booklet | P2 | M | -| Output & production | Package | P1 | M | +| Output & production | Package | P1 | D | | Output & production | Preflight | P1 | D | | Output & production | Export JPEG/PNG | P1 | D | | Output & production | Export IDML | P0 | D |