O: File › Package and Copy Links To (file.package, links.copyTo)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 15:50:56 -04:00
parent 67dd4852ca
commit acfb542391
5 changed files with 200 additions and 5 deletions

View File

@ -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`.

View File

@ -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());

View File

@ -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<CommandSpec> {
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>) -> 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<PathBuf>)> {
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<AssetId> = 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<Value> {
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::<Vec<_>>(), "report": r}))
}
fn copy_links(s: &mut Session, p: &Value) -> Result<Value> {
let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("links.copyTo", "missing `dir`"))?);
let only: Option<Vec<AssetId>> = 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##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="40" height="40"/></svg>"##).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<String> = 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);
}
}

View File

@ -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<Result<Value,
}
"app.exportPng" => 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") {

View File

@ -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 |