Merge pull request #777 from shmuelbiller1/fix/package-transitive-placed-links
Fix/package transitive placed links
This commit is contained in:
commit
d28cbacbf7
File diff suppressed because one or more lines are too long
|
|
@ -255,6 +255,34 @@ 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<Node>, 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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)) {
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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<Vec<u8>>,
|
||||
|
|
@ -297,10 +300,10 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
|
|||
.filter(|g| seen.insert(g.link.path.clone()))
|
||||
.map(|g| {
|
||||
let own = |b: &&ImageBlob| !b.is_proxy() && fileio::format_for_name(g.link.name()).is_some_and(|f| f.mime == b.mime);
|
||||
let bytes = locate(&g.link, folder(doc_path))
|
||||
.and_then(|mut f| f.read().ok())
|
||||
.or_else(|| d.images.get(&g.key).filter(own).map(|b| b.bytes.to_vec()));
|
||||
LinkedFile { path: g.link.path.clone(), name: g.link.name().to_string(), bytes }
|
||||
let found = locate(&g.link, folder(doc_path));
|
||||
let found_path = found.as_ref().map(|f| f.path.clone());
|
||||
let bytes = found.and_then(|mut f| f.read().ok()).or_else(|| d.images.get(&g.key).filter(own).map(|b| b.bytes.to_vec()));
|
||||
LinkedFile { path: g.link.path.clone(), name: g.link.name().to_string(), found_path, bytes }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::links::hash_bytes;
|
||||
use vectorcraft_doc::{Document, LinkInfo};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -21,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Package",
|
||||
[],
|
||||
None,
|
||||
"{folder?, name?: (default: \"<document> 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 (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.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: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder",
|
||||
"{folder?, name?: (default: \"<document> 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 (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.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: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder",
|
||||
has_doc,
|
||||
package
|
||||
)]
|
||||
|
|
@ -70,6 +73,238 @@ struct Options {
|
|||
report: bool,
|
||||
}
|
||||
|
||||
/// An asset that has been copied to the package, including rewritten linked documents.
|
||||
#[derive(Clone)]
|
||||
struct Copied {
|
||||
name: String,
|
||||
size: Option<u64>,
|
||||
hash: Option<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, excessive nesting, and unreadable children are reported, not fatal.
|
||||
struct Collector<'a> {
|
||||
opts: &'a Options,
|
||||
root: Option<PathBuf>,
|
||||
entries: Vec<Entry>,
|
||||
copies: BTreeMap<String, Copied>,
|
||||
visiting: BTreeMap<String, String>,
|
||||
taken: HashSet<String>,
|
||||
link_names: HashSet<String>,
|
||||
font_files: HashSet<String>,
|
||||
fonts: usize,
|
||||
missing: Vec<String>,
|
||||
skipped: Vec<Value>,
|
||||
warnings: Vec<String>,
|
||||
/// The report's lines, one section per document: the packaged one first, then each placed
|
||||
/// document in the order it is reached.
|
||||
sections: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
impl<'a> Collector<'a> {
|
||||
fn new(opts: &'a Options, root: Option<PathBuf>, 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: BTreeMap::new(),
|
||||
taken,
|
||||
link_names: HashSet::new(),
|
||||
font_files: HashSet::new(),
|
||||
fonts: 0,
|
||||
missing: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
sections: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn warn(&mut self, lines: &mut Vec<String>, 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.
|
||||
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<String>, 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::<String, Copied>::new();
|
||||
if self.opts.copy_links {
|
||||
let mut placed = HashSet::new();
|
||||
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(&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;
|
||||
}
|
||||
if let Some(existing) = self.copies.get(found_path) {
|
||||
local.insert(file.path.clone(), existing.clone());
|
||||
continue;
|
||||
}
|
||||
let Some(mut bytes) = file.bytes else {
|
||||
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;
|
||||
};
|
||||
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) {
|
||||
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
|
||||
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 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(
|
||||
&mut lines,
|
||||
format!(
|
||||
"{}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked",
|
||||
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);
|
||||
lines.push(format!("{} → {destination_file}", file.path));
|
||||
self.entries.push((destination_file, bytes));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
if self.opts.copy_fonts {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
heading(&mut lines, "FONTS");
|
||||
for used in super::fonts::used_fonts(doc) {
|
||||
let font = super::fonts::font_label(&used);
|
||||
let (family, style, version) = used;
|
||||
// Found by any of its names, as the canvas draws it (in the version the type names).
|
||||
let resolved = db
|
||||
.resolve(&family, &style)
|
||||
.filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing)
|
||||
.map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
|
||||
// A style the family lacks is shown in its closest style, whose file is copied.
|
||||
let shown = match &resolved {
|
||||
Some((f, vectorcraft_text::FontMatch::Style)) => format!(" (shown in {} {})", f.family, f.style),
|
||||
_ => String::new(),
|
||||
};
|
||||
let reason = match resolved {
|
||||
Some((f, _)) if f.embeddable() => {
|
||||
let data = f.file_data();
|
||||
let file = packaged_name(f.path(), &f.family, &f.style, data);
|
||||
// A collection, or a style shown in another face's file, is copied once.
|
||||
if self.font_files.insert(file.to_lowercase()) {
|
||||
self.entries.push((format!("Fonts/{file}"), data.to_vec()));
|
||||
self.fonts += 1;
|
||||
}
|
||||
lines.push(format!("{font}{shown} → Fonts/{file}"));
|
||||
continue;
|
||||
}
|
||||
Some(_) => "its licence doesn't allow embedding",
|
||||
None => "not available on this computer",
|
||||
};
|
||||
lines.push(format!("{font}{shown}: {reason}, not copied"));
|
||||
self.skipped.push(json!({ "font": font, "reason": reason }));
|
||||
}
|
||||
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) = 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);
|
||||
link.size = copy.size;
|
||||
link.hash = copy.hash.clone();
|
||||
link.modified = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(s) = self.sections.get_mut(section) {
|
||||
*s = lines;
|
||||
}
|
||||
self.visiting.remove(source);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = Options {
|
||||
copy_links: bool_or(p, "copyLinks", true),
|
||||
|
|
@ -88,91 +323,26 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<Entry> = 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 used in super::fonts::used_fonts(&st.doc) {
|
||||
let font = super::fonts::font_label(&used);
|
||||
let (family, style, version) = used;
|
||||
// Found by any of its names, as the canvas draws it (in the version the type names).
|
||||
let resolved = db
|
||||
.resolve(&family, &style)
|
||||
.filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing)
|
||||
.map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
|
||||
// A style the family lacks is shown in its closest style, whose file is copied.
|
||||
let shown = match &resolved {
|
||||
Some((f, vectorcraft_text::FontMatch::Style)) => format!(" (shown in {} {})", f.family, f.style),
|
||||
_ => String::new(),
|
||||
};
|
||||
let reason = match resolved {
|
||||
Some((f, _)) if f.embeddable() => {
|
||||
let data = f.file_data();
|
||||
let file = packaged_name(f.path(), &f.family, &f.style, data);
|
||||
// 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}{shown} → Fonts/{file}"));
|
||||
continue;
|
||||
}
|
||||
Some(_) => "its licence doesn't allow embedding",
|
||||
None => "not available on this computer",
|
||||
};
|
||||
lines.push(format!("{font}{shown}: {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);
|
||||
// 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;
|
||||
}
|
||||
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 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)));
|
||||
taken.insert(doc_name.to_lowercase());
|
||||
let mut taken = std::mem::take(&mut collector.taken);
|
||||
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);
|
||||
let fonts = collector.fonts;
|
||||
let links = collector.copies.len();
|
||||
|
||||
// The report.
|
||||
if let Some(info) = info {
|
||||
|
|
@ -184,7 +354,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
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, "warnings": warnings });
|
||||
match root {
|
||||
Some(root) => {
|
||||
for (rel, bytes) in &entries {
|
||||
|
|
|
|||
|
|
@ -223,3 +223,311 @@ 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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
||||
/// 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<usize> = 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]
|
||||
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}");
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 `<document> Folder`): `<document>.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
|
||||
`<document> Report.txt`. Every option defaults to true. Without `folder` (the web, or an agent that wants the bytes) the
|
||||
`<document> 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: "<name>.zip", dataBase64}`, entries under `<name>/`).
|
||||
|
||||
`document.info {selectionOnly?, category?, format?: "text"}` adds `sections` (`[{id, title, rows: [[label, value]]}]`:
|
||||
|
|
|
|||
Loading…
Reference in New Issue