Skip to content

Commit

Permalink
fix(color-contrast): account for text-shadow (#2334)
Browse files Browse the repository at this point in the history
* fix(color-contrast): account for text-shadow

* chore: IE text-shadow issues

* chore: more IE fixes

* chore: more IE tests

* chore: fix failing tests

* chore: fix IE stuff

* chore: ci fix

* Update lib/commons/color/get-text-shadow-colors.js

Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com>

* chore: support hsl colors

* Apply suggestions from code review

Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com>

* revert code-highlighting.js

Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com>
  • Loading branch information
WilcoFiers and straker authored Jul 20, 2020
1 parent 2b5507e commit 3eb6d2c
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 10 deletions.
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(
!current.colorStr,
`Multiple colors identified in text-shadow: ${textShadow}`
);
str = str.replace(colorMatch[0], '').trim();
current.colorStr = colorMatch[0];
} else if (pixelMatch) {
assert(
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(
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}`);
}
}

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

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() {
'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

0 comments on commit 3eb6d2c

Please sign in to comment.