mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 05:36:46 -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
|
||||
|
||||
**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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>) -> 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<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)?;
|
||||
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<Value> {
|
||||
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<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 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("<Contents") && !spreads.contains(r#"StoredState="Embedded""#), "no image is embedded");
|
||||
let back = designcraft_idml::import_idml_with(&idml, &|_| None).unwrap();
|
||||
let asset = back.assets.values().next().unwrap();
|
||||
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
|
||||
@@ -573,6 +585,29 @@ mod tests {
|
||||
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]
|
||||
fn package_writes_document_links_and_report() {
|
||||
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
|
||||
|
||||
> **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
|
||||
|
||||
Reference in New Issue
Block a user