diff --git a/lib/commons/color/flatten-colors.js b/lib/commons/color/flatten-colors.js index 2c3de0e033..a87de6656f 100644 --- a/lib/commons/color/flatten-colors.js +++ b/lib/commons/color/flatten-colors.js @@ -1,11 +1,66 @@ import Color from './color'; +// clamp a value between two numbers (inclusive) +function clamp(value, min, max) { + return Math.min(Math.max(min, value), max); +} + // 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; + }, + multiply(Cb, Cs) { + // @see https://www.w3.org/TR/compositing-1/#blendingmultiply + return Cs * Cb; + }, + screen(Cb, Cs) { + // @see https://www.w3.org/TR/compositing-1/#blendingscreen + return Cb + Cs - Cb * Cs; + }, + overlay(Cb, Cs) { + // @see https://www.w3.org/TR/compositing-1/#blendingoverlay + return this['hard-light'](Cs, Cb); + }, + darken(Cb, Cs) { + // @see https://www.w3.org/TR/compositing-1/#blendingdarken + return Math.min(Cb, Cs); + }, + lighten(Cb, Cs) { + // @see https://www.w3.org/TR/compositing-1/#blendinglighten + return Math.max(Cb, Cs); + }, + 'color-dodge'(Cb, Cs) { + // @see https://www.w3.org/TR/compositing-1/#blendingcolordodge + return Cb === 0 ? 0 : Cs === 1 ? 1 : Math.min(1, Cb / (1 - Cs)); + }, + 'color-burn'(Cb, Cs) { + // @see https://www.w3.org/TR/compositing-1/#blendingcolorburn + return Cb === 1 ? 1 : Cs === 0 ? 0 : 1 - Math.min(1, (1 - Cb) / Cs); + }, + 'hard-light'(Cb, Cs) { + // @see https://www.w3.org/TR/compositing-1/#blendinghardlight + + return Cs <= 0.5 ? this.multiply(Cb, 2 * Cs) : this.screen(Cb, 2 * Cs - 1); + }, + 'soft-light'(Cb, Cs) { + // @see https://www.w3.org/TR/compositing-1/#blendingsoftlight + if (Cs <= 0.5) { + return Cb - (1 - 2 * Cs) * Cb * (1 - Cb); + } else { + const D = Cb <= 0.25 ? ((16 * Cb - 12) * Cb + 4) * Cb : Math.sqrt(Cb); + return Cb + (2 * Cs - 1) * (D - Cb); + } + }, + difference(Cb, Cs) { + // @see https://www.w3.org/TR/compositing-1/#blendingdifference + return Math.abs(Cb - Cs); + }, + exclusion(Cb, Cs) { + // @see https://www.w3.org/TR/compositing-1/#blendingexclusion + return Cb + Cs - 2 * Cb * Cs; } }; @@ -19,12 +74,12 @@ const blendFunctions = { // @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( + return ( αs * (1 - αb) * Cs + - αs * αb * blendFunctions[blendMode](Cb, Cs) + - (1 - αs) * αb * Cb + // Note: Cs and Cb values need to be between 0 and 1 inclusive for the blend function + // @see https://www.w3.org/TR/compositing-1/#simplealphacompositing + αs * αb * blendFunctions[blendMode](Cb / 255, Cs / 255) * 255 + + (1 - αs) * αb * Cb ); } @@ -63,12 +118,22 @@ function flattenColors(fgColor, bgColor, blendMode = 'normal') { // 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) - ); + const αo = clamp(fgColor.alpha + bgColor.alpha * (1 - fgColor.alpha), 0, 1); + + // simple alpha compositing gives premultiplied values, but our Color + // constructor takes unpremultiplied values. So we need to divide the + // final color values by the final alpha + // formula: Co = co / αo + // @see https://www.w3.org/TR/compositing-1/#simplealphacompositing + // @see https://github.com/w3c/fxtf-drafts/issues/440#issuecomment-956418953 + // + // 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) + const Cr = Math.round(r / αo); + const Cg = Math.round(g / αo); + const Cb = Math.round(b / αo); - return new Color(r, g, b, a); + return new Color(Cr, Cg, Cb, αo); } export default flattenColors; diff --git a/lib/commons/color/get-background-color.js b/lib/commons/color/get-background-color.js index ff2a28bc87..499511b770 100644 --- a/lib/commons/color/get-background-color.js +++ b/lib/commons/color/get-background-color.js @@ -27,8 +27,9 @@ export default function getBackgroundColor( ) { let bgColors = getTextShadowColors(elm, { minRatio: shadowOutlineEmMax }); if (bgColors.length) { - bgColors = [bgColors.reduce(flattenShadowColors)]; + bgColors = [{ color: bgColors.reduce(flattenShadowColors) }]; } + const elmStack = getBackgroundStack(elm); // Search the stack until we have an alpha === 1 background @@ -53,7 +54,11 @@ export default function getBackgroundColor( if (bgColor.alpha !== 0) { // store elements contributing to the br color. bgElms.push(bgElm); - bgColors.unshift(bgColor); + const blendMode = bgElmStyle.getPropertyValue('mix-blend-mode'); + bgColors.unshift({ + color: bgColor, + blendMode: normalizeBlendMode(blendMode) + }); // Exit if the background is opaque return bgColor.alpha === 1; @@ -72,14 +77,28 @@ export default function getBackgroundColor( ); bgColors.unshift(...pageBgs); + // default to white if bgColors is empty + if (bgColors.length === 0) { + return new Color(255, 255, 255, 1); + } // Mix the colors together. 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 - var colors = bgColors.reduce((bgColor, fgColor) => { - return flattenColors(fgColor, bgColor); + const blendedColor = bgColors.reduce((bgColor, fgColor) => { + return flattenColors( + fgColor.color, + bgColor.color instanceof Color ? bgColor.color : bgColor, + fgColor.blendMode + ); }); - return colors; + + // default page background is white which must be mixed last + // @see https://www.w3.org/TR/compositing-1/#pagebackdrop + return flattenColors( + blendedColor.color instanceof Color ? blendedColor.color : blendedColor, + new Color(255, 255, 255, 1) + ); } /** @@ -99,6 +118,9 @@ function elmPartiallyObscured(elm, bgElm, bgColor) { return obscured; } +function normalizeBlendMode(blendmode) { + return !!blendmode ? blendmode : undefined; +} /** * Get the page background color. * @private @@ -131,24 +153,30 @@ function getPageBackgroundColors(elm, stackContainsBody) { const bodyBgColor = getOwnBackgroundColor(bodyStyle); const bodyBgColorApplies = bodyBgColor.alpha !== 0 && visuallyContains(elm, body); - if ( (bodyBgColor.alpha !== 0 && htmlBgColor.alpha === 0) || (bodyBgColorApplies && bodyBgColor.alpha !== 1) ) { - pageColors.unshift(bodyBgColor); + pageColors.unshift({ + color: bodyBgColor, + blendMode: normalizeBlendMode( + bodyStyle.getPropertyValue('mix-blend-mode') + ) + }); } if ( htmlBgColor.alpha !== 0 && (!bodyBgColorApplies || (bodyBgColorApplies && bodyBgColor.alpha !== 1)) ) { - pageColors.unshift(htmlBgColor); + pageColors.unshift({ + color: htmlBgColor, + blendMode: normalizeBlendMode( + htmlStyle.getPropertyValue('mix-blend-mode') + ) + }); } } - // default page background is white - pageColors.unshift(new Color(255, 255, 255, 1)); - return pageColors; } diff --git a/test/commons/color/flatten-colors.js b/test/commons/color/flatten-colors.js index bceb351f52..3343eda8b5 100644 --- a/test/commons/color/flatten-colors.js +++ b/test/commons/color/flatten-colors.js @@ -38,9 +38,251 @@ describe('color.flattenColors', function() { 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.red, 153); + assert.equal(flat6.green, 51); assert.equal(flat6.blue, 0); assert.equal(flat6.alpha, 0.625); }); }); + +describe('color.flattenColors mix-blend-mode functions', function() { + 'use strict'; + + var colourOne = new axe.commons.color.Color(216, 22, 22, 1); + var colourTwo = new axe.commons.color.Color(114, 129, 114, 0.25); + + var colourThree = new axe.commons.color.Color(211, 162, 180, 1); + var colourFour = new axe.commons.color.Color(115, 255, 0, 0.5); + + it('should flatten colors correctly using blend mode: multiply', function() { + var flatten = axe.commons.color.flattenColors( + colourTwo, + colourOne, + 'multiply' + ); + assert.equal(flatten.red, 186); + assert.equal(flatten.green, 19); + assert.equal(flatten.blue, 19); + assert.equal(flatten.alpha, 1); + + var flattenTwo = axe.commons.color.flattenColors( + colourFour, + colourThree, + 'multiply' + ); + assert.equal(flattenTwo.red, 153); + assert.equal(flattenTwo.green, 162); + assert.equal(flattenTwo.blue, 90); + assert.equal(flattenTwo.alpha, 1); + }); + + it('should flatten colors correctly using blend mode: screen', function() { + var flatten = axe.commons.color.flattenColors( + colourTwo, + colourOne, + 'screen' + ); + assert.equal(flatten.red, 220); + assert.equal(flatten.green, 51); + assert.equal(flatten.blue, 48); + assert.equal(flatten.alpha, 1); + + var flattenTwo = axe.commons.color.flattenColors( + colourFour, + colourThree, + 'screen' + ); + assert.equal(flattenTwo.red, 221); + assert.equal(flattenTwo.green, 209); + assert.equal(flattenTwo.blue, 180); + assert.equal(flattenTwo.alpha, 1); + }); + + it('should flatten colors correctly using blend mode: overlay', function() { + var flatten = axe.commons.color.flattenColors( + colourTwo, + colourOne, + 'overlay' + ); + assert.equal(flatten.red, 215); + assert.equal(flatten.green, 22); + assert.equal(flatten.blue, 21); + assert.equal(flatten.alpha, 1); + }); + + it('should flatten colors correctly using blend mode: darken', function() { + var flatten = axe.commons.color.flattenColors( + colourTwo, + colourOne, + 'darken' + ); + assert.equal(flatten.red, 191); + assert.equal(flatten.green, 22); + assert.equal(flatten.blue, 22); + assert.equal(flatten.alpha, 1); + + var flattenTwo = axe.commons.color.flattenColors( + colourFour, + colourThree, + 'darken' + ); + assert.equal(flattenTwo.red, 163); + assert.equal(flattenTwo.green, 162); + assert.equal(flattenTwo.blue, 90); + assert.equal(flattenTwo.alpha, 1); + }); + + it('should flatten colors correctly using blend mode: lighten', function() { + var flatten = axe.commons.color.flattenColors( + colourTwo, + colourOne, + 'lighten' + ); + assert.equal(flatten.red, 216); + assert.equal(flatten.green, 49); + assert.equal(flatten.blue, 45); + assert.equal(flatten.alpha, 1); + + var flattenTwo = axe.commons.color.flattenColors( + colourFour, + colourThree, + 'lighten' + ); + assert.equal(flattenTwo.red, 211); + assert.equal(flattenTwo.green, 209); + assert.equal(flattenTwo.blue, 180); + assert.equal(flattenTwo.alpha, 1); + }); + + it('should flatten colors correctly using blend mode: color-dodge', function() { + var flatten = axe.commons.color.flattenColors( + colourTwo, + colourOne, + 'color-dodge' + ); + assert.equal(flatten.red, 226); + assert.equal(flatten.green, 28); + assert.equal(flatten.blue, 26); + assert.equal(flatten.alpha, 1); + + var flattenTwo = axe.commons.color.flattenColors( + colourFour, + colourThree, + 'color-dodge' + ); + assert.equal(flattenTwo.red, 233); + assert.equal(flattenTwo.green, 209); + assert.equal(flattenTwo.blue, 180); + assert.equal(flattenTwo.alpha, 1); + }); + + it('should flatten colors correctly using blend mode: color-burn', function() { + var flatten = axe.commons.color.flattenColors( + colourTwo, + colourOne, + 'color-burn' + ); + assert.equal(flatten.red, 204); + assert.equal(flatten.green, 17); + assert.equal(flatten.blue, 17); + assert.equal(flatten.alpha, 1); + + var flattenTwo = axe.commons.color.flattenColors( + colourFour, + colourThree, + 'color-burn' + ); + assert.equal(flattenTwo.red, 184); + assert.equal(flattenTwo.green, 162); + assert.equal(flattenTwo.blue, 90); + assert.equal(flattenTwo.alpha, 1); + }); + + it('should flatten colors correctly using blend mode: hard-light', function() { + var flatten = axe.commons.color.flattenColors( + colourTwo, + colourOne, + 'hard-light' + ); + assert.equal(flatten.red, 210); + assert.equal(flatten.green, 23); + assert.equal(flatten.blue, 21); + assert.equal(flatten.alpha, 1); + + var flattenTwo = axe.commons.color.flattenColors( + colourFour, + colourThree, + 'hard-light' + ); + assert.equal(flattenTwo.red, 201); + assert.equal(flattenTwo.green, 209); + assert.equal(flattenTwo.blue, 90); + assert.equal(flattenTwo.alpha, 1); + }); + + it('should flatten colors correctly using blend mode: soft-light', function() { + var flatten = axe.commons.color.flattenColors( + colourTwo, + colourOne, + 'soft-light' + ); + assert.equal(flatten.red, 215); + assert.equal(flatten.green, 22); + assert.equal(flatten.blue, 21); + assert.equal(flatten.alpha, 1); + + var flattenTwo = axe.commons.color.flattenColors( + colourFour, + colourThree, + 'soft-light' + ); + assert.equal(flattenTwo.red, 209); + assert.equal(flattenTwo.green, 183); + assert.equal(flattenTwo.blue, 154); + assert.equal(flattenTwo.alpha, 1); + }); + + it('should flatten colors correctly using blend mode: difference', function() { + var flatten = axe.commons.color.flattenColors( + colourTwo, + colourOne, + 'difference' + ); + assert.equal(flatten.red, 188); + assert.equal(flatten.green, 43); + assert.equal(flatten.blue, 40); + assert.equal(flatten.alpha, 1); + + var flattenTwo = axe.commons.color.flattenColors( + colourFour, + colourThree, + 'difference' + ); + assert.equal(flattenTwo.red, 154); + assert.equal(flattenTwo.green, 128); + assert.equal(flattenTwo.blue, 180); + assert.equal(flattenTwo.alpha, 1); + }); + + it('should flatten colors correctly using blend mode: exclusion', function() { + var flatten = axe.commons.color.flattenColors( + colourTwo, + colourOne, + 'exclusion' + ); + assert.equal(flatten.red, 196); + assert.equal(flatten.green, 49); + assert.equal(flatten.blue, 46); + assert.equal(flatten.alpha, 1); + + var flattenTwo = axe.commons.color.flattenColors( + colourFour, + colourThree, + 'exclusion' + ); + assert.equal(flattenTwo.red, 173); + assert.equal(flattenTwo.green, 128); + assert.equal(flattenTwo.blue, 180); + assert.equal(flattenTwo.alpha, 1); + }); +}); diff --git a/test/integration/full/contrast/blending.html b/test/integration/full/contrast/blending.html index 2fbc1ad89e..1af0520c50 100644 --- a/test/integration/full/contrast/blending.html +++ b/test/integration/full/contrast/blending.html @@ -22,18 +22,20 @@ margin: 4rem 2rem; } - #fixture { + .test-group { + position: relative; + z-index: 1; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } - #fixture > div { + .test-group > div { display: flex; flex-direction: row; border: 1px solid white; } - #fixture * { + .test-group * { width: 100px; height: 100px; flex-shrink: 0; @@ -57,128 +59,131 @@ >