From ea61d34a938ec0d7b5ca926768ebdec1550c7781 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 03:27:45 +0700 Subject: [PATCH] M3.85: spot colours defined in Lab, with Spot Colors options for Lab values or CMYK equivalents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colours gain a CIE Lab model (`Color::Lab {l,a,b}`, serde tag `lab`), handled through the CMS (display, separation, conversions, gamut), tints (toward paper white), keep_model, harmonies, recolouring, blends, Info and the Color panel. Swatch Options gets a Lab mode (`swatch.edit {mode: "lab"}` / `{color: {l,a,b}}`), and colour params accept `{l,a,b}`. Swatches panel → Spot Colors… (`ui.spotColors`, dialog `spotColors`) runs `swatch.spotOptions {useLab}` (Document.spot_use_lab, default on): Lab spot colours show and separate from their Lab values, or art linked to them takes their working-CMYK equivalents, as one undo step. PDF export writes Lab spot inks as Separation spaces with a Lab alternate (tint function from paper white), or a DeviceCMYK one with CMYK equivalents. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/cms/mod.rs | 26 ++- crates/color/src/cms/tests.rs | 26 +++ crates/color/src/lib.rs | 76 ++++++- crates/color/src/libraries.rs | 2 + crates/doc/src/lib.rs | 10 + crates/doc/src/live.rs | 1 + crates/doc/src/overprint.rs | 4 +- crates/doc/src/swatches.rs | 25 ++- crates/engine/src/cmd/colormgmt.rs | 8 +- crates/engine/src/cmd/mod.rs | 6 +- crates/engine/src/cmd/paint.rs | 2 +- crates/engine/src/cmd/swatch.rs | 58 +++++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_labspots.rs | 204 +++++++++++++++++++ crates/engine/src/tests_swatches.rs | 4 +- crates/pdf/src/export.rs | 31 ++- crates/pdf/src/lab_spot.rs | 135 ++++++++++++ crates/pdf/src/lib.rs | 1 + crates/render/src/freeform.rs | 1 + crates/render/src/proof.rs | 17 +- crates/tools/src/params.rs | 3 +- crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/spot_colors.rs | 90 ++++++++ crates/ui-egui/src/dialogs/swatch_options.rs | 47 +++-- crates/ui-egui/src/menus.rs | 8 + crates/ui-egui/src/panels/color.rs | 89 ++++++-- crates/ui-egui/src/panels/info.rs | 1 + crates/ui-egui/src/panels/swatches.rs | 48 ++++- docs/control-protocol.md | 6 +- docs/mcp.md | 14 ++ 30 files changed, 855 insertions(+), 92 deletions(-) create mode 100644 crates/engine/src/tests_labspots.rs create mode 100644 crates/pdf/src/lab_spot.rs create mode 100644 crates/ui-egui/src/dialogs/spot_colors.rs diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 7960d44f..202bb9b7 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -140,6 +140,8 @@ pub enum Model { Rgb, Cmyk, Gray, + /// CIE L*a*b* (D50). + Lab, } #[derive(Clone)] @@ -354,6 +356,7 @@ impl Cms { Color::Rgb { r, g, b } => self.rgb_to_srgb([r, g, b]), Color::Cmyk { c, m, y, k } => self.cmyk_to_srgb([c, m, y, k], false), Color::Gray { k } => [1.0 - k.clamp(0.0, 1.0); 3], + Color::Lab { l, a, b } => lab::lab_to_srgb(Lab::new(l, a, b)), } } @@ -361,23 +364,33 @@ impl Cms { pub fn lab(&self, c: &Color) -> Lab { match *c { Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]), + Color::Lab { l, a, b } => Lab::new(l, a, b), _ => lab::srgb_to_lab(self.display_rgb(c)), } } - /// Ink values of any colour in the working CMYK space (CMYK passes through; grey is K only). + /// Ink values of any colour in the working CMYK space (CMYK passes through; grey is K only; + /// Lab separates from its own values). pub fn to_cmyk(&self, c: &Color, intent: Intent) -> [f32; 4] { match *c { Color::Cmyk { c, m, y, k } => [c, m, y, k], Color::Gray { k } => [0.0, 0.0, 0.0, k], Color::Rgb { .. } => self.srgb_to_cmyk(self.display_rgb(c), intent), + Color::Lab { l, a, b } => self.lab_to_cmyk(Lab::new(l, a, b), intent), } } /// Convert `c` into `model` (a colour already in `model` is returned unchanged). pub fn convert(&self, c: &Color, model: Model, intent: Intent) -> Color { match (c, model) { - (Color::Rgb { .. }, Model::Rgb) | (Color::Cmyk { .. }, Model::Cmyk) | (Color::Gray { .. }, Model::Gray) => *c, + (Color::Rgb { .. }, Model::Rgb) + | (Color::Cmyk { .. }, Model::Cmyk) + | (Color::Gray { .. }, Model::Gray) + | (Color::Lab { .. }, Model::Lab) => *c, + (_, Model::Lab) => { + let Lab { l, a, b } = self.lab(c); + Color::Lab { l, a, b } + } (_, Model::Cmyk) => { let [c, m, y, k] = self.to_cmyk(c, intent); Color::Cmyk { c, m, y, k } @@ -404,19 +417,18 @@ impl Cms { /// How far (ΔE2000) the best CMYK reproduction of `c` is from `c` (0 for CMYK/grey colours). pub fn gamut_error(&self, c: &Color) -> f32 { match c { - Color::Rgb { .. } => { - let srgb = self.display_rgb(c); - let src = lab::srgb_to_lab(srgb); + Color::Rgb { .. } | Color::Lab { .. } => { + let src = self.lab(c); match &self.cmyk { CmykSpace::Generic(g) => g.gamut_error(src), CmykSpace::Device => 0.0, CmykSpace::Icc(_) => { - let back = self.cmyk_to_srgb(self.srgb_to_cmyk(srgb, Intent::RelativeColorimetric), false); + let back = self.cmyk_to_srgb(self.to_cmyk(c, Intent::RelativeColorimetric), false); delta_e2000(src, lab::srgb_to_lab(back)) } } } - _ => 0.0, + Color::Cmyk { .. } | Color::Gray { .. } => 0.0, } } diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index a5b211ca..95506389 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -215,3 +215,29 @@ fn loads_system_cmyk_icc() { assert!(de(rgb, back) < 5.0, "{back:?}"); assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]); } + +#[test] +fn lab_colours_round_trip_through_the_cms() { + let c = cms_no_bpc(); + let ri = Intent::RelativeColorimetric; + // In-gamut Lab colours separate into working CMYK and come back within a small ΔE. + for (l, a, b) in [(60.0, 20.0, 30.0), (50.0, -20.0, -10.0), (75.0, 5.0, 40.0), (40.0, 30.0, -25.0)] { + let lab = Color::lab(l, a, b); + assert_eq!(c.lab(&lab), Lab::new(l, a, b), "Lab passes through"); + assert_eq!(c.convert(&lab, Model::Lab, ri), lab, "already Lab: unchanged"); + let ink = c.convert(&lab, Model::Cmyk, ri); + let Color::Cmyk { c: cc, m, y, k } = ink else { panic!("CMYK") }; + assert_eq!([cc, m, y, k], c.to_cmyk(&lab, ri), "separation = conversion"); + let back = c.lab(&ink); + assert!(delta_e2000(back, Lab::new(l, a, b)) < 1.5, "{l} {a} {b} → {ink:?} → {back:?}"); + assert!(!c.out_of_gamut(&lab), "{l} {a} {b} is printable"); + // Through RGB and back to Lab. + let rgb = c.convert(&lab, Model::Rgb, ri); + let Color::Lab { l: l2, a: a2, b: b2 } = c.convert(&rgb, Model::Lab, ri) else { panic!("Lab") }; + assert!(delta_e76(Lab::new(l2, a2, b2), Lab::new(l, a, b)) < 0.05, "{l} {a} {b} → {rgb:?} → {l2} {a2} {b2}"); + } + // A saturated Lab blue no press reaches is out of gamut; Lab grey converts to a neutral. + assert!(c.out_of_gamut(&Color::lab(30.0, 60.0, -100.0))); + let Color::Gray { k } = c.convert(&Color::lab(50.0, 0.0, 0.0), Model::Gray, ri) else { panic!("Gray") }; + assert!((k - 0.53).abs() < 0.01, "{k}"); +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index b118521e..5f12e897 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -1,7 +1,7 @@ //! Colour, paint and blend-mode types for VectorCraft. //! -//! Colours keep the model the user picked them in (RGB, CMYK, Gray, HSB is a UI view of RGB), so -//! documents don't drift when converting back and forth. Rendering asks for [`Color::to_rgba`]. +//! Colours keep the model the user picked them in (RGB, CMYK, Gray, Lab; HSB is a UI view of RGB), +//! so documents don't drift when converting back and forth. Rendering asks for [`Color::to_rgba`]. #![forbid(unsafe_code)] pub mod blend; @@ -21,13 +21,31 @@ pub use swatch::{Swatch, SwatchGroup, default_swatches}; use serde::{Deserialize, Serialize}; -/// A colour in its authoring model. Components are 0..=1. +/// A colour in its authoring model. Components are 0..=1, except Lab's. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "model", rename_all = "lowercase")] pub enum Color { - Rgb { r: f32, g: f32, b: f32 }, - Cmyk { c: f32, m: f32, y: f32, k: f32 }, - Gray { k: f32 }, + Rgb { + r: f32, + g: f32, + b: f32, + }, + Cmyk { + c: f32, + m: f32, + y: f32, + k: f32, + }, + Gray { + k: f32, + }, + /// CIE L*a*b* (D50): L 0..100, a and b about −128..127. Device independent, the way spot + /// colours are defined; it shows and separates through the active colour settings. + Lab { + l: f32, + a: f32, + b: f32, + }, } impl Default for Color { @@ -52,6 +70,9 @@ impl Color { pub fn gray(k: f32) -> Self { Color::Gray { k } } + pub fn lab(l: f32, a: f32, b: f32) -> Self { + Color::Lab { l, a, b } + } /// Display (sRGB) colour through the active colour settings ([`cms::active`]): RGB is /// converted from the working RGB space, CMYK through the working CMYK profile (relative @@ -62,15 +83,18 @@ impl Color { Color::Cmyk { c, m, y, k } if cms::cmyk_is_device() => cms::naive_cmyk_to_rgb([c, m, y, k]), // Illustrator's Gray is ink percentage: 0 = white, 1 = black. Color::Gray { k } => [1.0 - k; 3], + Color::Lab { l, a, b } => cms::lab::lab_to_srgb(cms::Lab::new(l, a, b)), _ => cms::active().display_rgb(self), } } - /// Profile-free display RGB (`(1−c)(1−k)` …), the pre-colour-management formula. + /// Profile-free display RGB (`(1−c)(1−k)` …), the pre-colour-management formula (Lab, being + /// device independent, is its sRGB colour). pub fn to_rgb_uncalibrated(&self) -> [f32; 3] { match *self { Color::Rgb { r, g, b } => [r, g, b], Color::Cmyk { c, m, y, k } => cms::naive_cmyk_to_rgb([c, m, y, k]), Color::Gray { k } => [1.0 - k; 3], + Color::Lab { l, a, b } => cms::lab::lab_to_srgb(cms::Lab::new(l, a, b)), } } /// CIE Lab (D50) through the active colour settings. @@ -182,11 +206,12 @@ impl Color { Color::Cmyk { c, m, y, k } } Color::Gray { k } => Color::Gray { k: 1.0 - k }, + Color::Lab { .. } => self.invert().in_model(cms::Model::Lab), } } /// Complement in the colour's own model: each component becomes (highest + lowest) − itself, /// over R, G, B or over C, M, Y (K kept). For RGB this is the hue turned by 180°; a grey is its - /// own complement. + /// own complement; Lab turns its hue by 180° (a and b negated). pub fn complement_keep_model(&self) -> Self { let flip = |v: [f32; 3]| { let s = v[0].max(v[1]).max(v[2]) + v[0].min(v[1]).min(v[2]); @@ -202,6 +227,7 @@ impl Color { Color::Cmyk { c, m, y, k } } Color::Gray { .. } => *self, + Color::Lab { l, a, b } => Color::Lab { l, a: -a, b: -b }, } } /// Linear interpolation in display RGB. @@ -216,11 +242,12 @@ impl Color { Color::Rgb { .. } => cms::Model::Rgb, Color::Cmyk { .. } => cms::Model::Cmyk, Color::Gray { .. } => cms::Model::Gray, + Color::Lab { .. } => cms::Model::Lab, } } /// This colour expressed in `model` with the profile-free formulas Edit Colors uses (RGB from - /// the display colour, CMYK by [`Color::to_cmyk`], Gray as ink from luminance); unchanged when it - /// is in `model` already. + /// the display colour, CMYK by [`Color::to_cmyk`], Gray as ink from luminance, Lab by + /// [`Color::to_lab`]); unchanged when it is in `model` already. pub fn in_model(self, model: cms::Model) -> Color { if self.model() == model { return self; @@ -238,10 +265,15 @@ impl Color { let [r, g, b] = self.to_rgb(); Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0)) } + cms::Model::Lab => { + let cms::Lab { l, a, b } = self.to_lab(); + Color::Lab { l, a, b } + } } } /// Tint `t` (0..1) of this colour, the colour a tint of a global or spot swatch shows: CMYK and - /// Gray inks scale by `t`, RGB mixes toward white (`t` = 0 is paper white, 1 the colour itself). + /// Gray inks scale by `t`, RGB and Lab mix toward white (`t` = 0 is paper white, 1 the colour + /// itself; Lab toward L 100, a = b = 0, as a PDF Lab alternate interpolates). pub fn tinted(self, t: f32) -> Color { let t = t.clamp(0.0, 1.0); if t == 1.0 { @@ -254,6 +286,7 @@ impl Color { let w = |v: f32| v + (1.0 - v) * (1.0 - t); Color::Rgb { r: w(r), g: w(g), b: w(b) } } + Color::Lab { l, a, b } => Color::Lab { l: 100.0 - (100.0 - l) * t, a: a * t, b: b * t }, } } pub fn model_name(&self) -> &'static str { @@ -261,6 +294,7 @@ impl Color { Color::Rgb { .. } => "RGB", Color::Cmyk { .. } => "CMYK", Color::Gray { .. } => "Grayscale", + Color::Lab { .. } => "Lab", } } } @@ -443,12 +477,14 @@ mod tests { Color::Cmyk { c, m, y, k } => vec![c, m, y, k], Color::Rgb { r, g, b } => vec![r, g, b], Color::Gray { k } => vec![k], + Color::Lab { l, a, b } => vec![l, a, b], }; a.model() == b.model() && v(a).iter().zip(v(b)).all(|(x, y)| (x - y).abs() < 1e-6) }; assert!(close(Color::cmyk(0.5, 1.0, 0.0, 0.2).tinted(0.4), Color::cmyk(0.2, 0.4, 0.0, 0.08))); assert!(close(Color::gray(0.5).tinted(0.5), Color::gray(0.25))); assert!(close(Color::rgb(1.0, 0.0, 0.5).tinted(0.4), Color::rgb(1.0, 0.6, 0.8)), "RGB mixes toward white"); + assert!(close(Color::lab(40.0, 60.0, -20.0).tinted(0.25), Color::lab(85.0, 15.0, -5.0)), "Lab mixes toward paper white"); assert_eq!(Color::rgb(0.2, 0.4, 0.6).tinted(1.0), Color::rgb(0.2, 0.4, 0.6)); assert_eq!(Color::cmyk(0.3, 0.3, 0.3, 0.3).tinted(0.0), Color::cmyk(0.0, 0.0, 0.0, 0.0)); assert_eq!(Color::rgb(0.0, 0.0, 0.0).tinted(-1.0).to_hex(), "#ffffff", "clamped"); @@ -483,6 +519,24 @@ mod tests { assert!(g.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.5)); } + #[test] + fn lab_colours_serialize_with_their_tag_and_keep_their_model() { + let c = Color::lab(52.5, 70.0, -30.0); + let s = serde_json::to_string(&c).unwrap(); + assert_eq!(s, r#"{"model":"lab","l":52.5,"a":70.0,"b":-30.0}"#); + assert_eq!(serde_json::from_str::(&s).unwrap(), c); + assert_eq!((c.model(), c.model_name()), (cms::Model::Lab, "Lab")); + assert_eq!(c.complement_keep_model(), Color::lab(52.5, -70.0, 30.0)); + assert_eq!(c.invert_keep_model().model(), cms::Model::Lab); + // Results of colour operations on a Lab colour stay Lab, and the conversion is exact. + let red = Color::rgb(1.0, 0.0, 0.0); + let Color::Lab { l, a, b } = keep_model(c, red) else { panic!("Lab") }; + assert!((l - 54.29).abs() < 0.1 && (a - 80.8).abs() < 0.3 && (b - 69.9).abs() < 0.3, "{l} {a} {b}"); + assert_eq!(keep_model(c, red).to_hex(), "#ff0000"); + assert_eq!(Color::lab(100.0, 0.0, 0.0).to_hex(), "#ffffff"); + assert_eq!(Color::lab(0.0, 0.0, 0.0).to_rgb_uncalibrated(), [0.0; 3]); + } + #[test] fn paint_serde() { let p = Paint::solid(Color::rgb8(10, 20, 30)); diff --git a/crates/color/src/libraries.rs b/crates/color/src/libraries.rs index effe3124..16303877 100644 --- a/crates/color/src/libraries.rs +++ b/crates/color/src/libraries.rs @@ -452,6 +452,8 @@ mod tests { Color::Rgb { r, g, b } => vec![r, g, b], Color::Cmyk { c, m, y, k } => vec![c, m, y, k], Color::Gray { k } => vec![k], + // Normalized to 0..1 like the others. + Color::Lab { l, a, b } => vec![l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0], }; lib.iter() .flat_map(|s| match &s.paint { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 82fcacdf..880ba9eb 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -428,12 +428,21 @@ pub struct Document { /// pass their contents through) knock each other out. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub page_knockout: bool, + /// Swatches panel → Spot Colors: spot colours defined in Lab show and separate from their Lab + /// values (on, the default) or from their working-CMYK equivalents (off) + /// ([`Document::linked_color`]). + #[serde(default = "yes", skip_serializing_if = "skip::is_true")] + pub spot_use_lab: bool, } fn ppi72() -> f64 { 72.0 } +fn yes() -> bool { + true +} + pub const FORMAT_VERSION: u32 = 1; impl Document { @@ -478,6 +487,7 @@ impl Document { unknown: Default::default(), page_isolate: false, page_knockout: false, + spot_use_lab: true, }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 623c57d4..d3c82c40 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -162,6 +162,7 @@ pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color { match (*a, *b) { (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)), (Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)), + (Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)), _ => a.lerp(b, t), } } diff --git a/crates/doc/src/overprint.rs b/crates/doc/src/overprint.rs index 2f0f2c75..0fcf3b18 100644 --- a/crates/doc/src/overprint.rs +++ b/crates/doc/src/overprint.rs @@ -11,13 +11,13 @@ use crate::{Document, Node, NodeId, NodeKind}; pub const LEGACY_OVERPRINT_KEY: &str = "overprintBlack"; /// Black ink of at least `min_k` (0..1): CMYK with that much K and, unless `rich`, no cyan, -/// magenta or yellow; or a grey that dark. RGB colours are never black ink. +/// magenta or yellow; or a grey that dark. RGB and Lab colours are never black ink. pub fn is_black_ink(c: &Color, min_k: f32, rich: bool) -> bool { const E: f32 = 0.005; match *c { Color::Cmyk { c, m, y, k } => k >= min_k - E && (rich || (c <= E && m <= E && y <= E)), Color::Gray { k } => k >= min_k - E, - Color::Rgb { .. } => false, + Color::Rgb { .. } | Color::Lab { .. } => false, } } diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 4fce4569..4eaeba0e 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -30,14 +30,27 @@ impl Document { pub fn swatch_mut(&mut self, name: &str) -> Option<&mut Swatch> { self.swatches_iter_mut().find(|s| s.name == name) } - /// The colour of global (or spot) swatch `name`, which its tints scale; `None` when `name` - /// isn't a global solid colour of its own (a tint swatch isn't). + /// The colour of global (or spot) swatch `name`, which its tints scale ([`Document::linked_color`] + /// of its own); `None` when `name` isn't a global solid colour of its own (a tint swatch isn't). pub fn global_color(&self, name: &str) -> Option { - match &self.swatch(name).filter(|w| w.global)?.paint { - Paint::Solid { color, swatch: None, .. } => Some(*color), + let w = self.swatch(name).filter(|w| w.global)?; + match &w.paint { + Paint::Solid { color, swatch: None, .. } => Some(self.linked_color(*color, w.spot)), _ => None, } } + /// The colour art linked to a swatch of `color` shows and prints from: a Lab spot colour is + /// its working-CMYK equivalent when the Spot Colors options use CMYK values + /// ([`Document::spot_use_lab`] off); any other colour is itself. + pub fn linked_color(&self, color: Color, spot: bool) -> Color { + match color { + Color::Lab { .. } if spot && !self.spot_use_lab => { + let cms = vectorcraft_color::cms::active(); + cms.convert(&color, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent) + } + _ => color, + } + } /// The link a colour taken from swatch `name` gets: a global solid colour links to itself at /// 100 %, a tint swatch to its base at its tint; `None` for other swatches. pub fn swatch_link(&self, name: &str) -> Option<(String, f32)> { @@ -156,7 +169,7 @@ impl Document { } /// The default name of a solid swatch: its values in its own colour model ("C=10 M=20 Y=30 K=0", -/// "R=255 G=128 B=0", "Gray K=40"). +/// "R=255 G=128 B=0", "Gray K=40", "L=52 a=70 b=-30"). pub fn color_name(c: Color) -> String { // Clamped first, so a conversion's tiny negative never reads "-0". let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round(); @@ -165,6 +178,8 @@ pub fn color_name(c: Color) -> String { Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)), Color::Rgb { r, g, b } => format!("R={} G={} B={}", byte(r), byte(g), byte(b)), Color::Gray { k } => format!("Gray K={}", pct(k)), + // `+ 0.0` turns a rounded -0 into 0. + Color::Lab { l, a, b } => format!("L={} a={} b={}", l.round() + 0.0, a.round() + 0.0, b.round() + 0.0), } } diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 28267cee..b7f28de8 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Convert Document Color Mode", ["File", "Document Color Mode"], None, - "{mode: \"cmyk\"|\"rgb\", intent?} set the document colour mode and convert every colour (art, symbols, swatches; swatch links kept) through the colour settings → {changed}", + "{mode: \"cmyk\"|\"rgb\", intent?} set the document colour mode and convert every colour (art, symbols, swatches; swatch links kept; Gray and Lab colours stay) through the colour settings → {changed}", has_doc, convert_mode ), @@ -245,8 +245,9 @@ fn convert_mode(s: &mut Session, p: &Value) -> Result { let c = cms::active(); let intent = intent_param(p, C)?.unwrap_or(c.settings().intent); let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb }; - // Greys stay greys (they print on the black plate in either mode). - let conv = |col: &Color| if matches!(col, Color::Gray { .. }) { *col } else { c.convert(col, model, intent) }; + // Greys stay greys (they print on the black plate in either mode); Lab colours (spot colour + // definitions) are device independent and fit either mode. + let conv = |col: &Color| if matches!(col, Color::Gray { .. } | Color::Lab { .. }) { *col } else { c.convert(col, model, intent) }; let mut changed = 0usize; s.edit("Document Color Mode", |d, _| { d.color_mode = mode; @@ -287,6 +288,7 @@ fn values(c: &Color) -> Vec { Color::Rgb { r, g, b } => vec![r, g, b], Color::Cmyk { c, m, y, k } => vec![c, m, y, k], Color::Gray { k } => vec![k], + Color::Lab { l, a, b } => vec![l, a, b], } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c7ecf4c3..dfb442f1 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -253,7 +253,8 @@ pub fn matrix_param(p: &Value, key: &str) -> Option { } Some(Affine::new(c)) } -/// A colour from `"#rrggbb"`, `[r,g,b]` (0..1), `{"c":..,"m":..,"y":..,"k":..}` or `{"gray":..}`. +/// A colour from `"#rrggbb"`, `[r,g,b]` (0..1), `{"c":..,"m":..,"y":..,"k":..}`, `{"gray":..}` or +/// `{"l":..,"a":..,"b":..}` (CIE Lab: L 0..100, a and b about −128..127). pub fn color_value(v: &Value) -> Option { match v { Value::String(s) => Color::from_hex(s), @@ -267,6 +268,9 @@ pub fn color_value(v: &Value) -> Option { let g = g.as_f64()?; return Some(Color::gray(if g > 1.0 { g / 100.0 } else { g } as f32)); } + if let (Some(l), Some(a), Some(b), None) = (o.get("l"), o.get("a"), o.get("b"), o.get("model")) { + return Some(Color::lab(l.as_f64()? as f32, a.as_f64()? as f32, b.as_f64()? as f32)); + } serde_json::from_value(v.clone()).ok() } _ => None, diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 2fb85b1e..e052a2b9 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", has_doc, |s, p| set_paint(s, p, true) ), diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 21b9bd3a..57483fde 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "New Swatch", ["Window", "Swatches"], None, - "{name?, color? | colors?: [colour] (one swatch each, in one undo step) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name, names: [every new swatch]}", + "{name?, color? | colors?: [colour] (one swatch each, in one undo step) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour; lab: CIE L*a*b*, how spot colours are usually defined), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\", \"L=55 a=60 b=40\") → {name, names: [every new swatch]}", has_doc, swatch_new ), @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Swatch Options", ["Window", "Swatches"], None, - "{name, newName?, color?, mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour, gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}", + "{name, newName?, color? (e.g. {l, a, b} for a Lab colour), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour, gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}", has_doc, swatch_edit ), @@ -113,6 +113,15 @@ pub fn specs() -> Vec { has_doc, swatch_sort_by_kind ), + cmd!( + "swatch.spotOptions", + "Spot Colors", + ["Window", "Swatches"], + None, + "{useLab?: bool (omitted: unchanged)} the document's Spot Colors options: spot colours defined in Lab show, print and export from their Lab values (true, the default: PDF Separation spaces get a Lab alternate) or from their working-CMYK equivalents (false: a DeviceCMYK alternate, matching older files). Art, gradient stops and tint swatches linked to Lab spot swatches take the chosen colour at their tint, as one undo step → {useLab, relinked: paints changed}", + has_doc, + swatch_spot_options + ), ] } @@ -420,12 +429,13 @@ fn convert_to_mode(c: Color, mode: &str, cmd: &str) -> Result { "gray" | "grayscale" => to(Model::Gray), "rgb" | "hsb" => to(Model::Rgb), "cmyk" => to(Model::Cmyk), + "lab" => to(Model::Lab), "web" => { let snap = |v: f32| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0; let [r, g, b] = to(Model::Rgb).to_rgb_uncalibrated(); Color::rgb(snap(r), snap(g), snap(b)) } - other => return Err(bad(cmd, format!("unknown mode `{other}` (gray, rgb, hsb, cmyk or web)"))), + other => return Err(bad(cmd, format!("unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web)"))), }) } @@ -434,7 +444,8 @@ struct Relink { from: String, /// The swatch's name after the edit. to: String, - /// The new colour of a global swatch (linked paints take it, at their tint). + /// The colour paints linked to the global swatch now show ([`Document::linked_color`]); they + /// take it at their tint. color: Option, /// false: drop the links (the swatch stopped being global); paints keep their colour. keep: bool, @@ -518,7 +529,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result { let spot = bool_or(p, "spot", sw.spot); let global = spot || bool_or(p, "global", sw.global); let to = name_param(p, "newName").filter(|n| *n != name).map_or_else(|| name.clone(), |n| d.free_swatch_name(&n)); - let relink = Relink { from: name.clone(), to: to.clone(), color: color.filter(|_| global), keep: global }; + let relink = Relink { from: name.clone(), to: to.clone(), color: color.filter(|_| global).map(|c| d.linked_color(c, spot)), keep: global }; let relinked = s.edit("Swatch Options", |d, _| { let w = d.swatch_mut(&name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?; w.name = to.clone(); @@ -673,7 +684,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "give two or more swatches")); } let kept = d.swatch(keep).ok_or_else(|| bad(C, format!("no swatch `{keep}`")))?; - let (color, link) = (kept.paint.color().unwrap_or_default(), kept.global.then(|| keep.clone())); + let (color, link) = (d.linked_color(kept.paint.color().unwrap_or_default(), kept.spot), kept.global.then(|| keep.clone())); // Tints keep their tint (of the kept colour). let mut relink = |c: &mut Color, l: &mut Option, t: &mut f32| { if !l.as_ref().is_some_and(|l| merged.contains(l)) { @@ -724,3 +735,38 @@ fn swatch_sort_by_kind(s: &mut Session, _: &Value) -> Result { })?; ok() } + +fn swatch_spot_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.spotOptions"; + let was = s.doc()?.doc.spot_use_lab; + let use_lab = match p.get("useLab").filter(|v| !v.is_null()) { + Some(v) => v.as_bool().ok_or_else(|| bad(C, "`useLab` must be true or false"))?, + None => was, + }; + if use_lab == was { + return Ok(json!({"useLab": use_lab, "relinked": 0})); + } + let (relinked, bases) = s.edit("Spot Colors", |d, _| { + d.spot_use_lab = use_lab; + // Each Lab spot swatch with the colour its linked paints now show. + let bases: Vec<(String, Color)> = d + .swatches_iter() + .filter(|w| w.spot && matches!(w.paint, Paint::Solid { color: Color::Lab { .. }, swatch: None, .. })) + .filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))) + .collect(); + let relinked = d.map_solid_paints(&mut follow_bases(&bases)); + Ok((relinked, bases)) + })?; + map_default_paints(s, &mut follow_bases(&bases)); + Ok(json!({"useLab": use_lab, "relinked": relinked})) +} + +/// Recolour the colours linked to the swatches of `bases` (name, colour) to that colour at their +/// tint; true when a colour changed. +fn follow_bases(bases: &[(String, Color)]) -> impl FnMut(&mut Color, &mut Option, &mut f32) -> bool + '_ { + |c, link, tint| { + let Some((_, base)) = bases.iter().find(|(n, _)| link.as_ref() == Some(n)) else { return false }; + let new = base.tinted(*tint); + std::mem::replace(c, new) != new + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3fcf69f8..5875e7cc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -843,6 +843,8 @@ mod tests_gradpanel; #[cfg(test)] mod tests_knockout; #[cfg(test)] +mod tests_labspots; +#[cfg(test)] mod tests_layerclip; #[cfg(test)] mod tests_linked_stops; diff --git a/crates/engine/src/tests_labspots.rs b/crates/engine/src/tests_labspots.rs new file mode 100644 index 00000000..f405d494 --- /dev/null +++ b/crates/engine/src/tests_labspots.rs @@ -0,0 +1,204 @@ +//! Spot colours defined in Lab: Lab swatches through Swatch Options, the Spot Colors options +//! (Lab values or CMYK equivalents for display and separations), Lab alternates in PDF, and the +//! saved setting. + +use serde_json::{Value, json}; +use vectorcraft_color::cms::{self, Model}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_render::proof; + +use super::tests_colormgmt::GLOBAL; +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn doc(s: &Session) -> &vectorcraft_doc::Document { + &s.doc().unwrap().doc +} + +fn fill(s: &Session, id: NodeId) -> Paint { + doc(s).node(id).unwrap().appearance.fill_paint() +} + +const LAB: Color = Color::Lab { l: 55.0, a: 60.0, b: 40.0 }; + +fn linked(color: Color, tint: f32) -> Paint { + Paint::Solid { color, swatch: Some("Lab Ink".into()), tint } +} + +/// The working-CMYK equivalent of a Lab colour, what CMYK spot options show. +fn cmyk_of(c: Color) -> Color { + let cms = cms::active(); + cms.convert(&c, Model::Cmyk, cms.settings().intent) +} + +/// A document with the Lab spot swatch "Lab Ink" and a rectangle filled with 40 % of it. +fn lab_ink() -> (Session, NodeId) { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Lab Ink", "color": {"l": 55, "a": 60, "b": 40}, "spot": true})); + let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap()); + run(&mut s, "paint.setFill", json!({"ids": [id.0], "swatch": "Lab Ink", "tint": 40})); + (s, id) +} + +#[test] +fn swatch_options_define_spot_colours_in_lab() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 80, "y": 60, "k": 0}, "spot": true})); + let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + run(&mut s, "paint.setFill", json!({"ids": [id.0], "swatch": "Ink"})); + // Lab mode converts the swatch's colour through the CMS; linked art follows. + run(&mut s, "swatch.edit", json!({"name": "Ink", "mode": "lab"})); + let lab = doc(&s).swatch("Ink").unwrap().paint.color().unwrap(); + assert_eq!(lab.model(), Model::Lab); + let back = cmyk_of(lab); + let Color::Cmyk { c, m, y, k } = back else { panic!("{back:?}") }; + assert!(c < 0.03 && (m - 0.8).abs() < 0.03 && (y - 0.6).abs() < 0.03 && k < 0.03, "Lab of C0 M80 Y60 K0 separates back: {back:?}"); + assert_eq!(fill(&s, id), Paint::Solid { color: lab, swatch: Some("Ink".into()), tint: 1.0 }); + // Lab values given directly; the swatch list reports the model. + let r = run(&mut s, "swatch.edit", json!({"name": "Ink", "color": {"l": 55, "a": 60, "b": 40}})); + assert_eq!(r["relinked"], 1); + assert_eq!(fill(&s, id).color(), Some(LAB)); + let list = run(&mut s, "swatch.list", json!({})); + let ink = list["swatches"].as_array().unwrap().iter().find(|w| w["name"] == "Ink").unwrap(); + assert_eq!((ink["color"]["model"].as_str(), ink["spot"].as_bool()), (Some("lab"), Some(true))); + assert!(s.execute("swatch.edit", &json!({"name": "Ink", "mode": "xyz"})).unwrap_err().to_string().contains("lab")); +} + +#[test] +fn spot_colors_options_switch_lab_spots_between_lab_and_cmyk() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let (mut s, id) = lab_ink(); + assert!(doc(&s).spot_use_lab, "Lab values by default"); + assert_eq!(fill(&s, id), linked(LAB.tinted(0.4), 0.4)); + // A gradient stop and a tint swatch of the ink follow too; a process swatch doesn't. + run(&mut s, "swatch.new", json!({"swatch": "Lab Ink", "tint": 70})); + let g = NodeId(run(&mut s, "shape.rectangle", json!({"x": 50, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap()); + run( + &mut s, + "paint.setFill", + json!({"ids": [g.0], "gradient": {"stops": [{"offset": 0, "swatch": "Lab Ink"}, {"offset": 1, "color": "#ffffff"}]}}), + ); + assert_eq!(run(&mut s, "swatch.spotOptions", json!({})), json!({"useLab": true, "relinked": 0}), "no change: a query"); + + let r = run(&mut s, "swatch.spotOptions", json!({"useLab": false})); + assert_eq!(r, json!({"useLab": false, "relinked": 3})); + let cmyk = cmyk_of(LAB); + assert!(matches!(cmyk, Color::Cmyk { .. })); + assert_eq!(fill(&s, id), linked(cmyk.tinted(0.4), 0.4), "the CMYK equivalent at the same tint"); + assert_eq!(doc(&s).swatch("Lab Ink 70%").unwrap().paint, linked(cmyk.tinted(0.7), 0.7)); + let Paint::Gradient(gp) = fill(&s, g) else { panic!("gradient") }; + assert_eq!((gp.gradient.stops[0].color, gp.gradient.stops[0].swatch.as_deref()), (cmyk, Some("Lab Ink"))); + assert_eq!(doc(&s).swatch("Lab Ink").unwrap().paint.color(), Some(LAB), "the definition stays Lab"); + assert_eq!(doc(&s).global_color("Lab Ink"), Some(cmyk)); + // New art takes the CMYK equivalent; the Separations plate shows it. + run(&mut s, "paint.setFill", json!({"ids": [id.0], "swatch": "Lab Ink", "tint": 50})); + assert_eq!(fill(&s, id), linked(cmyk.tinted(0.5), 0.5)); + let plate = |s: &Session| proof::plates(doc(s)).into_iter().find(|p| p.name == "Lab Ink").unwrap().rgb; + assert_eq!(plate(&s), cmyk.to_rgb()); + // One undo step back to Lab values. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert!(doc(&s).spot_use_lab); + assert_eq!(fill(&s, id), linked(LAB.tinted(0.4), 0.4)); + assert_eq!(plate(&s), LAB.to_rgb()); + assert!(s.execute("swatch.spotOptions", &json!({"useLab": "yes"})).is_err()); +} + +#[test] +fn colour_values_parse_lab() { + let mut s = session(); + let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + run(&mut s, "paint.setFill", json!({"ids": [id.0], "color": {"l": 50, "a": -20, "b": 30.5}})); + assert_eq!(fill(&s, id).color(), Some(Color::lab(50.0, -20.0, 30.5))); + run(&mut s, "paint.setFill", json!({"ids": [id.0], "color": {"model": "lab", "l": 20, "a": 1, "b": 2}})); + assert_eq!(fill(&s, id).color(), Some(Color::lab(20.0, 1.0, 2.0))); + // Lab colours keep their model through colour operations. + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "paint.complement", json!({})); + assert_eq!(fill(&s, id).color(), Some(Color::lab(20.0, -1.0, -2.0))); // A Lab swatch is named by its values. + let r = run(&mut s, "swatch.new", json!({"color": {"l": 50.4, "a": -20, "b": -0.2}})); + assert_eq!(r["name"], "L=50 a=-20 b=0"); +} + +#[test] +fn spot_options_and_lab_colours_round_trip() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let (mut s, id) = lab_ink(); + let saved = vectorcraft_format::save(doc(&s), false); + assert!(!String::from_utf8_lossy(&saved).contains("spot_use_lab"), "the default isn't written"); + run(&mut s, "swatch.spotOptions", json!({"useLab": false})); + let d = doc(&s).clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); + assert!(!back.spot_use_lab); + assert_eq!(back.swatch("Lab Ink").unwrap().paint.color(), Some(LAB)); + assert_eq!(back.node(id).unwrap().appearance.fill_paint(), d.node(id).unwrap().appearance.fill_paint()); + // Files from before the option use Lab values. + let mut v: Value = serde_json::to_value(&d).unwrap(); + v.as_object_mut().unwrap().remove("spot_use_lab"); + assert!(serde_json::from_value::(v).unwrap().spot_use_lab); +} + +fn pdf_bytes(s: &Session) -> Vec { + let opts = vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }; + vectorcraft_pdf::export(doc(s), &opts).unwrap() +} + +/// Every in-use cross-reference entry points at its object, and `startxref` at the table. +fn assert_xref_valid(pdf: &[u8]) { + let sx = pdf.windows(9).rposition(|w| w == b"startxref").unwrap(); + let xref: usize = std::str::from_utf8(&pdf[sx + 9..]).unwrap().split_whitespace().next().unwrap().parse().unwrap(); + assert!(pdf[xref..].starts_with(b"xref\n0 "), "startxref → the table"); + // The table and trailer are ASCII. + let mut lines = std::str::from_utf8(&pdf[xref..]).unwrap().lines().skip(1); + let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap(); + for (id, e) in lines.take(count).enumerate() { + if e.trim_end().ends_with('n') { + let off: usize = e[..10].parse().unwrap(); + assert!(pdf[off..].starts_with(format!("{id} 0 obj").as_bytes()), "object {id} at {off}"); + } + } +} + +#[test] +fn lab_spots_export_with_a_lab_alternate() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let (mut s, _) = lab_ink(); + // A tint gradient of the ink is a Separation shading of the same space. + let g = NodeId(run(&mut s, "shape.rectangle", json!({"x": 50, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap()); + run( + &mut s, + "paint.setFill", + json!({"ids": [g.0], "gradient": {"stops": [{"offset": 0, "swatch": "Lab Ink"}, {"offset": 1, "color": "#ffffff"}]}}), + ); + let pdf = pdf_bytes(&s); + let text = String::from_utf8_lossy(&pdf); + assert!( + text.contains("[/Separation/Lab#20Ink[/Lab<>]<>]"), + "a Lab alternate running from paper white to the Lab values" + ); + assert!(!text.contains("/Separation/Lab#20Ink/DeviceCMYK")); + assert!(text.contains("/ShadingType 2"), "the gradient is still a shading"); + assert_xref_valid(&pdf); + // The file reads back: the 40 % tint shows as that tint of the Lab colour. + let back = vectorcraft_pdf::import(&pdf).unwrap(); + let mut fills = vec![]; + back.visit_paints(&mut |p| fills.extend(p.color())); + let want = LAB.tinted(0.4).to_rgba8(1.0); + assert!(fills.iter().any(|c| c.to_rgba8(1.0).iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 3)), "{fills:?} has {want:?}"); + // CMYK values: a DeviceCMYK alternate, as for process-defined spots. + run(&mut s, "swatch.spotOptions", json!({"useLab": false})); + let pdf = pdf_bytes(&s); + let text = String::from_utf8_lossy(&pdf); + assert!(text.contains("/Separation/Lab#20Ink/DeviceCMYK") && !text.contains("/Lab<<")); + assert_xref_valid(&pdf); +} diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests_swatches.rs index dc2ef57b..271eaee0 100644 --- a/crates/engine/src/tests_swatches.rs +++ b/crates/engine/src/tests_swatches.rs @@ -195,7 +195,7 @@ fn grouped_swatches_edit_and_convert_modes() { assert!(s.execute("swatch.edit", &json!({"name": "Sunset", "color": "#000000"})).is_err()); assert!(s.execute("swatch.edit", &json!({"name": "Sunset", "spot": true})).is_err()); assert!(s.execute("swatch.edit", &json!({"name": "[None]", "newName": "x"})).is_err()); - assert!(s.execute("swatch.edit", &json!({"name": "Bright Red", "mode": "lab"})).is_err()); + assert!(s.execute("swatch.edit", &json!({"name": "Bright Red", "mode": "xyz"})).is_err()); assert_eq!(run(&mut s, "swatch.edit", json!({"name": "Sunset", "newName": "Dusk"}))["name"], "Dusk"); } @@ -264,7 +264,7 @@ fn new_swatches_go_into_groups_convert_modes_and_can_be_spot() { assert_eq!(d.swatch_group_of("Ink").map(|g| d.swatch_groups[g].name.as_str()), Some("Brights")); assert!(s.execute("swatch.new", &json!({"color": "#00ff00", "group": "Nope"})).is_err()); assert!(s.execute("swatch.new", &json!({"swatch": "Sunset", "group": "Brights"})).is_err(), "groups hold solid colours only"); - assert!(s.execute("swatch.new", &json!({"color": "#00ff00", "mode": "lab"})).is_err()); + assert!(s.execute("swatch.new", &json!({"color": "#00ff00", "mode": "xyz"})).is_err()); } /// Two rectangles: `a` filled red and stroked green, `b` filled red and stroked with global swatch diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index bed66a1d..22952bfb 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -71,7 +71,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Result { brushes: Option>, /// Whether the group being written is a knockout group (what its neutral children inherit). knockout: bool, + /// Spot colours written with a Lab alternate ([`crate::lab_spot`]): colorant name, Lab values. + lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>, } fn xf(a: Affine) -> Transform { @@ -205,6 +208,10 @@ fn color(c: &Color) -> krilla::color::Color { Color::Cmyk { c, m, y, k } => cmyk::Color::new(q(c), q(m), q(y), q(k)).into(), // VectorCraft grey is ink coverage (0 = white); PDF DeviceGray is lightness. Color::Gray { k } => luma::Color::new(q(1.0 - k)).into(), + Color::Lab { .. } => { + let [r, g, b] = c.to_rgb(); + rgb::Color::new(q(r), q(g), q(b)).into() + } } } @@ -232,10 +239,10 @@ fn rects_overlap(a: Rect, b: Rect) -> bool { } impl Exporter<'_> { - /// A colour for the page: in CMYK documents RGB colours are separated into DeviceCMYK through - /// the active colour settings, so the file carries press values. + /// A colour for the page: in CMYK documents RGB and Lab colours are separated into DeviceCMYK + /// through the active colour settings, so the file carries press values. fn col(&mut self, c: &Color) -> krilla::color::Color { - if self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk && matches!(c, Color::Rgb { .. }) { + if self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk && matches!(c, Color::Rgb { .. } | Color::Lab { .. }) { let cms = vectorcraft_color::cms::active(); let [cc, m, y, k] = cms.to_cmyk(c, cms.settings().intent); return cmyk::Color::new(q(cc), q(m), q(y), q(k)).into(); @@ -244,11 +251,19 @@ impl Exporter<'_> { } /// The Separation colour space of spot swatch `name` (its CMYK equivalent is the alternate - /// space); `None` when `name` isn't a spot colour. - fn separation(&self, name: &str) -> Option { - let sw = self.doc.swatch(name).filter(|s| s.spot)?; + /// space; a Lab spot colour also gets a Lab one, [`crate::lab_spot`], unless the Spot Colors + /// options use CMYK values); `None` when `name` isn't a spot colour. + fn separation(&mut self, name: &str) -> Option { + let doc = self.doc; + let sw = doc.swatch(name).filter(|s| s.spot)?; + let color = doc.linked_color(sw.paint.color()?, true); + if let Color::Lab { l, a, b } = color + && !self.lab_spots.iter().any(|(n, _)| n == name) + { + self.lab_spots.push((name.to_string(), vectorcraft_color::cms::Lab::new(l, a, b))); + } let cms = vectorcraft_color::cms::active(); - let full = cms.to_cmyk(&sw.paint.color()?, cms.settings().intent); + let full = cms.to_cmyk(&color, cms.settings().intent); let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3]))); Some(SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), alt)) } diff --git a/crates/pdf/src/lab_spot.rs b/crates/pdf/src/lab_spot.rs new file mode 100644 index 00000000..8772cdbd --- /dev/null +++ b/crates/pdf/src/lab_spot.rs @@ -0,0 +1,135 @@ +//! Lab alternate spaces for spot colours defined in Lab. +//! +//! The PDF writer gives a Separation colour space a device alternate only, so the export writes +//! Lab spot colours with their CMYK equivalent and [`lab_alternates`] then rewrites those spaces +//! to `[/Separation /Name [/Lab …] ]`, the tint function running from paper white +//! (L 100, a = b = 0) to the colour's Lab values, and moves the cross-reference offsets after the +//! rewritten bytes. + +use vectorcraft_color::cms::Lab; +use vectorcraft_color::cms::lab::D50; + +/// `name` as the PDF writer writes a name object (`/` then the bytes, irregular ones as `#XX`). +fn pdf_name(name: &str) -> Vec { + let mut out = vec![b'/']; + for &b in name.as_bytes() { + let regular = !b"\0\t\n\x0C\r ()<>[]{}/%".contains(&b); + if b != b'#' && (b'!'..=b'~').contains(&b) && regular { + out.push(b); + } else { + out.extend(format!("#{b:02X}").bytes()); + } + } + out +} + +/// A number for PDF syntax: at most 4 decimals, no trailing zeros. +fn num(v: f32) -> String { + let s = format!("{:.4}", v); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +/// The Separation array of spot colour `name` with a Lab alternate. +fn lab_separation(name: &str, lab: Lab) -> Vec { + let mut out = b"[/Separation".to_vec(); + out.extend(pdf_name(name)); + let [x, y, z] = D50.map(num); + let (l, a, b) = (num(lab.l.clamp(0.0, 100.0)), num(lab.a.clamp(-128.0, 127.0)), num(lab.b.clamp(-128.0, 127.0))); + out.extend( + format!( + "[/Lab<>]<>]" + ) + .bytes(), + ); + out +} + +fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { + hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from) +} + +/// Rewrite the DeviceCMYK Separation spaces of `spots` (colorant name, Lab values) in `pdf` (as +/// written by the export: uncompressed objects, a cross-reference table) to Lab alternates. +/// Spaces written another way are left as they are. +pub(crate) fn lab_alternates(pdf: Vec, spots: &[(String, Lab)]) -> Vec { + // (start, end, replacement) of each rewritten array, in file order. + let mut edits: Vec<(usize, usize, Vec)> = vec![]; + for (name, lab) in spots { + let mut head = b"[/Separation".to_vec(); + head.extend(pdf_name(name)); + head.extend(b"/DeviceCMYK<<"); + let mut from = 0; + while let Some(start) = find(&pdf, &head, from) { + let Some(end) = find(&pdf, b">>]", start).map(|e| e + 3) else { break }; + edits.push((start, end, lab_separation(name, *lab))); + from = end; + } + } + let Some(xref) = xref_offset(&pdf) else { return pdf }; + if edits.is_empty() || edits.iter().any(|e| e.1 > xref) { + return pdf; + } + edits.sort_by_key(|e| e.0); + // Where a byte offset of the old file lands in the new one. + let moved = |off: usize| -> usize { + edits.iter().filter(|e| e.1 <= off).fold(off as isize, |o, e| o + e.2.len() as isize - (e.1 - e.0) as isize) as usize + }; + let mut out = Vec::with_capacity(pdf.len() + 256 * edits.len()); + let mut at = 0; + for (start, end, new) in &edits { + out.extend_from_slice(&pdf[at..*start]); + out.extend_from_slice(new); + at = *end; + } + out.extend_from_slice(&pdf[at..xref]); + // The cross-reference table: `xref\n0 N\n` then N 20-byte entries; in-use ones hold offsets. + let tail = &pdf[xref..]; + let Some(entries) = find(tail, b"\n", 5).map(|i| i + 1) else { return pdf }; + let Some(count) = std::str::from_utf8(&tail[5..entries - 1]).ok().and_then(|s| s.split_whitespace().nth(1)?.parse::().ok()) else { + return pdf; + }; + let table_end = entries + 20 * count; + if tail.len() < table_end { + return pdf; + } + out.extend_from_slice(&tail[..entries]); + for e in tail[entries..table_end].chunks(20) { + match std::str::from_utf8(&e[..10]).ok().and_then(|s| s.parse::().ok()) { + Some(off) if e[17] == b'n' => { + out.extend(format!("{:010}", moved(off)).bytes()); + out.extend_from_slice(&e[10..]); + } + _ => out.extend_from_slice(e), + } + } + let rest = &tail[table_end..]; + let Some(sx) = rfind(rest, b"startxref\n") else { return pdf }; + out.extend_from_slice(&rest[..sx]); + out.extend(format!("startxref\n{}\n%%EOF", moved(xref)).bytes()); + out +} + +fn rfind(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).rposition(|w| w == needle) +} + +/// The offset `startxref` gives, when it points at a cross-reference table. +fn xref_offset(pdf: &[u8]) -> Option { + let sx = rfind(pdf, b"startxref")?; + let off: usize = std::str::from_utf8(&pdf[sx + 9..]).ok()?.split_whitespace().next()?.parse().ok()?; + pdf.get(off..)?.starts_with(b"xref").then_some(off) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_are_written_like_the_pdf_writer_writes_them() { + assert_eq!(pdf_name("Ink"), b"/Ink"); + assert_eq!(pdf_name("PMSish 123"), b"/PMSish#20123"); + assert_eq!(pdf_name("A#(b)"), b"/A#23#28b#29"); + assert_eq!((num(0.5), num(-0.0), num(100.0), num(-12.34567)), ("0.5".into(), "0".into(), "100".into(), "-12.3457".into())); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ef400b37..7aafaddf 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -14,6 +14,7 @@ mod export; mod import; +mod lab_spot; pub use export::{export, export_with_report}; pub use import::{import, import_with_report}; diff --git a/crates/render/src/freeform.rs b/crates/render/src/freeform.rs index 2a150d31..e4253079 100644 --- a/crates/render/src/freeform.rs +++ b/crates/render/src/freeform.rs @@ -34,6 +34,7 @@ fn hash_color(c: &Color, h: &mut impl Hasher) { Color::Rgb { r, g, b } => (0u8, r.to_bits(), g.to_bits(), b.to_bits()).hash(h), Color::Cmyk { c, m, y, k } => (1u8, c.to_bits(), m.to_bits(), y.to_bits(), k.to_bits()).hash(h), Color::Gray { k } => (2u8, k.to_bits()).hash(h), + Color::Lab { l, a, b } => (3u8, l.to_bits(), a.to_bits(), b.to_bits()).hash(h), } } diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index ddfbfbf6..840a7bcd 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -75,17 +75,16 @@ pub fn plates(doc: &Document) -> Vec { if let (true, Paint::Solid { color, .. }) = (sw.spot, &sw.paint) && !v.iter().any(|p| p.name == sw.name) { - v.push(Plate { name: sw.name.clone(), spot: true, rgb: c.display_rgb(color) }); + v.push(Plate { name: sw.name.clone(), spot: true, rgb: c.display_rgb(&doc.linked_color(*color, true)) }); } } v } -fn spot_swatch<'a>(doc: &'a Document, name: &str) -> Option<&'a Color> { - doc.swatch(name).filter(|s| s.spot).and_then(|s| match &s.paint { - Paint::Solid { color, .. } => Some(color), - _ => None, - }) +/// The ink colour of spot swatch `name` as it shows ([`Document::linked_color`]). +fn spot_color(doc: &Document, name: &str) -> Option { + let s = doc.swatch(name).filter(|s| s.spot)?; + s.paint.color().map(|c| doc.linked_color(c, true)) } /// A colour's swatch link and tint (`(swatch name, tint 0..1)`), as the colour visitors pass it. @@ -98,7 +97,7 @@ pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) return Inks { cmyk: [tint.clamp(0.0, 1.0); 4], spot: None, registration: true }; } if let Some((name, tint)) = link - && spot_swatch(doc, name).is_some() + && doc.swatch(name).is_some_and(|s| s.spot && s.paint.color().is_some()) { return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))), registration: false }; } @@ -201,9 +200,9 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], for name in visible.iter().filter(|v| !PROCESS_PLATES.contains(&v.as_str())) { let t = ink.spot_tint(name); if t > 0.0 - && let Some(sc) = spot_swatch(doc, name) + && let Some(sc) = spot_color(doc, name) { - let s = c.display_rgb(sc); + let s = c.display_rgb(&sc); for i in 0..3 { rgb[i] *= 1.0 - t * (1.0 - s[i]); } diff --git a/crates/tools/src/params.rs b/crates/tools/src/params.rs index 694b4211..4481aa74 100644 --- a/crates/tools/src/params.rs +++ b/crates/tools/src/params.rs @@ -4,12 +4,13 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Freeform, FreeformPoint, GradientPaint, GradientStop}; -/// A colour as a `color` param in its own model: `[r,g,b]`, `{c,m,y,k}` or `{gray}`. +/// A colour as a `color` param in its own model: `[r,g,b]`, `{c,m,y,k}`, `{gray}` or `{l,a,b}`. pub fn color_json(c: &Color) -> Value { match *c { Color::Rgb { r, g, b } => json!([r, g, b]), Color::Cmyk { c, m, y, k } => json!({"c": c, "m": m, "y": y, "k": k}), Color::Gray { k } => json!({"gray": k}), + Color::Lab { l, a, b } => json!({"l": l, "a": a, "b": b}), } } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 9feb7989..6f0accd9 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -34,6 +34,7 @@ mod save_changes; pub mod save_style_library; pub mod save_swatch_library; mod shapes; +pub mod spot_colors; pub mod swatch_options; pub mod tile_edge_color; mod tools; @@ -164,6 +165,7 @@ registry! { FlattenerPresets: [flattener_presets::KIND] => flattener_presets::SPEC, SaveStyleLibrary: [save_style_library::KIND] => save_style_library::SPEC, Expand: [expand::KIND] => expand::SPEC, + SpotColors: [spot_colors::KIND] => spot_colors::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/spot_colors.rs b/crates/ui-egui/src/dialogs/spot_colors.rs new file mode 100644 index 00000000..d724ebed --- /dev/null +++ b/crates/ui-egui/src/dialogs/spot_colors.rs @@ -0,0 +1,90 @@ +//! Swatches panel → Spot Colors…: whether spot colours defined in Lab show and separate from +//! their Lab values or from their CMYK equivalents. OK runs `swatch.spotOptions`. +//! +//! Fields: `useLab` (true: Lab values; false: CMYK equivalents). + +use serde_json::{Value, json}; + +use super::swatch_options::{grid, label}; +use super::{DialogSpec, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Spot Colors. +pub const KIND: &str = "spotColors"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Spot Colors".into(), body, confirm, min_width: 360.0, ..DialogSpec::FORM }; + +/// The choices: (`useLab`, label, what it does). +const CHOICES: [(bool, &str, &str); 2] = [ + (true, "Lab Values", "Show and print from the Lab definition; PDF files carry it as the ink's alternate color."), + (false, "CMYK Equivalents", "Show and separate through the working CMYK space, as in older documents."), +]; + +/// Open Spot Colors on the active document's setting. +pub fn open(app: &mut VectorcraftApp) -> Result { + let use_lab = app.session.active().ok_or("no document")?.doc.spot_use_lab; + app.ui.dialog = Some(Dialog::new(KIND, json!({ "useLab": use_lab }))); + Ok(Value::Null) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let use_lab = d.bool("useLab"); + grid(ui, |ui| { + for (i, (value, text, help)) in CHOICES.into_iter().enumerate() { + if i == 0 { + label(ui, "Lab Spot Colors:"); + } else { + ui.label(""); + } + ui.vertical(|ui| { + if widgets::radio(ui, text, use_lab == value, true) { + d.fields.insert("useLab".into(), json!(value)); + } + ui.indent(text, |ui| label(ui, help)); + }); + ui.end_row(); + } + }); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + run_and_close(app, "swatch.spotOptions", json!({ "useLab": d.bool("useLab") })) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_color::Color; + use vectorcraft_engine::Session; + + #[test] + fn the_dialog_switches_lab_spots_to_cmyk_and_back() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + assert!(app.run("ui.spotColors", json!({})).is_err(), "needs a document"); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("swatch.new", json!({"name": "Ink", "color": {"l": 60, "a": 50, "b": 20}, "spot": true})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.run("paint.setFill", json!({"swatch": "Ink"})).unwrap(); + let fill = |app: &VectorcraftApp| crate::panels::current_paints(app).0.color().unwrap(); + assert_eq!(fill(&app), Color::lab(60.0, 50.0, 20.0)); + app.run("ui.spotColors", json!({})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().bool("useLab"), "opens on the document's setting"); + // Drawn headlessly in the shared frame; picking the CMYK radio goes through the fields. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + app.ui.dialog.as_mut().unwrap().fields.insert("useLab".into(), json!(false)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert!(!app.session.active().unwrap().doc.spot_use_lab); + assert!(matches!(fill(&app), Color::Cmyk { .. }), "the linked fill shows the CMYK equivalent"); + app.run("ui.spotColors", json!({})).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().bool("useLab")); + app.ui.dialog.as_mut().unwrap().fields.insert("useLab".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(fill(&app), Color::lab(60.0, 50.0, 20.0)); + } +} diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index 1823e228..d8696364 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -4,7 +4,7 @@ //! editing ([`open`]). //! //! Fields: `__swatch` (the swatch being edited), `name`, `spot`, `global`, `mode` (`gray`, `rgb`, -//! `hsb`, `cmyk` or `web`), `color` (`"#rrggbb"` or a colour object such as +//! `hsb`, `lab`, `cmyk` or `web`), `color` (`"#rrggbb"` or a colour object such as //! `{"model": "cmyk", "c": 0.1, "m": 0.2, "y": 0.3, "k": 0}`) and `preview`; a gradient swatch has //! `name`, `preview` and `__gradient` (the gradient shown) only. @@ -25,8 +25,9 @@ pub const KIND: &str = "swatchOptions"; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Swatch Options".into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM }; -/// Colour modes with their `mode` field / `swatch.edit` ids. -const MODES: [(Mode, &str); 5] = [(Mode::Grayscale, "gray"), (Mode::Rgb, "rgb"), (Mode::Hsb, "hsb"), (Mode::Cmyk, "cmyk"), (Mode::WebSafe, "web")]; +/// Colour modes, in menu order, with their `mode` field / `swatch.edit` ids. +const MODES: [(Mode, &str); 6] = + [(Mode::Grayscale, "gray"), (Mode::Rgb, "rgb"), (Mode::Hsb, "hsb"), (Mode::Lab, "lab"), (Mode::Cmyk, "cmyk"), (Mode::WebSafe, "web")]; const TYPES: [&str; 2] = ["Process Color", "Spot Color"]; @@ -178,11 +179,9 @@ pub(super) fn editor(ui: &mut egui::Ui, d: &mut Dialog) -> bool { } ui.end_row(); label(ui, "Color Mode:"); - let mode = mode_of(d); - let labels: Vec<&str> = Mode::ALL.iter().map(|m| m.1).collect(); - let current = Mode::ALL.iter().find(|m| m.0 == mode).map_or("RGB", |m| m.1); - if let Some(i) = widgets::dropdown(ui, "swatch-mode", current, &labels, 200.0) { - let m = Mode::ALL[i].0; + let labels = MODES.map(|m| m.0.label()); + if let Some(i) = widgets::dropdown(ui, "swatch-mode", mode_of(d).label(), &labels, 200.0) { + let m = MODES[i].0; // Switching modes converts the colour (HSB is a view of RGB), like the Color panel. let base = if m == Mode::Hsb { Mode::Rgb } else { m }; set_color(d, from_components(base, &components(base, &color_of(d)))); @@ -213,17 +212,16 @@ fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool { for (i, lbl) in mode.labels().iter().enumerate() { ui.horizontal(|ui| { ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new(*lbl).size(12.5).color(t.text))); - let max = mode.max(i); let v = comps.get(i).copied().unwrap_or(0.0); let track = |x: f32| crate::panels::c32(&track_color(mode, &comps, i, x)); - if let (Some(nv), _) = widgets::color_slider(ui, ("swatch-slider", i), v / max, 200.0, &track) { + if let (Some(nv), _) = widgets::color_slider(ui, ("swatch-slider", i), mode.unit(i, v), 200.0, &track) { let mut c2 = comps.clone(); - c2[i] = if mode == Mode::WebSafe { ((nv * 5.0).round() / 5.0) * max } else { (nv * max).round() }; + c2[i] = mode.value_at(i, nv); new = Some((from_components(mode, &c2), c2)); } if let Some(fv) = widgets::plain_field(ui, ("swatch-field", i), v as f64, mode.suffix(i), 0, 52.0) { let mut c2 = comps.clone(); - c2[i] = (fv as f32).clamp(0.0, max); + c2[i] = (fv as f32).clamp(mode.min(i), mode.max(i)); new = Some((from_components(mode, &c2), c2)); } }); @@ -286,4 +284,29 @@ mod tests { (Some("A"), Some("B"), Some(true), Some("cmyk")) ); } + + #[test] + fn lab_mode_defines_a_spot_colour_in_lab() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 80, "y": 60, "k": 0}, "spot": true})).unwrap(); + app.run("ui.swatchOptions", json!({"name": "Ink"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("lab")); + // Drawn headlessly: three Lab sliders (a and b signed) preview the edit. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + assert_eq!(mode_of(app.ui.dialog.as_ref().unwrap()).labels(), ["L", "a", "b"]); + super::super::confirm(&mut app).unwrap(); + let ink = app.session.active().unwrap().doc.swatch("Ink").unwrap().paint.color().unwrap(); + assert_eq!(ink.model(), vectorcraft_color::cms::Model::Lab); + assert_eq!(ink.to_hex(), Color::cmyk(0.0, 0.8, 0.6, 0.0).to_hex(), "the same colour, now in Lab"); + // Lab values typed into the fields. + app.run("ui.swatchOptions", json!({"name": "Ink"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("mode"), "lab", "opens in the colour's mode"); + app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!(Color::lab(40.0, -30.0, 20.0))); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().doc.swatch("Ink").unwrap().paint.color(), Some(Color::lab(40.0, -30.0, 20.0))); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0ab0c7c4..fb5dacb0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -321,6 +321,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button) → {url}", ), + ( + "ui.spotColors", + "Spot Colors…", + "", + "{} open Spot Colors (dialog `spotColors`, field `useLab`: true shows and separates Lab spot colours from their Lab values, false from their CMYK equivalents); OK runs swatch.spotOptions as one undo step", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -689,6 +695,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::expand::open(app), "attributes.openUrl" => crate::panels::attributes::open_url(app, p), + "ui.spotColors" => crate::dialogs::spot_colors::open(app), _ => return None, }; Some(r) @@ -874,6 +881,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.saveGraphicStyleLibrary" => app.session.active().is_some(), id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => crate::panels::graphic_styles::user_library(app, id).is_some(), "ui.expandDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.spotColors" => app.session.active().is_some(), _ => true, } } diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 07cfee6d..ebfdfe5d 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -27,31 +27,61 @@ pub enum Mode { Hsb, Cmyk, WebSafe, + /// CIE Lab: Swatch Options offers it; the panel shows it for Lab colours. + Lab, } impl Mode { - pub const ALL: [(Mode, &'static str); 5] = - [(Mode::Grayscale, "Grayscale"), (Mode::Rgb, "RGB"), (Mode::Hsb, "HSB"), (Mode::Cmyk, "CMYK"), (Mode::WebSafe, "Web Safe RGB")]; + /// The panel menu's modes, in order. + pub const ALL: [Mode; 5] = [Mode::Grayscale, Mode::Rgb, Mode::Hsb, Mode::Cmyk, Mode::WebSafe]; + pub fn label(self) -> &'static str { + match self { + Mode::Grayscale => "Grayscale", + Mode::Rgb => "RGB", + Mode::Hsb => "HSB", + Mode::Cmyk => "CMYK", + Mode::WebSafe => "Web Safe RGB", + Mode::Lab => "Lab", + } + } pub fn labels(self) -> &'static [&'static str] { match self { Mode::Grayscale => &["K"], Mode::Rgb | Mode::WebSafe => &["R", "G", "B"], Mode::Hsb => &["H", "S", "B"], Mode::Cmyk => &["C", "M", "Y", "K"], + Mode::Lab => &["L", "a", "b"], } } - /// Maximum of each displayed component (minimum is 0). + /// Maximum of each displayed component. pub fn max(self, i: usize) -> f32 { match self { Mode::Rgb | Mode::WebSafe => 255.0, Mode::Hsb if i == 0 => 360.0, + Mode::Lab if i > 0 => 127.0, _ => 100.0, } } + /// Minimum of each displayed component (0 but for Lab's a and b). + pub fn min(self, i: usize) -> f32 { + if self == Mode::Lab && i > 0 { -128.0 } else { 0.0 } + } + /// Slider position (0..1) of component `i` at value `v`. + pub fn unit(self, i: usize, v: f32) -> f32 { + (v - self.min(i)) / (self.max(i) - self.min(i)) + } + /// Component `i` at slider position `t` (0..1), in whole units (Web Safe RGB snaps to its six + /// levels). + pub fn value_at(self, i: usize, t: f32) -> f32 { + match self { + Mode::WebSafe => ((t * 5.0).round() / 5.0) * self.max(i), + _ => (self.min(i) + t * (self.max(i) - self.min(i))).round(), + } + } pub fn suffix(self, i: usize) -> &'static str { match self { - Mode::Rgb | Mode::WebSafe => "", + Mode::Rgb | Mode::WebSafe | Mode::Lab => "", Mode::Hsb if i == 0 => "°", _ => "%", } @@ -62,11 +92,12 @@ impl Mode { Color::Rgb { .. } => Mode::Rgb, Color::Cmyk { .. } => Mode::Cmyk, Color::Gray { .. } => Mode::Grayscale, + Color::Lab { .. } => Mode::Lab, } } } -/// Displayed component values of `c` in `mode` (RGB 0–255, HSB °/%/%, CMYK %, K %). +/// Displayed component values of `c` in `mode` (RGB 0–255, HSB °/%/%, CMYK %, K %, Lab L*a*b*). pub fn components(mode: Mode, c: &Color) -> Vec { match mode { Mode::Grayscale => { @@ -85,6 +116,10 @@ pub fn components(mode: Mode, c: &Color) -> Vec { vec![h, s * 100.0, b * 100.0] } Mode::Cmyk => c.to_cmyk().iter().map(|v| v * 100.0).collect(), + Mode::Lab => { + let l = c.to_lab(); + vec![l.l, l.a, l.b] + } } } @@ -102,6 +137,7 @@ pub fn from_components(mode: Mode, v: &[f32]) -> Color { (g(2) / 100.0).clamp(0.0, 1.0), (g(3) / 100.0).clamp(0.0, 1.0), ), + Mode::Lab => Color::lab(g(0).clamp(0.0, 100.0), g(1).clamp(-128.0, 127.0), g(2).clamp(-128.0, 127.0)), } } @@ -121,7 +157,7 @@ pub fn is_web_safe(c: &Color) -> bool { pub fn track_color(mode: Mode, comps: &[f32], i: usize, t: f32) -> Color { let mut v = comps.to_vec(); if i < v.len() { - v[i] = t * mode.max(i); + v[i] = mode.min(i) + t * (mode.max(i) - mode.min(i)); } // HSB hue track at full saturation/brightness reads better when S or B are 0. let m = if mode == Mode::WebSafe { Mode::Rgb } else { mode }; @@ -144,6 +180,7 @@ pub fn spectrum_at(mode: Mode, x: f32, y: f32) -> Color { Color::cmyk(c0, m, yy, k) } Mode::WebSafe => web_safe(&c), + Mode::Lab => c.in_model(vectorcraft_color::cms::Model::Lab), _ => c, } } @@ -228,8 +265,8 @@ impl Mode { } /// The next mode (Shift-click on the spectrum). pub fn next(self) -> Mode { - let i = Mode::ALL.iter().position(|(m, _)| *m == self).unwrap_or(0); - Mode::ALL[(i + 1) % Mode::ALL.len()].0 + let i = Mode::ALL.iter().position(|m| *m == self).unwrap_or(0); + Mode::ALL[(i + 1) % Mode::ALL.len()] } /// Can Shift-drag move the sliders in tandem? (Not HSB, and Grayscale has one slider.) fn tandem(self) -> bool { @@ -491,19 +528,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for (i, lbl) in mode.labels().iter().enumerate() { ui.horizontal(|ui| { ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new(*lbl).size(12.5).color(t.text))); - let max = mode.max(i); let v = comps.get(i).copied().unwrap_or(0.0); let track = |x: f32| to32(&track_color(mode, &comps, i, x)); let grey = |_x: f32| t.input; let (nv, phase) = if enabled { - widgets::color_slider(ui, ("color-slider", i), v / max, slider_w, &track) + widgets::color_slider(ui, ("color-slider", i), mode.unit(i, v), slider_w, &track) } else { widgets::color_slider(ui, ("color-slider", i), 0.0, slider_w, &grey) }; if let Some(nv) = nv && enabled { - let nv = if mode == Mode::WebSafe { ((nv * 5.0).round() / 5.0) * max } else { (nv * max).round() }; + let nv = mode.value_at(i, nv); let c2 = if shift && mode.tandem() { tandem(mode, &comps, i, nv) } else { @@ -517,7 +553,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { && enabled { let mut c2 = comps.clone(); - c2[i] = (fv as f32).clamp(0.0, max); + c2[i] = (fv as f32).clamp(mode.min(i), mode.max(i)); new = Some((from_components(mode, &c2), Live::Released, c2)); } }); @@ -584,8 +620,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let color = tgt.color(); // Tint mode checks no colour mode: picking one makes the tint a process colour. let cur = display_mode(pstate(ui.ctx(), "color-mode"), color.as_ref()); - for (m, label) in Mode::ALL { - if menu_item(ui, label, true, m == cur && tgt.tint().is_none()) { + for m in Mode::ALL { + if menu_item(ui, m.label(), true, m == cur && tgt.tint().is_none()) { set_mode(app, ui.ctx(), &tgt, m); } } @@ -705,6 +741,31 @@ mod tests { }); } + #[test] + fn lab_colours_show_lab_sliders_with_signed_ranges() { + let lab = Color::lab(50.0, -20.0, 30.0); + assert_eq!(Mode::of(&lab), Mode::Lab); + assert_eq!(display_mode(Some(Mode::Rgb), Some(&lab)), Mode::Lab); + assert!(!Mode::ALL.contains(&Mode::Lab), "not a panel menu mode"); + assert_eq!(components(Mode::Lab, &lab), vec![50.0, -20.0, 30.0]); + assert_eq!(from_components(Mode::Lab, &[50.0, -20.0, 30.0]), lab); + assert_eq!(from_components(Mode::Lab, &[150.0, -300.0, 300.0]), Color::lab(100.0, -128.0, 127.0), "clamped"); + assert_eq!((Mode::Lab.unit(1, -128.0), Mode::Lab.unit(2, 127.0), Mode::Lab.unit(0, 50.0)), (0.0, 1.0, 0.5)); + assert_eq!((Mode::Lab.value_at(1, 0.0), Mode::Lab.value_at(0, 1.0)), (-128.0, 100.0)); + assert_eq!((Mode::Rgb.value_at(0, 0.5), Mode::WebSafe.value_at(0, 0.45)), (128.0, 102.0), "other modes as before"); + assert_eq!(track_color(Mode::Lab, &[50.0, -20.0, 30.0], 1, 0.0), Color::lab(50.0, -128.0, 30.0)); + assert_eq!(convert_to(Mode::Lab, &Color::rgb(1.0, 0.0, 0.0)).model(), vectorcraft_color::cms::Model::Lab); + // In the panel: a Lab fill shows L, a, b. + let mut app = app(); + let ctx = egui::Context::default(); + app.run("paint.setFill", json!({"color": {"l": 50, "a": -20, "b": 30}})).unwrap(); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + let _ = ctx.run_ui(Default::default(), |ui| { + let mode = display_mode(pstate(ui.ctx(), "color-mode"), target(&app, ui).color().as_ref()); + assert_eq!(mode.labels(), ["L", "a", "b"]); + }); + } + #[test] fn pure_blue_is_out_of_gamut_in_rgb() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index 9af9879c..0948c6db 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -22,6 +22,7 @@ pub fn color_values(c: &Color) -> Vec<(&'static str, String)> { ] } Color::Gray { k } => vec![("K", format!("{:.0}%", k * 100.0))], + Color::Lab { l, a, b } => vec![("L", format!("{l:.0}")), ("a", format!("{a:.0}")), ("b", format!("{b:.0}"))], } } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index be372fd5..b26ff622 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -118,7 +118,13 @@ fn entries(app: &VectorcraftApp, kind: Kind, query: &str) -> Vec { // A tint swatch shows its base's kind (global or spot). let sw = |s: &vectorcraft_color::Swatch| { let (global, spot) = s.tint_of().and_then(|(base, _)| d.swatch(base)).map_or((s.global, s.spot), |b| (b.global, b.spot)); - Entry::Swatch { name: s.name.clone(), paint: s.paint.clone(), global, spot } + // A spot colour shows the colour its art shows (a Lab one's CMYK equivalent when the Spot + // Colors options use those). + let paint = match s.paint { + Paint::Solid { color, swatch: None, .. } if spot => Paint::solid(d.linked_color(color, true)), + _ => s.paint.clone(), + }; + Entry::Swatch { name: s.name.clone(), paint, global, spot } }; // None first, then Registration, then the rest (the reference app's order). let (specials, rest): (Vec<_>, Vec<_>) = d.swatches.iter().partition(|s| s.paint.is_none()); @@ -219,6 +225,9 @@ pub(crate) fn draw_folder(ui: &Ui, r: Rect) { /// The ink colours of the CMYK glyph (cyan, magenta, yellow, black) and the RGB glyph's bars. const CMYK_INKS: [Color32; 4] = [Color32::from_rgb(0, 174, 239), Color32::from_rgb(236, 0, 140), Color32::from_rgb(255, 242, 0), Color32::BLACK]; const RGB_BARS: [Color32; 3] = [Color32::from_rgb(255, 0, 0), Color32::from_rgb(0, 200, 0), Color32::from_rgb(0, 0, 255)]; +/// Lab's opponent axes: green | red (a) over blue | yellow (b). +const LAB_AXES: [Color32; 4] = + [Color32::from_rgb(0, 166, 81), Color32::from_rgb(230, 30, 60), Color32::from_rgb(0, 90, 220), Color32::from_rgb(250, 205, 0)]; /// A swatch's kind in words, for list tooltips ("Global Process Color, CMYK"). pub(crate) fn describe(paint: &Paint, global: bool, spot: bool) -> String { @@ -241,7 +250,8 @@ pub(crate) fn describe(paint: &Paint, global: bool, spot: bool) -> String { /// The kind and colour-mode icons at the right of list row `r`: spot (a dot in a ring), global (a /// square with a filled corner) or process colours (a square), gradients and patterns; then a -/// colour's model (CMYK as four ink quarters, RGB as three bars, Gray as a grey square). +/// colour's model (CMYK as four ink quarters, RGB as three bars, Gray as a grey square, Lab as its +/// two opponent axes). pub(crate) fn list_icons(ui: &Ui, r: Rect, paint: &Paint, global: bool, spot: bool) { let t = Tokens::get(ui.ctx()); let p = ui.painter(); @@ -260,13 +270,15 @@ pub(crate) fn list_icons(ui: &Ui, r: Rect, paint: &Paint, global: bool, spot: bo p.add(Shape::convex_polygon(vec![c, c - vec2(6.0, 0.0), c - vec2(0.0, 6.0)], t.icon, Stroke::NONE)); } } - match color { - Color::Cmyk { .. } => { - let q = mode.size() / 2.0; - for (i, ink) in CMYK_INKS.into_iter().enumerate() { - p.rect_filled(Rect::from_min_size(mode.min + vec2((i % 2) as f32 * q.x, (i / 2) as f32 * q.y), q), 0.0, ink); - } + let quarters = |colors: [Color32; 4]| { + let q = mode.size() / 2.0; + for (i, c) in colors.into_iter().enumerate() { + p.rect_filled(Rect::from_min_size(mode.min + vec2((i % 2) as f32 * q.x, (i / 2) as f32 * q.y), q), 0.0, c); } + }; + match color { + Color::Cmyk { .. } => quarters(CMYK_INKS), + Color::Lab { .. } => quarters(LAB_AXES), Color::Rgb { .. } => { let w = mode.width() / 3.0; for (i, bar) in RGB_BARS.into_iter().enumerate() { @@ -943,7 +955,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { { app.run("ui.swatchOptions", json!({"name": n})).ok(); } - menu_item(ui, "Spot Colors…", false, false); + if menu_item(ui, "Spot Colors…", app.session.active().is_some(), false) { + app.run("ui.spotColors", json!({})).ok(); + } ui.separator(); ui.menu_button("Open Swatch Library", |ui| library_panel::library_menu::(app, ui)); if menu_item(ui, "Save Swatch Library…", true, false) { @@ -1492,4 +1506,20 @@ mod tests { assert_eq!(View::MediumThumb.tile(), (15.5, 17.0)); assert!(View::SmallList.is_list() && !View::LargeThumb.is_list()); } + + #[test] + fn lab_spot_tiles_show_the_colour_the_spot_colors_options_pick() { + let mut app = app(); + let lab = Color::lab(60.0, 50.0, 20.0); + app.run("swatch.new", json!({"name": "Ink", "color": lab, "spot": true})).unwrap(); + let tile = |app: &VectorcraftApp| { + let ink = entries(app, Kind::All, "Ink").into_iter().find(|e| e.name() == "Ink"); + let Some(Entry::Swatch { paint, .. }) = ink else { panic!("the Ink tile") }; + paint.color().unwrap() + }; + assert_eq!(tile(&app), lab); + app.run("swatch.spotOptions", json!({"useLab": false})).unwrap(); + assert_eq!(tile(&app), app.session.active().unwrap().doc.global_color("Ink").unwrap()); + assert!(matches!(tile(&app), Color::Cmyk { .. })); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 12ce2d88..952f6291 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -22,7 +22,7 @@ | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch -(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object, +(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object, `preview`), which previews on the canvas while open; `ui.dialog.confirm` applies it with `swatch.edit` as one undo step and `ui.dialog.cancel` rolls the preview back. For a gradient swatch the dialog has `name` only (it shows the gradient) and OK renames it; pattern swatches open pattern editing instead. Dropping a gradient (or colour) on a @@ -148,3 +148,7 @@ one undo step. Attributes panel: `window.panel {panel: "attributes"}` (Cmd+F11) shows it; its controls run `attributes.set`, `path.reverse {reversed}` and `path.setFillRule {rule}`, and its Browser button runs the UI command `attributes.openUrl {url?}`, which opens the URL (default: the selection's) in the web browser and answers `{url}`. + +Spot Colors: the Swatches panel menu's Spot Colors… (`ui.spotColors`) opens the `spotColors` dialog (field `useLab`: +true shows and separates spot colours defined in Lab from their Lab values, false from their working-CMYK +equivalents); `ui.dialog.confirm` runs `swatch.spotOptions {useLab}` as one undo step. diff --git a/docs/mcp.md b/docs/mcp.md index ac3ded6c..bef863b5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -566,6 +566,20 @@ stroke}`), and `object.mesh.create` on a gradient-filled object colours the mesh ```json {"name":"run_command","arguments":{"command":"object.expand","params":{"stroke":false,"gradient":"mesh"}}} {"name":"run_command","arguments":{"command":"object.expand","params":{"steps":16}}} + +## Lab spot colours + +Colours can be CIE Lab (D50): give `{"l": 55, "a": 60, "b": 40}` (L 0–100, a and b about −128–127) wherever a +colour is taken; documents store them as `{"model": "lab", …}`. `swatch.new {color, spot: true}` or +`swatch.edit {name, mode: "lab"}` defines a spot colour in Lab (Swatch Options' Lab mode). `swatch.spotOptions +{useLab}` sets the document's Spot Colors options: with `true` (the default) Lab spot colours show and print from +their Lab values and PDF export writes their Separation spaces with a Lab alternate; with `false` art linked to them +takes their working-CMYK equivalents and PDF uses a DeviceCMYK alternate. Without `useLab` it answers the current +setting. + +```json +{"name":"run_command","arguments":{"command":"swatch.new","params":{"name":"Lab Ink","color":{"l":55,"a":60,"b":40},"spot":true}}} +{"name":"run_command","arguments":{"command":"swatch.spotOptions","params":{"useLab":false}}} ``` ## Attributes, URLs and image maps