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): get text stoke from offset shadows #4079

Merged
merged 6 commits into from
Jul 14, 2023
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
11 changes: 8 additions & 3 deletions lib/checks/color/color-contrast-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,19 @@ export default function colorContrastEvaluate(node, options, virtualNode) {
return undefined;
}

const bgNodes = [];
const bgColor = getBackgroundColor(node, bgNodes, shadowOutlineEmMax);
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,
maxRatio: shadowOutlineEmMax
});
if (shadowColors === null) {
this.data({ messageKey: 'complexTextShadows' });
return undefined;
}

const bgNodes = [];
const bgColor = getBackgroundColor(node, bgNodes, shadowOutlineEmMax);
const fgColor = getForegroundColor(node, false, bgColor, options);

let contrast = null;
let contrastContributor = null;
Expand Down
1 change: 1 addition & 0 deletions lib/checks/color/color-contrast.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"bgGradient": "Element's background color could not be determined due to a background gradient",
"imgNode": "Element's background color could not be determined because element contains an image node",
"bgOverlap": "Element's background color could not be determined because it is overlapped by another element",
"complexTextShadows": "Element's contrast could not be determined because it uses complex text shadows",
"fgAlpha": "Element's foreground color could not be determined because of alpha transparency",
"elmPartiallyObscured": "Element's background color could not be determined because it's partially obscured by another element",
"elmPartiallyObscuring": "Element's background color could not be determined because it partially overlaps other elements",
Expand Down
6 changes: 5 additions & 1 deletion lib/commons/color/get-background-color.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ function _getBackgroundColor(elm, bgElms, shadowOutlineEmMax) {
}

const textRects = getVisibleChildTextRects(elm);
let bgColors = getTextShadowColors(elm, { minRatio: shadowOutlineEmMax });
let bgColors =
getTextShadowColors(elm, {
minRatio: shadowOutlineEmMax,
ignoreEdgeCount: true
}) ?? []; // Empty object shouldn't be necessary. Just being safe.
if (bgColors.length) {
bgColors = [{ color: bgColors.reduce(flattenShadowColors) }];
}
Expand Down
97 changes: 97 additions & 0 deletions lib/commons/color/get-stroke-colors-from-shadows.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import Color from './color';

/** Magic numbers **/
// Alpha value to use when text shadows are offset between .5px and 1.5px
const SHADOW_STROKE_ALPHA = 0.54;
// Shadows offset by less than this are not visible enough no matter how much you stack them
const VISIBLE_SHADOW_MIN_PX = 0.5;
// Shadows offset by more than this have full opacity
const OPAQUE_STROKE_OFFSET_MIN_PX = 1.5;

const edges = ['top', 'right', 'bottom', 'left'];

/**
* Work out which color(s) of an array of text shadows form a stroke around the text.
* @param {Array[]} testShadows Parsed test shadows (see color.parseTestShadow())
* @param {Object} options (optional)
* @property {Bool} ignoreEdgeCount Do not return null when if shadows cover 2 or 3 edges, ignore those instead
* @returns {Array|null} Array of colors or null if text-shadow was too complex to measure
*/
export default function getStrokeColorsFromShadows(
parsedShadows,
{ ignoreEdgeCount = false } = {}
) {
const shadowMap = getShadowColorsMap(parsedShadows);
const shadowsByColor = Object.entries(shadowMap).map(([colorStr, sides]) => {
const edgeCount = edges.filter(side => sides[side].length !== 0).length;
return { colorStr, sides, edgeCount };
});

// Bail immediately if any shadow group covers too much of the text to be ignored, but not enough to be tested
if (
!ignoreEdgeCount &&
shadowsByColor.some(({ edgeCount }) => edgeCount > 1 && edgeCount < 4)
) {
return null;
}

return shadowsByColor
.map(shadowGroupToColor)
.filter(shadow => shadow !== null);
}

/**
* Create a map of colors to the sides they are on
*/
function getShadowColorsMap(parsedShadows) {
const colorMap = {};
for (const { colorStr, pixels } of parsedShadows) {
colorMap[colorStr] ??= { top: [], right: [], bottom: [], left: [] };
const borders = colorMap[colorStr];
const [offsetX, offsetY] = pixels;

if (offsetX > VISIBLE_SHADOW_MIN_PX) {
borders.right.push(offsetX);
} else if (-offsetX > VISIBLE_SHADOW_MIN_PX) {
borders.left.push(-offsetX);
}
if (offsetY > VISIBLE_SHADOW_MIN_PX) {
borders.bottom.push(offsetY);
} else if (-offsetY > VISIBLE_SHADOW_MIN_PX) {
borders.top.push(-offsetY);
}
}
return colorMap;
}

/**
* Using colorStr and thickness of sides, create a color object
*/
function shadowGroupToColor({ colorStr, sides, edgeCount }) {
if (edgeCount !== 4) {
Copy link
Contributor

Choose a reason for hiding this comment

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

It feels off that this check is still here when the caller is now also doing an overlapping edgeCount check. Maybe more clear to move this check out of shadowGroupToColor and update the .filter on L32 to filter out based on edgeCount before mapping this function, instead of mapping this function and then filtering based on its return value.

return null; // ignore thin shadows and shadows on one side of the text
}
const strokeColor = new Color();
strokeColor.parseString(colorStr);

// Detect whether any sides' shadows are thin enough to be considered
// translucent, and if so, calculate an alpha value to apply on top of
// the parsed color.
let density = 0;
let isSolid = true;
edges.forEach(edge => {
// Decimal values are ignored. a .6px shadow is treated as 1px
// because it is not rendered evenly around the text.
// I.e. .6 ends up as 70% alpha on one side and 16% on the other.
density += sides[edge].length / 4;
isSolid &&= sides[edge].every(
offset => offset > OPAQUE_STROKE_OFFSET_MIN_PX
);
});

if (!isSolid) {
// As more shadows surround the text, the opacity increases
strokeColor.alpha = 1 - Math.pow(SHADOW_STROKE_ALPHA, density);
}
return strokeColor;
}
121 changes: 51 additions & 70 deletions lib/commons/color/get-text-shadow-colors.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
import Color from './color';
import assert from '../../core/utils/assert';
import getStrokeColorsFromShadows from './get-stroke-colors-from-shadows';
import parseTextShadows from './parse-text-shadows';

/**
* Get text-shadow colors that can impact the color contrast of the text
* Get text-shadow colors that can impact the color contrast of the text, including from:
* - Shadows which are individually thick enough (minRatio <= thickness <= maxRatio) to distinguish as characters
* - Groups of "thin" shadows (thickness < minRatio) that collectively act as a pseudo-text-stroke (see #4064)
* @param {Element} node DOM Element
* @param {Object} options (optional)
* @property {Bool} minRatio Ignore shadows smaller than this, ratio shadow size divided by font size
* @property {Bool} minRatio Treat shadows smaller than this as "thin", ratio shadow size divided by font size
* @property {Bool} maxRatio Ignore shadows equal or larger than this, ratio shadow size divided by font size
* @property {Bool} ignoreEdgeCount Do not return null when if shadows cover 2 or 3 edges, ignore those instead
* @returns {Array|null} Array of colors or null if text-shadow was too complex to measure
*/
function getTextShadowColors(node, { minRatio, maxRatio } = {}) {
export default function getTextShadowColors(
node,
{ minRatio, maxRatio, ignoreEdgeCount } = {}
) {
const shadowColors = [];
const style = window.getComputedStyle(node);
const textShadow = style.getPropertyValue('text-shadow');
if (textShadow === 'none') {
return [];
return shadowColors;
}

const fontSizeStr = style.getPropertyValue('font-size');
Expand All @@ -22,80 +32,53 @@ function getTextShadowColors(node, { minRatio, maxRatio } = {}) {
`Unable to determine font-size value ${fontSizeStr}`
);

const shadowColors = [];
const thinShadows = [];
const shadows = parseTextShadows(textShadow);
shadows.forEach(({ colorStr, pixels }) => {
for (const shadow of shadows) {
// Defaults only necessary for IE
colorStr = colorStr || style.getPropertyValue('color');
const [offsetY, offsetX, blurRadius = 0] = pixels;
if (
(!minRatio || blurRadius >= fontSize * minRatio) &&
(!maxRatio || blurRadius < fontSize * maxRatio)
) {
const color = textShadowColor({
colorStr,
offsetY,
offsetX,
blurRadius,
fontSize
const colorStr = shadow.colorStr || style.getPropertyValue('color');
const [offsetX, offsetY, blurRadius = 0] = shadow.pixels;
if (maxRatio && blurRadius >= fontSize * maxRatio) {
continue;
}
if (minRatio && blurRadius < fontSize * minRatio) {
thinShadows.push({ colorStr, pixels: shadow.pixels });
continue;
}
if (thinShadows.length > 0) {
// Inset any stroke colors before this shadow
const strokeColors = getStrokeColorsFromShadows(thinShadows, {
ignoreEdgeCount
});
shadowColors.push(color);
if (strokeColors === null) {
return null; // Exit early if text-shadow is too complex
}
shadowColors.push(...strokeColors);
thinShadows.splice(0, thinShadows.length); // empty
}
});
return shadowColors;
}

/**
* 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 [];
const color = textShadowColor({
colorStr,
offsetX,
offsetY,
blurRadius,
fontSize
});
shadowColors.push(color);
}

while (str) {
const colorMatch =
str.match(/^rgba?\([0-9,.\s]+\)/i) ||
str.match(/^[a-z]+/i) ||
str.match(/^#[0-9a-f]+/i);
const 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}`);
if (thinShadows.length > 0) {
// Append any remaining stroke colors
const strokeColors = getStrokeColorsFromShadows(thinShadows, {
ignoreEdgeCount
});
if (strokeColors === null) {
return null; // Exit early if text-shadow is too complex
}
shadowColors.push(...strokeColors);
}

return shadows;
return shadowColors;
}

function textShadowColor({ colorStr, offsetX, offsetY, blurRadius, fontSize }) {
Expand All @@ -121,5 +104,3 @@ function blurRadiusToAlpha(blurRadius, fontSize) {
const relativeBlur = blurRadius / fontSize;
return 0.185 / (relativeBlur + 0.4);
}

export default getTextShadowColors;
4 changes: 3 additions & 1 deletion lib/commons/color/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export { default as getContrast } from './get-contrast';
export { default as getForegroundColor } from './get-foreground-color';
export { default as getOwnBackgroundColor } from './get-own-background-color';
export { default as getRectStack } from './get-rect-stack';
export { default as getStrokeColorsFromShadows } from './get-stroke-colors-from-shadows';
export { default as getTextShadowColors } from './get-text-shadow-colors';
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';
export { default as parseTextShadows } from './parse-text-shadows';
export { getStackingContext, stackingContextToColor } from './stacking-context';
62 changes: 62 additions & 0 deletions lib/commons/color/parse-text-shadows.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import assert from '../../core/utils/assert';

/**
* 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
* @param {String} textShadow
* @returns {Array} Array of objects with `pixels` and `colorStr` properties
*/
export default function parseTextShadows(textShadow) {
let current = { pixels: [] };
let str = textShadow.trim();
const shadows = [current];
if (!str) {
return [];
}

while (str) {
const colorMatch =
str.match(/^rgba?\([0-9,.\s]+\)/i) ||
str.match(/^[a-z]+/i) ||
str.match(/^#[0-9a-f]+/i);
const 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}`);
}
}

shadows.forEach(({ pixels }) => {
if (pixels.length === 2) {
pixels.push(0); // Append default blur
}
});

return shadows;
}
1 change: 1 addition & 0 deletions locales/_template.json
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,7 @@
"bgGradient": "Element's background color could not be determined due to a background gradient",
"imgNode": "Element's background color could not be determined because element contains an image node",
"bgOverlap": "Element's background color could not be determined because it is overlapped by another element",
"complexTextShadows": "Element's contrast could not be determined because it uses complex text shadows",
"fgAlpha": "Element's foreground color could not be determined because of alpha transparency",
"elmPartiallyObscured": "Element's background color could not be determined because it's partially obscured by another element",
"elmPartiallyObscuring": "Element's background color could not be determined because it partially overlaps other elements",
Expand Down
Loading