//! 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 { 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 { 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::>(), ["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 = 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 { fn find(n: &Node) -> Option> { 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()); }