1 // Copyright 2014 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
6 * @fileoverview Provides output services for ChromeVox.
9 goog.provide('Output');
10 goog.provide('Output.EventType');
12 goog.require('AutomationUtil.Dir');
13 goog.require('cursors.Cursor');
14 goog.require('cursors.Range');
15 goog.require('cursors.Unit');
16 goog.require('cvox.AbstractEarcons');
17 goog.require('cvox.NavBraille');
18 goog.require('cvox.Spannable');
19 goog.require('cvox.ValueSelectionSpan');
20 goog.require('cvox.ValueSpan');
22 goog.scope(function() {
23 var Dir = AutomationUtil.Dir;
26 * An Output object formats a cursors.Range into speech, braille, or both
27 * representations. This is typically a cvox.Spannable.
29 * The translation from Range to these output representations rely upon format
30 * rules which specify how to convert AutomationNode objects into annotated
32 * The format of these rules is as follows.
34 * $ prefix: used to substitute either an attribute or a specialized value from
35 * an AutomationNode. Specialized values include role and state. Attributes
36 * available for substitution are AutomationNode.prototype.attributes and
37 * AutomationNode.prototype.state.
38 * For example, $value $role $enabled
39 * @ prefix: used to substitute a message. Note the ability to specify params to
40 * the message. For example, '@tag_html' '@selected_index($text_sel_start,
42 * = suffix: used to specify substitution only if not previously appended.
43 * For example, $name= would insert the name attribute only if no name
44 * attribute had been inserted previously.
48 // TODO(dtseng): Include braille specific rules.
49 /** @type {!cvox.Spannable} */
50 this.buffer_ = new cvox.Spannable();
51 /** @type {!cvox.Spannable} */
52 this.brailleBuffer_ = new cvox.Spannable();
53 /** @type {!Array<Object>} */
55 /** @type {function()} */
56 this.speechStartCallback_;
57 /** @type {function()} */
58 this.speechEndCallback_;
61 * Current global options.
62 * @type {{speech: boolean, braille: boolean, location: boolean}}
64 this.formatOptions_ = {speech: true, braille: false, location: true};
67 * Speech properties to apply to the entire output.
68 * @type {!Object<string, *>}
70 this.speechProperties_ = {};
74 * Delimiter to use between output values.
80 * Rules specifying format of AutomationNodes for output.
81 * @type {!Object<string, Object<string, Object<string, string>>>}
86 speak: '$name $role $value',
90 speak: '!doNotInterrupt ' +
91 '@aria_role_alert $name $earcon(ALERT_NONMODAL) $descendants'
94 speak: '$name $earcon(BUTTON, @tag_button)'
97 speak: '$if($checked, @describe_checkbox_checked($name), ' +
98 '@describe_checkbox_unchecked($name)) ' +
100 '$earcon(CHECK_ON, @input_type_checkbox), ' +
101 '$earcon(CHECK_OFF, @input_type_checkbox))'
107 enter: '@aria_role_heading',
108 speak: '@aria_role_heading $name='
114 enter: '$name= $visited $earcon(LINK, @tag_link)=',
115 stay: '$name= $visited @tag_link',
116 speak: '$name= $visited $earcon(LINK, @tag_link)='
125 speak: '$if($haspopup, @describe_menu_item_with_submenu($name), ' +
126 '@describe_menu_item($name)) ' +
127 '@describe_index($indexInParent, $parentChildCount)'
130 speak: '$name $value @aria_role_menuitem ' +
131 '@describe_index($indexInParent, $parentChildCount)'
137 speak: '$value $name @tag_button @aria_has_popup $earcon(LISTBOX) ' +
138 '$if($collapsed, @aria_expanded_false, @aria_expanded_true)'
141 speak: '$if($checked, @describe_radio_selected($name), ' +
142 '@describe_radio_unselected($name)) ' +
144 '$earcon(CHECK_ON, @input_type_radio), ' +
145 '$earcon(CHECK_OFF, @input_type_radio))'
148 speak: '@describe_slider($value, $name)'
154 speak: '$name $value $earcon(EDITABLE_TEXT, @input_type_text)'
157 speak: '@describe_tab($name)'
160 speak: '$name $value $earcon(EDITABLE_TEXT, @input_type_text) $protected'
167 speak: '@describe_window($name) $earcon(OBJECT_OPEN)'
172 speak: '@chrome_menu_opened($name) $role $earcon(OBJECT_OPEN)'
177 speak: '$earcon(OBJECT_CLOSE)'
180 menuListValueChanged: {
182 speak: '$value $name ' +
183 '$find({"state": {"selected": true, "invisible": false}}, ' +
184 '@describe_index($indexInParent, $parentChildCount)) '
189 speak: '!doNotInterrupt ' +
190 '@aria_role_alert $name $earcon(ALERT_NONMODAL) $descendants'
196 * Alias equivalent attributes.
197 * @type {!Object<string, string>}
199 Output.ATTRIBUTE_ALIAS = {
205 * Custom actions performed while rendering an output string.
206 * @param {function()} action
209 Output.Action = function(action) {
210 this.action_ = action;
213 Output.Action.prototype = {
220 * Annotation for selection.
221 * @param {number} startIndex
222 * @param {number} endIndex
225 Output.SelectionSpan = function(startIndex, endIndex) {
226 // TODO(dtseng): Direction lost below; should preserve for braille panning.
227 this.startIndex = startIndex < endIndex ? startIndex : endIndex;
228 this.endIndex = endIndex > startIndex ? endIndex : startIndex;
232 * Possible events handled by ChromeVox internally.
241 * Gets the output buffer for speech.
242 * @return {!cvox.Spannable}
244 getBuffer: function() {
249 * Specify ranges for speech.
250 * @param {!cursors.Range} range
251 * @param {cursors.Range} prevRange
252 * @param {chrome.automation.EventType|Output.EventType} type
255 withSpeech: function(range, prevRange, type) {
256 this.formatOptions_ = {speech: true, braille: false, location: true};
257 this.render_(range, prevRange, type, this.buffer_);
262 * Specify ranges for braille.
263 * @param {!cursors.Range} range
264 * @param {cursors.Range} prevRange
265 * @param {chrome.automation.EventType|Output.EventType} type
268 withBraille: function(range, prevRange, type) {
269 this.formatOptions_ = {speech: false, braille: true, location: false};
270 this.render_(range, prevRange, type, this.brailleBuffer_);
275 * Specify the same ranges for speech and braille.
276 * @param {!cursors.Range} range
277 * @param {cursors.Range} prevRange
278 * @param {chrome.automation.EventType|Output.EventType} type
281 withSpeechAndBraille: function(range, prevRange, type) {
282 this.withSpeech(range, prevRange, type);
283 this.withBraille(range, prevRange, type);
288 * Triggers callback for a speech event.
289 * @param {function()} callback
291 onSpeechStart: function(callback) {
292 this.speechStartCallback_ = callback;
297 * Triggers callback for a speech event.
298 * @param {function()} callback
300 onSpeechEnd: function(callback) {
301 this.speechEndCallback_ = callback;
306 * Executes all specified output.
310 var buff = this.buffer_;
311 if (buff.toString()) {
312 if (this.speechStartCallback_)
313 this.speechProperties_['startCallback'] = this.speechStartCallback_;
314 if (this.speechEndCallback_) {
315 this.speechProperties_['endCallback'] = this.speechEndCallback_;
318 cvox.ChromeVox.tts.speak(
319 buff.toString(), cvox.QueueMode.FLUSH, this.speechProperties_);
322 var actions = buff.getSpansInstanceOf(Output.Action);
324 actions.forEach(function(a) {
331 this.brailleBuffer_.getSpanInstanceOf(Output.SelectionSpan);
332 var startIndex = -1, endIndex = -1;
334 var valueStart = this.brailleBuffer_.getSpanStart(selSpan);
335 var valueEnd = this.brailleBuffer_.getSpanEnd(selSpan);
336 if (valueStart === undefined || valueEnd === undefined) {
340 startIndex = valueStart + selSpan.startIndex;
341 endIndex = valueStart + selSpan.endIndex;
342 this.brailleBuffer_.setSpan(new cvox.ValueSpan(valueStart),
343 valueStart, valueEnd);
344 this.brailleBuffer_.setSpan(new cvox.ValueSelectionSpan(),
345 startIndex, endIndex);
349 var output = new cvox.NavBraille({
350 text: this.brailleBuffer_,
351 startIndex: startIndex,
355 if (this.brailleBuffer_)
356 cvox.ChromeVox.braille.write(output);
359 chrome.accessibilityPrivate.setFocusRing(this.locations_);
363 * Renders the given range using optional context previous range and event
365 * @param {!cursors.Range} range
366 * @param {cursors.Range} prevRange
367 * @param {chrome.automation.EventType|string} type
368 * @param {!cvox.Spannable} buff Buffer to receive rendered output.
371 render_: function(range, prevRange, type, buff) {
372 if (range.isSubNode())
373 this.subNode_(range, prevRange, type, buff);
375 this.range_(range, prevRange, type, buff);
379 * Format the node given the format specifier.
380 * @param {!chrome.automation.AutomationNode} node
381 * @param {string|!Object} format The output format either specified as an
382 * output template string or a parsed output format tree.
383 * @param {!Object=} opt_exclude A set of attributes to exclude.
386 format_: function(node, format, buff, opt_exclude) {
387 opt_exclude = opt_exclude || {};
391 // Hacky way to support args.
392 if (typeof(format) == 'string') {
393 format = format.replace(/([,:])\W/g, '$1');
394 tokens = format.split(' ');
399 tokens.forEach(function(token) {
400 // Ignore empty tokens.
406 if (typeof(token) == 'string')
407 tree = this.createParseTree(token);
411 // Obtain the operator token.
414 // Set suffix options.
416 options.ifEmpty = token[token.length - 1] == '=';
418 token = token.substring(0, token.length - 1);
420 // Process token based on prefix.
421 var prefix = token[0];
422 token = token.slice(1);
424 if (opt_exclude[token])
427 // All possible tokens based on prefix.
429 options.annotation = token;
430 if (token == 'role') {
431 // Non-localized role and state obtained by default.
432 this.addToSpannable_(buff, node.role, options);
433 } else if (token == 'value') {
434 var text = node.attributes.value;
436 var offset = buff.getLength();
437 if (node.attributes.textSelStart !== undefined) {
438 options.annotation = new Output.SelectionSpan(
439 node.attributes.textSelStart,
440 node.attributes.textSelEnd);
442 } else if (node.role == chrome.automation.RoleType.staticText) {
443 // TODO(dtseng): Remove once Blink treats staticText values as
445 text = node.attributes.name;
447 this.addToSpannable_(buff, text, options);
448 } else if (token == 'indexInParent') {
449 this.addToSpannable_(buff, node.indexInParent + 1);
450 } else if (token == 'parentChildCount') {
452 this.addToSpannable_(buff, node.parent.children.length);
453 } else if (token == 'state') {
454 Object.getOwnPropertyNames(node.state).forEach(function(s) {
455 this.addToSpannable_(buff, s, options);
457 } else if (token == 'find') {
458 // Find takes two arguments: JSON query string and format string.
459 if (tree.firstChild) {
460 var jsonQuery = tree.firstChild.value;
462 /** @type {Object}*/(JSON.parse(jsonQuery)));
463 var formatString = tree.firstChild.nextSibling;
465 this.format_(node, formatString, buff);
467 } else if (token == 'descendants') {
468 if (AutomationPredicate.leaf(node))
471 // Construct a range to the leftmost and rightmost leaves.
472 var leftmost = AutomationUtil.findNodePre(
473 node, Dir.FORWARD, AutomationPredicate.leaf);
474 var rightmost = AutomationUtil.findNodePre(
475 node, Dir.BACKWARD, AutomationPredicate.leaf);
476 if (!leftmost || !rightmost)
479 var subrange = new cursors.Range(
480 new cursors.Cursor(leftmost, 0),
481 new cursors.Cursor(rightmost, 0));
482 this.range_(subrange, null, 'navigate', buff);
483 } else if (node.attributes[token]) {
484 this.addToSpannable_(buff, node.attributes[token], options);
485 } else if (node.state[token]) {
486 this.addToSpannable_(buff, token, options);
487 } else if (tree.firstChild) {
490 var cond = tree.firstChild;
491 var attrib = cond.value.slice(1);
492 if (node.attributes[attrib] || node.state[attrib])
493 this.format_(node, cond.nextSibling, buff);
495 this.format_(node, cond.nextSibling.nextSibling, buff);
496 } else if (token == 'earcon') {
497 var contentBuff = new cvox.Spannable();
498 if (tree.firstChild.nextSibling)
499 this.format_(node, tree.firstChild.nextSibling, contentBuff);
500 options.annotation = new Output.Action(function() {
501 cvox.ChromeVox.earcons.playEarcon(
502 cvox.AbstractEarcons[tree.firstChild.value]);
504 this.addToSpannable_(buff, contentBuff, options);
507 } else if (prefix == '@') {
510 var curMsg = tree.firstChild;
513 var arg = curMsg.value;
515 console.error('Unexpected value: ' + arg);
518 var msgBuff = new cvox.Spannable();
519 this.format_(node, arg, msgBuff);
520 msgArgs.push(msgBuff.toString());
521 curMsg = curMsg.nextSibling;
523 var msg = cvox.ChromeVox.msgs.getMsg(msgId, msgArgs);
525 if (this.formatOptions_.braille)
526 msg = cvox.ChromeVox.msgs.getMsg(msgId + '_brl', msgArgs) || msg;
530 this.addToSpannable_(buff, msg, options);
532 } else if (prefix == '!') {
533 this.speechProperties_[token] = true;
539 * @param {!cursors.Range} range
540 * @param {cursors.Range} prevRange
541 * @param {chrome.automation.EventType|string} type
542 * @param {!cvox.Spannable} rangeBuff
545 range_: function(range, prevRange, type, rangeBuff) {
547 prevRange = cursors.Range.fromNode(range.getStart().getNode().root);
549 var cursor = range.getStart();
550 var prevNode = prevRange.getStart().getNode();
552 var formatNodeAndAncestors = function(node, prevNode) {
553 var buff = new cvox.Spannable();
554 this.ancestry_(node, prevNode, type, buff);
555 this.node_(node, prevNode, type, buff);
556 if (this.formatOptions_.location)
557 this.locations_.push(node.location);
561 while (cursor.getNode() != range.getEnd().getNode()) {
562 var node = cursor.getNode();
563 this.addToSpannable_(
564 rangeBuff, formatNodeAndAncestors(node, prevNode));
566 cursor = cursor.move(cursors.Unit.NODE,
567 cursors.Movement.DIRECTIONAL,
570 var lastNode = range.getEnd().getNode();
571 this.addToSpannable_(rangeBuff, formatNodeAndAncestors(lastNode, prevNode));
575 * @param {!chrome.automation.AutomationNode} node
576 * @param {!chrome.automation.AutomationNode} prevNode
577 * @param {chrome.automation.EventType|string} type
578 * @param {!cvox.Spannable} buff
579 * @param {!Object=} opt_exclude A list of attributes to exclude from
583 ancestry_: function(node, prevNode, type, buff, opt_exclude) {
584 opt_exclude = opt_exclude || {};
585 var prevUniqueAncestors =
586 AutomationUtil.getUniqueAncestors(node, prevNode);
587 var uniqueAncestors = AutomationUtil.getUniqueAncestors(prevNode, node);
589 // First, look up the event type's format block.
590 // Navigate is the default event.
591 var eventBlock = Output.RULES[type] || Output.RULES['navigate'];
593 for (var i = 0, formatPrevNode;
594 (formatPrevNode = prevUniqueAncestors[i]);
596 var roleBlock = eventBlock[formatPrevNode.role] || eventBlock['default'];
598 this.format_(formatPrevNode, roleBlock.leave, buff, opt_exclude);
601 var enterOutput = [];
603 for (var j = uniqueAncestors.length - 2, formatNode;
604 (formatNode = uniqueAncestors[j]);
606 var roleBlock = eventBlock[formatNode.role] || eventBlock['default'];
607 if (roleBlock.enter) {
608 if (enterRole[formatNode.role])
610 enterRole[formatNode.role] = true;
611 var tempBuff = new cvox.Spannable('');
612 this.format_(formatNode, roleBlock.enter, tempBuff, opt_exclude);
613 enterOutput.unshift(tempBuff);
616 enterOutput.forEach(function(c) {
617 this.addToSpannable_(buff, c);
620 if (!opt_exclude.stay) {
621 var commonFormatNode = uniqueAncestors[0];
622 while (commonFormatNode && commonFormatNode.parent) {
623 commonFormatNode = commonFormatNode.parent;
625 eventBlock[commonFormatNode.role] || eventBlock['default'];
627 this.format_(commonFormatNode, roleBlock.stay, buff, opt_exclude);
633 * @param {!chrome.automation.AutomationNode} node
634 * @param {!chrome.automation.AutomationNode} prevNode
635 * @param {chrome.automation.EventType|string} type
636 * @param {!cvox.Spannable} buff
639 node_: function(node, prevNode, type, buff) {
640 // Navigate is the default event.
641 var eventBlock = Output.RULES[type] || Output.RULES['navigate'];
642 var roleBlock = eventBlock[node.role] || eventBlock['default'];
643 var speakFormat = roleBlock.speak || eventBlock['default'].speak;
644 this.format_(node, speakFormat, buff);
648 * @param {!cursors.Range} range
649 * @param {cursors.Range} prevRange
650 * @param {chrome.automation.EventType|string} type
651 * @param {!cvox.Spannable} buff
654 subNode_: function(range, prevRange, type, buff) {
657 var dir = cursors.Range.getDirection(prevRange, range);
658 var prevNode = prevRange.getBound(dir).getNode();
660 range.getStart().getNode(), prevNode, type, buff,
661 {stay: true, name: true, value: true});
662 var startIndex = range.getStart().getIndex();
663 var endIndex = range.getEnd().getIndex();
664 if (startIndex === endIndex)
666 this.addToSpannable_(
667 buff, range.getStart().getText().substring(startIndex, endIndex));
671 * Adds to the given buffer with proper delimiters added.
672 * @param {!cvox.Spannable} spannable
673 * @param {string|!cvox.Spannable} value
674 * @param {{ifEmpty: boolean,
675 * annotation: (string|Output.Action|undefined)}=} opt_options
677 addToSpannable_: function(spannable, value, opt_options) {
678 opt_options = opt_options || {ifEmpty: false, annotation: undefined};
679 if ((!value || value.length == 0) && !opt_options.annotation)
682 var spannableToAdd = new cvox.Spannable(value, opt_options.annotation);
683 if (spannable.getLength() == 0) {
684 spannable.append(spannableToAdd);
688 if (opt_options.ifEmpty &&
689 opt_options.annotation &&
690 (spannable.getSpanStart(opt_options.annotation) != undefined ||
691 spannable.getSpanStart(
692 Output.ATTRIBUTE_ALIAS[opt_options.annotation]) != undefined))
695 var prefixed = new cvox.Spannable(Output.SPACE);
696 prefixed.append(spannableToAdd);
697 spannable.append(prefixed);
701 * Parses the token containing a custom function and returns a tree.
702 * @param {string} inputStr
705 createParseTree: function(inputStr) {
706 var root = {value: ''};
707 var currentNode = root;
709 var braceNesting = 0;
710 while (index < inputStr.length) {
711 if (inputStr[index] == '(') {
712 currentNode.firstChild = {value: ''};
713 currentNode.firstChild.parent = currentNode;
714 currentNode = currentNode.firstChild;
715 } else if (inputStr[index] == ')') {
716 currentNode = currentNode.parent;
717 } else if (inputStr[index] == '{') {
719 currentNode.value += inputStr[index];
720 } else if (inputStr[index] == '}') {
722 currentNode.value += inputStr[index];
723 } else if (inputStr[index] == ',' && braceNesting === 0) {
724 currentNode.nextSibling = {value: ''};
725 currentNode.nextSibling.parent = currentNode.parent;
726 currentNode = currentNode.nextSibling;
728 currentNode.value += inputStr[index];
733 if (currentNode != root)
734 throw 'Unbalanced parenthesis.';