From 69ff3fc00e88cda78d2ed717ba5d88a0a00daf70 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 20:05:46 -0700 Subject: [PATCH 1/2] Part of #3235: PDF import keeps the spot swatch of a DeviceN color that mixes inks A DeviceN color with a spot ink at a tint above 0 together with a process plate or a second ink imports as RGB. The importer returned before marking those inks as used, and only used inks become spot swatches, so the spot ink was missing from the document and from every later export. Shading stops take the same path, so a DeviceN gradient lost its ink the same way. The art still imports as RGB with the existing warning; each ink it paints with now becomes a spot swatch with its 100% color. --- crates/pdf/src/import_color.rs | 10 ++++++++++ crates/pdf/src/tests/import_color.rs | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/crates/pdf/src/import_color.rs b/crates/pdf/src/import_color.rs index ac5b1580e..ff63f9493 100644 --- a/crates/pdf/src/import_color.rs +++ b/crates/pdf/src/import_color.rs @@ -301,6 +301,16 @@ impl<'p> Colors<'p> { let process = cmyk.iter().any(|v| *v > 0.0); let (c, t) = match (ink, first_ink) { (Some(_), _) if several || process => { + // The color can't link to one swatch, but each ink it paints with is still a + // spot swatch. + for (c, t) in colorants.iter().zip(comps) { + if let Colorant::Ink(k) = *c + && round3(t.clamp(0.0, 1.0)) > 0.0 + && let Some(ink) = self.inks.get_mut(k) + { + ink.used = true; + } + } self.mixed = true; return None; } diff --git a/crates/pdf/src/tests/import_color.rs b/crates/pdf/src/tests/import_color.rs index 8b98f7703..e2a7283df 100644 --- a/crates/pdf/src/tests/import_color.rs +++ b/crates/pdf/src/tests/import_color.rs @@ -164,6 +164,29 @@ fn device_n_inks_become_spot_swatches_and_process_plates_cmyk() { assert_eq!(d.swatches.iter().filter(|s| s.spot).count(), 2); } +#[test] +fn a_spot_ink_mixed_with_a_process_plate_keeps_its_swatch() { + // Gold and Cyan → CMYK (cyan, 0, gold, 0): a fill, then an axial shading of Gold 0 to 1 over + // Cyan at 0.3. + let space = format!("[/DeviceN [/Gold /Cyan] /DeviceCMYK {} 0 R]", first_extra(1)); + let func = "<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1 0 1] /Length 17 >>\nstream\n{ exch 0 exch 0 }\nendstream"; + let shading = format!( + "<< /ShadingType 2 /ColorSpace {space} /Coords [0 0 50 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0.3] /C1 [1 0.3] /N 1 >> /Extend [true true] >>" + ); + let fill = page_with(&format!("/CS0 {space}"), &[("CS0", "0.5 0.3")]); + let sh = PdfPage { resources: format!("/Shading << /Sh0 {shading} >>"), ..PdfPage::new(100.0, 50.0, "q 0 0 50 50 re W n /Sh0 sh Q") }; + for (what, page) in [("fill", fill), ("shading", sh)] { + let r = import_with_report(&pdf_with(&[page], &[func], None), &ImportOptions::default()).unwrap(); + let d = &r.document; + assert!(r.warnings.iter().any(|w| w.contains("several inks")), "{what}: {:?}", r.warnings); + assert!(links(d).iter().all(Option::is_none), "{what}: the art stays RGB"); + let gold = d.swatch("Gold").unwrap_or_else(|| panic!("{what}: no Gold swatch")); + assert!(gold.spot && gold.global, "{what}: Gold is a spot swatch"); + assert!(near(gold.paint.color().unwrap(), Color::cmyk(0.0, 0.0, 1.0, 0.0)), "{what}: {gold:?}"); + assert_eq!(d.swatches.iter().filter(|s| s.spot).count(), 1, "{what}: Cyan is a process plate"); + } +} + /// The spaces `/CS1` and `/CS2` of two inks (`[/{kind} {a} …]`, `[/{kind} {b} …]`) with the same /// alternate and tint transform, blue at 100 %: they look the same to the interpreter. fn blues(kind: &str, a: &str, b: &str) -> String { From ef8d178441a061490d540df4bcd99e1d86b3bb64 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 22:14:56 -0700 Subject: [PATCH 2/2] Part of #3235: PDF import keeps the spot swatch of an ink a shading paints only between its stops An axial or radial shading's stops are the samples that linear interpolation does not reproduce. An ink that changes nothing in RGB, such as a varnish, could paint at dropped samples only, and its spot swatch was then missing. Every sample now marks the inks it paints with as used; the stops are unchanged. --- crates/pdf/src/import_color.rs | 27 +++++++++++++++++++-------- crates/pdf/src/import_shading.rs | 4 ++++ crates/pdf/src/tests/import_color.rs | 19 +++++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/crates/pdf/src/import_color.rs b/crates/pdf/src/import_color.rs index ff63f9493..2362634ed 100644 --- a/crates/pdf/src/import_color.rs +++ b/crates/pdf/src/import_color.rs @@ -303,14 +303,7 @@ impl<'p> Colors<'p> { (Some(_), _) if several || process => { // The color can't link to one swatch, but each ink it paints with is still a // spot swatch. - for (c, t) in colorants.iter().zip(comps) { - if let Colorant::Ink(k) = *c - && round3(t.clamp(0.0, 1.0)) > 0.0 - && let Some(ink) = self.inks.get_mut(k) - { - ink.used = true; - } - } + self.mark_inks(Space::Inks(space), comps); self.mixed = true; return None; } @@ -330,6 +323,24 @@ impl<'p> Colors<'p> { } } + /// Mark each ink that components `comps` of `space` paint with (a tint above 0) as used, so it + /// becomes a spot swatch; nothing for a space with a colorant that can't be read. + pub fn mark_inks(&mut self, space: Space, comps: &[f32]) { + let Space::Inks(i) = space else { return }; + let Some(s) = self.spaces.get(i) else { return }; + if s.colorants.iter().any(|c| matches!(c, Colorant::Unknown)) { + return; + } + for (c, t) in s.colorants.iter().zip(comps) { + if let Colorant::Ink(k) = *c + && round3(t.clamp(0.0, 1.0)) > 0.0 + && let Some(ink) = self.inks.get_mut(k) + { + ink.used = true; + } + } + } + /// Find the file's Separation and DeviceN colour spaces (once, when a colour first needs them). fn scan(&mut self) { if self.scanned { diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs index ed169d724..1d2bc9cca 100644 --- a/crates/pdf/src/import_shading.rs +++ b/crates/pdf/src/import_shading.rs @@ -45,6 +45,10 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, ink for i in 0..=N { let t = i as f32 / N as f32; pts.push((t, sample(t)?.to_rgb())); + // An ink that paints only at samples dropped below is still a spot swatch. + if let Some(v) = at(t) { + colors.mark_inks(space, &v); + } } let ts: Vec = linear_keep(&pts).iter().filter_map(|&k| pts.get(k)).map(|p| p.0).collect(); let mut stop_at = |t: f32, offset: f32| -> GradientStop { diff --git a/crates/pdf/src/tests/import_color.rs b/crates/pdf/src/tests/import_color.rs index e2a7283df..e26697858 100644 --- a/crates/pdf/src/tests/import_color.rs +++ b/crates/pdf/src/tests/import_color.rs @@ -187,6 +187,25 @@ fn a_spot_ink_mixed_with_a_process_plate_keeps_its_swatch() { } } +#[test] +fn a_spot_ink_only_between_a_shadings_stops_keeps_its_swatch() { + // Varnish and Cyan → CMYK (cyan, 0, 0, 0): Varnish draws nothing, so the shading's RGB is one + // colour and only its ends (Varnish 0) become stops; Varnish rises to 1 at the middle. + let space = format!("[/DeviceN [/Varnish /Cyan] /DeviceCMYK {} 0 R]", first_extra(1)); + let func = "<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1 0 1] /Length 18 >>\nstream\n{ exch pop 0 0 0 }\nendstream"; + let half = |a: u8, b: u8| format!("<< /FunctionType 2 /Domain [0 1] /C0 [{a} 0.3] /C1 [{b} 0.3] /N 1 >>"); + let shading = format!( + "<< /ShadingType 2 /ColorSpace {space} /Coords [0 0 50 0] /Function << /FunctionType 3 /Domain [0 1] /Functions [{} {}] /Bounds [0.5] /Encode [0 1 0 1] >> /Extend [true true] >>", + half(0, 1), + half(1, 0) + ); + let page = PdfPage { resources: format!("/Shading << /Sh0 {shading} >>"), ..PdfPage::new(100.0, 50.0, "q 0 0 50 50 re W n /Sh0 sh Q") }; + let r = import_with_report(&pdf_with(&[page], &[func], None), &ImportOptions::default()).unwrap(); + let d = &r.document; + let varnish = d.swatch("Varnish").unwrap_or_else(|| panic!("no Varnish swatch: {:?}", d.swatches)); + assert!(varnish.spot && varnish.global, "{varnish:?}"); +} + /// The spaces `/CS1` and `/CS2` of two inks (`[/{kind} {a} …]`, `[/{kind} {b} …]`) with the same /// alternate and tint transform, blue at 100 %: they look the same to the interpreter. fn blues(kind: &str, a: &str, b: &str) -> String {