From 5aa0441f2b33f8e1055ac32d981df4f796f7bb88 Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Fri, 8 Oct 2021 09:33:54 -0600 Subject: [PATCH] fix(color-contrast): properly blend multiple alpha colors (#3193) * fix(color-contrast): properly blend multiple alpha colors * revert shadow * add back shadow color flatten * fix * fix ie11 * type * typo --- lib/checks/color/color-contrast-evaluate.js | 4 +- lib/commons/color/flatten-colors.js | 64 ++++++- lib/commons/color/flatten-shadow-colors.js | 22 +++ lib/commons/color/get-background-color.js | 17 +- lib/commons/color/get-foreground-color.js | 3 +- lib/commons/color/index.js | 1 + test/commons/color/flatten-colors.js | 23 ++- test/integration/full/contrast/blending.html | 189 +++++++++++++++++++ test/integration/full/contrast/blending.js | 56 ++++++ 9 files changed, 365 insertions(+), 14 deletions(-) create mode 100644 lib/commons/color/flatten-shadow-colors.js create mode 100644 test/integration/full/contrast/blending.html create mode 100644 test/integration/full/contrast/blending.js diff --git a/lib/checks/color/color-contrast-evaluate.js b/lib/checks/color/color-contrast-evaluate.js index 18a58f4e4b..8aa165d37d 100644 --- a/lib/checks/color/color-contrast-evaluate.js +++ b/lib/checks/color/color-contrast-evaluate.js @@ -12,7 +12,7 @@ import { getContrast, getOwnBackgroundColor, getTextShadowColors, - flattenColors + flattenShadowColors } from '../../commons/color'; import { memoize } from '../../core/utils'; @@ -73,7 +73,7 @@ export default function colorContrastEvaluate(node, options, virtualNode) { } else if (fgColor && bgColor) { // Thin shadows can pass either by contrasting with the text color // or when contrasting with the background. - shadowColor = [...shadowColors, bgColor].reduce(flattenColors); + shadowColor = [...shadowColors, bgColor].reduce(flattenShadowColors); const bgContrast = getContrast(bgColor, shadowColor); const fgContrast = getContrast(shadowColor, fgColor); contrast = Math.max(bgContrast, fgContrast); diff --git a/lib/commons/color/flatten-colors.js b/lib/commons/color/flatten-colors.js index e4dc587fa6..2c3de0e033 100644 --- a/lib/commons/color/flatten-colors.js +++ b/lib/commons/color/flatten-colors.js @@ -1,5 +1,33 @@ import Color from './color'; +// how to combine background and foreground colors together when using +// the CSS property `mix-blend-mode`. Defaults to `normal` +// @see https://www.w3.org/TR/compositing-1/#blendingseparable +const blendFunctions = { + normal(Cb, Cs) { + return Cs; + } +}; + +// Simple Alpha Compositing written as non-premultiplied. +// formula: Rrgb × Ra = Srgb × Sa + Drgb × Da × (1 − Sa) +// Cs: the source color +// αs: the source alpha +// Cb: the backdrop color +// αb: the backdrop alpha +// @see https://www.w3.org/TR/compositing-1/#simplealphacompositing +// @see https://www.w3.org/TR/compositing-1/#blending +// @see https://ciechanow.ski/alpha-compositing/ +function simpleAlphaCompositing(Cs, αs, Cb, αb, blendMode) { + // RGB color space doesn't have decimal values so we will follow what browsers do and round + // e.g. rgb(255.2, 127.5, 127.8) === rgb(255, 128, 128) + return Math.round( + αs * (1 - αb) * Cs + + αs * αb * blendFunctions[blendMode](Cb, Cs) + + (1 - αs) * αb * Cb + ); +} + /** * Combine the two given color according to alpha blending. * @method flattenColors @@ -9,12 +37,36 @@ import Color from './color'; * @param {Color} bgColor Background color * @return {Color} Blended color */ -function flattenColors(fgColor, bgColor) { - var alpha = fgColor.alpha; - var r = (1 - alpha) * bgColor.red + alpha * fgColor.red; - var g = (1 - alpha) * bgColor.green + alpha * fgColor.green; - var b = (1 - alpha) * bgColor.blue + alpha * fgColor.blue; - var a = fgColor.alpha + bgColor.alpha * (1 - fgColor.alpha); +function flattenColors(fgColor, bgColor, blendMode = 'normal') { + // foreground is the "source" color and background is the "backdrop" color + const r = simpleAlphaCompositing( + fgColor.red, + fgColor.alpha, + bgColor.red, + bgColor.alpha, + blendMode + ); + const g = simpleAlphaCompositing( + fgColor.green, + fgColor.alpha, + bgColor.green, + bgColor.alpha, + blendMode + ); + const b = simpleAlphaCompositing( + fgColor.blue, + fgColor.alpha, + bgColor.blue, + bgColor.alpha, + blendMode + ); + + // formula: αo = αs + αb x (1 - αs) + // clamp alpha between 0 and 1 + const a = Math.max( + 0, + Math.min(fgColor.alpha + bgColor.alpha * (1 - fgColor.alpha), 1) + ); return new Color(r, g, b, a); } diff --git a/lib/commons/color/flatten-shadow-colors.js b/lib/commons/color/flatten-shadow-colors.js new file mode 100644 index 0000000000..404aae41ac --- /dev/null +++ b/lib/commons/color/flatten-shadow-colors.js @@ -0,0 +1,22 @@ +import Color from './color'; + +/** + * Combine the two given shadow colors according to alpha blending. + * @method flattenColors + * @memberof axe.commons.color.Color + * @instance + * @param {Color} fgColor Foreground color + * @param {Color} bgColor Background color + * @return {Color} Blended color + */ +function flattenColors(fgColor, bgColor) { + var alpha = fgColor.alpha; + var r = (1 - alpha) * bgColor.red + alpha * fgColor.red; + var g = (1 - alpha) * bgColor.green + alpha * fgColor.green; + var b = (1 - alpha) * bgColor.blue + alpha * fgColor.blue; + var a = fgColor.alpha + bgColor.alpha * (1 - fgColor.alpha); + + return new Color(r, g, b, a); +} + +export default flattenColors; diff --git a/lib/commons/color/get-background-color.js b/lib/commons/color/get-background-color.js index f281b7ebba..230fa9d568 100644 --- a/lib/commons/color/get-background-color.js +++ b/lib/commons/color/get-background-color.js @@ -4,6 +4,7 @@ import getOwnBackgroundColor from './get-own-background-color'; import elementHasImage from './element-has-image'; import Color from './color'; import flattenColors from './flatten-colors'; +import flattenShadowColors from './flatten-shadow-colors'; import getTextShadowColors from './get-text-shadow-colors'; import visuallyContains from '../dom/visually-contains'; @@ -38,6 +39,9 @@ function elmPartiallyObscured(elm, bgElm, bgColor) { */ function getBackgroundColor(elm, bgElms = [], shadowOutlineEmMax = 0.1) { let bgColors = getTextShadowColors(elm, { minRatio: shadowOutlineEmMax }); + if (bgColors.length) { + bgColors = [bgColors.reduce(flattenShadowColors)]; + } const elmStack = getBackgroundStack(elm); // Search the stack until we have an alpha === 1 background @@ -62,7 +66,7 @@ function getBackgroundColor(elm, bgElms = [], shadowOutlineEmMax = 0.1) { if (bgColor.alpha !== 0) { // store elements contributing to the br color. bgElms.push(bgElm); - bgColors.push(bgColor); + bgColors.unshift(bgColor); // Exit if the background is opaque return bgColor.alpha === 1; @@ -75,9 +79,14 @@ function getBackgroundColor(elm, bgElms = [], shadowOutlineEmMax = 0.1) { return null; } - // Mix the colors together, on top of a default white - bgColors.push(new Color(255, 255, 255, 1)); - var colors = bgColors.reduce(flattenColors); + // Mix the colors together, on top of a default white. Colors must be mixed + // in bottom up order (background to foreground order) to produce the correct + // result. + // @see https://github.com/dequelabs/axe-core/issues/2924 + bgColors.unshift(new Color(255, 255, 255, 1)); + var colors = bgColors.reduce((bgColor, fgColor) => { + return flattenColors(fgColor, bgColor); + }); return colors; } diff --git a/lib/commons/color/get-foreground-color.js b/lib/commons/color/get-foreground-color.js index 594d31cd15..d9b8139af3 100644 --- a/lib/commons/color/get-foreground-color.js +++ b/lib/commons/color/get-foreground-color.js @@ -2,6 +2,7 @@ import Color from './color'; import getBackgroundColor from './get-background-color'; import incompleteData from './incomplete-data'; import flattenColors from './flatten-colors'; +import flattenShadowColors from './flatten-shadow-colors'; import getTextShadowColors from './get-text-shadow-colors'; import { getNodeFromTree } from '../../core/utils'; @@ -66,7 +67,7 @@ function getForegroundColor(node, _, bgColor) { if (fgColor.alpha < 1) { const textShadowColors = getTextShadowColors(node, { minRatio: 0 }); - return [fgColor, ...textShadowColors, bgColor].reduce(flattenColors); + return [fgColor, ...textShadowColors, bgColor].reduce(flattenShadowColors); } return flattenColors(fgColor, bgColor); diff --git a/lib/commons/color/index.js b/lib/commons/color/index.js index c795340b85..156cb3eded 100644 --- a/lib/commons/color/index.js +++ b/lib/commons/color/index.js @@ -9,6 +9,7 @@ export { default as elementHasImage } from './element-has-image'; export { default as elementIsDistinct } from './element-is-distinct'; export { default as filteredRectStack } from './filtered-rect-stack'; export { default as flattenColors } from './flatten-colors'; +export { default as flattenShadowColors } from './flatten-shadow-colors'; export { default as getBackgroundColor } from './get-background-color'; export { default as getBackgroundStack } from './get-background-stack'; export { default as getContrast } from './get-contrast'; diff --git a/test/commons/color/flatten-colors.js b/test/commons/color/flatten-colors.js index c9244e40ca..bceb351f52 100644 --- a/test/commons/color/flatten-colors.js +++ b/test/commons/color/flatten-colors.js @@ -6,7 +6,10 @@ describe('color.flattenColors', function() { var fullblack = new axe.commons.color.Color(0, 0, 0, 1); var transparent = new axe.commons.color.Color(0, 0, 0, 0); var white = new axe.commons.color.Color(255, 255, 255, 1); - var gray = new axe.commons.color.Color(127.5, 127.5, 127.5, 1); + var gray = new axe.commons.color.Color(128, 128, 128, 1); + var halfRed = new axe.commons.color.Color(255, 0, 0, 0.5); + var quarterLightGreen = new axe.commons.color.Color(0, 128, 0, 0.25); + var flat = axe.commons.color.flattenColors(halfblack, white); assert.equal(flat.red, gray.red); assert.equal(flat.green, gray.green); @@ -21,5 +24,23 @@ describe('color.flattenColors', function() { assert.equal(flat3.red, white.red); assert.equal(flat3.green, white.green); assert.equal(flat3.blue, white.blue); + + var flat4 = axe.commons.color.flattenColors(halfRed, white); + assert.equal(flat4.red, 255); + assert.equal(flat4.green, 128); + assert.equal(flat4.blue, 128); + assert.equal(flat4.alpha, 1); + + var flat5 = axe.commons.color.flattenColors(quarterLightGreen, white); + assert.equal(flat5.red, 191); + assert.equal(flat5.green, 223); + assert.equal(flat5.blue, 191); + assert.equal(flat5.alpha, 1); + + var flat6 = axe.commons.color.flattenColors(quarterLightGreen, halfRed); + assert.equal(flat6.red, 96); + assert.equal(flat6.green, 32); + assert.equal(flat6.blue, 0); + assert.equal(flat6.alpha, 0.625); }); }); diff --git a/test/integration/full/contrast/blending.html b/test/integration/full/contrast/blending.html new file mode 100644 index 0000000000..2fbc1ad89e --- /dev/null +++ b/test/integration/full/contrast/blending.html @@ -0,0 +1,189 @@ + + + + Color Contrast Blending Verification Tests + + + + + + + + + +

+ Use this page to verify that axe-core produces the correct colors for each + blended background. If using Chrome, please ensure it is using the + sRGB color profile by navigating to chrome://flags/, searching for "Force + color profile" and setting it to "sRGB" (otherwise it uses the OS color + profile which for Mac, which we believe is "Display P3 D65" and will + produce the incorrect result color when blending). +

+

+ For more information, see + https://github.com/dequelabs/axe-core/issues/2924 +

+
+
+
+
+
+ Test1 +
+
+
+
Test1 result
+
+ +
+
+
+ Test2 +
+
+
Test2 result
+
+ +
+
+
+ Test3 +
+
+
Test3 result
+
+ +
+
+
+
+ Test4 +
+
+
+
Test4 result
+
+ +
+
+
+ Test5 +
+
+
Test5 result
+
+ +
+
+
+ Test6 +
+
+
Test6 result
+
+ +
+
+
+
+
+ Test7 +
+
+
+
+
Test7 result
+
+ +
+
+
+
+
+
+
+ Test8 +
+
+
+
+
+
+
Test8 result
+
+ +
+
+
+ Test9 +
+
+
Test9 result
+
+
+ +
+ + + + diff --git a/test/integration/full/contrast/blending.js b/test/integration/full/contrast/blending.js new file mode 100644 index 0000000000..7d1bde4859 --- /dev/null +++ b/test/integration/full/contrast/blending.js @@ -0,0 +1,56 @@ +describe('color-contrast blending test', function() { + var include = []; + var resultElms = []; + var expected = [ + 'rgb(223, 112, 96)', + 'rgb(255, 128, 128)', + 'rgb(191, 223, 191)', + 'rgb(125, 38, 54)', + 'rgb(179, 38, 0)', + 'rgb(179, 0, 77)', + 'rgb(143, 192, 80)', + 'rgb(147, 153, 119)', + 'rgb(221, 221, 221)' + ]; + var testElms = Array.from(document.querySelectorAll('#fixture > div')); + testElms.forEach(function(testElm) { + var id = testElm.id; + var target = testElm.querySelector('#' + id + '-target'); + var result = testElm.querySelector('#' + id + '-result'); + include.push(target); + resultElms.push(result); + }); + + before(function(done) { + axe.run({ include: include }, { runOnly: ['color-contrast'] }, function( + err, + res + ) { + assert.isNull(err); + + // don't care where the result goes as we just want to + // extract the background color for each one + var results = [] + .concat(res.passes) + .concat(res.violations) + .concat(res.incomplete); + results.forEach(function(result) { + result.nodes.forEach(function(node) { + var bgColor = node.any[0].data.bgColor; + var id = node.target[0].split('-')[0]; + var result = document.querySelector(id + '-result'); + result.style.backgroundColor = bgColor; + }); + }); + + done(); + }); + }); + + resultElms.forEach(function(elm, index) { + it('produces the correct blended color for ' + elm.id, function() { + var style = window.getComputedStyle(elm); + assert.equal(style.getPropertyValue('background-color'), expected[index]); + }); + }); +});