Bug 1505848 - switch from CSS based approach to calculating contrast to canvas one, that also handles gradients and images. r=jdescottes,pbro
authorYura Zenevich <yura.zenevich@gmail.com>
Fri, 16 Nov 2018 03:59:08 +0000
changeset 503144 403db98e8ac8b9ac50a1ebb26755d603c058d157
parent 503143 d051864f170d2b5dc670aa8b2edfd9cab47c5665
child 503145 6925b3d4dbd01533ae6719851accd6dc7bf433ea
push id10290
push userffxbld-merge
push dateMon, 03 Dec 2018 16:23:23 +0000
treeherdermozilla-beta@700bed2445e6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjdescottes, pbro
bugs1505848
milestone65.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1505848 - switch from CSS based approach to calculating contrast to canvas one, that also handles gradients and images. r=jdescottes,pbro MozReview-Commit-ID: JS39hAY571f Differential Revision: https://phabricator.services.mozilla.com/D11368
devtools/server/actors/accessibility/accessible.js
devtools/server/actors/accessibility/walker.js
devtools/server/actors/highlighters.css
devtools/server/actors/highlighters/utils/accessibility.js
devtools/server/actors/highlighters/utils/markup.js
devtools/server/actors/highlighters/xul-accessible.js
devtools/server/actors/utils/accessibility.js
devtools/shared/locales/en-US/accessibility.properties
--- a/devtools/server/actors/accessibility/accessible.js
+++ b/devtools/server/actors/accessibility/accessible.js
@@ -289,18 +289,25 @@ const AccessibleActor = ActorClassWithSp
   get _nonEmptyTextLeafs() {
     return this.children().filter(child => this._isValidTextLeaf(child.rawAccessible));
   },
 
   /**
    * 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,
+    });
   },
 
   /**
    * Audit the state of the accessible object.
    *
    * @return {Object|null}
    *         Audit results for the accessible object.
   */
--- a/devtools/server/actors/accessibility/walker.js
+++ b/devtools/server/actors/accessibility/walker.js
@@ -14,20 +14,32 @@ loader.lazyRequireGetter(this, "CustomHi
 loader.lazyRequireGetter(this, "DevToolsUtils", "devtools/shared/DevToolsUtils");
 loader.lazyRequireGetter(this, "events", "devtools/shared/event-emitter");
 loader.lazyRequireGetter(this, "getCurrentZoom", "devtools/shared/layout/utils", true);
 loader.lazyRequireGetter(this, "InspectorUtils", "InspectorUtils");
 loader.lazyRequireGetter(this, "isDefunct", "devtools/server/actors/utils/accessibility", true);
 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;
 
 const {
   EVENT_TEXT_CHANGED,
   EVENT_TEXT_INSERTED,
   EVENT_TEXT_REMOVED,
@@ -331,17 +343,17 @@ const AccessibleWalkerActor = ActorClass
     if (!Services.appinfo.accessibilityEnabled) {
       return null;
     }
 
     return this.a11yService.getAccessibleFor(rawNode);
   },
 
   async getAncestry(accessible) {
-    if (accessible.indexInParent === -1) {
+    if (!accessible || accessible.indexInParent === -1) {
       return [];
     }
     const doc = await this.getDocument();
     const ancestry = [];
     if (accessible === doc) {
       return ancestry;
     }
 
@@ -473,24 +485,33 @@ const AccessibleWalkerActor = ActorClass
    * @param  {Object} options
    *         Object used for passing options. Available options:
    *         - duration {Number}
    *                    Duration of time that the highlighter should be shown.
    * @return {Boolean}
    *         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;
   },
 
   /**
    * Public method used to hide an accessible object highlighter on the client
    * side.
    */
   unhighlight() {
     if (!this._highlighter) {
@@ -739,17 +760,18 @@ const AccessibleWalkerActor = ActorClass
     target.addEventListener("mouseleave", this._preventContentEvent, true);
     target.addEventListener("mouseenter", this._preventContentEvent, true);
     target.addEventListener("dblclick", this._preventContentEvent, true);
     target.addEventListener("keydown", this.onKey, true);
     target.addEventListener("keyup", this._preventContentEvent, true);
   },
 
   /**
-   * 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;
 
     if (!target) {
       return;
     }
 
@@ -794,19 +816,17 @@ const AccessibleWalkerActor = ActorClass
     this._currentTarget = null;
     this._currentTargetState = null;
   },
 
   /**
    * 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();
       this._isPicking = false;
       this._currentAccessible = null;
     }
   },
 });
--- a/devtools/server/actors/highlighters.css
+++ b/devtools/server/actors/highlighters.css
@@ -648,32 +648,49 @@
 }
 
 :-moz-native-anonymous .accessible-infobar-name,
 :-moz-native-anonymous .accessible-infobar-audit {
   color: var(--highlighter-infobar-color);
   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;
 }
 
 :-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),
 :-moz-native-anonymous .accessible-infobar-audit:not(:empty) {
   border-inline-start: 1px solid #5a6169;
   margin-inline-start: 6px;
   padding-inline-start: 6px;
 }
--- a/devtools/server/actors/highlighters/utils/accessibility.js
+++ b/devtools/server/actors/highlighters/utils/accessibility.js
@@ -470,42 +470,102 @@ class AuditReport {
  * inforbar,
  */
 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");
   }
 
   /**
    * Update contrast ratio score infobar markup.
    * @param  {Number}
    *         Contrast ratio for an accessible object being highlighted.
    * @return {Boolean}
    *         True if the contrast ratio markup was updated correctly and infobar audit
    *         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;
   }
 }
 
 /**
  * A helper function that calculate accessible object bounds and positioning to
  * be used for highlighting.
  *
@@ -558,25 +618,25 @@ function getBounds(win, { x, y, w, h, zo
   const height = bottom - top;
 
   return { left, right, top, bottom, width, height };
 }
 
 /**
  * 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) {
     style = "AAA";
   } else if (ratio >= levels.AA) {
     style = "AA";
   }
 
--- a/devtools/server/actors/highlighters/utils/markup.js
+++ b/devtools/server/actors/highlighters/utils/markup.js
@@ -158,35 +158,41 @@ exports.createSVGNode = createSVGNode;
  * @param {Object} Options for the node include:
  * - nodeType: the type of node, defaults to "div".
  * - namespace: the namespace to use to create the node, defaults to XHTML namespace.
  * - attributes: a {name:value} object to be used as attributes for the node.
  * - prefix: a string that will be used to prefix the values of the id and class
  *   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];
     if (options.prefix && (name === "class" || name === "id")) {
       value = options.prefix + value;
     }
     node.setAttribute(name, value);
   }
 
   if (options.parent) {
     options.parent.appendChild(node);
   }
 
+  if (options.text) {
+    node.appendChild(doc.createTextNode(options.text));
+  }
+
   return node;
 }
 exports.createNode = createNode;
 
 /**
  * Every highlighters should insert their markup content into the document's
  * canvasFrame anonymous content container (see dom/webidl/Document.webidl).
  *
--- a/devtools/server/actors/highlighters/xul-accessible.js
+++ b/devtools/server/actors/highlighters/xul-accessible.js
@@ -74,32 +74,49 @@ const ACCESSIBLE_BOUNDS_SHEET = "data:te
   }
 
   .accessible-infobar-name,
   .accessible-infobar-audit {
     color: hsl(210, 30%, 85%);
     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;
   }
 
   .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),
   .accessible-infobar-audit:not(:empty) {
     border-inline-start: 1px solid #5a6169;
     margin-inline-start: 6px;
     padding-inline-start: 6px;
   }
--- a/devtools/server/actors/utils/accessibility.js
+++ b/devtools/server/actors/utils/accessibility.js
@@ -2,70 +2,221 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 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,
   };
 }
 
 /**
  * Helper function that determines if nsIAccessible object is in defunct state.
  *
  * @param  {nsIAccessible}  accessible
  *         object to be tested.
--- a/devtools/shared/locales/en-US/accessibility.properties
+++ b/devtools/shared/locales/en-US/accessibility.properties
@@ -1,8 +1,16 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 # LOCALIZATION NOTE (accessibility.contrast.ratio): A title text for the color contrast
 # 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: