diff --git a/devtools/server/actors/accessibility/accessible.js b/devtools/server/actors/accessibility/accessible.js index da6e6cc90b72..42a8a9cff990 100644 --- a/devtools/server/actors/accessibility/accessible.js +++ b/devtools/server/actors/accessibility/accessible.js @@ -294,8 +294,15 @@ const AccessibleActor = ActorClassWithSpec(accessibleSpec, { * Calculate the contrast ratio of the given accessible. */ _getContrastRatio() { - return getContrastRatioFor(this._isValidTextLeaf(this.rawAccessible) ? - this.rawAccessible.DOMNode.parentNode : this.rawAccessible.DOMNode); + if (!this._isValidTextLeaf(this.rawAccessible)) { + return null; + } + + return getContrastRatioFor(this.rawAccessible.DOMNode.parentNode, { + bounds: this.bounds, + contexts: this.walker.contexts, + win: this.walker.rootWin, + }); }, /** diff --git a/devtools/server/actors/accessibility/walker.js b/devtools/server/actors/accessibility/walker.js index 3343d5acacd1..439300afa21d 100644 --- a/devtools/server/actors/accessibility/walker.js +++ b/devtools/server/actors/accessibility/walker.js @@ -19,10 +19,22 @@ loader.lazyRequireGetter(this, "isDefunct", "devtools/server/actors/utils/access loader.lazyRequireGetter(this, "isTypeRegistered", "devtools/server/actors/highlighters", true); loader.lazyRequireGetter(this, "isWindowIncluded", "devtools/shared/layout/utils", true); loader.lazyRequireGetter(this, "isXUL", "devtools/server/actors/highlighters/utils/markup", true); +loader.lazyRequireGetter(this, "loadSheet", "devtools/shared/layout/utils", true); loader.lazyRequireGetter(this, "register", "devtools/server/actors/highlighters", true); +loader.lazyRequireGetter(this, "removeSheet", "devtools/shared/layout/utils", true); const kStateHover = 0x00000004; // NS_EVENT_STATE_HOVER +const HIGHLIGHTER_STYLES_SHEET = `data:text/css;charset=utf-8, +* { + transition: none !important; +} + +:-moz-devtools-highlighted { + color: transparent !important; + text-shadow: none !important; +}`; + const nsIAccessibleEvent = Ci.nsIAccessibleEvent; const nsIAccessibleStateChangeEvent = Ci.nsIAccessibleStateChangeEvent; const nsIAccessibleRole = Ci.nsIAccessibleRole; @@ -336,7 +348,7 @@ const AccessibleWalkerActor = ActorClassWithSpec(accessibleWalkerSpec, { }, async getAncestry(accessible) { - if (accessible.indexInParent === -1) { + if (!accessible || accessible.indexInParent === -1) { return []; } const doc = await this.getDocument(); @@ -478,14 +490,23 @@ const AccessibleWalkerActor = ActorClassWithSpec(accessibleWalkerSpec, { * True if highlighter shows the accessible object. */ highlightAccessible(accessible, options = {}) { + this.unhighlight(); const { bounds } = accessible; if (!bounds) { return false; } + // Disable potential mouse driven transitions (This is important because accessibility + // highlighter temporarily modifies text color related CSS properties. In case where + // there are transitions that affect them, there might be unexpected side effects when + // taking a snapshot for contrast measurement) + loadSheet(this.rootWin, HIGHLIGHTER_STYLES_SHEET); const { audit, name, role } = accessible; - return this.highlighter.show({ rawNode: accessible.rawAccessible.DOMNode }, - { ...options, ...bounds, name, role, audit }); + const shown = this.highlighter.show({ rawNode: accessible.rawAccessible.DOMNode }, + { ...options, ...bounds, name, role, audit }); + // Re-enable transitions. + removeSheet(this.rootWin, HIGHLIGHTER_STYLES_SHEET); + return shown; }, /** @@ -744,7 +765,8 @@ const AccessibleWalkerActor = ActorClassWithSpec(accessibleWalkerSpec, { }, /** - * If content is still alive, stop picker content listeners. + * If content is still alive, stop picker content listeners, reset the hover state for + * last target element. */ _unsetPickerEnvironment: function() { const target = this.targetActor.chromeEventHandler; @@ -799,9 +821,7 @@ const AccessibleWalkerActor = ActorClassWithSpec(accessibleWalkerSpec, { * Cacncel picker pick. Remvoe all content listeners and hide the highlighter. */ cancelPick: function() { - if (this._highlighter) { - this.highlighter.hide(); - } + this.unhighlight(); if (this._isPicking) { this._unsetPickerEnvironment(); diff --git a/devtools/server/actors/highlighters.css b/devtools/server/actors/highlighters.css index 544c57555441..5d6098d03e63 100644 --- a/devtools/server/actors/highlighters.css +++ b/devtools/server/actors/highlighters.css @@ -653,6 +653,10 @@ max-width: 90%; } +:-moz-native-anonymous .accessible-infobar-audit .accessible-contrast-ratio:not(:empty):after { + margin-inline-start: 2px; +} + :-moz-native-anonymous .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AA:after, :-moz-native-anonymous .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AAA:after { color: #90E274; @@ -660,15 +664,28 @@ :-moz-native-anonymous .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).fail:after { color: #E57180; - content: " ⚠️"; + content: "⚠️"; } :-moz-native-anonymous .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AA:after { - content: " AA\2713"; + content: "AA\2713"; } :-moz-native-anonymous .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AAA:after { - content: " AAA\2713"; + content: "AAA\2713"; +} + +:-moz-native-anonymous .accessible-infobar-audit .accessible-contrast-ratio-label, +:-moz-native-anonymous .accessible-infobar-audit #accessible-contrast-ratio-max:not(:empty):before { + margin-inline-end: 3px; +} + +:-moz-native-anonymous .accessible-infobar-audit #accessible-contrast-ratio-max { + margin-inline-start: 3px; +} + +:-moz-native-anonymous .accessible-infobar-audit #accessible-contrast-ratio-max:not(:empty):before { + content: "-"; } :-moz-native-anonymous .accessible-infobar-name:not(:empty), diff --git a/devtools/server/actors/highlighters/utils/accessibility.js b/devtools/server/actors/highlighters/utils/accessibility.js index 1af7ba192e03..0a1b1595abdc 100644 --- a/devtools/server/actors/highlighters/utils/accessibility.js +++ b/devtools/server/actors/highlighters/utils/accessibility.js @@ -471,15 +471,55 @@ class AuditReport { */ class ContrastRatio extends AuditReport { buildMarkup(root) { + createNode(this.win, { + nodeType: "span", + parent: root, + attributes: { + "class": "contrast-ratio-label", + "id": "contrast-ratio-label", + }, + prefix: this.prefix, + text: L10N.getStr("accessibility.contrast.ratio.label"), + }); + createNode(this.win, { nodeType: "span", parent: root, attributes: { "class": "contrast-ratio", - "id": "contrast-ratio", + "id": "contrast-ratio-error", + }, + prefix: this.prefix, + text: L10N.getStr("accessibility.contrast.ratio.error"), + }); + + createNode(this.win, { + nodeType: "span", + parent: root, + attributes: { + "class": "contrast-ratio", + "id": "contrast-ratio-min", }, prefix: this.prefix, }); + + createNode(this.win, { + nodeType: "span", + parent: root, + attributes: { + "class": "contrast-ratio", + "id": "contrast-ratio-max", + }, + prefix: this.prefix, + }); + } + + _fillAndStyleContrastValue(el, value, isLargeText, stringName) { + value = value.toFixed(2); + const style = getContrastRatioScoreStyle(value, isLargeText); + this.setTextContent(el, stringName ? L10N.getFormatStr(stringName, value) : value); + el.classList.add(style); + el.removeAttribute("hidden"); } /** @@ -491,16 +531,36 @@ class ContrastRatio extends AuditReport { * block should be visible. */ update({ contrastRatio }) { - const el = this.getElement("contrast-ratio"); - ["fail", "AA", "AAA"].forEach(style => el.classList.remove(style)); + const els = {}; + for (const key of ["label", "min", "max", "error"]) { + const el = els[key] = this.getElement(`contrast-ratio-${key}`); + if (["min", "max"].includes(key)) { + ["fail", "AA", "AAA"].forEach(className => el.classList.remove(className)); + this.setTextContent(el, ""); + } + + el.setAttribute("hidden", true); + } if (!contrastRatio) { return false; } - el.classList.add(getContrastRatioScoreStyle(contrastRatio)); - this.setTextContent(el, - L10N.getFormatStr("accessibility.contrast.ratio", contrastRatio.ratio.toFixed(2))); + const { isLargeText, error } = contrastRatio; + els.label.removeAttribute("hidden"); + if (error) { + els.error.removeAttribute("hidden"); + return true; + } + + if (contrastRatio.value) { + this._fillAndStyleContrastValue(els.min, contrastRatio.value, isLargeText); + return true; + } + + this._fillAndStyleContrastValue(els.min, contrastRatio.min, isLargeText); + this._fillAndStyleContrastValue(els.max, contrastRatio.max, isLargeText); + return true; } } @@ -563,15 +623,15 @@ function getBounds(win, { x, y, w, h, zoom }) { /** * Get contrast ratio score styling to be applied on the element that renders the contrast * ratio. - * @param {Number} options.ratio + * @param {Number} ratio * Value of the contrast ratio for a given accessible object. - * @param {Boolean} options.largeText + * @param {Boolean} isLargeText * True if the accessible object contains large text. * @return {String} * CSS class that represents the appropriate contrast ratio score styling. */ -function getContrastRatioScoreStyle({ ratio, largeText }) { - const levels = largeText ? { AA: 3, AAA: 4.5 } : { AA: 4.5, AAA: 7 }; +function getContrastRatioScoreStyle(ratio, isLargeText) { + const levels = isLargeText ? { AA: 3, AAA: 4.5 } : { AA: 4.5, AAA: 7 }; let style = "fail"; if (ratio >= levels.AAA) { diff --git a/devtools/server/actors/highlighters/utils/markup.js b/devtools/server/actors/highlighters/utils/markup.js index 7dbfdaf1eda8..0afdc43b0940 100644 --- a/devtools/server/actors/highlighters/utils/markup.js +++ b/devtools/server/actors/highlighters/utils/markup.js @@ -163,12 +163,14 @@ exports.createSVGNode = createSVGNode; * attributes. * - parent: if provided, the newly created element will be appended to this * node. + * - text: if provided, set the text content of the element. */ function createNode(win, options) { const type = options.nodeType || "div"; const namespace = options.namespace || XHTML_NS; + const doc = win.document; - const node = win.document.createElementNS(namespace, type); + const node = doc.createElementNS(namespace, type); for (const name in options.attributes || {}) { let value = options.attributes[name]; @@ -182,6 +184,10 @@ function createNode(win, options) { options.parent.appendChild(node); } + if (options.text) { + node.appendChild(doc.createTextNode(options.text)); + } + return node; } exports.createNode = createNode; diff --git a/devtools/server/actors/highlighters/xul-accessible.js b/devtools/server/actors/highlighters/xul-accessible.js index 0a33927ac3dc..dd090200802b 100644 --- a/devtools/server/actors/highlighters/xul-accessible.js +++ b/devtools/server/actors/highlighters/xul-accessible.js @@ -79,6 +79,10 @@ const ACCESSIBLE_BOUNDS_SHEET = "data:text/css;charset=utf-8," + encodeURICompon max-width: 90%; } + .accessible-infobar-audit .accessible-contrast-ratio:not(:empty):after { + margin-inline-start: 2px; + } + .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AA:after, .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AAA:after { color: #90E274; @@ -86,15 +90,28 @@ const ACCESSIBLE_BOUNDS_SHEET = "data:text/css;charset=utf-8," + encodeURICompon .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).fail:after { color: #E57180; - content: " ⚠️"; + content: "⚠️"; } .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AA:after { - content: " AA\u2713"; + content: "AA\u2713"; } .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AAA:after { - content: " AAA\u2713"; + content: "AAA\u2713"; + } + + .accessible-infobar-audit .accessible-contrast-ratio-label, + .accessible-infobar-audit #accessible-contrast-ratio-max:not(:empty):before { + margin-inline-end: 3px; + } + + .accessible-infobar-audit #accessible-contrast-ratio-max { + margin-inline-start: 3px; + } + + .accessible-infobar-audit #accessible-contrast-ratio-max:not(:empty):before { + content: "-"; } .accessible-infobar-name:not(:empty), diff --git a/devtools/server/actors/utils/accessibility.js b/devtools/server/actors/utils/accessibility.js index e1b82dab17a8..2f1fdd6b4c09 100644 --- a/devtools/server/actors/utils/accessibility.js +++ b/devtools/server/actors/utils/accessibility.js @@ -7,60 +7,211 @@ loader.lazyRequireGetter(this, "Ci", "chrome", true); loader.lazyRequireGetter(this, "colorUtils", "devtools/shared/css/color", true); loader.lazyRequireGetter(this, "CssLogic", "devtools/server/actors/inspector/css-logic", true); -loader.lazyRequireGetter(this, "InspectorActorUtils", "devtools/server/actors/inspector/utils"); +loader.lazyRequireGetter(this, "getBounds", "devtools/server/actors/highlighters/utils/accessibility", true); +loader.lazyRequireGetter(this, "getCurrentZoom", "devtools/shared/layout/utils", true); loader.lazyRequireGetter(this, "Services"); +loader.lazyRequireGetter(this, "addPseudoClassLock", "devtools/server/actors/highlighters/utils/markup", true); +loader.lazyRequireGetter(this, "removePseudoClassLock", "devtools/server/actors/highlighters/utils/markup", true); + +const HIGHLIGHTED_PSEUDO_CLASS = ":-moz-devtools-highlighted"; /** - * Calculates the contrast ratio of the referenced DOM node. - * + * Get text style properties for a given node, if possible. * @param {DOMNode} node - * The node for which we want to calculate the contrast ratio. - * - * @return {Number|null} Contrast ratio value. -*/ -function getContrastRatioFor(node) { - const backgroundColor = InspectorActorUtils.getClosestBackgroundColor(node); - const backgroundImage = InspectorActorUtils.getClosestBackgroundImage(node); + * DOM node for which text styling information is to be calculated. + * @return {Object} + * Color and text size information for a given DOM node. + */ +function getTextProperties(node) { const computedStyles = CssLogic.getComputedStyle(node); if (!computedStyles) { return null; } const { color, "font-size": fontSize, "font-weight": fontWeight } = computedStyles; - const isBoldText = parseInt(fontWeight, 10) >= 600; - const backgroundRgbaColor = new colorUtils.CssColor(backgroundColor, true); - const textRgbaColor = new colorUtils.CssColor(color, true); + const opacity = parseFloat(computedStyles.opacity); + let { r, g, b, a } = colorUtils.colorToRGBA(color, true); + a = opacity * a; + const textRgbaColor = new colorUtils.CssColor(`rgba(${r}, ${g}, ${b}, ${a})`, true); // TODO: For cases where text color is transparent, it likely comes from the color of - // the background that is underneath it (commonly from background-clip: text property). - // With some additional investigation it might be possible to calculate the color - // contrast where the color of the background is used as text color and the color of - // the ancestor's background is used as its background. + // the background that is underneath it (commonly from background-clip: text + // property). With some additional investigation it might be possible to calculate the + // color contrast where the color of the background is used as text color and the + // color of the ancestor's background is used as its background. if (textRgbaColor.isTransparent()) { return null; } - // TODO: these cases include handling gradient backgrounds and the actual image - // backgrounds. Each one needs to be handled individually. - if (backgroundImage !== "none") { + const isBoldText = parseInt(fontWeight, 10) >= 600; + const isLargeText = Math.ceil(parseFloat(fontSize) * 72) / 96 >= (isBoldText ? 14 : 18); + + return { + // Blend text color taking its alpha into account asuming white background. + color: colorUtils.blendColors([r, g, b, a]), + isLargeText, + }; +} + +/** + * Get canvas rendering context for the current target window bound by the bounds of the + * accessible objects. + * @param {Object} win + * Current target window. + * @param {Object} bounds + * Bounds for the accessible object. + * @param {null|DOMNode} node + * If not null, a node that corresponds to the accessible object to be used to + * make its text color transparent. + * @return {CanvasRenderingContext2D} + * Canvas rendering context for the current window. + */ +function getImageCtx(win, bounds, node) { + const doc = win.document; + const canvas = doc.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); + const scale = getCurrentZoom(win); + + const { left, top, width, height } = bounds; + canvas.width = width / scale; + canvas.height = height / scale; + const ctx = canvas.getContext("2d", { alpha: false }); + + // If node is passed, make its color related text properties invisible. + if (node) { + addPseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS); + } + + ctx.drawWindow(win, left / scale, top / scale, width / scale, height / scale, "#fff", + ctx.DRAWWINDOW_USE_WIDGET_LAYERS); + + // Restore all inline styling. + if (node) { + removePseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS); + } + + return ctx; +} + +/** + * Get RGBA or a range of RGBAs for the background pixels under the text. If luminance is + * uniform, only return one value of RGBA, otherwise return values that correspond to the + * min and max luminances. + * @param {ImageData} dataText + * pixel data for the accessible object with text visible. + * @param {ImageData} dataBackground + * pixel data for the accessible object with transparent text. + * @return {Object} + * RGBA or a range of RGBAs with min and max values. + */ +function getBgRGBA(dataText, dataBackground) { + let min = [0, 0, 0, 1]; + let max = [255, 255, 255, 1]; + let minLuminance = 1; + let maxLuminance = 0; + const luminances = {}; + + let foundDistinctColor = false; + for (let i = 0; i < dataText.length; i = i + 4) { + const tR = dataText[i]; + const bgR = dataBackground[i]; + const tG = dataText[i + 1]; + const bgG = dataBackground[i + 1]; + const tB = dataText[i + 2]; + const bgB = dataBackground[i + 2]; + + // Ignore pixels that are the same where pixels that are different between the two + // images are assumed to belong to the text within the node. + if (tR === bgR && tG === bgG && tB === bgB) { + continue; + } + + foundDistinctColor = true; + + const bgColor = `rgb(${bgR}, ${bgG}, ${bgB})`; + let luminance = luminances[bgColor]; + + if (!luminance) { + // Calculate luminance for the RGB value and store it to only measure once. + luminance = colorUtils.calculateLuminance([bgR, bgG, bgB]); + luminances[bgColor] = luminance; + } + + if (minLuminance >= luminance) { + minLuminance = luminance; + min = [bgR, bgG, bgB, 1]; + } + + if (maxLuminance <= luminance) { + maxLuminance = luminance; + max = [bgR, bgG, bgB, 1]; + } + } + + if (!foundDistinctColor) { return null; } - let { r: bgR, g: bgG, b: bgB, a: bgA} = backgroundRgbaColor.getRGBATuple(); - let { r: textR, g: textG, b: textB, a: textA } = textRgbaColor.getRGBATuple(); + return minLuminance === maxLuminance ? { value: max } : { min, max }; +} - // If the element has opacity in addition to text and background alpha values, take it - // into account. - const opacity = parseFloat(computedStyles.opacity); - if (opacity < 1) { - bgA = opacity * bgA; - textA = opacity * textA; +/** + * Calculates the contrast ratio of the referenced DOM node. + * + * @param {DOMNode} node + * The node for which we want to calculate the contrast ratio. + * @param {Object} options + * - bounds {Object} + * Bounds for the accessible object. + * - contexts {null|Object} + * Canvas rendering contexts that have a window drawn as is and also + * with the all text made transparent for contrast comparison. + * - win {Object} + * Target window. + * + * @return {Object} + * An object that may contain one or more of the following fields: error, + * isLargeText, value, min, max values for contrast. +*/ +function getContrastRatioFor(node, options = {}) { + const props = getTextProperties(node); + if (!props) { + return { + error: true, + }; } + const bounds = getBounds(options.win, options.bounds); + const textContext = getImageCtx(options.win, bounds); + const backgroundContext = getImageCtx(options.win, bounds, node); + + const { data: dataText } = textContext.getImageData(0, 0, bounds.width, bounds.height); + const { data: dataBackground } = backgroundContext.getImageData( + 0, 0, bounds.width, bounds.height); + + const rgba = getBgRGBA(dataText, dataBackground); + if (!rgba) { + return { + error: true, + }; + } + + const { color, isLargeText } = props; + if (rgba.value) { + return { + value: colorUtils.calculateContrastRatio(rgba.value, color), + isLargeText, + }; + } + + // calculateContrastRatio modifies the array, since we need to use color array twice, + // pass its copy to the method. + const min = colorUtils.calculateContrastRatio(rgba.min, Array.from(color)); + const max = colorUtils.calculateContrastRatio(rgba.max, Array.from(color)); + return { - ratio: colorUtils.calculateContrastRatio([ bgR, bgG, bgB, bgA ], - [ textR, textG, textB, textA ]), - largeText: Math.ceil(parseFloat(fontSize) * 72) / 96 >= (isBoldText ? 14 : 18), + min: min < max ? min : max, + max: min < max ? max : min, + isLargeText, }; } diff --git a/devtools/shared/locales/en-US/accessibility.properties b/devtools/shared/locales/en-US/accessibility.properties index 3ad31bae909c..58444eaf1e8f 100644 --- a/devtools/shared/locales/en-US/accessibility.properties +++ b/devtools/shared/locales/en-US/accessibility.properties @@ -6,3 +6,11 @@ # ratio description, used by the accessibility highlighter to display the value. %S in the # content will be replaced by the contrast ratio numerical value. accessibility.contrast.ratio=Contrast: %S + +# LOCALIZATION NOTE (accessibility.contrast.ratio.error): A title text for the color +# contrast ratio, used when the tool is unable to calculate the contrast ratio value. +accessibility.contrast.ratio.error=Unable to calculate + +# LOCALIZATION NOTE (accessibility.contrast.ratio.label): A title text for the color +# contrast ratio description, used together with the actual values. +accessibility.contrast.ratio.label=Contrast: