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

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Yura Zenevich 2018-11-16 03:59:08 +00:00
Родитель 8d7c6d41ca
Коммит aa155585ea
8 изменённых файлов: 343 добавлений и 57 удалений

Просмотреть файл

@ -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,
});
},
/**

Просмотреть файл

@ -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();

Просмотреть файл

@ -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),

Просмотреть файл

@ -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) {

Просмотреть файл

@ -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;

Просмотреть файл

@ -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),

Просмотреть файл

@ -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,
};
}

Просмотреть файл

@ -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: