Skip to content

Commit

Permalink
fix(color-contrast): properly blend multiple alpha colors (#3193)
Browse files Browse the repository at this point in the history
* fix(color-contrast): properly blend multiple alpha colors

* revert shadow

* add back shadow color flatten

* fix

* fix ie11

* type

* typo
  • Loading branch information
straker committed Oct 18, 2021
1 parent d742b29 commit 5aa0441
Show file tree
Hide file tree
Showing 9 changed files with 365 additions and 14 deletions.
4 changes: 2 additions & 2 deletions lib/checks/color/color-contrast-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
getContrast,
getOwnBackgroundColor,
getTextShadowColors,
flattenColors
flattenShadowColors
} from '../../commons/color';
import { memoize } from '../../core/utils';

Expand Down Expand Up @@ -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);
Expand Down
64 changes: 58 additions & 6 deletions lib/commons/color/flatten-colors.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
}
Expand Down
22 changes: 22 additions & 0 deletions lib/commons/color/flatten-shadow-colors.js
Original file line number Diff line number Diff line change
@@ -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;
17 changes: 13 additions & 4 deletions lib/commons/color/get-background-color.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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;
}

Expand Down
3 changes: 2 additions & 1 deletion lib/commons/color/get-foreground-color.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions lib/commons/color/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
23 changes: 22 additions & 1 deletion test/commons/color/flatten-colors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
});
});
Loading

0 comments on commit 5aa0441

Please sign in to comment.