Skip to content

Commit

Permalink
fix(color-contrast): correcly apply opacity to foreground color (#3973)
Browse files Browse the repository at this point in the history
* fix(color-contrast): correcly apply opacity to foreground color

* finalize

* add test

* fix test

* suggestions
  • Loading branch information
straker committed Apr 6, 2023
1 parent 9670df2 commit d7db279
Show file tree
Hide file tree
Showing 4 changed files with 130 additions and 67 deletions.
140 changes: 97 additions & 43 deletions lib/commons/color/get-foreground-color.js
Expand Up @@ -3,7 +3,7 @@ import getBackgroundColor from './get-background-color';
import incompleteData from './incomplete-data';
import flattenColors from './flatten-colors';
import getTextShadowColors from './get-text-shadow-colors';
import { getNodeFromTree } from '../../core/utils';
import { getStackingContext, stackingContextToColor } from './stacking-context';

/**
* Returns the flattened foreground color of an element, or null if it can't be determined because
Expand All @@ -21,42 +21,49 @@ import { getNodeFromTree } from '../../core/utils';
*/
export default function getForegroundColor(node, _, bgColor, options = {}) {
const nodeStyle = window.getComputedStyle(node);
const opacity = getOpacity(node, nodeStyle);

// Start with -webkit-text-stroke, it is rendered on top
const strokeColor = getStrokeColor(nodeStyle, options);
if (strokeColor && strokeColor.alpha * opacity === 1) {
strokeColor.alpha = 1;
return strokeColor;
}
const colorStack = [
// Start with -webkit-text-stroke, it is rendered on top
() => getStrokeColor(nodeStyle, options),
// Next color / -webkit-text-fill-color
() => getTextColor(nodeStyle),
// If text is (semi-)transparent shadows are visible through it
() => getTextShadowColors(node, { minRatio: 0 })
];
let fgColors = [];

// Next color / -webkit-text-fill-color
const textColor = getTextColor(nodeStyle);
let fgColor = strokeColor ? flattenColors(strokeColor, textColor) : textColor;
if (fgColor.alpha * opacity === 1) {
fgColor.alpha = 1;
return fgColor;
}
for (const colorFn of colorStack) {
const color = colorFn();
if (!color) {
continue;
}

fgColors = fgColors.concat(color);

// If text is (semi-)transparent shadows are visible through it.
const textShadowColors = getTextShadowColors(node, { minRatio: 0 });
fgColor = textShadowColors.reduce((colorA, colorB) => {
return flattenColors(colorA, colorB);
}, fgColor);
if (fgColor.alpha * opacity === 1) {
fgColor.alpha = 1;
return fgColor;
if (color.alpha === 1) {
break;
}
}

// Lastly, if text opacity still isn't at 1, blend the background
const fgColor = fgColors.reduce((source, backdrop) => {
return flattenColors(source, backdrop);
});

// Lastly blend the background
bgColor ??= getBackgroundColor(node, []);
if (bgColor === null) {
const reason = incompleteData.get('bgColor');
incompleteData.set('fgColor', reason);
return null;
}
fgColor.alpha = fgColor.alpha * opacity;
return flattenColors(fgColor, bgColor);

const stackingContexts = getStackingContext(node);
const context = findNodeInContexts(stackingContexts, node);
return flattenColors(
calculateBlendedForegroundColor(fgColor, context, stackingContexts),
// default page background
new Color(255, 255, 255, 1)
);
}

function getTextColor(nodeStyle) {
Expand All @@ -83,26 +90,73 @@ function getStrokeColor(nodeStyle, { textStrokeEmMin = 0 }) {
return new Color().parseString(strokeColor);
}

function getOpacity(node, nodeStyle) {
if (!node) {
return 1;
}
/**
* Blend a foreground color into the background stacking context, taking into account opacity at each step.
* @param {Color} fgColor
* @param {Object} context - The nodes stacking context
* @param {Object[]} stackingContexts - Array of all stacking contexts
* @return {Color}
*/
function calculateBlendedForegroundColor(fgColor, context, stackingContexts) {
while (context) {
// find the nearest ancestor that has opacity < 1
if (context.opacity === 1 && context.ancestor) {
context = context.ancestor;
continue;
}

const vNode = getNodeFromTree(node);
if (vNode && vNode._opacity !== undefined && vNode._opacity !== null) {
return vNode._opacity;
}
fgColor.alpha *= context.opacity;

nodeStyle ??= window.getComputedStyle(node);
const opacity = nodeStyle.getPropertyValue('opacity');
const finalOpacity = opacity * getOpacity(node.parentElement);
// when blending the foreground color to a background color with opacity,
// we ignore the background color of the node itself and instead blend
// with the stack behind it
let stack = context.ancestor?.descendants || stackingContexts;
if (context.opacity !== 1) {
stack = stack.slice(0, stack.indexOf(context));
}

// cache the results of the getOpacity check on the parent tree
// so we don't have to look at the parent tree again for all its
// descendants
if (vNode) {
vNode._opacity = finalOpacity;
const bgColors = stack.map(stackingContextToColor);

if (!bgColors.length) {
context = context.ancestor;
continue;
}

const bgColor = bgColors.reduce(
(backdrop, source) => {
return flattenColors(
source.color,
backdrop.color instanceof Color ? backdrop.color : backdrop
);
},
{
color: new Color(0, 0, 0, 0),
blendMode: 'normal'
}
);

fgColor = flattenColors(fgColor, bgColor);
context = context.ancestor;
}

return finalOpacity;
return fgColor;
}

/**
* Find the stacking context that belongs to the passed in node
* @param {Object} contexts - Array of stacking contexts
* @param {Element} node
* @returns {Object}
*/
function findNodeInContexts(contexts, node) {
for (const context of contexts) {
if (context.vNode?.actualNode === node) {
return context;
}

const found = findNodeInContexts(context.descendants, node);
if (found) {
return found;
}
}
}
9 changes: 6 additions & 3 deletions lib/commons/color/stacking-context.js
Expand Up @@ -169,12 +169,14 @@ function reduceToColor(backdropContext, sourceContext) {

/**
* Create a stacking context object for a virtual node.
* @param {VirtualNode} vNod
* @param {VirtualNode} vNode
* @param {Object} ancestorContext
* @return {Object}
*/
function createStackingContext(vNode) {
function createStackingContext(vNode, ancestorContext) {
return {
vNode: vNode,
ancestor: ancestorContext,
opacity: parseFloat(vNode?.getComputedStylePropertyValue('opacity') ?? 1),
bgColor: new Color(0, 0, 0, 0),
blendMode: normalizeBlendMode(
Expand All @@ -201,8 +203,9 @@ function normalizeBlendMode(blendmode) {
* @return {Object}
*/
function addToStackingContext(contextMap, vNode, ancestorVNode) {
const context = contextMap.get(vNode) ?? createStackingContext(vNode);
const ancestorContext = contextMap.get(ancestorVNode);
const context =
contextMap.get(vNode) ?? createStackingContext(vNode, ancestorContext);
if (
ancestorContext &&
ancestorVNode !== vNode &&
Expand Down
38 changes: 17 additions & 21 deletions test/commons/color/get-foreground-color.js
Expand Up @@ -17,7 +17,7 @@ describe('color.getForegroundColor', () => {

it('returns the CSS color property', () => {
const target = queryFixture(
'<div id="target" style="color: rgb(0 0 128)"></div>'
'<div id="target" style="color: rgb(0 0 128)">Hello World</div>'
).actualNode;
const fgColor = getForegroundColor(target);
assertSameColor(fgColor, new Color(0, 0, 128));
Expand All @@ -26,7 +26,7 @@ describe('color.getForegroundColor', () => {
it('returns the CSS color from inside of Shadow DOM', () => {
const target = queryShadowFixture(
'<div id="shadow" style="height: 40px; width: 30px; background-color: red;"></div>',
'<div id="target" style="height:20px; width:15px; color:rgb(0 0 128); background-color:green;"></div>'
'<div id="target" style="height:20px; width:15px; color:rgb(0 0 128); background-color:green;">Hello World</div>'
).actualNode;

const fgColor = getForegroundColor(target);
Expand All @@ -38,28 +38,16 @@ describe('color.getForegroundColor', () => {
'<div style="height: 40px; width: 30px;' +
'background-color: #800000; background-image: url(image.png);">' +
'<div id="target" style="height: 20px; width: 15px; color: blue; background-color: green; opacity: 0.5;">' +
'Hello World' +
'</div></div>'
).actualNode;
assert.isNull(getForegroundColor(target));
assert.equal(axe.commons.color.incompleteData.get('fgColor'), 'bgImage');
});

it('does not recalculate bgColor if passed in', () => {
const target = queryFixture(
'<div style="height: 40px; background-color: #000000;">' +
'<div id="target" style="height: 40px; color: rgba(0, 0, 128, 0.5);">' +
'This is my text' +
'</div></div>'
).actualNode;

const bgColor = new Color(64, 64, 0);
const fgColor = getForegroundColor(target, false, bgColor);
assertSameColor(fgColor, new Color(32, 32, 64), 0.8);
});

it('returns `-webkit-text-fill-color` over `color`', () => {
const target = queryFixture(
'<div id="target" style="-webkit-text-fill-color: rgb(0 0 255); color: rgb(0 0 128)"></div>'
'<div id="target" style="-webkit-text-fill-color: rgb(0 0 255); color: rgb(0 0 128)">Hello World</div>'
).actualNode;
const fgColor = getForegroundColor(target);
assertSameColor(fgColor, new Color(0, 0, 255));
Expand All @@ -68,7 +56,7 @@ describe('color.getForegroundColor', () => {
describe('text-stroke', () => {
it('ignores stroke when equal to 0', () => {
const target = queryFixture(
'<div style="color: rgb(0 0 128); -webkit-text-stroke: 0 #CCC" id="target"></div>'
'<div style="color: rgb(0 0 128); -webkit-text-stroke: 0 #CCC" id="target">Hello World</div>'
).actualNode;
const options = { textStrokeEmMin: 0 };
const fgColor = getForegroundColor(target, null, null, options);
Expand All @@ -77,7 +65,7 @@ describe('color.getForegroundColor', () => {

it('ignores stroke when less then the minimum', () => {
const target = queryFixture(
'<div style="color: rgb(0 0 128); -webkit-text-stroke: 0.1em #CCC" id="target"></div>'
'<div style="color: rgb(0 0 128); -webkit-text-stroke: 0.1em #CCC" id="target">Hello World</div>'
).actualNode;
const options = { textStrokeEmMin: 0.2 };
const fgColor = getForegroundColor(target, null, null, options);
Expand All @@ -86,7 +74,7 @@ describe('color.getForegroundColor', () => {

it('uses stroke color when thickness is equal to the minimum', () => {
const target = queryFixture(
'<div style="color: #CCC; -webkit-text-stroke: 0.2em rgb(0 0 128);" id="target"></div>'
'<div style="color: #CCC; -webkit-text-stroke: 0.2em rgb(0 0 128);" id="target">Hello World</div>'
).actualNode;
const options = { textStrokeEmMin: 0.2 };
const fgColor = getForegroundColor(target, null, null, options);
Expand All @@ -95,7 +83,7 @@ describe('color.getForegroundColor', () => {

it('blends the stroke color with `color`', () => {
const target = queryFixture(
'<div style="color: rgb(0 0 55); -webkit-text-stroke: 0.2em rgb(0 0 255 / 50%);" id="target"></div>'
'<div style="color: rgb(0 0 55); -webkit-text-stroke: 0.2em rgb(0 0 255 / 50%);" id="target">Hello World</div>'
).actualNode;
const options = { textStrokeEmMin: 0.1 };
const fgColor = getForegroundColor(target, null, null, options);
Expand Down Expand Up @@ -125,7 +113,15 @@ describe('color.getForegroundColor', () => {
'</div></div>'
).actualNode;
const fgColor = getForegroundColor(target);
assertSameColor(fgColor, new Color(32, 32, 64));
assertSameColor(fgColor, new Color(64, 0, 64));
});

it('does not apply opacity to node background', () => {
const target = queryFixture(
'<div id="target" style="color: #fff; background-color: #00633D; opacity: 0.65"><span>Hello World</span></div>'
).actualNode;
const fgColor = getForegroundColor(target);
assertSameColor(fgColor, new Color(255, 255, 255));
});

it('combines opacity with text stroke alpha color', () => {
Expand Down
10 changes: 10 additions & 0 deletions test/commons/color/stacking-context.js
Expand Up @@ -31,6 +31,7 @@ describe('color.stackingContext', () => {
assert.deepEqual(stackingContext, [
{
vNode,
ancestor: undefined,
opacity: 1,
bgColor: new Color(0, 0, 0, 0),
blendMode: 'normal',
Expand All @@ -53,20 +54,23 @@ describe('color.stackingContext', () => {
assert.deepEqual(stackingContext, [
{
vNode: querySelectorAll(axe._tree[0], '#elm1')[0],
ancestor: undefined,
opacity: 1,
bgColor: new Color(0, 0, 0, 0),
blendMode: 'normal',
descendants: []
},
{
vNode: querySelectorAll(axe._tree[0], '#elm2')[0],
ancestor: undefined,
opacity: 1,
bgColor: new Color(0, 0, 0, 0),
blendMode: 'normal',
descendants: []
},
{
vNode,
ancestor: undefined,
opacity: 1,
bgColor: new Color(0, 0, 0, 0),
blendMode: 'normal',
Expand All @@ -89,19 +93,22 @@ describe('color.stackingContext', () => {
assert.deepEqual(stackingContext, [
{
vNode: querySelectorAll(axe._tree[0], '#elm1')[0],
ancestor: undefined,
opacity: 1,
bgColor: new Color(0, 0, 0, 0),
blendMode: 'normal',
descendants: [
{
vNode: querySelectorAll(axe._tree[0], '#elm2')[0],
ancestor: stackingContext[0],
opacity: 1,
bgColor: new Color(0, 0, 0, 0),
blendMode: 'normal',
descendants: []
},
{
vNode,
ancestor: stackingContext[0],
opacity: 1,
bgColor: new Color(0, 0, 0, 0),
blendMode: 'normal',
Expand All @@ -122,6 +129,7 @@ describe('color.stackingContext', () => {
assert.deepEqual(stackingContext, [
{
vNode,
ancestor: undefined,
opacity: 0.8,
bgColor: new Color(255, 0, 0, 0.5),
blendMode: 'difference',
Expand All @@ -143,12 +151,14 @@ describe('color.stackingContext', () => {
assert.deepEqual(stackingContext, [
{
vNode: querySelectorAll(axe._tree[0], '#elm1')[0],
ancestor: undefined,
opacity: 0.8,
bgColor: new Color(0, 0, 0, 0),
blendMode: 'normal',
descendants: [
{
vNode,
ancestor: stackingContext[0],
opacity: 1,
bgColor: new Color(0, 0, 0, 0),
blendMode: 'normal',
Expand Down

0 comments on commit d7db279

Please sign in to comment.