Skip to content

Commit

Permalink
fix(color-contrast): get text stoke from offset shadows (#4079)
Browse files Browse the repository at this point in the history
* fix(color-contrast): get text stoke from offset shadows

* Apply suggestions from code review

Co-authored-by: Dan Bjorge <[email protected]>

* Refactor & cleanup

* Have partial text-shadows report as incomplete

* Ignore thin shadows on one side of text

* Resolve feedback

---------

Co-authored-by: Dan Bjorge <[email protected]>
  • Loading branch information
WilcoFiers and dbjorge authored Jul 14, 2023
1 parent 9d5f496 commit 13acffe
Show file tree
Hide file tree
Showing 15 changed files with 737 additions and 83 deletions.
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) {
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 @@ -615,6 +615,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

0 comments on commit 13acffe

Please sign in to comment.