From 1840b9a58266ef986e4d8f356675501ab57395de Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 14:45:38 -0700 Subject: [PATCH] EPS export: gradients of spot colors keep their spot inks EPS export wrote every gradient with a spot color stop in process colors. A gradient of tints of one spot color and paper white is now written in that ink's Separation space, at Level 2 and 3. At Level 3, a gradient that mixes a spot color with CMYK or gray colors, or two spot colors, is written in a DeviceN space of the process inks its stops use and the spot inks, each spot ink with the CMYK equivalent its Separation space has. An off-center midpoint is a stop with the inks halfway between its two stops. Paper white is an exact white, whether or not the stop is linked to a process swatch. These gradients are still written in process colors, each with its own warning: a mix with other inks at Level 2; a mix with RGB or Lab colors in an RGB export (a CMYK export converts those stops to CMYK first); a DeviceN space that would need more than 32 colorants or a spot ink named Cyan, Magenta, Yellow, Black, All or None; and a Perceptual gradient. The old warning for every spot gradient has been removed. The header lists two or more spot colors with each %%+ line right after the comment it continues: %%DocumentCustomColors and %%CMYKCustomColor were interleaved, so a DSC reader took the second ink's name as part of the first ink's %%CMYKCustomColor. --- crates/eps/src/lib.rs | 7 +- crates/eps/src/scene.rs | 296 ++++++++++++++++++++++++++++++++------ crates/eps/src/tests.rs | 241 ++++++++++++++++++++++++++++++- docs/target-app-parity.md | 1 + 4 files changed, 498 insertions(+), 47 deletions(-) diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 59926fe18..2b828e093 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -263,10 +263,13 @@ pub(crate) fn comments(s: &mut String, title: &str, created: Option, level: } let _ = writeln!(s, "%%LanguageLevel: {}", level.id()); s.push_str("%%DocumentData: Clean7Bit\n"); - for (i, (name, cmyk)) in custom.iter().enumerate() { + // Each `%%+` line continues the comment right above it. + for (i, (name, _)) in custom.iter().enumerate() { let _ = writeln!(s, "{}{}", if i == 0 { "%%DocumentCustomColors: " } else { "%%+ " }, ps::string(name)); + } + for (i, (name, cmyk)) in custom.iter().enumerate() { let [c, m, y, k] = cmyk.map(|v| ps::num(f64::from(v))); - let _ = writeln!(s, "%%CMYKCustomColor: {c} {m} {y} {k} {}", ps::string(name)); + let _ = writeln!(s, "{}{c} {m} {y} {k} {}", if i == 0 { "%%CMYKCustomColor: " } else { "%%+ " }, ps::string(name)); } } diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index f99aea923..b5437b670 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -37,7 +37,13 @@ const IMAGE_PARTLY: &str = "partly transparent image pixels are written over whi /// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; const LARGE_IMAGE: &str = "an image that is too large for EPS was left out"; -const SPOT_GRADIENTS: &str = "gradients of spot colours are written in process colours"; +const SPOT_GRADIENTS_RGB: &str = "gradients mixing a spot color with RGB or Lab colors are written without the spot ink"; +const SPOT_GRADIENTS_L2: &str = + "gradients mixing a spot color with other inks are written in process colors at PostScript Level 2 (Level 3 keeps the spot inks)"; +const SPOT_GRADIENTS_SHARED: &str = "gradients whose spot inks can't share a DeviceN color space are written without the spot inks"; +const SPOT_GRADIENTS_PERCEPTUAL: &str = "Perceptual gradients of spot colors are written in process colors"; +/// The most colorants a DeviceN color space may have (PostScript's limit). +const MAX_DEVICE_N: usize = 32; const MISSING_PATTERN: &str = "missing patterns are written as mid-grey"; const TOO_LARGE: &str = "the artwork is too large for EPS: what follows 256 MB of PostScript was left out"; /// The most PostScript the page body may grow to: symbols and patterns that hold one another @@ -78,6 +84,29 @@ impl Space { } } +/// The color space a gradient's stops are written in: a process space, or one defined in the +/// setup (a spot color's Separation space or a DeviceN space of spot and process inks) by name, +/// with its number of components. +#[derive(Clone, Debug, PartialEq)] +enum GradientSpace { + Process(Space), + Inks(String, usize), +} + +impl GradientSpace { + fn n(&self) -> usize { + match self { + GradientSpace::Process(s) => s.n(), + GradientSpace::Inks(_, n) => *n, + } + } +} + +/// A gradient's stops as written: offsets from 0 to 1 and the components in its space. +type Stops = Vec<(f64, Vec)>; +/// A gradient's stops before their offsets are put in order and run from 0 to 1. +type RawStops = Vec<(f32, Vec)>; + /// A colour as written: in a process space (its first [`Space::n`] components) or a tint of a /// spot colour space defined in the setup. #[derive(Clone, Copy, Debug, PartialEq)] @@ -115,6 +144,8 @@ pub(crate) struct Scene<'a> { setup: String, /// Spot colour spaces defined in the setup (`VCsp`): swatch name and CMYK equivalent. spots: Vec<(String, [f32; 4])>, + /// DeviceN color spaces defined in the setup (`VCdn`), as their definitions. + device_n: Vec, warnings: Vec, brushes: Option>, /// Image keys → what draws them (`None`: left out). @@ -136,6 +167,7 @@ impl<'a> Scene<'a> { body: String::new(), setup: String::new(), spots: vec![], + device_n: vec![], warnings: vec![], brushes: None, images: HashMap::new(), @@ -659,14 +691,189 @@ impl<'a> Scene<'a> { /// The colour space a gradient's stops are written in, and its stops in it from offset 0 to 1 /// (midpoints as stops of their own). - fn gradient_stops(&mut self, g: &vectorcraft_color::Gradient) -> (Space, Vec<(f64, [f32; 4])>) { + fn gradient_stops(&mut self, g: &vectorcraft_color::Gradient) -> (GradientSpace, Stops) { if g.stops.iter().any(|s| s.opacity < 1.0) { self.warn(TRANSPARENCY); } - let doc = self.doc; - if g.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) { - self.warn(SPOT_GRADIENTS); + let (space, raw) = match self.ink_stops(g) { + Some(inks) => inks, + None => { + let (space, raw) = self.process_stops(g); + (GradientSpace::Process(space), raw) + } + }; + let mut out: Stops = vec![]; + let mut last = 0.0f64; + for (o, c) in raw { + let o = if o.is_finite() { f64::from(o).clamp(last, 1.0) } else { last }; + last = o; + out.push((o, c)); } + match (out.first().cloned(), out.last().cloned()) { + (Some(first), Some(end)) => { + if first.0 > 0.0 { + out.insert(0, (0.0, first.1)); + } + if end.0 < 1.0 { + out.push((1.0, end.1)); + } + } + _ => out = vec![(0.0, vec![0.0; space.n()]), (1.0, vec![0.0; space.n()])], + } + (space, out) + } + + /// The stops of a gradient with a spot ink as tints of its inks: a Separation space when every + /// stop is a tint of one spot ink or paper white, else (at Level 3) a DeviceN space of the + /// process inks the stops use, in CMYK order, and the spot inks, in the order the stops name + /// them. Paper white adds no ink and a gray stop is black ink. An off-center midpoint is a stop + /// with the inks halfway between its two stops. `None` for a gradient without a spot ink, and, + /// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method; + /// it is then written in process colors. + fn ink_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<(GradientSpace, RawStops)> { + let doc = self.doc; + let spot_of = |s: &vectorcraft_color::GradientStop| -> Option { + s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot)).map(str::to_string) + }; + // The spot inks, collected up to one more than a DeviceN space can hold. + let mut names: Vec = vec![]; + for s in &g.stops { + if let Some(n) = spot_of(s) + && !names.contains(&n) + { + names.push(n); + if names.len() > MAX_DEVICE_N { + break; + } + } + } + if names.is_empty() { + return None; + } + if !g.method.is_classic() { + self.warn(SPOT_GRADIENTS_PERCEPTUAL); + return None; + } + if names.len() > MAX_DEVICE_N { + self.warn(SPOT_GRADIENTS_SHARED); + return None; + } + // Paper white exactly: any ink at all, however little, is kept. + let paper = |c: &Color| match *c { + Color::Gray { k } => k <= 0.0, + Color::Cmyk { c, m, y, k } => [c, m, y, k].iter().all(|&v| v <= 0.0), + Color::Rgb { r, g, b } => [r, g, b].iter().all(|&v| v >= 1.0), + Color::Lab { l, a, b } => l >= 100.0 && a == 0.0 && b == 0.0, + }; + // Each stop's ink coverage: the four process inks, then one tint per spot ink. + let mut inks: Vec> = Vec::with_capacity(g.stops.len()); + for s in &g.stops { + let mut v = vec![0.0; 4 + names.len()]; + match spot_of(s) { + Some(n) => { + if let Some(i) = names.iter().position(|m| *m == n) { + v[4 + i] = unit(s.tint); + } + } + None if paper(&s.color) => {} + None => match self.process(&s.color) { + Ink::Process(Space::Cmyk, c) => v[..4].copy_from_slice(&c), + Ink::Process(Space::Gray, [lightness, ..]) => v[3] = unit(1.0 - lightness), + _ => { + self.warn(SPOT_GRADIENTS_RGB); + return None; + } + }, + } + inks.push(v); + } + let process: Vec = (0..4).filter(|&i| inks.iter().any(|v| v[i] > 0.0)).collect(); + let space = if let ([name], true) = (names.as_slice(), process.is_empty()) { + let Some(i) = self.spot_space(name) else { + self.warn(SPOT_GRADIENTS_SHARED); + return None; + }; + GradientSpace::Inks(format!("VCsp{i}"), 1) + } else if self.o.level != Level::Three { + self.warn(SPOT_GRADIENTS_L2); + return None; + } else { + let Some(space) = self.device_n_space(&process, &names) else { + self.warn(SPOT_GRADIENTS_SHARED); + return None; + }; + space + }; + let pick = |v: &[f32]| -> Vec { process.iter().map(|&i| v[i]).chain(v[4..].iter().copied()).collect() }; + let mut out = vec![]; + for (i, s) in g.stops.iter().enumerate() { + out.push((s.offset, pick(&inks[i]))); + if let (Some(n), Some(next)) = (g.stops.get(i + 1), inks.get(i + 1)) + && (s.midpoint - 0.5).abs() > 1e-3 + { + let half: Vec = inks[i].iter().zip(next).map(|(a, b)| (a + b) / 2.0).collect(); + out.push((s.offset + (n.offset - s.offset) * s.midpoint, pick(&half))); + } + } + Some((space, out)) + } + + /// A DeviceN color space of process inks `process` (indices into CMYK) and spot inks `names`, + /// defined in the setup the first time, each spot ink with the CMYK equivalent its Separation + /// space has. `None` when a spot ink has no Separation space, two colorants share a name, a + /// spot ink has the name of a process ink, `All` or `None`, or there are more than + /// [`MAX_DEVICE_N`] colorants. + fn device_n_space(&mut self, process: &[usize], names: &[String]) -> Option { + const PROCESS: [&str; 4] = ["Cyan", "Magenta", "Yellow", "Black"]; + if process.len() + names.len() > MAX_DEVICE_N { + return None; + } + let mut colorants: Vec<&str> = process.iter().map(|&i| PROCESS[i]).collect(); + let mut weights: Vec<[f32; 4]> = process.iter().map(|&i| [0, 1, 2, 3].map(|j| if i == j { 1.0 } else { 0.0 })).collect(); + for (i, name) in names.iter().enumerate() { + if PROCESS.contains(&name.as_str()) || name == "All" || name == "None" || names[..i].contains(name) { + return None; + } + } + for name in names { + colorants.push(name); + let i = self.spot_space(name)?; + weights.push(self.spots.get(i)?.1); + } + let mut def = String::from("[/DeviceN ["); + for (i, c) in colorants.iter().enumerate() { + if i < process.len() { + let _ = write!(def, "/{c} "); + } else { + let _ = write!(def, "{} cvn ", ps::string(c)); + } + } + // The tint transform: the components into an array, then each CMYK ink the sum of the + // components times their amounts of it. + let n = weights.len(); + let _ = write!(def, "] /DeviceCMYK {{{n} array astore"); + for j in 0..4 { + def.push_str(" 0"); + for (i, w) in weights.iter().enumerate().filter(|(_, w)| w[j] > 0.0) { + let _ = write!(def, " {} index {i} get {} mul add", j + 1, ps::num(f64::from(w[j]))); + } + } + def.push_str(" 5 -1 roll pop}]"); + let i = match self.device_n.iter().position(|d| *d == def) { + Some(i) => i, + None => { + let i = self.device_n.len(); + let _ = writeln!(self.setup, "/VCdn{i} {def} def"); + self.device_n.push(def); + i + } + }; + Some(GradientSpace::Inks(format!("VCdn{i}"), n)) + } + + /// The process color space a gradient's stops are written in, and its stops in it (midpoints + /// as stops of their own). + fn process_stops(&mut self, g: &vectorcraft_color::Gradient) -> (Space, RawStops) { let stops = g.expanded_stops(); let space = if self.cmyk || stops.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) { Space::Cmyk @@ -686,24 +893,7 @@ impl<'a> Scene<'a> { } } }; - let mut out: Vec<(f64, [f32; 4])> = vec![]; - let mut last = 0.0f64; - for (o, c, _) in &stops { - let o = if o.is_finite() { f64::from(*o).clamp(last, 1.0) } else { last }; - last = o; - out.push((o, comps(c))); - } - match (out.first().copied(), out.last().copied()) { - (Some(first), Some(end)) => { - if first.0 > 0.0 { - out.insert(0, (0.0, first.1)); - } - if end.0 < 1.0 { - out.push((1.0, end.1)); - } - } - _ => out = vec![(0.0, [0.0; 4]), (1.0, [0.0; 4])], - } + let out = stops.iter().map(|(o, c, _)| (*o, comps(c)[..space.n()].to_vec())).collect(); (space, out) } @@ -714,11 +904,12 @@ impl<'a> Scene<'a> { let (space, stops) = self.gradient_stops(&g.gradient); if !stops.windows(2).any(|w| w[1].0 > w[0].0) { if let Some((_, c)) = stops.first() { - self.set_comps(space, *c); + self.set_comps(&space, c); self.rect_fill(area); } return; } + let space = &space; let smooth = self.o.level == Level::Three && !self.o.compatible_gradients; match g.gradient.kind { GradientKind::Radial => { @@ -750,9 +941,9 @@ impl<'a> Scene<'a> { } /// A Level 3 smooth shading of `kind` (2 axial, 3 radial) at `coords`, extended both ways. - fn shading(&mut self, kind: u8, space: Space, coords: &[f64], stops: &[(f64, [f32; 4])]) { + fn shading(&mut self, kind: u8, space: &GradientSpace, coords: &[f64], stops: &[(f64, Vec)]) { let n = space.n(); - let comps = |c: &[f32; 4]| -> String { + let comps = |c: &[f32]| -> String { let mut s = String::from("["); push_nums(&mut s, &c.iter().take(n).map(|v| f64::from(*v)).collect::>()); s.pop(); @@ -760,7 +951,7 @@ impl<'a> Scene<'a> { s }; let segments: Vec<_> = stops.windows(2).filter(|w| w[1].0 > w[0].0).collect(); - let interp = |w: &&[(f64, [f32; 4])]| format!("<< /FunctionType 2 /Domain [0 1] /C0 {} /C1 {} /N 1 >>", comps(&w[0].1), comps(&w[1].1)); + let interp = |w: &&[(f64, Vec)]| format!("<< /FunctionType 2 /Domain [0 1] /C0 {} /C1 {} /N 1 >>", comps(&w[0].1), comps(&w[1].1)); let function = match segments.as_slice() { [one] => interp(one), many => { @@ -778,27 +969,44 @@ impl<'a> Scene<'a> { f } }; - let _ = write!(self.body, "<< /ShadingType {kind} /ColorSpace {} /Coords [", space.name()); + let name = match space { + GradientSpace::Process(s) => s.name(), + GradientSpace::Inks(name, _) => name, + }; + let _ = write!(self.body, "<< /ShadingType {kind} /ColorSpace {name} /Coords ["); push_nums(&mut self.body, coords); let _ = writeln!(self.body, "] /Extend [true true]\n/Function {function} >> shfill"); } /// The bands of each stretch between stops: how many, for colours `a` to `b` over `length` /// points (one per colour level, none thinner than [`MIN_BAND`]). - fn bands(a: &[f32; 4], b: &[f32; 4], length: f64, segments: usize) -> usize { + fn bands(a: &[f32], b: &[f32], length: f64, segments: usize) -> usize { let delta = a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); let by_color = (f64::from(delta) * 255.0).ceil().max(1.0) as usize; let by_size = (length / MIN_BAND).ceil().max(1.0) as usize; by_color.min(by_size).min(MAX_STEPS / segments.max(1)).max(1) } - fn set_comps(&mut self, space: Space, c: [f32; 4]) { - self.set_ink(Ink::Process(space, c)); + fn set_comps(&mut self, space: &GradientSpace, c: &[f32]) { + match space { + GradientSpace::Process(s) => { + let mut v = [0.0; 4]; + for (x, y) in v.iter_mut().zip(c) { + *x = *y; + } + self.set_ink(Ink::Process(*s, v)); + } + GradientSpace::Inks(name, _) => { + let _ = write!(self.body, "{name} setcolorspace "); + push_nums(&mut self.body, &c.iter().map(|v| f64::from(*v)).collect::>()); + self.line("setcolor"); + } + } } /// A linear gradient from `s` to `e` as bands across it, covering `area`: each band painted /// from where it starts to the far end, so the next one leaves no seam. - fn linear_steps(&mut self, s: Point, e: Point, area: Rect, space: Space, stops: &[(f64, [f32; 4])]) { + fn linear_steps(&mut self, s: Point, e: Point, area: Rect, space: &GradientSpace, stops: &[(f64, Vec)]) { let d = e - s; let len = d.hypot(); // Gradient space: x along the gradient (0 to 1), y across it in points. @@ -812,21 +1020,21 @@ impl<'a> Scene<'a> { sc.line("rectfill"); }; if let Some((_, c)) = stops.first() { - self.set_comps(space, *c); + self.set_comps(space, c); band(self, t0); } let segments: Vec<_> = stops.windows(2).filter(|w| w[1].0 > w[0].0).collect(); for w in &segments { - let ((oa, ca), (ob, cb)) = (w[0], w[1]); - let n = Self::bands(&ca, &cb, (ob - oa) * len, segments.len()); + let ((oa, ca), (ob, cb)) = (&w[0], &w[1]); + let n = Self::bands(ca, cb, (ob - oa) * len, segments.len()); for k in 0..n { let at = oa + (ob - oa) * k as f64 / n as f64; - self.set_comps(space, mix(&ca, &cb, (k as f32 + 0.5) / n as f32)); + self.set_comps(space, &mix(ca, cb, (k as f32 + 0.5) / n as f32)); band(self, at); } } if let Some((_, c)) = stops.last() { - self.set_comps(space, *c); + self.set_comps(space, c); band(self, 1.0); } self.line("Q"); @@ -834,18 +1042,18 @@ impl<'a> Scene<'a> { /// A radial gradient from focal point `f` to the circle of radius `r` around `c` as discs, /// largest first, over the last colour across `area` (all in the gradient's own space). - fn radial_steps(&mut self, f: Point, c: Point, r: f64, area: Rect, space: Space, stops: &[(f64, [f32; 4])]) { + fn radial_steps(&mut self, f: Point, c: Point, r: f64, area: Rect, space: &GradientSpace, stops: &[(f64, Vec)]) { if let Some((_, last)) = stops.last() { - self.set_comps(space, *last); + self.set_comps(space, last); self.rect_fill(area); } let segments: Vec<_> = stops.windows(2).filter(|w| w[1].0 > w[0].0).collect(); for w in segments.iter().rev() { - let ((oa, ca), (ob, cb)) = (w[0], w[1]); - let n = Self::bands(&ca, &cb, (ob - oa) * r, segments.len()); + let ((oa, ca), (ob, cb)) = (&w[0], &w[1]); + let n = Self::bands(ca, cb, (ob - oa) * r, segments.len()); for k in (0..n).rev() { let t = oa + (ob - oa) * (k + 1) as f64 / n as f64; - self.set_comps(space, mix(&ca, &cb, (k as f32 + 0.5) / n as f32)); + self.set_comps(space, &mix(ca, cb, (k as f32 + 0.5) / n as f32)); let at = f + (c - f) * t; push_nums(&mut self.body, &[at.x, at.y, r * t]); self.line("0 360 newpath arc fill"); @@ -1145,6 +1353,6 @@ impl<'a> Scene<'a> { } /// `a` to `b` at `t`. -fn mix(a: &[f32; 4], b: &[f32; 4], t: f32) -> [f32; 4] { - [0, 1, 2, 3].map(|i| a[i] + (b[i] - a[i]) * t) +fn mix(a: &[f32], b: &[f32], t: f32) -> Vec { + a.iter().zip(b).map(|(x, y)| x + (y - x) * t).collect() } diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 6d1331c2f..5b89922d5 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint, Swatch}; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint, Swatch}; use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind}; use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes}; @@ -240,6 +240,245 @@ fn spot_colours_are_separations_and_overprints_are_kept_or_dropped() { assert!(!page_of(&d, &EpsOptions { overprint: Overprint::Discard, ..opts(&d) }).contains("true op")); } +const GOLD: Color = Color::Cmyk { c: 0.0, m: 0.2, y: 0.8, k: 0.1 }; +const LEAF: Color = Color::Cmyk { c: 1.0, m: 0.0, y: 1.0, k: 0.0 }; + +/// A stop linked to spot swatch `name` (of color `color`) at `tint`. +fn spot_stop(offset: f32, name: &str, color: Color, tint: f32) -> GradientStop { + GradientStop { swatch: Some(name.into()), tint, ..GradientStop::new(offset, color.tinted(tint)) } +} + +/// A document with the spot swatches Gold Ink and Leaf Ink and a rectangle filled with a linear +/// gradient of `stops`. +fn spot_gradient_doc(stops: Vec) -> Document { + spot_gradient_doc_of(Gradient::new(GradientKind::Linear, stops)) +} + +/// [`spot_gradient_doc`] with the rectangle filled with `gradient`. +fn spot_gradient_doc_of(gradient: Gradient) -> Document { + let mut d = doc_with(|_| vec![]); + d.swatches.push(Swatch { name: "Gold Ink".into(), paint: Paint::solid(GOLD), global: true, spot: true }); + d.swatches.push(Swatch { name: "Leaf Ink".into(), paint: Paint::solid(LEAF), global: true, spot: true }); + let paint = Paint::Gradient(Box::new(GradientPaint::new(gradient))); + let n = path(&mut d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 120.0)), paint, Paint::None, 0.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + d +} + +/// The solid fill colors of the objects of `d`, down through groups. +fn fill_colors(d: &Document) -> Vec { + fn walk(n: &Node, out: &mut Vec) { + for i in &n.appearance.items { + if let AppearanceItem::Fill(f) = i + && let Some(c) = f.paint.color() + { + out.push(c); + } + } + for c in n.children().into_iter().flatten() { + walk(c, out); + } + } + let mut out = vec![]; + for l in &d.layers { + walk(l, &mut out); + } + out +} + +/// Whether one of `colors` is CMYK `want`, to within rounding. +fn has_cmyk(colors: &[Color], want: [f32; 4]) -> bool { + colors.iter().any(|c| matches!(*c, Color::Cmyk { c, m, y, k } if [c, m, y, k].iter().zip(want).all(|(a, b)| (a - b).abs() < 2e-3))) +} + +#[test] +fn a_gradient_of_one_spot_ink_is_a_separation_shading() { + let d = spot_gradient_doc(vec![ + spot_stop(0.0, "Gold Ink", GOLD, 1.0), + spot_stop(0.5, "Gold Ink", GOLD, 0.2), + GradientStop::new(1.0, Color::rgb(1.0, 1.0, 1.0)), + ]); + let out = export(&d, &opts(&d), None, None).unwrap(); + let ps = text(&out); + assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Gold Ink)")); + assert!(ps.contains("/VCsp0 [/Separation (Gold Ink) cvn /DeviceCMYK"), "{ps}"); + let page = checked_page(&ps); + assert!(page.contains("/ShadingType 2 /ColorSpace VCsp0 /Coords"), "{page}"); + assert!(page.contains("/C0 [1] /C1 [0.2]") && page.contains("/C0 [0.2] /C1 [0]"), "tints of Gold Ink to paper: {page}"); + assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings); + // Level 2: bands of the ink's tints. + let out = export(&d, &EpsOptions { level: Level::Two, ..opts(&d) }, None, None).unwrap(); + let page = checked_page(&text(&out)); + assert!(!page.contains("shfill") && !page.contains(" k\n"), "{page}"); + assert!(page.contains("VCsp0 setcolorspace 1 setcolor\n") && page.contains("VCsp0 setcolorspace 0 setcolor\n"), "{page}"); + assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings); +} + +#[test] +fn gradients_mixing_spot_inks_with_other_inks_are_device_n_shadings() { + // Gold Ink, with its midpoint at 30%, to Leaf Ink at 50%. + let mut gold = spot_stop(0.0, "Gold Ink", GOLD, 1.0); + gold.midpoint = 0.3; + let two = spot_gradient_doc(vec![gold, spot_stop(1.0, "Leaf Ink", LEAF, 0.5)]); + let out = export(&two, &opts(&two), None, None).unwrap(); + let ps = text(&out); + assert!( + ps.contains("%%DocumentCustomColors: (Gold Ink)\n%%+ (Leaf Ink)\n%%CMYKCustomColor: 0 0.2 0.8 0.1 (Gold Ink)\n%%+ 1 0 1 0 (Leaf Ink)\n"), + "both inks, each list continued right below its comment: {ps}" + ); + assert!( + ps.contains( + "/VCdn0 [/DeviceN [(Gold Ink) cvn (Leaf Ink) cvn ] /DeviceCMYK {2 array astore 0 1 index 1 get 1 mul add \ + 0 2 index 0 get 0.2 mul add 0 3 index 0 get 0.8 mul add 3 index 1 get 1 mul add 0 4 index 0 get 0.1 mul add 5 -1 roll pop}] def" + ), + "{ps}" + ); + let page = checked_page(&ps); + assert!(page.contains("/ShadingType 2 /ColorSpace VCdn0 /Coords") && page.contains("/Bounds [0.3 ]"), "{page}"); + assert!(page.contains("/C0 [1 0] /C1 [0.5 0.25]") && page.contains("/C0 [0.5 0.25] /C1 [0 0.5]"), "the inks through the midpoint: {page}"); + assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings); + + // Gold Ink to 60% process yellow to 40% gray: Yellow, Black and Gold Ink. + let mixed = spot_gradient_doc(vec![ + spot_stop(0.0, "Gold Ink", GOLD, 1.0), + GradientStop::new(0.5, Color::cmyk(0.0, 0.0, 0.6, 0.0)), + GradientStop::new(1.0, Color::Gray { k: 0.4 }), + ]); + let out = export(&mixed, &opts(&mixed), None, None).unwrap(); + let ps = text(&out); + assert!(ps.contains("/VCdn0 [/DeviceN [/Yellow /Black (Gold Ink) cvn ] /DeviceCMYK {3 array astore"), "{ps}"); + let page = checked_page(&ps); + assert!(page.contains("/C0 [0 0 1] /C1 [0.6 0 0]") && page.contains("/C0 [0.6 0 0] /C1 [0 0.4 0]"), "{page}"); + assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings); + + // Read back as bands, the tint transforms give each ink's CMYK equivalent. + for (d, end) in [(&two, [0.5, 0.0, 0.5, 0.0]), (&mixed, [0.0, 0.0, 0.0, 0.4])] { + let out = export(d, &EpsOptions { compatible_gradients: true, ..opts(d) }, None, None).unwrap(); + let page = checked_page(&text(&out)); + assert!(page.contains("VCdn0 setcolorspace ") && !page.contains("shfill"), "{page}"); + let colors = fill_colors(&import(&out.bytes).unwrap().document); + assert!(has_cmyk(&colors, [0.0, 0.2, 0.8, 0.1]) && has_cmyk(&colors, end), "{colors:?}"); + } + + // Level 2 has no DeviceN: process colors, with a warning. + let out = export(&two, &EpsOptions { level: Level::Two, ..opts(&two) }, None, None).unwrap(); + assert!(!text(&out).contains("DeviceN")); + assert!(out.warnings.iter().any(|w| w.contains("process colors at PostScript Level 2")), "{:?}", out.warnings); +} + +#[test] +fn a_gradient_mixing_a_spot_ink_with_rgb_is_written_without_the_ink() { + let d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), GradientStop::new(1.0, Color::rgb(0.0, 1.0, 0.0))]); + let out = export(&d, &opts(&d), None, None).unwrap(); + let ps = text(&out); + assert!(!ps.contains("/Separation") && !ps.contains("/DeviceN"), "{ps}"); + assert!(out.warnings.iter().any(|w| w.contains("RGB or Lab colors are written without the spot ink")), "{:?}", out.warnings); + // Written as CMYK, the green stop mixes with the ink. + let out = export(&d, &EpsOptions { cmyk: true, ..opts(&d) }, None, None).unwrap(); + assert!(text(&out).contains("/ColorSpace VCdn0")); + assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings); +} + +#[test] +fn spot_inks_named_like_process_inks_all_or_none_are_not_device_n_colorants() { + for name in ["Cyan", "Black", "All", "None"] { + // Gold Ink to the spot ink, with no process ink in the stops. + let mut d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), spot_stop(1.0, name, LEAF, 1.0)]); + d.swatches.retain(|s| s.name != name); + d.swatches.push(Swatch { name: name.into(), paint: Paint::solid(LEAF), global: true, spot: true }); + let out = export(&d, &opts(&d), None, None).unwrap(); + let ps = text(&out); + assert!(!ps.contains("/DeviceN") && !ps.contains("/Separation"), "{name}: {ps}"); + assert!(out.warnings.iter().any(|w| w.contains("can't share a DeviceN color space")), "{name}: {:?}", out.warnings); + } +} + +#[test] +fn a_device_n_space_has_at_most_32_colorants() { + for (inks, device_n) in [(32, true), (33, false)] { + let names: Vec = (0..inks).map(|i| format!("Ink {i}")).collect(); + let stops = names.iter().enumerate().map(|(i, n)| spot_stop(i as f32 / (inks - 1) as f32, n, LEAF, 1.0)).collect(); + let mut d = spot_gradient_doc(stops); + d.swatches.extend(names.iter().map(|n| Swatch { name: n.clone(), paint: Paint::solid(LEAF), global: true, spot: true })); + let out = export(&d, &opts(&d), None, None).unwrap(); + let ps = text(&out); + assert_eq!(ps.contains("/DeviceN [(Ink 0) cvn "), device_n, "{inks} inks: {ps}"); + assert_eq!(out.warnings.iter().any(|w| w.contains("can't share a DeviceN color space")), !device_n, "{:?}", out.warnings); + } +} + +#[test] +fn gradients_of_more_than_32_spot_inks_are_written_in_process_colors_at_every_level() { + // 400 stops over 40 inks. + let names: Vec = (0..40).map(|i| format!("Ink {i}")).collect(); + let stops = (0..400).map(|i| spot_stop(i as f32 / 399.0, &names[i % names.len()], LEAF, 1.0)).collect(); + let mut d = spot_gradient_doc(stops); + d.swatches.extend(names.iter().map(|n| Swatch { name: n.clone(), paint: Paint::solid(LEAF), global: true, spot: true })); + for level in [Level::Three, Level::Two] { + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + assert!(!ps.contains("/DeviceN") && !ps.contains("VCsp"), "{level:?}: {ps}"); + assert!(out.warnings.iter().any(|w| w.contains("can't share a DeviceN color space")), "{level:?}: {:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w.contains("PostScript Level 2")), "{level:?}: {:?}", out.warnings); + } +} + +#[test] +fn near_white_stops_next_to_a_spot_ink_keep_their_ink() { + let d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), GradientStop::new(1.0, Color::Gray { k: 0.001 })]); + let out = export(&d, &opts(&d), None, None).unwrap(); + let ps = text(&out); + assert!(ps.contains("/VCdn0 [/DeviceN [/Black (Gold Ink) cvn ]"), "{ps}"); + assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings); + // A near-white RGB stop is RGB, not paper. + let d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.999))]); + let out = export(&d, &opts(&d), None, None).unwrap(); + assert!(out.warnings.iter().any(|w| w.contains("RGB or Lab colors are written without the spot ink")), "{:?}", out.warnings); +} + +#[test] +fn a_white_stop_linked_to_a_process_swatch_is_paper_next_to_a_spot_ink() { + let white = Color::rgb(1.0, 1.0, 1.0); + let mut d = spot_gradient_doc(vec![ + spot_stop(0.0, "Gold Ink", GOLD, 1.0), + GradientStop { swatch: Some("Paper".into()), ..GradientStop::new(1.0, white) }, + ]); + d.swatches.push(Swatch { name: "Paper".into(), paint: Paint::solid(white), global: true, spot: false }); + let out = export(&d, &opts(&d), None, None).unwrap(); + let page = checked_page(&text(&out)); + assert!(page.contains("/ShadingType 2 /ColorSpace VCsp0 /Coords"), "{page}"); + assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings); +} + +#[test] +fn perceptual_gradients_of_spot_inks_are_written_in_process_colors() { + let stops = vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), spot_stop(1.0, "Leaf Ink", LEAF, 1.0)]; + let mut perceptual = Gradient::new(GradientKind::Linear, stops.clone()); + perceptual.method = GradientMethod::Perceptual; + let d = spot_gradient_doc_of(perceptual); + let out = export(&d, &opts(&d), None, None).unwrap(); + let page = checked_page(&text(&out)); + assert!(!page.contains("VCdn") && !page.contains("VCsp"), "{page}"); + assert!(page.contains("/ColorSpace /DeviceCMYK"), "{page}"); + assert!(out.warnings.iter().any(|w| w.contains("Perceptual gradients of spot colors are written in process colors")), "{:?}", out.warnings); + // The in-between stops follow the Perceptual mix, as for a gradient without spot inks. + let process = |c: Color| GradientStop::new(0.0, c); + let mut plain = Gradient::new(GradientKind::Linear, vec![process(GOLD), GradientStop { offset: 1.0, ..process(LEAF) }]); + plain.method = GradientMethod::Perceptual; + let p = spot_gradient_doc_of(plain); + let plain_page = checked_page(&text(&export(&p, &opts(&p), None, None).unwrap())); + let shading = |s: &str| -> Option { + let i = s.find("/ShadingType")?; + Some(s[i..i + s[i..].find("shfill")?].to_string()) + }; + assert!(shading(&page).is_some_and(|s| s.contains("/Bounds")), "{page}"); + assert_eq!(shading(&page), shading(&plain_page)); + // Classic keeps the inks. + let d = spot_gradient_doc(stops); + assert!(text(&export(&d, &opts(&d), None, None).unwrap()).contains("/ColorSpace VCdn0")); +} + #[test] fn rgb_is_written_as_cmyk_when_asked_and_in_cmyk_documents() { let d = doc_with(|d| vec![red_rect(d)]); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e90c39ef7..f6ac63f8e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1065,6 +1065,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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`). - 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, Black, All or None, are also written in process colors with a warning (`crates/eps/src/tests.rs`). - Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests/links.rs`). - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. The last valid `resc` or `resd` resolution value wins on each axis; an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`).