You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
233 lines
7.4 KiB
233 lines
7.4 KiB
const postcss = require('postcss');
|
|
const parser = require('postcss-values-parser');
|
|
const colorString = require('color-string');
|
|
|
|
module.exports = postcss.plugin('postcss-color-contrast-ratio', () => {
|
|
/* Yes there are color libraries for JavaScript, but haven't found any that does
|
|
color conversion from CIELAB to RGB correctly.
|
|
|
|
CIELAB and CIEXYZ need to use the D50 standard illuminant per the CSS color
|
|
specification (and because Photoshop uses it, too), and most libs will use
|
|
the illuminant of the input color. In the case of sRGB that'd be D65, and that's
|
|
incorrect. */
|
|
|
|
const lab2xyz = (() => {
|
|
const D50 = [ 0.96422, 1, 0.82521 ];
|
|
|
|
function f(t) {
|
|
return (t > 6 / 29) ? t ** 3 : 3 * (6 / 29) ** 2 * (t - 4 / 29);
|
|
}
|
|
|
|
return function lab2xyz([ l, a, b ]) {
|
|
const fl = (l + 16) / 116;
|
|
const fa = a / 500;
|
|
const fb = b / 200;
|
|
|
|
return [
|
|
D50[0] * f(fl + fa),
|
|
D50[1] * f(fl),
|
|
D50[2] * f(fl - fb)
|
|
];
|
|
};
|
|
})();
|
|
|
|
const xyz2rgb = (() => {
|
|
function sRGBCompanding(v) {
|
|
return (v <= 0.0031308) ? 12.92 * v : 1.055 * (v ** (1 / 2.4)) - 0.055;
|
|
}
|
|
|
|
// Conversion matrix from D50 to D65. Normally you'd go through a chromatic
|
|
// adaptation algorithm here then convert to RGB, but I pulled the result matrix
|
|
// from http://www.brucelindbloom.com/Eqn_RGB_XYZ_Matrix.html instead.
|
|
const sRGBD50toD65Matrix = [
|
|
3.1338561, -1.6168667, -0.4906146,
|
|
-0.9787684, 1.9161415, 0.0334540,
|
|
0.0719453, -0.2289914, 1.4052427
|
|
];
|
|
|
|
return function xyz2rgb([ x, y, z ]) {
|
|
let lrgb = matrixMultiplyVector(sRGBD50toD65Matrix, [x, y, z]);
|
|
|
|
return [
|
|
Math.min(Math.max(0, sRGBCompanding(lrgb[0])), 1),
|
|
Math.min(Math.max(0, sRGBCompanding(lrgb[1])), 1),
|
|
Math.min(Math.max(0, sRGBCompanding(lrgb[2])), 1)
|
|
];
|
|
}
|
|
})();
|
|
|
|
const matrixMultiplyVector = ( [ a, b, c, d, e, f, g, h, i ], [x, y, z]) => {
|
|
return [
|
|
a * x + b * y + c * z,
|
|
d * x + e * y + f * z,
|
|
g * x + h * y + i * z
|
|
];
|
|
};
|
|
|
|
const relativeLuminance = ([ r, g, b ]) => {
|
|
if (r <= 0.03928) {
|
|
r = r / 12.92;
|
|
} else {
|
|
r = Math.pow(((r + 0.055) / 1.055), 2.4);
|
|
}
|
|
|
|
if (g <= 0.03928) {
|
|
g = g / 12.92;
|
|
} else {
|
|
g = Math.pow(((g + 0.055) / 1.055), 2.4);
|
|
}
|
|
|
|
if (b <= 0.03928) {
|
|
b = b / 12.92;
|
|
} else {
|
|
b = Math.pow(((b + 0.055) / 1.055), 2.4);
|
|
}
|
|
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
};
|
|
|
|
const RGBtoHex = ([ r, g, b ]) => {
|
|
r = Math.round(r * 255).toString(16);
|
|
g = Math.round(g * 255).toString(16);
|
|
b = Math.round(b * 255).toString(16);
|
|
|
|
if (r.length % 2) {
|
|
r = '0' + r;
|
|
}
|
|
|
|
if (g.length % 2) {
|
|
g = '0' + g;
|
|
}
|
|
|
|
if (b.length % 2) {
|
|
b = '0' + b;
|
|
}
|
|
|
|
return `#${r}${g}${b}`;
|
|
};
|
|
|
|
const parseCrab = (node) => {
|
|
let check = '',
|
|
fail = false,
|
|
// Weird way to handle argument checking, but bear with me...
|
|
arguments = node.nodes.filter((n) => {
|
|
// The css value parser includes the parentheses and commas, so filter them out
|
|
if (n.type === 'paren' || n.type === 'comma') {
|
|
return false;
|
|
}
|
|
|
|
// While doing so add the types to a string to be checked momentarily
|
|
check += n.type;
|
|
|
|
return true;
|
|
});
|
|
|
|
// If the arguments length isn't 3 or 4 then they're invalid.
|
|
// If the arguments aren't one of the following then they're invalid:
|
|
// number, number, number
|
|
// number, number, number, word
|
|
// number, number, number, function
|
|
if ((arguments.length === 3 || arguments.length === 4) && (check === 'numbernumbernumber' || check === 'numbernumbernumberword' || check === 'numbernumbernumberfunc')) {
|
|
let cr = arguments[0].value,
|
|
a = arguments[1].value,
|
|
b = arguments[2].value,
|
|
bg = [ 1, 1, 1 ];
|
|
|
|
// Normalize the arguments
|
|
// WCAG contrast ratio is a value from 1 to 21; starting at 1.4 here
|
|
cr = Math.min(Math.max(cr, 1.4), 21);
|
|
// a* is a value from -128 to 127
|
|
a = Math.min(Math.max(a, -128), 127);
|
|
// b* is a value from -128 to 127
|
|
b = Math.min(Math.max(b, -128), 127);
|
|
|
|
// The last argument isn't required, so if it is defined then parse it
|
|
if (arguments[3] !== undefined) {
|
|
bg = arguments[3];
|
|
|
|
// If it is another crab function then parse it recursively
|
|
if (bg.type ==='func' && bg.value === 'crab') {
|
|
bg = parseCrab(bg);
|
|
|
|
if (bg === null) {
|
|
return null;
|
|
}
|
|
}
|
|
// Otherwise, if it is a color constant then try to parse it
|
|
// Otherwise, if it's some color function parse that, too
|
|
else {
|
|
bg = (bg.type === 'word') ? bg.value : bg.toString().trim();
|
|
|
|
if (bg === null) {
|
|
return null;
|
|
}
|
|
|
|
// Change the values to binary notation
|
|
bg = colorString.get.rgb(bg).slice(0, 3).map((n) => {
|
|
return n / 255;
|
|
});
|
|
}
|
|
|
|
// If any of the parsers failed then return null
|
|
if (bg === null) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// TODO: Optimize
|
|
// Start from 100 L*a*b luminance and go backwards until the color is within the
|
|
// desired contrast ratio range and return it
|
|
|
|
let l = 100,
|
|
bgLum = relativeLuminance(bg),
|
|
c, cLum, ratio;
|
|
|
|
while (true) {
|
|
c = lab2xyz([ l, a, b ]);
|
|
c = xyz2rgb(c);
|
|
|
|
cLum = relativeLuminance(c);
|
|
|
|
ratio = (bgLum + 0.05) / (cLum + 0.05);
|
|
|
|
if (cLum > bgLum) {
|
|
ratio = 1 / ratio;
|
|
}
|
|
|
|
if (ratio - 0.1 <= cr && ratio + 0.1 >= cr) {
|
|
break;
|
|
}
|
|
|
|
l -= 0.1;
|
|
}
|
|
|
|
return c;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
return (css) => {
|
|
css.walkDecls((decl) => {
|
|
// At first at least check if the value might have a crab function
|
|
if (!/(^|[^\w-])crab\(/i.test(decl.value)) {
|
|
return;
|
|
}
|
|
|
|
// If it might then parse the values and walk through them
|
|
let parsedValue = parser(decl.value).parse();
|
|
parsedValue.walk((node) => {
|
|
if (node.type === 'func' && node.value === 'crab') {
|
|
const value = parseCrab(node);
|
|
|
|
node.removeAll();
|
|
node.value = (value !== null) ? RGBtoHex(value) : 'transparent';
|
|
|
|
return value;
|
|
}
|
|
});
|
|
|
|
decl.value = parsedValue.toString();
|
|
});
|
|
};
|
|
});
|