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): allow small text shadows to serve as text outline #2627

Merged
merged 5 commits into from
Nov 9, 2020
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
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 |
|   `contrastRatio.normal` | N/A | Contrast ratio requirements for normal text (non-bold text or text smaller than `largeTextPt`) |
|     `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);
Comment on lines -95 to +118
Copy link
Contributor Author

@WilcoFiers WilcoFiers Nov 9, 2020

Choose a reason for hiding this comment

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

In working through this I realised these numbers should be relative to the font-size. I used a 20px font when I got these numbers, so I divided the numbers by 20. Block 9 in the text-shadow integration tests handle this.

}

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