M3.85: spot colours defined in Lab, with Spot Colors options for Lab values or CMYK equivalents

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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 03:27:45 +07:00
parent c1df6f0621
commit ea61d34a93
30 changed files with 855 additions and 92 deletions

View File

@ -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,
}
}

View File

@ -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}");
}

View File

@ -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::<Color>(&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));

View File

@ -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 {

View File

@ -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))));

View File

@ -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),
}
}

View File

@ -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,
}
}

View File

@ -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<Color> {
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),
}
}

View File

@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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<f32> {
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],
}
}

View File

@ -253,7 +253,8 @@ pub fn matrix_param(p: &Value, key: &str) -> Option<Affine> {
}
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<Color> {
match v {
Value::String(s) => Color::from_hex(s),
@ -267,6 +268,9 @@ pub fn color_value(v: &Value) -> Option<Color> {
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,

View File

@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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)
),

View File

@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
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<Color> {
"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<Color>,
/// 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<Value> {
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<Value> {
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<String>, 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<Value> {
})?;
ok()
}
fn swatch_spot_options(s: &mut Session, p: &Value) -> Result<Value> {
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<String>, &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
}
}

View File

@ -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;

View File

@ -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::<vectorcraft_doc::Document>(v).unwrap().spot_use_lab);
}
fn pdf_bytes(s: &Session) -> Vec<u8> {
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<</WhitePoint[0.9642 1 0.8252]/Range[-128 127 -128 127]>>]<</FunctionType 2/Domain[0 1]/Range[0 100 -128 127 -128 127]/C0[100 0 0]/C1[55 60 40]/N 1>>]"),
"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);
}

View File

@ -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

View File

@ -71,7 +71,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
}
pdf.set_metadata(meta);
let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new(), brushes: None, knockout: doc.page_knockout };
let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new(), brushes: None, knockout: doc.page_knockout, lab_spots: vec![] };
for i in indices {
let ab = &doc.artboards[i];
let r = ab.rect;
@ -95,6 +95,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
page.finish();
}
let bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
let bytes = if ex.lab_spots.is_empty() { bytes } else { crate::lab_spot::lab_alternates(bytes, &ex.lab_spots) };
let mut warnings = ex.warnings;
warnings.dedup();
Ok(ExportReport { bytes, warnings })
@ -141,6 +142,8 @@ struct Exporter<'a> {
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// 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<SeparationSpace> {
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<SeparationSpace> {
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))
}

135
crates/pdf/src/lab_spot.rs Normal file
View File

@ -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 …] <tint function>]`, 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<u8> {
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<u8> {
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<</WhitePoint[{x} {y} {z}]/Range[-128 127 -128 127]>>]<</FunctionType 2/Domain[0 1]/Range[0 100 -128 127 -128 127]/C0[100 0 0]/C1[{l} {a} {b}]/N 1>>]"
)
.bytes(),
);
out
}
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
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<u8>, spots: &[(String, Lab)]) -> Vec<u8> {
// (start, end, replacement) of each rewritten array, in file order.
let mut edits: Vec<(usize, usize, Vec<u8>)> = 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::<usize>().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::<usize>().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<usize> {
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<usize> {
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()));
}
}

View File

@ -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};

View File

@ -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),
}
}

View File

@ -75,17 +75,16 @@ pub fn plates(doc: &Document) -> Vec<Plate> {
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<Color> {
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]);
}

View File

@ -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}),
}
}

View File

@ -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).

View File

@ -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<Value, String> {
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<Value, String> {
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));
}
}

View File

@ -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)));
}
}

View File

@ -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<R
},
"ui.expandDialog" => 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,
}
}

View File

@ -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<f32> {
match mode {
Mode::Grayscale => {
@ -85,6 +116,10 @@ pub fn components(mode: Mode, c: &Color) -> Vec<f32> {
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();

View File

@ -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}"))],
}
}

View File

@ -118,7 +118,13 @@ fn entries(app: &VectorcraftApp, kind: Kind, query: &str) -> Vec<Entry> {
// 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::<SwatchLibraries>(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 { .. }));
}
}

View File

@ -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.

View File

@ -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