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(); }); }; });