diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..c6160f82b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 4e7627e9e..b5a582b1f 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -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 = 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 { + 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 = 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. diff --git a/crates/pdf/src/gradient_inks.rs b/crates/pdf/src/gradient_inks.rs new file mode 100644 index 000000000..a0d682563 --- /dev/null +++ b/crates/pdf/src/gradient_inks.rs @@ -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 { + 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> { + // 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> = 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::>>() 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 = (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 = 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> = 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)> = 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 = 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> { + if has_spot { + self.warn(format!("gradient '{swatch}': spot inks printed as process colour ({why})")); + } + None + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ad1123897..b589fcc8c 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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; diff --git a/crates/pdf/tests/spot_gradients.rs b/crates/pdf/tests/spot_gradients.rs new file mode 100644 index 000000000..426e7bf1b --- /dev/null +++ b/crates/pdf/tests/spot_gradients.rs @@ -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 { + if let Some(functions) = function.get::>(b"Functions") { + let functions: Vec<_> = functions.iter::>().collect(); + return endpoint(if first { functions.first().unwrap() } else { functions.last().unwrap() }, first); + } + function.get::>(if first { b"C0" } else { b"C1" }).unwrap().iter::().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, + start: Vec, + end: Vec, +} + +fn export(d: &Document, standard: Standard) -> (Vec, Vec) { + 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::>(name.as_ref()).unwrap(); + let shading = pattern.get::>(b"Shading").unwrap(); + let text = |n: Name<'_>| String::from_utf8_lossy(n.as_ref()).into_owned(); + let (family, colorants) = match shading.get::>(b"ColorSpace") { + Some(n) => (text(n), Vec::new()), + None => { + let space = shading.get::>(b"ColorSpace").unwrap(); + let mut it = space.flex_iter(); + let family = text(it.next::>().unwrap()); + let colorants = match family.as_str() { + "Separation" => vec![text(it.next::>().unwrap())], + "DeviceN" => it.next::>().unwrap().iter::>().map(text).collect(), + _ => Vec::new(), + }; + (family, colorants) + } + }; + let function = shading.get::>(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])); +}