Package: link the packaged document and IDML relative to the package (fixes #340)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDKUo6t8zLKhzeuPrJxa3n
This commit is contained in:
Paul G
2026-10-17 17:11:31 -07:00
co-authored by Claude Sonnet 5.5
parent 5aeb4dd743
commit 17848dd1e9
4 changed files with 57 additions and 12 deletions
+1
View File
@@ -1,6 +1,7 @@
# DesignCraft roadmap # DesignCraft roadmap
**Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) **Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
- PK340: File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path.
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
+11 -1
View File
@@ -254,7 +254,17 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, package: Option<&s
let Some(package) = package else { return }; let Some(package) = package else { return };
for asset in document.assets.values_mut() { for asset in document.assets.values_mut() {
let Some(link) = asset.link.as_deref() else { continue }; let Some(link) = asset.link.as_deref() else { continue };
if link.len() > MAX_LINK_PATH_BYTES || crate::io::document_path(link, Some(package)).is_ok_and(crate::io::is_file) { if link.len() > MAX_LINK_PATH_BYTES {
continue;
}
if let Ok(path) = crate::io::document_path(link, Some(package))
&& crate::io::is_file(&path)
{
// A link relative to the document (what File › Package writes, `Links/logo.png`) is
// held as the file it names, as every other link is, so preflight and updates find it.
if std::path::Path::new(link).is_relative() {
std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned());
}
continue; continue;
} }
let Some(path) = packaged_link_candidates(link, Some(package)).into_iter().find(|p| crate::io::is_file(p)) else { continue }; let Some(path) = packaged_link_candidates(link, Some(package)).into_iter().find(|p| crate::io::is_file(p)) else { continue };
+43 -8
View File
@@ -18,6 +18,9 @@ use serde_json::{Value, json};
const MAX_OUTPUT_COMPONENT_BYTES: usize = 200; const MAX_OUTPUT_COMPONENT_BYTES: usize = 200;
/// The folder of a package that holds the files its document places.
const LINKS_FOLDER: &str = "Links";
fn path_error(path: &Path, message: impl std::fmt::Display) -> EngineError { fn path_error(path: &Path, message: impl std::fmt::Display) -> EngineError {
EngineError::Other(format!("{}: {message}", path.display())) EngineError::Other(format!("{}: {message}", path.display()))
} }
@@ -282,7 +285,10 @@ fn unique_name(base: String, taken: &mut Vec<String>) -> String {
} }
/// Write each asset's bytes into `dir`; returns the relinked document and the paths written. /// Write each asset's bytes into `dir`; returns the relinked document and the paths written.
fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec<PathBuf>)> { /// `relative_to` names the folder `dir` is, beside the saved document: the links then read
/// `Links/logo.png`, so a package holds no path from the machine that made it (#340). `None` links
/// the copies by their full paths (Links › Copy Link(s) To, which relinks the open document).
fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>, relative_to: Option<&str>) -> Result<(Document, Vec<PathBuf>)> {
root.validate_directory(dir)?; root.validate_directory(dir)?;
let mut out = d.clone(); let mut out = d.clone();
let mut taken = Vec::new(); let mut taken = Vec::new();
@@ -303,7 +309,10 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset
let path = dir.join(&name); let path = dir.join(&name);
root.write(&path, a.data.as_slice())?; root.write(&path, a.data.as_slice())?;
let mut na = (**a).clone(); let mut na = (**a).clone();
na.link = Some(path.to_string_lossy().to_string()); na.link = Some(match relative_to {
Some(folder) => format!("{folder}/{name}"),
None => path.to_string_lossy().to_string(),
});
out.assets.insert(id, Arc::new(na)); out.assets.insert(id, Arc::new(na));
written.push(path); written.push(path);
} }
@@ -387,8 +396,8 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
let links = s.execute("links.list", &json!({}))?; let links = s.execute("links.list", &json!({}))?;
let pre = s.execute("preflight.run", &json!({})).unwrap_or(Value::Null); let pre = s.execute("preflight.run", &json!({})).unwrap_or(Value::Null);
let d = s.doc()?.doc.clone(); let d = s.doc()?.doc.clone();
let links_dir = root.child("Links")?; let links_dir = root.child(LINKS_FOLDER)?;
let (packed, mut files) = write_links(&d, &root, &links_dir, None)?; let (packed, mut files) = write_links(&d, &root, &links_dir, None, Some(LINKS_FOLDER))?;
let doc_path = package_output_path(&dir, &title, "designcraft")?; let doc_path = package_output_path(&dir, &title, "designcraft")?;
root.write(&doc_path, &super::to_bytes(&packed)?)?; root.write(&doc_path, &super::to_bytes(&packed)?)?;
files.insert(0, doc_path); files.insert(0, doc_path);
@@ -463,7 +472,7 @@ fn copy_links(s: &mut Session, p: &Value) -> Result<Value> {
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 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 d = s.doc()?.doc.clone();
let root = OutputRoot::new(dir.clone())?; let root = OutputRoot::new(dir.clone())?;
let (relinked, written) = write_links(&d, &root, &dir, only.as_deref())?; let (relinked, written) = write_links(&d, &root, &dir, only.as_deref(), None)?;
s.edit(|doc, _| { s.edit(|doc, _| {
doc.assets = relinked.assets; doc.assets = relinked.assets;
Ok(()) Ok(())
@@ -520,14 +529,17 @@ mod tests {
std::io::Read::read_to_string(&mut f, &mut spreads).unwrap(); std::io::Read::read_to_string(&mut f, &mut spreads).unwrap();
} }
} }
let linked = dir.join("Links").join("my photo.png"); // Relative to the package: no path from the machine that made it (#340).
let uri = format!("file:{}", linked.to_string_lossy().replace(' ', "%20")); let uri = "file:Links/my%20photo.png";
let folder = dir.file_name().unwrap().to_string_lossy().into_owned();
assert!(!spreads.contains(&folder), "the package's own folder is not written: {spreads}");
assert!(spreads.contains(&format!(r#"LinkResourceURI="{uri}""#)), "{spreads}"); assert!(spreads.contains(&format!(r#"LinkResourceURI="{uri}""#)), "{spreads}");
assert!(!spreads.contains("<Contents") && !spreads.contains(r#"StoredState="Embedded""#), "no image is embedded"); assert!(!spreads.contains("<Contents") && !spreads.contains(r#"StoredState="Embedded""#), "no image is embedded");
let back = designcraft_idml::import_idml_with(&idml, &|_| None).unwrap(); let back = designcraft_idml::import_idml_with(&idml, &|_| None).unwrap();
let asset = back.assets.values().next().unwrap(); let asset = back.assets.values().next().unwrap();
assert!(asset.data.is_empty(), "no image is embedded"); assert!(asset.data.is_empty(), "no image is embedded");
assert_eq!(asset.link.as_deref(), linked.to_str()); assert_eq!(asset.link.as_deref(), Some("Links/my photo.png"));
assert!(dir.join("Links").join("my photo.png").exists());
} }
/// A link whose file is gone has no bytes to copy: the package leaves it out of Links/, the /// A link whose file is gone has no bytes to copy: the package leaves it out of Links/, the
@@ -573,6 +585,29 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap(); std::fs::remove_dir_all(dir).unwrap();
} }
/// A package holds no path from the machine that made it: the document and the IDML link the
/// copies in `Links/` by relative names, and they still open as linked after the move (#340).
#[test]
fn package_documents_hold_relative_links_only() {
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("dc-package-relative-{}-{nonce}", std::process::id()));
let folder = dir.file_name().unwrap().to_string_lossy().into_owned();
let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png();
let mut s = Session::new();
s.execute("file.new", &json!({"title": "RelativePackage", "width": 72, "height": 72})).unwrap();
s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "owned.png", "x": 5, "y": 5, "width": 16})).unwrap();
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap();
let packed = super::super::from_bytes(&std::fs::read(dir.join("RelativePackage.designcraft")).unwrap()).unwrap();
let link = packed.assets.values().next().unwrap().link.clone().unwrap();
assert_eq!(link, "Links/owned.png");
assert!(!link.contains(&folder));
// Copy Link(s) To is unchanged: it relinks the open document by full path.
s.execute("links.copyTo", &json!({"dir": dir.join("Copies").to_string_lossy()})).unwrap();
let copied = s.doc().unwrap().doc.assets.values().next().unwrap().link.clone().unwrap();
assert!(std::path::Path::new(&copied).is_absolute(), "{copied}");
std::fs::remove_dir_all(dir).unwrap();
}
#[test] #[test]
fn package_writes_document_links_and_report() { fn package_writes_document_links_and_report() {
let dir = std::env::temp_dir().join(format!("dc-package-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("dc-package-{}", std::process::id()));
+2 -3
View File
@@ -1,6 +1,6 @@
# Where DesignCraft falls short of InDesign # Where DesignCraft falls short of InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
InDesign professional from doing real work. This is the work list: agents pick from the top. InDesign professional from doing real work. This is the work list: agents pick from the top.
@@ -57,8 +57,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
- **Missing:** a corpus test of real InDesign-authored IDML (via `storytold/photocraft-corpus` or - **Missing:** a corpus test of real InDesign-authored IDML (via `storytold/photocraft-corpus` or
a sibling) measuring what survives import → export → reopen in InDesign. Open bugs: frames not a sibling) measuring what survives import → export → reopen in InDesign. Open bugs: frames not
rendered without a warning (#193), `$ID/` font names reported missing (#338), no rendered without a warning (#193), `$ID/` font names reported missing (#338), no
`PDFAttribute` on export (#339), Package writes build-machine absolute paths (#340), drag-drop `PDFAttribute` on export (#339), drag-drop open of IDML on the app icon (#211).
open of IDML on the app icon (#211).
- **Evidence:** importer coverage is broad (styles, tables, footnotes, xrefs, index, CJK, Arabic, - **Evidence:** importer coverage is broad (styles, tables, footnotes, xrefs, index, CJK, Arabic,
verified in InDesign 2026 with synthetic files), but nothing is measured on real documents. verified in InDesign 2026 with synthetic files), but nothing is measured on real documents.
- **Impact:** IDML is the main interchange format until INDD lands, and the only one that goes - **Impact:** IDML is the main interchange format until INDD lands, and the only one that goes