Skip to content

Commit

Permalink
feat(color-contrast): add support for CSS mix-blend-mode (#3226)
Browse files Browse the repository at this point in the history
* add mix-blend-mode functions

* implement some blend functions and tests

* fix color-dodge and add test

* fix color-burn and add test

* clean up

* fix hard-light add unit test

* add overlay blend-mode and unit test

* tests

* typo

* fix tests

* fix flattenColor function

* better output for blending integration test

* better output for blending integration test

* position each group so they don't affect one another

* fix blending background white

* revert playground

* revert playground

* fix backgroundColor

* fix blending integration tests

* add skip for IE11 as blending is not supported

* typo

Co-authored-by: Steven Lambert <steven.lambert@deque.com>
  • Loading branch information
Zidious and straker authored Dec 9, 2021
1 parent 182c551 commit d497f40
Show file tree
Hide file tree
Showing 5 changed files with 638 additions and 134 deletions.
85 changes: 75 additions & 10 deletions lib/commons/color/flatten-colors.js
Original file line number Diff line number Diff line change
@@ -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;
}
};

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

Expand Down Expand Up @@ -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;
50 changes: 39 additions & 11 deletions lib/commons/color/get-background-color.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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)
);
}

/**
Expand All @@ -99,6 +118,9 @@ function elmPartiallyObscured(elm, bgElm, bgColor) {
return obscured;
}

function normalizeBlendMode(blendmode) {
return !!blendmode ? blendmode : undefined;
}
/**
* Get the page background color.
* @private
Expand Down Expand Up @@ -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;
}
Loading

0 comments on commit d497f40

Please sign in to comment.