From 90bc821f3f23d8de92d037bf06c3c6d0b5e588aa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 05:31:56 -0800 Subject: [PATCH] PDF: dithered and Perceptual CMYK gradients stay CMYK A dithered fill or a Perceptual linear or radial gradient whose stops are all CMYK was written as an RGB image. It is now a CMYK image. The stops keep their inks, an off-center midpoint is halfway between its two stops' inks, and a Perceptual curve uses the ink mixes a DeviceN shading uses. Dither's noise is added per ink and adds no ink where the gradient has none. The Output settings convert the inks as they do for any CMYK image. A gradient with an RGB, gray or Lab stop, a stop linked to a spot swatch, or a Perceptual curve of more than 4,096 stops is still written as an RGB image. --- crates/color/src/gradient.rs | 86 ++++++++++++++++++++++---- crates/color/src/gradient/tests.rs | 93 ++++++++++++++++++++++++++++ crates/pdf/src/export/paint.rs | 41 +++++++++---- crates/pdf/src/tests/cmykimages.rs | 99 +++++++++++++++++++++++++++++- 4 files changed, 296 insertions(+), 23 deletions(-) diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 8e947c143..03d0f5670 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -456,6 +456,41 @@ impl Gradient { } Some(out) } + /// A gradient whose stops are all CMYK as a ramp of C, M, Y, K and opacity: the stops' own + /// inks, an off-center midpoint halfway between its two stops' inks, and a Perceptual + /// gradient's curve as [`Self::ink_mixes`] mixes the inks. `None` when a stop is in another + /// model, there are no stops, or [`Self::ink_mixes`] gives none for the curve. + fn ink_ramp(&self) -> Option> { + let inks: Vec<[f32; 4]> = self + .stops + .iter() + .map(|s| match s.color { + Color::Cmyk { c, m, y, k } => Some([c, m, y, k]), + _ => None, + }) + .collect::>()?; + if inks.is_empty() { + return None; + } + let entry = |i: usize, w: f32, o: f32| { + let (a, b) = (inks[i], inks.get(i + 1).copied().unwrap_or(inks[i])); + let [c, m, y, k]: [f32; 4] = std::array::from_fn(|n| a[n] + (b[n] - a[n]) * w); + [c, m, y, k, o] + }; + if !self.method.is_classic() { + return Some(Ramp { stops: self.ink_mixes()?.into_iter().map(|(t, i, w, o)| (t, entry(i, w, o))).collect() }); + } + let mut stops = Vec::with_capacity(inks.len()); + for (i, s) in self.stops.iter().enumerate() { + stops.push((s.offset, entry(i, 0.0, s.opacity))); + if let Some(n) = self.stops.get(i + 1) + && (s.midpoint - 0.5).abs() > 1e-3 + { + stops.push((s.offset + (n.offset - s.offset) * s.midpoint, entry(i, 0.5, (s.opacity + n.opacity) / 2.0))); + } + } + Some(Ramp { stops }) + } /// The same ramp the other way round: the stops in reverse order at mirrored offsets, each /// segment's midpoint mirrored with it. pub fn reverse(&mut self) { @@ -895,19 +930,45 @@ impl GradientPaint { fill: bool, rgb: &dyn Fn(&Color) -> [f32; 3], ) -> impl Iterator + use<> { + self.grid(Ramp::new(&self.gradient, rgb), bounds, area, cols, rows, cells, fill, false) + } + /// [`Self::pixels`] of a linear or radial gradient whose stops are all CMYK, as C, M, Y, K + /// and opacity levels mixed from the stops' inks; `None` when a stop is in another model, + /// there are no stops, or a Perceptual curve has too many stops for [`Gradient::ink_mixes`]. + /// Dither's noise adds no ink where the ramp has none. + pub fn ink_pixels(&self, bounds: Rect, area: Rect, cols: u16, rows: u16, fill: bool) -> Option + use<>> { + let ramp = self.gradient.ink_ramp()?; + Some(self.grid(ramp, bounds, area, cols, rows, [0..cols, 0..rows], fill, true)) + } + /// `ramp` sampled at each cell's center in columns `cells[0]` and rows `cells[1]` of the grid, + /// its last channel (opacity) rounded and each other channel with dither's noise, except + /// (`inks`) where that channel is empty. + #[allow(clippy::too_many_arguments)] + fn grid( + &self, + ramp: Ramp, + bounds: Rect, + area: Rect, + cols: u16, + rows: u16, + cells: [std::ops::Range; 2], + fill: bool, + inks: bool, + ) -> impl Iterator + use { let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), fill && self.gradient.dither); - let ramp = Ramp::new(&self.gradient, rgb); let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1))); let (x0, y0) = (area.x0, area.y0); let [across, down] = cells.map(|r| u32::from(r.start)..u32::from(r.end)); down.flat_map(move |j| across.clone().map(move |i| (i, j))).map(move |(i, j)| { let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch); let c = ramp.at(geom.param_at(kind, p) as f32); - let level = |k: usize| { - let noise = if dither { dither_noise(i, j, k as u32) } else { 0.0 }; + std::array::from_fn(|k| { + if k + 1 == N { + return (c[k].clamp(0.0, 1.0) * 255.0).round() as u8; + } + let noise = if dither && !(inks && c[k] <= 0.0) { dither_noise(i, j, k as u32) } else { 0.0 }; (c[k].clamp(0.0, 1.0) * 255.0 + noise).round().clamp(0.0, 255.0) as u8 - }; - [level(0), level(1), level(2), (c[3].clamp(0.0, 1.0) * 255.0).round() as u8] + }) }) } } @@ -916,9 +977,9 @@ impl GradientPaint { /// (display RGB, or an ink's coverage): its stops, midpoints and a Perceptual gradient's /// in-between colours as stops (as [`Gradient::expanded_stops`]), each turned into that space /// once, with colour and opacity mixed linearly between them, each on its own (as the renderer's -/// vector gradients mix them). -pub struct Ramp { - stops: Vec<(f32, [f32; 4])>, +/// vector gradients mix them). `N` counts the channels, with opacity last. +pub struct Ramp { + stops: Vec<(f32, [f32; N])>, } impl Ramp { @@ -932,10 +993,13 @@ impl Ramp { .collect(); Self { stops } } +} + +impl Ramp { /// Unpremultiplied colour and opacity at `t`, padded past the ends (transparent black /// without stops). - pub fn at(&self, t: f32) -> [f32; 4] { - let Some(&(t0, first)) = self.stops.first() else { return [0.0; 4] }; + pub fn at(&self, t: f32) -> [f32; N] { + let Some(&(t0, first)) = self.stops.first() else { return [0.0; N] }; // Not after the first stop (or not a number): its colour. if t.partial_cmp(&t0) != Some(std::cmp::Ordering::Greater) { return first; @@ -947,7 +1011,7 @@ impl Ramp { return std::array::from_fn(|k| ca[k] + (cb[k] - ca[k]) * u); } } - self.stops.last().map_or([0.0; 4], |s| s.1) + self.stops.last().map_or([0.0; N], |s| s.1) } } diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 1005b8430..87372a1c2 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -604,3 +604,96 @@ fn ink_mixes_use_the_mix_position_when_the_lightness_turns_back() { } assert!(mixes.windows(2).filter(|p| p[1].1 == 0).all(|p| p[0].2 <= p[1].2), "the weights rise: {mixes:?}"); } + +fn cmyk_stop(offset: f32, c: f32, m: f32, y: f32, k: f32) -> GradientStop { + GradientStop::new(offset, Color::cmyk(c, m, y, k)) +} + +/// The `n` × 1 grid of `g` as a left-to-right linear gradient's ink pixels. +fn ink_row(g: Gradient, n: u16, fill: bool) -> Vec<[u8; 5]> { + let b = Rect::new(0.0, 0.0, f64::from(n), 1.0); + GradientPaint::new(g).ink_pixels(b, b, n, 1, fill).expect("CMYK stops").collect() +} + +#[test] +fn ink_pixels_mix_the_stops_inks_in_cmyk() { + let mut g = Gradient::new(GradientKind::Linear, vec![cmyk_stop(0.0, 1.0, 0.0, 0.0, 0.0), cmyk_stop(1.0, 0.0, 0.0, 0.0, 1.0)]); + g.stops[1].opacity = 0.5; + let px = ink_row(g.clone(), 10, true); + let at = |x: f32| (x * 255.0).round() as u8; + for (i, p) in px.iter().enumerate() { + let t = (i as f32 + 0.5) / 10.0; + assert_eq!(*p, [at(1.0 - t), 0, 0, at(t), at(1.0 - t * 0.5)], "pixel {i}"); + } + // An off-center midpoint is halfway between the two stops' inks, not a display RGB mix. + g.stops[0].midpoint = 0.25; + let px = ink_row(g.clone(), 4, false); + assert_eq!(&px[0][..4], &[at(0.75), 0, 0, at(0.25)], "halfway to the midpoint"); + assert_eq!(&px[1][..4], &[at(0.5 * 5.0 / 6.0), 0, 0, at(0.5 + 0.5 / 6.0)], "a sixth of the way past it"); + // A hard stop keeps both sides. + let hard = Gradient::new( + GradientKind::Linear, + vec![ + cmyk_stop(0.0, 1.0, 0.0, 0.0, 0.0), + cmyk_stop(0.5, 1.0, 0.0, 0.0, 0.0), + cmyk_stop(0.5, 0.0, 1.0, 0.0, 0.0), + cmyk_stop(1.0, 0.0, 1.0, 0.0, 0.0), + ], + ); + let px = ink_row(hard, 4, false); + assert_eq!((&px[1][..4], &px[2][..4]), (&[255, 0, 0, 0][..], &[0, 255, 0, 0][..])); +} + +#[test] +fn ink_pixels_dither_only_the_inks_the_ramp_has() { + let mut g = Gradient::new(GradientKind::Linear, vec![cmyk_stop(0.0, 0.2, 0.0, 0.0, 0.4), cmyk_stop(1.0, 0.8, 0.0, 0.0, 0.4)]); + g.dither = true; + let noisy = ink_row(g.clone(), 64, true); + let plain = ink_row(g.clone(), 64, false); + assert_ne!(noisy, plain, "a fill has noise, a stroke none"); + for (n, p) in noisy.iter().zip(&plain) { + assert_eq!((n[1], n[2]), (0, 0), "no ink on the empty plates: {n:?}"); + assert!(n[0].abs_diff(p[0]) <= 1 && n[3].abs_diff(p[3]) <= 1, "{n:?} {p:?}"); + assert_eq!(n[4], 255, "opacity has no noise"); + } + assert!(noisy.iter().zip(&plain).any(|(n, p)| n[3] != p[3]), "K gets its own noise"); + assert!(noisy.iter().zip(&plain).any(|(n, p)| (n[0] != p[0]) != (n[3] != p[3])), "the noise differs from channel to channel"); +} + +#[test] +fn ink_pixels_follow_the_perceptual_curve_as_ink_mixes() { + let mut g = Gradient::new(GradientKind::Linear, vec![cmyk_stop(0.0, 0.0, 0.0, 0.0, 0.0), cmyk_stop(1.0, 1.0, 0.0, 0.0, 0.0)]); + g.method = GradientMethod::Perceptual; + let mixes = g.ink_mixes().unwrap(); + let ramp = g.ink_ramp().unwrap(); + assert_eq!(ramp.stops.len(), mixes.len()); + for ((t, ink), (o, i, w, _)) in ramp.stops.iter().zip(&mixes) { + assert_eq!((*t, ink[0]), (*o, if *i == 0 { *w } else { 1.0 }), "cyan at {t}"); + } + let px = ink_row(g, 2, false); + assert!(px[0][0] > 0 && px[0][0] < px[1][0], "{px:?}"); + assert!(px.iter().all(|p| p[1] == 0 && p[2] == 0 && p[3] == 0), "{px:?}"); +} + +#[test] +fn ink_pixels_need_every_stop_in_cmyk() { + let b = Rect::new(0.0, 0.0, 2.0, 1.0); + let with = |stop: GradientStop| GradientPaint::new(Gradient::new(GradientKind::Linear, vec![cmyk_stop(0.0, 1.0, 0.0, 0.0, 0.0), stop])); + assert!(with(cmyk_stop(1.0, 0.0, 0.0, 0.0, 1.0)).ink_pixels(b, b, 2, 1, true).is_some()); + for other in [Color::rgb(1.0, 0.0, 0.0), Color::gray(0.5), Color::lab(50.0, 10.0, 10.0)] { + assert!(with(GradientStop::new(1.0, other)).ink_pixels(b, b, 2, 1, true).is_none(), "{other:?}"); + } + assert!(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![])).ink_pixels(b, b, 2, 1, true).is_none(), "no stops"); +} + +/// A Perceptual curve over the limit of [`Gradient::ink_mixes`] has no inks; a Classic gradient of as many stops does. +#[test] +fn ink_pixels_stop_at_the_ink_mixes_limit() { + let stops: Vec<_> = (0..=MAX_INK_TINTS).map(|i| cmyk_stop(i as f32 / MAX_INK_TINTS as f32, (i % 2) as f32, 0.0, 0.0, 0.0)).collect(); + let classic = Gradient::new(GradientKind::Linear, stops); + let perceptual = Gradient { method: GradientMethod::Perceptual, ..classic.clone() }; + assert!(!perceptual.fits_ink_tints()); + let b = Rect::new(0.0, 0.0, 2.0, 1.0); + assert!(GradientPaint::new(perceptual).ink_pixels(b, b, 2, 1, false).is_none()); + assert!(GradientPaint::new(classic).ink_pixels(b, b, 2, 1, false).is_some()); +} diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 17a3a10bf..4750bef85 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -435,20 +435,27 @@ impl Exporter<'_> { /// Warn when gradient `g`, drawn as an image, has stops linked to a spot swatch: the image is /// in process colors, so the spot inks have no plate of their own. fn warn_spot_image(&mut self, g: &vectorcraft_color::GradientPaint) { - let doc = self.doc; let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points(); - if from_stops && g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) { + if from_stops && self.has_spot_stop(&g.gradient) { self.warn( "spot colors in gradients exported as images (freeform or dithered gradients, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors", ); } } + /// Is a stop of `g` linked to a spot swatch? + fn has_spot_stop(&self, g: &vectorcraft_color::Gradient) -> bool { + let doc = self.doc; + g.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) + } + /// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's /// raster effects resolution where it is drawn (`area` as the current transform sizes it, so /// scaled type gets its pixels too): a freeform gradient's colour field (at most /// [`MAX_FIELD_PX`] a side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side), - /// with dither's noise when it paints a fill (`fill`). + /// with dither's noise when it paints a fill (`fill`). A linear or radial gradient whose stops + /// are all CMYK and not linked to a spot swatch is a CMYK image, unless its Perceptual curve + /// has too many stops; any other is an RGB image. pub(super) fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect, fill: bool) { use vectorcraft_color::cms::Model; use vectorcraft_color::freeform::{painted_box, spread_scale}; @@ -469,24 +476,36 @@ impl Exporter<'_> { let (cols, rows) = (along(w), along(h)); let field = freeform.then(|| g.freeform_on(b).model_field(spread_scale(b))); let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; - let img = match &field { - Some(field) if field.model() == Some(Model::Cmyk) || (cmyk_doc && field.model() == Some(Model::Gray)) => { + if !freeform { + self.warn("linear and radial gradients drawn as pixels are exported as images"); + } + let ramp_inks = if freeform || self.has_spot_stop(&g.gradient) { None } else { g.ink_pixels(b, area, cols, rows, fill) }; + let ink_image = |inks, alpha| { + let convert = if self.convert { self.out.cmyk_pixels() } else { None }; + crate::images::from_inks(inks, alpha, cols.into(), rows.into(), self.interpolate, convert) + }; + let img = match (&field, ramp_inks) { + (Some(field), _) if field.model() == Some(Model::Cmyk) || (cmyk_doc && field.model() == Some(Model::Gray)) => { let (mut inks, mut alpha) = (Vec::new(), Vec::new()); for (color, a) in field.grid(area, cols, rows) { let [c, m, y, k] = color.to_cmyk(); inks.extend([q(c), q(m), q(y), q(k)]); alpha.push(q(a)); } - let convert = if self.convert { self.out.cmyk_pixels() } else { None }; - crate::images::from_inks(inks, alpha, cols.into(), rows.into(), self.interpolate, convert) + ink_image(inks, alpha) + } + (_, Some(pixels)) => { + let (mut inks, mut alpha) = (Vec::new(), Vec::new()); + for [c, m, y, k, a] in pixels { + inks.extend([c, m, y, k]); + alpha.push(a); + } + ink_image(inks, alpha) } _ => { let rgba: Vec = match &field { Some(field) => field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect(), - None => { - self.warn("linear and radial gradients drawn as pixels are exported as images"); - g.pixels(b, area, cols, rows, fill, &|c| c.to_rgb()).flatten().collect() - } + None => g.pixels(b, area, cols, rows, fill, &|c| c.to_rgb()).flatten().collect(), }; let pixels = if self.convert { self.out.pixels() } else { None }; crate::images::from_rgba_in(rgba, cols.into(), rows.into(), self.interpolate, pixels) diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 5a38a8201..6abe2f388 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -8,7 +8,7 @@ use craft_pdf::syntax::Pdf; use craft_pdf::syntax::object::{Array, Name, Object, Stream}; use serde_json::json; use vectorcraft_color::cms::{self, DEVICE_CMYK, GENERIC_CMYK, SRGB}; -use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint}; +use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint, Swatch}; use vectorcraft_doc::cmyk::Inks; use vectorcraft_doc::{Appearance, ColorMode, Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::{Affine, Point, Rect, shapes}; @@ -514,6 +514,103 @@ fn freeform_gradients_of_cmyk_points_are_written_as_cmyk_images() { assert_ne!(w[0].space, "DeviceCMYK", "converted to RGB"); } +/// A 100 × 100 pt document of `mode` whose square is filled with `g`, at one pixel a point. +fn gradient_doc(mode: ColorMode, g: Gradient) -> Document { + let mut d = Document::new_with_mode(100.0, 100.0, mode); + d.raster_effects_ppi = 72.0; + let square = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), + Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(g))), Paint::None, 0.0), + ); + let l = d.layers[0].id; + d.insert(Some(l), 0, square).unwrap(); + d +} + +/// A gradient of `stops`, dithered or Perceptual. +fn drawn_as_pixels(kind: GradientKind, stops: Vec, dither: bool) -> Gradient { + let method = if dither { GradientMethod::Classic } else { GradientMethod::Perceptual }; + Gradient { dither, method, ..Gradient::new(kind, stops) } +} + +fn cyan_to_black() -> Vec { + vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 1.0))] +} + +#[test] +fn dithered_and_perceptual_gradients_of_cmyk_stops_are_written_as_cmyk_images() { + for mode in [ColorMode::Cmyk, ColorMode::Rgb] { + for kind in [GradientKind::Linear, GradientKind::Radial] { + for dither in [true, false] { + let d = gradient_doc(mode, drawn_as_pixels(kind, cyan_to_black(), dither)); + let out = export_with_report(&d, &opts(json!({}))).unwrap(); + let w = written(&out.bytes); + assert_eq!(w.len(), 1, "{mode:?} {kind:?} dither {dither}: opaque, no soft mask: {w:?}"); + assert_eq!(w[0].space, "DeviceCMYK", "{mode:?} {kind:?} dither {dither}"); + let back = import(&out.bytes).unwrap(); + let inks = all_images(&back)[0].cmyk().unwrap(); + assert_eq!((inks.width, inks.height), (100, 100)); + assert!(inks.data.chunks(4).all(|p| p[1] == 0 && p[2] == 0), "{mode:?} {kind:?} dither {dither}: only cyan and black"); + if kind == GradientKind::Linear { + let row = &inks.data[50 * 400..51 * 400]; + assert!(close(&row[..4], &[254, 0, 0, 1], 3), "{mode:?} dither {dither}: {:?}", &row[..4]); + assert!(close(&row[396..], &[1, 0, 0, 254], 3), "{mode:?} dither {dither}: {:?}", &row[396..]); + if dither { + assert!(close(&row[50 * 4..50 * 4 + 4], &[127, 0, 0, 128], 3), "{mode:?}: {:?}", &row[50 * 4..50 * 4 + 4]); + } + } + } + } + } + // Opacity goes in a soft mask, and the inks stay. + let mut stops = cyan_to_black(); + stops[1].opacity = 0.5; + let w = written( + &export_with_report(&gradient_doc(ColorMode::Cmyk, drawn_as_pixels(GradientKind::Linear, stops, true)), &opts(json!({}))).unwrap().bytes, + ); + assert_eq!(w.len(), 2, "the image and its soft mask: {w:?}"); + assert!(w.iter().any(|i| i.space == "DeviceCMYK") && w.iter().any(|i| i.space == "DeviceGray"), "{w:?}"); + // The Output settings convert the inks like any CMYK image's. + let d = gradient_doc(ColorMode::Cmyk, drawn_as_pixels(GradientKind::Linear, cyan_to_black(), true)); + let w = written(&export_with_report(&d, &opts(json!({"output": {"conversion": "destination", "destination": SRGB}}))).unwrap().bytes); + assert_eq!(w.len(), 1, "{w:?}"); + assert_ne!(w[0].space, "DeviceCMYK", "converted to RGB"); +} + +/// A stop in another model, or linked to a spot swatch, keeps the RGB image. +#[test] +fn gradients_with_a_stop_that_is_not_process_cmyk_stay_rgb_images() { + let cyan = || GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)); + let others = [Color::rgb(1.0, 0.0, 0.0), Color::gray(0.5), Color::lab(50.0, 10.0, 10.0)]; + for mode in [ColorMode::Cmyk, ColorMode::Rgb] { + for other in others { + for dither in [true, false] { + let d = gradient_doc(mode, drawn_as_pixels(GradientKind::Linear, vec![cyan(), GradientStop::new(1.0, other)], dither)); + let w = written(&export_with_report(&d, &opts(json!({}))).unwrap().bytes); + assert_eq!(w.len(), 1, "{mode:?} {other:?}: {w:?}"); + assert_eq!(w[0].space, "DeviceRGB", "{mode:?} {other:?} dither {dither}"); + } + } + } + // A stop of a spot swatch whose color is CMYK: a dithered fill is an RGB image with a warning. + let gold = Color::cmyk(0.0, 0.2, 0.8, 0.1); + let mut stop = GradientStop::new(1.0, gold); + stop.swatch = Some("Gold".into()); + let mut d = gradient_doc(ColorMode::Cmyk, drawn_as_pixels(GradientKind::Linear, vec![cyan(), stop.clone()], true)); + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(gold), global: true, spot: true }); + let out = export_with_report(&d, &opts(json!({}))).unwrap(); + let w = written(&out.bytes); + assert_eq!(w.len(), 1, "{w:?}"); + assert_eq!(w[0].space, "DeviceRGB"); + assert!(out.warnings.iter().any(|m| m.contains("written in process colors")), "{:?}", out.warnings); + // A stop of a global process swatch is written as its CMYK color. + let mut d = gradient_doc(ColorMode::Cmyk, drawn_as_pixels(GradientKind::Linear, vec![cyan(), stop], true)); + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(gold), global: true, spot: false }); + let w = written(&export_with_report(&d, &opts(json!({}))).unwrap().bytes); + assert_eq!(w[0].space, "DeviceCMYK", "{w:?}"); +} + #[test] fn freeform_gradients_of_lab_points_are_written_as_their_lab_mix() { let d = freeform_doc(ColorMode::Rgb, Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0), 1.0);