Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(color-contrast): correcly apply opacity to foreground color #3973

Merged
merged 5 commits into from
Apr 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 97 additions & 43 deletions lib/commons/color/get-foreground-color.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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