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:
parent
c1df6f0621
commit
ea61d34a93
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}"))],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
14
docs/mcp.md
14
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue