Skip to content

Commit

Permalink
fix(color-contrast): consider -webkit-text-stroke & -webkit-text-fill…
Browse files Browse the repository at this point in the history
…-color (#3791)

* fix(color-contrast): use text-stroke colors

* Use textStrokeEmMin option in contrast checks

* address comment

* Editorial

* Update test/commons/color/get-foreground-color.js

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

Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com>
  • Loading branch information
WilcoFiers and straker authored Nov 28, 2022
1 parent 5865462 commit 228daf1
Show file tree
Hide file tree
Showing 14 changed files with 367 additions and 235 deletions.
1 change: 1 addition & 0 deletions doc/check-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ All checks allow these global options:
| `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. |
| `textStrokeEmMin` | `0.03` | The minimum EM width of `-webkit-text-stroke` before axe uses the text stroke color over the actual text color. |
| `pseudoSizeThreshold` | `0.25` | Minimum area of the pseudo element, relative to the text element, below which it will be ignored for colot contrast. |
| `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`) |
Expand Down
3 changes: 2 additions & 1 deletion lib/checks/color/color-contrast-enhanced.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
}
},
"pseudoSizeThreshold": 0.25,
"shadowOutlineEmMax": 0.1
"shadowOutlineEmMax": 0.1,
"textStrokeEmMin": 0.03
},
"metadata": {
"impact": "serious",
Expand Down
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 @@ -73,7 +73,7 @@ export default function colorContrastEvaluate(node, options, virtualNode) {

const bgNodes = [];
const bgColor = getBackgroundColor(node, bgNodes, shadowOutlineEmMax);
const fgColor = getForegroundColor(node, false, bgColor);
const fgColor = getForegroundColor(node, false, bgColor, options);
// Thin shadows only. Thicker shadows are included in the background instead
const shadowColors = getTextShadowColors(node, {
minRatio: 0.001,
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 @@ -17,7 +17,8 @@
}
},
"pseudoSizeThreshold": 0.25,
"shadowOutlineEmMax": 0.2
"shadowOutlineEmMax": 0.2,
"textStrokeEmMin": 0.03
},
"metadata": {
"impact": "serious",
Expand Down
13 changes: 9 additions & 4 deletions lib/commons/color/color.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ function hslToRgb([hue, saturation, lightness, alpha]) {
* @param {number} blue
* @param {number} alpha
*/
function Color(red, green, blue, alpha) {
function Color(red, green, blue, alpha = 1) {
/** @type {number} */
this.red = red;

Expand Down Expand Up @@ -104,6 +104,11 @@ function Color(red, green, blue, alpha) {
);
};

this.toJSON = function toJSON() {
const { red, green, blue, alpha } = this;
return { red, green, blue, alpha };
};

const hexRegex = /^#[0-9a-f]{3,8}$/i;
const colorFnRegex = /^((?:rgb|hsl)a?)\s*\(([^\)]*)\)/i;

Expand All @@ -121,17 +126,17 @@ function Color(red, green, blue, alpha) {
this.green = green;
this.blue = blue;
this.alpha = colorString === 'transparent' ? 0 : 1;
return;
return this;
}

if (colorString.match(colorFnRegex)) {
this.parseColorFnString(colorString);
return;
return this;
}

if (colorString.match(hexRegex)) {
this.parseHexString(colorString);
return;
return this;
}
throw new Error(`Unable to parse color "${colorString}"`);
};
Expand Down
3 changes: 3 additions & 0 deletions lib/commons/color/flatten-colors.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ function flattenColors(fgColor, bgColor, blendMode = 'normal') {
// formula: αo = αs + αb x (1 - αs)
// clamp alpha between 0 and 1
const αo = clamp(fgColor.alpha + bgColor.alpha * (1 - fgColor.alpha), 0, 1);
if (αo === 0) {
return new Color(r, g, b, αo);
}

// simple alpha compositing gives premultiplied values, but our Color
// constructor takes unpremultiplied values. So we need to divide the
Expand Down
110 changes: 71 additions & 39 deletions lib/commons/color/get-foreground-color.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,9 @@ import Color from './color';
import getBackgroundColor from './get-background-color';
import incompleteData from './incomplete-data';
import flattenColors from './flatten-colors';
import flattenShadowColors from './flatten-shadow-colors';
import getTextShadowColors from './get-text-shadow-colors';
import { getNodeFromTree } from '../../core/utils';

function getOpacity(node) {
if (!node) {
return 1;
}

const vNode = getNodeFromTree(node);

if (vNode && vNode._opacity !== undefined && vNode._opacity !== null) {
return vNode._opacity;
}

const nodeStyle = window.getComputedStyle(node);
const opacity = nodeStyle.getPropertyValue('opacity');
const finalOpacity = opacity * getOpacity(node.parentElement);

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

return finalOpacity;
}

/**
* Returns the flattened foreground color of an element, or null if it can't be determined because
* of transparency
Expand All @@ -40,37 +14,95 @@ function getOpacity(node) {
* @param {Element} node
* @param {Boolean} noScroll (default false)
* @param {Color} bgColor
* @param {Object} Options
* @return {Color|null}
*
* @deprecated noScroll parameter
*/
function getForegroundColor(node, _, bgColor) {
export default function getForegroundColor(node, _, bgColor, options = {}) {
const nodeStyle = window.getComputedStyle(node);
const opacity = getOpacity(node, nodeStyle);

const fgColor = new Color();
fgColor.parseString(nodeStyle.getPropertyValue('color'));
const opacity = getOpacity(node);
fgColor.alpha = fgColor.alpha * opacity;
if (fgColor.alpha === 1) {
// 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;
}

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

if (!bgColor) {
bgColor = getBackgroundColor(node, []);
// 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;
}

// Lastly, if text opacity still isn't at 1, 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);
}

function getTextColor(nodeStyle) {
return new Color().parseString(
nodeStyle.getPropertyValue('-webkit-text-fill-color') ||
nodeStyle.getPropertyValue('color')
);
}

if (fgColor.alpha < 1) {
const textShadowColors = getTextShadowColors(node, { minRatio: 0 });
return [fgColor, ...textShadowColors, bgColor].reduce(flattenShadowColors);
function getStrokeColor(nodeStyle, { textStrokeEmMin = 0 }) {
const strokeWidth = parseFloat(
nodeStyle.getPropertyValue('-webkit-text-stroke-width')
);
if (strokeWidth === 0) {
return null;
}
const fontSize = nodeStyle.getPropertyValue('font-size');
const relativeStrokeWidth = strokeWidth / parseFloat(fontSize);
if (isNaN(relativeStrokeWidth) || relativeStrokeWidth < textStrokeEmMin) {
return null;
}

return flattenColors(fgColor, bgColor);
const strokeColor = nodeStyle.getPropertyValue('-webkit-text-stroke-color');
return new Color().parseString(strokeColor);
}

export default getForegroundColor;
function getOpacity(node, nodeStyle) {
if (!node) {
return 1;
}

const vNode = getNodeFromTree(node);
if (vNode && vNode._opacity !== undefined && vNode._opacity !== null) {
return vNode._opacity;
}

nodeStyle ??= window.getComputedStyle(node);
const opacity = nodeStyle.getPropertyValue('opacity');
const finalOpacity = opacity * getOpacity(node.parentElement);

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

return finalOpacity;
}
18 changes: 18 additions & 0 deletions test/commons/color/color.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@ describe('color.Color', function () {
'use strict';
var Color = axe.commons.color.Color;

it('can be constructed without alpha', () => {
const c1 = new Color(4, 3, 2);
assert.equal(c1.red, 4);
assert.equal(c1.green, 3);
assert.equal(c1.blue, 2);
assert.equal(c1.alpha, 1);
});

it('has a toJSON method', () => {
const c1 = new Color(255, 128, 0);
assert.deepEqual(c1.toJSON(), {
red: 255,
green: 128,
blue: 0,
alpha: 1
});
});

describe('parseColorFnString', function () {
it('should set values properly via RGB', function () {
var c = new Color();
Expand Down
50 changes: 31 additions & 19 deletions test/commons/color/flatten-colors.js
Original file line number Diff line number Diff line change
@@ -1,51 +1,63 @@
describe('color.flattenColors', function () {
'use strict';
const { Color, flattenColors } = axe.commons.color;

it('should flatten colors properly', function () {
var halfblack = new axe.commons.color.Color(0, 0, 0, 0.5);
var fullblack = new axe.commons.color.Color(0, 0, 0, 1);
var transparent = new axe.commons.color.Color(0, 0, 0, 0);
var white = new axe.commons.color.Color(255, 255, 255, 1);
var gray = new axe.commons.color.Color(128, 128, 128, 1);
var halfRed = new axe.commons.color.Color(255, 0, 0, 0.5);
var quarterLightGreen = new axe.commons.color.Color(0, 128, 0, 0.25);

var flat = axe.commons.color.flattenColors(halfblack, white);
const halfBlack = new Color(0, 0, 0, 0.5);
const fullBlack = new Color(0, 0, 0, 1);
const transparent = new Color(0, 0, 0, 0);
const white = new Color(255, 255, 255, 1);
const gray = new Color(128, 128, 128, 1);
const halfRed = new Color(255, 0, 0, 0.5);
const quarterLightGreen = new Color(0, 128, 0, 0.25);

const flat = flattenColors(halfBlack, white);
assert.equal(flat.red, gray.red);
assert.equal(flat.green, gray.green);
assert.equal(flat.blue, gray.blue);

var flat2 = axe.commons.color.flattenColors(fullblack, white);
assert.equal(flat2.red, fullblack.red);
assert.equal(flat2.green, fullblack.green);
assert.equal(flat2.blue, fullblack.blue);
const flat2 = flattenColors(fullBlack, white);
assert.equal(flat2.red, fullBlack.red);
assert.equal(flat2.green, fullBlack.green);
assert.equal(flat2.blue, fullBlack.blue);

var flat3 = axe.commons.color.flattenColors(transparent, white);
const flat3 = flattenColors(transparent, white);
assert.equal(flat3.red, white.red);
assert.equal(flat3.green, white.green);
assert.equal(flat3.blue, white.blue);

var flat4 = axe.commons.color.flattenColors(halfRed, white);
const flat4 = flattenColors(halfRed, white);
assert.equal(flat4.red, 255);
assert.equal(flat4.green, 128);
assert.equal(flat4.blue, 128);
assert.equal(flat4.alpha, 1);

var flat5 = axe.commons.color.flattenColors(quarterLightGreen, white);
const flat5 = flattenColors(quarterLightGreen, white);
assert.equal(flat5.red, 191);
assert.equal(flat5.green, 223);
assert.equal(flat5.blue, 191);
assert.equal(flat5.alpha, 1);

var flat6 = axe.commons.color.flattenColors(quarterLightGreen, halfRed);
const flat6 = flattenColors(quarterLightGreen, halfRed);
assert.equal(flat6.red, 153);
assert.equal(flat6.green, 51);
assert.equal(flat6.blue, 0);
assert.equal(flat6.alpha, 0.625);
});

it('handles two colors with alpha:0', () => {
const transparent1 = new Color(0, 0, 0, 0);
const transparent2 = new Color(255, 255, 255, 0);
const transparent3 = flattenColors(transparent1, transparent2);
assert.deepEqual(transparent3.toJSON(), {
red: 0,
green: 0,
blue: 0,
alpha: 0
});
});
});

describe('color.flattenColors mix-blend-mode functions', function () {
describe('color.flattenColors ', function () {
'use strict';

var colourOne = new axe.commons.color.Color(216, 22, 22, 1);
Expand Down
Loading

0 comments on commit 228daf1

Please sign in to comment.