From 17848dd1e9f21d6f475c2875256dfeaf50a855eb Mon Sep 17 00:00:00 2001 From: Paul G <4waymedia@gmail.com> Date: Sat, 17 Oct 2026 17:11:31 -0700 Subject: [PATCH] Package: link the packaged document and IDML relative to the package (fixes #340) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EDKUo6t8zLKhzeuPrJxa3n --- ROADMAP.md | 1 + crates/engine/src/cmd/interchange.rs | 12 ++++++- crates/engine/src/cmd/package.rs | 51 +++++++++++++++++++++++----- docs/gaps.md | 5 ++- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274e..d3729509 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,7 @@ # 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%) +- 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. > **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 diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index a33525aa..bf0e011a 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -254,7 +254,17 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, package: Option<&s let Some(package) = package else { return }; for asset in document.assets.values_mut() { 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; } let Some(path) = packaged_link_candidates(link, Some(package)).into_iter().find(|p| crate::io::is_file(p)) else { continue }; diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index b953fdc7..f2d33ccf 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -18,6 +18,9 @@ use serde_json::{Value, json}; 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 { EngineError::Other(format!("{}: {message}", path.display())) } @@ -282,7 +285,10 @@ fn unique_name(base: String, taken: &mut Vec) -> String { } /// 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)> { +/// `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)> { root.validate_directory(dir)?; let mut out = d.clone(); 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); root.write(&path, a.data.as_slice())?; 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)); written.push(path); } @@ -387,8 +396,8 @@ fn package(s: &mut Session, p: &Value) -> Result { 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 links_dir = root.child("Links")?; - let (packed, mut files) = write_links(&d, &root, &links_dir, None)?; + let links_dir = root.child(LINKS_FOLDER)?; + let (packed, mut files) = write_links(&d, &root, &links_dir, None, Some(LINKS_FOLDER))?; let doc_path = package_output_path(&dir, &title, "designcraft")?; root.write(&doc_path, &super::to_bytes(&packed)?)?; files.insert(0, doc_path); @@ -463,7 +472,7 @@ fn copy_links(s: &mut Session, p: &Value) -> Result { 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 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, _| { doc.assets = relinked.assets; Ok(()) @@ -520,14 +529,17 @@ mod tests { std::io::Read::read_to_string(&mut f, &mut spreads).unwrap(); } } - let linked = dir.join("Links").join("my photo.png"); - let uri = format!("file:{}", linked.to_string_lossy().replace(' ', "%20")); + // Relative to the package: no path from the machine that made it (#340). + 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(" **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 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 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 - `PDFAttribute` on export (#339), Package writes build-machine absolute paths (#340), drag-drop - open of IDML on the app icon (#211). + `PDFAttribute` on export (#339), drag-drop open of IDML on the app icon (#211). - **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. - **Impact:** IDML is the main interchange format until INDD lands, and the only one that goes