Print color: spot gradients export as Separation and DeviceN shadings

This commit is contained in:
aiongg
2026-10-20 13:12:30 +00:00
parent 6c9709e11f
commit 717079fa0d
5 changed files with 423 additions and 38 deletions
+1
View File
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-20** · Spot gradients print on their plates. A gradient stop keeps the swatch it was given (Gradient panel drop, `swatch.create {gradient}`, `object.gradient`, IDML `StopColor`), following its renames and edits. PDF export shades one spot with its tints or [Paper] as a Separation, and a spot with process inks or a second spot as DeviceN; a process gradient to [Paper] prints in DeviceCMYK. Documents with plain-colour stops export as before. No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
+45 -36
View File
@@ -545,11 +545,11 @@ pub(crate) fn norm(v: f32) -> NormalizedF32 {
NormalizedF32::new(if v.is_finite() { v.clamp(0.0, 1.0) } else { 1.0 }).unwrap_or(NormalizedF32::ONE)
}
fn q(v: f32) -> u8 {
pub(crate) fn q(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
fn regular(c: &Color, rgb_only: bool) -> krilla::color::RegularColor {
pub(crate) fn regular(c: &Color, rgb_only: bool) -> krilla::color::RegularColor {
use krilla::color::RegularColor as R;
if rgb_only && matches!(c, Color::Cmyk { .. }) {
let [r, g, b] = c.to_rgb();
@@ -1061,41 +1061,10 @@ impl Exporter<'_> {
let (swatch, tint) = (fill.swatch.as_str(), fill.tint);
if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) {
let kind = g.kind;
let mut expanded = g.expanded_stops();
// Grey and CMYK stops blend as inks, so a grey stop beside a CMYK one is the K ink.
if !self.rgb_only && expanded.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) {
for (_, c, _) in &mut expanded {
if let Color::Gray { k } = *c {
*c = Color::cmyk(0.0, 0.0, 0.0, k);
}
}
}
// A PDF shading needs one colour space. Midpoint expansion can introduce RGB even
// when every authored stop is CMYK/Gray; PDF/A can also change the output space.
let components = |c: &Color| match c {
Color::Cmyk { .. } if !self.rgb_only => 4,
Color::Gray { .. } => 1,
_ => 3,
let stops = match self.ink_gradient_stops(g, swatch) {
Some(stops) => stops,
None => self.device_gradient_stops(g, swatch),
};
let mixed = expanded.first().is_some_and(|(_, first, _)| expanded.iter().any(|(_, c, _)| components(c) != components(first)));
if mixed {
self.warn(format!("gradient '{swatch}': mixed color spaces converted to RGB (colour appearance and separations may change)"));
}
let mut stops: Vec<Stop> = Vec::new();
let mut last = 0.0f32;
for (o, c, a) in expanded {
let o = o.clamp(last, 1.0);
last = o;
let color = if mixed && !matches!(c, Color::Rgb { .. }) {
let [r, g, b] = c.to_rgb();
rgb::Color::new(q(r), q(g), q(b)).into()
} else {
// Preserve RGB values, including already-interpolated display RGB, and
// leave every homogeneous gradient on its existing device-space path.
device(&c, self.rgb_only)
};
stops.push(Stop { offset: norm(o), color, opacity: norm(a) });
}
if stops.is_empty() {
return None;
}
@@ -1139,6 +1108,46 @@ impl Exporter<'_> {
self.swatch_color(swatch, tint).map(Into::into)
}
/// A gradient's stops in device colours: CMYK, RGB or Gray, as one space.
fn device_gradient_stops(&mut self, g: &designcraft_color::Gradient, swatch: &str) -> Vec<Stop> {
let mut expanded = g.expanded_stops();
// Grey and CMYK stops blend as inks, so a grey stop beside a CMYK one is the K ink.
if !self.rgb_only && expanded.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) {
for (_, c, _) in &mut expanded {
if let Color::Gray { k } = *c {
*c = Color::cmyk(0.0, 0.0, 0.0, k);
}
}
}
// A PDF shading needs one colour space. Midpoint expansion can introduce RGB even
// when every authored stop is CMYK/Gray; PDF/A can also change the output space.
let components = |c: &Color| match c {
Color::Cmyk { .. } if !self.rgb_only => 4,
Color::Gray { .. } => 1,
_ => 3,
};
let mixed = expanded.first().is_some_and(|(_, first, _)| expanded.iter().any(|(_, c, _)| components(c) != components(first)));
if mixed {
self.warn(format!("gradient '{swatch}': mixed color spaces converted to RGB (colour appearance and separations may change)"));
}
let mut stops: Vec<Stop> = Vec::new();
let mut last = 0.0f32;
for (o, c, a) in expanded {
let o = o.clamp(last, 1.0);
last = o;
let color = if mixed && !matches!(c, Color::Rgb { .. }) {
let [r, g, b] = c.to_rgb();
rgb::Color::new(q(r), q(g), q(b)).into()
} else {
// Preserve RGB values, including already-interpolated display RGB, and
// leave every homogeneous gradient on its existing device-space path.
device(&c, self.rgb_only)
};
stops.push(Stop { offset: norm(o), color, opacity: norm(a) });
}
stops
}
fn stroke(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: &Path) {
let st = &it.stroke;
// A gradient swatch runs across the path's box, as on a fill.
+194
View File
@@ -0,0 +1,194 @@
//! Gradients written in their inks. A gradient whose stops name a spot swatch shades in that
//! spot: one spot with paper or tints of itself is a Separation shading, a spot with process
//! inks or a second spot a DeviceN shading (process colorants in CMYK order, then the spots).
//! In print, a process gradient with a `[Paper]` stop shades in DeviceCMYK.
use craft_pdf::write::backend as krilla;
use designcraft_color::swatch::{ColorType, SwatchValue, apply_tint};
use designcraft_color::{Color, Gradient};
use krilla::color::device_n::{self, Colorant, DeviceNSpace, Process};
use krilla::color::separation::{self, SeparationColorant, SeparationSpace};
use krilla::paint::Stop;
use crate::export::{Exporter, norm, q, regular};
/// A stop as inks.
enum Ink {
/// CMYK tints (0..=1); `[Paper]` is all zero.
Process([f32; 4]),
Spot {
name: String,
alternate: Color,
tint: f32,
},
}
/// Inks of a plain colour: CMYK as it is, grey as black. RGB has none.
fn process_inks(c: Color) -> Option<[f32; 4]> {
let tint = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
match c {
Color::Cmyk { c, m, y, k } => Some([tint(c), tint(m), tint(y), tint(k)]),
Color::Gray { k } => Some([0.0, 0.0, 0.0, tint(k)]),
Color::Rgb { .. } => None,
}
}
/// The shading's color space.
enum Space {
Separation(SeparationSpace),
DeviceN(DeviceNSpace),
}
impl Exporter<'_> {
/// The inks a stop prints with, following tints and the Ink Manager as solid fills do.
/// `None` when they can't go into an ink shading (RGB, `[Registration]`).
fn stop_ink(&self, stop: &designcraft_color::GradientStop) -> Option<Ink> {
let Some(mut n) = stop.swatch.as_deref() else { return process_inks(stop.color).map(Ink::Process) };
let mut t = 1.0f32;
for _ in 0..8 {
// A swatch that's gone leaves the stop's own colour.
let Some(sw) = self.doc.swatch(n) else { return process_inks(stop.color).map(Ink::Process) };
match &sw.value {
SwatchValue::Paper { .. } => return Some(Ink::Process([0.0; 4])),
SwatchValue::Tint { base, tint } => {
t *= tint;
n = base;
}
SwatchValue::Color { color_type: ColorType::Spot, .. } => {
let (ink, process) = self.doc.inks.resolve(&sw.name);
let target =
self.doc.swatch(ink).filter(|w| matches!(w.value, SwatchValue::Color { color_type: ColorType::Spot, .. })).unwrap_or(sw);
let SwatchValue::Color { color, .. } = &target.value else { return None };
if process {
return process_inks(apply_tint(*color, t)).map(Ink::Process);
}
let tint = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 };
return Some(Ink::Spot { name: target.name.clone(), alternate: *color, tint });
}
SwatchValue::Color { color, .. } => return process_inks(apply_tint(*color, t)).map(Ink::Process),
SwatchValue::Registration | SwatchValue::None | SwatchValue::Gradient { .. } => return None,
}
}
None
}
/// The gradient's stops in its inks, or `None` to write it in device colours (no spot, or
/// inks that can't share one shading space, which is warned about).
pub(crate) fn ink_gradient_stops(&mut self, g: &Gradient, swatch: &str) -> Option<Vec<Stop>> {
// Plain-colour stops (all older documents) keep their device colours.
if g.stops.iter().all(|s| s.swatch.is_none()) {
return None;
}
let inks: Vec<Option<Ink>> = g.stops.iter().map(|s| self.stop_ink(s)).collect();
let has_spot = inks.iter().any(|i| matches!(i, Some(Ink::Spot { .. })));
let on_paper = g.stops.iter().any(|s| s.swatch.as_deref() == Some(designcraft_color::swatch::PAPER));
if !has_spot && !(on_paper && self.for_print() && !self.rgb_only) {
return None;
}
let Some(inks) = inks.into_iter().collect::<Option<Vec<Ink>>>() else {
return self.unshaded(swatch, "a stop has no inks (RGB or [Registration])", has_spot);
};
// Spots in the order the stops name them; process inks a stop sets.
let mut spots: Vec<(String, Color)> = Vec::new();
let mut process = [false; 4];
for i in &inks {
match i {
Ink::Process(v) => {
for (used, v) in process.iter_mut().zip(v) {
*used |= *v > 1e-6;
}
}
Ink::Spot { name, alternate, .. } => {
if !spots.iter().any(|(n, _)| n == name) {
spots.push((name.clone(), *alternate));
}
}
}
}
let process: Vec<usize> = (0..4).filter(|j| process.get(*j).copied().unwrap_or(false)).collect();
let space = match (spots.as_slice(), process.is_empty()) {
([], _) => None,
([(name, alternate)], true) => {
Some(Space::Separation(SeparationSpace::new(SeparationColorant::Custom(name.clone()), regular(alternate, self.rgb_only))))
}
_ => {
if self.rgb_only {
return self.unshaded(swatch, "DeviceN isn't allowed in PDF/A", true);
}
let mut colorants: Vec<Colorant> = process.iter().filter_map(|j| Process::ALL.get(*j).copied().map(Colorant::Process)).collect();
for (name, alternate) in &spots {
let Color::Cmyk { c, m, y, k } = *alternate else {
return self.unshaded(swatch, &format!("'{name}' has no CMYK values"), true);
};
colorants.push(Colorant::Spot { name: name.clone(), alternate: krilla::color::cmyk::Color::new(q(c), q(m), q(y), q(k)) });
}
match DeviceNSpace::new(colorants) {
Some(s) => Some(Space::DeviceN(s)),
None => return self.unshaded(swatch, "its inks don't fit one DeviceN space", true),
}
}
};
// Tints per colorant at each authored stop.
let tints: Vec<Vec<f32>> = inks
.iter()
.map(|i| {
let mut v = vec![0.0f32; process.len() + spots.len()];
match i {
Ink::Process(p) => {
for (slot, j) in v.iter_mut().zip(&process) {
*slot = p.get(*j).copied().unwrap_or(0.0);
}
}
Ink::Spot { name, tint, .. } => {
if let Some(slot) = spots.iter().position(|(n, _)| n == name).and_then(|k| v.get_mut(process.len() + k)) {
*slot = *tint;
}
}
}
v
})
.collect();
// A midpoint becomes a stop halfway between its neighbours' tints, as the display does.
let mut expanded: Vec<(f32, Vec<f32>, f32)> = Vec::with_capacity(tints.len() * 2);
for (k, (s, v)) in g.stops.iter().zip(&tints).enumerate() {
expanded.push((s.offset, v.clone(), s.opacity));
if let (Some(n), Some(nv)) = (g.stops.get(k + 1), tints.get(k + 1))
&& (s.midpoint - 0.5).abs() > 1e-3
{
let mid: Vec<f32> = v.iter().zip(nv).map(|(a, b)| (a + b) / 2.0).collect();
expanded.push((s.offset + (n.offset - s.offset) * s.midpoint, mid, (s.opacity + n.opacity) / 2.0));
}
}
let mut stops = Vec::with_capacity(expanded.len());
let mut last = 0.0f32;
for (o, v, a) in expanded {
let o = if o.is_finite() { o.clamp(last, 1.0) } else { last };
last = o;
let color: krilla::color::Color = match &space {
Some(Space::Separation(sp)) => separation::Color::new(q(v.first().copied().unwrap_or(0.0)), sp.clone()).into(),
Some(Space::DeviceN(sp)) => device_n::Color::new(v.iter().map(|t| q(*t)).collect(), sp.clone())?.into(),
// Process inks only (a gradient to [Paper] in print).
None => {
let mut cmyk = [0.0f32; 4];
for (t, j) in v.iter().zip(&process) {
if let Some(slot) = cmyk.get_mut(*j) {
*slot = *t;
}
}
let [c, m, y, k] = cmyk;
krilla::color::cmyk::Color::new(q(c), q(m), q(y), q(k)).into()
}
};
stops.push(Stop { offset: norm(o), color, opacity: norm(a) });
}
(!stops.is_empty()).then_some(stops)
}
/// Falls back to device colours, warning when that loses a spot ink.
fn unshaded(&mut self, swatch: &str, why: &str, has_spot: bool) -> Option<Vec<Stop>> {
if has_spot {
self.warn(format!("gradient '{swatch}': spot inks printed as process colour ({why})"));
}
None
}
}
+6 -2
View File
@@ -12,8 +12,11 @@
//! - **Colour:** CMYK as DeviceCMYK, RGB as DeviceRGB, Gray as DeviceGray, spot swatches (and their
//! tints) as `/Separation` with the swatch's own values as the alternate space, `[Registration]`
//! as `/Separation /All`.
//! Gradients with mixed output colour spaces are converted to RGB with an export warning;
//! homogeneous gradients keep their existing colour space (subject to PDF/A conversion).
//! Gradient stops on swatches shade in their inks: one spot with tints of itself or `[Paper]`
//! as a `/Separation` shading, a spot with process inks or a second spot as `/DeviceN`
//! (process colorants first; not in PDF/A, which goes to process colour with a warning), and in
//! print a process gradient to `[Paper]` as DeviceCMYK. Plain-colour stops keep their own
//! space; gradients mixing RGB with CMYK or Gray are converted to RGB with an export warning.
//! - **Images:** clipped to their frame; JPEG data is passed through, PNG/GIF/WebP are embedded
//! losslessly (or re-encoded as JPEG with [`PdfOptions::compress_images`]). PDF/X-4 and PDF/A
//! images are written without interpolation. In PDF/X-4 an RGB image without a profile of its own
@@ -30,6 +33,7 @@
#![forbid(unsafe_code)]
mod export;
mod gradient_inks;
mod links;
mod marks;
mod text;
+177
View File
@@ -0,0 +1,177 @@
//! Gradients on spot swatches shade in their inks: Separation for one spot, DeviceN for a spot
//! with process inks or a second spot.
use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, Swatch, SwatchValue};
use designcraft_compose::Cache;
use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, build::NewDocument};
use designcraft_geom::{Rect, shapes};
use designcraft_pdf::{PdfOptions, Standard, export_pdf_with_report};
use hayro_syntax::{
Pdf,
object::{Array, Dict, Name},
};
const CYAN: &str = "C=100 M=0 Y=0 K=0";
fn spot(d: &mut Document, name: &str, color: Color) {
d.swatches.push(Swatch { value: SwatchValue::Color { color, color_type: ColorType::Spot }, ..Swatch::color(name, color) });
}
/// A document with spot inks Gold and Silver, a 40 % tint of Gold, and one box filled with a gradient on `stops`
/// (swatch name or plain colour, location).
fn document(stops: &[(Result<&str, Color>, f32)], midpoint: f32) -> Document {
let mut d = Document::new(&NewDocument { width: 120.0, height: 120.0, facing_pages: false, ..Default::default() });
spot(&mut d, "Gold", Color::cmyk(0.0, 0.3, 0.9, 0.1));
spot(&mut d, "Silver", Color::cmyk(0.2, 0.15, 0.1, 0.3));
d.swatches.push(Swatch { value: SwatchValue::Tint { base: "Gold".into(), tint: 0.4 }, ..Swatch::color("Gold 40%", Color::BLACK) });
let stops = stops
.iter()
.map(|(s, offset)| match s {
Ok(name) => GradientStop { swatch: Some(name.to_string()), midpoint, ..GradientStop::new(*offset, d.resolve_color(name, 1.0).unwrap()) },
Err(c) => GradientStop { midpoint, ..GradientStop::new(*offset, *c) },
})
.collect();
d.swatches.push(Swatch {
value: SwatchValue::Gradient { gradient: Gradient { kind: GradientKind::Linear, stops } },
..Swatch::color("Fade", Color::BLACK)
});
let mut item = Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 110.0)));
item.fill = Fill::swatch("Fade");
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
d
}
fn endpoint(function: &Dict<'_>, first: bool) -> Vec<f32> {
if let Some(functions) = function.get::<Array<'_>>(b"Functions") {
let functions: Vec<_> = functions.iter::<Dict<'_>>().collect();
return endpoint(if first { functions.first().unwrap() } else { functions.last().unwrap() }, first);
}
function.get::<Array<'_>>(if first { b"C0" } else { b"C1" }).unwrap().iter::<f32>().collect()
}
/// Each shading on the page: its color space family, colorant names, and the colours at its
/// two ends.
struct Shading {
family: String,
colorants: Vec<String>,
start: Vec<f32>,
end: Vec<f32>,
}
fn export(d: &Document, standard: Standard) -> (Vec<Shading>, Vec<String>) {
let report = export_pdf_with_report(d, &Cache::new(), &PdfOptions { standard, compress: false, ..Default::default() }).unwrap();
let pdf = Pdf::new(report.bytes).unwrap();
let patterns = &pdf.pages()[0].resources().patterns;
let mut out = Vec::new();
for (name, _) in patterns.entries() {
let pattern = patterns.get::<Dict<'_>>(name.as_ref()).unwrap();
let shading = pattern.get::<Dict<'_>>(b"Shading").unwrap();
let text = |n: Name<'_>| String::from_utf8_lossy(n.as_ref()).into_owned();
let (family, colorants) = match shading.get::<Name<'_>>(b"ColorSpace") {
Some(n) => (text(n), Vec::new()),
None => {
let space = shading.get::<Array<'_>>(b"ColorSpace").unwrap();
let mut it = space.flex_iter();
let family = text(it.next::<Name<'_>>().unwrap());
let colorants = match family.as_str() {
"Separation" => vec![text(it.next::<Name<'_>>().unwrap())],
"DeviceN" => it.next::<Array<'_>>().unwrap().iter::<Name<'_>>().map(text).collect(),
_ => Vec::new(),
};
(family, colorants)
}
};
let function = shading.get::<Dict<'_>>(b"Function").unwrap();
out.push(Shading { family, colorants, start: endpoint(&function, true), end: endpoint(&function, false) });
}
assert!(!out.is_empty(), "the gradient is a shading");
(out, report.warnings)
}
fn close(a: &[f32], b: &[f32]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01)
}
fn no_conversion(warnings: &[String]) {
assert!(!warnings.iter().any(|w| w.contains("Fade")), "{warnings:?}");
}
#[test]
fn a_spot_to_paper_gradient_is_a_separation_shading() {
for standard in [Standard::None, Standard::PdfX4] {
let (shadings, warnings) = export(&document(&[(Ok("Gold"), 0.0), (Ok("[Paper]"), 1.0)], 0.5), standard);
no_conversion(&warnings);
let s = &shadings[0];
assert_eq!((s.family.as_str(), s.colorants.as_slice()), ("Separation", &["Gold".to_string()][..]), "{standard:?}");
assert!(close(&s.start, &[1.0]) && close(&s.end, &[0.0]), "{:?} → {:?}", s.start, s.end);
}
// A tint of the spot shades in the same Separation.
let (shadings, _) = export(&document(&[(Ok("Gold"), 0.0), (Ok("Gold 40%"), 1.0)], 0.5), Standard::PdfX4);
assert_eq!(shadings[0].family, "Separation");
assert!(close(&shadings[0].end, &[0.4]), "{:?}", shadings[0].end);
// Converted to process in the Ink Manager: CMYK.
let mut d = document(&[(Ok("Gold"), 0.0), (Ok("[Paper]"), 1.0)], 0.5);
d.inks.to_process.push("Gold".into());
let (shadings, _) = export(&d, Standard::PdfX4);
assert_eq!(shadings[0].family, "DeviceCMYK");
assert!(close(&shadings[0].start, &[0.0, 0.3, 0.9, 0.1]), "{:?}", shadings[0].start);
}
#[test]
fn a_spot_to_cyan_gradient_is_a_device_n_shading() {
let (shadings, warnings) = export(&document(&[(Ok("Gold"), 0.0), (Ok(CYAN), 1.0)], 0.5), Standard::PdfX4);
no_conversion(&warnings);
let s = &shadings[0];
assert_eq!(s.family, "DeviceN");
assert_eq!(s.colorants, ["Cyan", "Gold"]);
assert!(close(&s.start, &[0.0, 1.0]) && close(&s.end, &[1.0, 0.0]), "{:?} → {:?}", s.start, s.end);
// A midpoint stays in the inks: the colours at the ends are unchanged and in DeviceN.
let (shadings, _) = export(&document(&[(Ok("Gold"), 0.0), (Ok(CYAN), 1.0)], 0.25), Standard::PdfX4);
assert_eq!(shadings[0].family, "DeviceN");
assert!(close(&shadings[0].start, &[0.0, 1.0]) && close(&shadings[0].end, &[1.0, 0.0]));
}
#[test]
fn a_two_spot_gradient_is_a_device_n_shading() {
let (shadings, warnings) = export(&document(&[(Ok("Gold"), 0.0), (Ok("Silver"), 1.0)], 0.5), Standard::PdfX4);
no_conversion(&warnings);
let s = &shadings[0];
assert_eq!(s.family, "DeviceN");
assert_eq!(s.colorants, ["Gold", "Silver"]);
assert!(close(&s.start, &[1.0, 0.0]) && close(&s.end, &[0.0, 1.0]), "{:?} → {:?}", s.start, s.end);
// Stop opacity is a soft mask over the same inks.
let mut d = document(&[(Ok("Gold"), 0.0), (Ok("Silver"), 1.0)], 0.5);
if let Some(SwatchValue::Gradient { gradient }) = d.swatches.iter_mut().find(|w| w.name == "Fade").map(|w| &mut w.value) {
gradient.stops[0].opacity = 0.4;
}
let (shadings, warnings) = export(&d, Standard::PdfX4);
no_conversion(&warnings);
assert!(shadings.iter().any(|s| s.family == "DeviceN" && s.colorants == ["Gold", "Silver"]));
}
#[test]
fn a_process_gradient_to_paper_prints_in_cmyk() {
let (shadings, warnings) = export(&document(&[(Ok(CYAN), 0.0), (Ok("[Paper]"), 1.0)], 0.5), Standard::PdfX4);
no_conversion(&warnings);
assert_eq!(shadings[0].family, "DeviceCMYK");
assert!(close(&shadings[0].start, &[1.0, 0.0, 0.0, 0.0]) && close(&shadings[0].end, &[0.0, 0.0, 0.0, 0.0]));
}
/// PDF/A forbids DeviceN's PostScript tint transform: the gradient goes to process colour, with
/// a warning, and the export still succeeds.
#[test]
fn pdf_a_writes_mixed_spot_gradients_in_process_colour() {
let (shadings, warnings) = export(&document(&[(Ok("Gold"), 0.0), (Ok(CYAN), 1.0)], 0.5), Standard::PdfA2b);
assert!(warnings.iter().any(|w| w.contains("'Fade'") && w.contains("spot inks printed as process")), "{warnings:?}");
assert!(shadings.iter().all(|s| s.family != "DeviceN"));
}
/// Stops with plain colours (every document saved before stops named swatches) export as they
/// did: the spot's values in DeviceCMYK.
#[test]
fn plain_colour_stops_keep_their_device_space() {
let gold = Color::cmyk(0.0, 0.3, 0.9, 0.1);
let (shadings, warnings) = export(&document(&[(Err(gold), 0.0), (Err(Color::cmyk(1.0, 0.0, 0.0, 0.0)), 1.0)], 0.5), Standard::PdfX4);
no_conversion(&warnings);
assert_eq!(shadings[0].family, "DeviceCMYK");
assert!(close(&shadings[0].start, &[0.0, 0.3, 0.9, 0.1]));
}