From c461369421b12fc3506c3d5b4b33aceaa712a2cb Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:27:13 -0400 Subject: [PATCH 01/22] feat(doc): allow portable relinking of images and placed documents --- crates/doc/src/links.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index d32ab993..398b9171 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -255,6 +255,32 @@ impl Align { } impl Document { + /// Rewrite linked file locations in both raster images and placed VectorCraft documents, + /// including those inside symbols, patterns and opacity masks. Used to make packages portable. + pub fn update_links(&mut self, mut f: impl FnMut(&mut LinkInfo)) { + fn walk(n: &mut Arc, f: &mut dyn FnMut(&mut LinkInfo)) { + let n = Arc::make_mut(n); + match &mut n.kind { + NodeKind::Image(image) => { + if let Some(link) = &mut image.link { + f(link); + } + } + NodeKind::PlacedDocument(placed) => f(&mut placed.link), + _ => {} + } + if let Some(mask) = &mut n.mask { + walk(&mut mask.art, f); + } + for child in n.children_mut().into_iter().flatten() { + walk(child, f); + } + } + for root in self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)) { + walk(root, &mut f); + } + } + /// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask /// art included) for which `pick` holds; only the subtrees holding one are copied. pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) { From eacef005b05c00a7a44a4a98e16f4158ecd9924d Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:29:01 -0400 Subject: [PATCH 02/22] fix(package): recursively collect and rebase placed documents and their assets --- crates/engine/src/cmd/package.rs | 233 +++++++++++++++++++++---------- 1 file changed, 162 insertions(+), 71 deletions(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 876853f9..debb2407 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -6,7 +6,10 @@ use std::collections::{BTreeMap, HashSet}; use std::io::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; + +use vectorcraft_doc::{Document, LinkInfo}; +use vectorcraft_doc::links::hash_bytes; use serde_json::{Value, json}; @@ -59,6 +62,152 @@ struct Options { report: bool, } +/// An asset that has been copied to the package, including rewritten linked documents. +#[derive(Clone)] +struct Copied { + name: String, + size: u64, + hash: String, +} + +/// The parent of a packaged document and its asset both live inside the package. +/// Use paths relative to that *document*, not the package's root: a linked document +/// in Links/ must reference its own sibling image as "image.png". +fn relative_in_package(document: &str, asset: &str) -> String { + let parent = document.rsplit_once('/').map_or("", |(dir, _)| dir); + let a: Vec<&str> = parent.split('/').filter(|s| !s.is_empty()).collect(); + let b: Vec<&str> = asset.split('/').filter(|s| !s.is_empty()).collect(); + let common = a.iter().zip(&b).take_while(|(a, b)| a == b).count(); + let mut out = vec![".."; a.len() - common]; + out.extend_from_slice(&b[common..]); + out.join("/") +} + +fn safe_asset_name(name: &str) -> String { + let name: String = name.chars().map(|c| if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect(); + let name = name.trim().trim_matches('.'); + if name.is_empty() { "asset".into() } else { name.into() } +} + +/// Collect dependencies depth-first so nested VectorCraft files are rewritten before +/// their parents: each parent can record the final hash of the rewritten child. +/// Cycles or excessive nesting must not silently yield incomplete packages. +struct Collector<'a> { + opts: &'a Options, + root: Option, + entries: Vec, + copies: BTreeMap, + visiting: HashSet, + taken: HashSet, + link_names: HashSet, + font_files: HashSet, + fonts: usize, + missing: Vec, + skipped: Vec, + lines: Vec, +} + +impl<'a> Collector<'a> { + fn new(opts: &'a Options, root: Option, doc_name: &str) -> Self { + let mut taken = HashSet::new(); + taken.insert(doc_name.to_lowercase()); + Self { + opts, root, entries: Vec::new(), copies: BTreeMap::new(), visiting: HashSet::new(), + taken, link_names: HashSet::new(), font_files: HashSet::new(), fonts: 0, + missing: Vec::new(), skipped: Vec::new(), lines: Vec::new(), + } + } + + fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize) -> Result<()> { + if depth > vectorcraft_doc::placed_document::MAX_DEPTH { + return Err(bad(C, "placed-document links exceed the supported nesting depth (8)")); + } + if !self.visiting.insert(source.to_string()) { + return Err(bad(C, format!("circular placed-document link involving {source}"))); + } + if self.opts.copy_links { + let mut placed = HashSet::new(); + doc.visit_placed(|_, p| { placed.insert(p.link.path.clone()); }); + self.lines.push(format!("LINKED FILES IN {source}")); + for file in super::links::linked_files(doc, Some(source)) { + if self.visiting.contains(&file.path) { + return Err(bad(C, format!("circular placed-document link involving {}", file.path))); + } + if self.copies.contains_key(&file.path) { + continue; + } + let Some(mut bytes) = file.bytes else { + self.lines.push(format!("{}: not found ({}), not copied", file.name, file.path)); + self.missing.push(format!("{} ({source})", file.name)); + continue; + }; + if self.copies.len() >= 10_000 { + return Err(bad(C, "more than 10000 linked files: package dependencies in smaller parts")); + } + let dir = if self.opts.links_folder { "Links/" } else { "" }; + let name = safe_asset_name(&file.name); + let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken }); + let destination_file = format!("{dir}{unique}"); + if placed.contains(&file.path) { + let mut nested = vectorcraft_format::load(&bytes).map_err(|e| bad(C, format!("linked VectorCraft document {} cannot be packaged: {e}", file.path)))?; + self.collect(&mut nested, &file.path, &destination_file, depth + 1)?; + if self.opts.relink { + bytes = vectorcraft_format::save_file(&nested); + } + } + let copy = Copied { name: destination_file.clone(), size: bytes.len() as u64, hash: hash_bytes(&bytes) }; + self.copies.insert(file.path.clone(), copy); + self.lines.push(format!("{} → {destination_file}", file.path)); + self.entries.push((destination_file, bytes)); + } + self.lines.push(String::new()); + } + if self.opts.copy_fonts { + let db = vectorcraft_text::FontDb::global(); + self.lines.push(format!("FONTS IN {source}")); + for (family, style) in super::fonts::used_fonts(doc) { + let font = format!("{family} {style}"); + let reason = match db.face(&family, &style) { + Some(f) if f.family.eq_ignore_ascii_case(&family) && f.embeddable() => { + let data = f.file_data(); + let file = f.path().and_then(Path::file_name).map_or_else( + || format!("{}-{}.{}", f.family, f.style, font_ext(data)).replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), ""), + |n| n.to_string_lossy().into_owned(), + ); + if self.font_files.insert(file.to_lowercase()) { + self.entries.push((format!("Fonts/{file}"), data.to_vec())); + self.fonts += 1; + } + self.lines.push(format!("{font} → Fonts/{file}")); + continue; + } + Some(f) if f.family.eq_ignore_ascii_case(&family) => "its licence doesn't allow embedding", + _ => "not available on this computer", + }; + self.lines.push(format!("{font}: {reason}, not copied")); + self.skipped.push(json!({ "font": font, "reason": reason })); + } + self.lines.push(String::new()); + } + if self.opts.relink { + // The child files have already been rewritten; the hash and size of each + // target must reflect the *packaged* bytes rather than the original. + doc.update_links(|link: &mut LinkInfo| { + if let Some(copy) = self.copies.get(&link.path) { + let relative = relative_in_package(destination, ©.name); + link.path = self.root.as_ref().map_or_else(|| copy.name.clone(), |r| r.join(©.name).to_string_lossy().into_owned()); + link.relative = Some(relative); + link.size = Some(copy.size); + link.hash = Some(copy.hash.clone()); + link.modified = None; + } + }); + } + self.visiting.remove(source); + Ok(()) + } +} + fn package(s: &mut Session, p: &Value) -> Result { let o = Options { copy_links: bool_or(p, "copyLinks", true), @@ -77,83 +226,25 @@ fn package(s: &mut Session, p: &Value) -> Result { let root = folder.map(|f| Path::new(f).join(&name)); let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None }; let st = s.doc()?; - let mut entries: Vec = vec![]; - let mut taken = HashSet::new(); - let mut lines = vec![]; - - // The linked files. - let (mut copies, mut missing) = (BTreeMap::new(), vec![]); - if o.copy_links { - let dir = if o.links_folder { "Links/" } else { "" }; - let mut names = HashSet::new(); - lines.push("LINKED FILES".to_string()); - for f in super::links::linked_files(&st.doc, Some(&doc_path)) { - let Some(bytes) = f.bytes else { - lines.push(format!("{}: not found ({}), not copied", f.name, f.path)); - missing.push(f.name); - continue; - }; - let rel = format!("{dir}{}", free_name(&f.name, if o.links_folder { &mut names } else { &mut taken })); - lines.push(format!("{} → {rel}", f.path)); - copies.insert(f.path, rel.clone()); - entries.push((rel, bytes)); - } - lines.push(String::new()); - } - - // The fonts. - let (mut fonts, mut skipped) = (0, vec![]); - if o.copy_fonts { - let db = vectorcraft_text::FontDb::global(); - let mut files = HashSet::new(); - lines.push("FONTS".to_string()); - for (family, style) in super::fonts::used_fonts(&st.doc) { - let font = format!("{family} {style}"); - let reason = match db.face(&family, &style) { - Some(f) if f.family.eq_ignore_ascii_case(&family) && f.embeddable() => { - let data = f.file_data(); - let file = f.path().and_then(Path::file_name).map_or_else( - || format!("{}-{}.{}", f.family, f.style, font_ext(data)).replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), ""), - |n| n.to_string_lossy().into_owned(), - ); - // A collection, or a style shown in another face's file, is copied once. - if files.insert(file.to_lowercase()) { - entries.push((format!("Fonts/{file}"), data.to_vec())); - fonts += 1; - } - lines.push(format!("{font} → Fonts/{file}")); - continue; - } - Some(f) if f.family.eq_ignore_ascii_case(&family) => "its licence doesn't allow embedding", - _ => "not available on this computer", - }; - lines.push(format!("{font}: {reason}, not copied")); - skipped.push(json!({ "font": font, "reason": reason })); - } - lines.push(String::new()); - } - - // The document, linking to the copies. let mut doc = (*st.doc).clone(); let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION); + let mut collector = Collector::new(&o, root.clone(), &doc_name); + collector.collect(&mut doc, &doc_path, &doc_name, 0)?; + // Keep original paths relative to this new folder for assets intentionally + // excluded from the package (copyLinks:false, missing links, relink:false). if let Some(root) = &root && let Some(d) = super::links::with_relative_paths(&doc, &root.join(&doc_name).to_string_lossy()) { doc = d; } - if o.relink && !copies.is_empty() { - let copied = |im: &vectorcraft_doc::ImageObject| im.link.as_ref().is_some_and(|l| copies.contains_key(&l.path)); - doc.update_images(copied, |im| { - if let Some(l) = &mut im.link - && let Some(rel) = copies.get(&l.path) - { - l.path = root.as_ref().map_or_else(|| rel.clone(), |r| r.join(rel).to_string_lossy().into_owned()); - (l.relative, l.modified) = (Some(rel.clone()), None); - } - }); - } + let mut entries = std::mem::take(&mut collector.entries); entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc))); - taken.insert(doc_name.to_lowercase()); + let mut taken = std::mem::take(&mut collector.taken); + let mut lines = std::mem::take(&mut collector.lines); + let missing = std::mem::take(&mut collector.missing); + let skipped = std::mem::take(&mut collector.skipped); + let fonts = collector.fonts; + let links = collector.copies.len(); // The report. if let Some(info) = info { @@ -165,7 +256,7 @@ fn package(s: &mut Session, p: &Value) -> Result { } let files: Vec<&str> = entries.iter().map(|(p, _)| p.as_str()).collect(); - let mut out = json!({ "files": files, "links": copies.len(), "fonts": fonts, "missingLinks": missing, "skippedFonts": skipped }); + let mut out = json!({ "files": files, "links": links, "fonts": fonts, "missingLinks": missing, "skippedFonts": skipped }); match root { Some(root) => { for (rel, bytes) in &entries { From ae68704dfcf6a99eaa79c953fa0613201fbed6b7 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:29:53 -0400 Subject: [PATCH 03/22] fix(package): preserve rebased portable paths for relative output folders --- crates/engine/src/cmd/package.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index debb2407..47b06a6d 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -228,15 +228,15 @@ fn package(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let mut doc = (*st.doc).clone(); let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION); - let mut collector = Collector::new(&o, root.clone(), &doc_name); - collector.collect(&mut doc, &doc_path, &doc_name, 0)?; - // Keep original paths relative to this new folder for assets intentionally - // excluded from the package (copyLinks:false, missing links, relink:false). + // Prepare original links for the destination *before* rebasing copied links. + // Recalculating afterward would overwrite the portable relative paths. if let Some(root) = &root && let Some(d) = super::links::with_relative_paths(&doc, &root.join(&doc_name).to_string_lossy()) { doc = d; } + let mut collector = Collector::new(&o, root.clone(), &doc_name); + collector.collect(&mut doc, &doc_path, &doc_name, 0)?; let mut entries = std::mem::take(&mut collector.entries); entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc))); let mut taken = std::mem::take(&mut collector.taken); From 4fe06391d455b84bb8215866e622a8f9e3b03d61 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:30:54 -0400 Subject: [PATCH 04/22] fix(package): retain existing top-level missing-link reporting API --- crates/engine/src/cmd/package.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 47b06a6d..d67fb2e2 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -138,7 +138,7 @@ impl<'a> Collector<'a> { } let Some(mut bytes) = file.bytes else { self.lines.push(format!("{}: not found ({}), not copied", file.name, file.path)); - self.missing.push(format!("{} ({source})", file.name)); + self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({source})", file.name) }); continue; }; if self.copies.len() >= 10_000 { From 816a0fe7621cc29a331ef374d4f8fd357c136323 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:31:30 -0400 Subject: [PATCH 05/22] test(package): reopen nested linked documents from portable folder and ZIP deliveries --- crates/engine/src/tests_package.rs | 107 +++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/crates/engine/src/tests_package.rs b/crates/engine/src/tests_package.rs index 2c607fc1..2593b3d6 100644 --- a/crates/engine/src/tests_package.rs +++ b/crates/engine/src/tests_package.rs @@ -193,3 +193,110 @@ fn the_document_info_report_has_every_category() { let face = vectorcraft_text::FontDb::global().face("Source Sans 3", "Regular").unwrap(); assert!(face.embeddable() && face.path().is_none()); } + + +/// A professional handoff: the poster links to a reusable illustration, whose +/// own artwork links to a raster asset. Both dependency levels must travel. +fn poster_with_placed_document(dir: &Folder) -> Session { + let image = dir.file("art/photo.png"); + write(&image, &png(600, 300, RED)); + let mut part = session(); + place(&mut part, &image); + let part_path = dir.file("art/part.vectorcraft"); + save(&mut part, &part_path); + + let mut poster = session(); + let placed = run(&mut poster, "file.place", json!({ "path": part_path, "link": true })); + assert_eq!(placed["linked"], true, "{placed}"); + save(&mut poster, &dir.file("poster.vectorcraft")); + poster +} + +/// Every link must point to a packaged copy, including links inside a linked +/// VectorCraft file. Validate after original assets have been removed. +fn verify_portable_nested_package(folder: &std::path::Path) { + let main = folder.join("poster.vectorcraft"); + let part = folder.join("Links/part.vectorcraft"); + let image = folder.join("Links/photo.png"); + assert!(main.is_file() && part.is_file() && image.is_file()); + + let mut parent = session(); + let opened = open(&mut parent, &main.to_string_lossy()); + assert_eq!(opened["missingLinks"], json!([]), "main package has broken links: {opened}"); + let mut targets = Vec::new(); + parent.doc().unwrap().doc.visit_placed(|_, p| targets.push(p.link.clone())); + assert_eq!(targets.len(), 1); + let link = &targets[0]; + assert_eq!(link.relative.as_deref(), Some("Links/part.vectorcraft")); + let packaged_part = std::fs::read(&part).unwrap(); + let expected_hash = vectorcraft_doc::links::hash_bytes(&packaged_part); + assert_eq!(link.hash.as_deref(), Some(expected_hash.as_str())); + let mut nested = session(); + let opened = open(&mut nested, &part.to_string_lossy()); + assert_eq!(opened["missingLinks"], json!([]), "nested document has broken links: {opened}"); + let mut img_links = Vec::new(); + nested.doc().unwrap().doc.visit_images(|_, im| img_links.extend(im.link.clone())); + assert_eq!(img_links.len(), 1); + assert_eq!(img_links[0].relative.as_deref(), Some("photo.png")); + assert_eq!(std::fs::read(&image).unwrap(), png(600, 300, RED)); +} + +#[test] +fn package_recursively_relinks_placed_documents_and_their_images() { + let dir = Folder::new("package-placed"); + let mut s = poster_with_placed_document(&dir); + let out = dir.file("delivery"); + let result = run(&mut s, "file.package", json!({ "folder": out })); + assert_eq!((result["links"].as_u64(), result["missingLinks"].clone()), (Some(2), json!([])), "{result}"); + let folder = dir.0.join("delivery/poster Folder"); + // The package must work on a second computer without the source tree. + std::fs::remove_dir_all(dir.0.join("art")).unwrap(); + verify_portable_nested_package(&folder); +} + +#[test] +fn zipped_package_recursively_relinks_without_absolute_creator_paths() { + let dir = Folder::new("package-placed-zip"); + let mut s = poster_with_placed_document(&dir); + let result = run(&mut s, "file.package", json!({})); + assert_eq!(result["links"], 2, "{result}"); + let bytes = vectorcraft_format::base64_decode(result["dataBase64"].as_str().unwrap()).unwrap(); + let archive = unzip(&bytes); + let extraction = dir.file("extracted"); + for (name, bytes) in archive { + write(&format!("{extraction}/{name}"), &bytes); + } + std::fs::remove_dir_all(dir.0.join("art")).unwrap(); + verify_portable_nested_package(&dir.0.join("extracted/poster Folder")); +} + +#[test] +fn package_keeps_distinct_linked_documents_with_colliding_file_names() { + let dir = Folder::new("package-collisions"); + let mut poster = session(); + for (number, color) in [(1, RED), (2, BLUE)] { + let image = dir.file(&format!("art{number}/picture.png")); + write(&image, &png(300, 300, color)); + let mut part = session(); + place(&mut part, &image); + let name = dir.file(&format!("art{number}/part.vectorcraft")); + save(&mut part, &name); + run(&mut poster, "file.place", json!({ "path": name, "link": true })); + } + save(&mut poster, &dir.file("poster.vectorcraft")); + let result = run(&mut poster, "file.package", json!({ "folder": dir.file("delivery") })); + assert_eq!(result["links"], 4, "{result}"); + assert_eq!(result["missingLinks"], json!([])); + let folder = dir.0.join("delivery/poster Folder"); + let packaged: Vec<_> = result["files"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|name| name.ends_with(".vectorcraft")).collect(); + assert_eq!(packaged.len(), 3, "both nested documents must be retained: {packaged:?}"); + std::fs::remove_dir_all(dir.0.join("art1")).unwrap(); + std::fs::remove_dir_all(dir.0.join("art2")).unwrap(); + let mut check = session(); + assert_eq!(open(&mut check, &folder.join("poster.vectorcraft").to_string_lossy())["missingLinks"], json!([])); + for name in packaged.into_iter().filter(|n| n.starts_with("Links/")) { + let mut inner = session(); + let result = open(&mut inner, &folder.join(name).to_string_lossy()); + assert_eq!(result["missingLinks"], json!([]), "{name}: {result}"); + } +} From 1f668dfaad5589bfa5d71b9ad23cc88ebd92bb7e Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:32:32 -0400 Subject: [PATCH 06/22] fix(package): retain nested documents' embedded ICC profile bytes --- crates/engine/src/cmd/package.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index d67fb2e2..91c153fd 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -149,10 +149,15 @@ impl<'a> Collector<'a> { let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken }); let destination_file = format!("{dir}{unique}"); if placed.contains(&file.path) { - let mut nested = vectorcraft_format::load(&bytes).map_err(|e| bad(C, format!("linked VectorCraft document {} cannot be packaged: {e}", file.path)))?; + let saved = vectorcraft_format::load_file(&bytes).map_err(|e| bad(C, format!("linked VectorCraft document {} cannot be packaged: {e}", file.path)))?; + let mut nested = saved.doc; self.collect(&mut nested, &file.path, &destination_file, depth + 1)?; if self.opts.relink { - bytes = vectorcraft_format::save_file(&nested); + // Preserve embedded ICC profiles when rewriting a linked native file. + let mut options = vectorcraft_format::SaveOptions::for_doc(&nested); + options.profiles = saved.profiles; + bytes = vectorcraft_format::save_with(&nested, &options) + .map_err(|e| bad(C, format!("could not rewrite packaged document {}: {e}", file.path)))?; } } let copy = Copied { name: destination_file.clone(), size: bytes.len() as u64, hash: hash_bytes(&bytes) }; From 022069d5a50586796ea22a94530382a3990ac1b6 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:33:42 -0400 Subject: [PATCH 07/22] fix(links): retain resolved source paths when packaging moved nested documents --- crates/engine/src/cmd/links.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 575a6f26..59915ff3 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -283,6 +283,9 @@ fn stale(d: &Document, g: &Group) -> bool { pub(crate) struct LinkedFile { pub path: String, pub name: String, + /// The file actually read, which may be next to the current document after a move. + /// Nested links must be resolved against this folder, not the obsolete original path. + pub found_path: Option, /// Read from where the file is found, else the pixels the document holds when they are the /// file's own; `None` when neither. pub bytes: Option>, @@ -297,10 +300,10 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec Date: Fri, 9 Oct 2026 11:34:02 -0400 Subject: [PATCH 08/22] fix(package): resolve transitive links against the moved file's actual location --- crates/engine/src/cmd/package.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 91c153fd..5319c69c 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -84,7 +84,10 @@ fn relative_in_package(document: &str, asset: &str) -> String { } fn safe_asset_name(name: &str) -> String { - let name: String = name.chars().map(|c| if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect(); + let name: String = name + .chars() + .map(|c| if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }) + .collect(); let name = name.trim().trim_matches('.'); if name.is_empty() { "asset".into() } else { name.into() } } @@ -130,7 +133,8 @@ impl<'a> Collector<'a> { doc.visit_placed(|_, p| { placed.insert(p.link.path.clone()); }); self.lines.push(format!("LINKED FILES IN {source}")); for file in super::links::linked_files(doc, Some(source)) { - if self.visiting.contains(&file.path) { + let found_path = file.found_path.as_deref().unwrap_or(&file.path); + if self.visiting.contains(found_path) { return Err(bad(C, format!("circular placed-document link involving {}", file.path))); } if self.copies.contains_key(&file.path) { @@ -149,9 +153,10 @@ impl<'a> Collector<'a> { let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken }); let destination_file = format!("{dir}{unique}"); if placed.contains(&file.path) { - let saved = vectorcraft_format::load_file(&bytes).map_err(|e| bad(C, format!("linked VectorCraft document {} cannot be packaged: {e}", file.path)))?; + let saved = vectorcraft_format::load_file(&bytes) + .map_err(|e| bad(C, format!("linked VectorCraft document {} cannot be packaged: {e}", file.path)))?; let mut nested = saved.doc; - self.collect(&mut nested, &file.path, &destination_file, depth + 1)?; + self.collect(&mut nested, found_path, &destination_file, depth + 1)?; if self.opts.relink { // Preserve embedded ICC profiles when rewriting a linked native file. let mut options = vectorcraft_format::SaveOptions::for_doc(&nested); From a4a040653de7edb3ad16b19d1529f54aea501f60 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:35:51 -0400 Subject: [PATCH 09/22] test(package): resolve nested links from their actual source after moving files --- crates/engine/src/tests_package.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests_package.rs b/crates/engine/src/tests_package.rs index 2593b3d6..b7f12d88 100644 --- a/crates/engine/src/tests_package.rs +++ b/crates/engine/src/tests_package.rs @@ -288,7 +288,13 @@ fn package_keeps_distinct_linked_documents_with_colliding_file_names() { assert_eq!(result["links"], 4, "{result}"); assert_eq!(result["missingLinks"], json!([])); let folder = dir.0.join("delivery/poster Folder"); - let packaged: Vec<_> = result["files"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|name| name.ends_with(".vectorcraft")).collect(); + let packaged: Vec<_> = result["files"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .filter(|name| name.ends_with(".vectorcraft")) + .collect(); assert_eq!(packaged.len(), 3, "both nested documents must be retained: {packaged:?}"); std::fs::remove_dir_all(dir.0.join("art1")).unwrap(); std::fs::remove_dir_all(dir.0.join("art2")).unwrap(); @@ -300,3 +306,22 @@ fn package_keeps_distinct_linked_documents_with_colliding_file_names() { assert_eq!(result["missingLinks"], json!([]), "{name}: {result}"); } } + +#[test] +fn package_finds_nested_assets_after_the_source_tree_moves() { + let dir = Folder::new("package-moved-nested"); + let _original = poster_with_placed_document(&dir); + let moved = dir.0.join("moved"); + std::fs::create_dir_all(&moved).unwrap(); + std::fs::rename(dir.0.join("art"), moved.join("art")).unwrap(); + std::fs::rename(dir.0.join("poster.vectorcraft"), moved.join("poster.vectorcraft")).unwrap(); + // The file paths embedded in both documents still point to the old location. + // Opening the parent repairs its direct link, but the child must resolve + // the image from the folder where it was actually found. + let mut opened = session(); + open(&mut opened, &moved.join("poster.vectorcraft").to_string_lossy()); + let result = run(&mut opened, "file.package", json!({ "folder": dir.file("delivery") })); + assert_eq!(result["missingLinks"], json!([]), "moved file's nested link wasn't found: {result}"); + std::fs::remove_dir_all(moved.join("art")).unwrap(); + verify_portable_nested_package(&dir.0.join("delivery/poster Folder")); +} From a9e3d85b1dc052acef76e4b378f6618b7e2befe2 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:37:24 -0400 Subject: [PATCH 10/22] style: keep new package traversal within rustfmt width --- crates/doc/src/links.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 398b9171..c8a2c48e 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -276,7 +276,8 @@ impl Document { walk(child, f); } } - for root in self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)) { + let roots = self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)); + for root in roots { walk(root, &mut f); } } From f549f6ad4d05d76460507fe795cb01c041828b9d Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:37:28 -0400 Subject: [PATCH 11/22] style: keep new package traversal within rustfmt width --- crates/engine/src/cmd/package.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 5319c69c..45ef0be8 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -181,7 +181,10 @@ impl<'a> Collector<'a> { Some(f) if f.family.eq_ignore_ascii_case(&family) && f.embeddable() => { let data = f.file_data(); let file = f.path().and_then(Path::file_name).map_or_else( - || format!("{}-{}.{}", f.family, f.style, font_ext(data)).replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), ""), + || { + format!("{}-{}.{}", f.family, f.style, font_ext(data)) + .replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), "") + }, |n| n.to_string_lossy().into_owned(), ); if self.font_files.insert(file.to_lowercase()) { From bbb465a0d313ff1e81e37795b9b0f9fb7b13c468 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:39:15 -0400 Subject: [PATCH 12/22] style(doc): format portable link traversal chain --- crates/doc/src/links.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index c8a2c48e..6c9168e9 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -276,7 +276,11 @@ impl Document { walk(child, f); } } - let roots = self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)); + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)); for root in roots { walk(root, &mut f); } From ba1b7a834f215d21b41168f7df0dbcc349981493 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:51:54 -0400 Subject: [PATCH 13/22] fix(package): deduplicate by resolved source and rebase links per containing document --- crates/engine/src/cmd/package.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 45ef0be8..db9cb823 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -128,6 +128,10 @@ impl<'a> Collector<'a> { if !self.visiting.insert(source.to_string()) { return Err(bad(C, format!("circular placed-document link involving {source}"))); } + // A file's saved path is only meaningful inside its own document. + // Different linked documents can reuse the same stale absolute path yet + // resolve it to distinct local assets. Keep a mapping per document. + let mut local = BTreeMap::::new(); if self.opts.copy_links { let mut placed = HashSet::new(); doc.visit_placed(|_, p| { placed.insert(p.link.path.clone()); }); @@ -137,7 +141,8 @@ impl<'a> Collector<'a> { if self.visiting.contains(found_path) { return Err(bad(C, format!("circular placed-document link involving {}", file.path))); } - if self.copies.contains_key(&file.path) { + if let Some(existing) = self.copies.get(found_path) { + local.insert(file.path.clone(), existing.clone()); continue; } let Some(mut bytes) = file.bytes else { @@ -166,7 +171,8 @@ impl<'a> Collector<'a> { } } let copy = Copied { name: destination_file.clone(), size: bytes.len() as u64, hash: hash_bytes(&bytes) }; - self.copies.insert(file.path.clone(), copy); + self.copies.insert(found_path.to_string(), copy.clone()); + local.insert(file.path.clone(), copy); self.lines.push(format!("{} → {destination_file}", file.path)); self.entries.push((destination_file, bytes)); } @@ -206,7 +212,7 @@ impl<'a> Collector<'a> { // The child files have already been rewritten; the hash and size of each // target must reflect the *packaged* bytes rather than the original. doc.update_links(|link: &mut LinkInfo| { - if let Some(copy) = self.copies.get(&link.path) { + if let Some(copy) = local.get(&link.path) { let relative = relative_in_package(destination, ©.name); link.path = self.root.as_ref().map_or_else(|| copy.name.clone(), |r| r.join(©.name).to_string_lossy().into_owned()); link.relative = Some(relative); From 7c3687af2979ddd6801240a2ee0ada19ea9fe325 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:52:37 -0400 Subject: [PATCH 14/22] test(package): distinct nested assets sharing a stale source path remain separate --- crates/engine/src/tests_package.rs | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/engine/src/tests_package.rs b/crates/engine/src/tests_package.rs index b7f12d88..2e22c64f 100644 --- a/crates/engine/src/tests_package.rs +++ b/crates/engine/src/tests_package.rs @@ -325,3 +325,46 @@ fn package_finds_nested_assets_after_the_source_tree_moves() { std::fs::remove_dir_all(moved.join("art")).unwrap(); verify_portable_nested_package(&dir.0.join("delivery/poster Folder")); } + +#[test] +fn identical_stale_paths_in_different_nested_documents_keep_separate_assets() { + let dir = Folder::new("package-stale-paths"); + let mut poster = session(); + for (number, color) in [(1, RED), (2, BLUE)] { + let image = dir.file(&format!("art{number}/photo.png")); + write(&image, &png(300, 300, color)); + let mut part = session(); + place(&mut part, &image); + let name = dir.file(&format!("art{number}/part.vectorcraft")); + save(&mut part, &name); + + // Two otherwise independent linked documents have the *same* obsolete + // absolute image path. Their relative paths identify different files. + let mut saved = vectorcraft_format::load(&std::fs::read(&name).unwrap()).unwrap(); + saved.update_links(|link| { + link.path = "/old-computer/illustrations/photo.png".into(); + link.relative = Some("photo.png".into()); + }); + write(&name, &vectorcraft_format::save_file(&saved)); + run(&mut poster, "file.place", json!({ "path": name, "link": true })); + } + save(&mut poster, &dir.file("poster.vectorcraft")); + let result = run(&mut poster, "file.package", json!({ "folder": dir.file("delivery") })); + assert_eq!(result["links"], 4, "both images and both documents must be copied: {result}"); + assert_eq!(result["missingLinks"], json!([]), "{result}"); + let folder = dir.0.join("delivery/poster Folder"); + std::fs::remove_dir_all(dir.0.join("art1")).unwrap(); + std::fs::remove_dir_all(dir.0.join("art2")).unwrap(); + + let mut actual = Vec::new(); + for filename in result["files"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|name| name.ends_with(".png")) { + actual.push(std::fs::read(folder.join(filename)).unwrap()); + } + assert_eq!(actual.len(), 2, "stale source paths must not cause asset deduplication"); + assert_ne!(actual[0], actual[1], "different original illustrations must keep different pixels"); + for filename in result["files"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|name| name.starts_with("Links/") && name.ends_with(".vectorcraft")) { + let mut nested = session(); + let opened = open(&mut nested, &folder.join(filename).to_string_lossy()); + assert_eq!(opened["missingLinks"], json!([]), "{filename}: {opened}"); + } +} From 71484fd0134a4a52fea7745bb55b36c8f248f7b6 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:54:37 -0400 Subject: [PATCH 15/22] fix(package): preserve nested previews, PDF content, compression and embedded assets --- crates/engine/src/cmd/package.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index db9cb823..b027820d 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -160,12 +160,25 @@ impl<'a> Collector<'a> { if placed.contains(&file.path) { let saved = vectorcraft_format::load_file(&bytes) .map_err(|e| bad(C, format!("linked VectorCraft document {} cannot be packaged: {e}", file.path)))?; + let preview = vectorcraft_format::preview(&bytes); + let pdf = vectorcraft_format::pdf_content(&bytes); + let compressed = bytes.starts_with(&[0x1f, 0x8b]); + let include_linked = saved + .doc + .linked_only_images() + .iter() + .any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy())); let mut nested = saved.doc; self.collect(&mut nested, found_path, &destination_file, depth + 1)?; if self.opts.relink { - // Preserve embedded ICC profiles when rewriting a linked native file. + // Preserve each native file's auxiliary content during relinking: + // thumbnails, PDF-compatible pages, ICC profiles, and embedded linked art. let mut options = vectorcraft_format::SaveOptions::for_doc(&nested); + options.preview = preview; + options.pdf = pdf; options.profiles = saved.profiles; + options.compress = compressed; + options.include_linked = include_linked; bytes = vectorcraft_format::save_with(&nested, &options) .map_err(|e| bad(C, format!("could not rewrite packaged document {}: {e}", file.path)))?; } From f9384dfed7f1afff008c68bfde6c1108607b1a75 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 11:55:49 -0400 Subject: [PATCH 16/22] test(package): nested native files retain PDF compatibility preview and compression --- crates/engine/src/tests_package.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/engine/src/tests_package.rs b/crates/engine/src/tests_package.rs index 2e22c64f..20f553c3 100644 --- a/crates/engine/src/tests_package.rs +++ b/crates/engine/src/tests_package.rs @@ -368,3 +368,31 @@ fn identical_stale_paths_in_different_nested_documents_keep_separate_assets() { assert_eq!(opened["missingLinks"], json!([]), "{filename}: {opened}"); } } + +#[test] +fn packaging_nested_files_preserves_embedded_preview_pdf_and_compression() { + let dir = Folder::new("package-native-extras"); + let mut parent = poster_with_placed_document(&dir); + let source = dir.file("art/part.vectorcraft"); + let original = std::fs::read(&source).unwrap(); + let doc = vectorcraft_format::load(&original).unwrap(); + let preview = png(8, 8, BLUE); + let pdf = b"%PDF-1.7\n%Embedded client preview\n".to_vec(); + let mut opts = vectorcraft_format::SaveOptions::for_doc(&doc); + opts.preview = Some(preview.clone()); + opts.pdf = Some(pdf.clone()); + opts.compress = true; + write(&source, &vectorcraft_format::save_with(&doc, &opts).unwrap()); + + let result = run(&mut parent, "file.package", json!({ "folder": dir.file("delivery") })); + assert_eq!(result["missingLinks"], json!([]), "{result}"); + let packaged = std::fs::read(dir.0.join("delivery/poster Folder/Links/part.vectorcraft")).unwrap(); + assert!(packaged.starts_with(&[0x1f, 0x8b]), "compression must be preserved"); + assert_eq!(vectorcraft_format::preview(&packaged), Some(preview)); + assert_eq!(vectorcraft_format::pdf_content(&packaged), Some(pdf)); + let nested = vectorcraft_format::load(&packaged).unwrap(); + let mut images = Vec::new(); + nested.visit_images(|_, image| images.push(image.link.clone())); + assert_eq!(images.len(), 1); + assert_eq!(images[0].as_ref().unwrap().relative.as_deref(), Some("photo.png")); +} From 0b326c48bc06e6bd521e48f2c8725099c25b358a Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 14:33:39 -0400 Subject: [PATCH 17/22] fix(package): keep copying linked documents on parse failures, cycles and depth limits --- crates/engine/src/cmd/package.rs | 111 +++++++++++++++++++------------ 1 file changed, 70 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index b027820d..dea7cb3a 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -66,8 +66,8 @@ struct Options { #[derive(Clone)] struct Copied { name: String, - size: u64, - hash: String, + size: Option, + hash: Option, } /// The parent of a packaged document and its asset both live inside the package. @@ -94,19 +94,20 @@ fn safe_asset_name(name: &str) -> String { /// Collect dependencies depth-first so nested VectorCraft files are rewritten before /// their parents: each parent can record the final hash of the rewritten child. -/// Cycles or excessive nesting must not silently yield incomplete packages. +/// Cycles, excessive nesting, and unreadable children are reported, not fatal. struct Collector<'a> { opts: &'a Options, root: Option, entries: Vec, copies: BTreeMap, - visiting: HashSet, + visiting: BTreeMap, taken: HashSet, link_names: HashSet, font_files: HashSet, fonts: usize, missing: Vec, skipped: Vec, + warnings: Vec, lines: Vec, } @@ -115,19 +116,31 @@ impl<'a> Collector<'a> { let mut taken = HashSet::new(); taken.insert(doc_name.to_lowercase()); Self { - opts, root, entries: Vec::new(), copies: BTreeMap::new(), visiting: HashSet::new(), - taken, link_names: HashSet::new(), font_files: HashSet::new(), fonts: 0, - missing: Vec::new(), skipped: Vec::new(), lines: Vec::new(), + opts, + root, + entries: Vec::new(), + copies: BTreeMap::new(), + visiting: BTreeMap::new(), + taken, + link_names: HashSet::new(), + font_files: HashSet::new(), + fonts: 0, + missing: Vec::new(), + skipped: Vec::new(), + warnings: Vec::new(), + lines: Vec::new(), } } + fn warn(&mut self, message: String) { + self.lines.push(format!("WARNING: {message}")); + self.warnings.push(message); + } + fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize) -> Result<()> { - if depth > vectorcraft_doc::placed_document::MAX_DEPTH { - return Err(bad(C, "placed-document links exceed the supported nesting depth (8)")); - } - if !self.visiting.insert(source.to_string()) { - return Err(bad(C, format!("circular placed-document link involving {source}"))); - } + // The caller checks cycles and depth before descending; active documents have + // a destination reserved so back-links can point at their single packaged copy. + self.visiting.insert(source.to_string(), destination.to_string()); // A file's saved path is only meaningful inside its own document. // Different linked documents can reuse the same stale absolute path yet // resolve it to distinct local assets. Keep a mapping per document. @@ -135,11 +148,18 @@ impl<'a> Collector<'a> { if self.opts.copy_links { let mut placed = HashSet::new(); doc.visit_placed(|_, p| { placed.insert(p.link.path.clone()); }); - self.lines.push(format!("LINKED FILES IN {source}")); + // Keep this exact header for programs parsing Package reports. + self.lines.push("LINKED FILES".into()); + self.lines.push(format!("Document: {source}")); for file in super::links::linked_files(doc, Some(source)) { let found_path = file.found_path.as_deref().unwrap_or(&file.path); - if self.visiting.contains(found_path) { - return Err(bad(C, format!("circular placed-document link involving {}", file.path))); + if let Some(ancestor) = self.visiting.get(found_path).cloned() { + // The ancestor is already included (possibly the root document). A + // cyclic reference cannot have a final content hash until both files + // have been written, so leave its size/hash unspecified, not stale. + self.warn(format!("{}: circular placed-document link to {ancestor}; stopped following the cycle", file.path)); + local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None }); + continue; } if let Some(existing) = self.copies.get(found_path) { local.insert(file.path.clone(), existing.clone()); @@ -158,32 +178,40 @@ impl<'a> Collector<'a> { let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken }); let destination_file = format!("{dir}{unique}"); if placed.contains(&file.path) { - let saved = vectorcraft_format::load_file(&bytes) - .map_err(|e| bad(C, format!("linked VectorCraft document {} cannot be packaged: {e}", file.path)))?; - let preview = vectorcraft_format::preview(&bytes); - let pdf = vectorcraft_format::pdf_content(&bytes); - let compressed = bytes.starts_with(&[0x1f, 0x8b]); - let include_linked = saved - .doc - .linked_only_images() - .iter() - .any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy())); - let mut nested = saved.doc; - self.collect(&mut nested, found_path, &destination_file, depth + 1)?; - if self.opts.relink { - // Preserve each native file's auxiliary content during relinking: - // thumbnails, PDF-compatible pages, ICC profiles, and embedded linked art. - let mut options = vectorcraft_format::SaveOptions::for_doc(&nested); - options.preview = preview; - options.pdf = pdf; - options.profiles = saved.profiles; - options.compress = compressed; - options.include_linked = include_linked; - bytes = vectorcraft_format::save_with(&nested, &options) - .map_err(|e| bad(C, format!("could not rewrite packaged document {}: {e}", file.path)))?; + if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { + self.warn(format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); + } else { + match vectorcraft_format::load_file(&bytes) { + Ok(saved) => { + let preview = vectorcraft_format::preview(&bytes); + let pdf = vectorcraft_format::pdf_content(&bytes); + let compressed = bytes.starts_with(&[0x1f, 0x8b]); + let include_linked = saved + .doc + .linked_only_images() + .iter() + .any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy())); + let mut nested = saved.doc; + self.collect(&mut nested, found_path, &destination_file, depth + 1)?; + if self.opts.relink { + // Preserve auxiliary native-file content when rewriting. + let mut options = vectorcraft_format::SaveOptions::for_doc(&nested); + options.preview = preview; + options.pdf = pdf; + options.profiles = saved.profiles; + options.compress = compressed; + options.include_linked = include_linked; + match vectorcraft_format::save_with(&nested, &options) { + Ok(rewritten) => bytes = rewritten, + Err(e) => self.warn(format!("{}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked", file.path)), + } + } + } + Err(e) => self.warn(format!("{}: unreadable linked document ({e}); copied unchanged, its own links were not collected", file.path)), + } } } - let copy = Copied { name: destination_file.clone(), size: bytes.len() as u64, hash: hash_bytes(&bytes) }; + let copy = Copied { name: destination_file.clone(), size: Some(bytes.len() as u64), hash: Some(hash_bytes(&bytes)) }; self.copies.insert(found_path.to_string(), copy.clone()); local.insert(file.path.clone(), copy); self.lines.push(format!("{} → {destination_file}", file.path)); @@ -275,6 +303,7 @@ fn package(s: &mut Session, p: &Value) -> Result { let mut lines = std::mem::take(&mut collector.lines); let missing = std::mem::take(&mut collector.missing); let skipped = std::mem::take(&mut collector.skipped); + let warnings = std::mem::take(&mut collector.warnings); let fonts = collector.fonts; let links = collector.copies.len(); @@ -288,7 +317,7 @@ fn package(s: &mut Session, p: &Value) -> Result { } let files: Vec<&str> = entries.iter().map(|(p, _)| p.as_str()).collect(); - let mut out = json!({ "files": files, "links": links, "fonts": fonts, "missingLinks": missing, "skippedFonts": skipped }); + let mut out = json!({ "files": files, "links": links, "fonts": fonts, "missingLinks": missing, "skippedFonts": skipped, "warnings": warnings }); match root { Some(root) => { for (rel, bytes) in &entries { From c8642eeddba26fdada1b3d8f5769f20783018fc7 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 14:34:21 -0400 Subject: [PATCH 18/22] test(package): retain unreadable children and break circular dependencies without aborting --- crates/engine/src/tests_package.rs | 58 ++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/engine/src/tests_package.rs b/crates/engine/src/tests_package.rs index 20f553c3..6d802351 100644 --- a/crates/engine/src/tests_package.rs +++ b/crates/engine/src/tests_package.rs @@ -396,3 +396,61 @@ fn packaging_nested_files_preserves_embedded_preview_pdf_and_compression() { assert_eq!(images.len(), 1); assert_eq!(images[0].as_ref().unwrap().relative.as_deref(), Some("photo.png")); } + +/// A damaged or newer-format linked native file is still an asset worth delivering. +/// Package must preserve the exact bytes instead of failing the entire handoff. +#[test] +fn unreadable_placed_document_is_copied_unchanged_with_warning() { + let dir = Folder::new("package-unreadable-placed"); + let mut parent = poster_with_placed_document(&dir); + let damaged = dir.file("art/part.vectorcraft"); + let bytes = b"not a parseable VectorCraft file".to_vec(); + write(&damaged, &bytes); + + let result = run(&mut parent, "file.package", json!({ "folder": dir.file("delivery") })); + assert_eq!(result["links"], 1, "{result}"); + assert_eq!(result["missingLinks"], json!([]), "{result}"); + let warnings = result["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 1, "{result}"); + assert!(warnings[0].as_str().unwrap().contains("unreadable linked document"), "{result}"); + + let packaged = dir.0.join("delivery/poster Folder"); + assert_eq!(std::fs::read(packaged.join("Links/part.vectorcraft")).unwrap(), bytes); + let report = std::fs::read_to_string(packaged.join("poster Report.txt")).unwrap(); + assert!(report.lines().any(|line| line == "LINKED FILES"), "{report}"); + assert!(report.contains("copied unchanged") && report.contains("its own links were not collected"), "{report}"); +} + +#[test] +fn circular_placed_links_copy_each_document_once_and_warn() { + let dir = Folder::new("package-placed-cycle"); + let source = dir.file("root.vectorcraft"); + let child_path = dir.file("child.vectorcraft"); + + let mut root = session(); + save(&mut root, &source); + let mut child = session(); + run(&mut child, "file.place", json!({ "path": source, "link": true })); + save(&mut child, &child_path); + run(&mut root, "file.place", json!({ "path": child_path, "link": true })); + save(&mut root, &source); + + let result = run(&mut root, "file.package", json!({ "folder": dir.file("delivery") })); + assert_eq!(result["links"], 1, "the root must not be copied a second time: {result}"); + assert_eq!(result["missingLinks"], json!([]), "{result}"); + assert!(result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("circular placed-document link")), "{result}"); + let packaged = dir.0.join("delivery/root Folder"); + let root_doc = vectorcraft_format::load(&std::fs::read(packaged.join("root.vectorcraft")).unwrap()).unwrap(); + let child_doc = vectorcraft_format::load(&std::fs::read(packaged.join("Links/child.vectorcraft")).unwrap()).unwrap(); + let mut direct = Vec::new(); + root_doc.visit_placed(|_, p| direct.push(p.link.relative.clone())); + assert_eq!(direct, [Some("Links/child.vectorcraft".into())]); + let mut back = Vec::new(); + child_doc.visit_placed(|_, p| { + back.push((p.link.relative.clone(), p.link.hash.clone(), p.link.size)); + }); + assert_eq!(back, [(Some("../root.vectorcraft".into()), None, None)]); + let report = std::fs::read_to_string(packaged.join("root Report.txt")).unwrap(); + assert!(report.lines().any(|line| line == "LINKED FILES"), "{report}"); + assert!(report.contains("stopped following the cycle"), "{report}"); +} From 7d75280ab4e63a6ea213cb353f127066a060df7c Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 14:35:42 -0400 Subject: [PATCH 19/22] fix(package): preserve FONTS report header and document warning output --- crates/engine/src/cmd/package.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index dea7cb3a..2ae32e36 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Package", [], None, - "{folder?, name?: (default: \" Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true ( Report.txt)} copy the saved document as it is now into folder/name as .vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}]}; no folder → the same with {name: \".zip\", dataBase64} (a zip of the files in /) instead of folder", + "{folder?, name?: (default: \" Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true ( Report.txt)} copy the saved document as it is now into folder/name as .vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \".zip\", dataBase64} (a zip of the files in /) instead of folder", has_doc, package )] @@ -221,7 +221,8 @@ impl<'a> Collector<'a> { } if self.opts.copy_fonts { let db = vectorcraft_text::FontDb::global(); - self.lines.push(format!("FONTS IN {source}")); + self.lines.push("FONTS".into()); + self.lines.push(format!("Document: {source}")); for (family, style) in super::fonts::used_fonts(doc) { let font = format!("{family} {style}"); let reason = match db.face(&family, &style) { From 6f260fac3ef0482e779b37652d7e0853a08df566 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 14:36:06 -0400 Subject: [PATCH 20/22] fix(package): propagate optional metadata for cyclic links without nesting Options --- crates/engine/src/cmd/package.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 2ae32e36..d7198056 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -140,7 +140,8 @@ impl<'a> Collector<'a> { fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize) -> Result<()> { // The caller checks cycles and depth before descending; active documents have // a destination reserved so back-links can point at their single packaged copy. - self.visiting.insert(source.to_string(), destination.to_string()); + let previous = self.visiting.insert(source.to_string(), destination.to_string()); + debug_assert!(previous.is_none(), "collector visits each document once"); // A file's saved path is only meaningful inside its own document. // Different linked documents can reuse the same stale absolute path yet // resolve it to distinct local assets. Keep a mapping per document. @@ -258,13 +259,13 @@ impl<'a> Collector<'a> { let relative = relative_in_package(destination, ©.name); link.path = self.root.as_ref().map_or_else(|| copy.name.clone(), |r| r.join(©.name).to_string_lossy().into_owned()); link.relative = Some(relative); - link.size = Some(copy.size); - link.hash = Some(copy.hash.clone()); + link.size = copy.size; + link.hash = copy.hash.clone(); link.modified = None; } }); } - self.visiting.remove(source); + let _ = self.visiting.remove(source); Ok(()) } } From b4784c02eeb07742a6b786ff6491f42bc61b3b00 Mon Sep 17 00:00:00 2001 From: shmuelbiller1 Date: Fri, 9 Oct 2026 14:36:37 -0400 Subject: [PATCH 21/22] test(package): retain and report the boundary file at the nesting limit --- crates/engine/src/tests_package.rs | 32 +++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests_package.rs b/crates/engine/src/tests_package.rs index 6d802351..692af18c 100644 --- a/crates/engine/src/tests_package.rs +++ b/crates/engine/src/tests_package.rs @@ -194,7 +194,6 @@ fn the_document_info_report_has_every_category() { assert!(face.embeddable() && face.path().is_none()); } - /// A professional handoff: the poster links to a reusable illustration, whose /// own artwork links to a raster asset. Both dependency levels must travel. fn poster_with_placed_document(dir: &Folder) -> Session { @@ -454,3 +453,34 @@ fn circular_placed_links_copy_each_document_once_and_warn() { assert!(report.lines().any(|line| line == "LINKED FILES"), "{report}"); assert!(report.contains("stopped following the cycle"), "{report}"); } + +#[test] +fn nesting_limit_copies_boundary_file_and_reports_uncollected_links() { + let dir = Folder::new("package-placed-depth-limit"); + let mut deepest = session(); + let mut next = dir.file("leaf.vectorcraft"); + save(&mut deepest, &next); + + let mut root = None; + for depth in (0..=vectorcraft_doc::placed_document::MAX_DEPTH).rev() { + let mut current = session(); + run(&mut current, "file.place", json!({ "path": next, "link": true })); + let name = dir.file(&format!("level{depth}.vectorcraft")); + save(&mut current, &name); + next = name; + if depth == 0 { + root = Some(current); + } + } + + let mut root = root.unwrap(); + let result = run(&mut root, "file.package", json!({ "folder": dir.file("delivery") })); + let expected_links = vectorcraft_doc::placed_document::MAX_DEPTH + 1; + assert_eq!(result["links"].as_u64(), Some(expected_links as u64), "{result}"); + assert!(result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("nesting limit reached")), "{result}"); + let folder = dir.0.join("delivery/level0 Folder"); + let packaged = std::fs::read(folder.join("Links/leaf.vectorcraft")).unwrap(); + assert_eq!(packaged, std::fs::read(dir.file("leaf.vectorcraft")).unwrap()); + let report = std::fs::read_to_string(folder.join("level0 Report.txt")).unwrap(); + assert!(report.contains("nesting limit reached"), "{report}"); +} From 2762f61e834131a94fafe9bbe4ef2f8b65127441 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 09:32:12 +0700 Subject: [PATCH 22/22] Review fixes for #777: report sections per document, fmt, docs and ROADMAP The report kept a placed document's LINKED FILES and FONTS inside its parent's list. Each document now gets its own sections, the packaged one first and unchanged (no Document: line), then each placed document's, headed by its file. Drops a debug_assert and an unneeded mut, runs rustfmt, and documents the nested packaging in docs/mcp.md and the ROADMAP. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/doc/src/links.rs | 7 +-- crates/engine/src/cmd/package.rs | 86 ++++++++++++++++++------------ crates/engine/src/tests_package.rs | 33 +++++++++--- docs/mcp.md | 4 +- 5 files changed, 84 insertions(+), 50 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 924a6cec..8e838ddf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675), Package collecting what placed documents link to (#777); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -170,7 +170,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](docs/affinity-validation.md); [scope, provenance and limits](crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. - - Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). + - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. - **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland. - **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`). diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 6c9168e9..d46d00b3 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -276,11 +276,8 @@ impl Document { walk(child, f); } } - let roots = self - .layers - .iter_mut() - .chain(self.symbols.iter_mut().map(|s| &mut s.art)) - .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)); + let roots = + self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)); for root in roots { walk(root, &mut f); } diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index b649c3eb..ed87362b 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -8,8 +8,8 @@ use std::collections::{BTreeMap, HashSet}; use std::io::Write; use std::path::{Path, PathBuf}; -use vectorcraft_doc::{Document, LinkInfo}; use vectorcraft_doc::links::hash_bytes; +use vectorcraft_doc::{Document, LinkInfo}; use serde_json::{Value, json}; @@ -95,10 +95,8 @@ fn relative_in_package(document: &str, asset: &str) -> String { } fn safe_asset_name(name: &str) -> String { - let name: String = name - .chars() - .map(|c| if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }) - .collect(); + let name: String = + name.chars().map(|c| if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect(); let name = name.trim().trim_matches('.'); if name.is_empty() { "asset".into() } else { name.into() } } @@ -119,7 +117,9 @@ struct Collector<'a> { missing: Vec, skipped: Vec, warnings: Vec, - lines: Vec, + /// The report's lines, one section per document: the packaged one first, then each placed + /// document in the order it is reached. + sections: Vec>, } impl<'a> Collector<'a> { @@ -139,37 +139,47 @@ impl<'a> Collector<'a> { missing: Vec::new(), skipped: Vec::new(), warnings: Vec::new(), - lines: Vec::new(), + sections: Vec::new(), } } - fn warn(&mut self, message: String) { - self.lines.push(format!("WARNING: {message}")); + fn warn(&mut self, lines: &mut Vec, message: String) { + lines.push(format!("WARNING: {message}")); self.warnings.push(message); } fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize) -> Result<()> { // The caller checks cycles and depth before descending; active documents have // a destination reserved so back-links can point at their single packaged copy. - let previous = self.visiting.insert(source.to_string(), destination.to_string()); - debug_assert!(previous.is_none(), "collector visits each document once"); + self.visiting.insert(source.to_string(), destination.to_string()); + // This document's section comes before those of the documents it places. + let section = self.sections.len(); + self.sections.push(vec![]); + let mut lines = vec![]; + // The packaged document's sections read as before; a placed document's name its file. + let heading = |lines: &mut Vec, title: &str| { + lines.push(title.into()); + if depth > 0 { + lines.push(format!("Document: {source}")); + } + }; // A file's saved path is only meaningful inside its own document. // Different linked documents can reuse the same stale absolute path yet // resolve it to distinct local assets. Keep a mapping per document. let mut local = BTreeMap::::new(); if self.opts.copy_links { let mut placed = HashSet::new(); - doc.visit_placed(|_, p| { placed.insert(p.link.path.clone()); }); - // Keep this exact header for programs parsing Package reports. - self.lines.push("LINKED FILES".into()); - self.lines.push(format!("Document: {source}")); + doc.visit_placed(|_, p| { + placed.insert(p.link.path.clone()); + }); + heading(&mut lines, "LINKED FILES"); for file in super::links::linked_files(doc, Some(source)) { let found_path = file.found_path.as_deref().unwrap_or(&file.path); if let Some(ancestor) = self.visiting.get(found_path).cloned() { // The ancestor is already included (possibly the root document). A // cyclic reference cannot have a final content hash until both files // have been written, so leave its size/hash unspecified, not stale. - self.warn(format!("{}: circular placed-document link to {ancestor}; stopped following the cycle", file.path)); + self.warn(&mut lines, format!("{}: circular placed-document link to {ancestor}; stopped following the cycle", file.path)); local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None }); continue; } @@ -178,7 +188,7 @@ impl<'a> Collector<'a> { continue; } let Some(mut bytes) = file.bytes else { - self.lines.push(format!("{}: not found ({}), not copied", file.name, file.path)); + lines.push(format!("{}: not found ({}), not copied", file.name, file.path)); self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({source})", file.name) }); continue; }; @@ -191,18 +201,15 @@ impl<'a> Collector<'a> { let destination_file = format!("{dir}{unique}"); if placed.contains(&file.path) { if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { - self.warn(format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); + self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); } else { match vectorcraft_format::load_file(&bytes) { Ok(saved) => { let preview = vectorcraft_format::preview(&bytes); let pdf = vectorcraft_format::pdf_content(&bytes); let compressed = bytes.starts_with(&[0x1f, 0x8b]); - let include_linked = saved - .doc - .linked_only_images() - .iter() - .any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy())); + let include_linked = + saved.doc.linked_only_images().iter().any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy())); let mut nested = saved.doc; self.collect(&mut nested, found_path, &destination_file, depth + 1)?; if self.opts.relink { @@ -215,26 +222,34 @@ impl<'a> Collector<'a> { options.include_linked = include_linked; match vectorcraft_format::save_with(&nested, &options) { Ok(rewritten) => bytes = rewritten, - Err(e) => self.warn(format!("{}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked", file.path)), + Err(e) => self.warn( + &mut lines, + format!( + "{}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked", + file.path + ), + ), } } } - Err(e) => self.warn(format!("{}: unreadable linked document ({e}); copied unchanged, its own links were not collected", file.path)), + Err(e) => self.warn( + &mut lines, + format!("{}: unreadable linked document ({e}); copied unchanged, its own links were not collected", file.path), + ), } } } let copy = Copied { name: destination_file.clone(), size: Some(bytes.len() as u64), hash: Some(hash_bytes(&bytes)) }; self.copies.insert(found_path.to_string(), copy.clone()); local.insert(file.path.clone(), copy); - self.lines.push(format!("{} → {destination_file}", file.path)); + lines.push(format!("{} → {destination_file}", file.path)); self.entries.push((destination_file, bytes)); } - self.lines.push(String::new()); + lines.push(String::new()); } if self.opts.copy_fonts { let db = vectorcraft_text::FontDb::global(); - self.lines.push("FONTS".into()); - self.lines.push(format!("Document: {source}")); + heading(&mut lines, "FONTS"); for used in super::fonts::used_fonts(doc) { let font = super::fonts::font_label(&used); let (family, style, version) = used; @@ -257,16 +272,16 @@ impl<'a> Collector<'a> { self.entries.push((format!("Fonts/{file}"), data.to_vec())); self.fonts += 1; } - self.lines.push(format!("{font}{shown} → Fonts/{file}")); + lines.push(format!("{font}{shown} → Fonts/{file}")); continue; } Some(_) => "its licence doesn't allow embedding", None => "not available on this computer", }; - self.lines.push(format!("{font}{shown}: {reason}, not copied")); + lines.push(format!("{font}{shown}: {reason}, not copied")); self.skipped.push(json!({ "font": font, "reason": reason })); } - self.lines.push(String::new()); + lines.push(String::new()); } if self.opts.relink { // The child files have already been rewritten; the hash and size of each @@ -282,7 +297,10 @@ impl<'a> Collector<'a> { } }); } - let _ = self.visiting.remove(source); + if let Some(s) = self.sections.get_mut(section) { + *s = lines; + } + self.visiting.remove(source); Ok(()) } } @@ -319,7 +337,7 @@ fn package(s: &mut Session, p: &Value) -> Result { let mut entries = std::mem::take(&mut collector.entries); entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc))); let mut taken = std::mem::take(&mut collector.taken); - let mut lines = std::mem::take(&mut collector.lines); + let lines = collector.sections.concat(); let missing = std::mem::take(&mut collector.missing); let skipped = std::mem::take(&mut collector.skipped); let warnings = std::mem::take(&mut collector.warnings); diff --git a/crates/engine/src/tests_package.rs b/crates/engine/src/tests_package.rs index acde4abf..a4c899c3 100644 --- a/crates/engine/src/tests_package.rs +++ b/crates/engine/src/tests_package.rs @@ -317,13 +317,8 @@ fn package_keeps_distinct_linked_documents_with_colliding_file_names() { assert_eq!(result["links"], 4, "{result}"); assert_eq!(result["missingLinks"], json!([])); let folder = dir.0.join("delivery/poster Folder"); - let packaged: Vec<_> = result["files"] - .as_array() - .unwrap() - .iter() - .filter_map(Value::as_str) - .filter(|name| name.ends_with(".vectorcraft")) - .collect(); + let packaged: Vec<_> = + result["files"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|name| name.ends_with(".vectorcraft")).collect(); assert_eq!(packaged.len(), 3, "both nested documents must be retained: {packaged:?}"); std::fs::remove_dir_all(dir.0.join("art1")).unwrap(); std::fs::remove_dir_all(dir.0.join("art2")).unwrap(); @@ -391,7 +386,13 @@ fn identical_stale_paths_in_different_nested_documents_keep_separate_assets() { } assert_eq!(actual.len(), 2, "stale source paths must not cause asset deduplication"); assert_ne!(actual[0], actual[1], "different original illustrations must keep different pixels"); - for filename in result["files"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|name| name.starts_with("Links/") && name.ends_with(".vectorcraft")) { + for filename in result["files"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .filter(|name| name.starts_with("Links/") && name.ends_with(".vectorcraft")) + { let mut nested = session(); let opened = open(&mut nested, &folder.join(filename).to_string_lossy()); assert_eq!(opened["missingLinks"], json!([]), "{filename}: {opened}"); @@ -426,6 +427,22 @@ fn packaging_nested_files_preserves_embedded_preview_pdf_and_compression() { assert_eq!(images[0].as_ref().unwrap().relative.as_deref(), Some("photo.png")); } +/// The report keeps the packaged document's sections as they were, then gives each placed +/// document's own, headed by its file, instead of nesting them inside the parent's list. +#[test] +fn the_report_lists_a_placed_documents_links_in_a_section_of_its_own() { + let dir = Folder::new("package-report-sections"); + let mut parent = poster_with_placed_document(&dir); + run(&mut parent, "file.package", json!({ "folder": dir.file("delivery"), "copyFonts": false })); + let report = std::fs::read_to_string(dir.0.join("delivery/poster Folder/poster Report.txt")).unwrap(); + let lines: Vec<&str> = report.lines().collect(); + let sections: Vec = lines.iter().enumerate().filter(|(_, l)| **l == "LINKED FILES").map(|(i, _)| i).collect(); + let [poster, part] = sections[..] else { panic!("two link sections: {report}") }; + assert!(lines[poster + 1].ends_with("→ Links/part.vectorcraft"), "the packaged document's section as before: {report}"); + assert!(lines[part + 1].starts_with("Document: ") && lines[part + 1].ends_with("part.vectorcraft"), "{report}"); + assert!(lines[part + 2].ends_with("→ Links/photo.png"), "{report}"); +} + /// A damaged or newer-format linked native file is still an asset worth delivering. /// Package must preserve the exact bytes instead of failing the entire handoff. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 2b3bb0f7..1927d23f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1844,7 +1844,9 @@ bounds; `clip` puts it in a clip group of the old bounds when it is larger). The unsaved one is an error) into `folder/name` (default name ` Folder`): `.vectorcraft`, its linked files in `Links/` (relinked: the packaged document points at the copies; the open one doesn't change), the fonts its type uses in `Fonts/` (fonts whose licence doesn't allow embedding are listed in `skippedFonts` instead) and -` Report.txt`. Every option defaults to true. Without `folder` (the web, or an agent that wants the bytes) the +` Report.txt`. A placed `.vectorcraft` document is packaged with its own linked files and fonts, relinked to +the copies (each file copied once); one that can't be read is copied as it is, and a cycle of placed documents or one +past the nesting limit stops there, each with a line in `warnings` and the report. Every option defaults to true. Without `folder` (the web, or an agent that wants the bytes) the result carries the same files as a zip (`{name: ".zip", dataBase64}`, entries under `/`). `document.info {selectionOnly?, category?, format?: "text"}` adds `sections` (`[{id, title, rows: [[label, value]]}]`: