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);