diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index f94f0ec4a8..ea2c44215e 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -150,7 +150,7 @@ fn planar(d: &mut tiff::decoder::Decoder>) -> bool { /// stores each pixel's samples together or one plane per channel. `None` when they don't decode to /// that many samples, which includes images whose planes together need more than the decoder's /// limit (256 MB of samples). -fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { +pub(crate) fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { let mut buf = tiff::decoder::DecodingResult::U8(Vec::new()); let layout = d.read_image_to_buffer(&mut buf).ok()?; let data = match buf { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3c4bcc20a5..15de79660f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -62,6 +62,7 @@ pub mod rastersettings; mod reach; pub mod recolor; pub mod selection; +pub mod separated; pub mod setup; pub mod shaper; pub mod slices; diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 415b2946e3..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) } @@ -827,21 +828,31 @@ impl Node { /// [`Self::shows_transparency`], counting a colorized image by itself only when `colorize` /// accepts it, for files that write some colorized images in their colorize paint (EPS). pub fn shows_transparency_with(&self, colorize: &dyn Fn(&Node) -> bool) -> bool { + self.visible && (self.own_transparency(colorize) || self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency_with(colorize)))) + } + + /// [`Self::shows_transparency`] of the object alone, what it holds left out. + pub fn shows_own_transparency(&self) -> bool { + self.visible && self.own_transparency(&|_| true) + } + + /// The object's own transparency, as [`Self::shows_transparency_with`] counts it. + fn own_transparency(&self, colorize: &dyn Fn(&Node) -> bool) -> bool { use vectorcraft_color::Paint; let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0)); let effects = |fx: &[crate::Effect]| fx.iter().any(|e| e.visible); - self.visible - && (self.opacity < 1.0 - || self.blend != BlendMode::Normal - || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) - || (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some() && colorize(self)) - || effects(&self.appearance.effects) - || self - .appearance - .items - .iter() - .any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects()))) - || self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency_with(colorize)))) + self.opacity < 1.0 + || self.blend != BlendMode::Normal + || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) + || (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some() && colorize(self)) + || effects(&self.appearance.effects) + || self + .appearance + .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 new file mode 100644 index 0000000000..8eb7cefe66 --- /dev/null +++ b/crates/doc/src/separated.rs @@ -0,0 +1,533 @@ +//! Separated images: an image whose inks come as one plate each (a DCS file placed linked), kept +//! as a TIFF of several images. The first is the composite, the plates mixed into CMYK +//! ([`Separated::composite`]), which every reader of a CMYK TIFF sees. Each later image is one +//! plate's ink amounts in grayscale (0 is no ink), with its ink's name and CMYK equivalent in its +//! ImageDescription. Print separations take each plate from these ([`Separated::coverage`]). + +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). +pub const MAX_SAMPLES: u64 = 1 << 29; +/// How a plate's ImageDescription starts. +const MARK: &str = "VectorCraft plate "; + +/// How the composite's ImageDescription starts when plates were read from files of their own. +const FILES_MARK: &str = "VectorCraft plate files"; + +/// A plate file of its own that a separated image was read from, as it was then: a change to it +/// changes the image as a change to the DCS file does. +#[derive(Clone, Debug, PartialEq)] +pub struct PlateFile { + /// Its file name, in the DCS file's folder. + pub name: String, + /// Its size (bytes). + pub size: u64, + /// Its modification time (ms since the Unix epoch; 0 when the file system keeps none). + pub modified: u64, +} + +/// `text` in hexadecimal (TIFF text is ASCII). +fn hex(text: &str) -> String { + text.bytes().map(|b| format!("{b:02x}")).collect() +} + +/// The text [`hex`] wrote, of at most `max` bytes. +fn unhex(hex: &str, max: usize) -> Option { + if !hex.len().is_multiple_of(2) || hex.len() > max * 2 { + return None; + } + let bytes: Option> = (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok()).collect(); + String::from_utf8(bytes?).ok() +} + +/// One ink's plate: its ink amounts, one byte a pixel (0 is none, 255 is solid), row by row from +/// the top. +#[derive(Clone, Debug, PartialEq)] +pub struct InkPlate { + /// "Cyan", "Magenta", "Yellow", "Black", or a spot ink's name. + pub name: String, + /// The process inks the ink stands for (C, M, Y, K, 0..1): one process ink at 100% for a + /// process plate. + pub cmyk: [f32; 4], + pub data: Vec, +} + +impl InkPlate { + /// Is it a spot ink's plate (not one of the four process inks)? + pub fn spot(&self) -> bool { + !PROCESS_PLATES.contains(&self.name.as_str()) + } +} + +/// The plates of a separated image, all of one size. +#[derive(Clone, Debug, PartialEq)] +pub struct Separated { + pub width: u32, + pub height: u32, + pub plates: Vec, +} + +impl Separated { + /// `plates` as a `width` × `height` image; `None` when there are none or more than + /// [`MAX_PLATES`], two have the same name, a name is empty or longer than [`MAX_NAME`], a plate + /// doesn't hold that many pixels, an equivalent isn't within 0..1, or the image is empty, has + /// more than [`MAX_CMYK_PIXELS`], or its plates together hold more than [`MAX_SAMPLES`] + /// ([`Self::fits`]). A process plate's equivalent is its own ink. + pub fn new(width: u32, height: u32, mut plates: Vec) -> Option { + let n = u64::from(width) * u64::from(height); + if !Self::fits(width, height, plates.len()) { + return None; + } + for p in plates.iter_mut() { + if p.name.is_empty() || p.name.len() > MAX_NAME || p.data.len() as u64 != n { + return None; + } + if let Some(j) = PROCESS_PLATES.iter().position(|q| *q == p.name) { + p.cmyk = [0.0; 4]; + p.cmyk[j] = 1.0; + } + if !p.cmyk.iter().all(|v| (0.0..=1.0).contains(v)) { + return None; + } + } + let mut names = std::collections::HashSet::new(); + if !plates.iter().all(|p| names.insert(p.name.as_str())) { + return None; + } + Some(Self { width, height, plates }) + } + + /// May an image of `width` × `height` pixels hold `plates` plates? Not when it is empty, has + /// more than [`MAX_CMYK_PIXELS`], has no plate or more than [`MAX_PLATES`], or the plates + /// together would hold more than [`MAX_SAMPLES`]. + pub fn fits(width: u32, height: u32, plates: usize) -> bool { + let n = u64::from(width) * u64::from(height); + n > 0 && n <= MAX_CMYK_PIXELS && (1..=MAX_PLATES).contains(&plates) && n * plates as u64 <= MAX_SAMPLES + } + + /// The plates of spot inks. + pub fn spots(&self) -> impl Iterator { + self.plates.iter().filter(|p| p.spot()) + } + + /// The plates mixed into process inks: each plate's ink amount times its equivalent, the + /// plates over one another as inks print (`1 − (1 − a)(1 − b)` for each process ink). + pub fn composite(&self) -> Inks { + let n = self.data_len(); + let mut data = vec![0u8; n * 4]; + for (i, px) in data.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let mut clear = [1.0f32; 4]; + for p in &self.plates { + let t = f32::from(p.data[i]) / 255.0; + for (c, e) in clear.iter_mut().zip(p.cmyk) { + *c *= 1.0 - t * e; + } + } + for (o, c) in px.iter_mut().zip(clear) { + *o = ((1.0 - c) * 255.0).round() as u8; + } + } + Inks { width: self.width, height: self.height, data } + } + + /// The ink amounts that print on plate `plate` (a process ink's name or a spot ink's), one byte + /// a pixel. A spot plate for which `own` is true prints on its own plate only; every other spot + /// plate (the job prints no plate of that name) prints as its process equivalent, mixed as + /// [`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, 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, e)| c * (1.0 - f32::from(p.data[i]) / 255.0 * e)); + ((1.0 - clear) * 255.0).round() as u8 + }) + .collect(); + } + match self.plates.iter().find(|p| p.name == plate && p.spot() && own(&p.name)) { + Some(p) => p.data.clone(), + None => vec![0; n], + } + } + + /// [`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, 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()?; + Some(ImageBlob::png(png)) + } + + fn data_len(&self) -> usize { + self.width as usize * self.height as usize + } +} + +/// A plate's ImageDescription: the mark, its equivalent and its name in hexadecimal (TIFF text is +/// ASCII). +fn description(p: &InkPlate) -> String { + let [c, m, y, k] = p.cmyk; + format!("{MARK}{c} {m} {y} {k} {}", hex(&p.name)) +} + +/// A plate's name and equivalent from its ImageDescription; `None` when it isn't one. +fn parse_description(s: &str) -> Option<(String, [f32; 4])> { + let rest = s.strip_prefix(MARK)?; + let mut words = rest.split(' '); + let mut cmyk = [0.0f32; 4]; + for v in &mut cmyk { + *v = words.next()?.parse().ok().filter(|v: &f32| v.is_finite())?; + } + let name = unhex(words.next()?, MAX_NAME)?; + words.next().is_none().then_some((name, cmyk)) +} + +/// Longest plate file name kept (bytes). +const MAX_FILE_NAME: usize = 4096; + +/// 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) { + out.push_str(&format!(" {} {} {}", hex(&f.name), f.size, f.modified)); + } + 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.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()? }); + words.chunks(3).take(MAX_PLATES).filter_map(file).collect() +} + +impl ImageBlob { + /// A TIFF of separated image `s`: its composite in CMYK, then its plates (Deflate-compressed), + /// declaring the resolution `ppi` when it is given and both are finite and above 0, and noting + /// `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)); + // Ten-thousandths of a pixel per inch. + let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 }; + let mut out = Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced)); + let mut img = enc.new_image::(s.width, s.height).ok()?; + if let Some((x, y)) = ppi { + img.encoder().write_tag(Tag::XResolution, r(x)).ok()?; + img.encoder().write_tag(Tag::YResolution, r(y)).ok()?; + img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).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 { + // White is no ink, so a viewer that shows a plate shows it as it prints. + let mut img = enc.new_image::(s.width, s.height).ok()?; + img.encoder().write_tag(Tag::ImageDescription, description(p).as_str()).ok()?; + let inverted: Vec = p.data.iter().map(|v| 255 - v).collect(); + img.write_data(&inverted).ok()?; + } + Some(Self::new("image/tiff", out.into_inner())) + } + + /// 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() { + 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)) { + plates += 1; + if plates > MAX_PLATES { + return false; + } + } + } + plates > 0 + } + + /// The plate files of their own a separated image was read from, as they were then; none for + /// other images. Reads only the TIFF's first directory. + pub fn plate_files(&self) -> Vec { + let Some((_, mut d)) = crate::cmyk::tiff(&self.bytes) else { return vec![] }; + 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 { + if d.next_image().is_err() { + break; + } + let plate = d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok().as_deref().and_then(parse_description); + inks.extend(plate.filter(|(name, _)| !PROCESS_PLATES.contains(&name.as_str()))); + } + inks + } + + /// The plates of a separated image; `None` for other images, and ones whose plates can't be + /// read. The plates of the last image read are kept for the next call (print separates one + /// image plate after plate). + pub fn separated(&self) -> Option> { + thread_local! { + static LAST: std::cell::RefCell)>> = const { std::cell::RefCell::new(None) }; + } + if !self.is_separated() { + return None; + } + let key = self.content_key(); + LAST.with_borrow_mut(|last| match last { + Some((k, s)) if *k == key => Some(s.clone()), + _ => { + let s = Arc::new(read_plates(&self.bytes)?); + *last = Some((key, s.clone())); + Some(s) + } + }) + } +} + +/// The plates of TIFF `bytes`, each the size of its first image. +fn read_plates(bytes: &[u8]) -> Option { + let (_, mut d) = crate::cmyk::tiff(bytes)?; + let (w, h) = d.dimensions().ok()?; + let n = usize::try_from(u64::from(w) * u64::from(h)).ok()?; + let mut plates = vec![]; + // 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; + }; + // Checked before the plate is decoded. + if d.dimensions().ok()? != (w, h) || !matches!(d.colortype().ok()?, tiff::ColorType::Gray(8)) || !Separated::fits(w, h, plates.len() + 1) { + return None; + } + let data = crate::cmyk::read_samples(&mut d, n, 1)?.into_iter().map(|v| 255 - v).collect(); + plates.push(InkPlate { name, cmyk, data }); + } + Separated::new(w, h, plates) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plate(name: &str, cmyk: [f32; 4], data: &[u8]) -> InkPlate { + InkPlate { name: name.into(), cmyk, data: data.to_vec() } + } + + /// Two pixels: cyan 100% and a spot ink at 50% on the first, nothing on the second. + fn two() -> Separated { + let plates = vec![plate("Cyan", [0.0; 4], &[255, 0]), plate("Spot Orange", [0.0, 0.6, 1.0, 0.0], &[128, 0])]; + Separated::new(2, 1, plates).unwrap() + } + + #[test] + fn plates_mix_into_the_composite_as_inks_print() { + let s = two(); + assert_eq!(s.plates[0].cmyk, [1.0, 0.0, 0.0, 0.0], "a process plate stands for its own ink"); + let c = s.composite(); + // Magenta: 0.6 × 128/255; yellow: 128/255. + assert_eq!(&c.data[..4], &[255, 77, 128, 0]); + assert_eq!(&c.data[4..], &[0, 0, 0, 0]); + } + + #[test] + fn a_spot_plate_prints_on_its_own_plate_or_as_process() { + let s = two(); + let own = |_: &str| true; + assert_eq!(s.coverage("Spot Orange", &own), [128, 0]); + assert_eq!(s.coverage("Magenta", &own), [0, 0], "nothing of the spot on a process plate"); + assert_eq!(s.coverage("Cyan", &own), [255, 0]); + assert_eq!(s.coverage("Black", &own), [0, 0]); + let process = |_: &str| false; + assert_eq!(s.coverage("Spot Orange", &process), [0, 0]); + assert_eq!(s.coverage("Magenta", &process), [77, 0]); + assert_eq!(s.coverage("Yellow", &process), [128, 0]); + assert_eq!(s.coverage("Other", &own), [0, 0]); + } + + #[test] + fn the_tiff_keeps_every_plate_and_shows_the_composite() { + let s = two(); + let files = + [PlateFile { name: "pic (1).C".into(), size: 1234, modified: 1_700_000_000_000 }, PlateFile { name: "ü.K".into(), size: 0, modified: 0 }]; + let b = ImageBlob::separated_tiff(&s, Some((300.0, 300.0)), &files).unwrap(); + assert_eq!(b.plate_files(), files); + assert!(ImageBlob::separated_tiff(&s, None, &[]).unwrap().plate_files().is_empty()); + assert!(b.is_separated()); + assert!(b.is_cmyk(), "readers of a CMYK TIFF see the composite"); + assert_eq!(b.cmyk(), Some(s.composite())); + assert_eq!(*b.separated().unwrap(), s); + assert_eq!(b.spot_plates(), [("Spot Orange".to_string(), [0.0, 0.6, 1.0, 0.0])]); + assert!(ImageBlob::cmyk_tiff(&s.composite()).unwrap().spot_plates().is_empty()); + 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(); + let b = ImageBlob::separated_tiff(&s, None, &[]).unwrap(); + assert_eq!(b.separated().unwrap().plates[0].name, "Grün 100 % (A)"); + } + + #[test] + fn bad_plates_are_refused() { + assert!(Separated::new(2, 1, vec![]).is_none(), "no plates"); + assert!(Separated::new(2, 1, vec![plate("Cyan", [0.0; 4], &[1])]).is_none(), "too few pixels"); + assert!(Separated::new(1, 1, vec![plate("A", [0.0; 4], &[1]), plate("A", [0.0; 4], &[1])]).is_none(), "the same name twice"); + assert!(Separated::new(1, 1, vec![plate("", [0.0; 4], &[1])]).is_none(), "no name"); + assert!(Separated::new(1, 1, vec![plate("A", [2.0, 0.0, 0.0, 0.0], &[1])]).is_none(), "equivalent past 100%"); + assert!(Separated::new(0, 1, vec![plate("A", [0.0; 4], &[])]).is_none(), "empty"); + let many: Vec = (0..=MAX_PLATES).map(|i| plate(&format!("S{i}"), [0.0; 4], &[1])).collect(); + assert!(Separated::new(1, 1, many).is_none(), "too many plates"); + assert!(Separated::fits(1 << 13, 1 << 13, 8), "512 MB of ink amounts"); + assert!(!Separated::fits(1 << 13, 1 << 13, 9), "more than 512 MB together"); + assert!(!Separated::fits(1 << 14, 1 << 13, 1), "more pixels than a CMYK image is decoded with"); + } +} diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 93679ca71d..91f73049f1 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -866,7 +866,8 @@ fn masks_surface(paint: &Paint, colored: bool) -> bool { } /// The graphics of the solids `filled` ([`graphic_art`]), each with the index of the surface it is -/// drawn right after (the last of those solids holding its face) and its face. With `per_member` +/// drawn right after (the last of those solids holding its face) and its face. Solids in a run +/// with the same opacity and blending mode share their graphics. With `per_member` /// (a member's opacity mask or blending mode applies to its surfaces) each member's solids get /// graphics of their own, clipped to that member's caps, so they stay in that member's scopes. /// All the groups share one limit on the path elements drawn: once it is reached, later groups @@ -883,11 +884,12 @@ fn placed_graphics( for f in filled { // Solids whose fills or strokes differ in opacity or blending mode get graphics of their // own, each drawn as its fill or stroke is. A solid whose surface carries an opacity mask - // gets its own too, which takes that mask ([`with_graphics`]). + // gets its own too, which takes that mask ([`with_graphics`]). A solid joins only the + // group of the solid before it, so a graphic is never drawn past a solid painted between. let joins = |first: &Filled| { (!per_member || first.member == f.member) && first.opacity == f.opacity && first.blend == f.blend && !first.masked && !f.masked }; - match groups.iter_mut().find(|g| g.first().is_some_and(|first| joins(first))) { + match groups.last_mut().filter(|g| g.first().is_some_and(|first| joins(first))) { Some(g) => g.push(f), None => groups.push(vec![f]), } diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index 64c947a9df..aa73535fc5 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -365,6 +365,41 @@ fn members_with_their_own_blending_get_graphics_of_their_own() { assert_eq!(blended, 1); } +#[test] +fn a_graphic_stays_under_a_solid_painted_after_its_own() { + let d = document(); + let member = |id: u64, r: Rect, color: Color, opacity: f32| { + let mut n = square(id, r, Paint::solid(color)); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.opacity = opacity; + } + Arc::new(n) + }; + let gray = Color::rgb(0.5, 0.5, 0.5); + // A and C share their fill's opacity; B, painted between them, has its own and covers part of A. + let a = member(1, Rect::new(0.0, 0.0, 60.0, 100.0), gray, 1.0); + let b = member(2, Rect::new(40.0, 0.0, 100.0, 100.0), Color::rgb(0.0, 0.0, 1.0), 0.5); + let c = member(4, Rect::new(200.0, 0.0, 260.0, 100.0), gray, 1.0); + let mut group = Node::group(NodeId(3), vec![a, b, c]); + let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false, "graphics": [{"symbol": "Mark", "scale": 3000.0}]}); + group.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + let art = solid_art_in(Some(&d), &group).unwrap(); + // In drawing order: 'G' for a graphic, 'B' for a face of B's solid. + let mut order = String::new(); + art.walk(&mut |n| { + if matches!(n.kind, NodeKind::Group { clip: true, .. }) && !graphics_in(n).is_empty() { + order.push('G'); + } else if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() + && f.paint.color().is_some_and(|c| c.to_rgb()[2] > 0.9 && c.to_rgb()[0] < 0.1) + { + order.push('B'); + } + }); + // A's graphic is drawn right after A, under B; C's is drawn after B. + assert_eq!(graphics_in(&art).len(), 3, "{order}"); + assert!(order.starts_with('G'), "{order}"); +} + /// [`document`] with symbol "Dense" too: a red polygon of 30,000 sides, straight or `curved`. fn dense_document(curved: bool) -> Document { let mut d = document(); diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 88300586b3..9185801f6b 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -886,6 +886,108 @@ fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() { } } +/// Under a Warp effect a hidden path in a layer that keeps its shape past the budget does not hold +/// the layer's gradient in place either: it moves and scales from the layer's bounds to the warped +/// square's bounds, as the art and inside the art. +#[test] +fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_follow_a_warp() { + use std::sync::Arc; + + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{AppearanceItem, FillLayer, LayerColor, NodeId, NodeKind, color::Paint}; + let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)]; + let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let ends = |ap: &Appearance| match ap.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| (g.start, g.end)), + _ => None, + }; + let oval = || shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)); + // A layer with a visible square and a hidden circle, both 0..100, and a gradient fill of its own. + let layer = || { + let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + let mut circle = Node::path(NodeId(4), oval(), Appearance::default()); + circle.visible = false; + let mut layer = Node::layer(NodeId(2), "Layer 1", LayerColor::Preset(0)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![square, Arc::new(circle)]; + } + layer.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + layer + }; + // The box map from the layer's bounds (the square's) to `b`: (0, 50) and (100, 50) go to the + // middles of b's sides. + let near = |got: Option<(Point, Point)>, b: Rect| { + let mid = (b.y0 + b.y1) / 2.0; + got.is_some_and(|(s, e)| s.distance(Point::new(b.x0, mid)) < 1e-6 && e.distance(Point::new(b.x1, mid)) < 1e-6) + }; + for style in ["arc", "inflate", "flag"] { + let warp = fx(&format!("warp.{style}"), json!({"bend": 40.0, "horizontal": 20.0})); + // Room for the warped square beside the circle as it was, and not for the warped circle + // beside the warped square: the paths take the budget in turn, so the circle keeps its shape. + let mut probe = layer(); + crate::reshape::reshape_leaves_within(&mut probe, &[&warp], Budget::EFFECTS, None); + let warped = |k: usize| util::line_estimate(probe.children().unwrap()[k].path_data().unwrap()); + let circle = util::line_estimate(&oval()); + assert!(warped(1) > circle, "warp.{style}: the warped circle is drawn with more lines"); + let budget = Budget { anchors: usize::MAX, lines: warped(0) + circle }; + let mut art = layer(); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&warp], budget, None); + assert_eq!(art.children().unwrap()[1].path_data(), Some(&oval()), "{}: the hidden circle keeps its shape", warp.id); + let square = art.children().unwrap()[0].geometric_bounds().unwrap(); + assert!(square != Rect::new(0.0, 0.0, 100.0, 100.0), "{}: the square takes the effect: {square:?}", warp.id); + let mut own = Appearance::clone(&art.appearance); + carry.apply(&mut own); + assert!(near(ends(&own), square), "{}: the layer's gradient, as the art: {:?} for {square:?}", warp.id, ends(&own)); + let mut art = Node::group(NodeId(9), vec![Arc::new(layer())]); + crate::reshape::reshape_leaves_within(&mut art, &[&warp], budget, None); + let got = ends(&art.children().unwrap()[0].appearance); + assert!(near(got, square), "{}: the layer's gradient, inside the art: {got:?} for {square:?}", warp.id); + } +} + +/// A freeform gradient whose points were edited moves its points with a Warp effect from the +/// bounds before the warp to the bounds after it, on a path, on a group inside the art and on the +/// art itself, as a placed gradient does. +#[test] +fn edited_freeform_points_follow_a_warp() { + use std::sync::Arc; + + use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint}; + use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint}; + let mut ramp = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Gradient::default() }); + let pt = |x: f64, c| FreeformPoint::new(Point::new(x, 50.0), c); + ramp.set_freeform(Freeform { points: vec![pt(0.0, Color::BLACK), pt(100.0, Color::WHITE)], ..Default::default() }); + assert!(ramp.geom.is_none() && !ramp.is_placed()); + let freeform = Paint::Gradient(Box::new(ramp)); + let points = |ap: &Appearance| match ap.fill_paint() { + Paint::Gradient(g) => g.freeform.as_ref().map(|f| f.points.iter().map(|p| p.at).collect::>()), + _ => None, + }; + let square = |ap: Appearance| Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap)); + // The box map from the square's bounds to `b`: (0, 50) and (100, 50) go to the middles of b's sides. + let near = |got: Option>, b: Rect| { + let mid = (b.y0 + b.y1) / 2.0; + got.is_some_and(|p| p.len() == 2 && p[0].distance(Point::new(b.x0, mid)) < 1e-6 && p[1].distance(Point::new(b.x1, mid)) < 1e-6) + }; + for style in ["arc", "inflate", "rise"] { + let e = fx(&format!("warp.{style}"), json!({"bend": 40.0, "vertical": 20.0})); + let mut inner = Node::group(NodeId(2), vec![square(Appearance::default())]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); + let mut art = Node::group(NodeId(9), vec![square(Appearance::basic(freeform.clone(), Paint::None, 0.0)), Arc::new(inner)]); + art.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS, None); + let b = art.geometric_bounds().unwrap(); + assert!(b != Rect::new(0.0, 0.0, 100.0, 100.0), "{}: the warp changes the bounds: {b:?}", e.id); + let members = art.children().unwrap(); + assert!(near(points(&members[0].appearance), b), "{}: the path's points {:?} for {b:?}", e.id, points(&members[0].appearance)); + assert!(near(points(&members[1].appearance), b), "{}: the inner group's points {:?} for {b:?}", e.id, points(&members[1].appearance)); + let mut own = Appearance::clone(&art.appearance); + carry.apply(&mut own); + assert!(near(points(&own), b), "{}: the art's own points {:?} for {b:?}", e.id, points(&own)); + } +} + /// A freeform gradient whose points were edited (its vector still fitted to the bounds) moves its /// points with a Transform effect and Free Distort, on a path, on a group inside the art and on the /// art itself, as a placed gradient does. diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 76843f610e..e9f6ecf3d4 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -73,6 +73,8 @@ pub struct RasterImage { pub height: u32, /// The resolution the file declares (see [`super::ppi::resolution`]). pub ppi: Option<(f64, f64)>, + /// A DCS file's clipping path, in the image's pixel space ([`crate::cmd::place::dcs`]). + pub clip: Option, } pub(super) fn err(e: impl std::fmt::Display) -> EngineError { @@ -386,6 +388,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let (mut doc, mut warnings, restored) = match format.id { // EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read. _ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => { + // A DCS file's plates print from its file: it is only placed linked. + if crate::cmd::place::dcs::is_dcs(bytes) { + return Err(err(format!("can't open `{}`: {}", file_name(name), crate::cmd::place::dcs::LINK_ONLY))); + } let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes))); restore_or_import(editing, || { let r = vectorcraft_eps::import_names(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names) @@ -1024,13 +1030,13 @@ fn stored(bytes: Vec, mime: &'static str, (width, height): (u32, u32), ppi: return Err(err("the image is empty")); } let blob = ImageBlob::new(mime, bytes); - Ok(RasterImage { key: blob.content_key(), blob, width, height, ppi }) + Ok(RasterImage { key: blob.content_key(), blob, width, height, ppi, clip: None }) } /// An image as a document of its physical size at the resolution it declares (as Place sizes it; /// 72 ppi, 1 px = 1 pt, when it declares none), the image named after the file. pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result { - let RasterImage { key, blob, width, height, ppi } = raster_image(bytes)?; + let RasterImage { key, blob, width, height, ppi, .. } = raster_image(bytes)?; let (sx, sy) = crate::cmd::place::pt_per_px(ppi); let mut d = Document::new(width as f64 * sx, height as f64 * sy); let layer = d.layers[0].id; diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index d79320c304..720a906462 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -31,10 +31,10 @@ 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}; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, ids_param, object_param, str_param, switch_or}; pub fn specs() -> Vec { vec![ @@ -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 ), @@ -79,7 +79,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, list ), @@ -97,7 +97,7 @@ pub fn specs() -> Vec { "Embed Image", [], None, - "{ids?: [image or placed document ids] (default: the selected linked ones)} break the links (the Links panel's Break Link): images keep the linked files' pixels in the document; a placed document becomes an editable copy of its art, as placing its file without link gives (its symbols, patterns, swatches and images joining the document). An image whose file can't be found and that shows the saved preview, or a placed document whose file can't be read or changed, stays linked: relink or update it first. One undo step → {embedded: [ids], missing: [ids]}", + "{ids?: [image or placed document ids] (default: the selected linked ones)} break the links (the Links panel's Break Link): images keep the linked files' pixels in the document; a placed document becomes an editable copy of its art, as placing its file without link gives (its symbols, patterns, swatches and images joining the document). An image whose file can't be found and that shows the saved preview, or a placed document whose file can't be read or changed, stays linked: relink or update it first. An image placed from a DCS file can't be embedded (an error, and nothing changes). One undo step → {embedded: [ids], missing: [ids]}", has_doc, embed ), @@ -160,6 +160,8 @@ struct Group { ids: Vec, /// Placed documents: what they show. document: Option, + /// An image placed from a DCS file: the plate files of their own its plates were read from. + plate_files: Vec, } /// What a placed document shows: whether the art's bounds (not the artboard or page) are shown, @@ -175,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 @@ -189,7 +199,11 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { let wanted: Option> = ids.map(|ids| ids.iter().copied().collect()); let mut add = |link: &LinkInfo, key: &str, document: Option, id: Option| { let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| { - out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document }); + let plate_files = match document { + None => d.images.get(key).map(ImageBlob::plate_files).unwrap_or_default(), + Some(_) => vec![], + }; + out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document, plate_files }); out.len() - 1 }); if let (Some(id), Some(g)) = (id, out.get_mut(i)) { @@ -335,10 +349,14 @@ fn refused(link: &LinkInfo, doc_path: Option<&str>) -> Option (Status, Option) { let Some(mut f) = locate(&g.link, base) else { return (Status::Missing, None) }; let l = &g.link; + if !g.plate_files.is_empty() && super::place::dcs::plate_files_changed(&g.plate_files, &f.path) { + return (Status::Modified, Some(f)); + } if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified { return (Status::Ok, Some(f)); } @@ -346,7 +364,7 @@ fn probe(g: &Group, base: LinkBase) -> (Status, Option) { let same = match &l.hash { Some(h) => *h == hash_bytes(&bytes), // Linked before links kept a hash: the image the file decodes to. - None => g.document.is_none() && fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key), + None => g.document.is_none() && super::place::dcs::raster(&bytes, Some(&f.path)).is_ok_and(|img| img.key == g.key), }; f.bytes = Some(bytes); (if same { Status::Ok } else { Status::Modified }, Some(f)) @@ -433,7 +451,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a previews.insert(key); continue; }; - let Ok(img) = fileio::raster_image(&bytes) else { + let Ok(img) = super::place::dcs::raster(&bytes, Some(&f.path)) else { previews.insert(key); continue; }; @@ -512,10 +530,11 @@ pub(crate) fn readable_and_kept(blob: ImageBlob, unread: bool, linked: bool) -> Ok(blob) } -/// The linked image file `bytes`, read ([`fileio::raster_image`]) with its preview; an error when -/// its pixels can't be read ([`readable`]). -fn linked_image(bytes: &[u8]) -> Result { - let mut img = fileio::raster_image(bytes)?; +/// The linked image file `bytes` (read from `path`, when there is one), read with its preview: a +/// DCS file's plates, else its image ([`super::place::dcs::raster`]); an error when its pixels +/// can't be read ([`readable`]). +fn linked_image(bytes: &[u8], path: Option<&str>) -> Result { + let mut img = super::place::dcs::raster(bytes, path)?; img.blob = readable(img.blob, kept_unread(bytes), true)?; Ok(img) } @@ -536,7 +555,7 @@ fn read_image(f: &mut Found, base: &LinkInfo, reads: &mut ImageReads) -> Result< let img = match reads.get(&f.path) { Some((h, img)) if *h == hash => img.clone(), _ => { - let img = linked_image(&bytes)?; + let img = linked_image(&bytes, Some(&f.path))?; reads.insert(f.path.clone(), (hash.clone(), img.clone())); img } @@ -586,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, } @@ -622,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, }; } @@ -640,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[..]) { @@ -655,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) { @@ -690,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; } @@ -723,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) }; @@ -801,10 +912,9 @@ fn placed_xf(xf: Affine, old: (f64, f64), old_pt: (f64, f64), new: (f64, f64), n (frame * at * Affine::scale_non_uniform(size.0 / nw, size.1 / nh), (o.clip && over).then_some((frame, room))) } -/// Clip image `id` of `d` to `room` in `frame`: the clipping path of the clip group holding just -/// it changes, else the image goes into a new clip group. -fn clip_image(d: &mut Document, id: NodeId, clip: Clip) -> Result<()> { - let path = clip_shape(clip); +/// The clipping path of the clip group of `d` holding just object `id` becomes `path`; false when +/// no such clip group holds it. +fn set_clip_path(d: &mut Document, id: NodeId, path: PathData) -> bool { let clip_path = d.parent_of(id).and_then(|p| match &d.node(p)?.kind { NodeKind::Group { children, clip: true } => match &children[..] { [c, im] if im.id == id && matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c.id), @@ -812,12 +922,30 @@ fn clip_image(d: &mut Document, id: NodeId, clip: Clip) -> Result<()> { }, _ => None, }); - if let Some(c) = clip_path { - if let Some(Node { kind: NodeKind::Path { path: p, .. }, .. }) = d.node_mut(c) { - *p = path; - } + let Some(c) = clip_path else { return false }; + if let Some(Node { kind: NodeKind::Path { path: p, .. }, .. }) = d.node_mut(c) { + *p = path; + } + true +} + +/// Clip image `id` of `d` to `room` in `frame`: the clipping path of the clip group holding just +/// it changes, else the image goes into a new clip group. +fn clip_image(d: &mut Document, id: NodeId, clip: Clip) -> Result<()> { + clip_image_to(d, id, clip_shape(clip)) +} + +/// Clip object `id` of `d` to `path`: the clipping path of the clip group holding just it changes, +/// else it goes into a new clip group. +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; @@ -839,7 +967,7 @@ pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_ // the app connect to another computer. let path = fileio::document_path(path, doc_path).map_err(|r| EngineError::Other(format!("the image `{path}` isn't followed: {r}")))?; let bytes = read_file(&path)?; - let read = (link_info(&path, &bytes), linked_image(&bytes)?); + let read = (link_info(&path, &bytes), linked_image(&bytes, Some(&path))?); install(d, &[id], read, Some(Preserve::Bounds), None) } @@ -853,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) }; @@ -872,15 +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())); } - clips.extend(clip.map(|c| (*id, c))); } + // 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, clip)| clip_image(d, id, clip)) + 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 @@ -1013,7 +1171,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps // Read when the images show the preview, or when the file's details changed. let bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None }; if preview && !eps_preview { - let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, linked_image(b).ok()?))) else { + let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, linked_image(b, Some(&f.path)).ok()?))) else { out.missing.extend(row); continue; }; @@ -1051,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); } @@ -1328,10 +1486,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). @@ -1513,7 +1705,11 @@ 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).map_err(|e| bad(C, format!("{path}: {e}")))?) }; + 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)?)); @@ -1548,12 +1744,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()) { @@ -1712,6 +1912,9 @@ pub(crate) fn statuses(st: &crate::DocState) -> BTreeMap &'static str { + if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_separated) { + return "DCS"; + } im.link .as_ref() .and_then(|l| fileio::format_for_name(l.name()).or_else(|| fileio::readable_format_for_name(l.name()))) @@ -1732,6 +1935,45 @@ fn status_rank(status: &str) -> usize { ["missing", "modified", "ok", "embedded"].iter().position(|s| *s == status).unwrap_or(4) } +/// The DCS images in the layers of `d` that are transparent (themselves, or a group or layer that +/// holds them) or overlap an object that is, as Show DCS Transparency Interactions marks them. +fn dcs_transparency(d: &Document) -> BTreeSet { + /// Each visible DCS image's id, bounds and whether it is transparent, and the bounds of each + /// other visible object with its own transparency. + fn walk(d: &Document, n: &Node, held: bool, images: &mut Vec<(NodeId, Rect, bool)>, see_through: &mut Vec) { + if !n.visible { + return; + } + let own = n.shows_own_transparency(); + match &n.kind { + 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()), + _ => {} + } + for c in n.children().into_iter().flatten() { + walk(d, c, held || own, images, see_through); + } + } + let (mut images, mut see_through) = (vec![], vec![]); + for l in &d.layers { + walk(d, l, false, &mut images, &mut see_through); + } + images + .into_iter() + .filter(|(_, b, transparent)| *transparent || see_through.iter().any(|t| t.intersect(*b).area() > 0.0)) + .map(|(id, ..)| id) + .collect() +} + fn list(s: &mut Session, p: &Value) -> Result { const C: &str = "links.list"; let st = s.doc()?; @@ -1739,6 +1981,7 @@ fn list(s: &mut Session, p: &Value) -> Result { if !matches!(show, "all" | "missing" | "modified" | "embedded") { return Err(bad(C, format!("show `{show}`: all, missing, modified or embedded"))); } + let interactions = if switch_or(p, "dcsTransparency", false, C)? { dcs_transparency(&st.doc) } else { BTreeSet::new() }; let statuses = statuses(st); let mut objects = vec![]; st.doc.walk(|n| { @@ -1793,6 +2036,9 @@ fn list(s: &mut Session, p: &Value) -> Result { if preview { row["preview"] = json!(true); } + if interactions.contains(&n.id) { + row["dcsTransparency"] = json!(true); + } rows.push(row); } let text = |r: &Value, k: &str| r[k].as_str().unwrap_or_default().to_lowercase(); @@ -1831,6 +2077,14 @@ fn embed(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select the linked images or placed documents, or pass ids")); } + // A DCS file's plates print from its file. + let separated = |id: &NodeId| match kind(id) { + Some(NodeKind::Image(im)) => st.doc.images.get(&im.key).is_some_and(ImageBlob::is_separated), + _ => false, + }; + if let Some(id) = ids.iter().find(|id| separated(id)) { + return Err(bad(C, format!("object {}: {}", id.0, super::place::dcs::LINK_ONLY))); + } // Placed documents become editable copies of their art: those whose file can be read. let (mut documents, mut missing) = (vec![], vec![]); for id in ids.iter().copied() { @@ -1849,6 +2103,10 @@ fn embed(s: &mut Session, p: &Value) -> Result { } match probe(&g, LinkBase::of(st)) { (Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link, &mut image_reads) { + // One showing its preview is a DCS file only its file tells. + Ok((_, img)) if img.blob.is_separated() => { + return Err(bad(C, format!("object {}: {}", g.ids.first().map_or(0, |id| id.0), super::place::dcs::LINK_ONLY))); + } Ok(read) => { done.extend(&g.ids); reads.push((g.ids, read)); @@ -1915,7 +2173,7 @@ fn unembed(s: &mut Session, p: &Value) -> Result { }; let path = absolute_path(&if fileio::extension(path).is_empty() { format!("{path}.{ext}") } else { path.to_string() }); // Read before writing: pixels that can't be read leave no file behind. - let img = linked_image(&bytes)?; + let img = linked_image(&bytes, Some(&path))?; write_file(&path, &bytes)?; let read = (link_info(&path, &bytes), img); s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds), None))?; diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index c05493e964..b4639f0de5 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Close Path", [], None, - "{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), in?: [x,y] (handle into the first anchor, placed as path.setHandle places it: a smooth first anchor keeps its outgoing handle in line at its own length, a corner keeps it where it is), out?: [x,y] (the first anchor's outgoing handle, placed after `in`; the anchor becomes a corner), independent?: bool (`in` makes the first anchor a corner and leaves its outgoing handle)}", + "{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), at?: [x,y] (move the first anchor there, its handles with it, before `in` and `out` are placed), in?: [x,y] (handle into the first anchor, placed as path.setHandle places it: a smooth first anchor keeps its outgoing handle in line at its own length, a corner keeps it where it is), out?: [x,y] (the first anchor's outgoing handle, placed after `in`; the anchor becomes a corner), independent?: bool (`in` makes the first anchor a corner and leaves its outgoing handle)}", has_doc, close ), @@ -205,6 +205,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result { /// Close the active open subpath of `id`, optionally setting the handles of the first anchor. The /// incoming handle moves as Move Direction Handle moves it: the outgoing handle turns with it only /// on a smooth anchor, so the first segment of a path that starts at a corner keeps its shape. +/// `at` first moves the anchor, its handles with it (the Pen's closing drag with Space held). fn close(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); @@ -212,6 +213,7 @@ fn close(s: &mut Session, p: &Value) -> Result { None => Ok(None), Some(h) => finite_numbers(h).map(|[x, y]| Some(Point::new(x, y))).ok_or_else(|| bad("path.close", format!("`{key}` must be [x, y]"))), }; + let at = handle("at")?; let h_in = handle("in")?; let h_out = handle("out")?; let independent = switch_or(p, "independent", false, "path.close")?; @@ -223,6 +225,9 @@ fn close(s: &mut Session, p: &Value) -> Result { }; sp.closed = true; if let Some(first) = sp.anchors.first_mut() { + if let Some(to) = at { + first.translate(to - first.p); + } if let Some(h) = h_in { first.set_handle(false, h, independent); } diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 81ec7fb7c2..36444878ef 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -11,6 +11,8 @@ //! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by //! its art: the document an EPS file of ours carries, else what its PostScript draws, else its //! preview ([`fileio::load`]). +//! - A DCS file, an EPS file with one program per printing plate, becomes one linked image of its +//! plates ([`dcs`]); it is never embedded. //! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page //! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art //! (`crop: "bounding"`). With Link, a PDF/.ai/.ait page placed from a file becomes a placed @@ -32,6 +34,7 @@ //! `place` tool) with several files, which emits one `file.place` per click or drag. pub(crate) mod adopt; +pub(crate) mod dcs; mod doc; pub(crate) mod document; pub(crate) mod docx; @@ -69,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 and PICT (.pct, .pic, .pict; 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. 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 and PICT (.pct, .pic, .pict; 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 ), @@ -78,7 +81,7 @@ pub fn specs() -> Vec { "Placed File Info", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), page?, crop?, password?, thumbnail?: px, link?: bool, dxf?: {…} (both as file.place)} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}", + "{path | name | dataBase64 (a file's path, or its name with its bytes), page?, crop?, password?, thumbnail?: px, link?: bool, dxf?: {…} (both as file.place)} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), plates?: [ink names] (a DCS file), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}", always, info ), @@ -114,6 +117,8 @@ enum Art { /// A VectorCraft document or a PDF/.ai page placed linked: the file read, the link, and /// whether the art's bounds (not the artboard or page) are shown. Document(document::Source, LinkInfo, bool), + /// A DCS file: one linked image of its plates, clipped by its clipping path ([`dcs`]). + Dcs(Box), Vector { /// The loaded document: the source of the art's resources. src: Box, @@ -205,6 +210,15 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result) -> Node { }; Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) } + Art::Dcs(a) => { + let a = *a; + super::links::store_image(d, &a.img.key, a.img.blob, link.is_some()); + dcs::add_spot_swatches(d, &a.spots); + let im = + ImageObject { key: a.img.key, width: a.img.width, height: a.img.height, xf: a.xf, link, placement: Default::default(), pixels: None }; + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); + match a.clip { + Some(c) => { + let children = vec![Arc::new(d.reid(&c)), Arc::new(image)]; + Node::new(d.alloc_id(), NodeKind::Group { children, clip: true }) + } + None => image, + } + } Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural, &d.setup)))), Art::Document(s, link, bounding) => document::node(d, &s, link, bounding), Art::Vector { src, mut nodes, clip } => { @@ -506,9 +535,13 @@ impl PlaceJob { pub fn read(self) -> Result { let mut loaded = load(&self.params, PLACE, self.target, true)?; let linked = loaded.link.is_some() && bool_or(&self.params, "link", true); - if let Art::Image(img) = &mut loaded.art { - let unread = super::links::kept_unread(&img.blob.bytes); - img.blob = super::links::readable_and_kept(img.blob.clone(), unread, linked)?; + match &mut loaded.art { + Art::Image(img) => { + let unread = super::links::kept_unread(&img.blob.bytes); + img.blob = super::links::readable_and_kept(img.blob.clone(), unread, linked)?; + } + Art::Dcs(a) => a.img.blob = super::links::readable(a.img.blob.clone(), false, true)?, + _ => {} } Ok(PlaceRead { job: self, loaded }) } @@ -628,7 +661,11 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { let below = st.current_layer().or_else(|| parent.and_then(|p| st.doc.layer_of(p))); let link = loaded.link.take().filter(|_| bool_or(p, "link", true)); let linked = link.is_some(); - let (name, format, warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings)); + 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)); + } let (id, size) = s.edit("Place", |d, sel| { let node = transformed(build(d, loaded, link), m); let size = node.geometric_bounds_with(&d.symbol_boxes()).unwrap_or_default(); @@ -650,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. @@ -750,10 +796,14 @@ fn read_queue(p: Value, queued: Vec) -> Result { // so that drawing it does not decode the file again. let checked = load(&q, QUEUE, None, false).and_then(|mut l| { let linked = l.link.is_some() && bool_or(&q, "link", true); - if let Art::Image(img) = &mut l.art { - let unread = super::links::kept_unread(&img.blob.bytes); - let check = if thumbnail.is_some() { super::links::readable_and_kept } else { super::links::readable }; - img.blob = check(img.blob.clone(), unread, linked)?; + match &mut l.art { + Art::Image(img) => { + let unread = super::links::kept_unread(&img.blob.bytes); + let check = if thumbnail.is_some() { super::links::readable_and_kept } else { super::links::readable }; + img.blob = check(img.blob.clone(), unread, linked)?; + } + Art::Dcs(a) => a.img.blob = super::links::readable(a.img.blob.clone(), false, true)?, + _ => {} } Ok(l) }); @@ -871,6 +921,15 @@ pub fn read_info(p: &Value) -> Result { out["ppi"] = json!([x, y]); out["colorMode"] = json!(color_mode(&img.blob.bytes)); } + if let Art::Dcs(a) = &l.art { + let (x, y) = a.img.ppi.unwrap_or((72.0, 72.0)); + out["pixelWidth"] = json!(a.img.width); + out["pixelHeight"] = json!(a.img.height); + out["ppi"] = json!([x, y]); + out["colorMode"] = json!("CMYK"); + let plates: Vec = a.img.blob.separated().map(|s| s.plates.iter().map(|p| p.name.clone()).collect()).unwrap_or_default(); + out["plates"] = json!(plates); + } if let Some(px) = p.get("thumbnail").and_then(Value::as_f64) { out["thumbnailBase64"] = json!(thumbnail_png(l, px)?); } diff --git a/crates/engine/src/cmd/place/dcs.rs b/crates/engine/src/cmd/place/dcs.rs new file mode 100644 index 0000000000..b540221a23 --- /dev/null +++ b/crates/engine/src/cmd/place/dcs.rs @@ -0,0 +1,132 @@ +//! DCS files (`file.place` with Link): an EPS file with one program per printing plate +//! ([`vectorcraft_eps::dcs`]). It is placed as one linked image whose plates are kept with its +//! pixels ([`vectorcraft_doc::separated`]): the screen and composite output show the plates mixed +//! into CMYK, and print separations put each plate on its own ink. Its spot plates become spot +//! swatches, and the clipping path its composite is drawn through clips it. A DCS file can't be +//! opened or embedded (Embed, Place without Link): the image stays linked to its file, which the +//! Links commands read again ([`raster`]). + +use vectorcraft_color::swatch::Swatch; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::separated::PlateFile; +use vectorcraft_doc::{Document, ImageBlob, Node, NodeKind}; +use vectorcraft_geom::{Affine, Rect}; + +use super::super::fileio::{self, RasterImage}; +use super::super::{EngineError, Result}; + +/// The largest plate file read (bytes). +const MAX_PLATE_FILE: u64 = 1 << 30; + +/// What refuses to open or embed a DCS file. +pub(crate) const LINK_ONLY: &str = "DCS files can only be placed linked: use File › Place with Link on"; + +/// A DCS file read for placing. +pub(crate) struct DcsArt { + pub img: RasterImage, + /// The image's pixels onto the file's page (its bounding box, top-left at the origin). + pub xf: Affine, + pub page: Rect, + /// The clipping path the composite is drawn through, on the page. + pub clip: Option, + /// The spot plates' inks and their process equivalents. + pub spots: Vec<(String, [f32; 4])>, + pub warnings: Vec, +} + +/// Is `bytes` a DCS file (an EPS file naming its plates)? +pub(crate) fn is_dcs(bytes: &[u8]) -> bool { + vectorcraft_eps::dcs::read(bytes).is_some() +} + +/// DCS file `bytes`, read from `path` (when there is one: plate files of their own are found in its +/// folder), with every plate. +pub(crate) fn read(bytes: &[u8], path: Option<&str>) -> Result { + let dcs = vectorcraft_eps::dcs::read(bytes).ok_or_else(|| EngineError::Other("not a DCS file".into()))?; + 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)?; + // 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 }) +} + +/// A linked file's image, as the Links commands read it again: a DCS file's plates +/// ([`read`]), else the image file ([`fileio::raster_image`]). +pub(crate) fn raster(bytes: &[u8], path: Option<&str>) -> Result { + if is_dcs(bytes) { read(bytes, path).map(|a| a.img) } else { fileio::raster_image(bytes) } +} + +/// The path of the plate file a DCS file at `main` names `name`: the file of that name (its last +/// component, of any system's path) in `main`'s folder, with that name. +fn plate_path(main: Option<&str>, name: &str) -> std::result::Result<(String, String), String> { + let base = name.rsplit(['/', '\\', ':']).next().unwrap_or(name); + let folder = main.map(std::path::Path::new).and_then(std::path::Path::parent); + let Some(folder) = folder.filter(|_| !base.is_empty()) else { + return Err(format!("`{name}` is a file of its own, which can only be found next to the DCS file's path")); + }; + 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)?; + match fileio::file_stamp(&path) { + None => Err(format!("`{base}` is not in the DCS file's folder")), + Some((size, _)) if size > MAX_PLATE_FILE => Err(format!("`{base}` is larger than {} MB", MAX_PLATE_FILE >> 20)), + Some((size, modified)) => { + let bytes = fileio::read_file(&path).map_err(|e| e.to_string())?; + files.push(PlateFile { name: base, size, modified: modified.unwrap_or(0) }); + Ok(bytes) + } + } +} + +/// Whether one of `files`, the plate files of their own a separated image was read from +/// ([`ImageBlob::plate_files`]), next to the DCS file at `main`, is gone or changed since. +pub(crate) fn plate_files_changed(files: &[PlateFile], main: &str) -> bool { + files.iter().any(|f| { + let stamp = plate_path(Some(main), &f.name).ok().and_then(|(path, _)| fileio::file_stamp(&path)); + stamp.is_none_or(|(size, modified)| size != f.size || modified.unwrap_or(0) != f.modified) + }) +} + +/// Add `spots` to `d` as spot swatches, each whose name no swatch has, in its process equivalent. +pub(crate) fn add_spot_swatches(d: &mut Document, spots: &[(String, [f32; 4])]) { + for (name, [c, m, y, k]) in spots { + if d.swatch(name).is_none() { + d.swatches.push(Swatch { name: name.clone(), paint: Paint::solid(Color::cmyk(*c, *m, *y, *k)), global: true, spot: true }); + } + } +} + +/// A warning for each of `spots` whose name a swatch of `d` already has that isn't a spot color in +/// the same process equivalent: the document's swatch stays, and the plate prints by it. +pub(crate) fn swatch_notes(d: &Document, spots: &[(String, [f32; 4])]) -> Vec { + let mut out = vec![]; + for (name, cmyk) in spots { + let Some(sw) = d.swatch(name) else { continue }; + let same = sw.spot + && matches!(sw.paint.color(), Some(Color::Cmyk { c, m, y, k }) if [c, m, y, k].iter().zip(cmyk).all(|(a, b)| (a - b).abs() < 0.005)); + if !same { + let how = if sw.spot { "a different color" } else { "a process color, so the plate prints as process inks" }; + out.push(format!("the document's swatch `{name}` is kept: it is {how}")); + } + } + out +} diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 9fe4665a5c..628facc1c3 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -112,6 +112,9 @@ fn preview(s: &mut Session, p: &Value) -> Result { super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?; let mut pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?; pv.warnings.extend(type_warnings(doc, set.print_layers, &areas(&pv))); + // What the job loses comes first, then the notes on the options. + let settings = std::mem::take(&mut pv.setting_notes); + pv.warnings.extend(settings); let mut v = serde_json::to_value(pv).map_err(|e| EngineError::Other(e.to_string()))?; v["settings"] = to_json(&set)?; Ok(v) @@ -199,7 +202,9 @@ fn print(s: &mut Session, p: &Value) -> Result { let mut r = vectorcraft_pdf::print(printed, &PrintOptions { settings: set, for_printer: bool_or(p, "forPrinter", false), ..Default::default() }) .map_err(|e| pdf_error(PRINT, e))?; + // What the job loses comes first, then the notes on the options. r.warnings.extend(typed); + r.warnings.extend(r.setting_notes); return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings })); } let level = match p.get("level").filter(|v| !v.is_null()) { @@ -209,12 +214,13 @@ fn print(s: &mut Session, p: &Value) -> Result { } None => Level::default(), }; - let mut warnings = vec![]; + // What the job loses, then the notes on the options: a reply lists them in that order. + let (mut warnings, mut setting_notes) = (vec![], vec![]); if !bitmap && (tile_images || doc.layers.iter().any(|l| l.shows_transparency())) { warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string()); } if !set.color.profile.trim().is_empty() && set.output.mode == vectorcraft_pdf::OutputMode::Composite { - warnings.push("the printer profile converts the colours of PDF output: PostScript prints the document's colours".to_string()); + setting_notes.push("the printer profile converts the colours of PDF output: PostScript prints the document's colours".to_string()); } let flatness = (!set.graphics.auto_flatness).then_some(set.graphics.flatness); let plan = vectorcraft_pdf::plan(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() }) @@ -244,5 +250,11 @@ fn print(s: &mut Session, p: &Value) -> Result { } let areas: Vec = plan.sheets.iter().filter_map(|sh| on_page(sh.area, sh.view * sh.place, sh.size)).collect(); warnings.extend(type_warnings(&full, which, &areas)); + for w in plan.setting_notes.iter().chain(&out.setting_notes) { + if !warnings.contains(w) && !setting_notes.contains(w) { + setting_notes.push(w.clone()); + } + } + warnings.extend(setting_notes); write_or_return(path, &out.bytes, json!({ "pages": plan.order.len(), "format": "postscript", "warnings": warnings })) } diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 751313957f..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). 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 ), @@ -988,8 +988,28 @@ fn swatch_unused(s: &mut Session, _: &Value) -> Result { let colors: HashSet = colors.iter().filter_map(color_key).collect(); 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. + // 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) + // 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.rs b/crates/engine/src/tests.rs index 18eadcdf80..95c98f1ea5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -44,6 +44,7 @@ mod containers; mod css; mod cut; mod dashalign; +mod dcs; mod distort; mod docsetup; mod document_move; diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs new file mode 100644 index 0000000000..2801432a0a --- /dev/null +++ b/crates/engine/src/tests/dcs.rs @@ -0,0 +1,697 @@ +//! DCS files placed with Link (`file.place`): one linked image of their plates, their spot plates +//! as spot swatches, clipped by their composite's clipping path, never opened or embedded; and +//! the Links panel's Show DCS Transparency Interactions (`links.list` with `dcsTransparency`). +//! The files are made from scratch as http://www.telegraphics.net/doc/DCS.txt describes them. + +use std::path::PathBuf; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Node, NodeKind}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +/// A fresh scratch folder for one test. +fn temp_dir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("vc-dcs-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A plate program: one 2 × 2 grayscale image (`gray`, row by row) on a 20 × 10 pt page. +fn plate_eps(gray: [u8; 4]) -> String { + let hex: String = gray.iter().map(|g| format!("{g:02x}")).collect(); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n20 10 scale\n2 2 8 [2 0 0 -2 0 2] <{hex}> image\nshowpage\n%%Trailer\n%%EOF\n" + ) +} + +/// A single-file DCS 2.0 file of `plates` (name, plate program), with the header lines `extra`; its +/// composite is drawn through a clipping path, the left half of the page. +fn single_file(plates: &[(&str, String)], extra: &str) -> Vec { + let header = |offsets: &[usize]| { + let mut s = String::from("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n"); + s.push_str(extra); + for ((name, eps), at) in plates.iter().zip(offsets) { + s.push_str(&format!("%%PlateFile: ({name}) EPS #{at:010} {:010}\n", eps.len())); + } + s.push_str("%%EndComments\ngsave newpath 0 0 moveto 10 0 lineto 10 10 lineto 0 10 lineto closepath clip newpath\n"); + s.push_str("20 10 scale 2 2 8 [2 0 0 -2 0 2] <808080808080808080808080> false 3 colorimage grestore showpage\n%%EOF\n"); + s + }; + let main = header(&vec![0; plates.len()]).len(); + let mut offsets = vec![]; + let mut at = main; + for (_, eps) in plates { + offsets.push(at); + at += eps.len(); + } + let mut out = header(&offsets).into_bytes(); + for (_, eps) in plates { + out.extend(eps.as_bytes()); + } + out +} + +/// Cyan solid on the top row, PANTONE 185 C at 50% on the left column. +fn two_plates() -> Vec { + single_file( + &[("Cyan", plate_eps([0, 0, 255, 255])), ("PANTONE 185 C", plate_eps([127, 255, 127, 255]))], + "%%DocumentCustomColors: (PANTONE 185 C)\n%%CMYKCustomColor: 0 .91 .76 0 (PANTONE 185 C)\n", + ) +} + +fn write(dir: &std::path::Path, name: &str, bytes: &[u8]) -> String { + let p = dir.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().into_owned() +} + +/// The image inside placed object `id` (itself, or the image of its clipping group). +fn image_of(s: &Session, id: &Value) -> Node { + let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone(); + let mut found = None; + n.walk(&mut |c: &Node| { + if found.is_none() && matches!(c.kind, NodeKind::Image(_)) { + found = Some(c.clone()); + } + }); + found.expect("an image") +} + +fn error(s: &mut Session, cmd: &str, p: Value) -> String { + s.execute(cmd, &p).unwrap_err().to_string() +} + +#[test] +fn a_dcs_file_places_as_one_linked_image_of_its_plates() { + let dir = temp_dir("place"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + assert_eq!(r["linked"], true); + assert_eq!(r["warnings"], json!([])); + let id = &r["ids"][0]; + let placed = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone(); + let NodeKind::Group { children, clip: true } = &placed.kind else { panic!("a clipping group: {:?}", placed.kind) }; + assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }), "the composite's clipping path"); + assert_eq!(children[0].geometric_bounds().map(|b| b.width().round()), Some(10.0)); + assert_eq!(r["width"].as_f64().map(f64::round), Some(10.0), "clipped to the left half of its 20 pt page"); + let image = image_of(&s, id); + let NodeKind::Image(im) = &image.kind else { not_an_image() }; + assert!(im.link.as_ref().is_some_and(|l| l.path.ends_with("pic.eps"))); + let doc = &s.doc().unwrap().doc; + let plates = doc.images[&im.key].separated().expect("its plates are kept"); + assert_eq!(plates.plates.iter().map(|p| p.name.as_str()).collect::>(), ["Cyan", "PANTONE 185 C"]); + assert_eq!(plates.plates[1].data, [128, 0, 128, 0]); + let sw = doc.swatch("PANTONE 185 C").expect("a spot swatch for the spot plate"); + assert!(sw.spot); + assert_eq!(sw.paint, Paint::solid(Color::cmyk(0.0, 0.91, 0.76, 0.0))); + assert!(!doc.swatches_iter().any(|w| w.spot && w.name == "Cyan"), "process plates add no spot swatch"); + let list = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(list["links"][0]["format"], "DCS"); + assert_eq!(list["links"][0]["status"], "ok"); + let info = s.execute("file.place.info", &json!({ "path": path })).unwrap(); + assert_eq!(info["plates"], json!(["Cyan", "PANTONE 185 C"])); + assert_eq!(info["colorMode"], "CMYK"); + assert_eq!(info["pixelWidth"], 2); + // One undo step takes the swatch away too. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.swatch("PANTONE 185 C").is_none()); +} + +fn not_an_image() -> ! { + panic!("an image") +} + +#[test] +fn a_dcs_file_is_never_opened_or_embedded() { + let dir = temp_dir("refused"); + let bytes = two_plates(); + let path = write(&dir, "pic.eps", &bytes); + let mut s = session(); + let only = "DCS files can only be placed linked"; + assert!(error(&mut s, "file.place", json!({ "path": path, "link": false })).contains(only)); + let data = json!({ "name": "pic.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes) }); + assert!(error(&mut s, "file.place", data).contains(only), "bytes alone can't be linked"); + assert!(error(&mut s, "document.open", json!({ "path": path })).contains(only)); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let image = image_of(&s, &r["ids"][0]); + let e = error(&mut s, "links.embed", json!({ "ids": [image.id.0] })); + assert!(e.contains(only), "{e}"); + let NodeKind::Image(im) = &image_of(&s, &r["ids"][0]).kind else { not_an_image() }; + assert!(im.link.is_some(), "still linked"); +} + +#[test] +fn a_swatch_of_the_same_name_is_kept_with_a_warning() { + let dir = temp_dir("swatch"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + s.execute("swatch.new", &json!({"name": "PANTONE 185 C", "color": {"c": 0.0, "m": 1.0, "y": 1.0, "k": 0.0}, "spot": true})).unwrap(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + assert_eq!(r["warnings"], json!(["the document's swatch `PANTONE 185 C` is kept: it is a different color"])); + let sw = s.doc().unwrap().doc.swatch("PANTONE 185 C").unwrap().clone(); + assert_eq!(sw.paint.color(), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0))); +} + +#[test] +fn plate_files_of_their_own_are_read_from_the_dcs_files_folder() { + let dir = temp_dir("files"); + write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes()); + write(&dir, "pic.K", plate_eps([255, 255, 255, 0]).as_bytes()); + let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: OldDisk:Scans:pic.C\n%%BlackPlate: pic.K\n%%EndComments\nshowpage\n"; + let path = write(&dir, "pic.eps", main.as_bytes()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let image = image_of(&s, &r["ids"][0]); + assert!(matches!(image.kind, NodeKind::Image(_)), "no clipping path without a composite"); + let NodeKind::Image(im) = &image.kind else { not_an_image() }; + let plates = s.doc().unwrap().doc.images[&im.key].separated().unwrap(); + assert_eq!(plates.plates[0].data, [255, 0, 0, 0], "the plate file found by its name in the folder"); + assert_eq!(plates.plates[1].data, [0, 0, 0, 255]); + std::fs::remove_file(dir.join("pic.K")).unwrap(); + let e = error(&mut s, "file.place", json!({ "path": path })); + assert!(e.contains("the Black plate can't be read") && e.contains("`pic.K` is not in the DCS file's folder"), "{e}"); +} + +#[test] +fn update_link_reads_the_plates_again() { + let dir = temp_dir("update"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + let changed = single_file(&[("Cyan", plate_eps([0, 0, 0, 0])), ("Varnish", plate_eps([255; 4]))], "%%CMYKCustomColor: 0 0 .2 0 (Varnish)\n"); + std::fs::write(&path, &changed).unwrap(); + let u = s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(u["updated"], json!([id.0])); + let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() }; + let plates = s.doc().unwrap().doc.images[&im.key].separated().unwrap(); + assert_eq!(plates.plates.len(), 2); + assert_eq!(plates.plates[0].data, [255; 4]); + let sw = s.doc().unwrap().doc.swatch("Varnish").expect("a spot swatch for the plate the file gained"); + assert!(sw.spot); + assert_eq!(sw.paint, Paint::solid(Color::cmyk(0.0, 0.0, 0.2, 0.0))); +} + +#[test] +fn update_link_takes_the_files_new_clipping_path() { + let dir = temp_dir("update-clip"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let group = NodeId(r["ids"][0].as_u64().unwrap()); + let id = image_of(&s, &r["ids"][0]).id; + let clip_width = |s: &Session| { + let NodeKind::Group { children, .. } = &s.doc().unwrap().doc.node(group).unwrap().kind else { panic!("a clipping group") }; + children[0].geometric_bounds().map(|b| b.width()) + }; + let before = clip_width(&s).unwrap(); + // The same file, its composite now drawn through the left quarter of the page (same length, + // so the plates stay where the header says). + let text = String::from_utf8(two_plates()).unwrap().replacen("10 0 lineto 10 10 lineto", " 5 0 lineto 5 10 lineto", 1); + std::fs::write(&path, text.as_bytes()).unwrap(); + let u = s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(u["updated"], json!([id.0])); + let after = clip_width(&s).unwrap(); + assert!((after - before / 2.0).abs() < 1e-6, "the clipping path follows the file: {before} → {after}"); +} + +#[test] +fn update_link_clips_an_image_whose_file_gained_a_clipping_path() { + let dir = temp_dir("update-gained-clip"); + // The composite drawn without its clipping path (same length). + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + let path = write(&dir, "pic.eps", unclipped.as_bytes()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::Image(_)), "no clipping path, no group"); + std::fs::write(&path, two_plates()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + let group = doc.node(doc.parent_of(id).expect("a parent")).unwrap(); + let NodeKind::Group { children, clip: true } = &group.kind else { panic!("a clipping group: {:?}", group.kind) }; + assert_eq!(children[0].geometric_bounds().map(|b| b.width().round()), Some(10.0), "the left half of the page"); +} + +#[test] +fn a_changed_plate_file_marks_the_link_modified() { + let dir = temp_dir("plate-changed"); + write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes()); + let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%EndComments\nshowpage\n"; + let path = write(&dir, "pic.eps", main.as_bytes()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "ok"); + // Only the plate file changes (a size of its own, so the change shows whatever the clock). + write(&dir, "pic.C", format!("{}% more\n", plate_eps([255, 255, 255, 0])).as_bytes()); + assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "modified"); + let u = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(u["updated"], json!([id.0])); + let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() }; + assert_eq!(s.doc().unwrap().doc.images[&im.key].separated().unwrap().plates[0].data, [0, 0, 0, 255]); + assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "ok"); + std::fs::remove_file(dir.join("pic.C")).unwrap(); + assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "modified", "a plate file that is gone"); +} + +#[test] +fn the_link_watch_reads_the_link_again_when_only_a_plate_file_changes() { + let dir = temp_dir("plate-watch"); + write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes()); + let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%EndComments\nshowpage\n"; + let path = write(&dir, "pic.eps", main.as_bytes()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + let files: Vec = crate::link_watch::linked_files(&s.doc().unwrap().doc).into_iter().map(|(p, _)| p).collect(); + assert!(files.iter().any(|p| p.ends_with("pic.C")), "the plate file is watched too: {files:?}"); + // The first look only remembers the files. + assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([])); + write(&dir, "pic.C", format!("{}% more\n", plate_eps([255, 255, 255, 0])).as_bytes()); + assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([id.0])); + let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() }; + assert_eq!(s.doc().unwrap().doc.images[&im.key].separated().unwrap().plates[0].data, [0, 0, 0, 255]); +} + +/// The width of the clipping path of the clip group holding the image in the art of symbol +/// `name`; `None` when no clip group holds it. +fn symbol_clip_width(s: &Session, name: &str) -> Option { + fn find(n: &Node) -> Option> { + if let NodeKind::Group { children, clip: true } = &n.kind + && let [c, im] = &children[..] + && matches!(im.kind, NodeKind::Image(_)) + { + return Some(c.geometric_bounds().map(|b| b.width())); + } + if matches!(n.kind, NodeKind::Image(_)) { + return Some(None); + } + n.children().and_then(|ch| ch.iter().find_map(|c| find(c))) + } + let doc = &s.doc().unwrap().doc; + find(&doc.symbols.iter().find(|w| w.name == name).expect("the symbol").art).expect("an image in the symbol") +} + +#[test] +fn update_link_takes_the_files_clipping_path_into_symbols_too() { + let dir = temp_dir("update-symbol-clip"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + let other = s.execute("file.place", &json!({ "path": path, "at": [200, 100] })).unwrap(); + s.execute("symbol.new", &json!({ "name": "Pic", "ids": [other["ids"][0]] })).unwrap(); + let before = symbol_clip_width(&s, "Pic").expect("the file's clipping path"); + // The composite now drawn through the left quarter of the page (same length). + let text = String::from_utf8(two_plates()).unwrap().replacen("10 0 lineto 10 10 lineto", " 5 0 lineto 5 10 lineto", 1); + std::fs::write(&path, text.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let after = symbol_clip_width(&s, "Pic").expect("still clipped"); + assert!((after - before / 2.0).abs() < 1e-6, "the symbol's clipping path follows the file: {before} → {after}"); + // The file without a clipping path (same length): the symbol's image is no longer clipped. + let unclipped = text.replacen("closepath clip newpath", "closepath newpath ", 1); + std::fs::write(&path, unclipped.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(symbol_clip_width(&s, "Pic"), None); +} + +#[test] +fn a_file_that_lost_its_clipping_path_unclips_the_image() { + let dir = temp_dir("update-lost-clip"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + std::fs::write(&path, unclipped.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + let placed = NodeId(r["ids"][0].as_u64().unwrap()); + assert_eq!(doc.node(placed).and_then(plain_group_of_image), Some((false, true)), "the placed group stays, without the clip"); + let parent = doc.parent_of(id).expect("a parent"); + assert!(!matches!(doc.node(parent).unwrap().kind, NodeKind::Group { clip: true, .. }), "the image is not clipped"); + // Relinked to a plain image: no clip group either. + let png = dir.join("plain.png"); + image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])).save(&png).unwrap(); + std::fs::write(&path, two_plates()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert!( + s.doc().unwrap().doc.parent_of(id).is_some_and(|p| matches!(s.doc().unwrap().doc.node(p).unwrap().kind, NodeKind::Group { clip: true, .. })) + ); + s.execute("links.relink", &json!({ "ids": [id.0], "path": png.to_string_lossy() })).unwrap(); + let doc = &s.doc().unwrap().doc; + assert!( + !matches!(doc.node(doc.parent_of(id).unwrap()).unwrap().kind, NodeKind::Group { clip: true, .. }), + "a plain image isn't clipped by the DCS file's path" + ); +} + +/// The kind of group `g` is, as (clip, the kinds of its children are just one image). +fn plain_group_of_image(g: &Node) -> Option<(bool, bool)> { + match &g.kind { + NodeKind::Group { children, clip } => Some((*clip, matches!(&children[..], [c] if matches!(c.kind, NodeKind::Image(_))))), + _ => None, + } +} + +#[test] +fn a_hidden_or_transparent_clip_group_loses_the_files_old_clipping_path() { + let dir = temp_dir("lost-clip-kept-group"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let place = |s: &mut Session, x: f64| s.execute("file.place", &json!({ "path": path, "at": [x, 100] })).unwrap(); + let (faded, hidden, in_symbol) = (place(&mut s, 100.0), place(&mut s, 200.0), place(&mut s, 300.0)); + let (faded, hidden) = (NodeId(faded["ids"][0].as_u64().unwrap()), NodeId(hidden["ids"][0].as_u64().unwrap())); + let image = image_of(&s, &json!(faded.0)).id; + s.execute("transparency.set", &json!({"ids": [faded.0, in_symbol["ids"][0]], "opacity": 50})).unwrap(); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(hidden) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + s.execute("symbol.new", &json!({ "name": "Pic", "ids": [in_symbol["ids"][0]] })).unwrap(); + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + std::fs::write(&path, unclipped.as_bytes()).unwrap(); + let hidden_image = image_of(&s, &json!(hidden.0)).id; + s.execute("links.update", &json!({ "ids": [image.0, hidden_image.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + for (g, why) in [(faded, "the transparent group"), (hidden, "the hidden group")] { + let n = doc.node(g).unwrap_or_else(|| panic!("{why} stays")); + assert_eq!(plain_group_of_image(n), Some((false, true)), "{why} holds the image alone, unclipped"); + } + assert!((doc.node(faded).unwrap().opacity - 0.5).abs() < 1e-6, "its transparency kept"); + assert!(!doc.node(hidden).unwrap().visible, "still hidden"); + // In a symbol's art too. + let art = &doc.symbols.iter().find(|w| w.name == "Pic").expect("the symbol").art; + let mut found = None; + art.walk(&mut |n: &Node| { + if found.is_none() && n.children().is_some_and(|ch| ch.iter().any(|c| matches!(c.kind, NodeKind::Image(_)))) { + found = plain_group_of_image(n); + } + }); + assert_eq!(found, Some((false, true)), "the symbol's transparent group holds the image alone, unclipped"); +} + +#[test] +fn a_clipping_mask_of_ones_own_stays_when_the_link_is_updated() { + let dir = temp_dir("own-mask"); + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + let path = write(&dir, "pic.eps", unclipped.as_bytes()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + let rect = s.execute("shape.rectangle", &json!({"x": 95, "y": 95, "width": 4, "height": 4})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({ "ids": [id.0, rect] })).unwrap(); + let g = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(s.doc().unwrap().doc.parent_of(id), Some(g), "the clipping mask made in the document stays"); + let mask_width = |s: &Session| { + let NodeKind::Group { children, .. } = &s.doc().unwrap().doc.node(g).unwrap().kind else { panic!("the mask's group") }; + children[0].geometric_bounds().map(|b| b.width().round()) + }; + assert_eq!(mask_width(&s), Some(4.0)); + // The file gains a clipping path: its clip group goes inside the mask, which keeps its path. + std::fs::write(&path, two_plates()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + let inner = doc.parent_of(id).expect("a parent"); + assert_ne!(inner, g); + assert!(matches!(doc.node(inner).unwrap().kind, NodeKind::Group { clip: true, .. }), "the file's clip group"); + assert_eq!(doc.parent_of(inner), Some(g)); + assert_eq!(mask_width(&s), Some(4.0), "the mask's path is kept"); +} + +#[test] +fn a_dcs_file_takes_the_place_parameters_an_eps_file_takes() { + let dir = temp_dir("params"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + assert!(error(&mut s, "file.place", json!({ "path": path, "page": 0 })).contains("page")); + assert!(error(&mut s, "file.place", json!({ "path": path, "crop": "trim" })).contains("crop")); +} + +#[test] +fn a_clipped_placement_answers_its_image_for_the_links_commands() { + let dir = temp_dir("image-id"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + assert_eq!(r["image"], json!(image_of(&s, &r["ids"][0]).id.0)); + let info = s.execute("links.info", &json!({ "id": r["image"] })).unwrap(); + assert_eq!(info["format"], "DCS"); + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + let plain = write(&dir, "plain.eps", unclipped.as_bytes()); + assert!(s.execute("file.place", &json!({ "path": plain })).unwrap().get("image").is_none(), "an image placed alone is its own id"); +} + +#[test] +fn update_and_relink_warn_of_a_swatch_kept_for_a_new_spot_plate() { + let dir = temp_dir("update-swatch"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + s.execute("swatch.new", &json!({"name": "Varnish", "color": {"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap(); + let changed = single_file(&[("Cyan", plate_eps([0, 0, 0, 0])), ("Varnish", plate_eps([255; 4]))], "%%CMYKCustomColor: 0 0 .2 0 (Varnish)\n"); + std::fs::write(&path, &changed).unwrap(); + let u = s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let kept = "the document's swatch `Varnish` is kept: it is a process color, so the plate prints as process inks"; + assert_eq!(u["warnings"], json!([kept])); + let other = write(&dir, "other.eps", &changed); + let r = s.execute("links.relink", &json!({ "ids": [id.0], "path": other })).unwrap(); + assert_eq!(r["warnings"], json!([kept])); + assert!(s.execute("links.update", &json!({ "ids": [id.0] })).unwrap().get("warnings").is_some(), "every read warns"); +} + +#[test] +fn a_deleted_dcs_image_leaves_its_spot_swatch_unused() { + let dir = temp_dir("unused"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].as_array().unwrap().iter().any(|n| n == "PANTONE 185 C"); + assert!(!unused(&mut s), "in use while the image shows it"); + s.execute("select.set", &json!({ "ids": [r["ids"][0]] })).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(unused(&mut s), "unused once the image is deleted"); +} + +#[test] +fn a_process_swatch_giving_a_dcs_plate_its_color_is_used() { + let dir = temp_dir("unused-process"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + s.execute("swatch.new", &json!({"name": "PANTONE 185 C", "color": {"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap(); + let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].as_array().unwrap().iter().any(|n| n == "PANTONE 185 C"); + assert!(unused(&mut s), "nothing uses it yet"); + s.execute("file.place", &json!({ "path": path })).unwrap(); + assert!(!unused(&mut s), "the plate of its name prints as its process inks"); +} + +#[test] +fn an_opened_clipping_path_is_not_the_files_and_stays() { + let dir = temp_dir("opened-clip"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let group = NodeId(r["ids"][0].as_u64().unwrap()); + let id = image_of(&s, &r["ids"][0]).id; + // The clipping path opened, its anchors where they were. + s.edit("open", |d, _| { + let clip = d.node(group).and_then(|g| g.children()).map(|ch| ch[0].id).unwrap(); + if let Some(Node { kind: NodeKind::Path { path, .. }, .. }) = d.node_mut(clip) { + path.subpaths[0].closed = false; + } + Ok(()) + }) + .unwrap(); + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + std::fs::write(&path, unclipped.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(s.doc().unwrap().doc.parent_of(id), Some(group), "a clipping path edited in the document stays"); +} + +#[test] +fn a_clipping_path_given_another_fill_rule_is_not_the_files_and_stays() { + let dir = temp_dir("rule-clip"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let group = NodeId(r["ids"][0].as_u64().unwrap()); + let id = image_of(&s, &r["ids"][0]).id; + // The clipping path's fill rule changed, its anchors where they were. + s.edit("rule", |d, _| { + let clip = d.node(group).and_then(|g| g.children()).map(|ch| ch[0].id).unwrap(); + if let Some(Node { kind: NodeKind::Path { rule, .. }, .. }) = d.node_mut(clip) { + *rule = vectorcraft_geom::FillRule::EvenOdd; + } + Ok(()) + }) + .unwrap(); + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + std::fs::write(&path, unclipped.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(s.doc().unwrap().doc.parent_of(id), Some(group), "a clipping path whose fill rule was changed stays"); +} + +#[test] +fn an_even_odd_clipping_path_is_placed_and_matched_under_the_nonzero_rule() { + let dir = temp_dir("eo-clip"); + // The same length, so the plates' offsets hold. + let eo = String::from_utf8(two_plates()).unwrap().replacen("gsave newpath 0 0 moveto", "gsave 0 0 moveto ", 1); + let eo = eo.replacen("closepath clip newpath", "closepath eoclip newpath", 1); + assert_eq!(eo.len(), two_plates().len()); + let path = write(&dir, "pic.eps", eo.as_bytes()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let group = NodeId(r["ids"][0].as_u64().unwrap()); + let id = image_of(&s, &r["ids"][0]).id; + let doc = &s.doc().unwrap().doc; + let clip = &doc.node(group).and_then(|g| g.children()).expect("the file's clip group")[0]; + assert!(matches!(clip.kind, NodeKind::Path { rule: vectorcraft_geom::FillRule::NonZero, clipping: true, .. }), "{:?}", clip.kind); + // The file without its clipping path: the group's clipping path is the file's, and goes. + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + std::fs::write(&path, unclipped.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(group).and_then(plain_group_of_image), Some((false, true)), "the file's clipping path is gone"); +} + +#[test] +fn files_updated_together_warn_when_they_add_one_ink_in_two_colors() { + let dir = temp_dir("update-two-inks"); + let (a, b) = (write(&dir, "a.eps", &two_plates()), write(&dir, "b.eps", &two_plates())); + let mut s = session(); + s.execute("file.place", &json!({ "path": a })).unwrap(); + s.execute("file.place", &json!({ "path": b })).unwrap(); + let varnish = + |c: &str| single_file(&[("Cyan", plate_eps([0; 4])), ("Varnish", plate_eps([255; 4]))], &format!("%%CMYKCustomColor: {c} (Varnish)\n")); + std::fs::write(&a, varnish("0 0 .2 0")).unwrap(); + std::fs::write(&b, varnish("1 0 0 0")).unwrap(); + let u = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(u["updated"].as_array().map(Vec::len), Some(2), "{u}"); + assert_eq!(u["warnings"], json!(["the document's swatch `Varnish` is kept: it is a different color"])); +} + +#[test] +fn relink_with_copy_refuses_a_dcs_file_with_plate_files() { + let dir = temp_dir("copy"); + write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes()); + let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%EndComments\nshowpage\n"; + let path = write(&dir, "pic.eps", main.as_bytes()); + let docs = dir.join("docs"); + std::fs::create_dir_all(&docs).unwrap(); + let mut s = session(); + s.execute("document.save", &json!({ "path": docs.join("doc.vectorcraft").to_string_lossy() })).unwrap(); + let png = dir.join("plain.png"); + image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])).save(&png).unwrap(); + let r = s.execute("file.place", &json!({ "path": png.to_string_lossy() })).unwrap(); + let id = r["ids"][0].clone(); + let e = error(&mut s, "links.relink", json!({ "ids": [id], "path": path, "copy": true })); + assert!(e.contains("plate files"), "{e}"); + assert!(!docs.join("pic.eps").exists(), "nothing is copied"); + // A single-file DCS file has its plates inside it: it is copied. + let single = write(&dir, "single.eps", &two_plates()); + s.execute("links.relink", &json!({ "ids": [id], "path": single, "copy": true })).unwrap(); + assert!(docs.join("single.eps").exists()); +} + +#[test] +fn a_saved_document_reads_the_plates_again_and_prints_the_spot_ink() { + let dir = temp_dir("saved"); + let path = write(&dir, "pic.eps", &two_plates()); + let saved = dir.join("doc.vectorcraft").to_string_lossy().into_owned(); + let mut s = session(); + s.execute("file.place", &json!({ "path": path })).unwrap(); + let unused = s.execute("swatch.unused", &json!({})).unwrap(); + assert!(!unused["names"].as_array().unwrap().iter().any(|n| n == "PANTONE 185 C"), "the plate's swatch is in use: {unused}"); + s.execute("document.save", &json!({ "path": saved })).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({ "path": saved })).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(list["links"][0]["format"], "DCS"); + assert_eq!(list["links"][0]["status"], "ok"); + let id = NodeId(list["links"][0]["id"].as_u64().unwrap()); + let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() }; + let plates = s.doc().unwrap().doc.images[&im.key].separated().expect("the plates, read from the file again"); + assert_eq!(plates.plates[1].data, [128, 0, 128, 0]); + let v = s.execute("print.preview", &json!({"settings": {"output": {"mode": "separations"}}})).unwrap(); + let inks: Vec<&str> = v["sheets"].as_array().unwrap().iter().filter_map(|p| p["ink"].as_str()).collect(); + assert_eq!(inks, ["Cyan", "Magenta", "Yellow", "Black", "PANTONE 185 C"]); +} + +#[test] +fn dcs_transparency_marks_transparent_and_overlapped_dcs_images() { + let dir = temp_dir("transparency"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let image = image_of(&s, &r["ids"][0]).id; + let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true; + assert!(!marked(&mut s), "nothing transparent"); + assert!(s.execute("links.list", &json!({})).unwrap()["links"][0].get("dcsTransparency").is_none()); + // A half-transparent square away from it, then over it. + let far = s.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("transparency.set", &json!({"ids": [far], "opacity": 50})).unwrap(); + assert!(!marked(&mut s), "a transparent object elsewhere"); + s.execute("shape.rectangle", &json!({"x": 90, "y": 90, "width": 20, "height": 20})).unwrap(); + let over = s.execute("shape.rectangle", &json!({"x": 92, "y": 92, "width": 4, "height": 4})).unwrap()["id"].as_u64().unwrap(); + assert!(!marked(&mut s), "opaque objects over it"); + s.execute("transparency.set", &json!({"ids": [over], "blend": "multiply"})).unwrap(); + assert!(marked(&mut s), "a blend mode over it"); + s.execute("transparency.set", &json!({"ids": [over], "blend": "normal"})).unwrap(); + s.execute("transparency.set", &json!({"ids": [image.0], "opacity": 80})).unwrap(); + assert!(marked(&mut s), "the image itself"); + assert!(error(&mut s, "links.list", json!({ "dcsTransparency": "yes" })).contains("dcsTransparency"), "a flag that isn't true or false"); +} + +#[test] +fn dcs_transparency_counts_a_transparent_dcs_image_over_another() { + let dir = temp_dir("transparency-two"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let a = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let b = s.execute("file.place", &json!({ "path": path, "at": [103, 100] })).unwrap(); + let (a, b) = (image_of(&s, &a["ids"][0]).id, image_of(&s, &b["ids"][0]).id); + s.execute("transparency.set", &json!({"ids": [a.0], "opacity": 80})).unwrap(); + let list = s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap(); + let marked = |id: NodeId| list["links"].as_array().unwrap().iter().any(|r| r["id"] == id.0 && r["dcsTransparency"] == true); + assert!(marked(a), "the transparent image"); + assert!(marked(b), "the opaque image it overlaps"); +} + +#[test] +fn dcs_transparency_counts_characters_with_a_transparent_gradient() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let dir = temp_dir("transparency-text"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let t = NodeId(s.execute("text.create", &json!({"x": 96, "y": 104, "text": "Hi", "size": 12})).unwrap()["id"].as_u64().unwrap()); + let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true; + assert!(!marked(&mut s), "opaque characters over it"); + let mut stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + stops[1].opacity = 0.5; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + s.edit("t", |d, _| { + if let Some(Node { kind: NodeKind::Text(text), .. }) = d.node_mut(t) { + text.runs[0].style.fill = gradient.clone(); + } + Ok(()) + }) + .unwrap(); + assert!(marked(&mut s), "characters filled with a gradient that has a transparent stop"); + assert!(s.doc().unwrap().doc.node(t).unwrap().shows_transparency()); +} diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index ea10df64b5..5986b618fd 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -3278,6 +3278,14 @@ fn close_path_places_the_first_anchors_handles() { s.execute("path.close", &json!({"id": id.0, "in": [60.0, 140.0], "out": [120.0, 60.0]})).unwrap(); let b = path(&s, id).subpaths[0].anchors[0]; assert_eq!((b.h_in, b.h_out, b.kind), (Point::new(60.0, 140.0), Point::new(120.0, 60.0), AnchorKind::Corner)); + // `at` moves the anchor, its handles with it, before `in` is placed. + s.execute("edit.undo", &json!({})).unwrap(); + refuses_with(&mut s, "path.close", &json!({"id": id.0, "at": "120, 90"}), "`at` must be [x, y]"); + s.execute("path.close", &json!({"id": id.0, "at": [120.0, 90.0], "in": [60.0, 140.0]})).unwrap(); + let b = path(&s, id).subpaths[0].anchors[0]; + assert_eq!((b.p, b.h_in, b.h_out), (Point::new(120.0, 90.0), Point::new(60.0, 140.0), Point::new(170.0, 90.0)), "{b:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id).subpaths[0].anchors[0], a, "one undo step"); } #[test] diff --git a/crates/engine/src/tests/print.rs b/crates/engine/src/tests/print.rs index 8ccd76deba..f10bf65c94 100644 --- a/crates/engine/src/tests/print.rs +++ b/crates/engine/src/tests/print.rs @@ -254,6 +254,35 @@ fn type_in_a_missing_family_warns_as_pdf_export_does() { } } +/// The warnings of `file.print` (PDF and PostScript) and `print.preview` list what the job +/// leaves out of the document or prints differently first, then the notes on the options. +#[test] +fn what_a_print_loses_comes_before_the_notes_on_the_options() { + let (mut s, _) = session(); + s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let notes = |v: &Value| -> Vec { v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() }; + // A fixed flatness isn't written to a PDF: a note on the options, made before the type's. + let fixed = json!({"settings": {"graphics": {"autoFlatness": false}}}); + for v in [print(&mut s, fixed.clone()).1, s.execute("print.preview", &fixed).unwrap()] { + let n = notes(&v); + assert!(n.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{n:?}"); + assert!(n.last().is_some_and(|w| w.starts_with("a fixed flatness")), "{n:?}"); + } + // PostScript doesn't apply the printer profile. + let profile = json!({"format": "postscript", "settings": {"color": {"profile": vectorcraft_color::cms::SRGB}}}); + let n = notes(&s.execute("file.print", &profile).unwrap()); + assert!(n.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{n:?}"); + assert!(n.last().is_some_and(|w| w.starts_with("the printer profile")), "{n:?}"); + // An artboard larger than Letter: copies per sheet that don't fit come after the art that + // doesn't print. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 700, "height": 900})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 700, "height": 900})).unwrap(); + let n = notes(&print(&mut s, json!({"settings": {"copiesPerSheet": true}})).1); + assert!(n.iter().any(|w| w.contains("part of it doesn't print")), "{n:?}"); + assert!(n.last().is_some_and(|w| w.ends_with("it prints one copy per sheet")), "{n:?}"); +} + #[test] fn type_that_does_not_fit_its_frame_is_named() { let (mut s, _) = session(); diff --git a/crates/eps/src/dcs.rs b/crates/eps/src/dcs.rs new file mode 100644 index 0000000000..1de21a4afe --- /dev/null +++ b/crates/eps/src/dcs.rs @@ -0,0 +1,491 @@ +//! DCS files (Desktop Color Separation): an EPS file whose header comments point at one EPS +//! program per printing plate. A DCS 2.0 file names each plate with `%%PlateFile: (name) EPS +//! #offset size`, the plate inside the file `offset` bytes from its start, or `%%PlateFile: (name) +//! EPS Local file`, the plate in a file of its own; a DCS 1.0 file names the four process plates' +//! files with `%%CyanPlate:`, `%%MagentaPlate:`, `%%YellowPlate:` and `%%BlackPlate:`. Spot inks +//! take their process equivalents from `%%CMYKCustomColor:` (else `%%RGBCustomColor:`). The comments +//! are described at http://www.telegraphics.net/doc/DCS.txt. +//! +//! [`read`] finds those comments; [`place`] reads every plate's image (its ink amounts: a dark +//! plate pixel is ink) into one [`Separated`] image, with the clipping path the main file's +//! composite is drawn through. + +use vectorcraft_color::cms::PROCESS_PLATES; +use vectorcraft_doc::separated::{InkPlate, MAX_NAME, MAX_PLATES, MAX_SAMPLES, Separated}; +use vectorcraft_doc::{Document, Node, NodeKind}; +use vectorcraft_geom::{Affine, Rect}; + +/// Where a plate's program is. +#[derive(Clone, Debug, PartialEq)] +pub enum PlateAt { + /// In the DCS file itself: `len` bytes from byte `offset` of the file. + Here { offset: usize, len: usize }, + /// In a file of its own, as the DCS file names it (a path or a file name). + File(String), +} + +/// One plate a DCS file names. +#[derive(Clone, Debug, PartialEq)] +pub struct PlateRef { + /// "Cyan", "Magenta", "Yellow", "Black" or a spot ink's name. + pub name: String, + pub at: PlateAt, +} + +/// The DCS comments of a file. +#[derive(Clone, Debug, PartialEq)] +pub struct Dcs { + /// 1 or 2. + pub version: u8, + pub plates: Vec, + /// Spot inks' process equivalents (C, M, Y, K, 0..1), by name. + pub equivalents: Vec<(String, [f32; 4])>, +} + +impl Dcs { + /// The names of the plate files kept apart from the DCS file. + pub fn plate_files(&self) -> impl Iterator { + self.plates.iter().filter_map(|p| match &p.at { + PlateAt::File(f) => Some(f.as_str()), + PlateAt::Here { .. } => None, + }) + } +} + +/// The DCS comments of EPS file `bytes` (behind its binary header, if it has one); `None` when it +/// names no plate. +pub fn read(bytes: &[u8]) -> Option { + let (ps, _) = crate::sections(bytes)?; + if !ps.starts_with(b"%!") { + return None; + } + // The plates of the DCS 2.0 comments and of the DCS 1.0 ones. + let mut plates: Vec = vec![]; + let mut old: Vec = vec![]; + let mut equivalents: Vec<(String, [f32; 4])> = vec![]; + let mut rgb: Vec<(String, [f32; 4])> = vec![]; + // 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 = header_line(raw); + let line = line.trim_end(); + if line.starts_with("%%EndComments") || !line.starts_with('%') { + break; + } + let (key, value) = match line.strip_prefix("%%+") { + Some(v) => (last.clone(), v.trim()), + None => match line.split_once(':') { + Some((k, v)) => (k.to_string(), v.trim()), + None => (line.to_string(), ""), + }, + }; + match key.as_str() { + "%%PlateFile" => { + if let Some(p) = plate_file(value) + && !plates.iter().any(|q| q.name == p.name) + { + plates.push(p); + } + } + "%%CyanPlate" | "%%MagentaPlate" | "%%YellowPlate" | "%%BlackPlate" => { + let name = key.trim_start_matches("%%").trim_end_matches("Plate").to_string(); + if !value.is_empty() && !old.iter().any(|q| q.name == name) { + old.push(PlateRef { name, at: PlateAt::File(value.to_string()) }); + } + } + "%%CMYKCustomColor" => equivalents.extend(custom_colors(value, false)), + "%%RGBCustomColor" => rgb.extend(custom_colors(value, true)), + _ => {} + } + last = key; + } + // A file is one version or the other; the plate comments of DCS 2.0 win. + let version = if plates.is_empty() { + plates = old; + 1 + } else { + 2 + }; + if plates.is_empty() { + return None; + } + for (name, c) in rgb { + if !equivalents.iter().any(|(n, _)| *n == name) { + equivalents.push((name, c)); + } + } + Some(Dcs { version, plates, equivalents }) +} + +/// 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 { + let (name, rest) = ps_string(v)?; + let mut words = rest.split_whitespace(); + let _kind = words.next()?; + let location = words.next()?; + let name = canonical(name)?; + if let Some(offset) = location.strip_prefix('#') { + let len = words.next()?.parse().ok()?; + return Some(PlateRef { name, at: PlateAt::Here { offset: offset.parse().ok()?, len } }); + } + if !location.eq_ignore_ascii_case("Local") { + return None; + } + // 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 +/// it is empty or longer than [`MAX_NAME`]. +fn canonical(name: String) -> Option { + if name.is_empty() || name.len() > MAX_NAME { + return None; + } + Some(PROCESS_PLATES.iter().find(|p| p.eq_ignore_ascii_case(&name)).map_or(name, |p| p.to_string())) +} + +/// The custom colours of a `%%CMYKCustomColor:` (four numbers then a name, repeated) or, with +/// `rgb`, a `%%RGBCustomColor:` comment (three numbers), the RGB ones as the process inks that +/// take their light away (`1 − r`, `1 − g`, `1 − b`, no black). Names may be bare words. +fn custom_colors(v: &str, rgb: bool) -> Vec<(String, [f32; 4])> { + let mut out = vec![]; + let mut rest = v.trim(); + let n = if rgb { 3 } else { 4 }; + while !rest.is_empty() && out.len() < MAX_PLATES { + let mut nums = [0.0f32; 4]; + for v in nums.iter_mut().take(n) { + let Some((word, after)) = rest.split_once(char::is_whitespace) else { return out }; + let Some(x) = word.parse::().ok().filter(|x| (0.0..=1.0).contains(x)) else { return out }; + *v = x; + rest = after.trim_start(); + } + let (name, after) = match ps_string(rest) { + Some((s, a)) => (s, a), + None => { + let (w, a) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + (w.to_string(), a) + } + }; + rest = after.trim_start(); + let cmyk = if rgb { [1.0 - nums[0], 1.0 - nums[1], 1.0 - nums[2], 0.0] } else { nums }; + if let Some(name) = canonical(name) { + out.push((name, cmyk)); + } + } + out +} + +/// The PostScript string at the start of `v` (`(…)`, with nested parentheses and backslash +/// escapes) and what follows it. +fn ps_string(v: &str) -> Option<(String, &str)> { + let body = v.trim_start().strip_prefix('(')?; + let mut out = String::new(); + let mut depth = 0usize; + let mut chars = body.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + match c { + '\\' => match chars.next()?.1 { + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + d @ '0'..='7' => { + let mut code = d.to_digit(8)?; + for _ in 0..2 { + match chars.peek().and_then(|(_, e)| e.to_digit(8)) { + Some(e) => { + chars.next(); + code = code * 8 + e; + } + None => break, + } + } + // Octal escapes name bytes; names are read as Latin-1. + out.push(char::from_u32(code & 0xff)?); + } + e => out.push(e), + }, + '(' => { + depth += 1; + out.push(c); + } + ')' if depth == 0 => return Some((out, body.get(i + 1..)?)), + ')' => { + depth -= 1; + out.push(c); + } + _ => out.push(c), + } + } + None +} + +/// The main part of DCS file `bytes`: its PostScript (behind its binary header, if it has one), up +/// to the first plate it holds. +pub fn main_program<'a>(bytes: &'a [u8], dcs: &Dcs) -> Option<&'a [u8]> { + let (ps, _) = crate::sections(bytes)?; + let start = ps.as_ptr() as usize - bytes.as_ptr() as usize; + let first = dcs.plates.iter().filter_map(|p| match p.at { + PlateAt::Here { offset, .. } if offset > start => Some(offset - start), + _ => None, + }); + Some(&ps[..first.min().unwrap_or(ps.len()).min(ps.len())]) +} + +/// A DCS file read for placing. +#[derive(Debug)] +pub struct Placed { + pub separated: Separated, + /// The file's page (its bounding box) with its top-left corner at the origin. + pub page: Rect, + /// The image's pixels onto the page. + pub xf: Affine, + /// The clipping path the composite is drawn through, on the page. + pub clip: Option, + /// The resolution of the plates (pixels per inch), from how large they print. + pub ppi: Option<(f64, f64)>, + pub warnings: Vec, +} + +/// One plate's image: its ink amounts, one byte a pixel, and where its pixels print on its page. +struct PlateImage { + width: u32, + height: u32, + data: Vec, + xf: Affine, +} + +/// Read DCS file `bytes` (its comments `dcs`) for placing: every plate's image, the plates kept +/// apart read with `file` (the name the file gives → the plate file's bytes). The plates are put on +/// the pixel grid of the first plate's image, grown to hold every plate's image where it prints; +/// another plate is resampled onto it. An error when the file names more than [`MAX_PLATES`] +/// plates or a plate can't be read. +pub fn place(bytes: &[u8], dcs: &Dcs, file: &mut dyn FnMut(&str) -> Result, String>) -> Result { + if dcs.plates.len() > MAX_PLATES { + return Err(format!("it names {} plates, and at most {MAX_PLATES} are read", dcs.plates.len())); + } + let main = main_program(bytes, dcs).ok_or("the file's preview header points outside the file")?; + let page = crate::page(main)?; + let mut warnings = vec![]; + let (mut images, mut samples) = (vec![], 0u64); + for p in &dcs.plates { + let read = match &p.at { + PlateAt::Here { offset, len } => { + let eps = offset.checked_add(*len).and_then(|end| bytes.get(*offset..end)); + let eps = eps.ok_or_else(|| format!("the {} plate lies outside the file", p.name))?; + plate_image(eps) + } + PlateAt::File(f) => file(f).and_then(|eps| plate_image(&eps)), + }; + let img = read.map_err(|e| format!("the {} plate can't be read: {e}", p.name))?; + // Checked before the other plates are read. + samples += u64::from(img.width) * u64::from(img.height); + if samples > MAX_SAMPLES { + return Err(format!("its plates are too large: more than {MAX_SAMPLES} pixels together")); + } + images.push((p, img)); + } + let Some((w, h, xf)) = grid(images.iter().map(|(_, img)| img)) else { return Err("the file names no plate".into()) }; + if !Separated::fits(w, h, images.len()) { + return Err(format!("its plates are too large: {} plates of {w} × {h} pixels", images.len())); + } + let mut plates = vec![]; + for (p, img) in images { + let name = p.name.clone(); + let aligned = img.xf.as_coeffs().iter().zip(xf.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-9); + let data = if (img.width, img.height) == (w, h) && aligned { img.data } else { resample(&img, w, h, xf) }; + let cmyk = match dcs.equivalents.iter().find(|(n, _)| *n == name) { + Some((_, c)) => *c, + None if PROCESS_PLATES.contains(&name.as_str()) => [0.0; 4], + None => { + warnings.push(format!("the file gives no process equivalent for the {name} ink: it shows as black")); + [0.0, 0.0, 0.0, 1.0] + } + }; + plates.push(InkPlate { name, cmyk, data }); + } + let separated = Separated::new(w, h, plates).ok_or("its plates can't be kept together")?; + let clip = clip_path(main); + // Points one pixel spans along each axis. + let [a, b, c, d, ..] = xf.as_coeffs(); + let ppi = [a.hypot(b), c.hypot(d)].map(|l| (l > 1e-12).then(|| 72.0 / l)); + let ppi = match ppi { + [Some(x), Some(y)] => Some((x, y)), + _ => None, + }; + Ok(Placed { separated, page, xf, clip, ppi, warnings }) +} + +/// The pixel grid the plates `images` go on: the first image's pixels, grown by whole pixels to +/// hold where every image prints, as its width, height and where its pixels print; `None` without +/// an image. It is the first image's own when its transform can't be inverted. +fn grid<'a>(mut images: impl Iterator) -> Option<(u32, u32, Affine)> { + let first = images.next()?; + let own = Some((first.width, first.height, first.xf)); + if first.xf.determinant().abs() <= 1e-12 { + return own; + } + let to_first = first.xf.inverse(); + let mut r = Rect::new(0.0, 0.0, f64::from(first.width), f64::from(first.height)); + for img in images { + r = r.union((to_first * img.xf).transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(img.width), f64::from(img.height)))); + } + // Edges within a millionth of a pixel of a whole one are on it. + let snap = |v: f64, up: bool| { + let n = v.round(); + if (v - n).abs() < 1e-6 { + n + } else if up { + v.ceil() + } else { + v.floor() + } + }; + let (x0, y0, x1, y1) = (snap(r.x0, false), snap(r.y0, false), snap(r.x1, true), snap(r.y1, true)); + let size = |a: f64, b: f64| Some(b - a).filter(|n| n.is_finite() && (1.0..=f64::from(u32::MAX)).contains(n)).map(|n| n as u32); + match (size(x0, x1), size(y0, y1)) { + (Some(w), Some(h)) => Some((w, h, first.xf * Affine::translate((x0, y0)))), + // Larger than any separated image may be ([`Separated::fits`]). + _ => Some((u32::MAX, u32::MAX, first.xf)), + } +} + +/// The ink amounts of `img` on the `w` × `h` pixels that `onto` puts on the page (each the +/// nearest pixel of `img` where it prints there, none outside it). When either transform can't be +/// inverted, the pixels are stretched over one another instead. +fn resample(img: &PlateImage, w: u32, h: u32, onto: Affine) -> Vec { + let (sw, sh) = (img.width as usize, img.height as usize); + let mut out = Vec::with_capacity(w as usize * h as usize); + let invertible = |m: Affine| m.determinant().abs() > 1e-12; + // From a pixel of the image made to a pixel of `img`. + let to_plate = (invertible(img.xf) && invertible(onto)).then(|| img.xf.inverse() * onto); + for y in 0..h as usize { + for x in 0..w as usize { + let at = match to_plate { + Some(m) => { + let p = m * vectorcraft_geom::Point::new(x as f64 + 0.5, y as f64 + 0.5); + let inside = p.x >= 0.0 && p.y >= 0.0 && p.x < sw as f64 && p.y < sh as f64; + inside.then(|| (p.y as usize).min(sh - 1) * sw + (p.x as usize).min(sw - 1)) + } + None => Some((y * sh / h as usize).min(sh - 1) * sw + (x * sw / w as usize).min(sw - 1)), + }; + out.push(at.and_then(|i| img.data.get(i).copied()).unwrap_or(0)); + } + } + out +} + +/// 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())); + } + // 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")?; + // The object's pixel space may differ from the pixels it keeps. + let xf = xf * Affine::scale_non_uniform(f64::from(iw) / f64::from(width.max(1)), f64::from(ih) / f64::from(height.max(1))); + Ok(PlateImage { width, height, data, xf }) +} + +/// The clipping path the first image of `main` is drawn through, when it is drawn through one. +fn clip_path(main: &[u8]) -> Option { + let r = crate::import_with(main, false).ok().filter(|r| !r.preview)?; + first_clip(&r.document) +} + +/// 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 { + /// 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))); + }; + 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 = || { + let mut any = false; + n.walk(&mut |c: &Node| any |= matches!(c.kind, NodeKind::Image(_))); + any + }; + first.filter(|_| holds_image()).map(|f| vec![(**f).clone()]) + } + 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)] +mod tests; diff --git a/crates/eps/src/dcs/tests.rs b/crates/eps/src/dcs/tests.rs new file mode 100644 index 0000000000..b55fea0b84 --- /dev/null +++ b/crates/eps/src/dcs/tests.rs @@ -0,0 +1,283 @@ +//! DCS files made from scratch as http://www.telegraphics.net/doc/DCS.txt describes them: a main +//! EPS file whose header names its plates, each plate an EPS program drawing one grayscale image. + +use std::collections::HashMap; + +use vectorcraft_doc::NodeKind; + +use super::*; + +/// A plate program: one `w` × `h` grayscale image (`gray`, row by row from the top) on a 20 × 10 +/// pt page. +pub(crate) fn plate_eps(w: u32, h: u32, gray: &[u8]) -> String { + let hex: String = gray.iter().map(|g| format!("{g:02x}")).collect(); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n20 10 scale\n{w} {h} 8 [{w} 0 0 -{h} 0 {h}] <{hex}> image\nshowpage\n%%Trailer\n%%EOF\n" + ) +} + +/// The composite: the image `w` × `h` drawn through a clipping path, the left half of the page. +fn composite(w: u32, h: u32) -> String { + let hex = "80".repeat((w * h * 3) as usize); + format!( + "gsave newpath 0 0 moveto 10 0 lineto 10 10 lineto 0 10 lineto closepath clip newpath\n20 10 scale\n{w} {h} 8 [{w} 0 0 -{h} 0 {h}] <{hex}> false 3 colorimage\ngrestore\nshowpage\n%%EOF\n" + ) +} + +/// A single-file DCS 2.0 file of `plates` (name, plate program), with the header lines `extra`. +pub(crate) fn single_file(plates: &[(&str, String)], extra: &str) -> Vec { + // Offsets and sizes are written ten digits wide, so the header's length is known first. + let header = |offsets: &[usize]| { + let mut s = String::from("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n"); + s.push_str(extra); + for ((name, eps), at) in plates.iter().zip(offsets) { + s.push_str(&format!("%%PlateFile: ({name}) EPS #{at:010} {:010}\n", eps.len())); + } + s.push_str("%%EndComments\n"); + s.push_str(&composite(2, 2)); + s + }; + let main = header(&vec![0; plates.len()]).len(); + let mut offsets = vec![]; + let mut at = main; + for (_, eps) in plates { + offsets.push(at); + at += eps.len(); + } + let mut out = header(&offsets).into_bytes(); + assert_eq!(out.len(), main); + for (_, eps) in plates { + out.extend(eps.as_bytes()); + } + out +} + +#[test] +fn single_file_plates_and_equivalents_are_read() { + let plate = plate_eps(1, 1, &[0]); + let extra = "%%DocumentCustomColors: (PANTONE 185 C)\n%%+ (Gr\\374n \\(A\\))\n%%CMYKCustomColor: 0 .91 .76 0 (PANTONE 185 C)\n%%+ .5 0 1 0 (Gr\\374n \\(A\\))\n%%RGBCustomColor: 1 1 0 (Lemon)\n"; + let bytes = single_file(&[("cyan", plate.clone()), ("PANTONE 185 C", plate.clone()), ("Grün (A)", plate.clone())], extra); + let dcs = read(&bytes).unwrap(); + assert_eq!(dcs.version, 2); + let names: Vec<&str> = dcs.plates.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Cyan", "PANTONE 185 C", "Grün (A)"], "a process ink in its usual case; escapes read"); + let PlateAt::Here { offset, len } = dcs.plates[1].at else { panic!("in the file: {:?}", dcs.plates[1]) }; + assert_eq!(&bytes[offset..offset + len], plate.as_bytes()); + assert_eq!( + dcs.equivalents, + [ + ("PANTONE 185 C".to_string(), [0.0, 0.91, 0.76, 0.0]), + ("Grün (A)".to_string(), [0.5, 0.0, 1.0, 0.0]), + ("Lemon".to_string(), [0.0, 0.0, 1.0, 0.0]) + ] + ); + assert_eq!(dcs.plate_files().count(), 0); + let main = main_program(&bytes, &dcs).unwrap(); + assert!(main.ends_with(b"%%EOF\n"), "up to the first plate"); + let PlateAt::Here { offset: first, .. } = dcs.plates[0].at else { panic!("in the file") }; + assert_eq!(main.len(), first); +} + +#[test] +fn plate_files_of_both_versions_are_read() { + let two = "%!PS-Adobe-3.0 EPSF-3.0\n%%PlateFile: (Black) EPS Local pic.K\n%%PlateFile: (Black) TIFF NewServe c:\\scans\\a0K.scn\n%%PlateFile: (TRUMATCH 1-a5) EPS Local my pic.9\n%%EndComments\n"; + let dcs = read(two.as_bytes()).unwrap(); + assert_eq!(dcs.version, 2); + assert_eq!( + dcs.plates, + [ + PlateRef { name: "Black".into(), at: PlateAt::File("pic.K".into()) }, + PlateRef { name: "TRUMATCH 1-a5".into(), at: PlateAt::File("my pic.9".into()) }, + ] + ); + let one = "%!PS-Adobe-2.0 EPSF-1.2\n%%CyanPlate: pic.C\n%%MagentaPlate: pic.M\n%%YellowPlate: pic.Y\n%%BlackPlate: pic.K\n%%EndComments\n"; + let dcs = read(one.as_bytes()).unwrap(); + assert_eq!(dcs.version, 1); + assert_eq!(dcs.plate_files().collect::>(), ["pic.C", "pic.M", "pic.Y", "pic.K"]); + let both = "%!PS-Adobe-2.0 EPSF-1.2\n%%CyanPlate: old.C\n%%PlateFile: (Cyan) EPS Local new.C\n%%EndComments\n"; + assert_eq!(read(both.as_bytes()).unwrap().plate_files().collect::>(), ["new.C"], "the DCS 2.0 comments win"); + // Comments after the header are not the file's. + assert!(read(b"%!PS-Adobe-3.0 EPSF-3.0\n%%EndComments\n%%PlateFile: (Cyan) EPS Local a.C\n").is_none()); + assert!(read(plate_eps(1, 1, &[0]).as_bytes()).is_none(), "a plain EPS file"); + 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. + let bytes = single_file( + &[("Cyan", plate_eps(2, 2, &[0, 0, 255, 255])), ("PANTONE 185 C", plate_eps(2, 2, &[127, 255, 127, 255]))], + "%%CMYKCustomColor: 0 .91 .76 0 (PANTONE 185 C)\n", + ); + let dcs = read(&bytes).unwrap(); + let p = place(&bytes, &dcs, &mut |_| Err("no files".into())).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + assert_eq!(p.page, Rect::new(0.0, 0.0, 20.0, 10.0)); + let s = &p.separated; + assert_eq!((s.width, s.height), (2, 2)); + assert_eq!(s.plates[0].data, [255, 255, 0, 0], "a dark plate pixel is ink"); + assert_eq!(s.plates[1].data, [128, 0, 128, 0]); + assert_eq!(s.plates[1].cmyk, [0.0, 0.91, 0.76, 0.0]); + assert_eq!(p.xf * vectorcraft_geom::Point::new(2.0, 2.0), vectorcraft_geom::Point::new(20.0, 10.0)); + assert_eq!(p.ppi, Some((7.2, 14.4))); + let clip = p.clip.expect("the composite's clipping path"); + assert!(matches!(clip.kind, NodeKind::Path { clipping: true, .. })); + assert_eq!(clip.geometric_bounds().map(|b| b.width().round()), Some(10.0), "the left half of the page"); +} + +#[test] +fn plate_files_are_read_with_the_callback() { + let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%BlackPlate: pic.K\n%%EndComments\nshowpage\n"; + let files: HashMap<&str, String> = [("pic.C", plate_eps(1, 1, &[0])), ("pic.K", plate_eps(2, 1, &[255, 0]))].into(); + let dcs = read(main.as_bytes()).unwrap(); + let mut asked = vec![]; + let p = place(main.as_bytes(), &dcs, &mut |f| { + asked.push(f.to_string()); + files.get(f).map(|e| e.as_bytes().to_vec()).ok_or_else(|| "missing".into()) + }) + .unwrap(); + assert_eq!(asked, ["pic.C", "pic.K"]); + assert!(p.clip.is_none(), "no composite, no clipping path"); + assert_eq!((p.separated.width, p.separated.height), (1, 1), "the first plate's size"); + assert_eq!(p.separated.plates[1].data, [255], "resampled to it: the pixel at its centre"); + let err = place(main.as_bytes(), &dcs, &mut |_| Err("not found".into())).unwrap_err(); + assert_eq!(err, "the Cyan plate can't be read: not found"); +} + +#[test] +fn plates_printing_elsewhere_on_the_page_stay_in_register() { + // The Black plate's one solid pixel prints on the right half of the page only. + let right = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n10 0 translate 10 10 scale\n1 1 8 [1 0 0 -1 0 1] <00> image\nshowpage\n%%EOF\n"; + let bytes = single_file(&[("Cyan", plate_eps(2, 1, &[255, 255])), ("Black", right.to_string())], ""); + let p = place(&bytes, &read(&bytes).unwrap(), &mut |_| Err("no files".into())).unwrap(); + assert_eq!((p.separated.width, p.separated.height), (2, 1)); + assert_eq!(p.separated.plates[1].data, [0, 255], "the ink lands on the right pixel only"); +} + +/// A plate program whose one solid pixel prints on a 10 × 10 pt square `x` pt from the page's left. +fn square_at(x: u32) -> String { + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n{x} 0 translate 10 10 scale\n1 1 8 [1 0 0 -1 0 1] <00> image\nshowpage\n%%EOF\n" + ) +} + +#[test] +fn the_plates_grid_grows_to_hold_every_plate() { + let bytes = single_file(&[("Cyan", square_at(0)), ("Black", square_at(10))], ""); + let p = place(&bytes, &read(&bytes).unwrap(), &mut |_| Err("no files".into())).unwrap(); + assert_eq!((p.separated.width, p.separated.height), (2, 1), "the first plate's pixels, grown over the second"); + assert_eq!(p.separated.plates[0].data, [255, 0]); + assert_eq!(p.separated.plates[1].data, [0, 255], "nothing of the second plate is cut off"); + assert_eq!(p.xf * vectorcraft_geom::Point::new(2.0, 1.0), vectorcraft_geom::Point::new(20.0, 10.0)); +} + +#[test] +fn a_file_naming_more_plates_than_are_read_is_an_error() { + let plate = plate_eps(1, 1, &[0]); + let names: Vec = (0..=MAX_PLATES).map(|i| format!("Ink {i}")).collect(); + let plates: Vec<(&str, String)> = names.iter().map(|n| (n.as_str(), plate.clone())).collect(); + let bytes = single_file(&plates, ""); + let dcs = read(&bytes).unwrap(); + assert_eq!(dcs.plates.len(), MAX_PLATES + 1, "every plate named"); + let err = place(&bytes, &dcs, &mut |_| Err("no files".into())).unwrap_err(); + assert_eq!(err, "it names 65 plates, and at most 64 are read"); +} + +#[test] +fn bad_plates_are_errors_and_missing_equivalents_warnings() { + let mut bytes = single_file(&[("Cyan", plate_eps(1, 1, &[0]))], ""); + let dcs = read(&bytes).unwrap(); + bytes.truncate(bytes.len() - 5); + assert_eq!(place(&bytes, &dcs, &mut |_| Err(String::new())).unwrap_err(), "the Cyan plate lies outside the file"); + let text = single_file(&[("Cyan", "%!PS-Adobe-3.0\n0 0 moveto 1 1 lineto stroke\n".to_string())], ""); + let err = place(&text, &read(&text).unwrap(), &mut |_| Err(String::new())).unwrap_err(); + assert_eq!(err, "the Cyan plate can't be read: it draws no image"); + let spot = single_file(&[("Mystery", plate_eps(1, 1, &[0]))], ""); + let p = place(&spot, &read(&spot).unwrap(), &mut |_| Err(String::new())).unwrap(); + assert_eq!(p.separated.plates[0].cmyk, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(p.warnings, ["the file gives no process equivalent for the Mystery ink: it shows as black"]); +} diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index f6b7fba609..44ec49c190 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -31,9 +31,11 @@ //! - `ai_editing`: the editing copy of the art a PDF-compatible `.ai` file carries //! ([`export_ai_editing`], [`ai_private_data`]). //! - `ai_ops`: the path and line style operators those two write. +//! - [`dcs`]: DCS files, an EPS file with one program per printing plate, read for placing. mod ai_editing; mod ai_ops; +pub mod dcs; mod import; mod latin; mod legacy_ai; diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index 0bad9d9f60..12c71756c8 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -81,7 +81,10 @@ pub struct PrintOptions { pub struct PrintReport { pub bytes: Vec, pub pages: usize, + /// What the job leaves out of the document or prints differently. pub warnings: Vec, + /// Notes on the options: one the job didn't apply or applied another way. + pub setting_notes: Vec, } /// One page of a job, as [`preview`] lists it (one copy). @@ -129,7 +132,12 @@ pub struct PrintPreview { pub tiles: Vec, /// The inks of a separation (empty for composite output). pub inks: Vec, + /// What the job leaves out of the document or prints differently. pub warnings: Vec, + /// Notes on the options: one the job doesn't apply or applies another way. They aren't + /// serialized: a reply lists them in `warnings`, after what the job leaves out. + #[serde(skip)] + pub setting_notes: Vec, } /// A laid-out job. @@ -144,7 +152,10 @@ struct Job<'a> { plates: Vec, /// One copy's pages: (layout, plate). sheets: Vec<(usize, Option)>, + /// What the job leaves out of the document or prints differently. warnings: Vec, + /// Notes on the options: one the job doesn't apply or applies another way. + setting_notes: Vec, } /// Keep the layers of `nodes` that print as `which` says, shown (hidden ones print with All), @@ -223,11 +234,11 @@ impl<'a> Job<'a> { fn new(doc: &'a Document, set: &PrintSettings, postscript: bool) -> Result { set.check()?; let doc = printed_document(doc, set); - let mut warnings = vec![]; - let (layouts, tiles) = layout::layout(&doc, set, &mut warnings)?; + let (mut warnings, mut setting_notes) = (vec![], vec![]); + let (layouts, tiles) = layout::layout(&doc, set, &mut warnings, &mut setting_notes)?; let separations = set.output.mode == OutputMode::Separations; let (inks, more) = if separations { print_inks(&doc, set) } else { Default::default() }; - warnings.extend(more); + setting_notes.extend(more); let plates: Vec = (0..inks.len()).filter(|i| inks[*i].print).collect(); let printed: Vec> = if separations { (0..plates.len()).map(Some).collect() } else { vec![None] }; if printed.is_empty() { @@ -245,18 +256,18 @@ impl<'a> Job<'a> { warnings.push("the document is RGB: separations convert its colours to CMYK with the colour settings".into()); } if !postscript && separations && set.output.inks.iter().any(|i| i.frequency.is_some() || i.angle.is_some()) { - warnings.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into()); + setting_notes.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into()); } if separations && set.advanced.print_as_bitmap { - warnings.push("Print as Bitmap is for composite output: separations print the art as it is".into()); + setting_notes.push("Print as Bitmap is for composite output: separations print the art as it is".into()); } if separations && cms::profile(set.color.profile.trim()).is_some_and(|p| p.kind != ProfileKind::Cmyk) { - warnings.push("separations separate with a CMYK profile: the RGB printer profile is left out".into()); + setting_notes.push("separations separate with a CMYK profile: the RGB printer profile is left out".into()); } if !postscript && !set.graphics.auto_flatness { - warnings.push("a fixed flatness is not written yet: the output device flattens curves".into()); + setting_notes.push("a fixed flatness is not written yet: the output device flattens curves".into()); } - Ok(Self { doc, layouts, tiles, inks, plates, sheets, warnings }) + Ok(Self { doc, layouts, tiles, inks, plates, sheets, warnings, setting_notes }) } /// The ink of plate `plate` (`None`: composite). @@ -303,7 +314,14 @@ pub fn preview(doc: &Document, set: &PrintSettings) -> Result { pub title: String, /// When the job ran (Unix seconds, UTC). pub created: Option, + /// What the job leaves out of the document or prints differently. pub warnings: Vec, + /// Notes on the options: one the job doesn't apply or applies another way. + pub setting_notes: Vec, } /// Lay out printing `doc` as `opts` say, for a writer to draw. @@ -415,7 +436,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result, }) .collect(); let order = job.order(set); - Ok(PrintPlan { plates, sheets, order, title, created, warnings: job.warnings, doc: job.doc }) + Ok(PrintPlan { plates, sheets, order, title, created, warnings: job.warnings, setting_notes: job.setting_notes, doc: job.doc }) } /// Landscape sheet `s` upright on the paper ([`PrintOptions::for_printer`]): a page wider than @@ -473,12 +494,18 @@ fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet = vec![]; + for m in plan.setting_notes.into_iter().chain(settings) { + if !warnings.contains(&m) && !setting_notes.contains(&m) { + setting_notes.push(m); + } + } + Ok(PrintReport { bytes, pages: plan.order.len(), warnings, setting_notes }) } /// The printer's marks of page `l` (`None` without marks), with its page information: the diff --git a/crates/pdf/src/print/layout.rs b/crates/pdf/src/print/layout.rs index 3d404abab9..a88dd89950 100644 --- a/crates/pdf/src/print/layout.rs +++ b/crates/pdf/src/print/layout.rs @@ -184,8 +184,14 @@ fn too_many_pages() -> PdfError { } /// The pages of `doc` (whose layers are those that print) as `set` lays them out, the tiles of -/// each artboard when tiling, and warnings (art that doesn't fit the imageable area). -pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec) -> Result<(Vec, Vec), PdfError> { +/// each artboard when tiling. `warnings` gets what doesn't print (art outside the imageable area, +/// an artboard with no area), and `setting_notes` the copies per sheet that don't fit. +pub(crate) fn layout( + doc: &Document, + set: &PrintSettings, + warnings: &mut Vec, + setting_notes: &mut Vec, +) -> Result<(Vec, Vec), PdfError> { let marks = set.marks.printer_marks(); let bleed = set.bleed.of(doc); let (pw, ph) = set.paper(); @@ -278,7 +284,7 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec { - warnings.push(format!("{what} with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet")); + setting_notes.push(format!("{what} with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet")); None } Some((cols, rows)) if cols * rows > MAX_COPIES_PER_SHEET as f64 => { diff --git a/crates/pdf/src/print/plates.rs b/crates/pdf/src/print/plates.rs index 42ca9e5a1d..ce6c5b336e 100644 --- a/crates/pdf/src/print/plates.rs +++ b/crates/pdf/src/print/plates.rs @@ -1,5 +1,6 @@ //! Separations: the inks of a job ([`print_inks`]) and the document as one ink's plate, every -//! colour (and image pixel) replaced by the grey of that ink's coverage. +//! colour (and image pixel) replaced by the grey of that ink's coverage. An image placed from a +//! DCS file prints its own plates ([`vectorcraft_doc::separated`]). use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -130,7 +131,30 @@ impl<'a> Separator<'a> { kept.insert(colorized_key(&key), blob.clone()); } } + // A DCS file's image prints its own plates: a spot plate on its ink when the job prints + // that ink, else as its process equivalent. + let spots: BTreeSet = if self.set.output.spots_to_process { + BTreeSet::new() + } else { + 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, &equivalent) { + *blob = gray; + } + continue; + } let gray = blob.map_rgb(|[r, g, b]| { let rgb = |v: u8| v as f32 / 255.0; let v = (255.0 * (1.0 - self.ink(plate, &Color::rgb(rgb(r), rgb(g), rgb(b)), None))).round() as u8; diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 2ad5ab8bba..5b86f2b895 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -322,6 +322,76 @@ fn image_doc(blob: ImageBlob, w: u32, h: u32) -> Document { d } +/// An image placed from a DCS file prints each of its plates on its own ink: a spot plate on its +/// spot ink's plate, or as its process equivalent when spots print as process. Its composite, the +/// CMYK samples every other reader sees, never prints on a separation. +#[test] +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("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: "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(); + let job = plan(&d, &PrintOptions { settings, ..Default::default() }).unwrap(); + let coverage: Vec> = 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 + }) + .collect(); + names.into_iter().zip(coverage).collect::>() + }; + let own = plates(false); + let get = |all: &[(String, Vec)], name: &str| all.iter().find(|(n, _)| n == name).map(|(_, c)| c.clone()); + 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, "Spot Orange"), Some(vec![128, 0])); + let process = plates(true); + 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/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 897e7241b4..5c5e1eb2c0 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -275,11 +275,8 @@ fn an_artboard_that_does_not_fit_prints_one_copy_per_sheet() { let d = doc(700.0, 900.0, &[(Rect::new(0.0, 0.0, 700.0, 900.0), rgb(0.0, 0.0, 1.0))]); let r = job(&d, json!({"copiesPerSheet": true})); assert_eq!(r.pages, 1); - assert!( - r.warnings.iter().any(|w| w == "Artboard 1 with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet"), - "{:?}", - r.warnings - ); + // A note on the options; the art that doesn't print is what the job loses. + assert_eq!(r.setting_notes, ["Artboard 1 with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet"]); assert!(r.warnings.iter().any(|w| w.contains("larger than the imageable area")), "{:?}", r.warnings); assert_eq!(r.bytes, job(&d, json!({})).bytes, "printed as without copies per sheet"); assert!(preview(&d, &settings(json!({"copiesPerSheet": true}))).unwrap().sheets[0].copies.is_empty()); diff --git a/crates/pdf/src/tests/printadvanced.rs b/crates/pdf/src/tests/printadvanced.rs index f9dacb7597..8ff14f2fd1 100644 --- a/crates/pdf/src/tests/printadvanced.rs +++ b/crates/pdf/src/tests/printadvanced.rs @@ -136,5 +136,5 @@ fn separations_separate_with_a_cmyk_printer_profile() { assert_ne!(device, press, "the profile separates"); // An RGB printer profile doesn't separate. let r = job(&d, json!({"output": {"mode": "separations"}, "color": {"profile": cms::SRGB}})); - assert!(r.warnings.iter().any(|w| w.contains("RGB printer profile")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("RGB printer profile")), "{:?}", r.setting_notes); } diff --git a/crates/pdf/src/tests/realtext.rs b/crates/pdf/src/tests/realtext.rs index 3db63a4b2a..a36cea3ee7 100644 --- a/crates/pdf/src/tests/realtext.rs +++ b/crates/pdf/src/tests/realtext.rs @@ -976,7 +976,10 @@ fn dithered_type_keeps_its_resolution_when_scaled_and_stays_searchable() { /// Exact craft-fonts face: a substituted Arabic font would miss Noto's dot decomposition. fn mark_text(text: &str) -> Option { - let face = FontDb::global().face("Noto Sans Arabic", "Regular").filter(|f| f.family == "Noto Sans Arabic"); + // The craft-fonts build of the face: a system font of the same family (Linux distributions + // install one) can lack the Latin and digit glyphs these fixtures shape with it. + let bundled = vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Arabic" && f.style == "Regular"); + let face = FontDb::global().face("Noto Sans Arabic", "Regular").filter(|f| bundled && f.family == "Noto Sans Arabic"); let Some(face) = face else { assert!(std::env::var("CRAFT_FONTS_REQUIRED").as_deref() != Ok("1"), "Noto Sans Arabic is required"); eprintln!("SKIPPED: PDF mark-text regression needs craft-fonts Noto Sans Arabic"); diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 15d5b4644a..a6736b022d 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -10,7 +10,8 @@ //! Registration swatch print their tint on every plate, spot plates included. //! Placed raster images show their pixels' process inks the same way ([`plate_image`]): a CMYK //! image its own inks, an RGB one its pixels separated through the color settings. They carry -//! no spot ink; a colorized image takes its color's plates, as a fill of it does. +//! no spot ink; a colorized image takes its color's plates, as a fill of it does. An image +//! placed from a DCS file shows its own plates, spot plates included ([`plates_shown`]). //! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that //! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with //! Darken, which approximates their inks printing over the inks below: a zero ink lets the @@ -82,6 +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 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, @@ -107,20 +112,29 @@ static PLATE_LUTS: Mutex>> = Mutex::new(Vec::new()); /// The last [`PlateLut::generation`] handed out. static PLATE_GENERATION: AtomicU64 = AtomicU64::new(0); -/// The tables placed images show Separations Preview's visible plates through with `opts`, or -/// `None` when it is off. -pub(crate) fn plate_lut(opts: &RenderOptions) -> Option> { +/// The tables the placed images of `doc` show Separations Preview's visible plates through with +/// `opts`, or `None` when it is off. +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 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) { + 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(), 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) } @@ -134,6 +148,11 @@ pub(crate) fn plate_image(lut: &PlateLut, key: &ImageKey, pm: &Pixmap, blob: Opt if let Some(p) = lookup(&want, touch) { return (want, p); } + // An image placed from a DCS file shows its own plates, spot plates included. + if let Some(s) = blob.filter(|b| crate::ink::shows_own_inks(key) && b.is_separated()).and_then(ImageBlob::separated) { + let out = images::store().insert(want.clone(), Arc::new(plates_shown(lut, &s, pm))); + return (want, out); + } let size = (u32::from(pm.width()), u32::from(pm.height())); let raw = crate::ink::key_inks(key, blob).filter(|i| (i.width, i.height) == size); let mut out = pm.clone(); @@ -165,6 +184,117 @@ 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 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 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(), 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| { + 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 mut out = pm.clone(); + 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 => { + let mut clear = [1.0f32; 4]; + for (data, inks) in &process { + let a = ink(data); + for (c, e) in clear.iter_mut().zip(inks) { + *c *= 1.0 - a * e; + } + } + let [x, y, z, _] = t.apply(clear.map(|c| 1.0 - c)); + let mut rgb = lab::xyz_to_srgb([x, y, z]); + for (data, c) in &spots { + let a = ink(data); + for (v, c) in rgb.iter_mut().zip(c) { + *v *= 1.0 - a * (1.0 - c); + } + } + rgb + } + }; + let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + *o = PremulRgba8 { r, g, b, a: 255 }; + } + 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/renderer.rs b/crates/render/src/renderer.rs index fc729c1cc3..500d62d5b4 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -150,7 +150,7 @@ impl Renderer { let screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display }; let symbols = doc.symbol_boxes().quick(); let output = Affine::scale(1.0 / f64::from(cell.max(1))) * view; - let plates = proof::plate_lut(opts); + let plates = proof::plate_lut(opts, doc); let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen, output, plates: plates.as_deref() }; self.begin_frame(); // View Opacity Mask: the mask's coverage in greyscale instead of the artwork. diff --git a/crates/render/src/tests/colorize.rs b/crates/render/src/tests/colorize.rs index 49e14e5e68..721707502d 100644 --- a/crates/render/src/tests/colorize.rs +++ b/crates/render/src/tests/colorize.rs @@ -182,6 +182,113 @@ fn separations_preview_shows_the_image_on_its_spot_plate() { assert!(off[..3].iter().all(|v| *v >= 250), "the process plates show paper, got {off:?}"); } +/// Separations Preview shows an image placed from a DCS file through its own plates: its spot +/// plate on that ink's plate only, its cyan plate on the cyan plate only. Without the preview it +/// shows its composite, the spot ink mixed in. +#[test] +fn separations_preview_shows_a_dcs_image_through_its_own_plates() { + use vectorcraft_doc::separated::{InkPlate, Separated}; + // Two pixels: the spot ink solid on the left, cyan solid on the right. + let plates = vec![ + InkPlate { name: "Cyan".into(), cmyk: [0.0; 4], data: vec![0, 255] }, + InkPlate { name: "Ink".into(), cmyk: [0.0, 1.0, 1.0, 0.0], data: vec![255, 0] }, + ]; + let s = Separated::new(2, 1, plates).unwrap(); + let mut d = Document::new_with_mode(40.0, 20.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: 2, height: 1, xf: Affine::scale(20.0), 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 shown = |plates: &[&str]| { + let out = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &separations(plates)); + (out.pixel(10, 10), out.pixel(30, 10)) + }; + let (left, right) = shown(&["Ink"]); + assert!(left[..3].iter().all(|v| *v < 20), "the Ink plate shows the spot plate as solid ink, got {left:?}"); + assert!(right[..3].iter().all(|v| *v >= 250), "and nothing of cyan, got {right:?}"); + let (left, right) = shown(&["Cyan"]); + assert!(left[..3].iter().all(|v| *v >= 250), "the Cyan plate shows nothing of the spot ink, got {left:?}"); + assert!(right[..3].iter().all(|v| *v < 20), "and the cyan plate as solid ink, got {right:?}"); + let (left, _) = shown(&["Cyan", "Magenta", "Yellow", "Black"]); + assert!(left[..3].iter().all(|v| *v >= 250), "the process plates show paper where only the spot ink prints, got {left:?}"); + let (left, _) = shown(&["Cyan", "Magenta", "Yellow", "Black", "Ink"]); + assert!(left[0] > 150 && left[1] < 100 && left[2] < 100, "with every plate the spot ink shows red, got {left:?}"); + let composite = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images: false, ..Default::default() }).pixel(10, 10); + assert!(composite[0] > 150 && composite[1] < 100, "the composite mixes the spot ink in, got {composite:?}"); + // Without a spot swatch of the ink, the job prints no plate for it: it shows on the process + // plates as its process equivalent, as it prints. + d.swatches.retain(|s| s.name != "Ink"); + let process = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &separations(&["Cyan", "Magenta", "Yellow", "Black"])).pixel(10, 10); + assert!(process[0] > 150 && process[1] < 100 && process[2] < 100, "the ink's magenta and yellow show, got {process:?}"); + let magenta = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &separations(&["Magenta"])).pixel(10, 10); + 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/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index ee7440dde6..6a287b9b54 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -368,7 +368,7 @@ impl SmartPenTool { } Target::Close { id, si, first, mut anchor } => { let out = Self::handle_to(cx, first, ev.pos, first, shift, false); - let act = close_drag(id, Some(si), first - (out - first), alt, anchor.as_mut()); + let act = close_drag(id, Some(si), None, first - (out - first), alt, anchor.as_mut()); pr.what = Target::Close { id, si, first, anchor }; ("", act) } diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 31aabfb38f..0b625f2813 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -7,7 +7,8 @@ //! incoming handle mirrors the pointer (a click leaves it retracted and the anchor a corner, so the //! closing segment comes in straight). Its outgoing handle stays where it is on a corner, so the first segment keeps its //! shape, and on a smooth anchor turns to stay in line at its own length; Alt makes the anchor a -//! corner, leaving the outgoing handle where it was when Alt went down. Clicking the +//! corner, leaving the outgoing handle where it was when Alt went down; Space held moves the +//! anchor, handles and all. Clicking the //! last one retracts its outgoing handle, so the next segment leaves it as a corner; dragging from //! it pulls a new one out on its own. //! Alt held over a handle end, an anchor or a segment of a selected path (the one being drawn too) @@ -76,13 +77,15 @@ const DRAG_THRESHOLD: f64 = 3.0; /// An anchor being placed, as the press decided: where it is, the path it goes on (none: the /// first anchor of a new path) and whether it closes that path (the press was on its first /// anchor). The drag only shapes its handles, whatever the path looks like meanwhile: a closing -/// preview leaves the path closed, so no longer one the pen draws on. +/// preview leaves the path closed, so no longer one the pen draws on. `moved`: Space held has +/// moved the anchor from where the press found it. #[derive(Clone, Copy)] struct Place { at: Point, path: Option, subpath: Option, closing: bool, + moved: bool, } /// The open path the pen is extending: the single selected open path, or the single open path of a @@ -329,7 +332,7 @@ impl Tool for PenTool { params["subpaths"] = json!([si, other_si]); return vec![Action::Exec("path.join".into(), params)]; } - self.drag = Some(Place { at: p, path: Some(id), subpath: Some(si), closing: false }); + self.drag = Some(Place { at: p, path: Some(id), subpath: Some(si), closing: false, moved: false }); self.in_h = p; let params = json!({"id": id.0, "x": p.x, "y": p.y, "subpath": si}); return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), params)]; @@ -366,7 +369,7 @@ impl Tool for PenTool { return vec![act]; } (self.drawing, self.path, self.subpath) = (true, None, None); - self.drag = Some(Place { at: p, path: None, subpath: None, closing: false }); + self.drag = Some(Place { at: p, path: None, subpath: None, closing: false, moved: false }); vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))] } PointerKind::Drag => { @@ -390,13 +393,19 @@ impl Tool for PenTool { } return vec![set_out_handle(id, si, ai, h)]; } - let Some(Place { at: mut a, path, subpath, closing }) = self.drag else { return vec![] }; - // Space held moves the anchor being placed, handles and all. - if ev.mods.space && !closing { + let Some(Place { at: mut a, path, subpath, closing, mut moved }) = self.drag else { return vec![] }; + // Space held moves the anchor being placed, handles and all; while closing, the + // first anchor, with the outgoing handle a closing drag keeps. + if ev.mods.space { let d = ev.pos - self.last; a += d; self.in_h += d; - self.drag = Some(Place { at: a, path, subpath, closing }); + if closing && let Some((anchor, out)) = &mut self.first { + anchor.translate(d); + *out += d; + } + moved |= d != Vec2::ZERO; + self.drag = Some(Place { at: a, path, subpath, closing, moved }); } self.last = ev.pos; let mut out_h = ev.pos; @@ -413,7 +422,7 @@ impl Tool for PenTool { )]; }; if closing { - return vec![close_drag(id, subpath, a - (out_h - a), alt, self.first.as_mut())]; + return vec![close_drag(id, subpath, moved.then_some(a), a - (out_h - a), alt, self.first.as_mut())]; } // Alt pressed mid-drag keeps the curve already shaped into the anchor: only the // outgoing handle follows. Held from the start, the incoming handle stays retracted. @@ -583,7 +592,7 @@ impl PenTool { /// anchor a corner with its incoming handle retracted until a drag pulls it out, as a click on /// the last anchor retracts its outgoing one ([`set_out_handle`]). fn begin_close(&mut self, cx: &ToolContext, id: NodeId, si: usize, at: Point) -> Vec { - self.drag = Some(Place { at, path: Some(id), subpath: Some(si), closing: true }); + self.drag = Some(Place { at, path: Some(id), subpath: Some(si), closing: true, moved: false }); self.first = first_anchor(cx, id, si).map(|a| (a, a.h_out)); let params = json!({"id": id.0, "subpath": si, "in": [at.x, at.y], "independent": true}); vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), params)] @@ -609,12 +618,23 @@ pub(crate) fn first_anchor(cx: &ToolContext, id: NodeId, si: usize) -> Option, in_h: Point, alt: bool, first: Option<&mut (Anchor, Point)>) -> Action { +/// The preview of a closing drag that puts the first anchor's incoming handle at `in_h`, the anchor +/// first moved to `at` when given (Space held). `first` holds that anchor as it was when the press +/// began closing the path (moved with it), and its outgoing handle as the drag last left it: a +/// smooth anchor turns that handle to stay in line, and Alt (`alt`), which makes the anchor a +/// corner, keeps it there. +pub(crate) fn close_drag( + id: NodeId, + subpath: Option, + at: Option, + in_h: Point, + alt: bool, + first: Option<&mut (Anchor, Point)>, +) -> Action { let mut params = json!({"id": id.0, "in": [in_h.x, in_h.y], "independent": alt}); + if let Some(at) = at { + params["at"] = json!([at.x, at.y]); + } if let Some((anchor, out)) = first { if alt { params["out"] = json!([out.x, out.y]); diff --git a/crates/tools/src/pen/tests.rs b/crates/tools/src/pen/tests.rs index 984f34646a..478589fe9e 100644 --- a/crates/tools/src/pen/tests.rs +++ b/crates/tools/src/pen/tests.rs @@ -628,6 +628,43 @@ fn alt_mid_close_keeps_the_turned_outgoing_handle() { assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 130.0, 200.0)), vec![Action::Commit]); } +/// Space held during a closing drag moves the first anchor, handles and all, by as far as the +/// pointer moves; released, the drag shapes the handles round the anchor's new place. +#[test] +fn space_moves_the_closing_anchor() { + use vectorcraft_geom::{Anchor, SubPath}; + let anchors = vec![Anchor::smooth(Point::new(100.0, 300.0), Point::new(150.0, 300.0)), Anchor::corner(Point::new(200.0, 200.0))]; + let (mut d, id, s) = drawing(SubPath { anchors, closed: false }); + let p = paint(); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), close_press(id, Point::new(100.0, 300.0))); + d.node_mut(id).unwrap().path_data_mut().unwrap().subpaths[0].closed = true; + let c = cx(&d, &s, &p); + let close = |v: serde_json::Value| vec![Action::Preview("path.close".into(), v)]; + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 100.0, 200.0)), + close(json!({"id": id.0, "in": [100.0, 400.0], "independent": false, "subpath": 0})) + ); + let space = Mods { space: true, ..Mods::default() }; + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 180.0).with_mods(space)), + close(json!({"id": id.0, "at": [130.0, 280.0], "in": [130.0, 380.0], "independent": false, "subpath": 0})), + "moved, handles and all" + ); + // Space released: the drag shapes the handles round the anchor's new place. + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 230.0)), + close(json!({"id": id.0, "at": [130.0, 280.0], "in": [130.0, 330.0], "independent": false, "subpath": 0})) + ); + // Alt keeps the outgoing handle where the drag had turned it, moved with the anchor. + let alt = Mods { alt: true, ..Mods::default() }; + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 230.0).with_mods(alt)), + close(json!({"id": id.0, "at": [130.0, 280.0], "in": [100.0, 330.0], "out": [130.0, 230.0], "independent": true, "subpath": 0})) + ); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 160.0, 230.0)), vec![Action::Commit]); +} + #[test] fn a_one_anchor_path_does_not_close_on_itself() { let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0)], false)); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ca25bdf078..8edde76eb3 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2204,6 +2204,8 @@ font-technology Variable Променливи Normal Screen Mode Нормален режим на екран Note: Забележка: Nothing matches the Show filter. Нищо не отговаря на филтъра за показване. + Show DCS Transparency Interactions Показване на взаимодействията на DCS с прозрачност + Transparent, or overlaps transparent objects: check its separations Прозрачно е или се припокрива с прозрачни обекти: проверете цветоотделянето му Nothing to print Няма нищо за отпечатване Nudge Преместване Nudge in Perspective Преместване в перспектива diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 69fcb871b9..c9c207eaa2 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1929,6 +1929,8 @@ digits Malayalam Malajalam Modified since it was read: {path} Izmijenjeno nakon čitanja: {path} No Thumbnails Bez sličica Nothing matches the Show filter. Ništa ne odgovara filteru Prikaži. + Show DCS Transparency Interactions Prikaži interakcije DCS-a s prozirnošću + Transparent, or overlaps transparent objects: check its separations Prozirno je ili preklapa prozirne objekte: provjerite njegove separacije PPI PPI Relink… Ponovo poveži… Find Linked Files Pronađi povezane datoteke diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 13b24da639..1045bdbd07 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2812,6 +2812,8 @@ digits Malayalam Malaiàlam Note: Nota: Nothing is selected. Select the art to export, then open this tab again. No hi ha res seleccionat. Selecciona la il·lustració que vols exportar i torna a obrir aquesta pestanya. Nothing matches the Show filter. Res no coincideix amb el filtre Mostra. + Show DCS Transparency Interactions Mostra les interaccions de transparència DCS + Transparent, or overlaps transparent objects: check its separations És transparent o se superposa a objectes transparents: comproveu-ne les separacions Nothing to print No hi ha res per imprimir NTSC Colors Colors NTSC Nudge Desplaça diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 472c387521..d694d81ef9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1960,6 +1960,8 @@ digits Malayalam Malajálamské Modified since it was read: {path} Upraveno od načtení: {path} No Thumbnails Bez miniatur Nothing matches the Show filter. Filtru Zobrazit nic neodpovídá. + Show DCS Transparency Interactions Zobrazit interakce DCS s průhledností + Transparent, or overlaps transparent objects: check its separations Je průhledný nebo překrývá průhledné objekty: zkontrolujte jeho separace PPI PPI Relink… Znovu propojit… Find Linked Files Najít propojené soubory diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 38300ca372..47a78ef612 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1775,6 +1775,8 @@ digits Malayalam Malayalam Modified since it was read: {path} Ændret siden den blev læst: {path} No Thumbnails Ingen miniaturer Nothing matches the Show filter. Intet matcher Vis-filteret. + Show DCS Transparency Interactions Vis DCS-gennemsigtighedsinteraktioner + Transparent, or overlaps transparent objects: check its separations Gennemsigtig eller overlapper gennemsigtige objekter: kontrollér dens separationer PPI PPI Relink… Sammenkæd igen… Find Linked Files Find sammenkædede filer diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5080e87138..f38d3e78af 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2238,6 +2238,8 @@ file-filter Templates Vorlagen Normal Screen Mode Normaler Bildschirmmodus Note: Notiz: Nothing matches the Show filter. Nichts entspricht dem Filter „Anzeigen“. + Show DCS Transparency Interactions DCS-Transparenzinteraktionen anzeigen + Transparent, or overlaps transparent objects: check its separations Transparent oder überlappt transparente Objekte: Separationen prüfen Nothing to print Nichts zu drucken Nudge Schrittweise verschieben Nudge in Perspective In Perspektive schrittweise verschieben diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5550f9e15f..05ebdefe68 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3136,6 +3136,8 @@ theme Appearance Εμφάνιση Normal Screen Mode Κανονική λειτουργία οθόνης Note: Σημείωση: Nothing matches the Show filter. Κανένα στοιχείο δεν αντιστοιχεί στο φίλτρο Εμφάνιση. + Show DCS Transparency Interactions Εμφάνιση αλληλεπιδράσεων διαφάνειας DCS + Transparent, or overlaps transparent objects: check its separations Διαφανές ή επικαλύπτει διαφανή αντικείμενα: ελέγξτε τους διαχωρισμούς του Nothing to print Δεν υπάρχει τίποτα για εκτύπωση Nudge Μετακίνηση κατά βήμα Nudge in Perspective Μετακίνηση κατά βήμα σε προοπτική diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d750ab8d0d..f23215552f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2276,6 +2276,8 @@ font-technology Variable Variables Normal Screen Mode Modo de pantalla normal Note: Nota: Nothing matches the Show filter. Nada coincide con el filtro Mostrar. + Show DCS Transparency Interactions Mostrar interacciones de transparencia DCS + Transparent, or overlaps transparent objects: check its separations Transparente o superpuesto a objetos transparentes: compruebe sus separaciones Nothing to print No hay nada para imprimir Nudge Desplazar Nudge in Perspective Desplazar en perspectiva diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b366a462b1..a2e2b75b78 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1921,6 +1921,8 @@ digits Malayalam Malajalam Modified since it was read: {path} Muokattu lukemisen jälkeen: {path} No Thumbnails Ei pikkukuvia Nothing matches the Show filter. Mikään ei vastaa Näytä-suodinta. + Show DCS Transparency Interactions Näytä DCS-läpinäkyvyyden vuorovaikutukset + Transparent, or overlaps transparent objects: check its separations Läpinäkyvä tai läpinäkyvien objektien päällä: tarkista sen erottelut PPI PPI Relink… Linkitä uudelleen… Find Linked Files Etsi linkitetyt tiedostot diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9b2bb5569a..daed4fe47f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2268,6 +2268,8 @@ font-technology Variable Variables Normal Screen Mode Mode Écran normal Note: Remarque : Nothing matches the Show filter. Aucun élément ne correspond au filtre Afficher. + Show DCS Transparency Interactions Afficher les interactions de transparence DCS + Transparent, or overlaps transparent objects: check its separations Transparent ou superposé à des objets transparents : vérifiez ses séparations Nothing to print Rien à imprimer Nudge Décaler Nudge in Perspective Décaler en perspective diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index f06be2e82a..d73b000257 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3065,6 +3065,8 @@ antiAlias Sharp शार्प Note: ध्यान दें: Nothing is selected. Select the art to export, then open this tab again. कुछ भी नहीं चुना गया है। निर्यात करने के लिए कला सिलेक्ट करें, फिर इस टैब को फिर से ओपन करें। Nothing matches the Show filter. कुछ भी नहीं शो फिल्टर से मेल खाता है। + Show DCS Transparency Interactions DCS पारदर्शिता इंटरैक्शन दिखाएँ + Transparent, or overlaps transparent objects: check its separations पारदर्शी है या पारदर्शी ऑब्जेक्ट पर चढ़ता है: इसके सेपरेशन जाँचें Nothing to print प्रिंट करने के लिए कुछ भी नहीं NTSC Colors NTSC रंग Nudge न्याय diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c26bf6420b..fb7fd765c5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2753,6 +2753,8 @@ file-filter Templates Sablonok Not found Nem található Note: Megjegyzés: Nothing matches the Show filter. A Megjelenítés szűrőnek semmi sem felel meg. + Show DCS Transparency Interactions DCS-átlátszósági kölcsönhatások megjelenítése + Transparent, or overlaps transparent objects: check its separations Átlátszó, vagy átlátszó objektumokat fed át: ellenőrizze a színkivonatait Nothing to print Nincs mit nyomtatni NTSC Colors NTSC-színek Nudge Léptetés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 16303f38f5..45e05b9622 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1775,6 +1775,8 @@ digits Malayalam Malayalam Modified since it was read: {path} Diubah sejak terakhir dibaca: {path} No Thumbnails Tanpa thumbnail Nothing matches the Show filter. Tidak ada yang cocok dengan filter Tampilkan. + Show DCS Transparency Interactions Tampilkan Interaksi Transparansi DCS + Transparent, or overlaps transparent objects: check its separations Transparan, atau menimpa objek transparan: periksa separasinya PPI PPI Relink… Tautkan ulang… Find Linked Files Temukan file tertaut diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0c97004be1..f8ed1005f4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2314,6 +2314,8 @@ file-filter Templates Modelli Normal Screen Mode Modalità schermo normale Note: Nota: Nothing matches the Show filter. Nessun elemento corrisponde al filtro Mostra. + Show DCS Transparency Interactions Mostra interazioni di trasparenza DCS + Transparent, or overlaps transparent objects: check its separations Trasparente o sovrapposto a oggetti trasparenti: controlla le sue separazioni Nothing to print Niente da stampare Nudge Sposta leggermente Nudge in Perspective Sposta leggermente in prospettiva diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 98a8a7c8ac..07287bef08 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2493,6 +2493,8 @@ antiAlias Sharp シャープ Normal 通常 Note: メモ: Nothing matches the Show filter. 表示フィルターに一致する項目がありません。 + Show DCS Transparency Interactions DCS の透明の相互作用を表示 + Transparent, or overlaps transparent objects: check its separations 透明、または透明なオブジェクトと重なっています:分版を確認してください Nothing to print プリントするものがありません Nudge 微調整 Nudge in Perspective 遠近で微調整 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 7cdb2dfb86..070f5a0db2 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1713,6 +1713,8 @@ digits Malayalam მალაიალამური Modified since it was read: {path} შეიცვალა წაკითხვის შემდეგ: {path} No Thumbnails ესკიზების გარეშე Nothing matches the Show filter. „ჩვენების“ ფილტრს არაფერი შეესაბამება. + Show DCS Transparency Interactions DCS-ის გამჭვირვალობის ურთიერთქმედებების ჩვენება + Transparent, or overlaps transparent objects: check its separations გამჭვირვალეა ან გადაფარავს გამჭვირვალე ობიექტებს: შეამოწმეთ მისი სეპარაციები PPI PPI Relink… ხელახლა დაკავშირება… Select an image to see its Link Info. ბმულის ინფორმაციის სანახავად მონიშნეთ გამოსახულება. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c886f8c035..2ed76227eb 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2483,6 +2483,8 @@ antiAlias Sharp 날카롭게 Normal 표준 Note: 참고: Nothing matches the Show filter. 표시 필터와 일치하는 항목이 없습니다. + Show DCS Transparency Interactions DCS 투명도 상호 작용 표시 + Transparent, or overlaps transparent objects: check its separations 투명하거나 투명한 개체와 겹칩니다: 분판을 확인하세요 Nothing to print 인쇄할 항목 없음 Nudge 미세 이동 Nudge in Perspective 원근 미세 이동 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a19e190ed6..327aee902c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1789,6 +1789,8 @@ digits Malayalam Malayalam Modified since it was read: {path} Gewijzigd sinds het is ingelezen: {path} No Thumbnails Geen miniaturen Nothing matches the Show filter. Niets komt overeen met het weergavefilter. + Show DCS Transparency Interactions DCS-transparantie-interacties weergeven + Transparent, or overlaps transparent objects: check its separations Transparant of overlapt transparante objecten: controleer de scheidingen PPI PPI Relink… Opnieuw koppelen… Find Linked Files Gekoppelde bestanden zoeken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 793eb8e15b..502056634e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1878,6 +1878,8 @@ digits Malayalam Malajalam Modified since it was read: {path} Zmodyfikowano od wczytania: {path} No Thumbnails Bez miniatur Nothing matches the Show filter. Nic nie pasuje do filtra Pokaż. + Show DCS Transparency Interactions Pokaż interakcje przezroczystości DCS + Transparent, or overlaps transparent objects: check its separations Przezroczysty lub nakłada się na przezroczyste obiekty: sprawdź jego separacje PPI PPI Relink… Połącz ponownie… Find Linked Files Znajdź połączone pliki diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2fd53cf23e..32782de4f5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1877,6 +1877,8 @@ antiAlias Sharp Nítido Normal Screen Mode Modo de Tela Normal Note: Nota: Nothing matches the Show filter. Nada corresponde ao filtro Mostrar. + Show DCS Transparency Interactions Mostrar interações de transparência DCS + Transparent, or overlaps transparent objects: check its separations Transparente ou sobrepõe objetos transparentes: verifique as separações Nothing to print Nada para imprimir Nudge Nudge Nudge in Perspective Nudge em Perspectiva diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6ca7f542eb..8faaad56cd 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2301,6 +2301,8 @@ file-filter Templates Шаблоны Normal Screen Mode Обычный режим экрана Note: Примечание: Nothing matches the Show filter. Ничего не соответствует фильтру «Показать». + Show DCS Transparency Interactions Показывать взаимодействия DCS с прозрачностью + Transparent, or overlaps transparent objects: check its separations Прозрачно или перекрывает прозрачные объекты: проверьте цветоделение Nothing to print Нечего печатать Nudge Сдвинуть Nudge in Perspective Сдвинуть в перспективе diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e5d23af1e8..6b08bb2fe0 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2215,6 +2215,8 @@ font-technology Variable Variabla Normal Screen Mode Normalt skärmläge Note: Obs: Nothing matches the Show filter. Inget matchar filtret Visa. + Show DCS Transparency Interactions Visa DCS-genomskinlighetsinteraktioner + Transparent, or overlaps transparent objects: check its separations Genomskinlig eller överlappar genomskinliga objekt: kontrollera dess separationer Nothing to print Inget att skriva ut Nudge Flytta stegvis Nudge in Perspective Flytta stegvis i perspektiv diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 3b94838c6b..bfc3b8dc06 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2085,6 +2085,8 @@ font-technology Variable Değişken Normal Screen Mode Normal Ekran Modu Note: Not: Nothing matches the Show filter. Hiçbir şey Göster filtresiyle eşleşmiyor. + Show DCS Transparency Interactions DCS Saydamlık Etkileşimlerini Göster + Transparent, or overlaps transparent objects: check its separations Saydam veya saydam nesnelerle çakışıyor: ayrımlarını denetleyin Nothing to print Yazdırılacak bir şey yok Nudge Dürt Nudge in Perspective Perspektifte İtme diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 43996c53e1..e89cfb962a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2306,6 +2306,8 @@ file-filter Templates Шаблони Normal Screen Mode Звичайний режим екрану Note: Примітка: Nothing matches the Show filter. Нічого не відповідає фільтру "Показати". + Show DCS Transparency Interactions Показувати взаємодії DCS із прозорістю + Transparent, or overlaps transparent objects: check its separations Прозоре або перекриває прозорі об'єкти: перевірте кольороподіл Nothing to print Нема чого друкувати Nudge Зрушити Nudge in Perspective Зрушити у перспективі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 16147c95d8..24e2bd5621 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2567,6 +2567,8 @@ digits Hindi Hindi Not found Không tìm thấy Note: Ghi chú: Nothing matches the Show filter. Không có gì khớp với bộ lọc Hiện. + Show DCS Transparency Interactions Hiện tương tác trong suốt DCS + Transparent, or overlaps transparent objects: check its separations Trong suốt hoặc chồng lên đối tượng trong suốt: hãy kiểm tra các bản tách màu Nothing to print Không có gì để in NTSC Colors Màu NTSC Nudge Đẩy nhẹ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2246fc7b7f..09ab0270e4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2173,6 +2173,8 @@ file-filter Templates 模板 Normal Screen Mode 正常屏幕模式 Note: 注释: Nothing matches the Show filter. 没有项目符合“显示”筛选条件。 + Show DCS Transparency Interactions 显示 DCS 透明度交互 + Transparent, or overlaps transparent objects: check its separations 透明或与透明对象重叠:请检查其分色 Nothing to print 没有可打印的内容 Nudge 微移 Nudge in Perspective 在透视中微移 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e1315f27e9..c44e4c4c87 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3362,6 +3362,8 @@ file-filter Templates 範本 Normal Screen Mode 正常螢幕模式 Note: 註解: Nothing matches the Show filter. 沒有項目符合「顯示」篩選條件。 + Show DCS Transparency Interactions 顯示 DCS 透明度互動 + Transparent, or overlaps transparent objects: check its separations 透明或與透明物件重疊:請檢查其分色 Nothing to print 沒有可列印的內容 Nudge 微移 Nudge in Perspective 在透視中微移 diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 38f9848760..aced758482 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -5,8 +5,9 @@ //! Go To, Update and Edit Original act on the selected rows (Relink through its dialog, with the //! Replace choice: `ui.relinkDialog`); Link Info shows the details of the first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files: //! `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Select Linked / Embedded / Missing / -//! Modified (`addon.select.byLinkStatus`, Shift adds), Show filters, Sort, thumbnail size -//! and Show in Folder. +//! Modified (`addon.select.byLinkStatus`, Shift adds), Show filters, Sort, thumbnail size, +//! Show in Folder and Show DCS Transparency Interactions (a warning on the rows of images placed +//! from DCS files that are transparent or overlap transparent objects). //! //! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI //! commands: they hand the file to the system). @@ -36,19 +37,22 @@ struct Options { thumb: f32, /// The Link Info section is open. info: bool, + /// Show DCS Transparency Interactions: rows of DCS images that are transparent or overlap + /// transparent objects get a warning. + dcs: bool, } impl Default for Options { fn default() -> Self { - Self { show: "all", sort: None, thumb: 28.0, info: true } + Self { show: "all", sort: None, thumb: 28.0, info: true, dcs: false } } } /// `links.list` and the first selected image's `links.info`, with what they were fetched for. #[derive(Clone, Default)] struct Cache { - /// (document uid, revision, show, sort). - key: Option<(u64, u64, &'static str, Option<&'static str>)>, + /// (document uid, revision, show, sort, Show DCS Transparency Interactions). + key: Option<(u64, u64, &'static str, Option<&'static str>, bool)>, at: f64, list: Value, info: Option<(u64, Value)>, @@ -68,13 +72,13 @@ fn set_options(ctx: &egui::Context, f: impl FnOnce(&mut Options)) { /// seconds) and the Link Info of the first selected image when the section is open. fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option { let st = app.session.active()?; - let key = Some((st.uid, st.revision, o.show, o.sort)); + let key = Some((st.uid, st.revision, o.show, o.sort, o.dcs)); let first = selected_images(app).first().copied(); let now = ctx.input(|i| i.time); let mut c: Cache = pstate(ctx, "links-cache"); let stale = c.key != key || now - c.at > RECHECK; if stale { - c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort })).ok()?; + c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort, "dcsTransparency": o.dcs })).ok()?; (c.key, c.at) = (key, now); } let want = first.filter(|_| o.info); @@ -279,6 +283,11 @@ fn status_tip(r: &Value) -> String { "embedded" => tl!("Embedded").into(), _ => path.to_string(), }; + let tip = if r["dcsTransparency"] == true { + format!("{tip}\n{}", tl!("Transparent, or overlaps transparent objects: check its separations")) + } else { + tip + }; // An image shown at reduced size or too large to draw (`links.list`'s note). match r["note"].as_str() { Some(note) => format!("{tip}\n{}", crate::i18n::msg(note)), @@ -367,6 +376,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { r["status"].as_str().unwrap_or_default(), ); let mut text_right = right - 4.0; + if r["dcsTransparency"] == true { + badge(ui, egui::Rect::from_center_size(egui::pos2(text_right - 8.0, rect.center().y), vec2(16.0, 16.0)), "modified"); + text_right -= 20.0; + } if let Some(page) = r["page"].as_u64() { let g = ui.painter().layout_no_wrap(format!("{page}"), egui::FontId::proportional(11.5), t.text_dim); text_right -= g.size().x; @@ -628,4 +641,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Show Link Info"), true, o.info) { set_options(ui.ctx(), |o| o.info = !o.info); } + if menu_item(ui, tl!("Show DCS Transparency Interactions"), true, o.dcs) { + set_options(ui.ctx(), |o| o.dcs = !o.dcs); + } } diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index 9cebea0ba6..08f0819b3a 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -612,6 +612,42 @@ fn a_geometry_effect_after_the_3d_effect_hides_the_graphics_handles() { handles_frame(&mut app, &ctx, vec![primary(handle, false)]); } +#[test] +fn a_hidden_fill_shows_no_handles_for_its_graphics() { + let (mut app, ctx) = materials_app(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + // The 3D effect, with its graphic, moves onto the square's fill. + let fill = { + let st = app.session.active_mut().unwrap(); + let id = st.selection.objects[0]; + let n = std::sync::Arc::make_mut(&mut st.doc).node_mut(id).unwrap(); + let fx = n.appearance.effects.remove(0); + let fill = n.appearance.items.iter().position(|i| matches!(i, vectorcraft_doc::AppearanceItem::Fill(_))).unwrap(); + n.appearance.effects_mut(Some(fill)).unwrap().push(fx); + fill + }; + let shown = |app: &VectorcraftApp| { + let mut shown = false; + ctx.run_ui(egui::RawInput::default(), |ui| { + crate::panels::set_pstate(ui.ctx(), super::DRAWN, Some(super::root_pass(ui.ctx()))); + shown = super::graphic_handles(app, ui.ctx()).is_some_and(|(_, _, handles)| !handles.is_empty()); + }) + .textures_delta + .clear(); + shown + }; + assert!(shown(&app), "the visible fill's graphic has a handle"); + { + let st = app.session.active_mut().unwrap(); + let id = st.selection.objects[0]; + let n = std::sync::Arc::make_mut(&mut st.doc).node_mut(id).unwrap(); + if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = n.appearance.items.get_mut(fill) { + f.visible = false; + } + } + assert!(!shown(&app), "the hidden fill draws no graphic, so it has no handle"); +} + #[test] fn a_press_that_closes_the_context_menu_does_not_grab_a_graphics_handle() { let (mut app, ctx) = materials_app(); diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs index 805abbd51f..1d829353f1 100644 --- a/crates/ui-egui/src/panels/three_d.rs +++ b/crates/ui-egui/src/panels/three_d.rs @@ -324,6 +324,10 @@ fn graphic_handles(app: &VectorcraftApp, ctx: &egui::Context) -> Option let st = app.session.active()?; let n = st.selection.objects.first().and_then(|id| st.doc.node(*id))?; let t = find(n)?; + // A hidden fill or stroke draws no solid and no graphics, so its graphics get no handles. + if t.item.is_some_and(|k| n.appearance.items.get(k).is_none_or(|it| !it.visible())) { + return None; + } // The frame of the effect whose graphics are listed: none while that effect is hidden. let frame = vectorcraft_effects::graphic_frame_at(Some(&st.doc), n, t.item, t.index)?; // A geometry effect after the 3D effect of any selected object moves or bends its drawn diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e0964f7200..8853d53c89 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -271,6 +271,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | +| DCS 1.0 / 2.0 | place linked | place linked only (one image of the plates; spot plates as spot swatches; the composite's clipping path; each plate on its own ink in Separations Preview and print separations) | no | `crates/eps/src/dcs/tests.rs`, `crates/engine/src/tests/dcs.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`); SVG Options › Include Variable Data writes the text variables, data sets and bound type objects, and opening the SVG reads them back (`tests::variables::an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open | | HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` | diff --git a/docs/mcp.md b/docs/mcp.md index 926b457c61..2f121db56d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -786,7 +786,8 @@ art's colours (by default as global swatches the art links to). them in the Swatches panel does); give colour group names instead to reorder the groups. Dropping a swatch on art in the app runs `paint.setFill` (or `paint.setStroke`, the active proxy) with `ids: [the object under the pointer]`. The Swatches panel menu's commands: `swatch.addUsedColors {selection?, global?}`, `swatch.unused` (a query: the -names Select All Unused selects), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and +names Select All Unused selects; a spot swatch counts as used while an image placed from a DCS file has a plate of +its ink), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and `swatch.sortByKind`. `brush.move {name | names, to?, type?}` reorders brushes among the brushes of their type, as dragging one onto another of its type in the Brushes panel does. `to` is an index among the brushes of that type in `brush.list` @@ -4698,13 +4699,15 @@ an `anchor` or `segment` in the same way. The document does not change and no un after the new anchor on its subpath are numbered one higher, and a selected segment that the new anchor splits stays selected as its two halves, with the new anchor as an end of both that moves with them. Undo restores the path and the selection in one step. -`path.close {id, subpath?, in?, out?, independent?}` closes an open subpath (by default the last open one). `in` places +`path.close {id, subpath?, at?, in?, out?, independent?}` closes an open subpath (by default the last open one). `at` +moves the first anchor to `[x, y]`, its handles with it, before `in` and `out` are placed. `in` places the first anchor's incoming handle as `path.setHandle` places a handle: a smooth anchor turns its outgoing handle to stay in line and keeps that handle's length, and a corner keeps its outgoing handle where it is. With `independent: true` the anchor becomes a corner first, so its outgoing handle does not move. `out`, placed after `in`, is the first anchor's outgoing handle, and makes the anchor a corner. A Pen press on the first anchor sends `in` at the anchor with `independent: true`. The Pen's and the Smart Pen's closing drags send the mirror of the pointer as -`in`, and once Alt is held they also send `out` at the outgoing handle's position when Alt went down. +`in`, and once Alt is held they also send `out` at the outgoing handle's position when Alt went down. Once Space held +during the Pen's closing drag has moved the first anchor, the drag also sends `at` at the anchor's new position. `path.reshape {id, x, y, dx, dy, tol?, focal?, radius?}` (a Reshape tool drag) grabs the path at `x`, `y`, adding an anchor there unless one lies within `tol` (3 by default), and moves that point by `dx`, `dy`. @@ -4753,7 +4756,7 @@ an `x` without a `y`, or a `y` without an `x`, with "x and y must be given toget `path.blob` refuse a point in `points` whose x or y is not a number, such as `["10", 10]` or a point written as a string, with "x and y must both be numbers". All of them except `path.curvature` refuse a pen pressure, tilt or rotation that is neither a number nor null with `` `pressure` must be a number, not "0.5" ``, naming the key -(`pressure`, `tiltX`, `tiltY` or `rotation`). `path.close` refuses an `in` or `out` that is not `[x, y]` +(`pressure`, `tiltX`, `tiltY` or `rotation`). `path.close` refuses an `at`, `in` or `out` that is not `[x, y]` with `` `in` must be [x, y] ``, naming the key. These commands refuse a string, a number or another value that is not true or false with @@ -5943,6 +5946,40 @@ of its art, as one clipped group. A PDF, `.ai` or `.ait` page read from `path` ( `textAs: "outlines"`) is a placed document the same way, showing page `page` clipped to its `crop` box (or its art's bounds); with `link: false` it is placed as one clipped group. EPS and PostScript `.ai` files are embedded. `at` centres it, `rect` fits it, `replace` swaps the selected object (keeping its place and transform), `template` puts it on a new template layer. +A DCS file (Desktop Color Separation, version 1.0 or 2.0: an EPS file whose header names one EPS program per printing +plate, <http://www.telegraphics.net/doc/DCS.txt>) is placed only from its `path` with link, as one linked image +([Import DCS files](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/import-dcs-files.html)). +`file.place` with `link: false` or from `dataBase64`, `document.open` and `links.embed` fail with "DCS files can only +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 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 (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. The art's bounds are the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects, guides and template layers do not count, and symbol instances are measured by their art. A page or artboard holds the objects whose shown art lies on it; a hidden object on it is still placed and stays hidden, as do the hidden members of the groups placed. @@ -6662,6 +6699,14 @@ allowance has bytes, drawn as a crossed gray box on the canvas and left out of e counter-clockwise), `placement` and, for a linked file, `fileName`, `location`, `fileSize`, `created`, `modified`. With automation read roots, a file outside them has no `path` or `found` in `links.list` and `links.check`, and no `link`, `path`, `location`, `fileSize` or `modified` in `links.info`; its row keeps the file's name. +An image placed from a DCS file has the `format` `DCS`. `links.list {dcsTransparency: true}` (the panel menu's Show +DCS Transparency Interactions, off by default) adds `dcsTransparency: true` to the rows of such images that +[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 (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 (another extension converts it) and links to it, and without `path` returns `{name, dataBase64}`. @@ -6910,7 +6955,11 @@ visible type that fits its frame or path, on the layers `printLayers` picks, on that print, and the type in the symbols, patterns, scatter brushes, opacity masks and placed documents of the art that prints. The warnings also give the ids of the area type and type on a path that print and whose text doesn't fit (objects of the document only, not type in the art of symbols, patterns, brushes, opacity masks or placed documents; -see [Fitting area type](#fitting-area-type-auto-size-shrink-text-to-fit-overflow)). +see [Fitting area type](#fitting-area-type-auto-size-shrink-text-to-fit-overflow)). The warnings of `file.print` +and `print.preview` list what the job leaves out of the document or prints differently first (art outside the +imageable area, type in a fallback font, transparency flattened for PostScript). The notes on the options follow: an +option not applied or applied another way, such as halftone screens, a fixed flatness or copies per sheet that don't +fit. Each kind keeps the order it is given in. ```json {"name":"run_command","arguments":{"command":"print.setup","params":{"settings":{"media":"a4","scaling":"fit","marks":{"trim":true}}}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6ef5362460..512c395267 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -591,7 +591,7 @@ since 2026-10-01. - **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Notes of an open in the status bar:** After File › Open the status message names the first note that says reading the file left something out (the notes that make Save ask before writing over the file), else the first note, and counts them all. With several notes, resting the pointer on the message lists them in that order, as many as fit in the window and at most 30, then how many more. Screen readers receive the list after the message. Scores and estimates are unchanged. - **Notes of a save or an export in the status bar:** After Save, Save As, Save a Copy, Save as Template, Save a Timestamped Copy, Save as PDF, Export As, Export Selection, Export for Screens, Asset Export, Save for Web, Preview in Browser, Save for Office Documents, Text Export Options, a print to a file or the CSS Properties panel's export, the status message names the first note and counts them all. With several notes, resting the pointer on the message lists them in the order the reply gives them (a save's notes start with what the format loses), as it lists an open's notes. Scores and estimates are unchanged. -- **What a save or an export loses, named first:** Each writer marks its notes as a loss (something of the document the file leaves out or writes differently) or a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The replies of the save and export commands list the losses first, each kind in the order given, across every file an export writes. The status message after a save or an export then names a loss when there is one: an export to lossy WebP of type in a font that isn't installed names the fallback-font note instead of the lossy WebP note. A print to a file keeps the order its notes are given in. Scores and estimates are unchanged. +- **What a save or an export loses, named first:** Each writer marks its notes as a loss (something of the document the file leaves out or writes differently) or a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The replies of the save and export commands list the losses first, each kind in the order given, across every file an export writes. The status message after a save or an export then names a loss when there is one: an export to lossy WebP of type in a font that isn't installed names the fallback-font note instead of the lossy WebP note. A print to a file and the print preview list theirs the same way: art outside the imageable area, type in a fallback font and transparency flattened for PostScript come before notes such as a fixed flatness that isn't written or copies per sheet that don't fit. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. @@ -1025,7 +1025,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Transform Each opens on the scale, move, rotate, reflect, Random, reference point, Transform Objects and Preview it was last confirmed with (kept with the UI state; Copy never is), and its Copy button, left of Cancel, transforms a copy of each object as the [Scale multiple objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-multiple-objects.html) help page has it; the Copy checkbox is gone (#1732; the remembered values follow a maintainer comment on the issue, not the help page). Its Scale section has the Relative and Absolute radio buttons the same page describes: percentages of each object's size, or the width and height it becomes (`object.transformEach` `relative`, which Transform Again repeats); Absolute measures each object's own box, and the scales act along its axes about that box's reference point, so a turned shape keeps its shape and objects of different sizes come out alike; the fields show the width and height the selected objects share and read "Mixed" when they differ or a side is 0 (a line's), each object measured by its own box, rotated with it, as the Transform panel and the Control bar show it (sizes they show alike count as the same), and a size typed into them sets every object to it (a side left Mixed keeps its size), our choices where the page leaves them open; a live Repeat scales its spacing by the geometric mean of the two factors, so its Absolute size is approximate. The link beside the two scales keeps them in proportion, as the shape dialogs' Width and Height link does (`__constrain`, kept with the dialog's values; where it sits is our choice, the public pages don't establish it). The panel lists Scale, Move and Rotate, then the Options in two columns (Transform Objects, Transform Patterns, Scale Strokes & Effects, Scale Corners; Reflect X, Reflect Y, Random), the Reference Point, and Preview at the left of the button row, level with Copy, Cancel and OK. Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)). - - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve and the release closes the path wherever it ends (#501). The first anchor's incoming handle mirrors the pointer, and a click leaves it retracted and the anchor a corner. Its outgoing handle stays where it is on a corner, so the first segment keeps its shape, and on a smooth anchor it turns to stay in line at its own length; Alt makes the anchor a corner and leaves the outgoing handle where it was when Alt went down. The Smart Pen's closing drag does the same (fixes #6634; `path.close`'s `in` and `out`; behavior reference: [draw with the Pen, Curvature, or Pencil tool](https://web.archive.org/web/20220614224656/https://helpx.adobe.com/illustrator/using/drawing-pen-curvature-or-pencil.html), where a click or drag on the first anchor closes the path and Alt breaks the pairing of the closing anchor's handles; which handles the drag sets are our choices). Moving the closing anchor with Space held, which that page also describes, is still missing. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494). A drag's preview (the Pen's, the Anchor Point tool's, Direct Selection's handles and anchors) follows every position the pointer is seen at (#834): the canvas sends the tool a drag whenever the pointer's document position differs from the last one it sent, not only when egui's frame delta is non-zero (zero when a compositor coalesces motion and in the frame the pointer comes back into the window after leaving it), and a move that arrives in the release's own frame shapes the preview before the release commits it, once the press has dragged. With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). + - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve and the release closes the path wherever it ends (#501). The first anchor's incoming handle mirrors the pointer, and a click leaves it retracted and the anchor a corner. Its outgoing handle stays where it is on a corner, so the first segment keeps its shape, and on a smooth anchor it turns to stay in line at its own length; Alt makes the anchor a corner and leaves the outgoing handle where it was when Alt went down. The Smart Pen's closing drag does the same (fixes #6634; `path.close`'s `in` and `out`; behavior reference: [draw with the Pen, Curvature, or Pencil tool](https://web.archive.org/web/20220614224656/https://helpx.adobe.com/illustrator/using/drawing-pen-curvature-or-pencil.html), where a click or drag on the first anchor closes the path and Alt breaks the pairing of the closing anchor's handles; which handles the drag sets are our choices). Space held during the Pen's closing drag moves the first anchor, handles and all, as that page also describes (`path.close`'s `at`). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494). A drag's preview (the Pen's, the Anchor Point tool's, Direct Selection's handles and anchors) follows every position the pointer is seen at (#834): the canvas sends the tool a drag whenever the pointer's document position differs from the last one it sent, not only when egui's frame delta is non-zero (zero when a compositor coalesces motion and in the frame the pointer comes back into the window after leaving it), and a move that arrives in the release's own frame shapes the preview before the release commits it, once the press has dragged. With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). - **Direct Selection (#1278):** clicking a segment picks its connection, with hollow endpoint anchors and only that curve's direction handles. Delete leaves the neighboring curves and appearance intact, opens/splits contours in one undo step, and another Delete removes the remaining selected path. A whole-path drag and deliberate anchor selection keep their existing behavior; egui frames and native control/art checked, OS mouse/visual QA pending. Where anchors of overlapping objects coincide, a click or drag takes the anchor of the selected object, also when another object lies over it (#1509, fixes #1508). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character. With Direct Selection and Group Selection, a drag that starts on an object while the selection also holds direct-selected anchors or segments moves those anchors and the objects selected whole in one undo step (`path.moveAnchors`), as a drag of a selected anchor does, and the other anchors of a partly selected path stay in place. While Alt is held during the drag, it copies the selected objects instead (`object.transform`). When direct-selected anchors are moved or transformed together with objects selected whole (such a drag, an anchor drag, an arrow key, or Move, Rotate, Scale, Reflect and Shear without a copy), a path inside a selected group is transformed once, with the group. A path segment with a control point whose x or y is infinite or larger in size than `f64::MAX / 16` is measured by its control points, so its bounds hold the whole curve and the canvas check refuses an edit that leaves such a path selected (`path.create` included). @@ -1572,6 +1572,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - JPEG XL (`.jxl`) images open and place, linked or embedded, as their first frame, decoded with jxl-oxide and stored as PNG: 16-bit and floating-point samples become 8 bits, alpha is kept, and an image past the pixel allowance is refused from its header (part of #2306; `crates/engine/src/cmd/fileio/tests_jxlread.rs`). - The Open and Place dialogs list PostScript `.ps` files and `.epsf` files. A `.ps` file opens and places through the EPS reader (its first page), also when its header is only `%!`, as the `ps` format, which exports don't write (Print to PostScript does); an `.epsf` file opens and places as EPS. Copied in a file manager, these files paste as EPS, and `clipboard.importEps` also reads PostScript whose header is only `%!` (`crates/engine/src/cmd/fileio/tests_epsimport.rs`). `document.pdfInfo` reads an EPS, a `.ps` file or a `.ai` in PostScript form as one page the size of its bounding box (a US Letter page without one), and its `thumbnail: 1` is a PNG of what that page prints (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`). + - DCS 1.0 and 2.0 files (an EPS file with one program per printing plate) place linked as one image of their plates, and Open, Embed and Place without Link refuse them ([Import DCS files](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/import-dcs-files.html)). Spot plates add spot swatches, the composite's clipping path clips the image, Separations Preview and print separations show each plate on its own ink, and the Links panel menu has Show DCS Transparency Interactions (`links.list {dcsTransparency}`). The plates are read as <http://www.telegraphics.net/doc/DCS.txt> describes them (`crates/eps/src/dcs/tests.rs`, `crates/engine/src/tests/dcs.rs`, `crates/doc/src/separated.rs`). - EPS export with Use Artboards writes a central EPS file of the whole document, bounded by its visible art, beside the file of each artboard when it writes two or more artboards; a document with no visible art gets only the artboard files (`crates/engine/src/cmd/fileio/tests_eps.rs`). EPS export writes a linear or radial gradient of tints of one spot color (and paper white) in that ink's Separation space, at Level 2 and 3, and at Level 3 one that mixes a spot color with process or gray colors, or two spot colors, in a DeviceN space of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. At Level 2, such a mix is written in process colors with a warning. In an RGB export, a gradient that mixes a spot color with RGB or Lab colors is written without the spot ink, with a warning; a CMYK export converts those stops to CMYK first, and the gradient follows the rules above. A Perceptual gradient of spot colors, and a mix whose DeviceN space would need more than 32 inks or a spot color named Cyan, Magenta, Yellow or Black, are also written in process colors with a warning (`crates/eps/src/tests.rs`). A spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4), is written as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients and DSC comments; Registration still prints on every plate (`tests::spot_inks_named_all_or_none_are_written_under_names_of_their_own`). EPS export and EPS Save make each file's PNG thumbnail from its TIFF preview, averaging the preview's pixels down to the thumbnail's size on white, instead of drawing the document a second time; a file without a preview still draws its thumbnail (`crates/engine/src/cmd/fileio/tests_eps.rs`). diff --git a/docs/three-d.md b/docs/three-d.md index e4cd4fa76f..38b59023dc 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -326,7 +326,8 @@ These rules are VectorCraft's own: - A graphic is drawn on the visible face of each of the object's solids (a fill's, and a stroke's outline), turned, foreshortened by the perspective and twisted and tapered with the face, and clipped to it. It is shaded - with the face's light: each color is multiplied by the light that falls on the face, and drawn with the + with the face's light: each color is multiplied by the light that falls on the face, except in pattern tiles, + which keep their own colors. It is drawn with the opacity and blending mode of the fill or stroke the face is painted with. A graphic on a face turned away from the viewer is not drawn. - On the back cap, X runs to the right as the back is seen from behind, so the graphic reads the right way round