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): consider -webkit-text-stroke & -webkit-text-fill-color #3791

Merged
merged 5 commits into from
Nov 28, 2022
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 @@ -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 |
|   `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