Merge pull request #4994 from dotCooCoo/fix/eps-gradient-keeps-spot-ink

EPS export: gradients of spot colors keep their spot inks
This commit is contained in:
BFlat
2026-11-01 06:26:38 +07:00
committed by GitHub
4 changed files with 498 additions and 47 deletions
+5 -2
View File
@@ -263,10 +263,13 @@ pub(crate) fn comments(s: &mut String, title: &str, created: Option<i64>, 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));
}
}
+252 -44
View File
@@ -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<f32>)>;
/// A gradient's stops before their offsets are put in order and run from 0 to 1.
type RawStops = Vec<(f32, Vec<f32>)>;
/// 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<i>`): swatch name and CMYK equivalent.
spots: Vec<(String, [f32; 4])>,
/// DeviceN color spaces defined in the setup (`VCdn<i>`), as their definitions.
device_n: Vec<String>,
warnings: Vec<String>,
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// 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<String> {
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<String> = 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<f32>> = 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<usize> = (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<f32> { 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<f32> = 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<GradientSpace> {
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<f32>)]) {
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::<Vec<_>>());
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<f32>)]| 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::<Vec<_>>());
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<f32>)]) {
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<f32>)]) {
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<f32> {
a.iter().zip(b).map(|(x, y)| x + (y - x) * t).collect()
}
+240 -1
View File
@@ -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<GradientStop>) -> 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<Color> {
fn walk(n: &Node, out: &mut Vec<Color>) {
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<String> = (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<String> = (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<String> {
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)]);
+1
View File
@@ -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`).