contrast.js (10153B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 loader.lazyRequireGetter( 8 this, 9 "CssLogic", 10 "resource://devtools/server/actors/inspector/css-logic.js", 11 true 12 ); 13 loader.lazyRequireGetter( 14 this, 15 "getCurrentZoom", 16 "resource://devtools/shared/layout/utils.js", 17 true 18 ); 19 loader.lazyRequireGetter( 20 this, 21 "addPseudoClassLock", 22 "resource://devtools/server/actors/highlighters/utils/markup.js", 23 true 24 ); 25 loader.lazyRequireGetter( 26 this, 27 "removePseudoClassLock", 28 "resource://devtools/server/actors/highlighters/utils/markup.js", 29 true 30 ); 31 loader.lazyRequireGetter( 32 this, 33 "getContrastRatioAgainstBackground", 34 "resource://devtools/shared/accessibility.js", 35 true 36 ); 37 loader.lazyRequireGetter( 38 this, 39 "getTextProperties", 40 "resource://devtools/shared/accessibility.js", 41 true 42 ); 43 loader.lazyRequireGetter( 44 this, 45 "InspectorActorUtils", 46 "resource://devtools/server/actors/inspector/utils.js" 47 ); 48 const lazy = {}; 49 ChromeUtils.defineESModuleGetters( 50 lazy, 51 { 52 DevToolsWorker: "resource://devtools/shared/worker/worker.sys.mjs", 53 }, 54 { global: "contextual" } 55 ); 56 57 const WORKER_URL = "resource://devtools/server/actors/accessibility/worker.js"; 58 const HIGHLIGHTED_PSEUDO_CLASS = ":-moz-devtools-highlighted"; 59 const { 60 LARGE_TEXT: { BOLD_LARGE_TEXT_MIN_PIXELS, LARGE_TEXT_MIN_PIXELS }, 61 } = require("resource://devtools/shared/accessibility.js"); 62 63 loader.lazyGetter(this, "worker", () => new lazy.DevToolsWorker(WORKER_URL)); 64 65 /** 66 * Get canvas rendering context for the current target window bound by the bounds of the 67 * accessible objects. 68 * 69 * @param {object} win 70 * Current target window. 71 * @param {object} bounds 72 * Bounds for the accessible object. 73 * @param {object} zoom 74 * Current zoom level for the window. 75 * @param {object} scale 76 * Scale value to scale down the drawn image. 77 * @param {null|DOMNode} node 78 * If not null, a node that corresponds to the accessible object to be used to 79 * make its text color transparent. 80 * @return {CanvasRenderingContext2D} 81 * Canvas rendering context for the current window. 82 */ 83 function getImageCtx(win, bounds, zoom, scale, node) { 84 const doc = win.document; 85 const canvas = doc.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); 86 87 const { left, top, width, height } = bounds; 88 canvas.width = width * zoom * scale; 89 canvas.height = height * zoom * scale; 90 const ctx = canvas.getContext("2d", { alpha: false }); 91 ctx.imageSmoothingEnabled = false; 92 ctx.scale(scale, scale); 93 94 // If node is passed, make its color related text properties invisible. 95 if (node) { 96 addPseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS); 97 } 98 99 ctx.drawWindow( 100 win, 101 left * zoom, 102 top * zoom, 103 width * zoom, 104 height * zoom, 105 "#fff", 106 ctx.DRAWWINDOW_USE_WIDGET_LAYERS 107 ); 108 109 // Restore all inline styling. 110 if (node) { 111 removePseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS); 112 } 113 114 return ctx; 115 } 116 117 /** 118 * Calculate the transformed RGBA when a color matrix is set in docShell by 119 * multiplying the color matrix with the RGBA vector. 120 * 121 * @param {Array} rgba 122 * Original RGBA array which we want to transform. 123 * @param {Array} colorMatrix 124 * Flattened 4x5 color matrix that is set in docShell. 125 * A 4x5 matrix of the form: 126 * 1 2 3 4 5 127 * 6 7 8 9 10 128 * 11 12 13 14 15 129 * 16 17 18 19 20 130 * will be set in docShell as: 131 * [1, 6, 11, 16, 2, 7, 12, 17, 3, 8, 13, 18, 4, 9, 14, 19, 5, 10, 15, 20] 132 * @return {Array} 133 * Transformed RGBA after the color matrix is multiplied with the original RGBA. 134 */ 135 function getTransformedRGBA(rgba, colorMatrix) { 136 const transformedRGBA = [0, 0, 0, 0]; 137 138 // Only use the first four columns of the color matrix corresponding to R, G, B and A 139 // color channels respectively. The fifth column is a fixed offset that does not need 140 // to be considered for the matrix multiplication. We end up multiplying a 4x4 color 141 // matrix with a 4x1 RGBA vector. 142 for (let i = 0; i < 16; i++) { 143 const row = i % 4; 144 const col = Math.floor(i / 4); 145 transformedRGBA[row] += colorMatrix[i] * rgba[col]; 146 } 147 148 return transformedRGBA; 149 } 150 151 /** 152 * Find RGBA or a range of RGBAs for the background pixels under the text. 153 * 154 * @param {DOMNode} node 155 * Node for which we want to get the background color data. 156 * @param {object} options 157 * - bounds {object} 158 * Bounds for the accessible object. 159 * - win {object} 160 * Target window. 161 * - size {Number} 162 * Font size of the selected text node 163 * - isBoldText {Boolean} 164 * True if selected text node is bold 165 * @return {object} 166 * Object with one or more of the following RGBA fields: value, min, max 167 */ 168 function getBackgroundFor(node, { win, bounds, size, isBoldText }) { 169 const zoom = 1 / getCurrentZoom(win); 170 // When calculating colour contrast, we traverse image data for text nodes that are 171 // drawn both with and without transparent text. Image data arrays are typically really 172 // big. In cases when the font size is fairly large or when the page is zoomed in image 173 // data is especially large (retrieving it and/or traversing it takes significant amount 174 // of time). Here we optimize the size of the image data by scaling down the drawn nodes 175 // to a size where their text size equals either BOLD_LARGE_TEXT_MIN_PIXELS or 176 // LARGE_TEXT_MIN_PIXELS (lower threshold for large text size) depending on the font 177 // weight. 178 // 179 // IMPORTANT: this optimization, in some cases where background colour is non-uniform 180 // (gradient or image), can result in small (not noticeable) blending of the background 181 // colours. In turn this might affect the reported values of the contrast ratio. The 182 // delta is fairly small (<0.1) to noticeably skew the results. 183 // 184 // NOTE: this optimization does not help in cases where contrast is being calculated for 185 // nodes with a lot of text. 186 let scale = 187 ((isBoldText ? BOLD_LARGE_TEXT_MIN_PIXELS : LARGE_TEXT_MIN_PIXELS) / size) * 188 zoom; 189 // We do not need to scale the images if the font is smaller than large or if the page 190 // is zoomed out (scaling in this case would've been scaling up). 191 scale = scale > 1 ? 1 : scale; 192 193 const textContext = getImageCtx(win, bounds, zoom, scale); 194 const backgroundContext = getImageCtx(win, bounds, zoom, scale, node); 195 196 const { data: dataText } = textContext.getImageData( 197 0, 198 0, 199 bounds.width * scale, 200 bounds.height * scale 201 ); 202 const { data: dataBackground } = backgroundContext.getImageData( 203 0, 204 0, 205 bounds.width * scale, 206 bounds.height * scale 207 ); 208 209 return worker.performTask( 210 "getBgRGBA", 211 { 212 dataTextBuf: dataText.buffer, 213 dataBackgroundBuf: dataBackground.buffer, 214 }, 215 [dataText.buffer, dataBackground.buffer] 216 ); 217 } 218 219 /** 220 * Calculates the contrast ratio of the referenced DOM node. 221 * 222 * @param {DOMNode} node 223 * The node for which we want to calculate the contrast ratio. 224 * @param {object} options 225 * - bounds {object} 226 * Bounds for the accessible object. 227 * - win {object} 228 * Target window. 229 * - appliedColorMatrix {Array|null} 230 * Simulation color matrix applied to 231 * to the viewport, if it exists. 232 * @return {object} 233 * An object that may contain one or more of the following fields: error, 234 * isLargeText, value, min, max values for contrast. 235 */ 236 async function getContrastRatioFor(node, options = {}) { 237 const computedStyle = CssLogic.getComputedStyle(node); 238 const props = computedStyle ? getTextProperties(computedStyle) : null; 239 240 if (!props) { 241 return { 242 error: true, 243 }; 244 } 245 246 const { isLargeText, isBoldText, size, opacity } = props; 247 const { appliedColorMatrix } = options; 248 const color = appliedColorMatrix 249 ? getTransformedRGBA(props.color, appliedColorMatrix) 250 : props.color; 251 let rgba = await getBackgroundFor(node, { 252 ...options, 253 isBoldText, 254 size, 255 }); 256 257 if (!rgba) { 258 // Fallback (original) contrast calculation algorithm. It tries to get the 259 // closest background colour for the node and use it to calculate contrast. 260 const backgroundColor = InspectorActorUtils.getClosestBackgroundColor(node); 261 const backgroundImage = InspectorActorUtils.getClosestBackgroundImage(node); 262 263 if (backgroundImage !== "none") { 264 // Both approaches failed, at this point we don't have a better one yet. 265 return { 266 error: true, 267 }; 268 } 269 270 let { r, g, b, a } = InspectorUtils.colorToRGBA(backgroundColor); 271 // If the element has opacity in addition to background alpha value, take it 272 // into account. TODO: this does not handle opacity set on ancestor 273 // elements (see bug https://bugzilla.mozilla.org/show_bug.cgi?id=1544721). 274 if (opacity < 1) { 275 a = opacity * a; 276 } 277 278 return getContrastRatioAgainstBackground( 279 { 280 value: appliedColorMatrix 281 ? getTransformedRGBA([r, g, b, a], appliedColorMatrix) 282 : [r, g, b, a], 283 }, 284 { 285 color, 286 isLargeText, 287 } 288 ); 289 } 290 291 if (appliedColorMatrix) { 292 rgba = rgba.value 293 ? { 294 value: getTransformedRGBA(rgba.value, appliedColorMatrix), 295 } 296 : { 297 min: getTransformedRGBA(rgba.min, appliedColorMatrix), 298 max: getTransformedRGBA(rgba.max, appliedColorMatrix), 299 }; 300 } 301 302 return getContrastRatioAgainstBackground(rgba, { 303 color, 304 isLargeText, 305 }); 306 } 307 308 exports.getContrastRatioFor = getContrastRatioFor; 309 exports.getBackgroundFor = getBackgroundFor;