diff --git a/lib/commons/color/get-foreground-color.js b/lib/commons/color/get-foreground-color.js index 91eb784be2..dca800421e 100644 --- a/lib/commons/color/get-foreground-color.js +++ b/lib/commons/color/get-foreground-color.js @@ -3,7 +3,7 @@ import getBackgroundColor from './get-background-color'; import incompleteData from './incomplete-data'; import flattenColors from './flatten-colors'; import getTextShadowColors from './get-text-shadow-colors'; -import { getNodeFromTree } from '../../core/utils'; +import { getStackingContext, stackingContextToColor } from './stacking-context'; /** * Returns the flattened foreground color of an element, or null if it can't be determined because @@ -21,42 +21,49 @@ import { getNodeFromTree } from '../../core/utils'; */ export default function getForegroundColor(node, _, bgColor, options = {}) { const nodeStyle = window.getComputedStyle(node); - const opacity = getOpacity(node, nodeStyle); - // 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; - } + const colorStack = [ + // Start with -webkit-text-stroke, it is rendered on top + () => getStrokeColor(nodeStyle, options), + // Next color / -webkit-text-fill-color + () => getTextColor(nodeStyle), + // If text is (semi-)transparent shadows are visible through it + () => getTextShadowColors(node, { minRatio: 0 }) + ]; + let fgColors = []; - // 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; - } + for (const colorFn of colorStack) { + const color = colorFn(); + if (!color) { + continue; + } + + fgColors = fgColors.concat(color); - // 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; + if (color.alpha === 1) { + break; + } } - // Lastly, if text opacity still isn't at 1, blend the background + const fgColor = fgColors.reduce((source, backdrop) => { + return flattenColors(source, backdrop); + }); + + // Lastly 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); + + const stackingContexts = getStackingContext(node); + const context = findNodeInContexts(stackingContexts, node); + return flattenColors( + calculateBlendedForegroundColor(fgColor, context, stackingContexts), + // default page background + new Color(255, 255, 255, 1) + ); } function getTextColor(nodeStyle) { @@ -83,26 +90,73 @@ function getStrokeColor(nodeStyle, { textStrokeEmMin = 0 }) { return new Color().parseString(strokeColor); } -function getOpacity(node, nodeStyle) { - if (!node) { - return 1; - } +/** + * Blend a foreground color into the background stacking context, taking into account opacity at each step. + * @param {Color} fgColor + * @param {Object} context - The nodes stacking context + * @param {Object[]} stackingContexts - Array of all stacking contexts + * @return {Color} + */ +function calculateBlendedForegroundColor(fgColor, context, stackingContexts) { + while (context) { + // find the nearest ancestor that has opacity < 1 + if (context.opacity === 1 && context.ancestor) { + context = context.ancestor; + continue; + } - const vNode = getNodeFromTree(node); - if (vNode && vNode._opacity !== undefined && vNode._opacity !== null) { - return vNode._opacity; - } + fgColor.alpha *= context.opacity; - nodeStyle ??= window.getComputedStyle(node); - const opacity = nodeStyle.getPropertyValue('opacity'); - const finalOpacity = opacity * getOpacity(node.parentElement); + // when blending the foreground color to a background color with opacity, + // we ignore the background color of the node itself and instead blend + // with the stack behind it + let stack = context.ancestor?.descendants || stackingContexts; + if (context.opacity !== 1) { + stack = stack.slice(0, stack.indexOf(context)); + } - // 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; + const bgColors = stack.map(stackingContextToColor); + + if (!bgColors.length) { + context = context.ancestor; + continue; + } + + const bgColor = bgColors.reduce( + (backdrop, source) => { + return flattenColors( + source.color, + backdrop.color instanceof Color ? backdrop.color : backdrop + ); + }, + { + color: new Color(0, 0, 0, 0), + blendMode: 'normal' + } + ); + + fgColor = flattenColors(fgColor, bgColor); + context = context.ancestor; } - return finalOpacity; + return fgColor; +} + +/** + * Find the stacking context that belongs to the passed in node + * @param {Object} contexts - Array of stacking contexts + * @param {Element} node + * @returns {Object} + */ +function findNodeInContexts(contexts, node) { + for (const context of contexts) { + if (context.vNode?.actualNode === node) { + return context; + } + + const found = findNodeInContexts(context.descendants, node); + if (found) { + return found; + } + } } diff --git a/lib/commons/color/stacking-context.js b/lib/commons/color/stacking-context.js index 805c2fb2f2..05c49e5745 100644 --- a/lib/commons/color/stacking-context.js +++ b/lib/commons/color/stacking-context.js @@ -169,12 +169,14 @@ function reduceToColor(backdropContext, sourceContext) { /** * Create a stacking context object for a virtual node. - * @param {VirtualNode} vNod + * @param {VirtualNode} vNode + * @param {Object} ancestorContext * @return {Object} */ -function createStackingContext(vNode) { +function createStackingContext(vNode, ancestorContext) { return { vNode: vNode, + ancestor: ancestorContext, opacity: parseFloat(vNode?.getComputedStylePropertyValue('opacity') ?? 1), bgColor: new Color(0, 0, 0, 0), blendMode: normalizeBlendMode( @@ -201,8 +203,9 @@ function normalizeBlendMode(blendmode) { * @return {Object} */ function addToStackingContext(contextMap, vNode, ancestorVNode) { - const context = contextMap.get(vNode) ?? createStackingContext(vNode); const ancestorContext = contextMap.get(ancestorVNode); + const context = + contextMap.get(vNode) ?? createStackingContext(vNode, ancestorContext); if ( ancestorContext && ancestorVNode !== vNode && diff --git a/test/commons/color/get-foreground-color.js b/test/commons/color/get-foreground-color.js index bbfc3cdd37..50b7ea5526 100644 --- a/test/commons/color/get-foreground-color.js +++ b/test/commons/color/get-foreground-color.js @@ -17,7 +17,7 @@ describe('color.getForegroundColor', () => { it('returns the CSS color property', () => { const target = queryFixture( - '
' + '
Hello World
' ).actualNode; const fgColor = getForegroundColor(target); assertSameColor(fgColor, new Color(0, 0, 128)); @@ -26,7 +26,7 @@ describe('color.getForegroundColor', () => { it('returns the CSS color from inside of Shadow DOM', () => { const target = queryShadowFixture( '
', - '
' + '
Hello World
' ).actualNode; const fgColor = getForegroundColor(target); @@ -38,28 +38,16 @@ describe('color.getForegroundColor', () => { '
' + '
' + + 'Hello World' + '
' ).actualNode; assert.isNull(getForegroundColor(target)); assert.equal(axe.commons.color.incompleteData.get('fgColor'), 'bgImage'); }); - it('does not recalculate bgColor if passed in', () => { - const target = queryFixture( - '
' + - '
' + - 'This is my text' + - '
' - ).actualNode; - - const bgColor = new Color(64, 64, 0); - const fgColor = getForegroundColor(target, false, bgColor); - assertSameColor(fgColor, new Color(32, 32, 64), 0.8); - }); - it('returns `-webkit-text-fill-color` over `color`', () => { const target = queryFixture( - '
' + '
Hello World
' ).actualNode; const fgColor = getForegroundColor(target); assertSameColor(fgColor, new Color(0, 0, 255)); @@ -68,7 +56,7 @@ describe('color.getForegroundColor', () => { describe('text-stroke', () => { it('ignores stroke when equal to 0', () => { const target = queryFixture( - '
' + '
Hello World
' ).actualNode; const options = { textStrokeEmMin: 0 }; const fgColor = getForegroundColor(target, null, null, options); @@ -77,7 +65,7 @@ describe('color.getForegroundColor', () => { it('ignores stroke when less then the minimum', () => { const target = queryFixture( - '
' + '
Hello World
' ).actualNode; const options = { textStrokeEmMin: 0.2 }; const fgColor = getForegroundColor(target, null, null, options); @@ -86,7 +74,7 @@ describe('color.getForegroundColor', () => { it('uses stroke color when thickness is equal to the minimum', () => { const target = queryFixture( - '
' + '
Hello World
' ).actualNode; const options = { textStrokeEmMin: 0.2 }; const fgColor = getForegroundColor(target, null, null, options); @@ -95,7 +83,7 @@ describe('color.getForegroundColor', () => { it('blends the stroke color with `color`', () => { const target = queryFixture( - '
' + '
Hello World
' ).actualNode; const options = { textStrokeEmMin: 0.1 }; const fgColor = getForegroundColor(target, null, null, options); @@ -125,7 +113,15 @@ describe('color.getForegroundColor', () => { '' ).actualNode; const fgColor = getForegroundColor(target); - assertSameColor(fgColor, new Color(32, 32, 64)); + assertSameColor(fgColor, new Color(64, 0, 64)); + }); + + it('does not apply opacity to node background', () => { + const target = queryFixture( + '
Hello World
' + ).actualNode; + const fgColor = getForegroundColor(target); + assertSameColor(fgColor, new Color(255, 255, 255)); }); it('combines opacity with text stroke alpha color', () => { diff --git a/test/commons/color/stacking-context.js b/test/commons/color/stacking-context.js index 8134873046..d02d62e48c 100644 --- a/test/commons/color/stacking-context.js +++ b/test/commons/color/stacking-context.js @@ -31,6 +31,7 @@ describe('color.stackingContext', () => { assert.deepEqual(stackingContext, [ { vNode, + ancestor: undefined, opacity: 1, bgColor: new Color(0, 0, 0, 0), blendMode: 'normal', @@ -53,6 +54,7 @@ describe('color.stackingContext', () => { assert.deepEqual(stackingContext, [ { vNode: querySelectorAll(axe._tree[0], '#elm1')[0], + ancestor: undefined, opacity: 1, bgColor: new Color(0, 0, 0, 0), blendMode: 'normal', @@ -60,6 +62,7 @@ describe('color.stackingContext', () => { }, { vNode: querySelectorAll(axe._tree[0], '#elm2')[0], + ancestor: undefined, opacity: 1, bgColor: new Color(0, 0, 0, 0), blendMode: 'normal', @@ -67,6 +70,7 @@ describe('color.stackingContext', () => { }, { vNode, + ancestor: undefined, opacity: 1, bgColor: new Color(0, 0, 0, 0), blendMode: 'normal', @@ -89,12 +93,14 @@ describe('color.stackingContext', () => { assert.deepEqual(stackingContext, [ { vNode: querySelectorAll(axe._tree[0], '#elm1')[0], + ancestor: undefined, opacity: 1, bgColor: new Color(0, 0, 0, 0), blendMode: 'normal', descendants: [ { vNode: querySelectorAll(axe._tree[0], '#elm2')[0], + ancestor: stackingContext[0], opacity: 1, bgColor: new Color(0, 0, 0, 0), blendMode: 'normal', @@ -102,6 +108,7 @@ describe('color.stackingContext', () => { }, { vNode, + ancestor: stackingContext[0], opacity: 1, bgColor: new Color(0, 0, 0, 0), blendMode: 'normal', @@ -122,6 +129,7 @@ describe('color.stackingContext', () => { assert.deepEqual(stackingContext, [ { vNode, + ancestor: undefined, opacity: 0.8, bgColor: new Color(255, 0, 0, 0.5), blendMode: 'difference', @@ -143,12 +151,14 @@ describe('color.stackingContext', () => { assert.deepEqual(stackingContext, [ { vNode: querySelectorAll(axe._tree[0], '#elm1')[0], + ancestor: undefined, opacity: 0.8, bgColor: new Color(0, 0, 0, 0), blendMode: 'normal', descendants: [ { vNode, + ancestor: stackingContext[0], opacity: 1, bgColor: new Color(0, 0, 0, 0), blendMode: 'normal',