Skip to content

Commit

Permalink
CSS4 toGamut fixes (#352)
Browse files Browse the repository at this point in the history
Co-authored-by: Jonny Gerig Meyer <jonny@oddbird.net>
  • Loading branch information
jamesnw and jgerigmeyer authored Dec 17, 2023
1 parent 742728d commit 38a5059
Show file tree
Hide file tree
Showing 3 changed files with 194 additions and 99 deletions.
45 changes: 21 additions & 24 deletions src/toGamut.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import deltaEOK from "./deltaE/deltaEOK.js";
import inGamut from "./inGamut.js";
import to from "./to.js";
import get from "./get.js";
import oklab from "./spaces/oklab.js";
import set from "./set.js";
import clone from "./clone.js";
import getColor from "./getColor.js";
Expand Down Expand Up @@ -117,8 +118,8 @@ toGamut.returns = "color";
// `Oklch` space. These are created in the `Oklab` space, as it is used by the
// DeltaEOK calculation, so it is guaranteed to be imported.
const COLORS = {
WHITE: { space: "oklab", coords: [1, 0, 0] },
BLACK: { space: "oklab", coords: [0, 0, 0] }
WHITE: { space: oklab, coords: [1, 0, 0] },
BLACK: { space: oklab, coords: [0, 0, 0] }
};

/**
Expand All @@ -135,12 +136,13 @@ export function toGamutCSS (origin, { space = origin.space }) {
const JND = 0.02;
const ε = 0.0001;
space = ColorSpace.get(space);
const oklchSpace = ColorSpace.get("oklch");

if (space.isUnbounded) {
return to(origin, space);
}

const origin_OKLCH = to(origin, ColorSpace.get("oklch"));
const origin_OKLCH = to(origin, oklchSpace);
let L = origin_OKLCH.coords[0];

// return media white or black, if lightness is out of range
Expand All @@ -155,48 +157,44 @@ export function toGamutCSS (origin, { space = origin.space }) {
return to(black, space);
}

if (inGamut(origin_OKLCH, space)) {
if (inGamut(origin_OKLCH, space, {epsilon: 0})) {
return to(origin_OKLCH, space);
}

function clip (_color) {
const destColor = to(_color, space);
const spaceCoords = Object.values(space.coords);
destColor.coords = destColor.coords.map((coord, index) => {
const spaceCoord = spaceCoords[index];
if (("range" in spaceCoord)) {
if (coord < spaceCoord.range[0]) {
return spaceCoord.range[0];
}
if (coord > spaceCoord.range[1]) {
return spaceCoord.range[1];
}
if ("range" in spaceCoords[index]) {
const [min, max] = spaceCoords[index].range;
return util.clamp(min, coord, max);
}
return coord;
});
return destColor;
}
let min = 0;
let max = origin_OKLCH.coords[1];

let min_inGamut = true;
let current;
let current = clone(origin_OKLCH);
let clipped = clip(current);

let E = deltaEOK(clipped, current);
if (E < JND) {
return clipped;
}

while ((max - min) > ε) {
const chroma = (min + max) / 2;
current = clone(origin_OKLCH);
current.coords[1] = chroma;
if (min_inGamut && inGamut(current, space)) {
if (min_inGamut && inGamut(current, space, {epsilon: 0})) {
min = chroma;
continue;
}
else if (!inGamut(current, space)) {
const clipped = clip(current);
const E = deltaEOK(clipped, current);
else {
clipped = clip(current);
E = deltaEOK(clipped, current);
if (E < JND) {
if ((JND - E < ε)) {
// match found
current = clipped;
break;
}
else {
Expand All @@ -206,9 +204,8 @@ export function toGamutCSS (origin, { space = origin.space }) {
}
else {
max = chroma;
continue;
}
}
}
return to(current, space);
return clipped;
}
11 changes: 11 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,14 @@ export function parseCoordGrammar (coordGrammars) {
});
});
}

/**
* Clamp value between the minimum and maximum
* @param {number} min minimum value to return
* @param {number} val the value to return if it is between min and max
* @param {number} max maximum value to return
* @returns number
*/
export function clamp (min, val, max){
return Math.max(Math.min(max, val), min);
}
237 changes: 162 additions & 75 deletions test/gamut.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,87 +11,174 @@ export default {
return color2;
},
map (c) {
return new Color(c).coords;
const color = new Color(c);
return this.data.checkAlpha ? [
...color.coords,
color.alpha
] : color.coords;
},
check: check.deep(check.proximity({ epsilon: 0.01 })),
check: check.deep(check.proximity({ epsilon: 0.001 })),
tests: [
{
name: "P3 primaries to sRGB",
data: {toSpace: "srgb"},
name: "P3 primaries to sRGB, CSS algorithm",
data: { toSpace: "srgb" },
tests: [
{
name: "Using chroma reduction",
tests: [
{
args: ["color(display-p3 1 0 0)"],
expect: "rgb(98.20411139286732% 21.834053137266363% 0%)"
},
{
args: ["color(display-p3 0 1 0)"],
expect: "rgb(0% 99.7921930734509% 0%)"
},
{
args: ["color(display-p3 0 0 1)"],
expect: "rgb(0% 0% 100%)"
},
{
args: ["color(display-p3 1 1 0)"],
expect: "rgb(100% 99.45446271521069% 0%)"
},
args: ["color(display-p3 1 0 0)"],
expect: "rgb(100% 4.457% 4.5932%)"
},
{
args: ["color(display-p3 0 1 0)"],
expect: "rgb(0% 98.576% 15.974%)"
},
{
args: ["color(display-p3 0 0 1)"],
expect: "rgb(0% 0% 100%)"
},
{
args: ["color(display-p3 1 1 0)"],
expect: "rgb(99.623% 99.901% 0%)"
},

{
args: ["color(display-p3 0 1 1)"],
expect: "rgb(0% 100% 98.93709142382755%)"
},
{
args: ["color(display-p3 1 0 1)"],
expect: "rgb(100% 8.637212218104592% 98.22133121285436%)"
},
]
},
{
name: "Using HSL saturation reduction",
data: {method: "hsl.s"},
tests: [
{
args: ["color(display-p3 1 0 0)"],
expect: "rgb(100% 0% 0%)"
},
{
args: ["color(display-p3 0 1 0)"],
expect: "rgb(0% 100% 0%)"
},
{
args: ["color(display-p3 0 0 1)"],
expect: "rgb(0% 0% 100%)"
},
{
args: ["color(display-p3 1 1 0)"],
expect: "rgb(100% 100% 0%)"
},
{
args: ["color(display-p3 0 1 1)"],
expect: "rgb(0% 100% 100%)"
},
{
args: ["color(display-p3 1 0 1)"],
expect: "rgb(100% 0% 100%)"
}
]
},
{
name: "Using clipping",
data: {method: "clip"},
tests: [
{
args: ["color(display-p3 1 0 0)"],
expect: "rgb(100% 0% 0%)"
},
{
args: ["color(display-p3 0 1 0)"],
expect: "rgb(0% 100% 0%)"
},
]
{
args: ["color(display-p3 0 1 1)"],
expect: "rgb(0% 99.645% 98.471%)"
},
{
args: ["color(display-p3 1 0 1)"],
expect: "rgb(100% 16.736% 98.264%)"
},
]
},
{
name: "P3 to sRGB whites/blacks CSS algorithm",
data: { toSpace: "srgb" },
tests: [
{
args: ["color(display-p3 1 1 1)"],
expect: "rgb(100% 100% 100%)"
},
{
args: ["color(display-p3 2 0 1)"],
expect: "rgb(100% 100% 100%)"
},
{
args: ["color(display-p3 0 0 0)"],
expect: "rgb(0% 0% 0%)"
},
{
args: ["color(display-p3 -1 0 0)"],
expect: "rgb(0% 0% 0%)"
}
]
},
{
name: "Maintains alpha",
data: { toSpace: "srgb", checkAlpha: true },
tests: [
{
args: ["color(display-p3 1 1 1 / 1)"],
expect: "rgb(100% 100% 100%)"
},
{
args: ["color(display-p3 1 1 1 / 0.5)"],
expect: "rgb(100% 100% 100% / 0.5)"
},
{
args: ["color(display-p3 1 1 1 / 0)"],
expect: "rgb(100% 100% 100% / 0)"
},
{
args: ["color(display-p3 1 0 0 / 1)"],
expect: "rgb(100% 4.457% 4.5932%)"
},
{
args: ["color(display-p3 1 0 0 / 0.5)"],
expect: "rgb(100% 4.457% 4.5932% / 0.5)"
},
{
args: ["color(display-p3 1 0 0 / 0)"],
expect: "rgb(100% 4.457% 4.5932% / 0)"
},
]
},
{
name: "P3 primaries to sRGB, LCH chroma Reduction",
data: { toSpace: "srgb", method: "lch.c" },
tests: [
{
args: ["color(display-p3 1 0 0)"],
expect: "rgb(98.20411139286732% 21.834053137266363% 0%)"
},
{
args: ["color(display-p3 0 1 0)"],
expect: "rgb(0% 99.7921930734509% 0%)"
},
{
args: ["color(display-p3 0 0 1)"],
expect: "rgb(0% 0% 100%)"
},
{
args: ["color(display-p3 1 1 0)"],
expect: "rgb(100% 99.45446271521069% 0%)"
},

{
args: ["color(display-p3 0 1 1)"],
expect: "rgb(0% 100% 98.93709142382755%)"
},
{
args: ["color(display-p3 1 0 1)"],
expect: "rgb(100% 8.637212218104592% 98.22133121285436%)"
}
]
},

{
name: "P3 primaries to sRGB, HSL saturation reduction",
data: { method: "hsl.s", toSpace: "sRGB" },
tests: [
{
args: ["color(display-p3 1 0 0)"],
expect: "rgb(100% 0% 0%)"
},
{
args: ["color(display-p3 0 1 0)"],
expect: "rgb(0% 75.29% 0%)"
},
{
args: ["color(display-p3 0 0 1)"],
expect: "rgb(0% 0% 100%)"
},
{
args: ["color(display-p3 1 1 0)"],
expect: "rgb(84.872% 84.872% 0%)"
},
{
args: ["color(display-p3 0 1 1)"],
expect: "rgb(0% 76.098% 75.455%))"
},
{
args: ["color(display-p3 1 0 1)"],
expect: "rgb(100% 0% 100%)"
}
]
},
{
name: "Using clipping",
data: { method: "clip", toSpace: "sRGB" },
tests: [
{
args: ["color(display-p3 1 0 0)"],
expect: "rgb(100% 0% 0%)"
},
{
args: ["color(display-p3 0 1 0)"],
expect: "rgb(0% 100% 0%)"
},
{
args: ["color(display-p3 0 0 1)"],
expect: "rgb(0% 0% 100%)"
},
]
},
Expand Down

0 comments on commit 38a5059

Please sign in to comment.