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): account for text-shadow #2334

Merged
merged 13 commits into from
Jul 20, 2020
2 changes: 1 addition & 1 deletion lib/checks/color/color-contrast-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ function colorContrastEvaluate(node, options, virtualNode) {

// ratio is outside range
if (
(typeof minThreshold === 'number' && contrast < minThreshold) ||
(typeof minThreshold === 'number' && contrast < minThreshold) ||
(typeof maxThreshold === 'number' && contrast > maxThreshold)
) {
return true;
Expand Down
15 changes: 8 additions & 7 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 getTextShadowColors from './get-text-shadow-colors';
import visuallyContains from '../dom/visually-contains';

/**
Expand Down Expand Up @@ -35,7 +36,7 @@ function elmPartiallyObscured(elm, bgElm, bgColor) {
* @returns {Color}
*/
function getBackgroundColor(elm, bgElms = []) {
let bgColors = [];
let bgColors = getTextShadowColors(elm);
let elmStack = getBackgroundStack(elm);

// Search the stack until we have an alpha === 1 background
Expand Down Expand Up @@ -69,14 +70,14 @@ function getBackgroundColor(elm, bgElms = []) {
}
});

if (bgColors !== null && elmStack !== null) {
// Mix the colors together, on top of a default white
bgColors.push(new Color(255, 255, 255, 1));
var colors = bgColors.reduce(flattenColors);
return colors;
if (bgColors === null || elmStack === null) {
return null;
}

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);
return colors;
}

export default getBackgroundColor;
98 changes: 98 additions & 0 deletions lib/commons/color/get-text-shadow-colors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import Color from './color';
import assert from '../../core/utils/assert';

/**
* Get text-shadow colors that can impact the color contrast of the text
* @param {Element} node DOM Element
* @param {Array} [bgElms=[]] Colors used in text-shadow
*/
function getTextShadowColors(node) {
const style = window.getComputedStyle(node);
const textShadow = style.getPropertyValue('text-shadow');
if (textShadow === 'none') {
return [];
}

const shadows = parseTextShadows(textShadow);
return shadows.map(({ colorStr, pixels }) => {
// Defautls only necessary for IE
colorStr = colorStr || style.getPropertyValue('color');
const [offsetY, offsetX, blurRadius = 0] = pixels;

return textShadowColor({ colorStr, offsetY, offsetX, blurRadius });
});
}

/**
* Parse text-shadow property value. Required for IE, which can return the color
* either at the start or the end, and either in rgb(a) or as a named color
*/
function parseTextShadows(textShadow) {
let current = { pixels: [] };
let str = textShadow.trim();
const shadows = [current];
if (!str) {
return [];
}

while (str) {
let colorMatch =
str.match(/^rgba?\([0-9,.\s]+\)/i) ||
str.match(/^[a-z]+/i) ||
str.match(/^#[0-9a-f]+/i);
let pixelMatch = str.match(/^([0-9.-]+)px/i) || str.match(/^(0)/);

if (colorMatch) {
assert(
WilcoFiers marked this conversation as resolved.
Show resolved Hide resolved
!current.colorStr,
`Multiple colors identified in text-shadow: ${textShadow}`
);
str = str.replace(colorMatch[0], '').trim();
current.colorStr = colorMatch[0];
} else if (pixelMatch) {
assert(
WilcoFiers marked this conversation as resolved.
Show resolved Hide resolved
current.pixels.length < 3,
`Too many pixel units in text-shadow: ${textShadow}`
);
str = str.replace(pixelMatch[0], '').trim();
const pixelUnit = parseFloat(
(pixelMatch[1][0] === '.' ? '0' : '') + pixelMatch[1]
);
current.pixels.push(pixelUnit);
} else if (str[0] === ',') {
// multiple text-shadows in a single string (e.g. `text-shadow: 1px 1px 1px #000, 3px 3px 5px blue;`
assert(
WilcoFiers marked this conversation as resolved.
Show resolved Hide resolved
current.pixels.length >= 2,
`Missing pixel value in text-shadow: ${textShadow}`
);
current = { pixels: [] };
shadows.push(current);
str = str.substr(1).trim();
} else {
throw new Error(`Unable to process text-shadows: ${textShadow}`);
straker marked this conversation as resolved.
Show resolved Hide resolved
}
}

return shadows;
}

function textShadowColor({ colorStr, offsetX, offsetY, blurRadius }) {
if (offsetX > blurRadius || offsetY > blurRadius) {
// Shadow is too far removed from the text to impact contrast
return new Color(0, 0, 0, 0);
}

const shadowColor = new Color();
shadowColor.parseString(colorStr);
shadowColor.alpha *= blurRadiusToAlpha(blurRadius);

return shadowColor;
}

function blurRadiusToAlpha(blurRadius) {
// This formula is an estimate based on various tests.
// Different people test this differently, so opinions may vary.
return 3.7 / (blurRadius + 8);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you leave a comment in the code explaining the formula and magic numbers? If we needed to adjust this formula based on user feedback, what would we need to adjust to get the desired results?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this resolved? I wouldn't be able to change this formula if we had to adjust it based on user feedback. Where does 3.7 and blurRadius + 8 come from? I know it was based on testing, if they are truly magic then maybe include the results of those tests so we can better gauge how to adjust this in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Afraid I don't have the data anymore, sorry. These estimates are pretty rough.

}

export default getTextShadowColors;
1 change: 1 addition & 0 deletions lib/commons/color/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export { default as getOwnBackgroundColor } from './get-own-background-color';
export { default as getRectStack } from './get-rect-stack';
export { default as hasValidContrastRatio } from './has-valid-contrast-ratio';
export { default as incompleteData } from './incomplete-data';
export { default as getTextShadowColors } from './get-text-shadow-colors';
19 changes: 19 additions & 0 deletions test/commons/color/get-background-color.js
Original file line number Diff line number Diff line change
Expand Up @@ -974,4 +974,23 @@ describe('color.getBackgroundColor', function() {
assert.equal(actual.alpha, 1);
}
);

it('should return the text-shadow mixed in with the background', function() {
fixture.innerHTML =
'<div id="parent" style="height: 40px; width: 30px; background-color: #800000;">' +
'<div id="target" style="height: 20px; width: 15px; text-shadow: red 0 0 2px">foo' +
'</div></div>';
var target = fixture.querySelector('#target');
var parent = fixture.querySelector('#parent');
var bgNodes = [];
axe.testUtils.flatTreeSetup(fixture);
var actual = axe.commons.color.getBackgroundColor(target, bgNodes);
// is 128 without the shadow
var expected = new axe.commons.color.Color(175, 0, 0, 1);
assert.closeTo(actual.red, expected.red, 0.5);
assert.closeTo(actual.green, expected.green, 0.5);
assert.closeTo(actual.blue, expected.blue, 0.5);
assert.closeTo(actual.alpha, expected.alpha, 0.1);
assert.deepEqual(bgNodes, [parent]);
});
});
128 changes: 128 additions & 0 deletions test/commons/color/get-text-shadow-colors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
describe('axe.commons.color.getTextShadowColors', function() {
WilcoFiers marked this conversation as resolved.
Show resolved Hide resolved
'use strict';

var fixture = document.getElementById('fixture');
var getTextShadowColors = axe.commons.color.getTextShadowColors;

afterEach(function() {
fixture.innerHTML = '';
});

it('returns an empty array when there is no text-shadow', function() {
fixture.innerHTML = '<span>Hello world</span>';
var span = fixture.querySelector('span');
var shadowColors = getTextShadowColors(span);
assert.lengthOf(shadowColors, 0);
});

it('returns a rgb values of each text-shadow color', function() {
fixture.innerHTML =
'<span style="text-shadow: ' +
'1px 1px 2px #F00, rgb(0, 0, 255) 0 0 1em, \n0\t 0 0.2em green;' +
'">Hello world</span>';

var span = fixture.querySelector('span');
var shadowColors = getTextShadowColors(span);

assert.lengthOf(shadowColors, 3);
assert.equal(shadowColors[0].red, 255);
assert.equal(shadowColors[0].green, 0);
assert.equal(shadowColors[0].blue, 0);

assert.equal(shadowColors[1].red, 0);
assert.equal(shadowColors[1].blue, 255);
assert.equal(shadowColors[1].green, 0);

assert.equal(shadowColors[2].red, 0);
assert.equal(shadowColors[2].green, 128);
assert.equal(shadowColors[2].blue, 0);
});

it('returns transparent if the blur radius is greater than the offset', function() {
fixture.innerHTML =
'<span style="text-shadow: ' +
'1px 3px 2px red, blue 10px 0 9px, 20px 20px 18px green;' +
'">Hello world</span>';
var span = fixture.querySelector('span');
var shadowColors = getTextShadowColors(span);

assert.lengthOf(shadowColors, 3);
assert.equal(shadowColors[0].alpha, 0);
assert.equal(shadowColors[1].alpha, 0);
assert.equal(shadowColors[2].alpha, 0);
});

it('returns an estimated alpha value based on blur radius', function() {
fixture.innerHTML =
'<span style="text-shadow: ' +
'1px 1px 2px red, blue 0 0 10px, \n0\t 0 18px green;' +
'">Hello world</span>';

var span = fixture.querySelector('span');
var shadowColors = getTextShadowColors(span);
var expected0 = 3.7 / (2 + 8);
var expected1 = 3.7 / (10 + 8);
var expected2 = 3.7 / (18 + 8);

assert.lengthOf(shadowColors, 3);
assert.closeTo(shadowColors[0].alpha, expected0, 0.05);
assert.closeTo(shadowColors[1].alpha, expected1, 0.05);
assert.closeTo(shadowColors[2].alpha, expected2, 0.05);
});

it('handles floating point values', function() {
fixture.innerHTML =
'<span style="text-shadow: ' +
'0 0.1px .2px red' +
'">Hello world</span>';

var span = fixture.querySelector('span');
var shadowColors = getTextShadowColors(span);
var expectedAlpha = 3.7 / (0.12 + 8);

assert.lengthOf(shadowColors, 1);
assert.closeTo(shadowColors[0].alpha, expectedAlpha, 0.01);
});

it('combines the blur radius alpha with the alpha of the text-shadow color', function() {
fixture.innerHTML =
'<span style="text-shadow: ' +
'rgba(255, 0, 0, 0) 0 0 2px, rgba(255,0,0,0.5) 0 0 2px, rgba(255,0,0,0.8) 0 0 2px' +
'">Hello world</span>';

var span = fixture.querySelector('span');
var shadowColors = getTextShadowColors(span);
var expected1 = (3.7 / (2 + 8)) * 0.5;
var expected2 = (3.7 / (2 + 8)) * 0.8;

assert.lengthOf(shadowColors, 3);
assert.closeTo(shadowColors[0].alpha, 0, 0.05);
assert.closeTo(shadowColors[1].alpha, expected1, 0.05);
assert.closeTo(shadowColors[2].alpha, expected2, 0.05);
});

it('treats the blur radius as 0 when left undefined', function() {
fixture.innerHTML =
'<span style="text-shadow: ' + '1px 2px red' + '">Hello world</span>';

var span = fixture.querySelector('span');
var shadowColors = getTextShadowColors(span);

assert.lengthOf(shadowColors, 1);
assert.equal(shadowColors[0].alpha, 0);
});

it('uses text color if text-shadow color is ommitted', function() {
fixture.innerHTML =
'<span style="color: red;' +
'text-shadow: 1px 1px 1px;' +
'">Hello world</span>';
var span = fixture.querySelector('span');
var shadowColors = getTextShadowColors(span);

assert.lengthOf(shadowColors, 1);
assert.equal(shadowColors[0].red, 255);
assert.equal(shadowColors[0].green, 0);
assert.equal(shadowColors[0].blue, 0);
});
});
14 changes: 14 additions & 0 deletions test/integration/rules/color-contrast/color-contrast.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@
</label>
</div>

<div
id="pass8"
style="color: #000; background: #737373; text-shadow: white 0 0 3px"
>
Hello world
</div>

<div id="fail2" style="background-color: gray; color: white; font-size: 8px;">
This is a fail.
</div>
Expand All @@ -47,6 +54,13 @@
</div>
</div>

<div
id="fail7"
style="color: #000; background: #777; text-shadow: black 0 0 3px"
>
Hello world
</div>

<!-- shouldnt run -->
<input
id="ignore0"
Expand Down
5 changes: 3 additions & 2 deletions test/integration/rules/color-contrast/color-contrast.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
{
"description": "color-contrast test",
"rule": "color-contrast",
"violations": [["#fail1"], ["#fail2"], ["#fail3"], ["#fail6"]],
"violations": [["#fail1"], ["#fail2"], ["#fail3"], ["#fail6"], ["#fail7"]],
"passes": [
["#pass1"],
["#pass2"],
["#pass3"],
["#pass4"],
["#pass5"],
["#pass7"],
["#pass7 > input"]
["#pass7 > input"],
["#pass8"]
],
"incomplete": [
["#canttell1"],
Expand Down