From 99f896f9b8cb4b07d2b62517db5b6025cfa0ad6d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 18:43:04 -0800 Subject: [PATCH] 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. --- crates/doc/src/node/ops.rs | 5 +- crates/doc/src/separated.rs | 154 +++++++++-- crates/engine/src/cmd/links.rs | 242 +++++++++++++++--- crates/engine/src/cmd/place.rs | 35 ++- crates/engine/src/cmd/place/dcs.rs | 12 +- crates/engine/src/cmd/swatch.rs | 23 +- crates/engine/src/link_watch.rs | 4 +- crates/engine/src/tests/dcs.rs | 382 ++++++++++++++++++++++++++++ crates/eps/src/dcs.rs | 94 +++++-- crates/eps/src/dcs/tests.rs | 84 ++++++ crates/pdf/src/print/plates.rs | 11 +- crates/pdf/src/tests/cmykimages.rs | 42 ++- crates/render/src/proof.rs | 107 ++++++-- crates/render/src/tests/colorize.rs | 64 +++++ docs/mcp.md | 37 ++- 15 files changed, 1171 insertions(+), 125 deletions(-) diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 106c97b2c3..415fc1cad1 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -819,7 +819,8 @@ impl Node { impl Node { /// Whether this object shows any transparency, itself or anything inside it: object, fill or /// stroke opacity below 100%, a blend mode, an opacity mask, effects, transparent gradient - /// stops or a colorized image. Hidden objects show none. + /// stops (characters' fills and strokes included) or a colorized image. Hidden objects show + /// none. pub fn shows_transparency(&self) -> bool { self.shows_transparency_with(&|_| true) } @@ -850,6 +851,8 @@ impl Node { .items .iter() .any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects()))) + // Characters' own fills and strokes, which draw with the object. + || matches!(&self.kind, NodeKind::Text(t) if t.runs.iter().any(|r| see_through(&r.style.fill) || (r.style.stroke_width > 0.0 && see_through(&r.style.stroke)))) } } diff --git a/crates/doc/src/separated.rs b/crates/doc/src/separated.rs index d9d1b0a740..8eb7cefe66 100644 --- a/crates/doc/src/separated.rs +++ b/crates/doc/src/separated.rs @@ -8,12 +8,15 @@ use std::io::Cursor; use std::sync::Arc; use vectorcraft_color::cms::PROCESS_PLATES; +use vectorcraft_geom::PathData; use crate::ImageBlob; use crate::cmyk::{Inks, MAX_CMYK_PIXELS}; /// Most plates a separated image holds. pub const MAX_PLATES: usize = 64; +/// Most directories a separated image's TIFF holds: its composite and its plates. +const MAX_DIRECTORIES: usize = MAX_PLATES + 1; /// Longest ink name kept (bytes). pub const MAX_NAME: usize = 255; /// Most ink amounts the plates of one image hold together (512 MB). @@ -146,12 +149,24 @@ impl Separated { /// [`Self::composite`] mixes it. A plate name the image has no plate for, and a spot plate that /// prints as process, print nothing. pub fn coverage(&self, plate: &str, own: &dyn Fn(&str) -> bool) -> Vec { + self.coverage_as(plate, own, &|p| p.cmyk) + } + + /// [`Self::coverage`], a spot plate that prints as process printing as the process inks + /// `equivalent` gives it (the document's swatch of its ink, when there is one). + pub fn coverage_as(&self, plate: &str, own: &dyn Fn(&str) -> bool, equivalent: &dyn Fn(&InkPlate) -> [f32; 4]) -> Vec { let n = self.data_len(); if let Some(j) = PROCESS_PLATES.iter().position(|q| *q == plate) { - let mixed: Vec<&InkPlate> = self.plates.iter().filter(|p| p.cmyk[j] > 0.0 && (!p.spot() || !own(&p.name))).collect(); + let mixed: Vec<(&InkPlate, f32)> = self + .plates + .iter() + .filter(|p| !p.spot() || !own(&p.name)) + .map(|p| (p, if p.spot() { equivalent(p)[j].clamp(0.0, 1.0) } else { p.cmyk[j] })) + .filter(|(_, e)| *e > 0.0) + .collect(); return (0..n) .map(|i| { - let clear = mixed.iter().fold(1.0f32, |c, p| c * (1.0 - f32::from(p.data[i]) / 255.0 * p.cmyk[j])); + let clear = mixed.iter().fold(1.0f32, |c, (p, e)| c * (1.0 - f32::from(p.data[i]) / 255.0 * e)); ((1.0 - clear) * 255.0).round() as u8 }) .collect(); @@ -162,10 +177,10 @@ impl Separated { } } - /// [`Self::coverage`] as a grayscale PNG, black for solid ink and white for none, as a plate + /// [`Self::coverage_as`] as a grayscale PNG, black for solid ink and white for none, as a plate /// prints; `None` when it can't be written. - pub fn plate_png(&self, plate: &str, own: &dyn Fn(&str) -> bool) -> Option { - let gray: Vec = self.coverage(plate, own).into_iter().map(|v| 255 - v).collect(); + pub fn plate_png(&self, plate: &str, own: &dyn Fn(&str) -> bool, equivalent: &dyn Fn(&InkPlate) -> [f32; 4]) -> Option { + let gray: Vec = self.coverage_as(plate, own, equivalent).into_iter().map(|v| 255 - v).collect(); let img = image::GrayImage::from_raw(self.width, self.height, gray)?; let mut png = Vec::new(); img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; @@ -199,8 +214,8 @@ fn parse_description(s: &str) -> Option<(String, [f32; 4])> { /// Longest plate file name kept (bytes). const MAX_FILE_NAME: usize = 4096; -/// The composite's ImageDescription for `files`: the mark, then each file's name in hexadecimal, -/// its size and its modification time. +/// The composite's ImageDescription line for `files`: the mark, then each file's name in +/// hexadecimal, its size and its modification time. fn files_description(files: &[PlateFile]) -> String { let mut out = String::from(FILES_MARK); for f in files.iter().take(MAX_PLATES) { @@ -209,9 +224,22 @@ fn files_description(files: &[PlateFile]) -> String { out } +/// How the composite's ImageDescription line holding the clipping path starts. +const CLIP_MARK: &str = "VectorCraft clip "; + +/// Longest clipping path kept (bytes of its JSON). +const MAX_CLIP: usize = 16 << 20; + +/// The composite's ImageDescription line for clipping path `clip`: the mark, then the path's JSON +/// in hexadecimal; `None` when it is longer than [`MAX_CLIP`]. +fn clip_description(clip: &PathData) -> Option { + let json = serde_json::to_string(clip).ok().filter(|j| j.len() <= MAX_CLIP)?; + Some(format!("{CLIP_MARK}{}", hex(&json))) +} + /// The plate files of a composite's ImageDescription; none when it isn't one. fn parse_files(s: &str) -> Vec { - let Some(rest) = s.strip_prefix(FILES_MARK) else { return vec![] }; + let Some(rest) = s.lines().find_map(|l| l.strip_prefix(FILES_MARK)) else { return vec![] }; let words: Vec<&str> = rest.split(' ').filter(|w| !w.is_empty()).collect(); let file = |w: &[&str]| Some(PlateFile { name: unhex(w.first()?, MAX_FILE_NAME)?, size: w.get(1)?.parse().ok()?, modified: w.get(2)?.parse().ok()? }); @@ -224,6 +252,12 @@ impl ImageBlob { /// `files`, the plate files of their own the plates were read from ([`Self::plate_files`]); /// `None` when it can't be written. pub fn separated_tiff(s: &Separated, ppi: Option<(f64, f64)>, files: &[PlateFile]) -> Option { + Self::separated_tiff_clipped(s, ppi, files, None) + } + + /// [`Self::separated_tiff`], noting `clip` too, the clipping path its file draws it through, in + /// its pixel space ([`Self::separated_clip`]). + pub fn separated_tiff_clipped(s: &Separated, ppi: Option<(f64, f64)>, files: &[PlateFile], clip: Option<&PathData>) -> Option { use tiff::encoder::{Compression, Rational, TiffEncoder, colortype, compression::DeflateLevel}; use tiff::tags::{ResolutionUnit, Tag}; let ppi = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)); @@ -237,8 +271,11 @@ impl ImageBlob { img.encoder().write_tag(Tag::YResolution, r(y)).ok()?; img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?; } - if !files.is_empty() { - img.encoder().write_tag(Tag::ImageDescription, files_description(files).as_str()).ok()?; + // One line for the plate files, one for the clipping path. + let lines: Vec = + [(!files.is_empty()).then(|| files_description(files)), clip.and_then(clip_description)].into_iter().flatten().collect(); + if !lines.is_empty() { + img.encoder().write_tag(Tag::ImageDescription, lines.join("\n").as_str()).ok()?; } img.write_data(&s.composite().data).ok()?; for p in &s.plates { @@ -251,19 +288,26 @@ impl ImageBlob { Some(Self::new("image/tiff", out.into_inner())) } - /// Does the image hold plates ([`ImageBlob::separated_tiff`])? Reads only the TIFF's - /// directories. + /// Does the image hold plates ([`ImageBlob::separated_tiff`]), at most [`MAX_PLATES`] of them? + /// One with more, or with more directories than its composite and [`MAX_PLATES`] plates, is the + /// CMYK image of its composite only, as [`Self::separated`] refuses it. Reads only the TIFF's + /// directories, at most [`MAX_DIRECTORIES`]. pub fn is_separated(&self) -> bool { let Some((_, mut d)) = crate::cmyk::tiff(&self.bytes) else { return false }; + let (mut plates, mut directories) = (0usize, 1usize); while d.more_images() { - if d.next_image().is_err() { + directories += 1; + if directories > MAX_DIRECTORIES || d.next_image().is_err() { return false; } if d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok().is_some_and(|s| s.starts_with(MARK)) { - return true; + plates += 1; + if plates > MAX_PLATES { + return false; + } } } - false + plates > 0 } /// The plate files of their own a separated image was read from, as they were then; none for @@ -273,9 +317,22 @@ impl ImageBlob { d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok().as_deref().map(parse_files).unwrap_or_default() } + /// The clipping path a separated image's file draws it through, in its pixel space, as + /// [`Self::separated_tiff_clipped`] noted it; `None` for other images. Reads only the TIFF's + /// first directory. + pub fn separated_clip(&self) -> Option { + let (_, mut d) = crate::cmyk::tiff(&self.bytes)?; + let text = d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok()?; + let json = unhex(text.lines().find_map(|l| l.strip_prefix(CLIP_MARK))?, MAX_CLIP)?; + serde_json::from_str(&json).ok() + } + /// The spot inks a separated image has plates for, each with its CMYK equivalent; none for /// other images. Reads only the TIFF's directories. pub fn spot_plates(&self) -> Vec<(String, [f32; 4])> { + if !self.is_separated() { + return vec![]; + } let Some((_, mut d)) = crate::cmyk::tiff(&self.bytes) else { return vec![] }; let mut inks = vec![]; while d.more_images() && inks.len() < MAX_PLATES { @@ -316,7 +373,14 @@ fn read_plates(bytes: &[u8]) -> Option { let (w, h) = d.dimensions().ok()?; let n = usize::try_from(u64::from(w) * u64::from(h)).ok()?; let mut plates = vec![]; - while d.more_images() && plates.len() < MAX_PLATES { + // Every plate is read: a file with more than [`MAX_PLATES`] is refused ([`Separated::fits`]), + // not cut short, and so is one with more than [`MAX_DIRECTORIES`] directories. + let mut directories = 1usize; + while d.more_images() { + directories += 1; + if directories > MAX_DIRECTORIES { + return None; + } d.next_image().ok()?; let Some((name, cmyk)) = d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok().as_deref().and_then(parse_description) else { continue; @@ -387,6 +451,64 @@ mod tests { assert!(!ImageBlob::cmyk_tiff(&s.composite()).unwrap().is_separated()); } + #[test] + fn the_tiff_keeps_the_clipping_path_beside_the_plate_files() { + let s = two(); + let clip = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 1.5, 1.0)); + let files = [PlateFile { name: "a.C".into(), size: 1, modified: 2 }]; + let b = ImageBlob::separated_tiff_clipped(&s, None, &files, Some(&clip)).unwrap(); + assert_eq!(b.separated_clip(), Some(clip.clone())); + assert_eq!(b.plate_files(), files); + let alone = ImageBlob::separated_tiff_clipped(&s, None, &[], Some(&clip)).unwrap(); + assert_eq!(alone.separated_clip(), Some(clip)); + assert!(alone.plate_files().is_empty()); + assert_eq!(ImageBlob::separated_tiff(&s, None, &files).unwrap().separated_clip(), None); + } + + #[test] + fn a_tiff_with_more_plates_than_are_read_is_refused() { + use tiff::encoder::{TiffEncoder, colortype}; + let tiff = |plates: usize| { + let mut out = Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut out).unwrap(); + enc.write_image::(1, 1, &[0, 0, 0, 0]).unwrap(); + for i in 0..plates { + let mut img = enc.new_image::(1, 1).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ImageDescription, description(&plate(&format!("S{i}"), [0.0; 4], &[0])).as_str()).unwrap(); + img.write_data(&[255]).unwrap(); + } + ImageBlob::new("image/tiff", out.into_inner()) + }; + assert_eq!(tiff(MAX_PLATES).separated().map(|s| s.plates.len()), Some(MAX_PLATES)); + let over = tiff(MAX_PLATES + 1); + assert!(over.separated().is_none(), "not cut short to the first {MAX_PLATES}"); + assert!(!over.is_separated() && over.spot_plates().is_empty(), "the CMYK image of its composite only"); + assert!(tiff(MAX_PLATES).is_separated()); + } + + #[test] + fn a_tiff_with_more_directories_than_a_composite_and_its_plates_is_refused() { + use tiff::encoder::{TiffEncoder, colortype}; + let tiff = |others: usize| { + let mut out = Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut out).unwrap(); + enc.write_image::(1, 1, &[0, 0, 0, 0]).unwrap(); + let mut img = enc.new_image::(1, 1).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ImageDescription, description(&plate("S", [0.0; 4], &[0])).as_str()).unwrap(); + img.write_data(&[255]).unwrap(); + // Directories that are not plates. + for _ in 0..others { + enc.write_image::(1, 1, &[255]).unwrap(); + } + ImageBlob::new("image/tiff", out.into_inner()) + }; + let most = tiff(MAX_PLATES - 1); + assert!(most.is_separated() && most.separated().is_some()); + let over = tiff(MAX_PLATES); + assert!(!over.is_separated() && over.separated().is_none() && over.spot_plates().is_empty()); + assert!(read_plates(&over.bytes).is_none(), "read_plates stops at the limit too"); + } + #[test] fn names_that_are_not_ascii_are_kept() { let s = Separated::new(1, 1, vec![plate("Grün 100 % (A)", [0.5, 0.0, 1.0, 0.0], &[9])]).unwrap(); diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 535b45e6ff..1e5dc71f28 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -31,7 +31,7 @@ use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; use vectorcraft_doc::{ Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, }; -use vectorcraft_geom::{Affine, PathData, Rect, shapes}; +use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, ids_param, object_param, str_param, switch_or}; @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Update Links", [], None, - "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids]}", + "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids], warnings?: [a DCS file's spot plate whose ink a swatch of the document already names, as file.place warns]}", has_doc, update ), @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used; an error for a DCS file that reads plate files of its own), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots), warnings?: (as links.update's)}", has_doc, relink ), @@ -177,9 +177,17 @@ fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option)> { } } -/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]). +/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]): an +/// image placed from a DCS file has its plate files of their own too, beside its file. pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec)> { - groups(d, None).into_iter().filter(|g| !g.ids.is_empty()).map(|g| (g.link.path, ids_json(&g.ids))).collect() + let mut out = vec![]; + for g in groups(d, None).into_iter().filter(|g| !g.ids.is_empty()) { + let ids = ids_json(&g.ids); + let plates: Vec = g.plate_files.iter().filter_map(|f| super::place::dcs::plate_file_path(&g.link.path, &f.name)).collect(); + out.push((g.link.path, ids.clone())); + out.extend(plates.into_iter().map(|p| (p, ids.clone()))); + } + out } /// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when @@ -597,12 +605,89 @@ fn clip_shape((frame, room): Clip) -> PathData { shapes::rectangle(Rect::new(0.0, 0.0, room.0, room.1)).transformed(frame) } +/// What happens to the clip of an object a link update remakes. +enum ClipTo { + /// It stays as it is. + Keep, + /// It is clipped to this path: the clipping path of the clip group holding just it changes, + /// else it goes into a new clip group. + Path(PathData), + /// It goes into a new clip group of this path, inside the clip group that holds it. + Nest(PathData), + /// The clipping path of the clip group holding just it goes (a DCS file's clipping path that + /// its new file lacks): the group stays as a plain group of it, with its id, name, visibility + /// and transparency. + Off, +} + +/// The clipping path, with its fill rule, of the clip group holding just object `id` of `d`, when +/// one holds it. +fn clip_path_of(d: &Document, id: NodeId) -> Option<(&PathData, FillRule)> { + let g = d.node(d.parent_of(id)?)?; + match &g.kind { + NodeKind::Group { children, clip: true } => match &children[..] { + [c, o] if o.id == id => match &c.kind { + NodeKind::Path { path, rule, clipping: true, .. } => Some((path, *rule)), + _ => None, + }, + _ => None, + }, + _ => None, + } +} + +/// Do `a` and `b` have the same subpaths, each closed or open alike, and the same anchors, each +/// within a thousandth of a point? +fn same_path(a: &PathData, b: &PathData) -> bool { + let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| p.distance(q) < 1e-3; + a.anchor_count() == b.anchor_count() + && a.subpaths.len() == b.subpaths.len() + && a.subpaths.iter().zip(&b.subpaths).all(|(x, y)| x.closed == y.closed) + && a.anchors() + .zip(b.anchors()) + .all(|((si, ai, x), (sj, aj, y))| si == sj && ai == aj && near(x.p, y.p) && near(x.h_in, y.h_in) && near(x.h_out, y.h_out)) +} + +/// The clip an image placed with `old_xf` takes when it shows `img`, read from a file, placed with +/// `new_xf`: Placement Options' clip `clip` when they clip it, else the clipping path a DCS file +/// draws `img` through. When neither clips it, a clip group holding just it whose clipping path +/// `held` is its old DCS file's (`old_clip`, in the old pixels, under the nonzero rule its clip +/// groups get) goes. +fn clip_to( + old_xf: Affine, + old_clip: Option<&PathData>, + clip: Option, + img: &RasterImage, + new_xf: Affine, + held: Option<(&PathData, FillRule)>, +) -> ClipTo { + if let Some(clip) = clip { + return ClipTo::Path(clip_shape(clip)); + } + // Whether the clip group holding just the image is the old DCS file's: a clipping path whose + // fill rule was changed in the document is not. + let file_held = match (old_clip, held) { + (Some(old), Some((held, rule))) => rule == FillRule::NonZero && same_path(&old.transformed(old_xf), held), + _ => false, + }; + match img.clip.as_ref().map(|path| path.transformed(new_xf)) { + // A clipping mask made in the document stays, with the file's clip group inside it. + Some(path) if held.is_some() && !file_held => ClipTo::Nest(path), + Some(path) => ClipTo::Path(path), + None if file_held => ClipTo::Off, + None => ClipTo::Keep, + } +} + +/// The object an object becomes, and what happens to its clip, given the clipping path of the clip +/// group holding just it ([`ClipTo`]). +type Remake<'a> = dyn FnMut(&Node, Option<(&PathData, FillRule)>) -> (Node, ClipTo) + 'a; + /// Remakes the linked objects outside the layer tree (in symbols, graph designs, patterns and /// opacity masks, where [`Document::node`] finds no ids) that `pick` holds for. struct Nested<'a> { pick: &'a dyn Fn(&Node) -> bool, - /// The object an object becomes, and the clip it takes ([`placed_xf`]). - make: &'a mut dyn FnMut(&Node) -> (Node, Option), + make: &'a mut Remake<'a>, /// The next node id for a clip group (taken from the document after the walk). next: u64, } @@ -633,12 +718,12 @@ impl Nested<'_> { if !(self.pick)(n) { return self.update(n, true); } - let (new, clip) = (self.make)(n); + let (new, clip) = (self.make)(n, None); let mut new = Arc::new(new); self.update(&mut new, true); *n = match clip { - Some(clip) => Arc::new(self.clip_group(new, clip)), - None => new, + ClipTo::Path(path) | ClipTo::Nest(path) => Arc::new(self.clip_group(new, path)), + ClipTo::Keep | ClipTo::Off => new, }; } @@ -651,6 +736,8 @@ impl Nested<'_> { (!Arc::ptr_eq(&art, &m.art)).then_some(art) }); let mut slots: Vec<(usize, Arc)> = vec![]; + // The object a clip group that loses its clipping path holds alone. + let mut declipped = None; if let Some(children) = n.children() { // A clip group holding just the object: its clipping path changes. let clip_path = match (&n.kind, &children[..]) { @@ -666,32 +753,45 @@ impl Nested<'_> { } continue; } - let (new, clip) = (self.make)(c); + let held = clip_path.filter(|_| i == 1).and_then(|p| match &p.kind { + NodeKind::Path { path, rule, .. } => Some((path, *rule)), + _ => None, + }); + let (new, clip) = (self.make)(c, held); let mut new = Arc::new(new); self.update(&mut new, true); match (clip, clip_path) { - (None, _) => slots.push((i, new)), - (Some(clip), Some(path)) if i == 1 => { + (ClipTo::Keep, _) => slots.push((i, new)), + (ClipTo::Path(clip), Some(path)) if i == 1 => { let mut path = Node::clone(path); if let NodeKind::Path { path: p, .. } = &mut path.kind { - *p = clip_shape(clip); + *p = clip; } slots.extend([(0, Arc::new(path)), (1, new)]); } - (Some(clip), _) => { + (ClipTo::Path(clip) | ClipTo::Nest(clip), _) => { let group = self.clip_group(new, clip); slots.push((i, Arc::new(group))); } + (ClipTo::Off, Some(_)) if i == 1 => declipped = Some(new), + (ClipTo::Off, _) => slots.push((i, new)), } } } - if mask.is_none() && slots.is_empty() { + if mask.is_none() && slots.is_empty() && declipped.is_none() { return; } let n = Arc::make_mut(n); if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) { m.art = art; } + // A clip group that stays as a plain group of just the object. + if let Some(object) = declipped { + if let NodeKind::Group { children, clip } = &mut n.kind { + (*children, *clip) = (vec![object], false); + } + return; + } if let Some(children) = n.children_mut() { for (i, c) in slots { if let Some(slot) = children.get_mut(i) { @@ -701,12 +801,12 @@ impl Nested<'_> { } } - /// A clip group of `object` clipped to `clip`, as [`clip_image`] makes one in the layers. - fn clip_group(&mut self, object: Arc, clip: Clip) -> Node { + /// A clip group of `object` clipped to `clip`, as [`clip_image_to`] makes one in the layers. + fn clip_group(&mut self, object: Arc, clip: PathData) -> Node { let (path_id, group_id) = (NodeId(self.next), NodeId(self.next + 1)); self.next += 2; let none = vectorcraft_color::Paint::None; - let mut path = Node::path(path_id, clip_shape(clip), Appearance::basic(none.clone(), none, 0.0)); + let mut path = Node::path(path_id, clip, Appearance::basic(none.clone(), none, 0.0)); if let NodeKind::Path { clipping, .. } = &mut path.kind { *clipping = true; } @@ -734,13 +834,13 @@ fn install_document( }; if let Some(also) = also { let pick = |n: &Node| shows(n, also, true); - let mut make = |n: &Node| { + let mut make = |n: &Node, _: Option<(&PathData, FillRule)>| { let mut n = n.clone(); - let mut clip = None; + let mut clip = ClipTo::Keep; if let NodeKind::PlacedDocument(p) = &mut n.kind { let (xf, c) = placed(p); (p.key, p.width, p.height, p.xf, p.link) = (src.key.clone(), w, h, xf, link.clone()); - clip = c; + clip = c.map_or(ClipTo::Keep, |c| ClipTo::Path(clip_shape(c))); } (n, clip) }; @@ -841,6 +941,11 @@ fn clip_image_to(d: &mut Document, id: NodeId, path: PathData) -> Result<()> { if set_clip_path(d, id, path.clone()) { return Ok(()); } + new_clip_group(d, id, path) +} + +/// Put object `id` of `d` into a new clip group of `path`, where it is. +fn new_clip_group(d: &mut Document, id: NodeId, path: PathData) -> Result<()> { let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?; let image = d.remove(id)?; let none = vectorcraft_color::Paint::None; @@ -876,15 +981,17 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio // Every image picked shows content `also.1`, so one resolution serves them all. let old_pt = file_pt(d, also.1); let pick = |n: &Node| shows(n, also, false); - let mut make = |n: &Node| { + // The clipping path the old content's DCS file drew it through. + let old_clip = d.images.get(also.1).and_then(ImageBlob::separated_clip); + let mut make = |n: &Node, held: Option<(&PathData, FillRule)>| { let mut n = n.clone(); - let mut clip = None; + let mut clip = ClipTo::Keep; if let NodeKind::Image(im) = &mut n.kind { let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement }; let size = |w: u32, h: u32| (w as f64, h as f64); let (xf, c) = placed_xf(im.xf, size(im.width, im.height), old_pt, size(img.width, img.height), new_pt, o); + clip = clip_to(im.xf, old_clip.as_ref(), c, &img, xf, held); (im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone())); - clip = c; } (n, clip) }; @@ -895,19 +1002,43 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement }; let size = |w: u32, h: u32| (w as f64, h as f64); let (xf, clip) = placed_xf(im.xf, size(im.width, im.height), file_pt(d, &im.key), size(img.width, img.height), new_pt, o); + // Placement Options' clip, else a DCS file's clipping path, read again with its plates. + let old_clip = d.images.get(&im.key).and_then(ImageBlob::separated_clip); + clips.push((*id, clip_to(im.xf, old_clip.as_ref(), clip, &img, xf, clip_path_of(d, *id)))); if let Some(n) = d.node_mut(*id) && let NodeKind::Image(im) = &mut n.kind { (im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone())); } - // A DCS file's clipping path, read again with its plates, unless Placement Options clip it. - let dcs_clip = img.clip.as_ref().map(|path| path.transformed(xf)); - clips.extend(clip.map(clip_shape).or(dcs_clip).map(|path| (*id, path))); } // A DCS file's spot plates print on spot swatches of their inks. super::place::dcs::add_spot_swatches(d, &img.blob.spot_plates()); store_image(d, &img.key, img.blob, true); - clips.into_iter().try_for_each(|(id, path)| clip_image_to(d, id, path)) + clips.into_iter().try_for_each(|(id, clip)| match clip { + ClipTo::Keep => Ok(()), + ClipTo::Path(path) => clip_image_to(d, id, path), + ClipTo::Nest(path) => new_clip_group(d, id, path), + ClipTo::Off => unclip(d, id), + }) +} + +/// Take the clipping path of the clip group holding just object `id` of `d` away: the group stays +/// as a plain group of the object, with its id, name, visibility and transparency as they were. +fn unclip(d: &mut Document, id: NodeId) -> Result<()> { + let Some((g, clip)) = d.parent_of(id).and_then(|g| match &d.node(g)?.kind { + NodeKind::Group { children, clip: true } => match &children[..] { + [c, o] if o.id == id && matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some((g, c.id)), + _ => None, + }, + _ => None, + }) else { + return Ok(()); + }; + d.remove(clip)?; + if let Some(Node { kind: NodeKind::Group { clip, .. }, .. }) = d.node_mut(g) { + *clip = false; + } + Ok(()) } /// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in @@ -1078,12 +1209,12 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps m.iter().find(|(k, doc, _)| k == key && *doc == document.is_some()).map(|(_, _, new)| new) }; let pick = |n: &Node| new(n).is_some(); - let mut make = |n: &Node| { + let mut make = |n: &Node, _: Option<(&PathData, FillRule)>| { let mut out = n.clone(); if let (Some(link), Some(l)) = (new(n), link_mut(&mut out)) { *l = link.clone(); } - (out, None) + (out, ClipTo::Keep) }; Nested { pick: &pick, make: &mut make, next: 0 }.run(d); } @@ -1348,10 +1479,44 @@ fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) - } } let updated: Vec = reads.iter().flat_map(|(g, _)| ids_json(&g.ids)).collect(); + let warnings = spot_swatch_notes(&st.doc, reads.iter().map(|(_, read)| read)); if !reads.is_empty() { s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(g, read)| install_read(d, &g.ids, read, None, Some(&g))))?; } - Ok(json!({ "updated": updated, "missing": missing })) + let mut out = json!({ "updated": updated, "missing": missing }); + if !warnings.is_empty() { + out["warnings"] = json!(warnings); + } + Ok(out) +} + +/// The warnings for the spot plates of the DCS files in `reads` whose ink a swatch of `d` already +/// names in another color, or as a process color ([`super::place::dcs::swatch_notes`]), or that an +/// earlier file of `reads` adds as a spot swatch in another color, each once. +fn spot_swatch_notes<'a>(d: &Document, reads: impl Iterator) -> Vec { + let mut out: Vec = vec![]; + // The spot swatches the reads add, in the order they are installed. + let mut added: Vec<(String, [f32; 4])> = vec![]; + for read in reads { + let Read::Image((_, img)) = read else { continue }; + let spots = img.blob.spot_plates(); + let mut notes = super::place::dcs::swatch_notes(d, &spots); + for (name, cmyk) in spots.into_iter().filter(|(name, _)| d.swatch(name).is_none()) { + match added.iter().find(|(n, _)| *n == name) { + Some((_, first)) if first.iter().zip(cmyk).any(|(a, b)| (a - b).abs() >= 0.005) => { + notes.push(format!("the document's swatch `{name}` is kept: it is a different color")); + } + Some(_) => {} + None => added.push((name, cmyk)), + } + } + for note in notes { + if !out.contains(¬e) { + out.push(note); + } + } + } + out } /// Which links `links.relink` points at the new file (the Replace choice of Relink). @@ -1534,6 +1699,10 @@ fn relink(s: &mut Session, p: &Value) -> Result { } // Read before it is copied: a file that can't be shown isn't copied. let img = if images.is_empty() { None } else { Some(linked_image(&bytes, Some(&path)).map_err(|e| bad(C, format!("{path}: {e}")))?) }; + // A copy beside the document would point at plate files it can't find there. + if copy_to.is_some() && img.as_ref().is_some_and(|img| !img.blob.plate_files().is_empty()) { + return Err(bad(C, format!("{path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are"))); + } let mut sources = vec![]; for ((page, bounding, page_box), ids) in documents { sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?)); @@ -1568,12 +1737,16 @@ fn relink(s: &mut Session, p: &Value) -> Result { } } let relinked: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); + let warnings = spot_swatch_notes(&s.doc()?.doc, reads.iter().map(|(_, read)| read)); if !reads.is_empty() { s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve, None)))?; } not_found.sort(); not_found.dedup(); let mut out = json!({ "relinked": relinked, "notFound": not_found }); + if !warnings.is_empty() { + out["warnings"] = json!(warnings); + } if copy_to.is_some() { // The copies are in the document's folder, which the client did not give. for pair in copied.iter_mut().chain(kept.iter_mut()) { @@ -1769,6 +1942,11 @@ fn dcs_transparency(d: &Document) -> BTreeSet { NodeKind::Image(im) if d.images.get(&im.key).is_some_and(ImageBlob::is_separated) => { if let Some(b) = n.geometric_bounds() { images.push((n.id, b, held || own)); + // A transparent DCS image is an object with its own transparency for the + // others it overlaps. + if own { + see_through.push(b); + } } } _ if own => see_through.extend(n.geometric_bounds()), diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 0c3ab347b9..f6c048a1d0 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), @@ -191,14 +191,6 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result 1, Some(v) => v.as_u64().filter(|n| (1..=100_000).contains(n)).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))? as usize, @@ -218,6 +210,15 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result Result { let link = loaded.link.take().filter(|_| bool_or(p, "link", true)); let linked = link.is_some(); let (name, format, mut warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings)); + let dcs_clipped = matches!(&loaded.art, Art::Dcs(a) if a.clip.is_some()); if let Art::Dcs(a) = &loaded.art { warnings.extend(dcs::swatch_notes(&st.doc, &a.spots)); } @@ -685,9 +687,18 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { } Ok((id, size)) })?; - Ok( - json!({ "ids": [id.0], "name": name, "format": format, "linked": linked, "width": size.width(), "height": size.height(), "warnings": warnings }), - ) + let mut out = json!({ "ids": [id.0], "name": name, "format": format, "linked": linked, "width": size.width(), "height": size.height(), "warnings": warnings }); + // A DCS file clipped by its clipping path: its image, which the Links commands take. + if dcs_clipped { + let image = s.doc()?.doc.node(id).and_then(|n| match &n.kind { + NodeKind::Group { children, clip: true } => children.get(1).filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.id), + _ => None, + }); + if let Some(image) = image { + out["image"] = json!(image.0); + } + } + Ok(out) } /// The artboard under `at` (its edges included, [`Document::artboard_holding`]), else the first. diff --git a/crates/engine/src/cmd/place/dcs.rs b/crates/engine/src/cmd/place/dcs.rs index 4ed7d1f8ca..b540221a23 100644 --- a/crates/engine/src/cmd/place/dcs.rs +++ b/crates/engine/src/cmd/place/dcs.rs @@ -46,13 +46,15 @@ pub(crate) fn read(bytes: &[u8], path: Option<&str>) -> Result { let mut files = vec![]; let mut file = |name: &str| plate_file(path, name, &mut files); let p = vectorcraft_eps::dcs::place(bytes, &dcs, &mut file).map_err(EngineError::Other)?; - let blob = ImageBlob::separated_tiff(&p.separated, p.ppi, &files).ok_or_else(|| EngineError::Other("the plates can't be kept".into()))?; - // The clipping path in the image's pixel space, where the Links commands put it again. + // The clipping path in the image's pixel space, where the Links commands put it again; the + // image keeps it, so they know a clip group of that path as the file's. let to_pixels = (p.xf.determinant().abs() > 1e-12).then(|| p.xf.inverse()); let clip = match (&p.clip, to_pixels) { (Some(Node { kind: NodeKind::Path { path, .. }, .. }), Some(inv)) => Some(path.transformed(inv)), _ => None, }; + let blob = ImageBlob::separated_tiff_clipped(&p.separated, p.ppi, &files, clip.as_ref()) + .ok_or_else(|| EngineError::Other("the plates can't be kept".into()))?; let img = RasterImage { key: blob.content_key(), blob, width: p.separated.width, height: p.separated.height, ppi: p.ppi, clip }; let spots = p.separated.spots().map(|s| (s.name.clone(), s.cmyk)).collect(); Ok(DcsArt { img, xf: p.xf, page: p.page, clip: p.clip, spots, warnings: p.warnings }) @@ -75,6 +77,12 @@ fn plate_path(main: Option<&str>, name: &str) -> std::result::Result<(String, St Ok((folder.join(base).to_string_lossy().into_owned(), base.to_string())) } +/// The path of the plate file named `name` that the DCS file at `main` (a path as a link keeps +/// it) was read with ([`plate_path`]); `None` when `main` has no folder. +pub(crate) fn plate_file_path(main: &str, name: &str) -> Option { + plate_path(Some(main), name).ok().map(|(path, _)| path) +} + /// The plate file a DCS file at `main` names `name` ([`plate_path`]), noted in `files` as it is now. fn plate_file(main: Option<&str>, name: &str, files: &mut Vec) -> std::result::Result, String> { let (path, base) = plate_path(main, name)?; diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index d1d31bd323..55245b63a5 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Select All Unused", ["Window", "Swatches"], None, - "{} → {names: [the swatches nothing uses, in panel order]}: a colour is used when a paint links to it or an unlinked paint, gradient stop or mesh point has its colour; a gradient when a gradient links to it or has its stops; a pattern when it fills or strokes anything (art, symbols, pattern tiles and graphic styles count); a spot color when an image placed from a DCS file has a plate of its ink. None is never listed", + "{} → {names: [the swatches nothing uses, in panel order]}: a colour is used when a paint links to it or an unlinked paint, gradient stop or mesh point has its colour; a gradient when a gradient links to it or has its stops; a pattern when it fills or strokes anything (art, symbols, pattern tiles and graphic styles count); a color, spot or process, when an image placed from a DCS file that the art shows has a spot plate of its name. None is never listed", has_doc, swatch_unused ), @@ -989,10 +989,27 @@ fn swatch_unused(s: &mut Session, _: &Value) -> Result { let patterns: HashSet<&String> = patterns.iter().collect(); let gradients: HashSet = gradients.iter().filter_map(gradient_key).collect(); // The spot inks the images placed from DCS files have plates for: their swatches print them. - let plate_inks: HashSet = d.images.values().flat_map(vectorcraft_doc::ImageBlob::spot_plates).map(|(name, _)| name).collect(); + // Only images the art shows count: a deleted one's pixels stay for undo. + let mut shown: HashSet<&str> = HashSet::new(); + for root in d + .layers + .iter() + .chain(d.symbols.iter().map(|s| &s.art)) + .chain(d.graph_designs.iter().map(|g| &g.art)) + .chain(d.patterns.iter().flat_map(|p| &p.art)) + { + vectorcraft_doc::swatches::walk_with_masks(root, &mut |n| { + if let vectorcraft_doc::NodeKind::Image(im) = &n.kind { + shown.insert(im.key.as_str()); + } + }); + } + let plate_inks: HashSet = + shown.iter().filter_map(|k| d.images.get(*k)).flat_map(vectorcraft_doc::ImageBlob::spot_plates).map(|(name, _)| name).collect(); let used = |w: &Swatch| { gradient_links.contains(&w.name) - || (w.spot && plate_inks.contains(&w.name)) + // A spot swatch prints the plate; a process one gives the color it prints as process. + || (w.paint.color().is_some() && plate_inks.contains(&w.name)) || match w.tint_of() { Some((base, tint)) => float_key(tint).is_some_and(|t| link_tints.contains(&(base, t))), None => link_names.contains(w.name.as_str()), diff --git a/crates/engine/src/link_watch.rs b/crates/engine/src/link_watch.rs index 747ce91545..c524f056a5 100644 --- a/crates/engine/src/link_watch.rs +++ b/crates/engine/src/link_watch.rs @@ -184,7 +184,9 @@ impl Session { if changed.is_empty() { return Ok(none()); } - let ids: Vec = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).collect(); + // Each once: a DCS file and its plate files list the same objects. + let mut seen = HashSet::new(); + let ids: Vec = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).filter(|id| seen.insert(*id)).collect(); let mode = if update { "automatically" } else { self.prefs.update_links.as_str() }; match mode { "automatically" => { diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index 0b511c8c75..2801432a0a 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -265,6 +265,349 @@ fn a_changed_plate_file_marks_the_link_modified() { 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"); @@ -313,3 +656,42 @@ fn dcs_transparency_marks_transparent_and_overlapped_dcs_images() { 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()); +} diff --git a/crates/eps/src/dcs.rs b/crates/eps/src/dcs.rs index 7ae8eb90a3..1de21a4afe 100644 --- a/crates/eps/src/dcs.rs +++ b/crates/eps/src/dcs.rs @@ -67,7 +67,7 @@ pub fn read(bytes: &[u8]) -> Option { // The comment a `%%+` line continues. let mut last = String::new(); for raw in ps.split(|b| matches!(b, b'\n' | b'\r')).filter(|l| !l.is_empty()).take(MAX_HEADER_LINES) { - let line = String::from_utf8_lossy(raw); + let line = header_line(raw); let line = line.trim_end(); if line.starts_with("%%EndComments") || !line.starts_with('%') { break; @@ -120,6 +120,20 @@ pub fn read(bytes: &[u8]) -> Option { /// Most header lines looked at for the DCS comments. const MAX_HEADER_LINES: usize = 10_000; +/// Header line `raw` as text: UTF-8 when it is, else Latin-1 (each byte one character), as the +/// octal escapes of names are read. +fn header_line(raw: &[u8]) -> std::borrow::Cow<'_, str> { + match std::str::from_utf8(raw) { + Ok(s) => std::borrow::Cow::Borrowed(s), + Err(_) => std::borrow::Cow::Owned(raw.iter().copied().map(char::from).collect()), + } +} + +/// `s` without its first word and the whitespace around that word. +fn after_word(s: &str) -> &str { + s.trim_start().split_once(char::is_whitespace).map_or("", |(_, rest)| rest.trim_start()) +} + /// A `%%PlateFile:` comment's plate: `(name) EPS #offset size` or `(name) EPS Local file`; `None` /// for other locations, which only a print server reads. fn plate_file(v: &str) -> Option { @@ -135,8 +149,9 @@ fn plate_file(v: &str) -> Option { if !location.eq_ignore_ascii_case("Local") { return None; } - let file = rest.split_whitespace().skip(2).collect::>().join(" "); - (!file.is_empty()).then_some(PlateRef { name, at: PlateAt::File(file) }) + // The file name as it is written, its spaces kept. + let file = after_word(after_word(rest)); + (!file.is_empty()).then(|| PlateRef { name, at: PlateAt::File(file.to_string()) }) } /// A plate's name as it is kept: a process ink's in its usual case ("cyan" is "Cyan"); `None` when @@ -378,21 +393,44 @@ fn resample(img: &PlateImage, w: u32, h: u32, onto: Affine) -> Vec { out } -/// The first image plate program `eps` draws, as ink amounts (how dark each pixel is: 255 less its -/// luma), and where it prints on its page. +/// Is an image under `n` (itself included) drawn through a clipping path: inside a clipping group +/// or layer, or `inside` one already? +fn clips_an_image(n: &Node, inside: bool) -> bool { + let inside = inside || matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); + match &n.kind { + NodeKind::Image(_) => inside, + _ => n.children().is_some_and(|ch| ch.iter().any(|c| clips_an_image(c, inside))), + } +} + +/// The image plate program `eps` draws, as ink amounts (how dark each pixel is: 255 less its +/// luma), and where it prints on its page; an error when it draws more than one image, or art +/// besides it. fn plate_image(eps: &[u8]) -> Result { let r = crate::import_with(eps, false)?; if r.preview { return Err(r.warnings.first().cloned().unwrap_or_else(|| "its PostScript can't be read".into())); } - let mut found = None; - r.document.walk(|n| { - if found.is_none() - && let NodeKind::Image(im) = &n.kind - { - found = Some((im.key.clone(), im.xf, im.width, im.height)); + // A plate is one image: one that draws more (several images, or art) would lose ink. + let (mut found, mut images, mut art) = (None, 0usize, false); + r.document.walk(|n| match &n.kind { + NodeKind::Image(im) => { + images += 1; + found.get_or_insert((im.key.clone(), im.xf, im.width, im.height)); } + NodeKind::Path { clipping: true, .. } | NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::RulerGuide { .. } => {} + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + art |= n.visible && n.appearance.items.iter().any(|i| i.visible() && !matches!(i.paint(), vectorcraft_color::Paint::None)) + } + _ => art |= n.visible, }); + if images > 1 || (art && images == 1) { + return Err(if images > 1 { "it draws more than one image".into() } else { "it draws art besides its image".into() }); + } + // Ink outside a clipping path would print: a plate whose image is clipped is refused. + if images == 1 && r.document.layers.iter().any(|l| clips_an_image(l, false)) { + return Err("it draws its image through a clipping path".into()); + } let (key, xf, iw, ih) = found.ok_or("it draws no image")?; let blob = r.document.images.get(&key).ok_or("it draws no image")?; let (width, height, data) = blob.coverage().ok_or("its image is too large to read")?; @@ -407,13 +445,20 @@ fn clip_path(main: &[u8]) -> Option { first_clip(&r.document) } -/// The clipping path of the innermost clipping group of `d` that holds an image. +/// The clipping path the first image of `d` is drawn through: the clipping path of the innermost +/// clipping group that holds an image, cut to the clipping paths of the clipping groups around it. fn first_clip(d: &Document) -> Option { - fn find(n: &Node) -> Option { + /// The clipping paths around the innermost clipping group under `n` that holds an image, + /// outermost first. + fn find(n: &Node) -> Option> { let NodeKind::Group { children, clip } = &n.kind else { return n.children().and_then(|ch| ch.iter().find_map(|c| find(c))); }; - if let Some(inner) = children.iter().find_map(|c| find(c)) { + let first = children.first().filter(|f| *clip && matches!(f.kind, NodeKind::Path { clipping: true, .. })); + if let Some(mut inner) = children.iter().find_map(|c| find(c)) { + if let Some(f) = first { + inner.insert(0, (**f).clone()); + } return Some(inner); } let holds_image = || { @@ -421,10 +466,25 @@ fn first_clip(d: &Document) -> Option { n.walk(&mut |c: &Node| any |= matches!(c.kind, NodeKind::Image(_))); any }; - let first = children.first()?; - (*clip && matches!(first.kind, NodeKind::Path { clipping: true, .. }) && holds_image()).then(|| (**first).clone()) + first.filter(|_| holds_image()).map(|f| vec![(**f).clone()]) } - d.layers.iter().find_map(|l| find(l)) + let mut clips = d.layers.iter().find_map(|l| find(l))?; + let mut innermost = clips.pop()?; + // The clipping paths together clip to where they overlap. + for outer in clips.iter().rev() { + if let (NodeKind::Path { path: o, rule: or, .. }, NodeKind::Path { path, rule, .. }) = (&outer.kind, &mut innermost.kind) { + *path = vectorcraft_pathops::boolean(path, *rule, o, *or, vectorcraft_pathops::BoolOp::Intersect); + *rule = vectorcraft_geom::FillRule::NonZero; + } + } + // An even-odd clipping path is kept as the same area under the nonzero rule, the rule of the + // clip groups the Links commands make and match again. + if let NodeKind::Path { path, rule: rule @ vectorcraft_geom::FillRule::EvenOdd, .. } = &mut innermost.kind + && let Ok(nonzero) = vectorcraft_pathops::try_boolean_n(&[(&*path, vectorcraft_geom::FillRule::EvenOdd)], |inside| !inside.is_empty()) + { + (*path, *rule) = (nonzero, vectorcraft_geom::FillRule::NonZero); + } + Some(innermost) } #[cfg(test)] diff --git a/crates/eps/src/dcs/tests.rs b/crates/eps/src/dcs/tests.rs index 9526ec1fea..b55fea0b84 100644 --- a/crates/eps/src/dcs/tests.rs +++ b/crates/eps/src/dcs/tests.rs @@ -102,6 +102,90 @@ fn plate_files_of_both_versions_are_read() { assert!(read(b"\x89PNG").is_none()); } +#[test] +fn a_plate_program_drawing_more_than_its_image_is_an_error() { + let plate = plate_eps(1, 1, &[0]); + // Two image tiles, and an image with a filled square beside it. + let tiles = plate.replacen("showpage", "1 1 8 [1 0 0 -1 0 1] <00> image\nshowpage", 1); + let art = plate.replacen("20 10 scale", "0 0 moveto 5 0 lineto 5 5 lineto closepath fill\n20 10 scale", 1); + for (eps, why) in [(tiles, "more than one image"), (art, "art besides its image")] { + let bytes = single_file(&[("Cyan", eps)], ""); + let err = place(&bytes, &read(&bytes).unwrap(), &mut |_| Err("no files".into())).unwrap_err(); + assert!(err.contains("the Cyan plate can't be read") && err.contains(why), "{err}"); + } + let bytes = single_file(&[("Cyan", plate)], ""); + assert!(place(&bytes, &read(&bytes).unwrap(), &mut |_| Err("no files".into())).is_ok(), "one image alone"); +} + +#[test] +fn a_plate_program_clipping_its_image_is_an_error() { + let clipped = plate_eps(1, 1, &[0]).replacen("20 10 scale", "newpath 0 0 moveto 5 0 lineto 5 5 lineto closepath clip newpath\n20 10 scale", 1); + let bytes = single_file(&[("Cyan", clipped)], ""); + let err = place(&bytes, &read(&bytes).unwrap(), &mut |_| Err("no files".into())).unwrap_err(); + assert!(err.contains("the Cyan plate can't be read") && err.contains("clipping path"), "{err}"); +} + +#[test] +fn only_an_image_inside_a_clipping_group_or_layer_is_clipped() { + use std::sync::Arc; + use vectorcraft_color::Paint; + use vectorcraft_doc::NodeId; + let clip_path = || { + let rect = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)); + let mut p = Node::path(NodeId(1), rect, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut p.kind { + *clipping = true; + } + Arc::new(p) + }; + let image = || { + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 1, + height: 1, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + Arc::new(Node::new(NodeId(2), NodeKind::Image(Box::new(im)))) + }; + let group = |children: Vec>, clip| Node::new(NodeId(3), NodeKind::Group { children, clip }); + let beside = group(vec![Arc::new(group(vec![clip_path()], true)), image()], false); + assert!(!clips_an_image(&beside, false), "a clipping group beside the image"); + assert!(clips_an_image(&group(vec![clip_path(), image()], true), false), "the image inside the clipping group"); + assert!(clips_an_image(&group(vec![image()], false), true), "inside a clipping layer"); +} + +#[test] +fn nested_clips_around_the_composite_clip_it_together() { + // The left two thirds of the page, then the right three quarters inside it: 5 to 13 pt. + let main = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n\ + gsave newpath 0 0 moveto 13 0 lineto 13 10 lineto 0 10 lineto closepath clip newpath\n\ + gsave newpath 5 0 moveto 20 0 lineto 20 10 lineto 5 10 lineto closepath clip newpath\n\ + 20 10 scale 2 2 8 [2 0 0 -2 0 2] <808080808080808080808080> false 3 colorimage grestore grestore showpage\n%%EOF\n"; + let clip = clip_path(main.as_bytes()).expect("a clipping path"); + let b = clip.geometric_bounds().expect("bounds"); + assert!((b.x0 - 5.0).abs() < 1e-6 && (b.x1 - 13.0).abs() < 1e-6, "where both clips overlap: {b:?}"); +} + +#[test] +fn plate_file_names_keep_their_spaces() { + let two = "%!PS-Adobe-3.0 EPSF-3.0\n%%PlateFile: (Cyan) EPS Local my pic .C\n%%EndComments\n"; + assert_eq!(read(two.as_bytes()).unwrap().plate_files().collect::>(), ["my pic .C"]); +} + +#[test] +fn header_bytes_that_are_not_utf8_are_latin1() { + // `Grün` with a raw Latin-1 ü (0xFC), in a plate name and in a custom color's name. + let mut two = b"%!PS-Adobe-3.0 EPSF-3.0\n%%CMYKCustomColor: .5 0 1 0 (Gr\xfcn)\n%%PlateFile: (Gr\xfcn) EPS Local a.9\n".to_vec(); + two.extend(b"%%PlateFile: (Grau \xc3\xa9) EPS Local b.9\n%%EndComments\n"); + let dcs = read(&two).unwrap(); + let names: Vec<&str> = dcs.plates.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Grün", "Grau é"], "a line that is UTF-8 is read as UTF-8"); + assert_eq!(dcs.equivalents, [("Grün".to_string(), [0.5, 0.0, 1.0, 0.0])]); +} + #[test] fn every_plate_is_read_with_the_clipping_path() { // 2 × 2 pixels: cyan solid on the top row, the spot ink at 50% on the first column. diff --git a/crates/pdf/src/print/plates.rs b/crates/pdf/src/print/plates.rs index 2001e7eb89..611b6822fa 100644 --- a/crates/pdf/src/print/plates.rs +++ b/crates/pdf/src/print/plates.rs @@ -139,9 +139,18 @@ impl<'a> Separator<'a> { plates(self.doc).into_iter().filter(|p| p.spot).map(|p| p.name).collect() }; let own = |name: &str| spots.contains(name); + // A spot plate printing as process prints as the document's swatch of its ink does, when + // there is one; else as the process equivalent its file gives. + let equivalent = |p: &vectorcraft_doc::separated::InkPlate| match self.doc.swatch(&p.name).and_then(|w| Some((w.paint.color()?, w.spot))) { + Some((c, spot)) => { + let c = self.doc.linked_color(c, spot); + cms::PROCESS_PLATES.map(|q| self.ink(q, &c, None)) + } + None => p.cmyk, + }; for blob in d.images.values_mut() { if let Some(s) = blob.separated() { - if let Some(gray) = s.plate_png(plate, &own) { + if let Some(gray) = s.plate_png(plate, &own, &equivalent) { *blob = gray; } continue; diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 3722511453..da81e70989 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -329,9 +329,9 @@ fn image_doc(blob: ImageBlob, w: u32, h: u32) -> Document { fn a_dcs_image_prints_each_plate_on_its_own_ink() { use vectorcraft_doc::separated::{InkPlate, Separated}; let plate = |name: &str, cmyk: [f32; 4], data: [u8; 2]| InkPlate { name: name.into(), cmyk, data: data.to_vec() }; - let s = Separated::new(2, 1, vec![plate("Cyan", [0.0; 4], [255, 0]), plate("Orange", [0.0, 0.6, 1.0, 0.0], [128, 0])]).unwrap(); + let s = Separated::new(2, 1, vec![plate("Cyan", [0.0; 4], [255, 0]), plate("Spot Orange", [0.0, 0.6, 1.0, 0.0], [128, 0])]).unwrap(); let mut d = image_doc(ImageBlob::separated_tiff(&s, None, &[]).unwrap(), 2, 1); - d.swatches.push(Swatch { name: "Orange".into(), paint: Paint::solid(Color::cmyk(0.0, 0.6, 1.0, 0.0)), global: true, spot: true }); + d.swatches.push(Swatch { name: "Spot Orange".into(), paint: Paint::solid(Color::cmyk(0.0, 0.6, 1.0, 0.0)), global: true, spot: true }); let plates = |spots_to_process: bool| { let settings: PrintSettings = serde_json::from_value(json!({"output": {"mode": "separations", "spotsToProcess": spots_to_process}})).unwrap(); let names: Vec = print_inks(&d, &settings).0.into_iter().map(|i| i.name).collect(); @@ -351,13 +351,47 @@ fn a_dcs_image_prints_each_plate_on_its_own_ink() { assert_eq!(get(&own, "Cyan"), Some(vec![255, 0])); assert_eq!(get(&own, "Magenta"), Some(vec![0, 0]), "the spot ink stays off the process plates"); assert_eq!(get(&own, "Yellow"), Some(vec![0, 0])); - assert_eq!(get(&own, "Orange"), Some(vec![128, 0])); + assert_eq!(get(&own, "Spot Orange"), Some(vec![128, 0])); let process = plates(true); - assert_eq!(get(&process, "Orange"), None); + assert_eq!(get(&process, "Spot Orange"), None); assert_eq!(get(&process, "Magenta"), Some(vec![77, 0]), "60% of the spot plate's 50%"); assert_eq!(get(&process, "Yellow"), Some(vec![128, 0])); } +/// A DCS spot plate that prints as process prints as the document's swatch of its ink does (the +/// swatch kept in place of the plate's), not as the equivalent its file gives. +#[test] +fn a_dcs_spot_plate_printing_as_process_takes_the_documents_swatch() { + use vectorcraft_doc::separated::{InkPlate, Separated}; + let plate = |name: &str, cmyk: [f32; 4], data: [u8; 2]| InkPlate { name: name.into(), cmyk, data: data.to_vec() }; + let s = Separated::new(2, 1, vec![plate("Cyan", [0.0; 4], [255, 0]), plate("Spot Orange", [0.0, 0.6, 1.0, 0.0], [0, 128])]).unwrap(); + let mut d = image_doc(ImageBlob::separated_tiff(&s, None, &[]).unwrap(), 2, 1); + let plates = |d: &Document, spots_to_process: bool| { + let settings: PrintSettings = serde_json::from_value(json!({"output": {"mode": "separations", "spotsToProcess": spots_to_process}})).unwrap(); + let names: Vec = print_inks(d, &settings).0.into_iter().map(|i| i.name).collect(); + let job = plan(d, &PrintOptions { settings, ..Default::default() }).unwrap(); + let coverage = job.plates.iter().map(|p| { + let NodeKind::Image(im) = &p.layers[0].children().unwrap()[0].kind else { panic!("an image") }; + p.image_blob(im).and_then(|b| b.coverage()).unwrap().2 + }); + names.into_iter().zip(coverage).collect::>() + }; + let get = |all: &[(String, Vec)], name: &str| all.iter().find(|(n, _)| n == name).map(|(_, c)| c.clone()); + // A spot swatch of the ink in another color. + d.swatches.push(Swatch { name: "Spot Orange".into(), paint: Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), global: true, spot: true }); + let process = plates(&d, true); + assert_eq!(get(&process, "Cyan"), Some(vec![255, 128]), "the swatch's cyan"); + assert_eq!(get(&process, "Magenta"), Some(vec![0, 0]), "not the file's magenta"); + assert_eq!(get(&process, "Yellow"), Some(vec![0, 0])); + // A process swatch of the ink's name: the job prints no plate for it. + d.swatches.retain(|w| w.name != "Spot Orange"); + d.swatches.push(Swatch { name: "Spot Orange".into(), paint: Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0)), global: true, spot: false }); + let own = plates(&d, false); + assert_eq!(get(&own, "Spot Orange"), None); + assert_eq!(get(&own, "Black"), Some(vec![0, 128]), "the swatch's black"); + assert_eq!(get(&own, "Yellow"), Some(vec![0, 0])); +} + #[test] fn the_output_settings_convert_cmyk_images_and_compression_recompresses_them_in_cmyk() { let ink = [0u8, 102, 255, 0]; diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 88359d547b..a6736b022d 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -83,9 +83,10 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], /// profiles. Each table is made when an image first needs it. pub(crate) struct PlateLut { proof: ProofSetup, - /// The document's spot inks, the names of its solid spot swatches: an image placed from a DCS - /// file shows a spot plate on its own ink only when it is one of them ([`plates_shown`]). - spots: Vec, + /// The document's solid swatches: name, whether it is a spot color, and its color. An image + /// placed from a DCS file shows a spot plate on its own ink only when it is one of the spot + /// ones, in that swatch's color, and else as the swatch of its name ([`plates_shown`]). + swatches: Vec<(String, bool, Color)>, cms: Arc, /// [`cms::profiles_generation`]. profiles: u64, @@ -116,18 +117,24 @@ static PLATE_GENERATION: AtomicU64 = AtomicU64::new(0); pub(crate) fn plate_lut(opts: &RenderOptions, doc: &Document) -> Option> { let proof = opts.proof.as_ref().filter(|p| p.separations.is_some())?; let (c, profiles) = (cms::active(), cms::profiles_generation()); - // The spot plates of `vectorcraft_doc::inks::plates`. - let spots: Vec = - doc.swatches_iter().filter(|s| s.spot && matches!(s.paint, vectorcraft_color::Paint::Solid { .. })).map(|s| s.name.clone()).collect(); + // The spot ones are the spot plates of `vectorcraft_doc::inks::plates`; their colors are part + // of the tables' identity, so a swatch edit shows. + let swatches: Vec<(String, bool, Color)> = doc + .swatches_iter() + .filter_map(|s| match &s.paint { + vectorcraft_color::Paint::Solid { color, .. } => Some((s.name.clone(), s.spot, doc.linked_color(*color, s.spot))), + _ => None, + }) + .collect(); let mut luts = PLATE_LUTS.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(l) = luts.iter().find(|l| Arc::ptr_eq(&l.cms, &c) && l.profiles == profiles && l.proof == *proof && l.spots == spots) { + if let Some(l) = luts.iter().find(|l| Arc::ptr_eq(&l.cms, &c) && l.profiles == profiles && l.proof == *proof && l.swatches == swatches) { return Some(l.clone()); } if luts.len() >= 4 { luts.remove(0); } let generation = PLATE_GENERATION.fetch_add(1, Ordering::Relaxed) + 1; - let l = Arc::new(PlateLut { proof: proof.clone(), spots, cms: c, profiles, generation, rgb: OnceLock::new(), cmyk: OnceLock::new() }); + let l = Arc::new(PlateLut { proof: proof.clone(), swatches, cms: c, profiles, generation, rgb: OnceLock::new(), cmyk: OnceLock::new() }); luts.push(l.clone()); Some(l) } @@ -177,36 +184,51 @@ pub(crate) fn plate_image(lut: &PlateLut, key: &ImageKey, pm: &Pixmap, blob: Opt (want, out) } -/// The visible plates of separated image `s` drawn at the size of `pm` (each pixel the plates' -/// nearest pixel): with one spot plate visible its ink as a grey, else the visible process inks -/// through `lut` with the visible spot inks multiplied on top, each in the color of its process -/// equivalent. A spot plate whose ink the document has no spot swatch for shows on the process -/// plates as its process equivalent, as it prints ([`vectorcraft_doc::separated::Separated::coverage`]). +/// The visible plates of separated image `s` drawn at the size of `pm` (each pixel the mean of the +/// plate pixels it covers, [`Fit`]): with one spot plate visible its ink as a grey, else the +/// visible process inks through `lut` with the visible spot inks multiplied on top, each in the +/// color of the document's spot swatch of its ink. A spot plate whose ink the document has no spot +/// swatch for shows on the process plates as it prints +/// ([`vectorcraft_doc::separated::Separated::coverage_as`]): as the document's swatch of its name +/// when there is one, else as the process equivalent its file gives. fn plates_shown(lut: &PlateLut, s: &vectorcraft_doc::separated::Separated, pm: &Pixmap) -> Pixmap { let visible = lut.proof.separations.as_deref().unwrap_or_default(); let shown = |name: &str| visible.iter().any(|v| v == name); - let own = |name: &str| lut.spots.iter().any(|v| v == name); + let swatch = |name: &str| lut.swatches.iter().find(|(n, ..)| n == name); + let own = |name: &str| swatch(name).is_some_and(|(_, spot, _)| *spot); + let (w, h) = (usize::from(pm.width()).max(1), usize::from(pm.height()).max(1)); + let fit = Fit::new(((s.width as usize).max(1), (s.height as usize).max(1)), (w, h)); let alone = match visible { [one] if !PROCESS_PLATES.contains(&one.as_str()) => Some(s.plates.iter().find(|p| p.name == *one && own(&p.name))), _ => None, }; - let spots: Vec<(&[u8], [f32; 3])> = - s.spots().filter(|p| own(&p.name) && shown(&p.name)).map(|p| (p.data.as_slice(), lut.cms.cmyk_to_srgb(p.cmyk, false))).collect(); + let spots: Vec<(&[u8], [f32; 3])> = s + .spots() + .filter(|p| own(&p.name) && shown(&p.name)) + .map(|p| (p.data.as_slice(), swatch(&p.name).map_or_else(|| lut.cms.cmyk_to_srgb(p.cmyk, false), |(.., c)| lut.cms.display_rgb(c)))) + .collect(); // The plates that print on the process plates, each with the process inks it stands for. - let process: Vec<(&[u8], [f32; 4])> = s.plates.iter().filter(|p| !p.spot() || !own(&p.name)).map(|p| (p.data.as_slice(), p.cmyk)).collect(); + let process: Vec<(&[u8], [f32; 4])> = s + .plates + .iter() + .filter(|p| !p.spot() || !own(&p.name)) + .map(|p| { + let inks = match swatch(&p.name) { + Some((.., c)) if p.spot() => lut.cms.to_cmyk(c, lut.proof.intent), + _ => p.cmyk, + }; + (p.data.as_slice(), inks) + }) + .collect(); let t = lut.cmyk.get_or_init(|| { CmykLut::build(|inks| { let [x, y, z] = lab::srgb_to_xyz(lut.shown(inks)); [x, y, z, 0.0] }) }); - let (w, h) = (usize::from(pm.width()).max(1), usize::from(pm.height()).max(1)); - let (sw, sh) = (s.width as usize, s.height as usize); let mut out = pm.clone(); - for (i, o) in out.data_mut().iter_mut().enumerate() { - let (x, y) = (i % w, i / w); - let at = (y * sh / h).min(sh.saturating_sub(1)) * sw + (x * sw / w).min(sw.saturating_sub(1)); - let ink = |data: &[u8]| f32::from(data.get(at).copied().unwrap_or(0)) / 255.0; + for (at, o) in out.data_mut().iter_mut().enumerate() { + let ink = |data: &[u8]| fit.ink(data, at); let rgb = match alone { Some(p) => [1.0 - p.map_or(0.0, |p| ink(&p.data)); 3], None => { @@ -234,6 +256,45 @@ fn plates_shown(lut: &PlateLut, s: &vectorcraft_doc::separated::Separated, pm: & out } +/// Plates of `from` (width, height) pixels drawn at `to` pixels: each pixel the mean of the plate +/// pixels it covers, so a reduced image (a smaller mip level) keeps the coverage the plate has; the +/// nearest plate pixel when it covers less than one. It keeps only the spans of each column and +/// row, not a copy of any plate. +struct Fit { + /// The plate's width. + sw: usize, + /// The shown width. + w: usize, + /// The plate columns each shown column covers, `start..end`. + columns: Vec<(usize, usize)>, + /// The plate rows each shown row covers, `start..end`. + rows: Vec<(usize, usize)>, +} + +impl Fit { + fn new((sw, sh): (usize, usize), (w, h): (usize, usize)) -> Self { + // The plate pixels each shown column or row covers: at least one. + // In 64 bits: a plate's width times the shown width may not fit 32 (the web build). + let span = |i: usize, n: usize, of: usize| { + let at = |i: usize| usize::try_from(i as u64 * of as u64 / n as u64).unwrap_or(of); + let start = at(i).min(of - 1); + (start, at(i + 1).clamp(start + 1, of)) + }; + Self { sw, w, columns: (0..w).map(|x| span(x, w, sw)).collect(), rows: (0..h).map(|y| span(y, h, sh)).collect() } + } + + /// The ink of plate `data` at shown pixel `at` (row by row), from 0 to 1. + fn ink(&self, data: &[u8], at: usize) -> f32 { + let (Some(&(x0, x1)), Some(&(y0, y1))) = (self.columns.get(at % self.w), self.rows.get(at / self.w)) else { return 0.0 }; + let mut sum = 0u64; + for row in y0..y1 { + sum += data.get(row * self.sw + x0..row * self.sw + x1).map_or(0, |r| r.iter().map(|&v| u64::from(v)).sum()); + } + let n = ((y1 - y0) * (x1 - x0)) as u64; + sum as f32 / n as f32 / 255.0 + } +} + /// The art a renderer drew with Overprint Preview or Separations Preview, kept for its next frame /// ([`prepare`]): the document's layers and symbol art as they were, each beside the art drawn for /// it, and what that art was made with. The kept nodes keep their addresses: no other node gets diff --git a/crates/render/src/tests/colorize.rs b/crates/render/src/tests/colorize.rs index 099a08aed6..721707502d 100644 --- a/crates/render/src/tests/colorize.rs +++ b/crates/render/src/tests/colorize.rs @@ -225,6 +225,70 @@ fn separations_preview_shows_a_dcs_image_through_its_own_plates() { assert!(magenta[..3].iter().all(|v| *v < 20), "the Magenta plate alone shows it as solid ink, got {magenta:?}"); } +/// A document of 40 × 20 pt holding separated image `s` (2 × 1 pixels) at 20 pt a pixel. +fn separated_doc(s: &vectorcraft_doc::separated::Separated) -> Document { + let mut d = Document::new_with_mode(40.0, 20.0, vectorcraft_doc::ColorMode::Cmyk); + d.images.insert("pic".into(), ImageBlob::separated_tiff(s, None, &[]).unwrap()); + let im = ImageObject { + key: "pic".into(), + width: s.width, + height: s.height, + xf: Affine::scale(40.0 / f64::from(s.width)), + link: None, + placement: Default::default(), + pixels: None, + }; + let (layer, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + d +} + +/// A DCS spot plate shows in the color of the document's swatch of its ink, as the art on that ink +/// does, and follows an edit of the swatch. +#[test] +fn separations_preview_colors_a_dcs_spot_plate_by_the_documents_swatch() { + use vectorcraft_doc::separated::{InkPlate, Separated}; + // The file says the ink is red; the document's swatch of it is cyan. + let s = Separated::new(2, 1, vec![InkPlate { name: "Ink".into(), cmyk: [0.0, 1.0, 1.0, 0.0], data: vec![255, 0] }]).unwrap(); + let mut d = separated_doc(&s); + d.swatches.push(vectorcraft_color::Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), global: true, spot: true }); + let all = ["Cyan", "Magenta", "Yellow", "Black", "Ink"]; + let left = |d: &Document, plates: &[&str]| Renderer::new().render(d, 40, 20, Affine::IDENTITY, &separations(plates)).pixel(10, 10); + let p = left(&d, &all); + assert!(p[0] < 100 && p[2] > 150, "the swatch's cyan, not the file's red, got {p:?}"); + // The swatch edited to yellow: the image follows. + if let Some(w) = d.swatches.iter_mut().find(|w| w.name == "Ink") { + w.paint = Paint::solid(Color::cmyk(0.0, 0.0, 1.0, 0.0)); + } + let p = left(&d, &all); + assert!(p[0] > 150 && p[2] < 100, "the edited swatch's yellow, got {p:?}"); + // A process swatch of the ink's name: the plate shows on the process plates as that swatch. + if let Some(w) = d.swatches.iter_mut().find(|w| w.name == "Ink") { + (w.paint, w.spot) = (Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), false); + } + let p = left(&d, &all[..4]); + assert!(p[0] < 100 && p[2] > 150, "the process swatch's cyan, got {p:?}"); +} + +/// A DCS image drawn reduced (from a smaller mip level) shows the coverage its plate has there, +/// the mean of the plate pixels each reduced pixel covers. +#[test] +fn separations_preview_keeps_a_reduced_dcs_plates_coverage() { + use vectorcraft_doc::separated::{InkPlate, Separated}; + // 64 × 64 pixels, the spot ink solid on every other column: 50% coverage. + let data: Vec = (0..64 * 64).map(|i| if i % 2 == 0 { 255 } else { 0 }).collect(); + let s = Separated::new(64, 64, vec![InkPlate { name: "Ink".into(), cmyk: [0.0, 1.0, 1.0, 0.0], data }]).unwrap(); + let mut d = Document::new_with_mode(64.0, 64.0, vectorcraft_doc::ColorMode::Cmyk); + d.swatches.push(vectorcraft_color::Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), global: true, spot: true }); + d.images.insert("pic".into(), ImageBlob::separated_tiff(&s, None, &[]).unwrap()); + let im = ImageObject { key: "pic".into(), width: 64, height: 64, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let (layer, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + let opts = RenderOptions { image_mips: true, ..separations(&["Ink"]) }; + let p = Renderer::new().render(&d, 16, 16, Affine::scale(0.25), &opts).pixel(8, 8); + assert!(p[..3].iter().all(|v| (100..=160).contains(v)), "half the ink, got {p:?}"); +} + /// Separations Preview showing `plates`, as the canvas draws it. fn separations(plates: &[&str]) -> RenderOptions { let proof = proof::ProofSetup { separations: Some(plates.iter().map(|p| p.to_string()).collect()), ..Default::default() }; diff --git a/docs/mcp.md b/docs/mcp.md index c85e3fba8e..944ffabe25 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5720,20 +5720,30 @@ plate, ) is placed only from its `path` be placed linked" and change nothing. The plates are read from the file (`%%PlateFile: (name) EPS #offset size`) or from the plate files it names (`%%PlateFile: (name) EPS Local file`, and the `%%CyanPlate:`, `%%MagentaPlate:`, `%%YellowPlate:` and `%%BlackPlate:` comments of DCS 1.0), which are looked for by file name in the DCS file's folder. -Each plate is the first image its program draws, and a dark pixel is ink. Each spot plate adds a spot swatch (when +Each plate is the one image its program draws, and a dark pixel is ink; 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. Each spot plate adds a spot swatch (when the file is placed, and when `links.update` or `links.relink` reads it) in the CMYK values of the file's `%%CMYKCustomColor:` comment (else `%%RGBCustomColor:`; black, with a warning, when the file -gives neither). A swatch of the same name that the document already has is kept, with a warning when it is not a spot -color of the same values; a plate whose name is a process swatch prints as process inks. `swatch.unused` does not -list a spot swatch while such an image has a plate of its ink; deleting the swatch makes the plate print as its CMYK -values. The clipping path the file's composite is drawn through clips the image (a clipping group); `links.update` and -`links.relink` clip the image by the file's clipping path again (in a new clipping group when none holds it), unless -Placement Options clip the image; when the file no longer has one, the clipping group stays. The link shows as -modified when a plate file of its own changes or is gone, as it does when the DCS file changes. These rules are -VectorCraft's own: the screen, composite print and exports show the image as its plates mixed into CMYK (each -ink amount times the ink's CMYK values, plates combined as `1 − (1 − a)(1 − b)`); Separations Preview and -`file.print` separations show each plate on its own ink, and a spot plate prints as its CMYK values with -`spotsToProcess`. A file may name 64 plates, a plate file may be 1 GB, and the plates together may hold 512 MB of +gives neither). A swatch of the same name that the document already has is kept, with a warning (in `warnings` of +`file.place`, `links.update` and `links.relink`) when it is not a spot color of the same values; Separations Preview +and `file.print` separations then show and print the plate in that swatch's color, and a plate whose name is a +process swatch prints as process inks in that swatch's color. `swatch.unused` does not list a spot or process swatch +while such an image that the art shows has a plate of its name; deleting the swatch makes the plate print as its CMYK values. The clipping path the +file's composite is drawn through clips the image (a clipping group, its path under the nonzero fill rule, an even-odd +clipping path kept as the same area), and `file.place` answers the image's id as +`image`, since the Links commands take the image and not the group. `links.update` and `links.relink` clip the image +by the file's clipping path again (in a new clipping group when none holds it, and inside a clipping mask made in the +document, whose path is kept), unless Placement Options clip the image, the same way for the images in symbols, graph designs, patterns and opacity masks; when the new file has no +clipping path, a clipping group holding just the image whose path is the old file's clipping path loses that path and +stays as a plain group of the image, with its id, name, visibility and transparency (one whose path was given the +even-odd fill rule stays as it is). The link shows as modified when a plate file of its own changes +or is gone, as it does when the DCS file changes, and the app's link watch acts on a changed plate file as on a +changed DCS file. `links.relink {copy: true}` fails for a DCS file that reads plate files of its own, and copies +nothing. These rules are VectorCraft's own: the screen, composite print and exports show the image as its plates +mixed into CMYK (each ink amount times the ink's CMYK values, plates combined as `1 − (1 − a)(1 − b)`); Separations +Preview and `file.print` separations show each plate on its own ink, and a spot plate prints as the document's swatch +of its ink (else its CMYK values) with `spotsToProcess`. Separations Preview shows a reduced image's plates as the +mean of the plate pixels each shown pixel covers. A file may name 64 plates, a plate file may be 1 GB, and the plates together may hold 512 MB of ink amounts; a plate that can't be read is an error naming the plate, and nothing is placed. The plates go on the first plate's pixel grid, grown to hold every plate where it prints; a plate of another size or position is resampled onto it, and a file naming more than 64 plates is an error. `file.place.info` answers `plates` (the ink names) for a DCS file. @@ -6419,7 +6429,8 @@ DCS Transparency Interactions, off by default) adds `dcsTransparency: true` to t [are transparent or overlap transparent objects](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/eps-and-dcs-file-import-in-illustrator.html), and the panel shows a warning mark on them. VectorCraft's rule for it: the image, or a group or layer holding it, has an opacity below 100%, a blend mode other than Normal, an opacity mask or an effect, or its bounds overlap the -bounds of another visible object that has one of those or a gradient with a transparent stop. The check looks at +bounds of another visible object that has one of those or a gradient with a transparent stop (in a fill, a stroke or +a character's fill or stroke); a DCS image with transparency of its own counts as such an object. The check looks at every object in the layers each time the list is read. A `dcsTransparency` that is not true or false is an error. `links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file