From 6ab46b5fd8542986c2d58eab759e10a846a09b6d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 04:46:10 -0800 Subject: [PATCH] Print: a spot gradient stop prints its process plates on the separations A gradient stop linked to a spot ink that also carries process plates (a DeviceN stop opened from a PDF) printed only its spot tint: print separations and Separations Preview left its Cyan, Magenta, Yellow and Black plates blank. They now print the stop's plates beside the tint. With spots printed as process, each process plate prints the stop's plate layered with the spot tint's process equivalent, as a separated image's spot plates layer. Plates beside no spot link, or beside Registration, still print nothing of their own. The color visitor also skipped a freeform gradient's points, so print plates kept their display colors and Document Color Mode left them unconverted. The points are now visited as unlinked colors. --- crates/doc/src/inks.rs | 113 ++++++++++++++++++++++++--- crates/engine/src/tests/colormgmt.rs | 20 +++++ crates/pdf/src/print/plates.rs | 29 ++++++- crates/pdf/src/tests/print.rs | 66 +++++++++++++++- crates/render/src/proof.rs | 10 ++- crates/render/src/tests/colorize.rs | 29 +++++++ docs/target-app-parity.md | 1 + 7 files changed, 246 insertions(+), 22 deletions(-) diff --git a/crates/doc/src/inks.rs b/crates/doc/src/inks.rs index e10c424ee9..a9fa5f3f80 100644 --- a/crates/doc/src/inks.rs +++ b/crates/doc/src/inks.rs @@ -94,25 +94,45 @@ pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) Inks { cmyk: c.to_cmyk(color, intent), spot: None, registration: false } } -/// Visits one colour: the colour, its swatch link and its tint, all of which it may change. -pub type ColorVisitor<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32) + 'a; +/// Process plates a colour prints together with its spot tint (C, M, Y, K, 0..1): a gradient +/// stop's [`vectorcraft_color::GradientStop::plates`]; `None` for every other colour. +pub type StopPlates = Option<[f32; 4]>; + +/// [`inks`] of a colour that may print process plates `plates` with its spot tint. The plates +/// print only beside a spot ink: a colour without a valid spot link, or linked to Registration, +/// prints as [`inks`] separates it. A plate amount that is not a finite number prints no ink. +pub fn stop_inks(doc: &Document, c: &Cms, color: &Color, link: Link, plates: StopPlates, intent: Intent) -> Inks { + let mut ink = inks(doc, c, color, link, intent); + if let (Some(p), Some(_)) = (plates, &ink.spot) { + ink.cmyk = p.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }); + } + ink +} + +/// Visits one colour: the colour, its swatch link, its tint and its process plates +/// ([`StopPlates`]), all of which it may change. +pub type ColorVisitor<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32, &mut StopPlates) + 'a; fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) { match p { - Paint::Solid { color, swatch, tint } => f(color, swatch, tint), + Paint::Solid { color, swatch, tint } => f(color, swatch, tint, &mut None), Paint::Gradient(g) => { for s in &mut g.gradient.stops { - f(&mut s.color, &mut s.swatch, &mut s.tint); + f(&mut s.color, &mut s.swatch, &mut s.tint, &mut s.plates); + } + for p in g.freeform.iter_mut().flat_map(|ff| ff.points.iter_mut()) { + f(&mut p.color, &mut None, &mut 1.0, &mut None); } } _ => {} } } -/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, an -/// image's colorize color, the fills and strokes a dynamic symbol instance keeps for objects of its -/// art, not opacity masks) with its swatch link and tint. Mesh points have no link: changes to it -/// are dropped. +/// Visit every colour of a node tree (fills, strokes, gradient stops, a freeform gradient's +/// points, text runs, mesh points, an image's colorize color, the fills and strokes a dynamic +/// symbol instance keeps for objects of its art, not opacity masks) with its swatch link, tint and +/// process plates. Freeform and mesh points have no link or plates: a link or plates `f` gives +/// them are dropped. pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) { for it in &mut n.appearance.items { match it { @@ -137,7 +157,7 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) { } NodeKind::Mesh(m) => { for p in &mut m.points { - f(&mut p.color, &mut None, &mut 1.0); + f(&mut p.color, &mut None, &mut 1.0, &mut None); } } _ => {} @@ -149,11 +169,16 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) { } } -/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh -/// points, a dynamic symbol instance's own fills and strokes) given its swatch link and tint, -/// keeping the links. +/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, a freeform +/// gradient's points, text runs, mesh points, a dynamic symbol instance's own fills and strokes) +/// given its swatch link and tint, keeping the links and process plates. pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Link) -> Color) { - visit_node_colors(n, &mut |c, swatch, tint| *c = f(c, swatch.as_deref().map(|s| (s, *tint)))); + map_node_inks(n, &mut |c, link, _| f(c, link)); +} + +/// [`map_node_colors`], with each colour's process plates ([`StopPlates`]) passed to `f` too. +pub fn map_node_inks(n: &mut Node, f: &mut dyn FnMut(&Color, Link, StopPlates) -> Color) { + visit_node_colors(n, &mut |c, swatch, tint, plates| *c = f(c, swatch.as_deref().map(|s| (s, *tint)), *plates)); } /// Apply `f` to every colour in the document's art and symbol definitions. @@ -170,8 +195,70 @@ pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Link) - mod tests { use super::*; use crate::{NodeId, SymbolOverride}; + use vectorcraft_color::{Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, GradientStop, Swatch}; use vectorcraft_geom::Affine; + /// A document with spot swatch `Gold` and process swatch `Teal`. + fn gold_doc() -> Document { + let mut d = Document::new(100.0, 100.0); + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true }); + d.swatches.push(Swatch { name: "Teal".into(), paint: Paint::solid(Color::cmyk(0.8, 0.0, 0.3, 0.0)), global: true, spot: false }); + d + } + + #[test] + fn a_spot_stops_process_plates_print_with_its_tint() { + let d = gold_doc(); + let c = vectorcraft_color::cms::active(); + let look = Color::cmyk(0.25, 0.1, 0.8, 0.05); + let ink = |link: Link, plates: StopPlates| stop_inks(&d, &c, &look, link, plates, Intent::RelativeColorimetric); + let mixed = ink(Some(("Gold", 0.5)), Some([0.25, 0.0, 0.4, 0.0])); + assert_eq!(mixed, Inks { cmyk: [0.25, 0.0, 0.4, 0.0], spot: Some(("Gold".into(), 0.5)), registration: false }); + assert_eq!((mixed.on_plate("Cyan"), mixed.on_plate("Gold")), (0.25, 0.5)); + // Amounts outside 0..1 are clamped; one that is not a number prints no ink. + let odd = ink(Some(("Gold", 1.0)), Some([f32::NAN, 2.0, -1.0, f32::INFINITY])); + assert_eq!(odd.cmyk, [0.0, 1.0, 0.0, 0.0]); + // Without plates the spot ink prints alone, as `inks` separates it. + assert_eq!(ink(Some(("Gold", 0.5)), None), inks(&d, &c, &look, Some(("Gold", 0.5)), Intent::RelativeColorimetric)); + // Plates beside no spot ink print nothing of their own: a process swatch, an unknown + // swatch, no link and Registration separate as `inks` does. + for link in [Some(("Teal", 1.0)), Some(("Missing", 1.0)), None, Some((REGISTRATION, 0.5))] { + assert_eq!(ink(link, Some([0.25, 0.0, 0.4, 0.0])), inks(&d, &c, &look, link, Intent::RelativeColorimetric), "{link:?}"); + } + } + + #[test] + fn gradient_stops_pass_their_plates_and_freeform_points_are_visited() { + let mut stop = GradientStop::new(0.0, Color::cmyk(0.25, 0.1, 0.8, 0.05)); + (stop.swatch, stop.tint, stop.plates) = (Some("Gold".into()), 0.5, Some([0.25, 0.0, 0.4, 0.0])); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![stop, GradientStop::new(1.0, Color::rgb(1.0, 1.0, 1.0))])); + let point = FreeformPoint::new(kurbo::Point::new(10.0, 10.0), Color::rgb(0.0, 1.0, 0.0)); + g.freeform = Some(Freeform { points: vec![point], ..Default::default() }); + let mut n = Node::path( + NodeId(1), + vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)), + crate::Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0), + ); + let mut seen = vec![]; + let gray = Color::Gray { k: 0.5 }; + map_node_inks(&mut n, &mut |c, link, plates| { + seen.push((*c, link.map(|(s, t)| (s.to_string(), t)), plates)); + gray + }); + assert_eq!( + seen, + vec![ + (Color::cmyk(0.25, 0.1, 0.8, 0.05), Some(("Gold".to_string(), 0.5)), Some([0.25, 0.0, 0.4, 0.0])), + (Color::rgb(1.0, 1.0, 1.0), None, None), + (Color::rgb(0.0, 1.0, 0.0), None, None), + ] + ); + let Some(Paint::Gradient(g)) = n.appearance.fill().map(|f| f.paint.clone()) else { panic!("no gradient fill") }; + assert_eq!(g.gradient.stops[0].plates, Some([0.25, 0.0, 0.4, 0.0]), "mapping a colour keeps its plates"); + assert!(g.gradient.stops.iter().all(|s| s.color == gray)); + assert_eq!(g.freeform.map(|f| f.points[0].color), Some(gray), "the freeform point is mapped too"); + } + #[test] fn a_dynamic_instances_own_fills_and_strokes_are_visited() { let red = Color::rgb(1.0, 0.0, 0.0); diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index 493f569805..99d099f68d 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -184,6 +184,26 @@ fn rgb_cmyk_converts_each_gradient_stop() { } } +/// Each point of a freeform gradient converts on its own, as gradient stops do. +#[test] +fn rgb_cmyk_converts_each_freeform_point() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + let id = rect(&mut s, 10.0, 10.0, 50.0, 50.0, json!("#000000")); + let stops = json!([{"offset": 0, "color": Color::rgb(0.912_345, 0.1, 0.2)}, {"offset": 1, "color": Color::rgb(0.3, 0.4, 0.654_321)}]); + let points = json!([{"at": [20, 20], "color": Color::rgb(0.2, 0.876_543, 0.3)}, {"at": [50, 50], "color": Color::rgb(0.6, 0.111_111, 0.9)}]); + let gradient = json!({"kind": "freeform", "stops": stops, "freeform": {"points": points}}); + s.execute("paint.setFill", &json!({"ids": [id.0], "gradient": gradient})).unwrap(); + let points = |s: &Session| match fill_of(s, id) { + Paint::Gradient(g) => g.freeform.map(|f| f.points.iter().map(|p| p.color).collect::>()).unwrap_or_default(), + p => panic!("not a gradient: {p:?}"), + }; + let old = points(&s); + assert!(!old.is_empty(), "the freeform fill has points"); + s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap(); + assert_eq!(points(&s), old.into_iter().map(to_cmyk).collect::>()); +} + /// A pattern's tile art converts, and the art filled with the pattern keeps it. #[test] fn rgb_cmyk_converts_a_pattern_tile() { diff --git a/crates/pdf/src/print/plates.rs b/crates/pdf/src/print/plates.rs index ce6c5b336e..8b0876d9f3 100644 --- a/crates/pdf/src/print/plates.rs +++ b/crates/pdf/src/print/plates.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use vectorcraft_color::Color; use vectorcraft_color::cms::{self, Cms, Model, ProfileKind}; use vectorcraft_color::swatch::REGISTRATION; -use vectorcraft_doc::inks::{Link, inks, plates, visit_node_colors}; +use vectorcraft_doc::inks::{Link, StopPlates, inks, plates, spot_color, stop_inks, visit_node_colors}; use vectorcraft_doc::overprint::darken_overprints; use vectorcraft_doc::{ColorMode, Document, ImageBlob, Node, NodeKind}; @@ -101,12 +101,33 @@ impl<'a> Separator<'a> { inks(self.doc, &self.cms, c, link, color.intent).on_plate(plate) } - /// Recolour `n`'s subtree as plate `plate`: the grey of each colour's coverage, unlinked. + /// [`Self::ink`] of a colour that prints process plates `plates` with its spot tint + /// ([`stop_inks`]): the spot plate prints the tint and the process plates print `plates`. With + /// spots as process, each process plate prints `plates` layered with the spot tint's process + /// equivalent, as a separated image's plates layer ([`vectorcraft_doc::separated::Separated::coverage_as`]). + fn stop_ink(&self, plate: &str, c: &Color, link: Link, plates: StopPlates) -> f32 { + let own = stop_inks(self.doc, &self.cms, c, link, plates, self.set.color.intent); + let (Some(_), Some((name, tint))) = (plates, own.spot.as_ref()) else { + return self.ink(plate, c, link); + }; + if !self.set.output.spots_to_process { + return own.on_plate(plate); + } + let Some(i) = cms::PROCESS_PLATES.iter().position(|p| *p == plate) else { + return 0.0; + }; + let spot = spot_color(self.doc, name).map_or(0.0, |s| self.ink(plate, &s.tinted(*tint), None)); + 1.0 - (1.0 - own.cmyk[i]) * (1.0 - spot) + } + + /// Recolour `n`'s subtree as plate `plate`: the grey of each colour's coverage, unlinked and + /// without process plates. pub fn node(&self, plate: &str, n: &mut Node) { - visit_node_colors(n, &mut |c, swatch, tint| { - *c = Color::Gray { k: self.ink(plate, c, swatch.as_deref().map(|s| (s, *tint))) }; + visit_node_colors(n, &mut |c, swatch, tint, plates| { + *c = Color::Gray { k: self.stop_ink(plate, c, swatch.as_deref().map(|s| (s, *tint)), *plates) }; *swatch = None; *tint = 1.0; + *plates = None; }); } diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 5c5e1eb2c0..155912c124 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::json; -use vectorcraft_color::{Color, Paint, Swatch}; +use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, GradientStop, Paint, Swatch}; use vectorcraft_doc::{Appearance, Artboard, Document, LayerColor, Node, NodeKind}; use vectorcraft_geom::{Rect, shapes}; use vectorcraft_testkit::raster::render_region; @@ -536,6 +536,70 @@ fn cmyk_and_a_spot_separate_into_five_plates() { assert_eq!(inks(&p[2]), [0.0, 40.0], "the gold's yellow"); } +/// A 100 pt CMYK document with spot swatch `Gold` and a square filled with gradient `g`. +fn gold_gradient_doc(g: GradientPaint) -> Document { + let mut d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 50.0, 50.0), Paint::Gradient(Box::new(g)))]); + d.color_mode = vectorcraft_doc::ColorMode::Cmyk; + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true }); + d +} + +/// The grey of each plate of a separation of `d` (job options `v`), in percent: of the first +/// gradient stop, and of the first freeform point when there is one. No plate keeps a link or +/// process plates. +fn gradient_plates(d: &Document, v: serde_json::Value) -> Vec<(f32, Option)> { + let opts = PrintOptions { settings: settings(v), ..Default::default() }; + let gray = |c: &Color| match c { + Color::Gray { k } => (k * 100.0).round(), + c => panic!("{c:?}"), + }; + let plan = plan(d, &opts).unwrap(); + plan.plates + .iter() + .map(|p| { + let mut found = vec![]; + p.walk(|n| { + if let Some(Paint::Gradient(g)) = n.appearance.fill().map(|f| &f.paint) { + let s = &g.gradient.stops[0]; + assert_eq!((s.swatch.as_deref(), s.tint, s.plates), (None, 1.0, None), "the stop is unlinked"); + found.push((gray(&s.color), g.freeform.as_ref().and_then(|f| f.points.first()).map(|q| gray(&q.color)))); + } + }); + assert_eq!(found.len(), 1); + found[0] + }) + .collect() +} + +#[test] +fn a_spot_gradient_stop_prints_its_process_plates() { + let mut mixed = GradientStop::new(0.0, Color::cmyk(0.25, 0.1, 0.8, 0.05)); + (mixed.swatch, mixed.tint, mixed.plates) = (Some("Gold".into()), 0.5, Some([0.25, 0.0, 0.4, 0.0])); + let g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![mixed, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))])); + let d = gold_gradient_doc(g); + let first = |v| gradient_plates(&d, v).into_iter().map(|(s, _)| s).collect::>(); + assert_eq!(first(json!({"output": {"mode": "separations"}})), [25.0, 0.0, 40.0, 0.0, 50.0], "Cyan, Magenta, Yellow, Black, Gold"); + // Spots as process: the plates layered with the gold tint's process inks (0, 10, 40, 5). + assert_eq!(first(json!({"output": {"mode": "separations", "spotsToProcess": true}})), [25.0, 10.0, 64.0, 5.0]); +} + +#[test] +fn a_freeform_gradients_points_separate_into_plates() { + let mut g = GradientPaint::new(Gradient::new(GradientKind::Freeform, vec![GradientStop::new(0.0, Color::cmyk(0.0, 0.0, 0.0, 1.0))])); + let point = FreeformPoint::new(vectorcraft_geom::Point::new(25.0, 25.0), Color::cmyk(0.0, 0.6, 0.0, 0.0)); + g.freeform = Some(Freeform { points: vec![point], ..Default::default() }); + let d = gold_gradient_doc(g); + let set = json!({"output": {"mode": "separations"}}); + let points = gradient_plates(&d, set.clone()).into_iter().map(|(_, p)| p).collect::>(); + assert_eq!(points, [Some(0.0), Some(60.0), Some(0.0), Some(0.0), Some(0.0)], "the point's magenta, on its plate only"); + // Each plate draws the square in the grey of the point's ink on it. + let plan = plan(&d, &PrintOptions { settings: settings(set), ..Default::default() }).unwrap(); + let drawn: Vec = plan.plates.iter().map(|p| render_region(p, Rect::new(0.0, 0.0, 50.0, 50.0), 1.0).over_white(25, 25)[0]).collect(); + for (plate, (v, ink)) in drawn.iter().zip([0.0, 0.6, 0.0, 0.0, 0.0]).enumerate() { + assert!((f32::from(*v) - 255.0 * (1.0 - ink)).abs() <= 3.0, "plate {plate}: {drawn:?}"); + } +} + #[test] fn trim_marks_surround_the_artboard_on_the_paper() { let d = doc(200.0, 100.0, &[(Rect::new(0.0, 0.0, 200.0, 100.0), rgb(1.0, 0.0, 0.0))]); diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index a6736b022d..284ad8ce5d 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -4,7 +4,8 @@ //! space (CMYK press or RGB/colour-blindness simulation) → display, through a cached 17³ LUT. //! * **Separations Preview** recolours the *document* before rendering: each paint colour is split //! into process inks (colour-managed), or a spot ink at the colour's tint when it's linked to a -//! spot swatch (solid colours and gradient stops alike). With one plate visible the plate +//! spot swatch (solid colours and gradient stops alike), with the process plates a gradient stop +//! prints beside its spot ink ([`vectorcraft_color::GradientStop::plates`]). With one plate visible the plate //! renders as greyscale ink coverage (black = 100%); with several, the visible inks are //! composited through the proof profile, spots multiplied on top. Colours linked to the //! Registration swatch print their tint on every plate, spot plates included. @@ -29,6 +30,7 @@ use vectorcraft_color::cms::{self, Cms, CmykLut, PROCESS_PLATES, ProofLut, lab}; pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget}; use vectorcraft_color::{Color, Swatch, SwatchGroup}; pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color}; +use vectorcraft_doc::inks::{StopPlates, map_node_inks, stop_inks}; use vectorcraft_doc::overprint::darken_overprints; use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, Shared}; use vello_cpu::Pixmap; @@ -59,8 +61,8 @@ fn plate_rgb(c: &Cms, proof: &ProofSetup, visible: &[String], ink: &Inks) -> [f3 c.proof_cmyk_to_srgb(cmyk, proof) } -fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, link: Link) -> Color { - let ink = inks(doc, c, color, link, proof.intent); +fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, link: Link, plates: StopPlates) -> Color { + let ink = stop_inks(doc, c, color, link, plates, proof.intent); let mut rgb = plate_rgb(c, proof, visible, &ink); // Over several plates the visible spot inks print on top. for name in visible.iter().filter(|v| visible.len() != 1 && !PROCESS_PLATES.contains(&v.as_str())) { @@ -355,7 +357,7 @@ impl How<'_> { fn apply(&self, a: &mut Arc) { darken_overprints(a, self.discard_white); if let Some((c, proof, visible)) = self.plates { - map_node_colors(Arc::make_mut(a), &mut |col, sw| plate_color(self.doc, c, proof, visible, col, sw)); + map_node_inks(Arc::make_mut(a), &mut |col, sw, plates| plate_color(self.doc, c, proof, visible, col, sw, plates)); } } } diff --git a/crates/render/src/tests/colorize.rs b/crates/render/src/tests/colorize.rs index 721707502d..8a8891c7da 100644 --- a/crates/render/src/tests/colorize.rs +++ b/crates/render/src/tests/colorize.rs @@ -289,6 +289,35 @@ fn separations_preview_keeps_a_reduced_dcs_plates_coverage() { assert!(p[..3].iter().all(|v| (100..=160).contains(v)), "half the ink, got {p:?}"); } +/// A gradient stop linked to a spot ink that prints process plates with it shows them on those +/// plates, and its tint on the spot plate. +#[test] +fn separations_preview_shows_a_spot_gradient_stops_process_plates() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let mut d = Document::new_with_mode(20.0, 20.0, vectorcraft_doc::ColorMode::Cmyk); + d.swatches.push(vectorcraft_color::Swatch { + name: "Gold".into(), + paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), + global: true, + spot: true, + }); + let stop = |offset| { + let mut s = GradientStop::new(offset, Color::cmyk(0.25, 0.1, 0.8, 0.05)); + (s.swatch, s.tint, s.plates) = (Some("Gold".into()), 0.5, Some([0.25, 0.0, 0.4, 0.0])); + s + }; + let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![stop(0.0), stop(1.0)])))); + let (layer, id) = (d.layers[0].id, d.alloc_id()); + let square = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(g, Paint::None, 0.0)); + d.insert(Some(layer), 0, square).unwrap(); + let gray = |plate: &str| Renderer::new().render(&d, 20, 20, Affine::IDENTITY, &separations(&[plate])).pixel(10, 10)[0]; + let near = |v: u8, ink: f32| (f32::from(v) - 255.0 * (1.0 - ink)).abs() <= 3.0; + for (plate, ink) in [("Cyan", 0.25), ("Magenta", 0.0), ("Yellow", 0.4), ("Black", 0.0), ("Gold", 0.5)] { + let v = gray(plate); + assert!(near(v, ink), "{plate}: {ink} of the ink, got {v}"); + } +} + /// Separations Preview showing `plates`, as the canvas draws it. fn separations(plates: &[&str]) -> RenderOptions { let proof = proof::ProofSetup { separations: Some(plates.iter().map(|p| p.to_string()).collect()), ..Default::default() }; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..42150a9f6f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1634,6 +1634,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur PDF export measures each member of a plain group or layer once to leave out what is off the page: the group or layer is measured through its members (`Node::visual_of_members`), and each member uses that box when it is written. The elements of a knockout group are measured twice, once for the group's box and once to choose the elements on the page (`pdf/src/tests/measuring.rs` counts the measures). A local byte comparison with the previous build found identical output on one open path of 200,000 anchors, 20,000 rectangles in 200 groups of 100, 5,000 point type objects and a document of plain, see-through, clipping and knockout groups. The walk that writes link annotations measures only an object with a URL that opens and a clipping group (`links.rs`), and its output on a document of linked objects in plain and clipping groups is the same byte for byte as when it measured every object. Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`). A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. The stops of an axial or radial shading in a DeviceN space that mixes one spot ink with process plates (a spot ink over a constant cyan, for example) link to the ink's swatch at its tint and keep the process plates, and PDF and EPS (Level 3) export write those stops in a DeviceN space of the ink and those plates, so the spot plate is not lost (#3235, `import_color::a_device_n_gradient_of_a_spot_ink_over_a_process_plate_keeps_both_inks`). Solid fills that mix a spot ink with process plates, and stops that mix several spot inks, still import as RGB, with the spot inks kept as swatches. + Print separations and Separations Preview print such a stop's process plates on the Cyan, Magenta, Yellow and Black plates beside its tint on the spot plate; with spots printed as process, each process plate prints the stop's plate layered with the spot tint's process equivalent. A freeform gradient's points separate into plates as its stops do, and converting the document's color mode converts them (`print::a_spot_gradient_stop_prints_its_process_plates`, `print::a_freeform_gradients_points_separate_into_plates`, `colorize::separations_preview_shows_a_spot_gradient_stops_process_plates`, `colormgmt::rgb_cmyk_converts_each_freeform_point`). A `.ai` or EPS opened from its editing data keeps both swatches when a pattern's name, or a named color's, is also another swatch's: a pattern's swatch named as a default swatch, a named color or a palette color becomes "Name 2", and a named color named as a gradient or pattern swatch of the palette becomes "Name 2", with the fills, strokes and gradient stops linked to it following it (`ai::tests::a_pattern_swatch_named_as_another_swatch_is_numbered`, `ai::tests::a_color_named_as_a_palette_gradient_or_pattern_is_numbered`). PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning. A CMYK document, or an export that converts colors to a CMYK destination profile, writes RGB and Lab stops in CMYK first (unless the export converts colors to an RGB destination profile), so there such a gradient, Classic or Perceptual, is written as a DeviceN shading that keeps the spot ink. A Perceptual gradient of spot inks is written as a Separation or DeviceN shading of the stops on its curve; a dithered gradient fill, and a Perceptual gradient with RGB or Lab stops other than white or more than 4,096 stops on its curve, are written as images in process colors, with a warning. A Lab spot color in such a shading is named by the same Separation space, with the Lab alternate, as its flat fills (`engine/src/tests/linked_stops.rs`, `engine/src/tests/labspots.rs`). PDF export writes a spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4) as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients, colorized images and Lab alternates; Registration still prints on every plate (`pdf/src/tests/output.rs`).