Files
vectorcraft/crates/engine/src/tests/dcs.rs
T
dotCooCoo 99f896f9b8 Place: DCS links follow their plate files, clips and document swatches
- The link watch and links.updateChanged look at a DCS file's plate files
  of their own, so a changed plate file is read again.
- links.update and links.relink apply the file's clipping path to the
  images in symbols, graph designs, patterns and masks too. When the new
  file has no clipping path, a clip group holding the old file's clipping
  path loses that path and stays as a plain group of the image, keeping
  its id, name, visibility and transparency. The image keeps that path with its plates to tell it from a
  clipping mask made in the document; a clipping path given another fill
  rule in the document stays. An even-odd clipping path in the file is
  kept as the same area under the nonzero rule.
- links.update and links.relink return the swatch warnings file.place
  gives, including two files that add one ink in two colors.
- links.relink with copy fails for a DCS file with plate files of its own.
- file.place answers the image id of a clipped DCS placement as image,
  and checks page and crop for a DCS file as it does for an EPS file.
- Print separations and Separations Preview take a spot plate's color
  from the document's swatch of its ink, and a reduced image's plates
  keep their coverage.
- Show DCS Transparency Interactions counts a transparent DCS image and
  characters filled or stroked with a transparent gradient.
- %%PlateFile names keep their spaces, and header bytes that are not
  UTF-8 are read as Latin-1.
- A plate program that draws more than one image, art besides its image,
  or its image through a clipping path is an error naming the plate
  instead of printing the wrong ink.
- Nested clipping paths around the composite clip the image together.
- A kept image with more plates than are read, or more TIFF directories
  than its composite and plates, is the CMYK image of its composite
  only, not a set of plates cut short.
- swatch.unused counts a spot or process swatch as used only while an
  image the art shows has a spot plate of its name.
2026-11-12 18:43:04 -08:00

698 lines
37 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.
//! DCS files placed with Link (`file.place`): one linked image of their plates, their spot plates
//! as spot swatches, clipped by their composite's clipping path, never opened or embedded; and
//! the Links panel's Show DCS Transparency Interactions (`links.list` with `dcsTransparency`).
//! The files are made from scratch as http://www.telegraphics.net/doc/DCS.txt describes them.
use std::path::PathBuf;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Node, NodeKind};
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
/// A fresh scratch folder for one test.
fn temp_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vc-dcs-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A plate program: one 2 × 2 grayscale image (`gray`, row by row) on a 20 × 10 pt page.
fn plate_eps(gray: [u8; 4]) -> String {
let hex: String = gray.iter().map(|g| format!("{g:02x}")).collect();
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n20 10 scale\n2 2 8 [2 0 0 -2 0 2] <{hex}> image\nshowpage\n%%Trailer\n%%EOF\n"
)
}
/// A single-file DCS 2.0 file of `plates` (name, plate program), with the header lines `extra`; its
/// composite is drawn through a clipping path, the left half of the page.
fn single_file(plates: &[(&str, String)], extra: &str) -> Vec<u8> {
let header = |offsets: &[usize]| {
let mut s = String::from("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n");
s.push_str(extra);
for ((name, eps), at) in plates.iter().zip(offsets) {
s.push_str(&format!("%%PlateFile: ({name}) EPS #{at:010} {:010}\n", eps.len()));
}
s.push_str("%%EndComments\ngsave newpath 0 0 moveto 10 0 lineto 10 10 lineto 0 10 lineto closepath clip newpath\n");
s.push_str("20 10 scale 2 2 8 [2 0 0 -2 0 2] <808080808080808080808080> false 3 colorimage grestore showpage\n%%EOF\n");
s
};
let main = header(&vec![0; plates.len()]).len();
let mut offsets = vec![];
let mut at = main;
for (_, eps) in plates {
offsets.push(at);
at += eps.len();
}
let mut out = header(&offsets).into_bytes();
for (_, eps) in plates {
out.extend(eps.as_bytes());
}
out
}
/// Cyan solid on the top row, PANTONE 185 C at 50% on the left column.
fn two_plates() -> Vec<u8> {
single_file(
&[("Cyan", plate_eps([0, 0, 255, 255])), ("PANTONE 185 C", plate_eps([127, 255, 127, 255]))],
"%%DocumentCustomColors: (PANTONE 185 C)\n%%CMYKCustomColor: 0 .91 .76 0 (PANTONE 185 C)\n",
)
}
fn write(dir: &std::path::Path, name: &str, bytes: &[u8]) -> String {
let p = dir.join(name);
std::fs::write(&p, bytes).unwrap();
p.to_string_lossy().into_owned()
}
/// The image inside placed object `id` (itself, or the image of its clipping group).
fn image_of(s: &Session, id: &Value) -> Node {
let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone();
let mut found = None;
n.walk(&mut |c: &Node| {
if found.is_none() && matches!(c.kind, NodeKind::Image(_)) {
found = Some(c.clone());
}
});
found.expect("an image")
}
fn error(s: &mut Session, cmd: &str, p: Value) -> String {
s.execute(cmd, &p).unwrap_err().to_string()
}
#[test]
fn a_dcs_file_places_as_one_linked_image_of_its_plates() {
let dir = temp_dir("place");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
assert_eq!(r["linked"], true);
assert_eq!(r["warnings"], json!([]));
let id = &r["ids"][0];
let placed = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone();
let NodeKind::Group { children, clip: true } = &placed.kind else { panic!("a clipping group: {:?}", placed.kind) };
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }), "the composite's clipping path");
assert_eq!(children[0].geometric_bounds().map(|b| b.width().round()), Some(10.0));
assert_eq!(r["width"].as_f64().map(f64::round), Some(10.0), "clipped to the left half of its 20 pt page");
let image = image_of(&s, id);
let NodeKind::Image(im) = &image.kind else { not_an_image() };
assert!(im.link.as_ref().is_some_and(|l| l.path.ends_with("pic.eps")));
let doc = &s.doc().unwrap().doc;
let plates = doc.images[&im.key].separated().expect("its plates are kept");
assert_eq!(plates.plates.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Cyan", "PANTONE 185 C"]);
assert_eq!(plates.plates[1].data, [128, 0, 128, 0]);
let sw = doc.swatch("PANTONE 185 C").expect("a spot swatch for the spot plate");
assert!(sw.spot);
assert_eq!(sw.paint, Paint::solid(Color::cmyk(0.0, 0.91, 0.76, 0.0)));
assert!(!doc.swatches_iter().any(|w| w.spot && w.name == "Cyan"), "process plates add no spot swatch");
let list = s.execute("links.list", &json!({})).unwrap();
assert_eq!(list["links"][0]["format"], "DCS");
assert_eq!(list["links"][0]["status"], "ok");
let info = s.execute("file.place.info", &json!({ "path": path })).unwrap();
assert_eq!(info["plates"], json!(["Cyan", "PANTONE 185 C"]));
assert_eq!(info["colorMode"], "CMYK");
assert_eq!(info["pixelWidth"], 2);
// One undo step takes the swatch away too.
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.swatch("PANTONE 185 C").is_none());
}
fn not_an_image() -> ! {
panic!("an image")
}
#[test]
fn a_dcs_file_is_never_opened_or_embedded() {
let dir = temp_dir("refused");
let bytes = two_plates();
let path = write(&dir, "pic.eps", &bytes);
let mut s = session();
let only = "DCS files can only be placed linked";
assert!(error(&mut s, "file.place", json!({ "path": path, "link": false })).contains(only));
let data = json!({ "name": "pic.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes) });
assert!(error(&mut s, "file.place", data).contains(only), "bytes alone can't be linked");
assert!(error(&mut s, "document.open", json!({ "path": path })).contains(only));
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
let image = image_of(&s, &r["ids"][0]);
let e = error(&mut s, "links.embed", json!({ "ids": [image.id.0] }));
assert!(e.contains(only), "{e}");
let NodeKind::Image(im) = &image_of(&s, &r["ids"][0]).kind else { not_an_image() };
assert!(im.link.is_some(), "still linked");
}
#[test]
fn a_swatch_of_the_same_name_is_kept_with_a_warning() {
let dir = temp_dir("swatch");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
s.execute("swatch.new", &json!({"name": "PANTONE 185 C", "color": {"c": 0.0, "m": 1.0, "y": 1.0, "k": 0.0}, "spot": true})).unwrap();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
assert_eq!(r["warnings"], json!(["the document's swatch `PANTONE 185 C` is kept: it is a different color"]));
let sw = s.doc().unwrap().doc.swatch("PANTONE 185 C").unwrap().clone();
assert_eq!(sw.paint.color(), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0)));
}
#[test]
fn plate_files_of_their_own_are_read_from_the_dcs_files_folder() {
let dir = temp_dir("files");
write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes());
write(&dir, "pic.K", plate_eps([255, 255, 255, 0]).as_bytes());
let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: OldDisk:Scans:pic.C\n%%BlackPlate: pic.K\n%%EndComments\nshowpage\n";
let path = write(&dir, "pic.eps", main.as_bytes());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
let image = image_of(&s, &r["ids"][0]);
assert!(matches!(image.kind, NodeKind::Image(_)), "no clipping path without a composite");
let NodeKind::Image(im) = &image.kind else { not_an_image() };
let plates = s.doc().unwrap().doc.images[&im.key].separated().unwrap();
assert_eq!(plates.plates[0].data, [255, 0, 0, 0], "the plate file found by its name in the folder");
assert_eq!(plates.plates[1].data, [0, 0, 0, 255]);
std::fs::remove_file(dir.join("pic.K")).unwrap();
let e = error(&mut s, "file.place", json!({ "path": path }));
assert!(e.contains("the Black plate can't be read") && e.contains("`pic.K` is not in the DCS file's folder"), "{e}");
}
#[test]
fn update_link_reads_the_plates_again() {
let dir = temp_dir("update");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
let id = image_of(&s, &r["ids"][0]).id;
let changed = single_file(&[("Cyan", plate_eps([0, 0, 0, 0])), ("Varnish", plate_eps([255; 4]))], "%%CMYKCustomColor: 0 0 .2 0 (Varnish)\n");
std::fs::write(&path, &changed).unwrap();
let u = s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
assert_eq!(u["updated"], json!([id.0]));
let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() };
let plates = s.doc().unwrap().doc.images[&im.key].separated().unwrap();
assert_eq!(plates.plates.len(), 2);
assert_eq!(plates.plates[0].data, [255; 4]);
let sw = s.doc().unwrap().doc.swatch("Varnish").expect("a spot swatch for the plate the file gained");
assert!(sw.spot);
assert_eq!(sw.paint, Paint::solid(Color::cmyk(0.0, 0.0, 0.2, 0.0)));
}
#[test]
fn update_link_takes_the_files_new_clipping_path() {
let dir = temp_dir("update-clip");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
let group = NodeId(r["ids"][0].as_u64().unwrap());
let id = image_of(&s, &r["ids"][0]).id;
let clip_width = |s: &Session| {
let NodeKind::Group { children, .. } = &s.doc().unwrap().doc.node(group).unwrap().kind else { panic!("a clipping group") };
children[0].geometric_bounds().map(|b| b.width())
};
let before = clip_width(&s).unwrap();
// The same file, its composite now drawn through the left quarter of the page (same length,
// so the plates stay where the header says).
let text = String::from_utf8(two_plates()).unwrap().replacen("10 0 lineto 10 10 lineto", " 5 0 lineto 5 10 lineto", 1);
std::fs::write(&path, text.as_bytes()).unwrap();
let u = s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
assert_eq!(u["updated"], json!([id.0]));
let after = clip_width(&s).unwrap();
assert!((after - before / 2.0).abs() < 1e-6, "the clipping path follows the file: {before} → {after}");
}
#[test]
fn update_link_clips_an_image_whose_file_gained_a_clipping_path() {
let dir = temp_dir("update-gained-clip");
// The composite drawn without its clipping path (same length).
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
let path = write(&dir, "pic.eps", unclipped.as_bytes());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
let id = NodeId(r["ids"][0].as_u64().unwrap());
assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::Image(_)), "no clipping path, no group");
std::fs::write(&path, two_plates()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
let group = doc.node(doc.parent_of(id).expect("a parent")).unwrap();
let NodeKind::Group { children, clip: true } = &group.kind else { panic!("a clipping group: {:?}", group.kind) };
assert_eq!(children[0].geometric_bounds().map(|b| b.width().round()), Some(10.0), "the left half of the page");
}
#[test]
fn a_changed_plate_file_marks_the_link_modified() {
let dir = temp_dir("plate-changed");
write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes());
let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%EndComments\nshowpage\n";
let path = write(&dir, "pic.eps", main.as_bytes());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
let id = image_of(&s, &r["ids"][0]).id;
assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "ok");
// Only the plate file changes (a size of its own, so the change shows whatever the clock).
write(&dir, "pic.C", format!("{}% more\n", plate_eps([255, 255, 255, 0])).as_bytes());
assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "modified");
let u = s.execute("links.update", &json!({})).unwrap();
assert_eq!(u["updated"], json!([id.0]));
let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() };
assert_eq!(s.doc().unwrap().doc.images[&im.key].separated().unwrap().plates[0].data, [0, 0, 0, 255]);
assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "ok");
std::fs::remove_file(dir.join("pic.C")).unwrap();
assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "modified", "a plate file that is gone");
}
#[test]
fn the_link_watch_reads_the_link_again_when_only_a_plate_file_changes() {
let dir = temp_dir("plate-watch");
write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes());
let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%EndComments\nshowpage\n";
let path = write(&dir, "pic.eps", main.as_bytes());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
let id = image_of(&s, &r["ids"][0]).id;
let files: Vec<String> = crate::link_watch::linked_files(&s.doc().unwrap().doc).into_iter().map(|(p, _)| p).collect();
assert!(files.iter().any(|p| p.ends_with("pic.C")), "the plate file is watched too: {files:?}");
// The first look only remembers the files.
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
write(&dir, "pic.C", format!("{}% more\n", plate_eps([255, 255, 255, 0])).as_bytes());
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([id.0]));
let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() };
assert_eq!(s.doc().unwrap().doc.images[&im.key].separated().unwrap().plates[0].data, [0, 0, 0, 255]);
}
/// The width of the clipping path of the clip group holding the image in the art of symbol
/// `name`; `None` when no clip group holds it.
fn symbol_clip_width(s: &Session, name: &str) -> Option<f64> {
fn find(n: &Node) -> Option<Option<f64>> {
if let NodeKind::Group { children, clip: true } = &n.kind
&& let [c, im] = &children[..]
&& matches!(im.kind, NodeKind::Image(_))
{
return Some(c.geometric_bounds().map(|b| b.width()));
}
if matches!(n.kind, NodeKind::Image(_)) {
return Some(None);
}
n.children().and_then(|ch| ch.iter().find_map(|c| find(c)))
}
let doc = &s.doc().unwrap().doc;
find(&doc.symbols.iter().find(|w| w.name == name).expect("the symbol").art).expect("an image in the symbol")
}
#[test]
fn update_link_takes_the_files_clipping_path_into_symbols_too() {
let dir = temp_dir("update-symbol-clip");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
let id = image_of(&s, &r["ids"][0]).id;
let other = s.execute("file.place", &json!({ "path": path, "at": [200, 100] })).unwrap();
s.execute("symbol.new", &json!({ "name": "Pic", "ids": [other["ids"][0]] })).unwrap();
let before = symbol_clip_width(&s, "Pic").expect("the file's clipping path");
// The composite now drawn through the left quarter of the page (same length).
let text = String::from_utf8(two_plates()).unwrap().replacen("10 0 lineto 10 10 lineto", " 5 0 lineto 5 10 lineto", 1);
std::fs::write(&path, text.as_bytes()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let after = symbol_clip_width(&s, "Pic").expect("still clipped");
assert!((after - before / 2.0).abs() < 1e-6, "the symbol's clipping path follows the file: {before} → {after}");
// The file without a clipping path (same length): the symbol's image is no longer clipped.
let unclipped = text.replacen("closepath clip newpath", "closepath newpath ", 1);
std::fs::write(&path, unclipped.as_bytes()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
assert_eq!(symbol_clip_width(&s, "Pic"), None);
}
#[test]
fn a_file_that_lost_its_clipping_path_unclips_the_image() {
let dir = temp_dir("update-lost-clip");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
let id = image_of(&s, &r["ids"][0]).id;
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
std::fs::write(&path, unclipped.as_bytes()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
let placed = NodeId(r["ids"][0].as_u64().unwrap());
assert_eq!(doc.node(placed).and_then(plain_group_of_image), Some((false, true)), "the placed group stays, without the clip");
let parent = doc.parent_of(id).expect("a parent");
assert!(!matches!(doc.node(parent).unwrap().kind, NodeKind::Group { clip: true, .. }), "the image is not clipped");
// Relinked to a plain image: no clip group either.
let png = dir.join("plain.png");
image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])).save(&png).unwrap();
std::fs::write(&path, two_plates()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
assert!(
s.doc().unwrap().doc.parent_of(id).is_some_and(|p| matches!(s.doc().unwrap().doc.node(p).unwrap().kind, NodeKind::Group { clip: true, .. }))
);
s.execute("links.relink", &json!({ "ids": [id.0], "path": png.to_string_lossy() })).unwrap();
let doc = &s.doc().unwrap().doc;
assert!(
!matches!(doc.node(doc.parent_of(id).unwrap()).unwrap().kind, NodeKind::Group { clip: true, .. }),
"a plain image isn't clipped by the DCS file's path"
);
}
/// The kind of group `g` is, as (clip, the kinds of its children are just one image).
fn plain_group_of_image(g: &Node) -> Option<(bool, bool)> {
match &g.kind {
NodeKind::Group { children, clip } => Some((*clip, matches!(&children[..], [c] if matches!(c.kind, NodeKind::Image(_))))),
_ => None,
}
}
#[test]
fn a_hidden_or_transparent_clip_group_loses_the_files_old_clipping_path() {
let dir = temp_dir("lost-clip-kept-group");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let place = |s: &mut Session, x: f64| s.execute("file.place", &json!({ "path": path, "at": [x, 100] })).unwrap();
let (faded, hidden, in_symbol) = (place(&mut s, 100.0), place(&mut s, 200.0), place(&mut s, 300.0));
let (faded, hidden) = (NodeId(faded["ids"][0].as_u64().unwrap()), NodeId(hidden["ids"][0].as_u64().unwrap()));
let image = image_of(&s, &json!(faded.0)).id;
s.execute("transparency.set", &json!({"ids": [faded.0, in_symbol["ids"][0]], "opacity": 50})).unwrap();
s.edit("hide", |d, _| {
if let Some(n) = d.node_mut(hidden) {
n.visible = false;
}
Ok(())
})
.unwrap();
s.execute("symbol.new", &json!({ "name": "Pic", "ids": [in_symbol["ids"][0]] })).unwrap();
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
std::fs::write(&path, unclipped.as_bytes()).unwrap();
let hidden_image = image_of(&s, &json!(hidden.0)).id;
s.execute("links.update", &json!({ "ids": [image.0, hidden_image.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
for (g, why) in [(faded, "the transparent group"), (hidden, "the hidden group")] {
let n = doc.node(g).unwrap_or_else(|| panic!("{why} stays"));
assert_eq!(plain_group_of_image(n), Some((false, true)), "{why} holds the image alone, unclipped");
}
assert!((doc.node(faded).unwrap().opacity - 0.5).abs() < 1e-6, "its transparency kept");
assert!(!doc.node(hidden).unwrap().visible, "still hidden");
// In a symbol's art too.
let art = &doc.symbols.iter().find(|w| w.name == "Pic").expect("the symbol").art;
let mut found = None;
art.walk(&mut |n: &Node| {
if found.is_none() && n.children().is_some_and(|ch| ch.iter().any(|c| matches!(c.kind, NodeKind::Image(_)))) {
found = plain_group_of_image(n);
}
});
assert_eq!(found, Some((false, true)), "the symbol's transparent group holds the image alone, unclipped");
}
#[test]
fn a_clipping_mask_of_ones_own_stays_when_the_link_is_updated() {
let dir = temp_dir("own-mask");
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
let path = write(&dir, "pic.eps", unclipped.as_bytes());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
let id = NodeId(r["ids"][0].as_u64().unwrap());
let rect = s.execute("shape.rectangle", &json!({"x": 95, "y": 95, "width": 4, "height": 4})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({ "ids": [id.0, rect] })).unwrap();
let g = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
assert_eq!(s.doc().unwrap().doc.parent_of(id), Some(g), "the clipping mask made in the document stays");
let mask_width = |s: &Session| {
let NodeKind::Group { children, .. } = &s.doc().unwrap().doc.node(g).unwrap().kind else { panic!("the mask's group") };
children[0].geometric_bounds().map(|b| b.width().round())
};
assert_eq!(mask_width(&s), Some(4.0));
// The file gains a clipping path: its clip group goes inside the mask, which keeps its path.
std::fs::write(&path, two_plates()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
let inner = doc.parent_of(id).expect("a parent");
assert_ne!(inner, g);
assert!(matches!(doc.node(inner).unwrap().kind, NodeKind::Group { clip: true, .. }), "the file's clip group");
assert_eq!(doc.parent_of(inner), Some(g));
assert_eq!(mask_width(&s), Some(4.0), "the mask's path is kept");
}
#[test]
fn a_dcs_file_takes_the_place_parameters_an_eps_file_takes() {
let dir = temp_dir("params");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
assert!(error(&mut s, "file.place", json!({ "path": path, "page": 0 })).contains("page"));
assert!(error(&mut s, "file.place", json!({ "path": path, "crop": "trim" })).contains("crop"));
}
#[test]
fn a_clipped_placement_answers_its_image_for_the_links_commands() {
let dir = temp_dir("image-id");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
assert_eq!(r["image"], json!(image_of(&s, &r["ids"][0]).id.0));
let info = s.execute("links.info", &json!({ "id": r["image"] })).unwrap();
assert_eq!(info["format"], "DCS");
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
let plain = write(&dir, "plain.eps", unclipped.as_bytes());
assert!(s.execute("file.place", &json!({ "path": plain })).unwrap().get("image").is_none(), "an image placed alone is its own id");
}
#[test]
fn update_and_relink_warn_of_a_swatch_kept_for_a_new_spot_plate() {
let dir = temp_dir("update-swatch");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
let id = image_of(&s, &r["ids"][0]).id;
s.execute("swatch.new", &json!({"name": "Varnish", "color": {"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap();
let changed = single_file(&[("Cyan", plate_eps([0, 0, 0, 0])), ("Varnish", plate_eps([255; 4]))], "%%CMYKCustomColor: 0 0 .2 0 (Varnish)\n");
std::fs::write(&path, &changed).unwrap();
let u = s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let kept = "the document's swatch `Varnish` is kept: it is a process color, so the plate prints as process inks";
assert_eq!(u["warnings"], json!([kept]));
let other = write(&dir, "other.eps", &changed);
let r = s.execute("links.relink", &json!({ "ids": [id.0], "path": other })).unwrap();
assert_eq!(r["warnings"], json!([kept]));
assert!(s.execute("links.update", &json!({ "ids": [id.0] })).unwrap().get("warnings").is_some(), "every read warns");
}
#[test]
fn a_deleted_dcs_image_leaves_its_spot_swatch_unused() {
let dir = temp_dir("unused");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].as_array().unwrap().iter().any(|n| n == "PANTONE 185 C");
assert!(!unused(&mut s), "in use while the image shows it");
s.execute("select.set", &json!({ "ids": [r["ids"][0]] })).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
assert!(unused(&mut s), "unused once the image is deleted");
}
#[test]
fn a_process_swatch_giving_a_dcs_plate_its_color_is_used() {
let dir = temp_dir("unused-process");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
s.execute("swatch.new", &json!({"name": "PANTONE 185 C", "color": {"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap();
let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].as_array().unwrap().iter().any(|n| n == "PANTONE 185 C");
assert!(unused(&mut s), "nothing uses it yet");
s.execute("file.place", &json!({ "path": path })).unwrap();
assert!(!unused(&mut s), "the plate of its name prints as its process inks");
}
#[test]
fn an_opened_clipping_path_is_not_the_files_and_stays() {
let dir = temp_dir("opened-clip");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
let group = NodeId(r["ids"][0].as_u64().unwrap());
let id = image_of(&s, &r["ids"][0]).id;
// The clipping path opened, its anchors where they were.
s.edit("open", |d, _| {
let clip = d.node(group).and_then(|g| g.children()).map(|ch| ch[0].id).unwrap();
if let Some(Node { kind: NodeKind::Path { path, .. }, .. }) = d.node_mut(clip) {
path.subpaths[0].closed = false;
}
Ok(())
})
.unwrap();
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
std::fs::write(&path, unclipped.as_bytes()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
assert_eq!(s.doc().unwrap().doc.parent_of(id), Some(group), "a clipping path edited in the document stays");
}
#[test]
fn a_clipping_path_given_another_fill_rule_is_not_the_files_and_stays() {
let dir = temp_dir("rule-clip");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
let group = NodeId(r["ids"][0].as_u64().unwrap());
let id = image_of(&s, &r["ids"][0]).id;
// The clipping path's fill rule changed, its anchors where they were.
s.edit("rule", |d, _| {
let clip = d.node(group).and_then(|g| g.children()).map(|ch| ch[0].id).unwrap();
if let Some(Node { kind: NodeKind::Path { rule, .. }, .. }) = d.node_mut(clip) {
*rule = vectorcraft_geom::FillRule::EvenOdd;
}
Ok(())
})
.unwrap();
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
std::fs::write(&path, unclipped.as_bytes()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
assert_eq!(s.doc().unwrap().doc.parent_of(id), Some(group), "a clipping path whose fill rule was changed stays");
}
#[test]
fn an_even_odd_clipping_path_is_placed_and_matched_under_the_nonzero_rule() {
let dir = temp_dir("eo-clip");
// The same length, so the plates' offsets hold.
let eo = String::from_utf8(two_plates()).unwrap().replacen("gsave newpath 0 0 moveto", "gsave 0 0 moveto ", 1);
let eo = eo.replacen("closepath clip newpath", "closepath eoclip newpath", 1);
assert_eq!(eo.len(), two_plates().len());
let path = write(&dir, "pic.eps", eo.as_bytes());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
let group = NodeId(r["ids"][0].as_u64().unwrap());
let id = image_of(&s, &r["ids"][0]).id;
let doc = &s.doc().unwrap().doc;
let clip = &doc.node(group).and_then(|g| g.children()).expect("the file's clip group")[0];
assert!(matches!(clip.kind, NodeKind::Path { rule: vectorcraft_geom::FillRule::NonZero, clipping: true, .. }), "{:?}", clip.kind);
// The file without its clipping path: the group's clipping path is the file's, and goes.
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
std::fs::write(&path, unclipped.as_bytes()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
assert_eq!(s.doc().unwrap().doc.node(group).and_then(plain_group_of_image), Some((false, true)), "the file's clipping path is gone");
}
#[test]
fn files_updated_together_warn_when_they_add_one_ink_in_two_colors() {
let dir = temp_dir("update-two-inks");
let (a, b) = (write(&dir, "a.eps", &two_plates()), write(&dir, "b.eps", &two_plates()));
let mut s = session();
s.execute("file.place", &json!({ "path": a })).unwrap();
s.execute("file.place", &json!({ "path": b })).unwrap();
let varnish =
|c: &str| single_file(&[("Cyan", plate_eps([0; 4])), ("Varnish", plate_eps([255; 4]))], &format!("%%CMYKCustomColor: {c} (Varnish)\n"));
std::fs::write(&a, varnish("0 0 .2 0")).unwrap();
std::fs::write(&b, varnish("1 0 0 0")).unwrap();
let u = s.execute("links.update", &json!({})).unwrap();
assert_eq!(u["updated"].as_array().map(Vec::len), Some(2), "{u}");
assert_eq!(u["warnings"], json!(["the document's swatch `Varnish` is kept: it is a different color"]));
}
#[test]
fn relink_with_copy_refuses_a_dcs_file_with_plate_files() {
let dir = temp_dir("copy");
write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes());
let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%EndComments\nshowpage\n";
let path = write(&dir, "pic.eps", main.as_bytes());
let docs = dir.join("docs");
std::fs::create_dir_all(&docs).unwrap();
let mut s = session();
s.execute("document.save", &json!({ "path": docs.join("doc.vectorcraft").to_string_lossy() })).unwrap();
let png = dir.join("plain.png");
image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])).save(&png).unwrap();
let r = s.execute("file.place", &json!({ "path": png.to_string_lossy() })).unwrap();
let id = r["ids"][0].clone();
let e = error(&mut s, "links.relink", json!({ "ids": [id], "path": path, "copy": true }));
assert!(e.contains("plate files"), "{e}");
assert!(!docs.join("pic.eps").exists(), "nothing is copied");
// A single-file DCS file has its plates inside it: it is copied.
let single = write(&dir, "single.eps", &two_plates());
s.execute("links.relink", &json!({ "ids": [id], "path": single, "copy": true })).unwrap();
assert!(docs.join("single.eps").exists());
}
#[test]
fn a_saved_document_reads_the_plates_again_and_prints_the_spot_ink() {
let dir = temp_dir("saved");
let path = write(&dir, "pic.eps", &two_plates());
let saved = dir.join("doc.vectorcraft").to_string_lossy().into_owned();
let mut s = session();
s.execute("file.place", &json!({ "path": path })).unwrap();
let unused = s.execute("swatch.unused", &json!({})).unwrap();
assert!(!unused["names"].as_array().unwrap().iter().any(|n| n == "PANTONE 185 C"), "the plate's swatch is in use: {unused}");
s.execute("document.save", &json!({ "path": saved })).unwrap();
let mut s = Session::new();
s.execute("document.open", &json!({ "path": saved })).unwrap();
let list = s.execute("links.list", &json!({})).unwrap();
assert_eq!(list["links"][0]["format"], "DCS");
assert_eq!(list["links"][0]["status"], "ok");
let id = NodeId(list["links"][0]["id"].as_u64().unwrap());
let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() };
let plates = s.doc().unwrap().doc.images[&im.key].separated().expect("the plates, read from the file again");
assert_eq!(plates.plates[1].data, [128, 0, 128, 0]);
let v = s.execute("print.preview", &json!({"settings": {"output": {"mode": "separations"}}})).unwrap();
let inks: Vec<&str> = v["sheets"].as_array().unwrap().iter().filter_map(|p| p["ink"].as_str()).collect();
assert_eq!(inks, ["Cyan", "Magenta", "Yellow", "Black", "PANTONE 185 C"]);
}
#[test]
fn dcs_transparency_marks_transparent_and_overlapped_dcs_images() {
let dir = temp_dir("transparency");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
let image = image_of(&s, &r["ids"][0]).id;
let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true;
assert!(!marked(&mut s), "nothing transparent");
assert!(s.execute("links.list", &json!({})).unwrap()["links"][0].get("dcsTransparency").is_none());
// A half-transparent square away from it, then over it.
let far = s.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("transparency.set", &json!({"ids": [far], "opacity": 50})).unwrap();
assert!(!marked(&mut s), "a transparent object elsewhere");
s.execute("shape.rectangle", &json!({"x": 90, "y": 90, "width": 20, "height": 20})).unwrap();
let over = s.execute("shape.rectangle", &json!({"x": 92, "y": 92, "width": 4, "height": 4})).unwrap()["id"].as_u64().unwrap();
assert!(!marked(&mut s), "opaque objects over it");
s.execute("transparency.set", &json!({"ids": [over], "blend": "multiply"})).unwrap();
assert!(marked(&mut s), "a blend mode over it");
s.execute("transparency.set", &json!({"ids": [over], "blend": "normal"})).unwrap();
s.execute("transparency.set", &json!({"ids": [image.0], "opacity": 80})).unwrap();
assert!(marked(&mut s), "the image itself");
assert!(error(&mut s, "links.list", json!({ "dcsTransparency": "yes" })).contains("dcsTransparency"), "a flag that isn't true or false");
}
#[test]
fn dcs_transparency_counts_a_transparent_dcs_image_over_another() {
let dir = temp_dir("transparency-two");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
let a = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
let b = s.execute("file.place", &json!({ "path": path, "at": [103, 100] })).unwrap();
let (a, b) = (image_of(&s, &a["ids"][0]).id, image_of(&s, &b["ids"][0]).id);
s.execute("transparency.set", &json!({"ids": [a.0], "opacity": 80})).unwrap();
let list = s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap();
let marked = |id: NodeId| list["links"].as_array().unwrap().iter().any(|r| r["id"] == id.0 && r["dcsTransparency"] == true);
assert!(marked(a), "the transparent image");
assert!(marked(b), "the opaque image it overlaps");
}
#[test]
fn dcs_transparency_counts_characters_with_a_transparent_gradient() {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
let dir = temp_dir("transparency-text");
let path = write(&dir, "pic.eps", &two_plates());
let mut s = session();
s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
let t = NodeId(s.execute("text.create", &json!({"x": 96, "y": 104, "text": "Hi", "size": 12})).unwrap()["id"].as_u64().unwrap());
let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true;
assert!(!marked(&mut s), "opaque characters over it");
let mut stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
stops[1].opacity = 0.5;
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
s.edit("t", |d, _| {
if let Some(Node { kind: NodeKind::Text(text), .. }) = d.node_mut(t) {
text.runs[0].style.fill = gradient.clone();
}
Ok(())
})
.unwrap();
assert!(marked(&mut s), "characters filled with a gradient that has a transparent stop");
assert!(s.doc().unwrap().doc.node(t).unwrap().shows_transparency());
}