mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
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:
co-authored by
Claude Sonnet 5.5
parent
5aeb4dd743
commit
17848dd1e9
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user