diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs index 666d317fe..61bc3a4d2 100644 --- a/crates/engine/src/tests/linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -140,6 +140,35 @@ fn spot_gradients_export_as_separation_shadings() { assert!(warnings.iter().any(|w| w.contains("spot")), "{warnings:?}"); } +#[test] +fn white_stops_linked_to_process_swatches_are_paper_in_spot_gradients() { + let (mut s, _) = setup(); + run(&mut s, "swatch.new", json!({"name": "Paper", "color": "#ffffff", "global": true})); + let ink_to_paper = json!({"stops": [{"offset": 0, "swatch": "Ink", "tint": 80}, {"offset": 1, "swatch": "Paper"}]}); + // As Ink → unlinked white: a Separation shading from 80 % to 0 %. + run(&mut s, "paint.editGradient", ink_to_paper.clone()); + let (pdf, warnings) = pdf_of(&s); + assert!(pdf.contains("/ShadingType 2") && pdf.contains("/Separation/Ink/DeviceCMYK"), "a Separation shading"); + assert!(!pdf.contains("/DeviceN") && pdf.contains("/C0[0.8]/C1[0]"), "the shading's tints"); + assert!(!warnings.iter().any(|w| w.contains("spot")), "{warnings:?}"); + // A Perceptual one too. + run(&mut s, "paint.editGradient", with(ink_to_paper, json!({"method": "perceptual"}))); + let (pdf, _) = pdf_of(&s); + assert!(!pdf.contains("/Subtype/Image") && !pdf.contains("/DeviceN") && pdf.contains("/Separation/Ink/DeviceCMYK"), "Perceptual"); + // With process yellow, the linked white stop adds no ink: Yellow and Ink only. + let yellow = json!({"c": 0, "m": 0, "y": 0.6, "k": 0}); + let three = + json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 0.5, "swatch": "Paper"}, {"offset": 1, "color": yellow}], "method": "classic"}); + run(&mut s, "paint.editGradient", three); + let (pdf, _) = pdf_of(&s); + assert!(pdf.contains("/DeviceN[/Yellow/Ink]/DeviceCMYK") && pdf.contains("/C0[0 1]/C1[0 0]"), "Yellow and Ink"); + // A white spot ink is an ink, not paper: a DeviceN shading of both spot inks. + run(&mut s, "swatch.new", json!({"name": "Opaque", "color": "#ffffff", "spot": true})); + run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "swatch": "Opaque"}]})); + let (pdf, _) = pdf_of(&s); + assert!(pdf.contains("/DeviceN[/Ink/Opaque]/DeviceCMYK"), "a white spot ink keeps its plate"); +} + #[test] fn spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() { let (mut s, _) = setup(); diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 665efe272..b68066ef6 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -69,13 +69,12 @@ impl Exporter<'_> { /// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink. fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?; - let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3]; let tints: Option> = g .stops .iter() .map(|s| match s.swatch.as_deref() { Some(n) if n == ink => Some(s.tint), - None if paper(&s.color) => Some(0.0), + _ if self.paper_stop(s) => Some(0.0), _ => None, }) .collect(); @@ -106,7 +105,6 @@ impl Exporter<'_> { /// the gradient is then written in the colors of its stops. fn device_n_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { use krilla::color::device_n::{Color as DnColor, Colorant, DeviceNSpace, Process}; - let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3]; let doc = self.doc; fn spot_of<'s>(doc: &vectorcraft_doc::Document, s: &'s vectorcraft_color::GradientStop) -> Option<&'s str> { s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot)) @@ -128,7 +126,7 @@ impl Exporter<'_> { let i = spots.iter().position(|(m, _)| m == n)?; v[4 + i] = s.tint; } - None if s.swatch.is_none() && paper(&s.color) => {} + None if self.paper_stop(s) => {} None => match self.page_color(&s.color) { Color::Cmyk { c, m, y, k } => v[..4].copy_from_slice(&[c, m, y, k]), Color::Gray { k } => v[3] = k, @@ -348,25 +346,30 @@ impl Exporter<'_> { Some(krilla::paint::Pattern { stream: sb.finish(), transform: xf(at), width: pw as f32, height: ph as f32 }.into()) } - /// The spot swatch whose tints are every stop of `g` that isn't paper white, unlinked; `None` - /// when no stop is linked to a spot swatch or a stop has another color. + /// Is stop `s` paper white: white, unlinked or linked to a swatch that isn't a spot color (a + /// white spot color is an ink of its own)? It adds no ink to a Separation or DeviceN shading. + fn paper_stop(&self, s: &vectorcraft_color::GradientStop) -> bool { + !s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot)) && s.color.to_rgba8(1.0)[..3] == [255; 3] + } + + /// The spot swatch whose tints are every stop of `g` that isn't paper white + /// ([`Self::paper_stop`]); `None` when no stop is linked to a spot swatch or a stop has + /// another color. fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> { let spot = |s: &'g vectorcraft_color::GradientStop| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)); let ink = g.stops.iter().find_map(spot)?; - let paper = |s: &vectorcraft_color::GradientStop| s.swatch.is_none() && s.color.to_rgba8(1.0)[..3] == [255; 3]; - g.stops.iter().all(|s| spot(s) == Some(ink) || paper(s)).then_some(ink) + g.stops.iter().all(|s| spot(s) == Some(ink) || self.paper_stop(s)).then_some(ink) } /// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): is a stop - /// linked to a spot swatch, and is every other stop paper white, unlinked, or written in CMYK - /// or gray? + /// linked to a spot swatch, and is every other stop paper white ([`Self::paper_stop`]) or + /// written in CMYK or gray? fn device_n_inks_only(&self, g: &vectorcraft_color::Gradient) -> bool { let doc = self.doc; let cmyk_doc = doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; let spot = |s: &vectorcraft_color::GradientStop| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot)); let inks = |s: &vectorcraft_color::GradientStop| { - (s.swatch.is_none() && s.color.to_rgba8(1.0)[..3] == [255; 3]) - || matches!(self.out.color(&s.color, self.intent, cmyk_doc, self.convert), Color::Cmyk { .. } | Color::Gray { .. }) + self.paper_stop(s) || matches!(self.out.color(&s.color, self.intent, cmyk_doc, self.convert), Color::Cmyk { .. } | Color::Gray { .. }) }; g.stops.iter().any(spot) && g.stops.iter().all(|s| spot(s) || inks(s)) } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 40e1d1267..3fc42861d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1280,6 +1280,8 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html): "Gradient colors can be assigned as CMYK process colors, RGB colors, or a Spot color."; [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html): "a separate plate is created for each spot color"). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). A Perceptual gradient that mixes spot colors with each other or with CMYK or gray stops is written as a DeviceN shading of its inks: at each stop on the curve, the inks of the two stops on either side are mixed at the weight where the two colors mixed in display RGB have the OKLab lightness of the color there (VectorCraft's own rule). Dithered fills, freeform gradients and Perceptual gradients that mix a spot color with RGB or Lab stops are still images, and the export warns that their spot colors are written in process colors. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve` and `ink_mixes_follow_the_perceptual_curve`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings`, `perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings` and `spot_gradients_drawn_as_images_warn`. +In these Separation and DeviceN shadings, a white stop linked to a swatch that is not a spot color (a global White swatch, for example) is paper white, 0% of the ink, as an unlinked white stop is, so a gradient of one spot ink and such a stop stays a Separation shading; a stop linked to a white spot color stays a stop of that ink (VectorCraft's own rule). Test: `engine::tests::linked_stops::white_stops_linked_to_process_swatches_are_paper_in_spot_gradients`. + ## Artwork Cleanup add-on (2026-10-30) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Artwork Cleanup and its