Files
designcraft/crates/engine/src/cmd/package/tests.rs
T
aiongg 9c66115fce DES-10: linked graphics from IDML, packages and file checks agree with T6 linking
IDML imports link the files they read through links::link_to_file, the
path File > Place takes, so a file read just after it was written keeps
an unverified fingerprint and its status comes from the hash instead of
reading as missing. File > Package and Links > Copy Links To point a
linked graphic's record (paths, unverified fingerprint) at its copy.
Regular-file metadata goes through io::file_metadata, which the file
guard accepts.
2026-11-14 11:56:59 +00:00

699 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
#[cfg(not(target_arch = "wasm32"))]
fn soft_effect_link_rects(bytes: &[u8]) -> Vec<Vec<f32>> {
use hayro_syntax::object::{Array, Name};
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
pdf.objects()
.into_iter()
.filter_map(|o| o.into_dict())
.filter(|d| d.get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Link"))
.map(|d| d.get::<Array<'_>>(b"Rect").unwrap().iter::<f32>().collect())
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
fn check_soft_effect_links(packaged: bool) {
for (text, grouped) in [(false, false), (true, false), (false, true), (true, true)] {
let dir = tempfile::tempdir().unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"title": "LinkedEffects", "width": 144, "height": 144})).unwrap();
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 100, 60], "content": "text", "text": "Visit us", "caret": false})).unwrap();
if text {
s.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 5})).unwrap();
s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap();
} else {
s.execute("hyperlink.create", &json!({"url": "https://example.com", "ids": [frame["id"]]})).unwrap();
}
let effect = if grouped {
let other = s.execute("frame.create", &json!({"rect": [100, 80, 110, 90]})).unwrap();
let group = s.execute("object.group", &json!({"ids": [frame["id"], other["id"]]})).unwrap()["id"].clone();
s.execute("transform.move", &json!({"ids": [group], "dx": 3, "dy": 4})).unwrap();
group
} else {
frame["id"].clone()
};
let before = s.execute("file.exportPdf", &json!({})).unwrap();
let before = soft_effect_link_rects(&super::super::base64_decode(before["base64"].as_str().unwrap()));
assert_eq!(before.len(), 1);
if !text {
assert_eq!(before[0], if grouped { vec![23.0, 80.0, 103.0, 120.0] } else { vec![20.0, 84.0, 100.0, 124.0] });
}
s.execute("object.dropShadow", &json!({"on": true, "size": 6, "distance": 5, "ids": [effect]})).unwrap();
let bytes = if packaged {
s.execute("file.package", &json!({"dir": dir.path(), "pdf": true, "idml": false})).unwrap();
std::fs::read(dir.path().join("LinkedEffects.pdf")).unwrap()
} else {
let result = s.execute("file.exportPdf", &json!({})).unwrap();
super::super::base64_decode(result["base64"].as_str().unwrap())
};
assert_eq!(soft_effect_link_rects(&bytes), before, "text={text}, grouped={grouped}");
assert_eq!(String::from_utf8_lossy(&bytes).matches("/Subtype/Image").count(), 2, "one image and its soft mask");
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn export_pdf_preserves_soft_effect_link_bounds() {
check_soft_effect_links(false);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn package_pdf_preserves_soft_effect_link_bounds() {
check_soft_effect_links(true);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn package_pdf_preserves_soft_effects_and_reports_warnings() {
let dir = tempfile::tempdir().unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"title": "SoftEffects", "width": 144, "height": 144})).unwrap();
let shadow = s.execute("frame.create", &json!({"rect": [20, 20, 60, 60]})).unwrap()["id"].clone();
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [shadow]})).unwrap();
s.execute("object.dropShadow", &json!({"on": true, "size": 6, "distance": 5, "ids": [shadow]})).unwrap();
let feather = s.execute("frame.create", &json!({"rect": [80, 80, 120, 120], "shape": "ellipse"})).unwrap()["id"].clone();
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [feather]})).unwrap();
s.execute("object.feather", &json!({"width": 6, "ids": [feather]})).unwrap();
let exported = s.execute("file.exportPdf", &json!({})).unwrap();
let expected = super::super::base64_decode(exported["base64"].as_str().unwrap());
let packaged = s.execute("file.package", &json!({"dir": dir.path(), "pdf": true, "idml": false})).unwrap();
let actual = std::fs::read(dir.path().join("SoftEffects.pdf")).unwrap();
let images = |bytes: &[u8]| String::from_utf8_lossy(bytes).matches("/Subtype/Image").count();
assert_eq!(images(&expected), 4, "two images and their soft masks");
assert_eq!(images(&actual), images(&expected), "package must rasterise the same objects as export");
assert_eq!(packaged["warnings"], exported["warnings"]);
let warnings = packaged["warnings"].as_array().unwrap();
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("2 object(s) with soft effects")));
let report = std::fs::read_to_string(dir.path().join("Instructions.txt")).unwrap();
assert_eq!(packaged["report"], report);
for warning in warnings {
assert!(report.contains(warning.as_str().unwrap()), "{report}");
}
}
#[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();
// The IDML copy links Links/owned.png, so its graphic opens as a link to that file.
assert_eq!(asset.data.load().ok().as_deref(), Some(&png), "{extension}: the packaged 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();
}
/// `pdf` takes file.exportPdf's options (a PDF preset's): a PDF/X-4 package PDF; `true` is the
/// default PDF, and bad options are refused.
#[test]
fn package_pdf_takes_export_options() {
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-pdf-options-{}-{nonce}", std::process::id()));
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Options", "width": 72, "height": 72})).unwrap();
let x4 = dir.join("x4");
s.execute("file.package", &json!({"dir": x4.to_string_lossy(), "idml": false, "pdf": {"standard": "x4"}})).unwrap();
let has = |path: &std::path::Path, what: &[u8]| std::fs::read(path).unwrap().windows(what.len()).any(|w| w == what);
assert!(has(&x4.join("Options.pdf"), b"GTS_PDFX"));
let plain = dir.join("plain");
s.execute("file.package", &json!({"dir": plain.to_string_lossy(), "idml": false, "pdf": true})).unwrap();
assert!(!has(&plain.join("Options.pdf"), b"GTS_PDFX"));
// Refused before anything is written: bad options, a bad flattener or view option, and a
// `pdf` that is neither a switch nor options.
for bad in [
json!({"standard": "nope"}),
json!({"flatten": "extreme"}),
json!({"pageLayout": "spiral"}),
json!({"view": "zoomed"}),
json!("print"),
json!(3),
] {
let out = dir.join("bad");
assert!(s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": bad})).is_err(), "{bad}");
assert!(!out.exists(), "{bad}: bad options write nothing");
}
std::fs::remove_dir_all(dir).unwrap();
}
/// A preset that flattens transparency (Screen and Email's `flatten: low`) gives the packaged
/// PDF the flattening `file.exportPdf` does.
#[test]
fn the_packaged_pdf_is_flattened_as_export_flattens_it() {
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-pdf-flatten-{}-{nonce}", std::process::id()));
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Flat", "width": 144, "height": 144})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "unassigned"})).unwrap()["id"].clone();
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap();
let exported = super::super::base64_decode(s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap()["base64"].as_str().unwrap());
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false, "pdf": {"flatten": "low"}})).unwrap();
let packaged = std::fs::read(dir.join("Flat.pdf")).unwrap();
let images = |pdf: &[u8]| pdf.windows(b"/Subtype/Image".len()).filter(|w| *w == b"/Subtype/Image").count();
assert!(images(&exported) > 0, "the export rasterises the transparent object");
assert_eq!(images(&packaged), images(&exported), "so does the package");
std::fs::remove_dir_all(dir).unwrap();
}
/// A per-placement PDF layer choice cannot travel in an IDML copy that links its files, so the
/// report says the copies differ instead of leaving it to be discovered.
#[test]
fn package_report_names_placed_pdf_layer_choices() {
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-pdf-layers-{}-{nonce}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let pdf = super::super::pdflayers::tests::two_layer_pdf();
let mut s = Session::new();
s.execute("file.new", &json!({"title": "LayeredPackage", "width": 100, "height": 100})).unwrap();
let id = s.execute("file.place", &json!({"base64": super::super::base64_encode(&pdf), "name": "art.pdf", "x": 0, "y": 0, "width": 100})).unwrap()
["id"]
.clone();
s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap();
let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap();
let report = r["report"].as_str().unwrap();
assert!(report.contains("Placed PDF layers"), "{report}");
// The DesignCraft copy keeps the choice; only the linked IDML copy shows file defaults.
let packed = super::super::from_bytes(&std::fs::read(dir.join("LayeredPackage.designcraft")).unwrap()).unwrap();
let item = packed.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap();
assert_eq!(item.pdf_hidden_layers, vec!["Red".to_string()]);
assert!(item.has_pdf_layer_override());
std::fs::remove_dir_all(dir).unwrap();
}
/// The package's IDML links its images to the files in the package's Links folder and embeds
/// no copies of them.
#[test]
fn package_idml_links_the_links_folder_and_embeds_nothing() {
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-idml-links-{}-{nonce}", std::process::id()));
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": "LinkedPackage", "width": 72, "height": 72})).unwrap();
s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "my photo.png", "x": 5, "y": 5, "width": 16})).unwrap();
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap();
let idml = std::fs::read(dir.join("LinkedPackage.idml")).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(idml.as_slice())).unwrap();
let mut spreads = String::new();
for i in 0..zip.len() {
let mut f = zip.by_index(i).unwrap();
if f.name().starts_with("Spreads/") {
std::io::Read::read_to_string(&mut f, &mut spreads).unwrap();
}
}
// Relative to the package: no path from the machine that made it (#340).
let uri = "file:Links/my%20photo.png";
let folder = dir.file_name().unwrap().to_string_lossy().into_owned();
assert!(!spreads.contains(&folder), "the package's own folder is not written: {spreads}");
assert!(spreads.contains(&format!(r#"LinkResourceURI="{uri}""#)), "{spreads}");
assert!(!spreads.contains("<Contents") && !spreads.contains(r#"StoredState="Embedded""#), "no image is embedded");
let back = designcraft_idml::import_idml_with(&idml, &|_| None).unwrap();
let asset = back.assets.values().next().unwrap();
assert!(asset.data.is_empty(), "no image is embedded");
assert_eq!(asset.link.as_deref(), Some("Links/my photo.png"));
assert!(dir.join("Links").join("my photo.png").exists());
}
/// A link whose file is gone has no bytes to copy: the package leaves it out of Links/, the
/// 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##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="40" height="40"/></svg>"##;
let (gone, kept) = (dir.join("gone.svg"), dir.join("kept.svg"));
std::fs::write(&gone, svg).unwrap();
std::fs::write(&kept, svg.replace("<rect", "<circle r=\"9\"/><rect")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"title": "TwoLinks"})).unwrap();
s.execute("file.place", &json!({"path": gone.to_string_lossy(), "x": 10, "y": 10})).unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
s.execute("file.place", &json!({"path": kept.to_string_lossy(), "x": 10, "y": 200})).unwrap();
let idml = dir.join("TwoLinks.idml");
s.execute("file.exportIdml", &json!({"path": idml.to_string_lossy(), "embedImages": false})).unwrap();
std::fs::remove_file(&gone).unwrap();
let mut s = Session::new();
s.execute("file.openIdml", &json!({"path": idml.to_string_lossy()})).unwrap();
let status = |s: &mut Session, name: &str| {
let links = s.execute("links.list", &json!({})).unwrap();
links.as_array().unwrap().iter().find(|l| l["name"] == name).map(|l| l["status"].clone())
};
assert_eq!(status(&mut s, "gone.svg"), Some(json!("missing")));
let out = dir.join("Pkg");
let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": true})).unwrap();
assert!(!out.join("Links").join("gone.svg").exists(), "a missing link is not copied");
assert!(out.join("Links").join("kept.svg").exists());
assert!(!r["files"].to_string().contains("gone.svg"), "{}", r["files"]);
let report = r["report"].as_str().unwrap();
assert!(report.contains("gone.svg (missing)"), "{report}");
for (command, extension) in [("file.open", "designcraft"), ("file.openIdml", "idml")] {
let mut opened = Session::new();
opened.execute(command, &json!({"path": out.join(format!("TwoLinks.{extension}")).to_string_lossy()})).unwrap();
// The open checks linked files in the background.
super::super::native_links::wait(&mut opened);
assert_eq!(status(&mut opened, "gone.svg"), Some(json!("missing")), "{extension}");
assert_eq!(status(&mut opened, "kept.svg"), Some(json!("ok")), "{extension}");
}
std::fs::remove_dir_all(dir).unwrap();
}
/// A package holds no path from the machine that made it: the document and the IDML link the
/// copies in `Links/` by relative names, and they still open as linked after the move (#340).
#[test]
fn package_documents_hold_relative_links_only() {
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("dc-package-relative-{}-{nonce}", std::process::id()));
let folder = dir.file_name().unwrap().to_string_lossy().into_owned();
let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png();
let mut s = Session::new();
s.execute("file.new", &json!({"title": "RelativePackage", "width": 72, "height": 72})).unwrap();
s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "owned.png", "x": 5, "y": 5, "width": 16})).unwrap();
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap();
let packed = super::super::from_bytes(&std::fs::read(dir.join("RelativePackage.designcraft")).unwrap()).unwrap();
let link = packed.assets.values().next().unwrap().link.clone().unwrap();
assert_eq!(link, "Links/owned.png");
assert!(!link.contains(&folder));
// Copy Link(s) To is unchanged: it relinks the open document by full path.
s.execute("links.copyTo", &json!({"dir": dir.join("Copies").to_string_lossy()})).unwrap();
let copied = s.doc().unwrap().doc.assets.values().next().unwrap().link.clone().unwrap();
assert!(std::path::Path::new(&copied).is_absolute(), "{copied}");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn package_writes_document_links_and_report() {
let dir = std::env::temp_dir().join(format!("dc-package-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let svg = dir.join("logo.svg");
std::fs::write(&svg, r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="40" height="40"/></svg>"##).unwrap();
// An older file than the copies made of it below.
let earlier = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
std::fs::File::options().write(true).open(&svg).unwrap().set_modified(earlier).unwrap();
let fingerprint = |path: &std::path::Path| crate::cmd::datamerge::fingerprint_of(path);
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();
assert_eq!(s.doc().unwrap().doc.assets.values().next().unwrap().link_fingerprint, fingerprint(&svg));
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<String> = 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.find("Print on matte.") < report.find("Package report:"), "the instructions come first: {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}");
// The link is the copy now: the original file's size and time don't go with it (the copy's
// own are too recent to vouch for it; the first check compares the bytes by their hash and
// keeps them).
let unverified = Some(super::super::links::UNVERIFIED_MTIME);
assert!(fingerprint(&out.join("Links").join("logo.svg")).is_some());
assert_eq!(packed.assets.values().next().unwrap().link_fingerprint.map(|f| f.mtime), unverified);
// 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);
let relinked = s.doc().unwrap().doc.assets.values().next().unwrap().clone();
assert!(relinked.link.as_deref().unwrap().contains("Copies"));
assert_eq!(relinked.link_fingerprint.map(|f| f.mtime), unverified);
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");
// Written without File › Save's preview: drawing it before the document's fonts load would
// pull in a system font that covers these characters (Unifont CSUR does) and draws them
// ahead of the document's own fonts in this process.
std::fs::write(&src, crate::cmd::to_bytes(&s.doc().unwrap().doc).unwrap()).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);
}
/// The package holds the font as the document loaded it: a link put at its path since (to a
/// private file, say) isn't followed, and its permissions are the file's.
#[cfg(unix)]
#[test]
fn packaged_fonts_are_the_loaded_bytes_not_what_is_at_the_path_now() {
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
use designcraft_fonts::testing::{font_with, installed_face};
let dir = std::env::temp_dir().join(format!("dc-package-font-swapped-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let from = dir.join("loaded.ttf");
let bytes = font_with("DocFont Package Swapped", &['S']).unwrap();
std::fs::write(&from, &bytes).unwrap();
let face = installed_face(bytes.clone(), from.clone()).unwrap();
std::fs::remove_file(&from).unwrap();
std::fs::write(dir.join("secret"), b"private").unwrap();
std::os::unix::fs::symlink(dir.join("secret"), &from).unwrap();
let root = OutputRoot::new(dir.clone()).unwrap();
let fonts = dir.join("Pkg").join(DOCUMENT_FONTS_FOLDER);
std::fs::create_dir_all(&fonts).unwrap();
let (mut copied, mut taken, mut files) = (Vec::new(), Taken::default(), Vec::new());
assert_eq!(copy_font(&root, &face, &fonts, &mut copied, &mut taken, &mut files), "");
assert_eq!(std::fs::read(fonts.join("loaded.ttf")).unwrap(), bytes);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn packaging_into_a_fonts_own_folder_neither_overwrites_nor_packages_a_changed_font() {
use designcraft_fonts::testing::{font_with, installed_face};
let dir = std::env::temp_dir().join(format!("dc-package-font-same-file-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let from = dir.join("loaded.ttf");
let bytes = font_with("DocFont Package Same File", &['S']).unwrap();
std::fs::write(&from, &bytes).unwrap();
let face = installed_face(bytes.clone(), from.clone()).unwrap();
let root = OutputRoot::new(dir.clone()).unwrap();
let copy = |taken: &mut Taken| copy_font(&root, &face, &dir, &mut Vec::new(), taken, &mut Vec::new());
// Unchanged since it loaded: already where it is wanted.
assert_eq!(copy(&mut Taken::default()), "");
assert_eq!(std::fs::read(&from).unwrap(), bytes);
// Changed on disk since: the package can't hold the loaded font there, and the new file is kept.
let newer = font_with("DocFont Package Same File", &['S', 'T']).unwrap();
std::fs::write(&from, &newer).unwrap();
let note = copy(&mut Taken::default());
assert!(note.contains("not copied") && note.contains("changed on disk"), "{note}");
assert_eq!(std::fs::read(&from).unwrap(), newer);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn packaged_fonts_keep_a_font_extension() {
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
use designcraft_fonts::testing::{font_with, installed_face};
// A font service's file without an extension is copied under a name the reopened
// package loads.
let dir = std::env::temp_dir().join(format!("dc-package-font-ext-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let from = dir.join(".12345");
let bytes = font_with("DocFont Package Extensionless", &['E']).unwrap();
std::fs::write(&from, &bytes).unwrap();
let face = installed_face(bytes, from).unwrap();
let root = OutputRoot::new(dir.clone()).unwrap();
let fonts = dir.join("Pkg").join(DOCUMENT_FONTS_FOLDER);
std::fs::create_dir_all(&fonts).unwrap();
let (mut copied, mut taken, mut files) = (Vec::new(), Taken::default(), Vec::new());
assert_eq!(copy_font(&root, &face, &fonts, &mut copied, &mut taken, &mut files), "");
assert_eq!(files, [fonts.join("12345.ttf")]);
let db = designcraft_fonts::FontDb::global();
let loaded = db.load_document_fonts(&fonts, &|_| Ok(()));
assert_eq!((loaded.faces, loaded.skipped.len()), (1, 0), "{:?}", loaded.skipped);
db.close_scope(loaded.scope);
// The first four bytes name the kind; a font extension or an unknown kind stays.
assert_eq!(font_file_name("Face".into(), b"OTTO...."), "Face.otf");
assert_eq!(font_file_name("Faces".into(), b"ttcf...."), "Faces.ttc");
assert_eq!(font_file_name("Mac".into(), b"true...."), "Mac.ttf");
assert_eq!(font_file_name("..".into(), &[0, 1, 0, 0]), "font.ttf");
assert_eq!(font_file_name("Face.OTF".into(), b"ttcf"), "Face.OTF");
assert_eq!(font_file_name("Face.dfont".into(), b"\0\0\x01\0"), "Face.dfont");
assert_eq!(font_file_name("short".into(), b"OT"), "short");
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 = Taken::default();
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");
// Many files of one name are numbered in order, each without trying every earlier number.
let mut taken = Taken::default();
for k in 1..=2000 {
let expected = if k == 1 { "logo.png".to_string() } else { format!("logo {k}.png") };
assert_eq!(unique_name("logo.png".into(), &mut taken), expected);
}
assert_eq!(unique_name("logo 7.png".into(), &mut taken), "logo 7 2.png", "a name already given out is still numbered");
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");
// A symbolic link needs Developer Mode or elevation (os error 1314 without them); a
// directory junction needs neither, and `is_redirect` treats both as redirects.
if symlink_dir(&outside, &redirected).is_err() && !make_junction(&outside, &redirected) {
eprintln!("skipped: directory redirects can't be made here");
let _ = std::fs::remove_dir_all(&base);
return;
}
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);
}
/// Makes `link` a directory junction to `target` with `mklink /J`, and reports whether it was made.
#[cfg(windows)]
fn make_junction(target: &Path, link: &Path) -> bool {
use std::os::windows::process::CommandExt;
// Windows paths can't hold `"`, so quoting each one keeps `&` and spaces in it literal for cmd.
std::process::Command::new("cmd")
.args(["/D", "/C"])
.raw_arg(format!("mklink /J \"{}\" \"{}\"", link.display(), target.display()))
.output()
.is_ok_and(|output| output.status.success())
}