Skip to content

Commit 6843967

Browse files
committed
Add rgb gamut parameter to clampChroma(), shorter jnd bypass for toGamut(), fixes #212, #213
1 parent bef6fb6 commit 6843967

File tree

3 files changed

+51
-16
lines changed

3 files changed

+51
-16
lines changed

docs/api.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ formatCss(toRgb(crimson));
359359

360360
<span aria-label='Source:'>☞</span> [src/clamp.js]({{codebase}}/src/clamp.js)
361361

362-
<a id="clampChroma" href="#clampChroma">#</a> **clampChroma**(_color_ or _string_, _mode = 'lch'_) → _color_
362+
<a id="clampChroma" href="#clampChroma">#</a> **clampChroma**(_color_ or _string_, _mode = 'lch'_, _rgbGamut = 'rgb'_) → _color_
363363

364364
<span aria-label='Source:'>☞</span> [src/clamp.js]({{codebase}}/src/clamp.js)
365365

@@ -374,6 +374,8 @@ clampChroma('lab(50% 100 100)');
374374

375375
By default, the color is converted to `lch` to perform the clamping, but any color space that contains a Chroma dimension can be used by sending an explicit `mode` argument.
376376

377+
Likewise, the destination RGB gamut can be overriden with the corresponding parameter.
378+
377379
```js
378380
import { clampChroma } from 'culori';
379381

@@ -414,10 +416,17 @@ toP3(color);
414416
// ⇒ { mode: "p3", r: 0.999…, g: 0.696…, b: 0.508… }
415417
```
416418

417-
To address the shortcomings of `clampChroma`, which can sometimes produce colors more desaturated than necessary, the test used in the binary search is replaced with "is color is roughly in gamut", by comparing the candidate to the clipped version (obtained with `clampGamut`). The test passes if the colors are not to dissimilar, judged by the `delta` color difference function and an associated `jnd` just-noticeable difference value.
419+
To address the shortcomings of `clampChroma`, which can sometimes produce colors more desaturated than necessary, the test used in the binary search is replaced with is color is roughly in gamut, by comparing the candidate to the clipped version (obtained with `clampGamut`). The test passes if the colors are not to dissimilar, judged by the `delta` color difference function and an associated `jnd` just-noticeable difference value.
418420

419421
The default arguments for this function correspond to [the gamut mapping algorithm](https://drafts.csswg.org/css-color/#css-gamut-mapping) defined in the CSS Color Module Level 4 spec, but the algorithm itself is slightly different.
420422

423+
The “roughly in gamut” aspect of the algorithm can be disabled by passing `null` for the `delta` color difference function:
424+
425+
```js
426+
import { toGamut } from 'culori';
427+
const clampToP3 = toGamut('p3', 'oklch', null);
428+
```
429+
421430
## Interpolation
422431

423432
In any color space, colors occupy positions given by their values for each channel. Interpolating colors means tracing a line through the coordinates of these colors, and figuring out what colors reside on the line at various positions.

src/clamp.js

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const fixup_rgb = c => {
1717
return res;
1818
};
1919

20+
const to_displayable_srgb = c => fixup_rgb(rgb(c));
21+
2022
const inrange_rgb = c => {
2123
return (
2224
c !== undefined &&
@@ -66,7 +68,7 @@ export function clampRgb(color) {
6668
// keep track of color's original mode
6769
let conv = converter(color.mode);
6870

69-
return conv(fixup_rgb(rgb(color)));
71+
return conv(to_displayable_srgb(color));
7072
}
7173

7274
/*
@@ -106,21 +108,25 @@ export function clampGamut(mode = 'rgb') {
106108
}
107109

108110
/*
109-
Obtain a color that's in the sRGB gamut
110-
by first converting it to `mode` and then
111-
finding the the greatest chroma value
112-
that fits the gamut.
111+
Obtain a color that’s in a RGB gamut (by default sRGB)
112+
by first converting it to `mode` and then finding
113+
the greatest chroma value that fits the gamut.
113114
114115
By default, the CIELCh color space is used,
115116
but any color that has a chroma component will do.
116117
117118
The result is returned in the color's original color space.
118119
*/
119-
export function clampChroma(color, mode = 'lch') {
120+
export function clampChroma(color, mode = 'lch', rgbGamut = 'rgb') {
120121
color = prepare(color);
121122

123+
let inDestinationGamut =
124+
rgbGamut === 'rgb' ? displayable : inGamut(rgbGamut);
125+
let clipToGamut =
126+
rgbGamut === 'rgb' ? to_displayable_srgb : clampGamut(rgbGamut);
127+
122128
// if the color is undefined or displayable, return it directly
123-
if (color === undefined || displayable(color)) return color;
129+
if (color === undefined || inDestinationGamut(color)) return color;
124130

125131
// keep track of color's original mode
126132
let conv = converter(color.mode);
@@ -133,8 +139,8 @@ export function clampChroma(color, mode = 'lch') {
133139

134140
// if not even chroma = 0 is displayable
135141
// fall back to RGB clamping
136-
if (!displayable(clamped)) {
137-
return conv(fixup_rgb(rgb(clamped)));
142+
if (!inDestinationGamut(clamped)) {
143+
return conv(clipToGamut(clamped));
138144
}
139145

140146
// By this time we know chroma = 0 is displayable and our current chroma is not.
@@ -147,7 +153,7 @@ export function clampChroma(color, mode = 'lch') {
147153

148154
while (end - start > resolution) {
149155
clamped.c = start + (end - start) * 0.5;
150-
if (displayable(clamped)) {
156+
if (inDestinationGamut(clamped)) {
151157
_last_good_c = clamped.c;
152158
start = clamped.c;
153159
} else {
@@ -156,7 +162,7 @@ export function clampChroma(color, mode = 'lch') {
156162
}
157163

158164
return conv(
159-
displayable(clamped) ? clamped : { ...clamped, c: _last_good_c }
165+
inDestinationGamut(clamped) ? clamped : { ...clamped, c: _last_good_c }
160166
);
161167
}
162168

@@ -173,13 +179,16 @@ export function clampChroma(color, mode = 'lch') {
173179
the test used in the binary search is replaced with
174180
"is color is roughly in gamut", by comparing the candidate
175181
to the clipped version (obtained with `clampGamut`).
176-
The test passes if the colors are not to dissimilar,
182+
The test passes if the colors are not too dissimilar,
177183
judged by the `delta` color difference function
178184
and an associated `jnd` just-noticeable difference value.
179185
180186
The default arguments for this function correspond to the
181187
gamut mapping algorithm defined in CSS Color Level 4:
182188
https://drafts.csswg.org/css-color/#css-gamut-mapping
189+
190+
To disable the “roughly in gamut” part, pass either
191+
`null` for the `delta` parameter, or zero for `jnd`.
183192
*/
184193
export function toGamut(
185194
dest = 'rgb',
@@ -234,7 +243,7 @@ export function toGamut(
234243
clipped = clipToGamut(candidate);
235244
if (
236245
inDestinationGamut(candidate) ||
237-
(jnd > 0 && delta(candidate, clipped) <= jnd)
246+
(delta && jnd > 0 && delta(candidate, clipped) <= jnd)
238247
) {
239248
start = candidate.c;
240249
} else {

test/clamp.test.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import {
55
inGamut,
66
clampGamut,
77
formatCss,
8-
toGamut
8+
toGamut,
9+
lch
910
} from '../src/index.js';
1011

1112
tape('RGB', function (test) {
@@ -67,6 +68,12 @@ tape('Issue #129', function (test) {
6768
clampChroma({ mode: 'oklch', l: 0.5, c: 0.16, h: 180 }, 'oklch'),
6869
{ mode: 'oklch', l: 0.5, c: 0.090703125, h: 180 }
6970
);
71+
72+
test.equal(
73+
formatCss(clampChroma('lch(80% 150 60)', 'lch', 'p3')),
74+
'lch(80 60.040283203125 60)',
75+
'with p3 gamut'
76+
);
7077
test.end();
7178
});
7279

@@ -157,5 +164,15 @@ tape('toGamut()', t => {
157164
'color(display-p3 0.9999999999999994 0.6969234154991887 0.5084794582132421)',
158165
'api docs example'
159166
);
167+
168+
const likeClampChroma = toGamut('rgb', 'lch', null);
169+
170+
t.deepEqual(lch(likeClampChroma('lch(50% 120 5)')), {
171+
mode: 'lch',
172+
l: 50.00519612994975,
173+
c: 77.47625128342412,
174+
h: 5.006331789592595
175+
});
176+
160177
t.end();
161178
});

0 commit comments

Comments
 (0)