Skip to content

Commit

Permalink
fix(color-contrast): allow small text shadows to serve as text outline (
Browse files Browse the repository at this point in the history
#2627)

* fix: allow small text shadows to contrast on either side

* fix(color-contrast): Make shadowOutlineEmMax configurable

* fix: skip text-shadow with null background

* Update doc/check-options.md

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

* chore: update text-shadow.html comment

Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com>
  • Loading branch information
WilcoFiers and straker committed Nov 9, 2020
1 parent 52fb138 commit 432e1f3
Show file tree
Hide file tree
Showing 10 changed files with 984 additions and 21 deletions.
1 change: 1 addition & 0 deletions doc/check-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ All checks allow these global options:
| `boldValue` | `700` | The minimum CSS `font-weight` value that designates bold text |
| `boldTextPt` | `14` | The minimum CSS `font-size` pt value that designates bold text as being large |
| `largeTextPt` | `18` | The minimum CSS `font-size` pt value that designates text as being large |
| `shadowOutlineEmMax` | `0.1` | The maximum `blur-radius` value (in ems) of the CSS `text-shadow` property. `blur-radius` values greater than this value will be treated as a background color rather than an outline color. |
| `contrastRatio` | N/A | Contrast ratio options |
| &nbsp;&nbsp;`contrastRatio.normal` | N/A | Contrast ratio requirements for normal text (non-bold text or text smaller than `largeTextPt`) |
| &nbsp;&nbsp;&nbsp;&nbsp;`contrastRatio.normal.expected` | `4.5` | The expected contrast ratio for normal text |
Expand Down
26 changes: 22 additions & 4 deletions lib/checks/color/color-contrast-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
getForegroundColor,
incompleteData,
getContrast,
getOwnBackgroundColor
getOwnBackgroundColor,
getTextShadowColors,
flattenColors
} from '../../commons/color';
import { memoize } from '../../core/utils';

Expand Down Expand Up @@ -41,7 +43,8 @@ function colorContrastEvaluate(node, options, virtualNode) {
boldValue,
boldTextPt,
largeTextPt,
contrastRatio
contrastRatio,
shadowOutlineEmMax
} = options;

const visibleText = visibleVirtual(virtualNode, false, true);
Expand All @@ -61,15 +64,30 @@ function colorContrastEvaluate(node, options, virtualNode) {
}

const bgNodes = [];
const bgColor = getBackgroundColor(node, bgNodes);
const bgColor = getBackgroundColor(node, bgNodes, shadowOutlineEmMax);
const fgColor = getForegroundColor(node, false, bgColor);
// Thin shadows only. Thicker shadows are included in the background instead
const shadowColors = getTextShadowColors(node, {
maxRatio: shadowOutlineEmMax
});

const nodeStyle = window.getComputedStyle(node);
const fontSize = parseFloat(nodeStyle.getPropertyValue('font-size'));
const fontWeight = nodeStyle.getPropertyValue('font-weight');
const bold = parseFloat(fontWeight) >= boldValue || fontWeight === 'bold';

const contrast = getContrast(bgColor, fgColor);
let contrast = null;
if (shadowColors.length === 0) {
contrast = getContrast(bgColor, fgColor);
} else if (fgColor && bgColor) {
// Thin shadows can pass either by contrasting with the text color
// or when contrasting with the background.
const shadowColor = [...shadowColors, bgColor].reduce(flattenColors);
const bgContrast = getContrast(bgColor, shadowColor);
const fgContrast = getContrast(shadowColor, fgColor);
contrast = Math.max(bgContrast, fgContrast);
}

const ptSize = Math.ceil(fontSize * 72) / 96;
const isSmallFont =
(bold && ptSize < boldTextPt) || (!bold && ptSize < largeTextPt);
Expand Down
3 changes: 2 additions & 1 deletion lib/checks/color/color-contrast.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"large": {
"expected": 3
}
}
},
"shadowOutlineEmMax": 0.1
},
"metadata": {
"impact": "serious",
Expand Down
7 changes: 4 additions & 3 deletions lib/commons/color/get-background-color.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ function elmPartiallyObscured(elm, bgElm, bgColor) {
* @method getBackgroundColor
* @memberof axe.commons.color
* @param {Element} elm Element to determine background color
* @param {Array} [bgElms=[]] elements to inspect
* @param {Array} [bgElms=[]] elements to inspect
* @param {Number} shadowOutlineEmMax Thickness of `text-shadow` at which it becomes a background color
* @returns {Color}
*/
function getBackgroundColor(elm, bgElms = []) {
let bgColors = getTextShadowColors(elm);
function getBackgroundColor(elm, bgElms = [], shadowOutlineEmMax = 0.1) {
let bgColors = getTextShadowColors(elm, { minRatio: shadowOutlineEmMax });
let elmStack = getBackgroundStack(elm);

// Search the stack until we have an alpha === 1 background
Expand Down
45 changes: 34 additions & 11 deletions lib/commons/color/get-text-shadow-colors.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,45 @@ 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
* @param {Object} options (optional)
* @property {Bool} minRatio Ignore shadows smaller than this, ratio shadow size divided by font size
* @property {Bool} maxRatio Ignore shadows equal or larter than this, ratio shadow size divided by font size
*/
function getTextShadowColors(node) {
function getTextShadowColors(node, { minRatio, maxRatio } = {}) {
const style = window.getComputedStyle(node);
const textShadow = style.getPropertyValue('text-shadow');
if (textShadow === 'none') {
return [];
}

const fontSizeStr = style.getPropertyValue('font-size');
const fontSize = parseInt(fontSizeStr);
assert(
isNaN(fontSize) === false,
`Unable to determine font-size value ${fontSizeStr}`
);

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

return textShadowColor({ colorStr, offsetY, offsetX, blurRadius });
if (
(!minRatio || blurRadius >= fontSize * minRatio) &&
(!maxRatio || blurRadius < fontSize * maxRatio)
) {
const color = textShadowColor({
colorStr,
offsetY,
offsetX,
blurRadius,
fontSize
});
shadowColors.push(color);
}
});
return shadowColors;
}

/**
Expand Down Expand Up @@ -59,8 +81,8 @@ function parseTextShadows(textShadow) {
(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;`
} 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}`
Expand All @@ -76,23 +98,24 @@ function parseTextShadows(textShadow) {
return shadows;
}

function textShadowColor({ colorStr, offsetX, offsetY, blurRadius }) {
function textShadowColor({ colorStr, offsetX, offsetY, blurRadius, fontSize }) {
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);
shadowColor.alpha *= blurRadiusToAlpha(blurRadius, fontSize);

return shadowColor;
}

function blurRadiusToAlpha(blurRadius) {
function blurRadiusToAlpha(blurRadius, fontSize) {
// This formula is an estimate based on various tests.
// Different people test this differently, so opinions may vary.
return 3.7 / (blurRadius + 8);
const relativeBlur = blurRadius / fontSize;
return 0.185 / (relativeBlur + 0.4);
}

export default getTextShadowColors;
32 changes: 32 additions & 0 deletions test/checks/color/color-contrast.js
Original file line number Diff line number Diff line change
Expand Up @@ -685,4 +685,36 @@ describe('color-contrast', function() {
assert.deepEqual(checkContext._relatedNodes, [container]);
}
);

it('passes if thin text shadows have sufficient contrast with the text', function() {
var params = checkSetup(
'<div id="target" style="background-color: #666; color:#aaa; ' +
'text-shadow: 0 0 0.09em #000, 0 0 0.09em #000, 0 0 0.09em #000;">' +
' Hello world' +
'</div>'
);
assert.isTrue(contrastEvaluate.apply(checkContext, params));
});

it('passes if thin text shadows have sufficient contrast with the background', function() {
var params = checkSetup(
'<div id="target" style="background-color: #aaa; color:#666; ' +
'text-shadow: 0 0 0.09em #000, 0 0 0.09em #000, 0 0 0.09em #000;">' +
' Hello world' +
'</div>'
);
assert.isTrue(contrastEvaluate.apply(checkContext, params));
});

it('fails if text shadows have sufficient contrast with the background if its with is thicker than `shadowOutlineEmMax`', function() {
var checkOptions = { shadowOutlineEmMax: 0.05 };
var params = checkSetup(
'<div id="target" style="background-color: #aaa; color:#666; ' +
'text-shadow: 0 0 0.09em #000, 0 0 0.09em #000, 0 0 0.09em #000;">' +
' Hello world' +
'</div>',
checkOptions
);
assert.isFalse(contrastEvaluate.apply(checkContext, params));
});
});
39 changes: 37 additions & 2 deletions test/commons/color/get-background-color.js
Original file line number Diff line number Diff line change
Expand Up @@ -978,19 +978,54 @@ describe('color.getBackgroundColor', function() {
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 id="target" style="height: 20px; width: 15px; text-shadow: red 0 0 1em">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);
var expected = new axe.commons.color.Color(145, 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]);
});

it('ignores thin text-shadows', function() {
fixture.innerHTML =
'<div id="parent" style="height: 40px; width: 30px; background-color: #000;">' +
'<div id="target" style="height: 20px; width: 15px; text-shadow: red 0 0 0.05em">foo' +
'</div></div>';
var target = fixture.querySelector('#target');
var bgNodes = [];
axe.testUtils.flatTreeSetup(fixture);
var actual = axe.commons.color.getBackgroundColor(target, bgNodes);

assert.equal(actual.red, 0);
assert.equal(actual.green, 0);
assert.equal(actual.blue, 0);
assert.equal(actual.alpha, 1);
});

it('ignores text-shadows thinner than shadowOutlineEmMax', function() {
fixture.innerHTML =
'<div style="height: 40px; width: 30px; background-color: #800000;">' +
'<div id="target" style="height: 20px; width: 15px; text-shadow: red 0 0 1em, green 0 0 0.5em">foo' +
'</div></div>';
var target = fixture.querySelector('#target');
var bgNodes = [];
axe.testUtils.flatTreeSetup(fixture);
var actual = axe.commons.color.getBackgroundColor(target, bgNodes, 1);

// is 128 without the shadow
var expected = new axe.commons.color.Color(145, 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);
});
});
27 changes: 27 additions & 0 deletions test/commons/color/get-text-shadow-colors.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,31 @@ describe('axe.commons.color.getTextShadowColors', function() {
assert.equal(shadowColors[0].green, 0);
assert.equal(shadowColors[0].blue, 0);
});

it('does not return shadows with a ratio less than minRatio', function() {
fixture.innerHTML =
'<span style="text-shadow: ' +
'0 0 1em #F00, 0 0 0.5em #0F0, 1px 1px 0.2em #00F;' +
'">Hello world</span>';

var span = fixture.querySelector('span');
var shadowColors = getTextShadowColors(span, { minRatio: 0.5 });

assert.lengthOf(shadowColors, 2);
assert.equal(shadowColors[0].red, 255);
assert.equal(shadowColors[1].green, 255);
});

it('does not return shadows with a ratio less than maxRatio', function() {
fixture.innerHTML =
'<span style="text-shadow: ' +
'0 0 1em #F00, 0 0 0.5em #0F0, 1px 1px 0.2em #00F;' +
'">Hello world</span>';

var span = fixture.querySelector('span');
var shadowColors = getTextShadowColors(span, { maxRatio: 0.5 });

assert.lengthOf(shadowColors, 1);
assert.equal(shadowColors[0].blue, 255);
});
});
Loading

0 comments on commit 432e1f3

Please sign in to comment.