//! File › Package and Links › Copy Links To: gather a document with the files it uses. use std::fs::Metadata; use std::path::{Path, PathBuf}; use std::sync::Arc; #[cfg(not(target_arch = "wasm32"))] use std::fs::File; #[cfg(not(target_arch = "wasm32"))] use std::io::Write; use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{EngineError, Result, Session}; use designcraft_compose::ComposedStory; use designcraft_doc::{AssetId, Document}; use designcraft_fonts::{FaceRef, FontFace, FontSource}; use serde_json::{Value, json}; const MAX_OUTPUT_COMPONENT_BYTES: usize = 200; fn path_error(path: &Path, message: impl std::fmt::Display) -> EngineError { EngineError::Other(format!("{}: {message}", path.display())) } fn is_redirect(metadata: &Metadata) -> bool { // Rust reports Windows name-surrogate reparse points (including directory junctions) as // symlinks, while leaving non-redirecting reparse metadata such as cloud placeholders alone. metadata.file_type().is_symlink() } fn ensure_real_directory(path: &Path, ancestor: bool) -> Result<()> { match std::fs::symlink_metadata(path) { Ok(metadata) => { if is_redirect(&metadata) { // An existing folder above the package may be reached through a link the user // set up (`/tmp` on macOS is one); the package folder itself may not. if ancestor && std::fs::metadata(path).is_ok_and(|m| m.is_dir()) { return Ok(()); } return Err(path_error(path, "refusing a redirected output directory")); } if !metadata.is_dir() { return Err(path_error(path, "output path is not a directory")); } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) && parent != path { ensure_real_directory(parent, true)?; } if let Err(e) = std::fs::create_dir(path) && e.kind() != std::io::ErrorKind::AlreadyExists { return Err(path_error(path, e)); } let metadata = std::fs::symlink_metadata(path).map_err(|e| path_error(path, e))?; if is_redirect(&metadata) || !metadata.is_dir() { return Err(path_error(path, "output directory was redirected while it was being created")); } } Err(e) => return Err(path_error(path, e)), } Ok(()) } /// A selected output directory and its resolved identity. Every package path is checked against /// it before use. Outputs are staged in the same directory and atomically persisted over an /// existing regular file, preserving overwrite behavior without following file links. struct OutputRoot { path: PathBuf, canonical: PathBuf, } impl OutputRoot { fn new(path: PathBuf) -> Result { // Before anything is created: the folder must be inside the automation write root. crate::file_access::check_write(&path).map_err(EngineError::Other)?; ensure_real_directory(&path, false)?; let canonical = std::fs::canonicalize(&path).map_err(|e| path_error(&path, e))?; Ok(Self { path, canonical }) } fn validate_directory(&self, path: &Path) -> Result<()> { if !path.starts_with(&self.path) { return Err(path_error(path, "output path is outside the selected directory")); } ensure_real_directory(path, false)?; let canonical = std::fs::canonicalize(path).map_err(|e| path_error(path, e))?; if !canonical.starts_with(&self.canonical) { return Err(path_error(path, "output directory resolves outside the selected directory")); } Ok(()) } fn child(&self, name: &str) -> Result { let path = self.path.join(name); if path.parent() != Some(self.path.as_path()) { return Err(path_error(&path, "invalid output directory name")); } Ok(path) } fn validate_file_entry(&self, path: &Path) -> Result { let parent = path.parent().ok_or_else(|| path_error(path, "output file has no parent directory"))?; self.validate_directory(parent)?; if !path.starts_with(&self.path) { return Err(path_error(path, "output file is outside the selected directory")); } match std::fs::symlink_metadata(path) { Ok(metadata) => { if is_redirect(&metadata) { return Err(path_error(path, "refusing to replace a redirected output file")); } if !metadata.is_file() { return Err(path_error(path, "output path is not a regular file")); } Ok(true) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(e) => Err(path_error(path, e)), } } #[cfg(not(target_arch = "wasm32"))] fn temporary_file(&self, path: &Path, permissions: Option) -> Result { let parent = path.parent().ok_or_else(|| path_error(path, "output file has no parent directory"))?; self.validate_directory(parent)?; let mut builder = tempfile::Builder::new(); builder.prefix(".designcraft-package-").suffix(".tmp"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; // Match fs::write for a new output: 0666 restricted by the caller's umask. builder.permissions(std::fs::Permissions::from_mode(0o666)); } let file = builder.tempfile_in(parent).map_err(|e| path_error(path, e))?; #[cfg(unix)] if let Some(permissions) = permissions { // Overwrites retain their prior mode; copied fonts retain their source mode. file.as_file().set_permissions(permissions).map_err(|e| path_error(path, e))?; } #[cfg(not(unix))] // The replacement inherits its directory's ACL. Rust's portable permission API cannot // reproduce a destination file's Windows DACL without platform-specific code. let _ = permissions; Ok(file) } fn write(&self, path: &Path, bytes: &[u8]) -> Result<()> { #[cfg(target_arch = "wasm32")] { let _ = (path, bytes); return Err(EngineError::Other("package files are unavailable on the web".into())); } #[cfg(not(target_arch = "wasm32"))] { let existed = self.validate_file_entry(path)?; let permissions = if existed { Some(std::fs::symlink_metadata(path).map_err(|e| path_error(path, e))?.permissions()) } else { None }; let mut file = self.temporary_file(path, permissions)?; file.write_all(bytes).map_err(|e| path_error(path, e))?; // Recheck immediately before replacement. If a name is raced after this check, // persist replaces that directory entry rather than following it. self.validate_file_entry(path)?; file.persist(path).map_err(|e| path_error(path, e.error))?; Ok(()) } } fn copy(&self, from: &Path, to: &Path) -> Result<()> { if self.validate_file_entry(to)? && std::fs::canonicalize(to).is_ok_and(|target| std::fs::canonicalize(from).is_ok_and(|source| source == target)) { return Ok(()); } #[cfg(target_arch = "wasm32")] { let _ = from; return Err(EngineError::Other("package files are unavailable on the web".into())); } #[cfg(not(target_arch = "wasm32"))] { let mut source = File::open(from).map_err(|e| path_error(from, e))?; let permissions = Some(source.metadata().map_err(|e| path_error(from, e))?.permissions()); let mut target = self.temporary_file(to, permissions)?; std::io::copy(&mut source, &mut target).map_err(|e| path_error(to, e))?; self.validate_file_entry(to)?; target.persist(to).map_err(|e| path_error(to, e.error))?; Ok(()) } } } pub fn specs() -> Vec { vec![ cmd!( noundo "file.package", "Package…", ["File"], None, "{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy, an optional PDF and a report → {dir, files, report}", has_doc, package ), cmd!( "links.copyTo", "Copy Link(s) To…", [], None, "{dir, assets?: [asset ids] (default: all)} — write the placed files to `dir` and relink to the copies → {copied}", has_doc, copy_links ), ] } fn is_reserved_windows_name(name: &str) -> bool { let stem = name.split('.').next().unwrap_or(name).trim_end_matches([' ', '.']); ["con", "prn", "aux", "nul"].into_iter().any(|reserved| caseless::compatibility_caseless_match_str(stem, reserved)) || (1..=9).any(|n| { caseless::compatibility_caseless_match_str(stem, &format!("com{n}")) || caseless::compatibility_caseless_match_str(stem, &format!("lpt{n}")) }) } fn portable_names_equal(a: &str, b: &str) -> bool { caseless::canonical_caseless_match_str(a, b) } /// A portable single path component made from document metadata. fn safe_component(name: &str, fallback: &str) -> String { let leaf = name.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(""); let mut safe = String::new(); for ch in leaf.trim().chars() { let replacement = ch.is_control() || matches!(ch, '<' | '>' | ':' | '"' | '|' | '?' | '*' | '/' | '\\'); let ch = if replacement { '_' } else { ch }; if safe.len() + ch.len_utf8() > MAX_OUTPUT_COMPONENT_BYTES { break; } safe.push(ch); } safe.truncate(safe.trim_end_matches([' ', '.']).len()); if safe.is_empty() || safe == "." || safe == ".." { safe = fallback.to_string(); } if is_reserved_windows_name(&safe) { safe.insert(0, '_'); } safe } fn package_output_path(dir: &Path, title: &str, extension: &str) -> Result { let path = dir.join(format!("{title}.{extension}")); if path.parent() != Some(dir) { return Err(bad("file.package", "an output filename would be outside the selected directory")); } Ok(path) } /// A file name for asset `a` in a Links folder, unique among `taken`. fn link_name(name: &str, id: AssetId, taken: &mut Vec) -> String { let fallback = format!("asset-{}", id.0); unique_name(safe_component(name, &fallback), taken) } /// `base`, or `base` numbered ("logo 2.png"), unique under portable Unicode normalization and /// case-insensitive comparison. fn unique_name(base: String, taken: &mut Vec) -> String { let (stem, ext) = match base.rsplit_once('.') { Some((s, e)) => (s.to_string(), format!(".{e}")), None => (base.clone(), String::new()), }; let mut n = base; let mut k = 2; while taken.iter().any(|t| portable_names_equal(t, &n)) { n = format!("{stem} {k}{ext}"); k += 1; } taken.push(n.clone()); n } /// Write each asset's bytes into `dir`; returns the relinked document and the paths written. fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec)> { root.validate_directory(dir)?; let mut out = d.clone(); let mut taken = Vec::new(); let mut written = Vec::new(); // Only files something places; the saved document leaves the others out. let ids = d.used_assets(); for id in ids { if only.is_some_and(|o| !o.contains(&id)) { continue; } let Some(a) = d.assets.get(&id) else { continue }; // A missing link has no bytes: an empty file under its name would read as present, so it // keeps its old (missing) path instead. if a.data.is_empty() { continue; } let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken); let path = dir.join(&name); root.write(&path, a.data.as_slice())?; let mut na = (**a).clone(); na.link = Some(path.to_string_lossy().to_string()); out.assets.insert(id, Arc::new(na)); written.push(path); } Ok((out, written)) } /// Copy the font file of `face` into `dir` (created when first needed) unless its licence /// restricts it or it is already there (`copied`: the source files copied) → what the package /// report adds after the font's name (nothing when copied). fn copy_font(root: &OutputRoot, face: &FontFace, dir: &Path, copied: &mut Vec, taken: &mut Vec, files: &mut Vec) -> String { let from = match &face.source { FontSource::Bundled => return " — included with DesignCraft".into(), FontSource::Memory => return " — not copied: no font file".into(), FontSource::Installed(p) | FontSource::Document(p) => p, }; if face.restricted_licence() { return " — not copied: its licence doesn't allow it".into(); } if copied.contains(from) { return String::new(); } let name = safe_component(&from.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(), "font"); let to = dir.join(unique_name(name, taken)); match root.validate_directory(dir).and_then(|_| root.copy(from, &to)) { Ok(()) => { copied.push(from.clone()); files.push(to); String::new() } Err(e) => format!(" — not copied: {e}"), } } /// Add the faces that draw the visible glyphs of `cs` (table cells and footnotes included) to /// `out`, each once. fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec) { // Cells and footnotes hold composed stories of their own; tables nest only so deep. if depth > 16 { return; } for f in &cs.frames { for g in f.lines.iter().flat_map(|l| &l.glyphs).filter(|g| g.visible) { if !out.iter().any(|o| o.id() == g.face.id()) { out.push(g.face); } } for c in f.tables.iter().flat_map(|t| &t.cells) { drawn_faces(&c.text, depth + 1, out); } for n in &f.notes { drawn_faces(&n.text, depth + 1, out); } } } fn package(s: &mut Session, p: &Value) -> Result { let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?); let st = s.doc()?; let root = OutputRoot::new(dir.clone())?; let display_title = st.doc.title.clone(); let title = safe_component(&display_title, "Untitled"); let fonts = s.execute("font.list", &json!({}))?; let links = s.execute("links.list", &json!({}))?; let pre = s.execute("preflight.run", &json!({})).unwrap_or(Value::Null); let d = s.doc()?.doc.clone(); let links_dir = root.child("Links")?; let (packed, mut files) = write_links(&d, &root, &links_dir, None)?; let doc_path = package_output_path(&dir, &title, "designcraft")?; root.write(&doc_path, &super::to_bytes(&packed))?; files.insert(0, doc_path); // The font files, and what the report says about each font. let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); let fonts_dir = root.child(designcraft_fonts::DOCUMENT_FONTS_FOLDER)?; let (mut copied, mut taken) = (Vec::new(), Vec::new()); let (mut font_lines, mut named) = (Vec::new(), Vec::new()); for f in fonts.as_array().into_iter().flatten() { let (family, style) = (f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or("")); let missing = f["missing"].as_bool().unwrap_or(false) || f["styleMissing"].as_bool().unwrap_or(false); // A missing font's text is drawn in its substitute, which isn't a fallback font. let face = db.face(family, style); named.push(face.id()); let note = if missing { " — MISSING".to_string() } else { copy_font(&root, &face, &fonts_dir, &mut copied, &mut taken, &mut files) }; font_lines.push(format!(" {family} {style}{note}\n")); } // Fallback fonts: the faces that draw characters the named fonts lack. let mut drawn = Vec::new(); for sid in d.stories.keys() { drawn_faces(&s.cache.get(&d, *sid, None), 0, &mut drawn); } drawn.retain(|f| !named.contains(&f.id())); drawn.sort_by(|a, b| (&a.family, &a.style).cmp(&(&b.family, &b.style))); let fallback_lines: Vec = drawn .iter() .map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(&root, f, &fonts_dir, &mut copied, &mut taken, &mut files))) .collect(); if p.get("idml").and_then(Value::as_bool).unwrap_or(true) { let path = package_output_path(&dir, &title, "idml")?; root.write(&path, &designcraft_idml::export_idml(&packed))?; files.push(path); } if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) { let path = package_output_path(&dir, &title, "pdf")?; let bytes = designcraft_pdf::export_pdf(&packed, &s.cache, &Default::default()).map_err(|e| EngineError::Other(e.to_string()))?; root.write(&path, &bytes)?; files.push(path); } // The report: fonts (missing first), links, preflight, the user's instructions. let mut r = format!("Package report: {display_title}\n\n"); if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) { r += &format!("Instructions\n{t}\n\n"); } r += "Fonts\n"; r += &font_lines.concat(); if !fallback_lines.is_empty() { r += "\nFallback fonts (for characters the fonts above lack)\n"; r += &fallback_lines.concat(); } r += "\nLinks\n"; for l in links.as_array().into_iter().flatten() { r += &format!(" {} ({})\n", l["name"].as_str().unwrap_or(""), l["status"].as_str().unwrap_or("")); } if let Some(issues) = pre.get("issues").and_then(Value::as_array) { r += &format!("\nPreflight: {} issue(s)\n", issues.len()); for i in issues { r += &format!(" {}\n", i.get("message").and_then(Value::as_str).unwrap_or("")); } } let report = dir.join("Instructions.txt"); root.write(&report, r.as_bytes())?; files.push(report); Ok(json!({"dir": dir.to_string_lossy(), "files": files.iter().map(|f| f.to_string_lossy().to_string()).collect::>(), "report": r})) } fn copy_links(s: &mut Session, p: &Value) -> Result { let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("links.copyTo", "missing `dir`"))?); let only: Option> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(AssetId).collect()); let d = s.doc()?.doc.clone(); let root = OutputRoot::new(dir.clone())?; let (relinked, written) = write_links(&d, &root, &dir, only.as_deref())?; s.edit(|doc, _| { doc.assets = relinked.assets; Ok(()) })?; Ok(json!({"copied": written.len()})) } #[cfg(test)] mod tests { use super::*; #[test] fn relocated_packages_rebind_embedded_image_links_on_open() { let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); let dir = std::env::temp_dir().join(format!("dc-package-relocation-{}-{nonce}", std::process::id())); let original = dir.join("original"); let moved = dir.join("moved"); let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); let mut s = Session::new(); s.execute("file.new", &json!({"title": "OwnedPackage", "width": 72, "height": 72})).unwrap(); s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "owned.png", "x": 5, "y": 5, "width": 16})).unwrap(); s.execute("file.package", &json!({"dir": original.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); std::fs::rename(&original, &moved).unwrap(); let expected = std::path::absolute(moved.join("Links").join("owned.png")).unwrap(); for (command, extension) in [("file.open", "designcraft"), ("file.openIdml", "idml")] { let mut opened = Session::new(); opened.execute(command, &json!({"path": moved.join(format!("OwnedPackage.{extension}")).to_string_lossy()})).unwrap(); let asset = opened.doc().unwrap().doc.assets.values().next().unwrap(); assert_eq!(*asset.data, png, "{extension}: preserve embedded pixels"); assert_eq!(asset.link.as_deref(), expected.to_str(), "{extension}: use the relocated Links file"); let links = opened.execute("links.list", &json!({})).unwrap(); assert_eq!(links[0]["status"], "ok", "{extension}"); } std::fs::remove_dir_all(dir).unwrap(); } /// A link whose file is gone has no bytes to copy: the package leaves it out of Links/, the /// report lists it as missing, and the packaged document keeps it missing (#908). #[test] fn package_leaves_missing_links_missing() { let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); let dir = std::env::temp_dir().join(format!("dc-package-missing-{}-{nonce}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let svg = r##""##; let (gone, kept) = (dir.join("gone.svg"), dir.join("kept.svg")); std::fs::write(&gone, svg).unwrap(); std::fs::write(&kept, svg.replace(""##).unwrap(); let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"path": svg.to_string_lossy(), "x": 100, "y": 100})).unwrap(); s.execute("frame.create", &json!({"rect": [72, 300, 400, 400], "content": "text", "text": "Hello"})).unwrap(); let out = dir.join("Pkg"); let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": true, "instructions": "Print on matte."})).unwrap(); let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); assert!(files.iter().any(|f| f.ends_with(".designcraft"))); assert!(files.iter().any(|f| f.ends_with(".idml"))); assert!(files.iter().any(|f| f.ends_with(".pdf"))); assert!(out.join("Links").join("logo.svg").exists()); let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}"); assert!(report.contains("Preflight:"), "{report}"); // The packaged document links to its Links folder. let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap(); let packed = super::super::from_bytes(&std::fs::read(doc_file).unwrap()).unwrap(); let link = packed.assets.values().next().unwrap().link.clone().unwrap(); assert!(link.contains("Links"), "{link}"); // Copy Links To relinks the open document. let r = s.execute("links.copyTo", &json!({"dir": dir.join("Copies").to_string_lossy()})).unwrap(); assert_eq!(r["copied"], 1); assert!(s.doc().unwrap().doc.assets.values().next().unwrap().link.as_deref().unwrap().contains("Copies")); let _ = std::fs::remove_dir_all(&dir); } #[test] fn package_copies_the_fonts_their_licences_allow() { use designcraft_fonts::DOCUMENT_FONTS_FOLDER; use designcraft_fonts::testing::{font_with, with_fs_type}; const FREE: &str = "DocFont Package Free"; const RESTRICTED: &str = "DocFont Package Restricted"; let dir = std::env::temp_dir().join(format!("dc-package-fonts-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER); std::fs::create_dir_all(&fonts).unwrap(); std::fs::write(fonts.join("free.ttf"), font_with(FREE, &['F']).unwrap()).unwrap(); std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &['R']).unwrap(), 0x0002).unwrap()).unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(fonts.join("free.ttf"), std::fs::Permissions::from_mode(0o640)).unwrap(); } // Text in both fonts, and in the bundled default font. let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Free Restricted Default"})).unwrap(); for (a, b, family) in [(0, 4, FREE), (5, 15, RESTRICTED)] { s.execute("text.select", &json!({"story": r["story"], "anchor": a, "focus": b})).unwrap(); s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); } let src = dir.join("src").join("Fonts.designcraft"); s.execute("file.save", &json!({"path": src.to_string_lossy()})).unwrap(); let mut s = Session::new(); assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 2); let out = dir.join("Pkg"); let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); assert!(packed_fonts.join("free.ttf").exists()); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert_eq!( std::fs::metadata(packed_fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777, std::fs::metadata(fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777 ); } assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps the font out"); assert!(r["files"].to_string().contains("free.ttf"), "{}", r["files"]); let report = r["report"].as_str().unwrap(); assert!(report.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}"); assert!(report.contains(&format!("{FREE} Regular\n")), "{report}"); assert!(report.contains(&format!("{} Regular — included with DesignCraft", designcraft_fonts::DEFAULT_FAMILY)), "{report}"); // The packaged document opens with its fonts. let doc = r["files"][0].as_str().unwrap().to_string(); assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 1); // Packaging into the folder the fonts come from leaves them whole. let len = std::fs::metadata(fonts.join("free.ttf")).unwrap().len(); s.execute("file.package", &json!({"dir": dir.join("src").to_string_lossy(), "idml": false})).unwrap(); assert_eq!(std::fs::metadata(fonts.join("free.ttf")).unwrap().len(), len); let _ = std::fs::remove_dir_all(&dir); } #[test] fn package_copies_the_fallback_fonts_that_draw_text() { use designcraft_fonts::DOCUMENT_FONTS_FOLDER; use designcraft_fonts::testing::{font_with, with_fs_type}; const NAMED: &str = "DocFont Fallback Named"; const HELPER: &str = "DocFont Fallback Helper"; const RESTRICTED: &str = "DocFont Fallback Restricted"; // Characters no other font has: the named font lacks both, the document's other fonts // draw them. let (helped, restricted) = ('\u{F0A41}', '\u{F0A42}'); let dir = std::env::temp_dir().join(format!("dc-package-fallback-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER); std::fs::create_dir_all(&fonts).unwrap(); std::fs::write(fonts.join("named.ttf"), font_with(NAMED, &['N']).unwrap()).unwrap(); std::fs::write(fonts.join("helper.ttf"), font_with(HELPER, &[helped]).unwrap()).unwrap(); std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &[restricted]).unwrap(), 0x0002).unwrap()).unwrap(); let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap(); let text = format!("N{helped}{restricted}"); let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); s.execute("type.char", &json!({"fontFamily": NAMED, "fontStyle": "Regular"})).unwrap(); let src = dir.join("src").join("Fallback.designcraft"); s.execute("file.save", &json!({"path": src.to_string_lossy()})).unwrap(); let mut s = Session::new(); assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 3); let out = dir.join("Pkg"); let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); assert!(packed_fonts.join("named.ttf").exists()); assert!(packed_fonts.join("helper.ttf").exists(), "the fallback font that draws text is copied"); assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps a fallback font out too"); let report = r["report"].as_str().unwrap(); let fallback = report.split("Fallback fonts").nth(1).unwrap_or_else(|| panic!("{report}")); assert!(fallback.contains(&format!("{HELPER} Regular\n")), "{report}"); assert!(fallback.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}"); assert!(!fallback.contains(NAMED), "{report}"); // The packaged document draws the same text with its fonts. let doc = r["files"][0].as_str().unwrap().to_string(); assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 2); let _ = std::fs::remove_dir_all(&dir); } #[test] fn package_uses_portable_names_inside_the_selected_directory() { assert_eq!(safe_component("Archive/Annual: Review", "Untitled"), "Annual_ Review"); assert_eq!(safe_component(".", "Untitled"), "Untitled"); assert_eq!(safe_component("CON", "Untitled"), "_CON"); assert_eq!(safe_component("COM¹.txt", "Untitled"), "_COM¹.txt"); assert_eq!(safe_component("LPT³", "Untitled"), "_LPT³"); for name in ["COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"] { assert!(is_reserved_windows_name(name), "{name}"); } let mut taken = Vec::new(); assert_eq!(unique_name("Logo.svg".into(), &mut taken), "Logo.svg"); assert_eq!(unique_name("logo.svg".into(), &mut taken), "logo 2.svg"); assert_eq!(unique_name("Résumé.pdf".into(), &mut taken), "Résumé.pdf"); assert_eq!(unique_name("Re\u{301}sume\u{301}.pdf".into(), &mut taken), "Re\u{301}sume\u{301} 2.pdf"); assert_eq!(unique_name("ΟΣ.txt".into(), &mut taken), "ΟΣ.txt"); assert_eq!(unique_name("ος.txt".into(), &mut taken), "ος 2.txt"); let dir = std::env::temp_dir().join(format!("dc-package-safe-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let mut s = Session::new(); s.execute("file.new", &json!({"title": "Archive/Annual: Review"})).unwrap(); let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); let files = r["files"].as_array().unwrap(); assert!(files.iter().all(|path| Path::new(path.as_str().unwrap()).starts_with(&dir))); assert!(dir.join("Annual_ Review.designcraft").is_file()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn package_overwrites_existing_regular_outputs() { let dir = std::env::temp_dir().join(format!("dc-package-overwrite-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let document = dir.join("Annual.designcraft"); std::fs::write(&document, b"previous package").unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&document, std::fs::Permissions::from_mode(0o640)).unwrap(); } let mut s = Session::new(); s.execute("file.new", &json!({"title": "Annual"})).unwrap(); s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); assert!(super::super::from_bytes(&std::fs::read(&document).unwrap()).is_ok()); assert!(!dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER).exists()); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert_eq!(std::fs::metadata(&document).unwrap().permissions().mode() & 0o777, 0o640); } let _ = std::fs::remove_dir_all(&dir); } #[test] fn package_accepts_a_relative_destination() { let dir = PathBuf::from(format!(".dc-package-relative-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let mut s = Session::new(); s.execute("file.new", &json!({"title": "Relative"})).unwrap(); s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); assert!(dir.join("Relative.designcraft").is_file()); let _ = std::fs::remove_dir_all(&dir); } #[cfg(unix)] #[test] fn package_refuses_redirected_destinations() { use std::os::unix::fs::symlink; let base = std::env::temp_dir().join(format!("dc-package-redirects-{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); let outside = base.join("separate"); std::fs::create_dir_all(&outside).unwrap(); let mut s = Session::new(); s.execute("file.new", &json!({"title": "Annual"})).unwrap(); let redirected_root = base.join("redirected-package"); symlink(&outside, &redirected_root).unwrap(); assert!(s.execute("file.package", &json!({"dir": redirected_root.to_string_lossy(), "idml": false})).is_err()); assert!(!outside.join("Annual.designcraft").exists()); std::fs::remove_file(&redirected_root).unwrap(); // A linked folder above the package is followed, as `/tmp` is on macOS. let linked_parent = base.join("linked-parent"); symlink(&outside, &linked_parent).unwrap(); let nested_package = linked_parent.join("nested").join("Package"); s.execute("file.package", &json!({"dir": nested_package.to_string_lossy(), "idml": false})).unwrap(); assert!(outside.join("nested").join("Package").join("Annual.designcraft").is_file()); std::fs::remove_file(&linked_parent).unwrap(); let package = base.join("Package"); std::fs::create_dir_all(&package).unwrap(); symlink(&outside, package.join("Links")).unwrap(); assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); assert!(!outside.join("Annual.designcraft").exists()); std::fs::remove_file(package.join("Links")).unwrap(); std::fs::create_dir(package.join("Links")).unwrap(); let kept = outside.join("kept.designcraft"); std::fs::write(&kept, b"kept").unwrap(); symlink(&kept, package.join("Annual.designcraft")).unwrap(); assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); assert_eq!(std::fs::read(&kept).unwrap(), b"kept"); let _ = std::fs::remove_dir_all(&base); } #[cfg(windows)] #[test] fn package_refuses_windows_directory_redirects() { use std::os::windows::fs::symlink_dir; let base = std::env::temp_dir().join(format!("dc-package-windows-redirect-{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); let outside = base.join("separate"); std::fs::create_dir_all(&outside).unwrap(); let redirected = base.join("Package"); match symlink_dir(&outside, &redirected) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { let _ = std::fs::remove_dir_all(&base); return; } Err(e) => panic!("could not create directory redirect: {e}"), } let mut s = Session::new(); s.execute("file.new", &json!({"title": "Annual"})).unwrap(); assert!(s.execute("file.package", &json!({"dir": redirected.to_string_lossy(), "idml": false})).is_err()); assert!(!outside.join("Annual.designcraft").exists()); std::fs::remove_dir(&redirected).unwrap(); let _ = std::fs::remove_dir_all(&base); } }