Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(79)

Side by Side Diff: ui/accessibility/extensions/caretbrowsing/accessibility_utils.js

Issue 606653005: Revert "Initial checkin of accessibility extensions." (Closed) Base URL: https://chromium.googlesource.com/chromium/src.git@master
Patch Set: Created 6 years, 2 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 // Copyright 2012 Google Inc.
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 //
7 // http://www.apache.org/licenses/LICENSE-2.0
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS,
11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 // See the License for the specific language governing permissions and
13 // limitations under the License.
14
15 axs = {};
16 axs.utils = {};
17
18 /**
19 * @constant
20 * @type {string}
21 */
22 axs.utils.FOCUSABLE_ELEMENTS_SELECTOR =
23 'input:not([type=hidden]):not([disabled]),' +
24 'select:not([disabled]),' +
25 'textarea:not([disabled]),' +
26 'button:not([disabled]),' +
27 'a[href],' +
28 'iframe,' +
29 '[tabindex]';
30
31 /**
32 * @constructor
33 * @param {number} red
34 * @param {number} green
35 * @param {number} blue
36 * @param {number} alpha
37 */
38 axs.utils.Color = function(red, green, blue, alpha) {
39 /** @type {number} */
40 this.red = red;
41
42 /** @type {number} */
43 this.green = green;
44
45 /** @type {number} */
46 this.blue = blue;
47
48 /** @type {number} */
49 this.alpha = alpha;
50 };
51
52 /**
53 * Calculate the contrast ratio between the two given colors. Returns the ratio
54 * to 1, for example for two two colors with a contrast ratio of 21:1, this
55 * function will return 21.
56 * @param {axs.utils.Color} fgColor
57 * @param {axs.utils.Color} bgColor
58 * @return {?number}
59 */
60 axs.utils.calculateContrastRatio = function(fgColor, bgColor) {
61 if (!fgColor || !bgColor)
62 return null;
63
64 if (fgColor.alpha < 1)
65 fgColor = axs.utils.flattenColors(fgColor, bgColor);
66
67 var fgLuminance = axs.utils.calculateLuminance(fgColor);
68 var bgLuminance = axs.utils.calculateLuminance(bgColor);
69 var contrastRatio = (Math.max(fgLuminance, bgLuminance) + 0.05) /
70 (Math.min(fgLuminance, bgLuminance) + 0.05);
71 return contrastRatio;
72 };
73
74 axs.utils.luminanceRatio = function(luminance1, luminance2) {
75 return (Math.max(luminance1, luminance2) + 0.05) /
76 (Math.min(luminance1, luminance2) + 0.05);
77 }
78
79 /**
80 * Returns the nearest ancestor which is an Element.
81 * @param {Node} node
82 * @return {Element}
83 */
84 axs.utils.parentElement = function(node) {
85 if (!node)
86 return null;
87 if (node.nodeType == Node.DOCUMENT_FRAGMENT_NODE)
88 return node.host;
89
90 var parentElement = node.parentElement;
91 if (parentElement)
92 return parentElement;
93
94 var parentNode = node.parentNode;
95 if (!parentNode)
96 return null;
97
98 switch (parentNode.nodeType) {
99 case Node.ELEMENT_NODE:
100 return /** @type {Element} */ (parentNode);
101 case Node.DOCUMENT_FRAGMENT_NODE:
102 return parentNode.host;
103 default:
104 return null;
105 }
106 };
107
108 /**
109 * Return the corresponding element for the given node.
110 * @param {Node} node
111 * @return {Element}
112 * @suppress {checkTypes}
113 */
114 axs.utils.asElement = function(node) {
115 /** @type {Element} */ var element;
116 switch (node.nodeType) {
117 case Node.COMMENT_NODE:
118 return null; // Skip comments
119 case Node.ELEMENT_NODE:
120 element = /** (@type {Element}) */ node;
121 if (element.tagName.toLowerCase() == 'script')
122 return null; // Skip script elements
123 break;
124 case Node.TEXT_NODE:
125 element = axs.utils.parentElement(node);
126 break;
127 default:
128 console.warn('Unhandled node type: ', node.nodeType);
129 return null;
130 }
131 return element;
132 }
133
134 /**
135 * @param {Element} element
136 * @return {boolean}
137 */
138 axs.utils.elementIsTransparent = function(element) {
139 return element.style.opacity == '0';
140 };
141
142 /**
143 * @param {Element} element
144 * @return {boolean}
145 */
146 axs.utils.elementHasZeroArea = function(element) {
147 var rect = element.getBoundingClientRect();
148 var width = rect.right - rect.left;
149 var height = rect.top - rect.bottom;
150 if (!width || !height)
151 return true;
152 return false;
153 };
154
155 /**
156 * @param {Element} element
157 * @return {boolean}
158 */
159 axs.utils.elementIsOutsideScrollArea = function(element) {
160 var parent = axs.utils.parentElement(element);
161
162 var defaultView = element.ownerDocument.defaultView;
163 while (parent != defaultView.document.body) {
164 if (axs.utils.isClippedBy(element, parent))
165 return true;
166
167 if (axs.utils.canScrollTo(element, parent) && !axs.utils.elementIsOutsid eScrollArea(parent))
168 return false;
169
170 parent = axs.utils.parentElement(parent);
171 }
172
173 return !axs.utils.canScrollTo(element, defaultView.document.body);
174 };
175
176 /**
177 * Checks whether it's possible to scroll to the given element within the given container.
178 * Assumes that |container| is an ancestor of |element|.
179 * If |container| cannot be scrolled, returns True if the element is within its bounding client
180 * rect.
181 * @param {Element} element
182 * @param {Element} container
183 * @return {boolean} True iff it's possible to scroll to |element| within |conta iner|.
184 */
185 axs.utils.canScrollTo = function(element, container) {
186 var rect = element.getBoundingClientRect();
187 var containerRect = container.getBoundingClientRect();
188 var containerTop = containerRect.top;
189 var containerLeft = containerRect.left;
190 var containerScrollArea =
191 { top: containerTop - container.scrollTop,
192 bottom: containerTop - container.scrollTop + container.scrollHeight,
193 left: containerLeft - container.scrollLeft,
194 right: containerLeft - container.scrollLeft + container.scrollWidth };
195
196 if (rect.right < containerScrollArea.left || rect.bottom < containerScrollAr ea.top ||
197 rect.left > containerScrollArea.right || rect.top > containerScrollA rea.bottom) {
198 return false;
199 }
200
201 var defaultView = element.ownerDocument.defaultView;
202 var style = defaultView.getComputedStyle(container);
203
204 if (rect.left > containerRect.right || rect.top > containerRect.bottom) {
205 return (style.overflow == 'scroll' || style.overflow == 'auto' ||
206 container instanceof defaultView.HTMLBodyElement);
207 }
208
209 return true;
210 };
211
212 /**
213 * Checks whether the given element is clipped by the given container.
214 * Assumes that |container| is an ancestor of |element|.
215 * @param {Element} element
216 * @param {Element} container
217 * @return {boolean} True iff |element| is clipped by |container|.
218 */
219 axs.utils.isClippedBy = function(element, container) {
220 var rect = element.getBoundingClientRect();
221 var containerRect = container.getBoundingClientRect();
222 var containerTop = containerRect.top;
223 var containerLeft = containerRect.left;
224 var containerScrollArea =
225 { top: containerTop - container.scrollTop,
226 bottom: containerTop - container.scrollTop + container.scrollHeight,
227 left: containerLeft - container.scrollLeft,
228 right: containerLeft - container.scrollLeft + container.scrollWidth };
229
230 var defaultView = element.ownerDocument.defaultView;
231 var style = defaultView.getComputedStyle(container);
232
233 if ((rect.right < containerRect.left || rect.bottom < containerRect.top ||
234 rect.left > containerRect.right || rect.top > containerRect.bottom) &&
235 style.overflow == 'hidden') {
236 return true;
237 }
238
239 if (rect.right < containerScrollArea.left || rect.bottom < containerScrollAr ea.top)
240 return (style.overflow != 'visible');
241
242 return false;
243 };
244
245 /**
246 * @param {Node} ancestor A potential ancestor of |node|.
247 * @param {Node} node
248 * @return {boolean} true if |ancestor| is an ancestor of |node| (including
249 * |ancestor| === |node|).
250 */
251 axs.utils.isAncestor = function(ancestor, node) {
252 if (node == null)
253 return false;
254 if (node === ancestor)
255 return true;
256
257 return axs.utils.isAncestor(ancestor, node.parentNode);
258 }
259
260 /**
261 * @param {Element} element
262 * @return {Array.<Element>} An array of any non-transparent elements which
263 * overlap the given element.
264 */
265 axs.utils.overlappingElements = function(element) {
266 if (axs.utils.elementHasZeroArea(element))
267 return null;
268
269 var overlappingElements = [];
270 var clientRects = element.getClientRects();
271 for (var i = 0; i < clientRects.length; i++) {
272 var rect = clientRects[i];
273 var center_x = (rect.left + rect.right) / 2;
274 var center_y = (rect.top + rect.bottom) / 2;
275 var elementAtPoint = document.elementFromPoint(center_x, center_y);
276
277 if (elementAtPoint == null || elementAtPoint == element ||
278 axs.utils.isAncestor(elementAtPoint, element) ||
279 axs.utils.isAncestor(element, elementAtPoint)) {
280 continue;
281 }
282
283 var overlappingElementStyle = window.getComputedStyle(elementAtPoint, nu ll);
284 if (!overlappingElementStyle)
285 continue;
286
287 var overlappingElementBg = axs.utils.getBgColor(overlappingElementStyle,
288 elementAtPoint);
289 if (overlappingElementBg && overlappingElementBg.alpha > 0 &&
290 overlappingElements.indexOf(elementAtPoint) < 0) {
291 overlappingElements.push(elementAtPoint);
292 }
293 }
294
295 return overlappingElements;
296 };
297
298 /**
299 * @param {Element} element
300 * @return boolean
301 */
302 axs.utils.elementIsHtmlControl = function(element) {
303 var defaultView = element.ownerDocument.defaultView;
304
305 // HTML control
306 if (element instanceof defaultView.HTMLButtonElement)
307 return true;
308 if (element instanceof defaultView.HTMLInputElement)
309 return true;
310 if (element instanceof defaultView.HTMLSelectElement)
311 return true;
312 if (element instanceof defaultView.HTMLTextAreaElement)
313 return true;
314
315 return false;
316 };
317
318 /**
319 * @param {Element} element
320 * @return boolean
321 */
322 axs.utils.elementIsAriaWidget = function(element) {
323 if (element.hasAttribute('role')) {
324 var roleValue = element.getAttribute('role');
325 // TODO is this correct?
326 if (roleValue) {
327 var role = axs.constants.ARIA_ROLES[roleValue];
328 if (role && 'widget' in role['allParentRolesSet'])
329 return true;
330 }
331 }
332 return false;
333 }
334
335 /**
336 * @param {Element} element
337 * @return {boolean}
338 */
339 axs.utils.elementIsVisible = function(element) {
340 if (axs.utils.elementIsTransparent(element))
341 return false;
342 if (axs.utils.elementHasZeroArea(element))
343 return false;
344 if (axs.utils.elementIsOutsideScrollArea(element))
345 return false;
346
347 var overlappingElements = axs.utils.overlappingElements(element);
348 if (overlappingElements.length)
349 return false;
350
351 return true;
352 };
353
354 /**
355 * @param {CSSStyleDeclaration} style
356 * @return {boolean}
357 */
358 axs.utils.isLargeFont = function(style) {
359 var fontSize = style.fontSize;
360 var bold = style.fontWeight == 'bold';
361 var matches = fontSize.match(/(\d+)px/);
362 if (matches) {
363 var fontSizePx = parseInt(matches[1], 10);
364 var bodyStyle = window.getComputedStyle(document.body, null);
365 var bodyFontSize = bodyStyle.fontSize;
366 matches = bodyFontSize.match(/(\d+)px/);
367 if (matches) {
368 var bodyFontSizePx = parseInt(matches[1], 10);
369 var boldLarge = bodyFontSizePx * 1.2;
370 var large = bodyFontSizePx * 1.5;
371 } else {
372 var boldLarge = 19.2;
373 var large = 24;
374 }
375 return (bold && fontSizePx >= boldLarge || fontSizePx >= large);
376 }
377 matches = fontSize.match(/(\d+)em/);
378 if (matches) {
379 var fontSizeEm = parseInt(matches[1], 10);
380 if (bold && fontSizeEm >= 1.2 || fontSizeEm >= 1.5)
381 return true;
382 return false;
383 }
384 matches = fontSize.match(/(\d+)%/);
385 if (matches) {
386 var fontSizePercent = parseInt(matches[1], 10);
387 if (bold && fontSizePercent >= 120 || fontSizePercent >= 150)
388 return true;
389 return false;
390 }
391 matches = fontSize.match(/(\d+)pt/);
392 if (matches) {
393 var fontSizePt = parseInt(matches[1], 10);
394 if (bold && fontSizePt >= 14 || fontSizePt >= 18)
395 return true;
396 return false;
397 }
398 return false;
399 };
400
401 /**
402 * @param {CSSStyleDeclaration} style
403 * @param {Element} element
404 * @return {?axs.utils.Color}
405 */
406 axs.utils.getBgColor = function(style, element) {
407 var bgColorString = style.backgroundColor;
408 var bgColor = axs.utils.parseColor(bgColorString);
409 if (!bgColor)
410 return null;
411
412 if (style.opacity < 1)
413 bgColor.alpha = bgColor.alpha * style.opacity
414
415 if (bgColor.alpha < 1) {
416 var parentBg = axs.utils.getParentBgColor(element);
417 if (parentBg == null)
418 return null;
419
420 bgColor = axs.utils.flattenColors(bgColor, parentBg);
421 }
422 return bgColor;
423 };
424
425 /**
426 * Gets the effective background color of the parent of |element|.
427 * @param {Element} element
428 * @return {?axs.utils.Color}
429 */
430 axs.utils.getParentBgColor = function(element) {
431 /** @type {Element} */ var parent = element;
432 var bgStack = [];
433 var foundSolidColor = null;
434 while (parent = axs.utils.parentElement(parent)) {
435 var computedStyle = window.getComputedStyle(parent, null);
436 if (!computedStyle)
437 continue;
438
439 var parentBg = axs.utils.parseColor(computedStyle.backgroundColor);
440 if (!parentBg)
441 continue;
442
443 if (computedStyle.opacity < 1)
444 parentBg.alpha = parentBg.alpha * computedStyle.opacity;
445
446 if (parentBg.alpha == 0)
447 continue;
448
449 bgStack.push(parentBg);
450
451 if (parentBg.alpha == 1) {
452 foundSolidColor = true;
453 break;
454 }
455 }
456
457 if (!foundSolidColor)
458 bgStack.push(new axs.utils.Color(255, 255, 255, 1));
459
460 var bg = bgStack.pop();
461 while (bgStack.length) {
462 var fg = bgStack.pop();
463 bg = axs.utils.flattenColors(fg, bg);
464 }
465 return bg;
466 }
467
468 /**
469 * @param {CSSStyleDeclaration} style
470 * @param {axs.utils.Color} bgColor The background color, which may come from
471 * another element (such as a parent element), for flattening into the
472 * foreground color.
473 * @return {?axs.utils.Color}
474 */
475 axs.utils.getFgColor = function(style, element, bgColor) {
476 var fgColorString = style.color;
477 var fgColor = axs.utils.parseColor(fgColorString);
478 if (!fgColor)
479 return null;
480
481 if (fgColor.alpha < 1)
482 fgColor = axs.utils.flattenColors(fgColor, bgColor);
483
484 if (style.opacity < 1) {
485 var parentBg = axs.utils.getParentBgColor(element);
486 fgColor.alpha = fgColor.alpha * style.opacity;
487 fgColor = axs.utils.flattenColors(fgColor, parentBg);
488 }
489
490 return fgColor;
491 };
492
493 /**
494 * @param {string} colorString The color string from CSS.
495 * @return {?axs.utils.Color}
496 */
497 axs.utils.parseColor = function(colorString) {
498 var rgbRegex = /^rgb\((\d+), (\d+), (\d+)\)$/;
499 var match = colorString.match(rgbRegex);
500
501 if (match) {
502 var r = parseInt(match[1], 10);
503 var g = parseInt(match[2], 10);
504 var b = parseInt(match[3], 10);
505 var a = 1
506 return new axs.utils.Color(r, g, b, a);
507 }
508
509 var rgbaRegex = /^rgba\((\d+), (\d+), (\d+), (\d*(\.\d+)?)\)/;
510 match = colorString.match(rgbaRegex);
511 if (match) {
512 var r = parseInt(match[1], 10);
513 var g = parseInt(match[2], 10);
514 var b = parseInt(match[3] ,10);
515 var a = parseFloat(match[4]);
516 return new axs.utils.Color(r, g, b, a);
517 }
518
519 return null;
520 };
521
522 /**
523 * @param {number} value The value of a color channel, 0 <= value <= 0xFF
524 * @return {string}
525 */
526 axs.utils.colorChannelToString = function(value) {
527 value = Math.round(value);
528 if (value <= 0xF)
529 return '0' + value.toString(16);
530 return value.toString(16);
531 };
532
533 /**
534 * @param {axs.utils.Color} color
535 * @return {string}
536 */
537 axs.utils.colorToString = function(color) {
538 if (color.alpha == 1) {
539 return '#' + axs.utils.colorChannelToString(color.red) +
540 axs.utils.colorChannelToString(color.green) + axs.utils.colorChannelToS tring(color.blue);
541 }
542 else
543 return 'rgba(' + [color.red, color.green, color.blue, color.alpha].join( ',') + ')';
544 };
545
546 axs.utils.luminanceFromContrastRatio = function(luminance, contrast, higher) {
547 if (higher) {
548 var newLuminance = (luminance + 0.05) * contrast - 0.05;
549 return newLuminance;
550 } else {
551 var newLuminance = (luminance + 0.05) / contrast - 0.05;
552 return newLuminance;
553 }
554 };
555
556 axs.utils.translateColor = function(ycc, luminance) {
557 var oldLuminance = ycc[0];
558 if (oldLuminance > luminance)
559 var endpoint = 0
560 else
561 var endpoint = 1;
562
563 var d = luminance - oldLuminance;
564 var scale = d / (endpoint - oldLuminance)
565
566 /** @type {Array.<number>} */ var translatedColor = [ luminance,
567 ycc[1] - ycc[1] * scal e,
568 ycc[2] - ycc[2] * scal e ];
569 var rgb = axs.utils.fromYCC(translatedColor);
570 return rgb;
571 };
572
573 /**
574 * @param {axs.utils.Color} fgColor
575 * @param {axs.utils.Color} bgColor
576 * @param {number} contrastRatio
577 * @param {CSSStyleDeclaration} style
578 * @return {Object}
579 */
580 axs.utils.suggestColors = function(bgColor, fgColor, contrastRatio, style) {
581 if (!axs.utils.isLowContrast(contrastRatio, style, true))
582 return null;
583 var colors = {};
584 var bgLuminance = axs.utils.calculateLuminance(bgColor);
585 var fgLuminance = axs.utils.calculateLuminance(fgColor);
586
587 var levelAAContrast = axs.utils.isLargeFont(style) ? 3.0 : 4.5;
588 var levelAAAContrast = axs.utils.isLargeFont(style) ? 4.5 : 7.0;
589 var fgLuminanceIsHigher = fgLuminance > bgLuminance;
590 var desiredFgLuminanceAA = axs.utils.luminanceFromContrastRatio(bgLuminance, levelAAContrast + 0.02, fgLuminanceIsHigher);
591 var desiredFgLuminanceAAA = axs.utils.luminanceFromContrastRatio(bgLuminance , levelAAAContrast + 0.02, fgLuminanceIsHigher);
592 var fgYCC = axs.utils.toYCC(fgColor);
593
594 if (axs.utils.isLowContrast(contrastRatio, style, false) &&
595 desiredFgLuminanceAA <= 1 && desiredFgLuminanceAA >= 0) {
596 var newFgColorAA = axs.utils.translateColor(fgYCC, desiredFgLuminanceAA) ;
597 var newContrastRatioAA = axs.utils.calculateContrastRatio(newFgColorAA, bgColor);
598 var newLuminance = axs.utils.calculateLuminance(newFgColorAA);
599 var suggestedColorsAA = {}
600 suggestedColorsAA['fg'] = axs.utils.colorToString(newFgColorAA);
601 suggestedColorsAA['bg'] = axs.utils.colorToString(bgColor);
602 suggestedColorsAA['contrast'] = newContrastRatioAA.toFixed(2);
603 colors['AA'] = suggestedColorsAA;
604 }
605 if (axs.utils.isLowContrast(contrastRatio, style, true) &&
606 desiredFgLuminanceAAA <= 1 && desiredFgLuminanceAAA >= 0) {
607 var newFgColorAAA = axs.utils.translateColor(fgYCC, desiredFgLuminanceAA A);
608 var newContrastRatioAAA = axs.utils.calculateContrastRatio(newFgColorAAA , bgColor);
609 var suggestedColorsAAA = {};
610 suggestedColorsAAA['fg'] = axs.utils.colorToString(newFgColorAAA);
611 suggestedColorsAAA['bg'] = axs.utils.colorToString(bgColor);
612 suggestedColorsAAA['contrast'] = newContrastRatioAAA.toFixed(2);
613 colors['AAA'] = suggestedColorsAAA;
614 }
615 var desiredBgLuminanceAA = axs.utils.luminanceFromContrastRatio(fgLuminance, levelAAContrast + 0.02, !fgLuminanceIsHigher);
616 var desiredBgLuminanceAAA = axs.utils.luminanceFromContrastRatio(fgLuminance , levelAAAContrast + 0.02, !fgLuminanceIsHigher);
617 var bgLuminanceBoundary = fgLuminanceIsHigher ? 0 : 1;
618 var bgYCC = axs.utils.toYCC(bgColor);
619
620 if (!('AA' in colors) && axs.utils.isLowContrast(contrastRatio, style, false ) &&
621 desiredBgLuminanceAA <= 1 && desiredBgLuminanceAA >= 0) {
622 var newBgColorAA = axs.utils.translateColor(bgYCC, desiredBgLuminanceAA) ;
623 var newContrastRatioAA = axs.utils.calculateContrastRatio(fgColor, newBg ColorAA);
624 var suggestedColorsAA = {};
625 suggestedColorsAA['bg'] = axs.utils.colorToString(newBgColorAA);
626 suggestedColorsAA['fg'] = axs.utils.colorToString(fgColor);
627 suggestedColorsAA['contrast'] = newContrastRatioAA.toFixed(2);
628 colors['AA'] = suggestedColorsAA;
629 }
630 if (!('AAA' in colors) && axs.utils.isLowContrast(contrastRatio, style, true ) &&
631 desiredBgLuminanceAAA <= 1 && desiredBgLuminanceAAA >= 0) {
632 var newBgColorAAA = axs.utils.translateColor(bgYCC, desiredBgLuminanceAA A);
633 var newContrastRatioAAA = axs.utils.calculateContrastRatio(fgColor, newB gColorAAA);
634 var suggestedColorsAAA = {};
635 suggestedColorsAAA['bg'] = axs.utils.colorToString(newBgColorAAA);
636 suggestedColorsAAA['fg'] = axs.utils.colorToString(fgColor);
637 suggestedColorsAAA['contrast'] = newContrastRatioAAA.toFixed(2);
638 colors['AAA'] = suggestedColorsAAA;
639 }
640 return colors;
641 }
642
643 /**
644 * Combine the two given color according to alpha blending.
645 * @param {axs.utils.Color} fgColor
646 * @param {axs.utils.Color} bgColor
647 * @return {axs.utils.Color}
648 */
649 axs.utils.flattenColors = function(fgColor, bgColor) {
650 var alpha = fgColor.alpha;
651 var r = ((1 - alpha) * bgColor.red) + (alpha * fgColor.red);
652 var g = ((1 - alpha) * bgColor.green) + (alpha * fgColor.green);
653 var b = ((1 - alpha) * bgColor.blue) + (alpha * fgColor.blue);
654 var a = fgColor.alpha + (bgColor.alpha * (1 - fgColor.alpha));
655
656 return new axs.utils.Color(r, g, b, a);
657 };
658
659 /**
660 * Calculate the luminance of the given color using the WCAG algorithm.
661 * @param {axs.utils.Color} color
662 * @return {number}
663 */
664 axs.utils.calculateLuminance = function(color) {
665 /* var rSRGB = color.red / 255;
666 var gSRGB = color.green / 255;
667 var bSRGB = color.blue / 255;
668
669 var r = rSRGB <= 0.03928 ? rSRGB / 12.92 : Math.pow(((rSRGB + 0.055)/1.055), 2.4);
670 var g = gSRGB <= 0.03928 ? gSRGB / 12.92 : Math.pow(((gSRGB + 0.055)/1.055), 2.4);
671 var b = bSRGB <= 0.03928 ? bSRGB / 12.92 : Math.pow(((bSRGB + 0.055)/1.055), 2.4);
672
673 return 0.2126 * r + 0.7152 * g + 0.0722 * b; */
674 var ycc = axs.utils.toYCC(color);
675 return ycc[0];
676 };
677
678 /**
679 * Returns an RGB to YCC conversion matrix for the given kR, kB constants.
680 * @param {number} kR
681 * @param {number} kB
682 * @return {Array.<Array.<number>>}
683 */
684 axs.utils.RGBToYCCMatrix = function(kR, kB) {
685 return [
686 [
687 kR,
688 (1 - kR - kB),
689 kB
690 ],
691 [
692 -kR/(2 - 2*kB),
693 (kR + kB - 1)/(2 - 2*kB),
694 (1 - kB)/(2 - 2*kB)
695 ],
696 [
697 (1 - kR)/(2 - 2*kR),
698 (kR + kB - 1)/(2 - 2*kR),
699 -kB/(2 - 2*kR)
700 ]
701 ];
702 }
703
704 /**
705 * Return the inverse of the given 3x3 matrix.
706 * @param {Array.<Array.<number>>} matrix
707 * @return Array.<Array.<number>> The inverse of the given matrix.
708 */
709 axs.utils.invert3x3Matrix = function(matrix) {
710 var a = matrix[0][0];
711 var b = matrix[0][1];
712 var c = matrix[0][2];
713 var d = matrix[1][0];
714 var e = matrix[1][1];
715 var f = matrix[1][2];
716 var g = matrix[2][0];
717 var h = matrix[2][1];
718 var k = matrix[2][2];
719
720 var A = (e*k - f*h);
721 var B = (f*g - d*k);
722 var C = (d*h - e*g);
723 var D = (c*h - b*k);
724 var E = (a*k - c*g);
725 var F = (g*b - a*h);
726 var G = (b*f - c*e);
727 var H = (c*d - a*f);
728 var K = (a*e - b*d);
729
730 var det = a * (e*k - f*h) - b * (k*d - f*g) + c * (d*h - e*g);
731 var z = 1/det;
732
733 return axs.utils.scalarMultiplyMatrix([
734 [ A, D, G ],
735 [ B, E, H ],
736 [ C, F, K ]
737 ], z);
738 };
739
740 axs.utils.scalarMultiplyMatrix = function(matrix, scalar) {
741 var result = [];
742 result[0] = [];
743 result[1] = [];
744 result[2] = [];
745
746 for (var i = 0; i < 3; i++) {
747 for (var j = 0; j < 3; j++) {
748 result[i][j] = matrix[i][j] * scalar;
749 }
750 }
751
752 return result;
753 }
754
755 axs.utils.kR = 0.2126;
756 axs.utils.kB = 0.0722;
757 axs.utils.YCC_MATRIX = axs.utils.RGBToYCCMatrix(axs.utils.kR, axs.utils.kB);
758 axs.utils.INVERTED_YCC_MATRIX = axs.utils.invert3x3Matrix(axs.utils.YCC_MATRIX);
759
760 /**
761 * Multiply the given color vector by the given transformation matrix.
762 * @param {Array.<Array.<number>>} matrix A 3x3 conversion matrix
763 * @param {Array.<number>} vector A 3-element color vector
764 * @return {Array.<number>} A 3-element color vector
765 */
766 axs.utils.convertColor = function(matrix, vector) {
767 var a = matrix[0][0];
768 var b = matrix[0][1];
769 var c = matrix[0][2];
770 var d = matrix[1][0];
771 var e = matrix[1][1];
772 var f = matrix[1][2];
773 var g = matrix[2][0];
774 var h = matrix[2][1];
775 var k = matrix[2][2];
776
777 var x = vector[0];
778 var y = vector[1];
779 var z = vector[2];
780
781 return [
782 a*x + b*y + c*z,
783 d*x + e*y + f*z,
784 g*x + h*y + k*z
785 ];
786 };
787
788 axs.utils.multiplyMatrices = function(matrix1, matrix2) {
789 var result = [];
790 result[0] = [];
791 result[1] = [];
792 result[2] = [];
793
794 for (var i = 0; i < 3; i++) {
795 for (var j = 0; j < 3; j++) {
796 result[i][j] = matrix1[i][0] * matrix2[0][j] +
797 matrix1[i][1] * matrix2[1][j] +
798 matrix1[i][2] * matrix2[2][j];
799 }
800 }
801 return result;
802 }
803
804 /**
805 * Convert a given RGB color to YCC.
806 */
807 axs.utils.toYCC = function(color) {
808 var rSRGB = color.red / 255;
809 var gSRGB = color.green / 255;
810 var bSRGB = color.blue / 255;
811
812 var r = rSRGB <= 0.03928 ? rSRGB / 12.92 : Math.pow(((rSRGB + 0.055)/1.055), 2.4);
813 var g = gSRGB <= 0.03928 ? gSRGB / 12.92 : Math.pow(((gSRGB + 0.055)/1.055), 2.4);
814 var b = bSRGB <= 0.03928 ? bSRGB / 12.92 : Math.pow(((bSRGB + 0.055)/1.055), 2.4);
815
816 return axs.utils.convertColor(axs.utils.YCC_MATRIX, [r, g, b]);
817 };
818
819 /**
820 * Convert a color from a YCC color (as a vector) to an RGB color
821 * @param {Array.<number>} yccColor
822 * @return {axs.utils.Color}
823 */
824 axs.utils.fromYCC = function(yccColor) {
825 var rgb = axs.utils.convertColor(axs.utils.INVERTED_YCC_MATRIX, yccColor);
826
827 var r = rgb[0];
828 var g = rgb[1];
829 var b = rgb[2];
830 var rSRGB = r <= 0.00303949 ? (r * 12.92) : (Math.pow(r, (1/2.4)) * 1.055) - 0.055;
831 var gSRGB = g <= 0.00303949 ? (g * 12.92) : (Math.pow(g, (1/2.4)) * 1.055) - 0.055;
832 var bSRGB = b <= 0.00303949 ? (b * 12.92) : (Math.pow(b, (1/2.4)) * 1.055) - 0.055;
833
834 var red = Math.min(Math.max(Math.round(rSRGB * 255), 0), 255);
835 var green = Math.min(Math.max(Math.round(gSRGB * 255), 0), 255);
836 var blue = Math.min(Math.max(Math.round(bSRGB * 255), 0), 255);
837
838 return new axs.utils.Color(red, green, blue, 1);
839 };
840
841 axs.utils.scalarMultiplyMatrix = function(matrix, scalar) {
842 var result = [];
843 result[0] = [];
844 result[1] = [];
845 result[2] = [];
846
847 for (var i = 0; i < 3; i++) {
848 for (var j = 0; j < 3; j++) {
849 result[i][j] = matrix[i][j] * scalar;
850 }
851 }
852
853 return result;
854 }
855
856 axs.utils.multiplyMatrices = function(matrix1, matrix2) {
857 var result = [];
858 result[0] = [];
859 result[1] = [];
860 result[2] = [];
861
862 for (var i = 0; i < 3; i++) {
863 for (var j = 0; j < 3; j++) {
864 result[i][j] = matrix1[i][0] * matrix2[0][j] +
865 matrix1[i][1] * matrix2[1][j] +
866 matrix1[i][2] * matrix2[2][j];
867 }
868 }
869 return result;
870 }
871
872 /**
873 * @param {Element} element
874 * @return {?number}
875 */
876 axs.utils.getContrastRatioForElement = function(element) {
877 var style = window.getComputedStyle(element, null);
878 return axs.utils.getContrastRatioForElementWithComputedStyle(style, element) ;
879 };
880
881 /**
882 * @param {CSSStyleDeclaration} style
883 * @param {Element} element
884 * @return {?number}
885 */
886 axs.utils.getContrastRatioForElementWithComputedStyle = function(style, element) {
887 if (axs.utils.isElementHidden(element))
888 return null;
889
890 var bgColor = axs.utils.getBgColor(style, element);
891 if (!bgColor)
892 return null;
893
894 var fgColor = axs.utils.getFgColor(style, element, bgColor);
895 if (!fgColor)
896 return null;
897
898 return axs.utils.calculateContrastRatio(fgColor, bgColor);
899 };
900
901 /**
902 * @param {Element} element
903 * @return {boolean}
904 */
905 axs.utils.isNativeTextElement = function(element) {
906 var tagName = element.tagName.toLowerCase();
907 var type = element.type ? element.type.toLowerCase() : '';
908 if (tagName == 'textarea')
909 return true;
910 if (tagName != 'input')
911 return false;
912
913 switch (type) {
914 case 'email':
915 case 'number':
916 case 'password':
917 case 'search':
918 case 'text':
919 case 'tel':
920 case 'url':
921 case '':
922 return true;
923 default:
924 return false;
925 }
926 };
927
928 /**
929 * @param {number} contrastRatio
930 * @param {CSSStyleDeclaration} style
931 * @param {boolean=} opt_strict Whether to use AA (false) or AAA (true) level
932 * @return {boolean}
933 */
934 axs.utils.isLowContrast = function(contrastRatio, style, opt_strict) {
935 // Round to nearest 0.1
936 var roundedContrastRatio = (Math.round(contrastRatio * 10) / 10);
937 if (!opt_strict) {
938 return roundedContrastRatio < 3.0 ||
939 (!axs.utils.isLargeFont(style) && roundedContrastRatio < 4.5);
940 } else {
941 return roundedContrastRatio < 4.5 ||
942 (!axs.utils.isLargeFont(style) && roundedContrastRatio < 7.0);
943 }
944 };
945
946 /**
947 * @param {Element} element
948 * @return {boolean}
949 */
950 axs.utils.hasLabel = function(element) {
951 var tagName = element.tagName.toLowerCase();
952 var type = element.type ? element.type.toLowerCase() : '';
953
954 if (element.hasAttribute('aria-label'))
955 return true;
956 if (element.hasAttribute('title'))
957 return true;
958 if (tagName == 'img' && element.hasAttribute('alt'))
959 return true;
960 if (tagName == 'input' && type == 'image' && element.hasAttribute('alt'))
961 return true;
962 if (tagName == 'input' && (type == 'submit' || type == 'reset'))
963 return true;
964
965 // There's a separate audit that makes sure this points to an actual element or elements.
966 if (element.hasAttribute('aria-labelledby'))
967 return true;
968
969 if (axs.utils.isNativeTextElement(element) && element.hasAttribute('placehol der'))
970 return true;
971
972 if (element.hasAttribute('id')) {
973 var labelsFor = document.querySelectorAll('label[for="' + element.id + ' "]');
974 if (labelsFor.length > 0)
975 return true;
976 }
977
978 var parent = axs.utils.parentElement(element);
979 while (parent) {
980 if (parent.tagName.toLowerCase() == 'label') {
981 var parentLabel = /** HTMLLabelElement */ parent;
982 if (parentLabel.control == element)
983 return true;
984 }
985 parent = axs.utils.parentElement(parent);
986 }
987 return false;
988 };
989
990 /**
991 * @param {Element} element An element to check.
992 * @return {boolean} True if the element is hidden from accessibility.
993 */
994 axs.utils.isElementHidden = function(element) {
995 if (!(element instanceof element.ownerDocument.defaultView.HTMLElement))
996 return false;
997
998 if (element.hasAttribute('chromevoxignoreariahidden'))
999 var chromevoxignoreariahidden = true;
1000
1001 var style = window.getComputedStyle(element, null);
1002 if (style.display == 'none' || style.visibility == 'hidden')
1003 return true;
1004
1005 if (element.hasAttribute('aria-hidden') &&
1006 element.getAttribute('aria-hidden').toLowerCase() == 'true') {
1007 return !chromevoxignoreariahidden;
1008 }
1009
1010 return false;
1011 };
1012
1013 /**
1014 * @param {Element} element An element to check.
1015 * @return {boolean} True if the element or one of its ancestors is
1016 * hidden from accessibility.
1017 */
1018 axs.utils.isElementOrAncestorHidden = function(element) {
1019 if (axs.utils.isElementHidden(element))
1020 return true;
1021
1022 if (axs.utils.parentElement(element))
1023 return axs.utils.isElementOrAncestorHidden(axs.utils.parentElement(eleme nt));
1024 else
1025 return false;
1026 };
1027
1028 /**
1029 * @param {Element} element An element to check
1030 * @return {boolean} True if the given element is an inline element, false
1031 * otherwise.
1032 */
1033 axs.utils.isInlineElement = function(element) {
1034 var tagName = element.tagName.toUpperCase();
1035 return axs.constants.InlineElements[tagName];
1036 }
1037
1038 /**
1039 * @param {Element} element
1040 * @return {Object|boolean}
1041 */
1042 axs.utils.getRoles = function(element) {
1043 if (!element.hasAttribute('role'))
1044 return false;
1045 var roleValue = element.getAttribute('role');
1046 var roleNames = roleValue.split(' ');
1047 var roles = []
1048 var valid = true;
1049 for (var i = 0; i < roleNames.length; i++) {
1050 var role = roleNames[i];
1051 if (axs.constants.ARIA_ROLES[role])
1052 roles.push({'name': role, 'details': axs.constants.ARIA_ROLES[role], 'valid': true});
1053 else {
1054 roles.push({'name': role, 'valid': false});
1055 valid = false;
1056 }
1057 }
1058
1059 return { 'roles': roles, 'valid': valid };
1060 };
1061
1062 /**
1063 * @param {!string} propertyName
1064 * @param {!string} value
1065 * @param {!Element} element
1066 * @return {!Object}
1067 */
1068 axs.utils.getAriaPropertyValue = function(propertyName, value, element) {
1069 var propertyKey = propertyName.replace(/^aria-/, '');
1070 var property = axs.constants.ARIA_PROPERTIES[propertyKey];
1071 var result = { 'name': propertyName, 'rawValue': value };
1072 if (!property) {
1073 result.valid = false;
1074 result.reason = '"' + propertyName + '" is not a valid ARIA property';
1075 return result;
1076 }
1077
1078 var propertyType = property.valueType;
1079 if (!propertyType) {
1080 result.valid = false;
1081 result.reason = '"' + propertyName + '" is not a valid ARIA property';
1082 return result;
1083 }
1084
1085 switch (propertyType) {
1086 case "idref":
1087 var isValid = axs.utils.isValidIDRefValue(value, element);
1088 result.valid = isValid.valid;
1089 result.reason = isValid.reason;
1090 result.idref = isValid.idref;
1091 case "idref_list":
1092 var idrefValues = value.split(/\s+/);
1093 result.valid = true;
1094 for (var i = 0; i < idrefValues.length; i++) {
1095 var refIsValid = axs.utils.isValidIDRefValue(idrefValues[i], elemen t);
1096 if (!refIsValid.valid)
1097 result.valid = false;
1098 if (result.values)
1099 result.values.push(refIsValid);
1100 else
1101 result.values = [refIsValid];
1102 }
1103 return result;
1104 case "integer":
1105 case "decimal":
1106 var validNumber = axs.utils.isValidNumber(value);
1107 if (!validNumber.valid) {
1108 result.valid = false;
1109 result.reason = validNumber.reason;
1110 return result;
1111 }
1112 if (Math.floor(validNumber.value) != validNumber.value) {
1113 result.valid = false;
1114 result.reason = '' + value + ' is not a whole integer';
1115 } else {
1116 result.valid = true;
1117 result.value = validNumber.value;
1118 }
1119 return result;
1120 case "number":
1121 var validNumber = axs.utils.isValidNumber(value);
1122 if (validNumber.valid) {
1123 result.valid = true;
1124 result.value = validNumber.value;
1125 }
1126 case "string":
1127 result.valid = true;
1128 result.value = value;
1129 return result;
1130 case "token":
1131 var validTokenValue = axs.utils.isValidTokenValue(propertyName, value.to LowerCase());
1132 if (validTokenValue.valid) {
1133 result.valid = true;
1134 result.value = validTokenValue.value;
1135 return result;
1136 } else {
1137 result.valid = false;
1138 result.value = value;
1139 result.reason = validTokenValue.reason;
1140 return result;
1141 }
1142 case "token_list":
1143 var tokenValues = value.split(/\s+/);
1144 result.valid = true;
1145 for (var i = 0; i < tokenValues.length; i++) {
1146 var validTokenValue = axs.utils.isValidTokenValue(propertyName, toke nValues[i].toLowerCase());
1147 if (!validTokenValue.valid) {
1148 result.valid = false;
1149 if (result.reason) {
1150 result.reason = [ result.reason ];
1151 result.reason.push(validTokenValue.reason);
1152 } else {
1153 result.reason = validTokenValue.reason;
1154 result.possibleValues = validTokenValue.possibleValues;
1155 }
1156 }
1157 // TODO (more structured result)
1158 if (result.values)
1159 result.values.push(validTokenValue.value);
1160 else
1161 result.values = [validTokenValue.value];
1162 }
1163 return result;
1164 case "tristate":
1165 var validTristate = axs.utils.isPossibleValue(value.toLowerCase(), axs.c onstants.MIXED_VALUES, propertyName);
1166 if (validTristate.valid) {
1167 result.valid = true;
1168 result.value = validTristate.value;
1169 } else {
1170 result.valid = false;
1171 result.value = value;
1172 result.reason = validTristate.reason;
1173 }
1174 return result;
1175 case "boolean":
1176 var validBoolean = axs.utils.isValidBoolean(value);
1177 if (validBoolean.valid) {
1178 result.valid = true;
1179 result.value = validBoolean.value;
1180 } else {
1181 result.valid = false;
1182 result.value = value;
1183 result.reason = validBoolean.reason;
1184 }
1185 return result;
1186 }
1187 result.valid = false
1188 result.reason = 'Not a valid ARIA property';
1189 return result;
1190 };
1191
1192 /**
1193 * @param {string} propertyName The name of the property.
1194 * @param {string} value The value to check.
1195 * @return {!Object}
1196 */
1197 axs.utils.isValidTokenValue = function(propertyName, value) {
1198 var propertyKey = propertyName.replace(/^aria-/, '');
1199 var propertyDetails = axs.constants.ARIA_PROPERTIES[propertyKey];
1200 var possibleValues = propertyDetails.valuesSet;
1201 return axs.utils.isPossibleValue(value, possibleValues, propertyName);
1202 };
1203
1204 /**
1205 * @param {string} value
1206 * @param {Object.<string, boolean>} possibleValues
1207 * @return {!Object}
1208 */
1209 axs.utils.isPossibleValue = function(value, possibleValues, propertyName) {
1210 if (!possibleValues[value])
1211 return { 'valid': false,
1212 'value': value,
1213 'reason': '"' + value + '" is not a valid value for ' + propert yName,
1214 'possibleValues': Object.keys(possibleValues) };
1215 return { 'valid': true, 'value': value };
1216 };
1217
1218 /**
1219 * @param {string} value
1220 * @return {!Object}
1221 */
1222 axs.utils.isValidBoolean = function(value) {
1223 try {
1224 var parsedValue = JSON.parse(value);
1225 } catch (e) {
1226 parsedValue = '';
1227 }
1228 if (typeof(parsedValue) != 'boolean')
1229 return { 'valid': false,
1230 'value': value,
1231 'reason': '"' + value + '" is not a true/false value' };
1232 return { 'valid': true, 'value': parsedValue };
1233 };
1234
1235 /**
1236 * @param {string} value
1237 * @param {!Element} element
1238 * @return {!Object}
1239 */
1240 axs.utils.isValidIDRefValue = function(value, element) {
1241 if (value.length == 0)
1242 return { 'valid': true, 'idref': value };
1243 if (!element.ownerDocument.getElementById(value))
1244 return { 'valid': false,
1245 'idref': value,
1246 'reason': 'No element with ID "' + value + '"' };
1247 return { 'valid': true, 'idref': value };
1248 };
1249
1250 /**
1251 * @param {string} value
1252 * @return {!Object}
1253 */
1254 axs.utils.isValidNumber = function(value) {
1255 try {
1256 var parsedValue = JSON.parse(value);
1257 } catch (ex) {
1258 return { 'valid': false,
1259 'value': value,
1260 'reason': '"' + value + '" is not a number' };
1261 }
1262 if (typeof(parsedValue) != 'number') {
1263 return { 'valid': false,
1264 'value': value,
1265 'reason': '"' + value + '" is not a number' };
1266 }
1267 return { 'valid': true, 'value': parsedValue };
1268 };
1269
1270 /**
1271 * @param {Element} element
1272 * @return {boolean}
1273 */
1274 axs.utils.isElementImplicitlyFocusable = function(element) {
1275 var defaultView = element.ownerDocument.defaultView;
1276
1277 if (element instanceof defaultView.HTMLAnchorElement ||
1278 element instanceof defaultView.HTMLAreaElement)
1279 return element.hasAttribute('href');
1280 if (element instanceof defaultView.HTMLInputElement ||
1281 element instanceof defaultView.HTMLSelectElement ||
1282 element instanceof defaultView.HTMLTextAreaElement ||
1283 element instanceof defaultView.HTMLButtonElement ||
1284 element instanceof defaultView.HTMLIFrameElement)
1285 return !element.disabled;
1286 return false;
1287 };
1288
1289 /**
1290 * Returns an array containing the values of the given JSON-compatible object.
1291 * (Simply ignores any function values.)
1292 * @param {Object} obj
1293 * @return {Array}
1294 */
1295 axs.utils.values = function(obj) {
1296 var values = [];
1297 for (var key in obj) {
1298 if (obj.hasOwnProperty(key) && typeof obj[key] != 'function')
1299 values.push(obj[key]);
1300 }
1301 return values;
1302 };
1303
1304 /**
1305 * Returns an object containing the same keys and values as the given
1306 * JSON-compatible object. (Simply ignores any function values.)
1307 * @param {Object} obj
1308 * @return {Object}
1309 */
1310 axs.utils.namedValues = function(obj) {
1311 var values = {};
1312 for (var key in obj) {
1313 if (obj.hasOwnProperty(key) && typeof obj[key] != 'function')
1314 values[key] = obj[key];
1315 }
1316 return values
1317 };
1318
1319 /** Gets a CSS selector text for a DOM object.
1320 * @param {Node} obj The DOM object.
1321 * @return {string} CSS selector text for the DOM object.
1322 */
1323 axs.utils.getQuerySelectorText = function(obj) {
1324 if (obj == null || obj.tagName == 'HTML') {
1325 return 'html';
1326 } else if (obj.tagName == 'BODY') {
1327 return 'body';
1328 }
1329
1330 if (obj.hasAttribute) {
1331 if (obj.id) {
1332 return '#' + obj.id;
1333 }
1334
1335 if (obj.className) {
1336 var selector = '';
1337 for (var i = 0; i < obj.classList.length; i++)
1338 selector += '.' + obj.classList[i];
1339
1340 var total = 0;
1341 if (obj.parentNode) {
1342 for (i = 0; i < obj.parentNode.children.length; i++) {
1343 var similar = obj.parentNode.children[i];
1344 if (axs.browserUtils.matchSelector(similar, selector))
1345 total++;
1346 if (similar === obj)
1347 break;
1348 }
1349 } else {
1350 total = 1;
1351 }
1352
1353 if (total == 1) {
1354 return axs.utils.getQuerySelectorText(obj.parentNode) +
1355 ' > ' + selector;
1356 }
1357 }
1358
1359 if (obj.parentNode) {
1360 var similarTags = obj.parentNode.children;
1361 var total = 1;
1362 var i = 0;
1363 while (similarTags[i] !== obj) {
1364 if (similarTags[i].tagName == obj.tagName) {
1365 total++;
1366 }
1367 i++;
1368 }
1369
1370 var next = '';
1371 if (obj.parentNode.tagName != 'BODY') {
1372 next = axs.utils.getQuerySelectorText(obj.parentNode) +
1373 ' > ';
1374 }
1375
1376 if (total == 1) {
1377 return next +
1378 obj.tagName;
1379 } else {
1380 return next +
1381 obj.tagName +
1382 ':nth-of-type(' + total + ')';
1383 }
1384 }
1385
1386 } else if (obj.selectorText) {
1387 return obj.selectorText;
1388 }
1389
1390 return '';
1391 };
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698