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( - '
' + '