mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 11:54:03 +00:00
Merge pull request #6245 from dotCooCoo/fix/generic-cmyk-swop
Color: Generic CMYK tints gain as CGATS TR 001 gives for SWOP printing
This commit is contained in:
+176
-102
@@ -1,19 +1,26 @@
|
||||
//! "VectorCraft Generic CMYK (SWOP-like)": our own parametric press model.
|
||||
//!
|
||||
//! We can't redistribute Adobe's or ECI's characterisation profiles, so the built-in CMYK space is
|
||||
//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*: the numbers
|
||||
//! are rounded, typical values for coated web offset, not a measured dataset.
|
||||
//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*. Two of its
|
||||
//! figures are published values for SWOP printing: the tone value increase of each ink at 40% and
|
||||
//! 50% from CGATS TR 001 (<https://registry.color.org/cmyk-registry/cgats_tr_001>), and the 300%
|
||||
//! total ink limit (<https://en.wikipedia.org/wiki/Specifications_for_Web_Offset_Publications>).
|
||||
//! The paper, solid and overprint colors and the black-generation curve are VectorCraft's own values.
|
||||
//!
|
||||
//! * **Forward (CMYK → XYZ):** each ink's nominal dot area is widened by a parabolic dot-gain
|
||||
//! curve (`a + 4·g·a·(1−a)`, i.e. `g` gain at 50%); the effective areas mix the eight CMY
|
||||
//! Neugebauer primaries with Demichel weights; black is mixed in the same way against
|
||||
//! "primary + K" colours whose optical densities add but saturate towards a maximum density
|
||||
//! (normalised so K on paper is exact), so rich blacks don't multiply to an unrealistic depth.
|
||||
//! * **Forward (CMYK → XYZ):** each ink's nominal dot area `a` is widened by a tone curve
|
||||
//! `a + 4·g·a·(1−a)·(1 + s·(2a−1))` that passes through the TR 001 tone value increase at 50%
|
||||
//! (`g`) and 40% (which sets `s`). The effective areas mix the 16 Neugebauer primaries (paper and
|
||||
//! every solid and overprint of C, M, Y and K, media-relative) with Demichel weights, linearly in
|
||||
//! XYZ, so a single ink's colorimetric tone value increase (Murray–Davies) is the TR 001 value.
|
||||
//! * **Inverse (Lab → CMYK):** damped, bounded Gauss–Newton on the forward model solves CMY for a
|
||||
//! fixed K. Black generation is GCR: solve CMY with K = 0, take the grey component
|
||||
//! `min(C, M, Y)`, map it through the black-generation curve to K, re-solve CMY. Under-colour
|
||||
//! fixed K. Black generation is GCR: solve CMY with K = 0, take the gray component
|
||||
//! `min(C, M, Y)`, add [`BlackGeneration::shortfall`] for each L* point by which that solve stays
|
||||
//! lighter than the target, map the sum through the black curve to K, re-solve CMY. Under-color
|
||||
//! removal enforces the total area coverage (TAC) limit by adding K and re-solving, then scaling
|
||||
//! CMY as a last resort.
|
||||
//! CMY as a last resort. The black point, which source black separates to and which displays as
|
||||
//! black with black-point compensation, is [`BlackGeneration::black_point_k`] black with neutral
|
||||
//! CMY filling the TAC. With compensation, a build darker than it in every XYZ channel also
|
||||
//! displays as black (see [`GenericCmyk::display_xyz`]).
|
||||
//! * **Rendering intents:** colorimetric intents map the source white to paper (relative) or not
|
||||
//! (absolute) and clip; perceptual applies black-point compensation plus soft chroma compression
|
||||
//! against a gamut boundary descriptor (segment maxima of the forward model); saturation keeps
|
||||
@@ -24,50 +31,82 @@ use std::sync::OnceLock;
|
||||
use super::Intent;
|
||||
use super::lab::{D50, Lab, delta_e2000, lab_to_xyz, srgb_to_lab, xyz_to_lab, xyz_to_srgb};
|
||||
|
||||
/// Black generation strength.
|
||||
/// Black generation.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BlackGeneration {
|
||||
/// Grey component below which no black is generated.
|
||||
pub start: f32,
|
||||
/// Maximum black.
|
||||
pub max_k: f32,
|
||||
/// Curve exponent (>1 = lighter black in the midtones).
|
||||
pub gamma: f32,
|
||||
/// The black curve as `(x, K)` knots with rising `x`, read by linear interpolation. `x` is the
|
||||
/// gray component of the solve without black plus [`Self::shortfall`] per L* point by which
|
||||
/// that solve stays lighter than the target.
|
||||
pub curve: [(f32, f32); 13],
|
||||
/// Added to the gray component for each L* point the black-free solve stays too light.
|
||||
pub shortfall: f32,
|
||||
/// Black of the black point, whose CMY is neutral and fills the TAC.
|
||||
pub black_point_k: f32,
|
||||
}
|
||||
|
||||
/// Parameters of the parametric press model.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenericCmykParams {
|
||||
pub name: &'static str,
|
||||
/// Paper color (absolute Lab, D50).
|
||||
pub paper: Lab,
|
||||
/// Solid overprints, Demichel order: C, M, CM, Y, CY, MY, CMY.
|
||||
pub solids: [Lab; 7],
|
||||
pub black: Lab,
|
||||
/// Dot gain at 50% per ink (C, M, Y, K).
|
||||
pub dot_gain: [f32; 4],
|
||||
/// Media-relative Lab of the solids and overprints, Demichel order (index `c | m<<1 | y<<2 |
|
||||
/// k<<3`, paper left out): C, M, CM, Y, CY, MY, CMY, then K, CK, MK, CMK, YK, CYK, MYK, CMYK.
|
||||
pub primaries: [Lab; 15],
|
||||
/// Tone value increase at 50% per ink (C, M, Y, K).
|
||||
pub tvi50: [f32; 4],
|
||||
/// Tone value increase at 40% per ink (C, M, Y, K).
|
||||
pub tvi40: [f32; 4],
|
||||
/// Total area coverage limit (sum of inks, 1.0 = 100%).
|
||||
pub tac: f32,
|
||||
pub bg: BlackGeneration,
|
||||
}
|
||||
|
||||
/// Rounded, typical SWOP-like values (not measured data).
|
||||
/// The built-in SWOP-like press: TR 001 tone value increase, a 300% ink limit, and VectorCraft's
|
||||
/// own paper, primaries and black curve.
|
||||
pub fn swop_like() -> GenericCmykParams {
|
||||
GenericCmykParams {
|
||||
name: super::GENERIC_CMYK,
|
||||
paper: Lab::new(93.0, -0.5, 2.5),
|
||||
solids: [
|
||||
Lab::new(55.0, -37.0, -50.0), // C
|
||||
Lab::new(48.0, 74.0, -3.0), // M
|
||||
Lab::new(24.0, 22.0, -46.0), // CM (blue)
|
||||
Lab::new(89.0, -5.0, 93.0), // Y
|
||||
Lab::new(50.0, -65.0, 27.0), // CY (green)
|
||||
Lab::new(47.0, 68.0, 48.0), // MY (red)
|
||||
Lab::new(24.0, 1.0, 0.0), // CMY
|
||||
primaries: [
|
||||
Lab::new(65.7, -42.6, -47.2), // C
|
||||
Lab::new(55.5, 76.2, -9.4), // M
|
||||
Lab::new(31.1, 20.3, -49.7), // CM (blue)
|
||||
Lab::new(96.2, -7.0, 90.1), // Y
|
||||
Lab::new(61.0, -68.4, 30.1), // CY (green)
|
||||
Lab::new(55.0, 69.8, 45.0), // MY (red)
|
||||
Lab::new(28.3, 0.7, -0.5), // CMY
|
||||
Lab::new(21.5, 0.8, -0.9), // K
|
||||
Lab::new(15.9, -8.4, -10.0), // CK
|
||||
Lab::new(14.7, 15.4, -1.0), // MK
|
||||
Lab::new(11.2, 8.9, -6.6), // CMK
|
||||
Lab::new(20.2, -2.4, 12.0), // YK
|
||||
Lab::new(15.7, -14.6, 4.6), // CYK
|
||||
Lab::new(14.5, 11.2, 5.7), // MYK
|
||||
Lab::new(11.0, -0.7, 0.1), // CMYK
|
||||
],
|
||||
black: Lab::new(19.0, 0.5, 0.5),
|
||||
dot_gain: [0.20, 0.20, 0.20, 0.22],
|
||||
tvi50: [0.200, 0.176, 0.168, 0.216],
|
||||
tvi40: [0.200, 0.173, 0.167, 0.219],
|
||||
tac: 3.0,
|
||||
bg: BlackGeneration { start: 0.1, max_k: 0.95, gamma: 1.25 },
|
||||
bg: BlackGeneration {
|
||||
curve: [
|
||||
(0.0, 0.0),
|
||||
(0.25, 0.0),
|
||||
(0.35, 0.006),
|
||||
(0.45, 0.059),
|
||||
(0.55, 0.139),
|
||||
(0.65, 0.23),
|
||||
(0.75, 0.322),
|
||||
(0.85, 0.41),
|
||||
(0.95, 0.511),
|
||||
(1.05, 0.6),
|
||||
(1.2, 0.733),
|
||||
(1.35, 0.858),
|
||||
(1.5, 1.0),
|
||||
],
|
||||
shortfall: 0.03,
|
||||
black_point_k: 0.9,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,11 +122,12 @@ const GBD_H: usize = 72; // 5° bins
|
||||
|
||||
pub struct GenericCmyk {
|
||||
pub params: GenericCmykParams,
|
||||
/// XYZ of the 8 CMY primaries (index = c | m<<1 | y<<2), without and with K.
|
||||
prim: [[f32; 3]; 8],
|
||||
prim_k: [[f32; 3]; 8],
|
||||
/// Media-relative XYZ of the 16 primaries (index = c | m<<1 | y<<2 | k<<3; 0 is paper).
|
||||
prim: [[f32; 3]; 16],
|
||||
/// Shape `s` of each ink's tone curve, from its 40% and 50% tone value increase.
|
||||
skew: [f32; 4],
|
||||
paper_xyz: [f32; 3],
|
||||
/// Media-relative XYZ of the darkest printable black (for black-point compensation).
|
||||
/// Media-relative XYZ of the black point (for black-point compensation).
|
||||
black_rel: [f32; 3],
|
||||
gbd: OnceLock<Gbd>,
|
||||
}
|
||||
@@ -95,37 +135,17 @@ pub struct GenericCmyk {
|
||||
impl GenericCmyk {
|
||||
pub fn new(params: GenericCmykParams) -> Self {
|
||||
let paper_xyz = lab_to_xyz(params.paper);
|
||||
let mut prim = [[0.0f32; 3]; 8];
|
||||
prim[0] = paper_xyz;
|
||||
for (p, lab) in prim[1..].iter_mut().zip(params.solids.iter()) {
|
||||
*p = lab_to_xyz(*lab);
|
||||
let mut prim = [D50; 16];
|
||||
for (p, lab) in prim[1..].iter_mut().zip(params.primaries.iter()) {
|
||||
*p = lab_to_xyz(*lab).map(|v| v.max(1e-6));
|
||||
}
|
||||
let k_xyz = lab_to_xyz(params.black);
|
||||
// Black over other inks: optical densities add, but saturate towards a maximum density
|
||||
// (ink trapping / surface reflection), normalised so that K on paper is exact.
|
||||
const D_MAX: f32 = 2.0;
|
||||
let sat = |d: f32| D_MAX * (1.0 - (-d / D_MAX).exp());
|
||||
let mut prim_k = [[0.0f32; 3]; 8];
|
||||
for i in 0..8 {
|
||||
for ch in 0..3 {
|
||||
let di = -(prim[i][ch] / paper_xyz[ch]).max(1e-6).log10();
|
||||
let dk = -(k_xyz[ch] / paper_xyz[ch]).max(1e-6).log10();
|
||||
let d = sat(di + dk) * dk / sat(dk).max(1e-6);
|
||||
prim_k[i][ch] = paper_xyz[ch] * 10f32.powf(-d.max(di));
|
||||
}
|
||||
}
|
||||
let mut g = Self { params, prim, prim_k, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() };
|
||||
// Darkest black within TAC: search a few rich blacks.
|
||||
let mut best = [1.0f32; 3];
|
||||
for k in [0.8f32, 0.9, 1.0] {
|
||||
let rest = (g.params.tac - k).max(0.0) / 3.0;
|
||||
let cmy = rest.min(1.0);
|
||||
let x = g.to_xyz_rel([cmy, cmy, cmy, k]);
|
||||
if x[1] < best[1] {
|
||||
best = x;
|
||||
}
|
||||
}
|
||||
g.black_rel = best;
|
||||
// The curve's increase at 40% is 0.96·g·(1 − 0.2·s), so `s` follows from the 40% value.
|
||||
let skew = [0, 1, 2, 3].map(|i| {
|
||||
let g = params.tvi50[i];
|
||||
if g > 1e-6 { (1.0 - params.tvi40[i] / (0.96 * g)) / 0.2 } else { 0.0 }
|
||||
});
|
||||
let mut g = Self { params, prim, skew, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() };
|
||||
g.black_rel = g.to_xyz_rel(g.black_point_build());
|
||||
g
|
||||
}
|
||||
|
||||
@@ -133,35 +153,81 @@ impl GenericCmyk {
|
||||
self.params.name
|
||||
}
|
||||
|
||||
fn gain(a: f32, g: f32) -> f32 {
|
||||
let a = a.clamp(0.0, 1.0);
|
||||
(a + 4.0 * g * a * (1.0 - a)).clamp(0.0, 1.0)
|
||||
/// The black point: [`BlackGeneration::black_point_k`] black, with C, M and Y
|
||||
/// chosen so that they fill the TAC and the color is neutral (a* = b* = 0).
|
||||
pub fn black_point_build(&self) -> [f32; 4] {
|
||||
let k = self.params.bg.black_point_k.clamp(0.0, 1.0);
|
||||
let rest = (self.params.tac - k).clamp(0.0, 3.0);
|
||||
let build = |c: f32, m: f32| [c, m, (rest - c - m).clamp(0.0, 1.0), k];
|
||||
let ab = |c: f32, m: f32| {
|
||||
let l = self.to_lab_rel(build(c, m));
|
||||
[l.a, l.b]
|
||||
};
|
||||
let lo = (rest - 2.0).max(0.0);
|
||||
let hi = rest.min(1.0);
|
||||
let (mut c, mut m) = ((rest / 3.0).clamp(lo, hi), (rest / 3.0).clamp(lo, hi));
|
||||
for _ in 0..30 {
|
||||
let r = ab(c, m);
|
||||
if r[0].abs() + r[1].abs() < 1e-4 {
|
||||
break;
|
||||
}
|
||||
let h = 1e-3;
|
||||
let rc = ab(c - h, m);
|
||||
let rm = ab(c, m - h);
|
||||
let j = [[(r[0] - rc[0]) / h, (r[0] - rm[0]) / h], [(r[1] - rc[1]) / h, (r[1] - rm[1]) / h]];
|
||||
let det = j[0][0] * j[1][1] - j[0][1] * j[1][0];
|
||||
if det.abs() < 1e-9 {
|
||||
break;
|
||||
}
|
||||
let dc = (-r[0] * j[1][1] + r[1] * j[0][1]) / det;
|
||||
let dm = (-r[1] * j[0][0] + r[0] * j[1][0]) / det;
|
||||
c = (c + dc.clamp(-0.1, 0.1)).clamp(lo, hi);
|
||||
m = (m + dm.clamp(-0.1, 0.1)).clamp(lo, hi);
|
||||
// Yellow takes the rest, so keep it within 0..1 as well.
|
||||
let y = rest - c - m;
|
||||
if y > 1.0 {
|
||||
c += (y - 1.0) / 2.0;
|
||||
m += (y - 1.0) / 2.0;
|
||||
} else if y < 0.0 {
|
||||
c += y / 2.0;
|
||||
m += y / 2.0;
|
||||
}
|
||||
c = c.clamp(lo, hi);
|
||||
m = m.clamp(lo, hi);
|
||||
}
|
||||
build(c.clamp(lo, hi), m.clamp(lo, hi))
|
||||
}
|
||||
|
||||
/// Effective dot area of ink `i` at nominal area `a`: the TR 001-style tone curve.
|
||||
fn gain(&self, i: usize, a: f32) -> f32 {
|
||||
let a = if a.is_finite() { a.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let g = self.params.tvi50[i];
|
||||
(a + 4.0 * g * a * (1.0 - a) * (1.0 + self.skew[i] * (2.0 * a - 1.0))).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// CMYK (0..1) → absolute XYZ (D50).
|
||||
pub fn to_xyz(&self, cmyk: [f32; 4]) -> [f32; 3] {
|
||||
let dg = self.params.dot_gain;
|
||||
let c = Self::gain(cmyk[0], dg[0]);
|
||||
let m = Self::gain(cmyk[1], dg[1]);
|
||||
let y = Self::gain(cmyk[2], dg[2]);
|
||||
let k = Self::gain(cmyk[3], dg[3]);
|
||||
let mut out = [0.0f32; 3];
|
||||
for i in 0..8 {
|
||||
let w = (if i & 1 != 0 { c } else { 1.0 - c }) * (if i & 2 != 0 { m } else { 1.0 - m }) * (if i & 4 != 0 { y } else { 1.0 - y });
|
||||
if w == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for (ch, o) in out.iter_mut().enumerate() {
|
||||
*o += w * ((1.0 - k) * self.prim[i][ch] + k * self.prim_k[i][ch]);
|
||||
}
|
||||
}
|
||||
out
|
||||
let x = self.to_xyz_rel(cmyk);
|
||||
[x[0] * self.paper_xyz[0] / D50[0], x[1] * self.paper_xyz[1] / D50[1], x[2] * self.paper_xyz[2] / D50[2]]
|
||||
}
|
||||
|
||||
/// Media-relative XYZ (paper = D50 white).
|
||||
pub fn to_xyz_rel(&self, cmyk: [f32; 4]) -> [f32; 3] {
|
||||
let x = self.to_xyz(cmyk);
|
||||
[x[0] * D50[0] / self.paper_xyz[0], x[1] * D50[1] / self.paper_xyz[1], x[2] * D50[2] / self.paper_xyz[2]]
|
||||
let e = [0, 1, 2, 3].map(|i| self.gain(i, cmyk[i]));
|
||||
let mut out = [0.0f32; 3];
|
||||
for (i, p) in self.prim.iter().enumerate() {
|
||||
let mut w = 1.0;
|
||||
for (bit, a) in e.iter().enumerate() {
|
||||
w *= if i & (1 << bit) != 0 { *a } else { 1.0 - a };
|
||||
}
|
||||
if w == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for (o, v) in out.iter_mut().zip(p) {
|
||||
*o += w * v;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn to_lab_rel(&self, cmyk: [f32; 4]) -> Lab {
|
||||
@@ -174,7 +240,9 @@ impl GenericCmyk {
|
||||
/// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour).
|
||||
/// With `bpc` (media-relative only) the press black displays as black, undoing the
|
||||
/// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the
|
||||
/// darkest rich black shows as black again.
|
||||
/// black point shows as black again. Each XYZ channel is clamped at 0 on its own, so a build
|
||||
/// darker than the black point in every channel shows as black, and one darker in only some
|
||||
/// channels shows as a colored near-black.
|
||||
pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
|
||||
if absolute {
|
||||
return self.to_xyz(cmyk);
|
||||
@@ -192,7 +260,7 @@ impl GenericCmyk {
|
||||
xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc))
|
||||
}
|
||||
|
||||
/// Lab of the darkest printable black (media-relative).
|
||||
/// Lab of the black point (media-relative), see [`Self::black_point_build`].
|
||||
pub fn black_point(&self) -> Lab {
|
||||
xyz_to_lab(self.black_rel)
|
||||
}
|
||||
@@ -283,12 +351,21 @@ impl GenericCmyk {
|
||||
rgb.map(|v| ((1.0 - v - k) / d).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn black_for_grey(&self, grey: f32) -> f32 {
|
||||
let bg = self.params.bg;
|
||||
if grey <= bg.start {
|
||||
return 0.0;
|
||||
/// Black for `x` on the black curve (see [`BlackGeneration::curve`]).
|
||||
fn black_for(&self, x: f32) -> f32 {
|
||||
let curve = &self.params.bg.curve;
|
||||
let mut prev = curve[0];
|
||||
if x <= prev.0 {
|
||||
return prev.1.clamp(0.0, 1.0);
|
||||
}
|
||||
(((grey - bg.start) / (1.0 - bg.start)).clamp(0.0, 1.0).powf(bg.gamma) * bg.max_k).clamp(0.0, 1.0)
|
||||
for &(kx, kk) in &curve[1..] {
|
||||
if x <= kx {
|
||||
let t = if kx > prev.0 { (x - prev.0) / (kx - prev.0) } else { 1.0 };
|
||||
return (prev.1 + (kk - prev.1) * t).clamp(0.0, 1.0);
|
||||
}
|
||||
prev = (kx, kk);
|
||||
}
|
||||
prev.1.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Separate a media-relative Lab (already gamut-mapped) into CMYK with GCR/UCR and the TAC limit.
|
||||
@@ -297,13 +374,10 @@ impl GenericCmyk {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let cmy0 = self.solve_cmy(target, 0.0, Self::initial_cmy(target, 0.0));
|
||||
let grey = cmy0[0].min(cmy0[1]).min(cmy0[2]);
|
||||
let mut k = self.black_for_grey(grey);
|
||||
// If CMY alone can't get dark enough, black must carry the rest.
|
||||
let gray = cmy0[0].min(cmy0[1]).min(cmy0[2]);
|
||||
// When CMY alone can't get dark enough, black carries the rest.
|
||||
let l0 = self.to_lab_rel([cmy0[0], cmy0[1], cmy0[2], 0.0]).l;
|
||||
if l0 > target.l + 1.0 {
|
||||
k = k.max(((l0 - target.l) / l0.max(1.0)).clamp(0.0, 1.0));
|
||||
}
|
||||
let mut k = self.black_for(gray + self.params.bg.shortfall * (l0 - target.l).max(0.0));
|
||||
let mut cmy = if k > 0.0 { self.solve_cmy(target, k, Self::initial_cmy(target, k)) } else { cmy0 };
|
||||
// UCR: trade CMY for K until within the TAC limit.
|
||||
for _ in 0..6 {
|
||||
|
||||
@@ -245,8 +245,8 @@ fn loads_system_cmyk_icc() {
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
|
||||
}
|
||||
|
||||
/// With black-point compensation, sRGB black separates to the press's darkest black and that black
|
||||
/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey).
|
||||
/// With black-point compensation, sRGB black separates to the press's black point and that black
|
||||
/// displays as black again (hex 000000 -> C75 M68 Y67 K90 -> 000000, not a dark gray).
|
||||
#[test]
|
||||
fn generic_cmyk_black_round_trips_with_bpc() {
|
||||
let c = cms();
|
||||
@@ -259,25 +259,104 @@ fn generic_cmyk_black_round_trips_with_bpc() {
|
||||
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
|
||||
}
|
||||
|
||||
/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few
|
||||
/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024
|
||||
/// in all) stays within 0.001 on every ink.
|
||||
/// Each ink's 40% and 50% tints gain like CGATS TR 001 (SWOP): the colorimetric tone value increase
|
||||
/// (Murray–Davies on X for cyan, Y for magenta and black, Z for yellow) is C 20.0, M 17.6, Y 16.8 and
|
||||
/// K 21.6 points at 50%, and C 20.0, M 17.3, Y 16.7 and K 21.9 points at 40%.
|
||||
#[test]
|
||||
fn generic_cmyk_tints_gain_like_tr001() {
|
||||
let g = generic();
|
||||
let tvi = |ink: usize, a: f32| {
|
||||
let ch = [0, 1, 2, 1][ink];
|
||||
let mut cmyk = [0.0f32; 4];
|
||||
cmyk[ink] = a;
|
||||
let tint = g.to_xyz_rel(cmyk)[ch];
|
||||
cmyk[ink] = 1.0;
|
||||
let solid = g.to_xyz_rel(cmyk)[ch];
|
||||
let paper = g.to_xyz_rel([0.0; 4])[ch];
|
||||
(paper - tint) / (paper - solid) - a
|
||||
};
|
||||
for (ink, (at50, at40)) in [(0.200, 0.200), (0.176, 0.173), (0.168, 0.167), (0.216, 0.219)].into_iter().enumerate() {
|
||||
assert!((tvi(ink, 0.5) - at50).abs() < 0.002, "ink {ink} at 50%: {}", tvi(ink, 0.5));
|
||||
assert!((tvi(ink, 0.4) - at40).abs() < 0.002, "ink {ink} at 40%: {}", tvi(ink, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
/// More of any ink never makes a Generic CMYK color lighter, in any XYZ channel: on a 10% grid of
|
||||
/// builds within the 300% ink limit, adding 10% of one ink keeps X, Y and Z or lowers them.
|
||||
#[test]
|
||||
fn generic_cmyk_more_ink_is_never_lighter() {
|
||||
let g = generic();
|
||||
let steps = (0..=10).map(|i| i as f32 / 10.0);
|
||||
for c in steps.clone() {
|
||||
for m in steps.clone() {
|
||||
for y in steps.clone() {
|
||||
for k in steps.clone() {
|
||||
let base = [c, m, y, k];
|
||||
if base.iter().sum::<f32>() > 3.0 - 0.1 + 1e-4 {
|
||||
continue;
|
||||
}
|
||||
let x0 = g.to_xyz_rel(base);
|
||||
for ink in 0..4 {
|
||||
if base[ink] >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
let mut more = base;
|
||||
more[ink] += 0.1;
|
||||
let x1 = g.to_xyz_rel(more);
|
||||
assert!((0..3).all(|ch| x1[ch] <= x0[ch] + 1e-5), "{base:?} plus ink {ink}: {x0:?} to {x1:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// sRGB black separates to 90% black under neutral CMY that fills the 300% ink limit, light grays
|
||||
/// carry no black, and black rises steadily from the light end of the gray ramp to the dark end.
|
||||
#[test]
|
||||
fn generic_cmyk_black_generation_follows_the_black_curve() {
|
||||
let c = cms();
|
||||
let black = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric);
|
||||
assert!((black[3] - 0.9).abs() < 0.005, "{black:?}");
|
||||
assert!((black.iter().sum::<f32>() - 3.0).abs() < 0.01, "{black:?}");
|
||||
let lab = c.cmyk_to_lab(black);
|
||||
assert!(lab.a.abs() < 0.05 && lab.b.abs() < 0.05, "neutral: {lab:?}");
|
||||
let light = c.srgb_to_cmyk([0.7; 3], Intent::RelativeColorimetric);
|
||||
assert_eq!(light[3], 0.0, "{light:?}");
|
||||
let mut prev = 0.0;
|
||||
for v in (0..=50).rev().map(|i| i as f32 / 50.0) {
|
||||
let k = c.srgb_to_cmyk([v; 3], Intent::RelativeColorimetric)[3];
|
||||
assert!(k >= prev - 1e-4, "gray {v}: K {k} after {prev}");
|
||||
prev = k;
|
||||
}
|
||||
}
|
||||
|
||||
/// A color whose solve holds an ink at a bound separates to the same inks when its values change by
|
||||
/// a few float steps: hex f01482 (cyan held at 0) and hex ed1e79 (magenta held at 100%), each with
|
||||
/// its red stepped one `f32` at a time over 4,001 values (about 0.00024 in all), stay within 0.001
|
||||
/// on every ink.
|
||||
#[test]
|
||||
fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() {
|
||||
let c = cms();
|
||||
let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0];
|
||||
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
|
||||
for step in -2000i32..=2000 {
|
||||
let mut rgb = pink;
|
||||
rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step));
|
||||
let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
|
||||
for i in 0..4 {
|
||||
lo[i] = lo[i].min(ink[i]);
|
||||
hi[i] = hi[i].max(ink[i]);
|
||||
let spread = |pink: [f32; 3]| {
|
||||
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
|
||||
for step in -2000i32..=2000 {
|
||||
let mut rgb = pink;
|
||||
rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step));
|
||||
let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
|
||||
for i in 0..4 {
|
||||
lo[i] = lo[i].min(ink[i]);
|
||||
hi[i] = hi[i].max(ink[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
(lo, hi)
|
||||
};
|
||||
let (lo, hi) = spread([240.0f32 / 255.0, 20.0 / 255.0, 130.0 / 255.0]);
|
||||
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
|
||||
assert_eq!(lo[0], 0.0, "{lo:?}");
|
||||
let (lo, hi) = spread([237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]);
|
||||
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
|
||||
assert_eq!(hi[1], 1.0, "{hi:?}");
|
||||
}
|
||||
|
||||
/// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change
|
||||
|
||||
@@ -287,7 +287,8 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// How Document Color Mode converts neutral RGB greys (R = G = B) to CMYK (the `grays` param).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Grays {
|
||||
/// Through the CMYK profile like any colour: four-colour greys and a rich black.
|
||||
/// Through the CMYK profile like any colour: light grays in cyan, magenta and yellow only, darker
|
||||
/// grays in four inks, and a rich black.
|
||||
#[default]
|
||||
Profile,
|
||||
/// On the black plate only: K is the grey's ink percentage (as Convert to Grayscale and then
|
||||
|
||||
@@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Color Mode",
|
||||
["File", "Document Color Mode"],
|
||||
None,
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks, effect colors and graphs' series and label paints included), symbols, graph designs, pattern tiles, graphic styles (their effect colors included), brushes, swatches and the paints new art takes through the color settings, together in one undo step (Undo restores their original values; Redo reapplies the conversion); swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}",
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks, effect colors and graphs' series and label paints included), symbols, graph designs, pattern tiles, graphic styles (their effect colors included), brushes, swatches and the paints new art takes through the color settings, together in one undo step (Undo restores their original values; Redo reapplies the conversion); swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}",
|
||||
has_doc,
|
||||
super::colormgmt::convert_mode
|
||||
),
|
||||
|
||||
@@ -932,25 +932,31 @@ fn gradients_and_patterns_come_back_from_the_editing_copy() {
|
||||
}
|
||||
let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": 7})).unwrap();
|
||||
let legacy = load::load_with("art.ai", &b64(&r), &LoadOptions::default()).unwrap();
|
||||
// Each gradient the art paints with, by its kind and its stops' places and colors to 5% (version
|
||||
// 7 writes a gradient stroke as its filled outline, so the objects differ, and a spot color's
|
||||
// tint through its CMYK equivalent, 2% off here).
|
||||
// Each gradient the art paints with, by its kind and its stops' places, and its stops' colors
|
||||
// within 5% (version 7 writes a gradient stroke as its filled outline, so the objects differ),
|
||||
// except a spot color's tint, which version 7 writes through its CMYK equivalent: 70% of that
|
||||
// equivalent displays about 7% off the tint of the spot color.
|
||||
let gradients = |doc: &vectorcraft_doc::Document| {
|
||||
let mut all = std::collections::BTreeSet::new();
|
||||
let mut all = std::collections::BTreeMap::new();
|
||||
doc.walk(|node| {
|
||||
for it in node.appearance.items.iter() {
|
||||
if let vectorcraft_color::Paint::Gradient(g) = it.paint() {
|
||||
let stops: Vec<String> = g
|
||||
.gradient
|
||||
.stops
|
||||
.iter()
|
||||
.map(|s| format!("{:.3} {:?}", s.offset, s.color.to_rgb().map(|v| (v * 20.0).round() * 5.0)))
|
||||
.collect();
|
||||
all.insert(format!("{:?} {stops:?}", g.gradient.kind));
|
||||
let places: Vec<String> = g.gradient.stops.iter().map(|s| format!("{:.3}", s.offset)).collect();
|
||||
let colors: Vec<([f32; 3], bool)> =
|
||||
g.gradient.stops.iter().map(|s| (s.color.to_rgb(), s.swatch.is_some() && s.tint < 1.0)).collect();
|
||||
all.insert(format!("{:?} {places:?}", g.gradient.kind), colors);
|
||||
}
|
||||
}
|
||||
});
|
||||
all
|
||||
};
|
||||
assert_eq!(gradients(&legacy.doc), gradients(&before));
|
||||
let (got, want) = (gradients(&legacy.doc), gradients(&before));
|
||||
assert_eq!(got.keys().collect::<Vec<_>>(), want.keys().collect::<Vec<_>>());
|
||||
for (key, stops) in &want {
|
||||
for (i, (rgb, spot)) in stops.iter().enumerate() {
|
||||
let back = got[key][i].0;
|
||||
let most = if *spot { 0.1 } else { 0.05 };
|
||||
assert!((0..3).all(|c| (back[c] - rgb[c]).abs() < most), "{key} stop {i}: {back:?} for {rgb:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ fn rgb_art_separates_with_gcr() {
|
||||
RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Black".into()]), ..Default::default() }), ..Default::default() },
|
||||
)
|
||||
.pixel(50, 50);
|
||||
assert!(k[0] < 20, "RGB black is mostly K: {k:?}");
|
||||
assert!((20..=30).contains(&k[0]), "RGB black carries 90% black: {k:?}");
|
||||
let c = render(
|
||||
&doc,
|
||||
RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Cyan".into()]), ..Default::default() }), ..Default::default() },
|
||||
|
||||
@@ -333,8 +333,8 @@ fn an_rgb_image_sampled_in_a_cmyk_document_separates_through_the_color_settings(
|
||||
let got = node(&s, dst).appearance.fill_paint().color().unwrap();
|
||||
assert_eq!(got, managed_cmyk(Color::rgb8(83, 170, 223)));
|
||||
let Color::Cmyk { c, m, y, k } = got else { panic!("{got:?}") };
|
||||
// Generic CMYK: about C59 M12 Y3 K0, with no black, where the formula gave K13.
|
||||
assert!((0.5..0.75).contains(&c) && (0.05..0.2).contains(&m) && y < 0.05 && k < 0.02, "{c} {m} {y} {k}");
|
||||
// Generic CMYK: about C62 M19 Y0 K0, with no black, where the formula gave K13.
|
||||
assert!((0.5..0.75).contains(&c) && (0.1..0.25).contains(&m) && y < 0.05 && k < 0.02, "{c} {m} {y} {k}");
|
||||
// A light blue shows close to the color it was picked from; the formula's C12 M7 showed duller.
|
||||
click(&mut s, 450.0, 50.0, Mods::default());
|
||||
let got = node(&s, dst).appearance.fill_paint().color().unwrap();
|
||||
|
||||
@@ -4463,7 +4463,7 @@ fn k_only(c: Color) -> f32 {
|
||||
fn document_color_mode_separates_rgb_greys_through_the_profile() {
|
||||
let (mut s, ids, _, _) = greys_document();
|
||||
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||||
// As Illustrator converts them: four-colour greys and a rich black.
|
||||
// Through the profile: a mid gray in four inks and a rich black.
|
||||
let Color::Cmyk { c, m, y, k } = fill_of(&s, ids[2]) else { panic!("not CMYK") };
|
||||
assert!(c > 0.05 && m > 0.05 && y > 0.05 && k > 0.0, "a four-colour mid grey: {c} {m} {y} {k}");
|
||||
let Color::Cmyk { c, k, .. } = fill_of(&s, ids[0]) else { panic!("not CMYK") };
|
||||
|
||||
@@ -925,11 +925,12 @@ fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
|
||||
for c in expanded(&mut s, 1.0) {
|
||||
assert!(close_inks(inks(c), [0.5, 0.2, 0.0, 0.1]), "{c:?}");
|
||||
}
|
||||
// Cyan and black points mix as inks: cyan and black only, adding up to one.
|
||||
// Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient blends
|
||||
// the points' display colors and the mesh blends their inks, so the two draw a little apart in between.
|
||||
let mut s = session();
|
||||
let (cyan, black) = (ink(1.0, 0.0, 0.0, 0.0), ink(0.0, 0.0, 0.0, 1.0));
|
||||
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan.clone(), black.clone(), black]);
|
||||
for c in expanded(&mut s, 5.0) {
|
||||
for c in expanded(&mut s, 7.5) {
|
||||
let [c, m, y, k] = inks(c);
|
||||
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
|
||||
}
|
||||
|
||||
@@ -503,7 +503,7 @@ fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() {
|
||||
// A swatch made from a hex colour is CMYK as well; the document round-trips natively.
|
||||
let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].as_str().unwrap().to_string();
|
||||
assert_eq!(doc(&s).swatch(&name).and_then(|w| w.paint.color()), Some(managed(Color::rgb(0.0, 1.0, 0.0))));
|
||||
assert_eq!(name, "C=61 M=0 Y=100 K=0");
|
||||
assert_eq!(name, "C=65 M=0 Y=100 K=0");
|
||||
let json = serde_json::to_string(doc(&s)).unwrap();
|
||||
assert_eq!(serde_json::from_str::<vectorcraft_doc::Document>(&json).unwrap(), *doc(&s));
|
||||
}
|
||||
|
||||
@@ -563,17 +563,25 @@ mod tests {
|
||||
fn the_ink_table_matches_the_profile() {
|
||||
let cms = cms::active();
|
||||
let lut = InkLut::build(&cms);
|
||||
let mut worst = 0.0f32;
|
||||
let (mut worst, mut worst_dark) = (0.0f32, 0.0f32);
|
||||
// Between the grid points (where interpolation errs most) and on them.
|
||||
for i in 0..=20 {
|
||||
for j in 0..=20 {
|
||||
let inks = [i as f32 / 20.0, j as f32 / 20.0, ((i * 7 + j * 3) % 21) as f32 / 20.0, ((i + j * 5) % 21) as f32 / 20.0];
|
||||
let (a, b) = (lut.apply(inks), cms.cmyk_to_srgb(inks, false));
|
||||
worst = worst.max((0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max));
|
||||
let err = (0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max);
|
||||
// Builds darker than the press's black point clip at 0 in one or more XYZ channels,
|
||||
// so the grid interpolates across that clip in the darkest levels.
|
||||
if b.iter().all(|v| *v < 0.05) {
|
||||
worst_dark = worst_dark.max(err);
|
||||
} else {
|
||||
worst = worst.max(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Within two 8-bit levels: far below a visible difference.
|
||||
assert!(worst * 255.0 < 2.5, "worst error {} levels", worst * 255.0);
|
||||
assert!(worst_dark * 255.0 < 5.0, "worst error near black {} levels", worst_dark * 255.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+2
-2
@@ -696,8 +696,8 @@ effect colors that new art takes; Undo restores their original values and Redo r
|
||||
colors of 3D effects stay RGB.
|
||||
Gray colors stay Gray and print
|
||||
on the black plate only. RGB grays are colors like any
|
||||
other: to CMYK they separate through the profile into four-colour greys and a rich black, as Illustrator converts
|
||||
them. `grays: "black"` puts RGB greys with R = G = B on the black plate instead, K = their grey value (the inks Edit Colors ›
|
||||
other: to CMYK they separate through the profile into light grays in cyan, magenta and yellow only, darker
|
||||
four-color grays and a rich black. `grays: "black"` puts RGB greys with R = G = B on the black plate instead, K = their grey value (the inks Edit Colors ›
|
||||
Convert to Grayscale before the switch would give); other colors convert as usual. The reply's `changed` counts the
|
||||
colors that changed in the art, symbols, graph designs and pattern tiles; it does not count the colors of graphic
|
||||
styles and brushes.
|
||||
|
||||
@@ -1158,7 +1158,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. EPS and SVG write the image's own pixels.
|
||||
- **Missing library files:** a persistent swatch or graphic style library panel whose file is gone at startup reopens and stays in the preferences, showing the file's path with Locate Library… (`ui.libraryPanel.locate`), which reads the file from where it is now into the same panel.
|
||||
- **Swatch library and Recolor Artwork parameters:** the `swatch.library.*` commands, `swatch.resetDefaults`, the graphic style library save, load and add, the `recolor.*` commands and `color.harmony`'s `limitTo` refuse parameters of the wrong form, such as `names: "Grays"`, `limitTo: 5` or `includeImages: "false"`. Errors name the parameter, except that a `limitTo` that is not a string, such as `5`, gets an unknown-library error. A `null` counts as left out except for `recolor.apply`'s `model`, which must be a string when given.
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB grays separate into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
|
||||
Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode").
|
||||
Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`.
|
||||
A Cmd/Ctrl-click on the Color panel's spectrum applies Complement and a Cmd/Ctrl+Shift-click applies Invert to the active Fill or Stroke (`paint.complement`, `paint.invert`); with Alt also held, the click recolors the inactive one, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) list them.
|
||||
@@ -1176,8 +1176,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Pasting into hex color fields:** in the hex fields of the Color Picker, Swatch Options, Recolor Artwork, the Color panel, Document Setup, Duotone, Color Themes and Perspective Grid, a click or keyboard focus selects the digits. A pasted code of six digits, with or without `#`, replaces them wherever the caret is. A pasted code of three digits also replaces them, unless the paste holds only those digits and exactly three digits stay; those digits then edit the text in place. A press on another field commits the text as Tab and Enter do. As VectorCraft's own rule, a paste that is not a color leaves the field as it was. Tests: `a_hex_code_pasted_into_the_hex_field_replaces_its_digits` in `crates/ui-egui/src/dialogs/tests.rs`, `define_grid_takes_a_three_digit_color` in `crates/ui-egui/src/dialogs/tests_perspective.rs` and `a_paste_into_a_hex_field_replaces_or_edits_its_digits` in `crates/ui-egui/src/tests/numfields.rs`.
|
||||
- **Color Picker layout (#2008):** a centered draggable header, equal-width actions above aligned HSB/RGB/Hex and Lab/CMYK fields, compact field and slider, and warnings below the preview. No horizontal scrolling; narrow windows stack the sections with vertical scrolling. Tests in `dialogs/color_picker.rs` cover actual pointer/key input, typed values before OK/Enter, Cancel/Escape rollback, one Undo step, mode-size stability, warnings and layout at two UI scales. The screen sampler sits above the field and the upstream two-grid Tab order is retained. AccessKit regressions check the action names and each channel slider's displayed value, units and range. German and enlarged English UI renders were reviewed locally; no reference images are shipped. This is a layout improvement, with no change to scores or remaining-hour estimates.
|
||||
- **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement.
|
||||
- **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C59 M12 Y3 K0, where the profile-free formula gave C63 M24 Y0 K13.
|
||||
- **RGB input to other commands in a CMYK document:** type colors (`text.create`, `text.setStyle`, `text.setRangeStyle`), a Shape Builder hex fill, `paint.editGradient` stops, freeform gradient points, gradient mesh points (the Mesh tool gives a new point the fill's color in its own model), the Symbol Stainer's color and the swatch commands (`swatch.new {colors}`, `swatch.newGroup`, `swatch.editGroup`, `swatch.edit`) separate RGB through the color settings as `paint.setFill` does: with Generic CMYK, `text.setStyle {fill: "#53aadf"}` gives C59 M12 Y3 K0. A color a swatch already has keeps its model, and a swatch color given with `mode` is converted into that mode only.
|
||||
- **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C62 M19 Y0 K0, where the profile-free formula gave C63 M24 Y0 K13.
|
||||
- **RGB input to other commands in a CMYK document:** type colors (`text.create`, `text.setStyle`, `text.setRangeStyle`), a Shape Builder hex fill, `paint.editGradient` stops, freeform gradient points, gradient mesh points (the Mesh tool gives a new point the fill's color in its own model), the Symbol Stainer's color and the swatch commands (`swatch.new {colors}`, `swatch.newGroup`, `swatch.editGroup`, `swatch.edit`) separate RGB through the color settings as `paint.setFill` does: with Generic CMYK, `text.setStyle {fill: "#53aadf"}` gives C62 M19 Y0 K0. A color a swatch already has keeps its model, and a swatch color given with `mode` is converted into that mode only.
|
||||
- **Generic CMYK (SWOP-like):** each ink's 40% and 50% tints gain as [CGATS TR 001](https://registry.color.org/cmyk-registry/cgats_tr_001) gives for SWOP printing, and separations stay within SWOP's [300% total ink limit](https://en.wikipedia.org/wiki/Specifications_for_Web_Offset_Publications). The paper, solid and overprint colors, the black-generation curve and the black point are VectorCraft's own values: RGB black separates to 90% black under neutral cyan, magenta and yellow that fill the ink limit (C75 M68 Y67 K90).
|
||||
- **Swatches made from a shown color in a CMYK document:** the Color panel menu's Create New Swatch, an Alt-click on the Swatches panel's New Swatch button, the New Swatch dialog for a gradient, a Fill or Stroke proxy or the Gradient panel's thumbnail dropped on the Swatches panel (or Alt-dropped on a swatch of its kind), and the Gradient panel's Add to Swatches and Save to Swatches keep each color's model, as the Color Guide's and Color Themes' saved colors do: an RGB object's color gives an RGB swatch, and a gradient's RGB stops stay RGB.
|
||||
- **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. Windows, Linux and other browsers have no sampler yet; the button is gray there.
|
||||
- **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/it/stroke_visibility.rs`).
|
||||
|
||||
Reference in New Issue
Block a user