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('EarconEngine');
14 goog.require('cursors.Cursor');
15 goog.require('cursors.Range');
16 goog.require('cursors.Unit');
17 goog.require('cvox.AbstractEarcons');
18 goog.require('cvox.NavBraille');
19 goog.require('cvox.Spannable');
20 goog.require('cvox.ValueSelectionSpan');
21 goog.require('cvox.ValueSpan');
22 goog.require('goog.i18n.MessageFormat');
24 goog.scope(function() {
25 var Dir = AutomationUtil.Dir;
28 * An Output object formats a cursors.Range into speech, braille, or both
29 * representations. This is typically a cvox.Spannable.
31 * The translation from Range to these output representations rely upon format
32 * rules which specify how to convert AutomationNode objects into annotated
34 * The format of these rules is as follows.
36 * $ prefix: used to substitute either an attribute or a specialized value from
37 * an AutomationNode. Specialized values include role and 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 * @@ prefix: similar to @, used to substitute a message, but also pulls the
43 * localized string through goog.i18n.MessageFormat to support locale
44 * aware plural handling. The first argument should be a number which will
45 * be passed as a COUNT named parameter to MessageFormat.
46 * TODO(plundblad): Make subsequent arguments normal placeholder arguments
48 * = suffix: used to specify substitution only if not previously appended.
49 * For example, $name= would insert the name attribute only if no name
50 * attribute had been inserted previously.
54 // TODO(dtseng): Include braille specific rules.
55 /** @type {!Array<!cvox.Spannable>} */
56 this.speechBuffer_ = [];
57 /** @type {!Array<!cvox.Spannable>} */
58 this.brailleBuffer_ = [];
59 /** @type {!Array<!Object>} */
61 /** @type {function(?)} */
62 this.speechEndCallback_;
65 * Current global options.
66 * @type {{speech: boolean, braille: boolean, location: boolean}}
68 this.formatOptions_ = {speech: true, braille: false, location: true};
71 * Speech properties to apply to the entire output.
74 this.speechProperties_ = {};
78 * Delimiter to use between output values.
84 * Metadata about supported automation roles.
85 * @const {Object<{msgId: string,
86 * earconId: (string|undefined),
87 * inherits: (string|undefined)}>}
88 * msgId: the message id of the role.
89 * earconId: an optional earcon to play when encountering the role.
90 * inherits: inherits rules from this role.
95 msgId: 'aria_role_alert',
96 earconId: 'ALERT_NONMODAL',
99 msgId: 'aria_role_alertdialog'
102 msgId: 'aria_role_article',
103 inherits: 'abstractContainer'
106 msgId: 'aria_role_application',
107 inherits: 'abstractContainer'
110 msgId: 'aria_role_banner',
111 inherits: 'abstractContainer'
122 msgId: 'aria_role_gridcell'
125 msgId: 'input_type_checkbox'
128 msgId: 'aria_role_columnheader',
129 inherits: 'abstractContainer'
132 msgId: 'aria_role_combobox'
135 msgId: 'aria_role_complementary',
136 inherits: 'abstractContainer'
139 msgId: 'aria_role_contentinfo',
140 inherits: 'abstractContainer'
143 msgId: 'input_type_date',
144 inherits: 'abstractContainer'
147 msgId: 'aria_role_definition',
148 inherits: 'abstractContainer'
154 msgId: 'aria_role_directory',
155 inherits: 'abstractContainer'
158 msgId: 'aria_role_document',
159 inherits: 'abstractContainer'
162 msgId: 'aria_role_form',
163 inherits: 'abstractContainer'
166 msgId: 'aria_role_grid'
169 msgId: 'aria_role_group'
172 msgId: 'aria_role_heading',
175 msgId: 'aria_role_img',
178 msgId: 'input_type_time',
179 inherits: 'abstractContainer'
186 msgId: 'aria_role_listbox',
190 msgId: 'aria_role_listitem',
191 earconId: 'LIST_ITEM'
194 msgId: 'aria_role_listitem',
195 earconId: 'LIST_ITEM'
198 msgId: 'aria_role_log',
201 msgId: 'aria_role_main',
202 inherits: 'abstractContainer'
205 msgId: 'aria_role_marquee',
208 msgId: 'aria_role_math',
209 inherits: 'abstractContainer'
212 msgId: 'aria_role_menu'
215 msgId: 'aria_role_menubar',
218 msgId: 'aria_role_menuitem'
221 msgId: 'aria_role_menuitemcheckbox'
224 msgId: 'aria_role_menuitemradio'
227 msgId: 'aria_role_menuitem'
230 msgId: 'aria_role_menu'
233 msgId: 'aria_role_navigation',
234 inherits: 'abstractContainer'
237 msgId: 'aria_role_note',
238 inherits: 'abstractContainer'
245 msgId: 'input_type_radio'
248 msgId: 'aria_role_radiogroup',
251 msgId: 'aria_role_region',
252 inherits: 'abstractContainer'
255 msgId: 'aria_role_rowheader',
256 inherits: 'abstractContainer'
259 msgId: 'aria_role_scrollbar',
262 msgId: 'aria_role_search',
263 inherits: 'abstractContainer'
266 msgId: 'aria_role_separator',
267 inherits: 'abstractContainer'
270 msgId: 'aria_role_spinbutton',
274 msgId: 'aria_role_status'
277 msgId: 'aria_role_tab'
280 msgId: 'aria_role_tablist'
283 msgId: 'aria_role_tabpanel'
286 msgId: 'input_type_text',
287 earconId: 'EDITABLE_TEXT'
290 msgId: 'input_type_text',
291 earconId: 'EDITABLE_TEXT'
295 inherits: 'abstractContainer'
298 msgId: 'aria_role_timer'
301 msgId: 'aria_role_toolbar'
304 msgId: 'aria_role_tree'
307 msgId: 'aria_role_treeitem'
312 * Metadata about supported automation states.
313 * @const {!Object<{on: {msgId: string, earconId: string},
314 * off: {msgId: string, earconId: string},
315 * omitted: {msgId: string, earconId: string}}>}
316 * on: info used to describe a state that is set to true.
317 * off: info used to describe a state that is set to false.
318 * omitted: info used to describe a state that is undefined.
321 Output.STATE_INFO_ = {
324 earconId: 'CHECK_ON',
325 msgId: 'checkbox_checked_state'
328 earconId: 'CHECK_OFF',
329 msgId: 'checkbox_unchecked_state'
332 earconId: 'CHECK_OFF',
333 msgId: 'checkbox_unchecked_state'
338 msgId: 'aria_expanded_false'
341 msgId: 'aria_expanded_true'
346 msgId: 'aria_expanded_true'
349 msgId: 'aria_expanded_false'
354 msgId: 'visited_state'
360 * Rules specifying format of AutomationNodes for output.
361 * @type {!Object<Object<Object<string>>>}
366 speak: '$name $value $help $role',
370 enter: '$name $role',
371 leave: '@exited_container($role)'
374 speak: '!doNotInterrupt $role $descendants'
377 enter: '$name $role $descendants'
380 enter: '@column_granularity $tableCellColumnIndex'
383 speak: '$name $role $checked'
392 enter: '@tag_h+$hierarchicalLevel',
393 speak: '@tag_h+$hierarchicalLevel $nameOrDescendants='
399 enter: '$name $if($visited, @visited_link, $role)',
400 stay: '$name= $if($visited, @visited_link, $role)',
401 speak: '$name= $if($visited, @visited_link, $role)'
404 enter: '$role @@list_with_items($countChildren(listItem))'
407 enter: '$name $role @@list_with_items($countChildren(listBoxOption))'
410 speak: '$name $role @describe_index($indexInParent, $parentChildCount)'
416 enter: '$name $role @@list_with_items($countChildren(menuItem))'
419 speak: '$name $role $if($haspopup, @has_submenu) ' +
420 '@describe_index($indexInParent, $parentChildCount)'
423 speak: '$name $value @aria_role_menuitem ' +
424 '@describe_index($indexInParent, $parentChildCount)'
427 speak: '$descendants'
430 speak: '$value $name $role @aria_has_popup ' +
431 '$if($collapsed, @aria_expanded_false, @aria_expanded_true)'
434 speak: '$if($checked, @describe_radio_selected($name), ' +
435 '@describe_radio_unselected($name))'
444 enter: '@row_granularity $tableRowIndex'
447 speak: '@describe_slider($value, $name) $help'
453 speak: '@describe_tab($name)'
456 speak: '$name $value $if(' +
457 '$inputType, @input_type_+$inputType, @input_type_text)',
464 enter: '$name $role @@list_with_items($countChildren(treeItem))'
467 enter: '$role $expanded $collapsed ' +
468 '@describe_index($indexInParent, $parentChildCount) ' +
469 '@describe_depth($hierarchicalLevel)'
473 speak: '@describe_window($name) $earcon(OBJECT_OPEN)'
478 speak: '@chrome_menu_opened($name) $earcon(OBJECT_OPEN)'
483 speak: '@chrome_menu_closed $earcon(OBJECT_CLOSE)'
486 menuListValueChanged: {
488 speak: '$value $name ' +
489 '$find({"state": {"selected": true, "invisible": false}}, ' +
490 '@describe_index($indexInParent, $parentChildCount)) '
495 speak: '!doNotInterrupt ' +
496 '@aria_role_alert $name $earcon(ALERT_NONMODAL) $descendants'
502 * Custom actions performed while rendering an output string.
505 Output.Action = function() {
508 Output.Action.prototype = {
514 * Action to play an earcon.
515 * @param {string} earconId
517 * @extends {Output.Action}
519 Output.EarconAction = function(earconId) {
520 Output.Action.call(this);
521 /** @type {string} */
522 this.earconId = earconId;
525 Output.EarconAction.prototype = {
526 __proto__: Output.Action.prototype,
530 cvox.ChromeVox.earcons.playEarcon(cvox.Earcon[this.earconId]);
535 * Annotation for selection.
536 * @param {number} startIndex
537 * @param {number} endIndex
540 Output.SelectionSpan = function(startIndex, endIndex) {
541 // TODO(dtseng): Direction lost below; should preserve for braille panning.
542 this.startIndex = startIndex < endIndex ? startIndex : endIndex;
543 this.endIndex = endIndex > startIndex ? endIndex : startIndex;
547 * Wrapper for automation nodes as annotations. Since the
548 * {@code chrome.automation.AutomationNode} constructor isn't exposed in
549 * the API, this class is used to allow isntanceof checks on these
551 @ @param {chrome.automation.AutomationNode} node
554 Output.NodeSpan = function(node) {
559 * Possible events handled by ChromeVox internally.
568 * Gets the spoken output with separator '|'.
569 * @return {!cvox.Spannable}
571 get speechOutputForTest() {
572 return this.speechBuffer_.reduce(function(prev, cur) {
582 * Gets the output buffer for braille.
583 * @return {!cvox.Spannable}
585 get brailleOutputForTest() {
586 return this.createBrailleOutput_();
590 * Specify ranges for speech.
591 * @param {!cursors.Range} range
592 * @param {cursors.Range} prevRange
593 * @param {chrome.automation.EventType|Output.EventType} type
596 withSpeech: function(range, prevRange, type) {
597 this.formatOptions_ = {speech: true, braille: false, location: true};
598 this.render_(range, prevRange, type, this.speechBuffer_);
603 * Specify ranges for braille.
604 * @param {!cursors.Range} range
605 * @param {cursors.Range} prevRange
606 * @param {chrome.automation.EventType|Output.EventType} type
609 withBraille: function(range, prevRange, type) {
610 this.formatOptions_ = {speech: false, braille: true, location: false};
611 this.render_(range, prevRange, type, this.brailleBuffer_);
616 * Specify the same ranges for speech and braille.
617 * @param {!cursors.Range} range
618 * @param {cursors.Range} prevRange
619 * @param {chrome.automation.EventType|Output.EventType} type
622 withSpeechAndBraille: function(range, prevRange, type) {
623 this.withSpeech(range, prevRange, type);
624 this.withBraille(range, prevRange, type);
629 * Apply a format string directly to the output buffer. This lets you
630 * output a message directly to the buffer using the format syntax.
631 * @param {string} formatStr
634 format: function(formatStr) {
635 this.formatOptions_ = {speech: true, braille: false, location: true};
636 this.format_(null, formatStr, this.speechBuffer_);
638 this.formatOptions_ = {speech: false, braille: true, location: false};
639 this.format_(null, formatStr, this.brailleBuffer_);
645 * Triggers callback for a speech event.
646 * @param {function()} callback
648 onSpeechEnd: function(callback) {
649 this.speechEndCallback_ = function(opt_cleanupOnly) {
650 if (!opt_cleanupOnly)
657 * Executes all specified output.
661 var queueMode = cvox.QueueMode.FLUSH;
662 this.speechBuffer_.forEach(function(buff, i, a) {
663 if (buff.toString()) {
665 var scopedBuff = buff;
666 this.speechProperties_['startCallback'] = function() {
667 var actions = scopedBuff.getSpansInstanceOf(Output.Action);
669 actions.forEach(function(a) {
676 if (this.speechEndCallback_ && i == a.length - 1)
677 this.speechProperties_['endCallback'] = this.speechEndCallback_;
679 this.speechProperties_['endCallback'] = null;
680 cvox.ChromeVox.tts.speak(
681 buff.toString(), queueMode, this.speechProperties_);
682 queueMode = cvox.QueueMode.QUEUE;
687 if (this.brailleBuffer_.length) {
688 var buff = this.createBrailleOutput_();
690 buff.getSpanInstanceOf(Output.SelectionSpan);
691 var startIndex = -1, endIndex = -1;
693 // Casts ok, since the span is known to be in the spannable.
695 /** @type {number} */ (buff.getSpanStart(selSpan));
697 /** @type {number} */ (buff.getSpanEnd(selSpan));
698 startIndex = valueStart + selSpan.startIndex;
699 endIndex = valueStart + selSpan.endIndex;
700 buff.setSpan(new cvox.ValueSpan(0), valueStart, valueEnd);
701 buff.setSpan(new cvox.ValueSelectionSpan(), startIndex, endIndex);
704 var output = new cvox.NavBraille({
706 startIndex: startIndex,
710 cvox.ChromeVox.braille.write(output);
714 chrome.accessibilityPrivate.setFocusRing(this.locations_);
718 * Renders the given range using optional context previous range and event
720 * @param {!cursors.Range} range
721 * @param {cursors.Range} prevRange
722 * @param {chrome.automation.EventType|string} type
723 * @param {!Array<cvox.Spannable>} buff Buffer to receive rendered output.
726 render_: function(range, prevRange, type, buff) {
727 if (range.isSubNode())
728 this.subNode_(range, prevRange, type, buff);
730 this.range_(range, prevRange, type, buff);
734 * Format the node given the format specifier.
735 * @param {chrome.automation.AutomationNode} node
736 * @param {string|!Object} format The output format either specified as an
737 * output template string or a parsed output format tree.
738 * @param {!Array<cvox.Spannable>} buff Buffer to receive rendered output.
739 * @param {!Object=} opt_exclude A set of attributes to exclude.
742 format_: function(node, format, buff, opt_exclude) {
743 opt_exclude = opt_exclude || {};
747 // Hacky way to support args.
748 if (typeof(format) == 'string') {
749 format = format.replace(/([,:])\W/g, '$1');
750 tokens = format.split(' ');
755 tokens.forEach(function(token) {
756 // Ignore empty tokens.
762 if (typeof(token) == 'string')
763 tree = this.createParseTree_(token);
767 // Obtain the operator token.
770 // Set suffix options.
772 options.annotation = [];
773 options.isUnique = token[token.length - 1] == '=';
774 if (options.isUnique)
775 token = token.substring(0, token.length - 1);
777 // Annotate braille output with the corresponding automation nodes
778 // to support acting on nodes based on location in the output.
779 if (this.formatOptions_.braille)
780 options.annotation.push(new Output.NodeSpan(node));
782 // Process token based on prefix.
783 var prefix = token[0];
784 token = token.slice(1);
786 if (opt_exclude[token])
789 // All possible tokens based on prefix.
791 if (token == 'value') {
792 var text = node.value;
793 if (text !== undefined) {
794 if (node.textSelStart !== undefined) {
795 options.annotation.push(new Output.SelectionSpan(
800 // Annotate this as a name so we don't duplicate names from ancestors.
801 if (node.role == chrome.automation.RoleType.inlineTextBox ||
802 node.role == chrome.automation.RoleType.staticText)
804 options.annotation.push(token);
805 this.append_(buff, text, options);
806 } else if (token == 'name') {
807 options.annotation.push(token);
808 if (this.formatOptions_.speech) {
809 var earconFinder = node;
810 while (earconFinder) {
811 var info = Output.ROLE_INFO_[earconFinder.role];
812 if (info && info.earconId) {
813 options.annotation.push(
814 new Output.EarconAction(info.earconId));
817 earconFinder = earconFinder.parent;
821 // Pending finalization of name calculation; we must use the
822 // description property to access aria-label. See crbug.com/473220.
823 var resolvedName = node.description || node.name;
824 this.append_(buff, resolvedName, options);
825 } else if (token == 'nameOrDescendants') {
826 options.annotation.push(token);
828 this.append_(buff, node.name, options);
830 this.format_(node, '$descendants', buff);
831 } else if (token == 'indexInParent') {
832 options.annotation.push(token);
833 this.append_(buff, String(node.indexInParent + 1));
834 } else if (token == 'parentChildCount') {
835 options.annotation.push(token);
837 this.append_(buff, String(node.parent.children.length));
838 } else if (token == 'state') {
839 options.annotation.push(token);
840 Object.getOwnPropertyNames(node.state).forEach(function(s) {
841 this.append_(buff, s, options);
843 } else if (token == 'find') {
844 // Find takes two arguments: JSON query string and format string.
845 if (tree.firstChild) {
846 var jsonQuery = tree.firstChild.value;
848 /** @type {Object}*/(JSON.parse(jsonQuery)));
849 var formatString = tree.firstChild.nextSibling;
851 this.format_(node, formatString, buff);
853 } else if (token == 'descendants') {
854 if (AutomationPredicate.leaf(node))
857 // Construct a range to the leftmost and rightmost leaves.
858 var leftmost = AutomationUtil.findNodePre(
859 node, Dir.FORWARD, AutomationPredicate.leaf);
860 var rightmost = AutomationUtil.findNodePre(
861 node, Dir.BACKWARD, AutomationPredicate.leaf);
862 if (!leftmost || !rightmost)
865 var subrange = new cursors.Range(
866 new cursors.Cursor(leftmost, 0),
867 new cursors.Cursor(rightmost, 0));
870 prev = cursors.Range.fromNode(node);
871 this.range_(subrange, prev, 'navigate', buff);
872 } else if (token == 'role') {
873 options.annotation.push(token);
875 var info = Output.ROLE_INFO_[node.role];
877 if (this.formatOptions_.braille)
878 msg = cvox.ChromeVox.msgs.getMsg(info.msgId + '_brl');
880 msg = cvox.ChromeVox.msgs.getMsg(info.msgId);
882 console.error('Missing role info for ' + node.role);
884 this.append_(buff, msg, options);
885 } else if (token == 'tableRowIndex' ||
886 token == 'tableCellColumnIndex') {
887 var value = node[token];
890 value = String(value + 1);
891 options.annotation.push(token);
892 this.append_(buff, value, options);
893 } else if (node[token] !== undefined) {
894 options.annotation.push(token);
895 var value = node[token];
896 if (typeof value == 'number')
897 value = String(value);
898 this.append_(buff, value, options);
899 } else if (Output.STATE_INFO_[token]) {
900 options.annotation.push('state');
901 var stateInfo = Output.STATE_INFO_[token];
902 var resolvedInfo = {};
903 if (node.state[token] === undefined)
904 resolvedInfo = stateInfo.omitted;
906 resolvedInfo = node.state[token] ? stateInfo.on : stateInfo.off;
909 if (this.formatOptions_.speech && resolvedInfo.earconId) {
910 options.annotation.push(
911 new Output.EarconAction(resolvedInfo.earconId));
914 this.formatOptions_.braille ? resolvedInfo.msgId + '_brl' :
916 var msg = cvox.ChromeVox.msgs.getMsg(msgId);
917 this.append_(buff, msg, options);
918 } else if (tree.firstChild) {
921 var cond = tree.firstChild;
922 var attrib = cond.value.slice(1);
923 if (node[attrib] || node.state[attrib])
924 this.format_(node, cond.nextSibling, buff);
926 this.format_(node, cond.nextSibling.nextSibling, buff);
927 } else if (token == 'earcon') {
928 // Ignore unless we're generating speech output.
929 if (!this.formatOptions_.speech)
931 // Assumes there's existing output in our buffer.
932 var lastBuff = buff[buff.length - 1];
937 new Output.EarconAction(tree.firstChild.value), 0, 0);
938 } else if (token == 'countChildren') {
939 var role = tree.firstChild.value;
940 var count = node.children.filter(function(e) {
941 return e.role == role;
943 this.append_(buff, String(count));
946 } else if (prefix == '@') {
947 var isPluralized = (token[0] == '@');
949 token = token.slice(1);
950 // Tokens can have substitutions.
951 var pieces = token.split('+');
952 token = pieces.reduce(function(prev, cur) {
955 lookup = node[cur.slice(1)];
956 return prev + lookup;
961 var curArg = tree.firstChild;
963 if (curArg.value[0] != '$') {
964 console.error('Unexpected value: ' + curArg.value);
968 this.format_(node, curArg, msgBuff);
969 msgArgs = msgArgs.concat(msgBuff);
970 curArg = curArg.nextSibling;
973 var msg = cvox.ChromeVox.msgs.getMsg(msgId, msgArgs);
975 if (this.formatOptions_.braille)
976 msg = cvox.ChromeVox.msgs.getMsg(msgId + '_brl', msgArgs) || msg;
980 console.error('Could not get message ' + msgId);
985 var arg = tree.firstChild;
986 if (!arg || arg.nextSibling) {
987 console.error('Pluralized messages take exactly one argument');
990 if (arg.value[0] != '$') {
991 console.error('Unexpected value: ' + arg.value);
995 this.format_(node, arg, argBuff);
996 var namedArgs = {COUNT: Number(argBuff[0])};
997 msg = new goog.i18n.MessageFormat(msg).format(namedArgs);
1000 this.append_(buff, msg, options);
1001 } else if (prefix == '!') {
1002 this.speechProperties_[token] = true;
1008 * @param {!cursors.Range} range
1009 * @param {cursors.Range} prevRange
1010 * @param {chrome.automation.EventType|string} type
1011 * @param {!Array<cvox.Spannable>} rangeBuff
1014 range_: function(range, prevRange, type, rangeBuff) {
1016 prevRange = cursors.Range.fromNode(range.start.node.root);
1018 var cursor = range.start;
1019 var prevNode = prevRange.start.node;
1021 var formatNodeAndAncestors = function(node, prevNode) {
1023 this.ancestry_(node, prevNode, type, buff);
1024 this.node_(node, prevNode, type, buff);
1025 if (this.formatOptions_.location)
1026 this.locations_.push(node.location);
1030 while (cursor.node != range.end.node) {
1031 var node = cursor.node;
1032 rangeBuff.push.apply(rangeBuff, formatNodeAndAncestors(node, prevNode));
1034 cursor = cursor.move(cursors.Unit.NODE,
1035 cursors.Movement.DIRECTIONAL,
1038 // Reached a boundary.
1039 if (cursor.node == prevNode)
1042 var lastNode = range.end.node;
1043 rangeBuff.push.apply(rangeBuff, formatNodeAndAncestors(lastNode, prevNode));
1047 * @param {!chrome.automation.AutomationNode} node
1048 * @param {!chrome.automation.AutomationNode} prevNode
1049 * @param {chrome.automation.EventType|string} type
1050 * @param {!Array<cvox.Spannable>} buff
1051 * @param {!Object=} opt_exclude A list of attributes to exclude from
1055 ancestry_: function(node, prevNode, type, buff, opt_exclude) {
1056 opt_exclude = opt_exclude || {};
1057 var prevUniqueAncestors =
1058 AutomationUtil.getUniqueAncestors(node, prevNode);
1059 var uniqueAncestors = AutomationUtil.getUniqueAncestors(prevNode, node);
1061 // First, look up the event type's format block.
1062 // Navigate is the default event.
1063 var eventBlock = Output.RULES[type] || Output.RULES['navigate'];
1065 var getMergedRoleBlock = function(role) {
1066 var parentRole = (Output.ROLE_INFO_[role] || {}).inherits;
1067 var roleBlock = eventBlock[role] || eventBlock['default'];
1068 var parentRoleBlock = parentRole ? eventBlock[parentRole] : {};
1069 var mergedRoleBlock = {};
1070 for (var key in parentRoleBlock)
1071 mergedRoleBlock[key] = parentRoleBlock[key];
1072 for (var key in roleBlock)
1073 mergedRoleBlock[key] = roleBlock[key];
1074 return mergedRoleBlock;
1077 for (var i = 0, formatPrevNode;
1078 (formatPrevNode = prevUniqueAncestors[i]);
1080 var roleBlock = getMergedRoleBlock(formatPrevNode.role);
1081 if (roleBlock.leave)
1082 this.format_(formatPrevNode, roleBlock.leave, buff, opt_exclude);
1085 var enterOutputs = [];
1087 for (var j = uniqueAncestors.length - 2, formatNode;
1088 (formatNode = uniqueAncestors[j]);
1090 var roleBlock = getMergedRoleBlock(formatNode.role);
1091 if (roleBlock.enter) {
1092 if (enterRole[formatNode.role])
1094 enterRole[formatNode.role] = true;
1096 this.format_(formatNode, roleBlock.enter, tempBuff, opt_exclude);
1097 enterOutputs.unshift(tempBuff);
1099 if (formatNode.role == 'window')
1102 enterOutputs.forEach(function(b) {
1103 buff.push.apply(buff, b);
1106 if (!opt_exclude.stay) {
1107 var commonFormatNode = uniqueAncestors[0];
1108 while (commonFormatNode && commonFormatNode.parent) {
1109 commonFormatNode = commonFormatNode.parent;
1111 eventBlock[commonFormatNode.role] || eventBlock['default'];
1113 this.format_(commonFormatNode, roleBlock.stay, buff, opt_exclude);
1119 * @param {!chrome.automation.AutomationNode} node
1120 * @param {!chrome.automation.AutomationNode} prevNode
1121 * @param {chrome.automation.EventType|string} type
1122 * @param {!Array<cvox.Spannable>} buff
1125 node_: function(node, prevNode, type, buff) {
1126 // Navigate is the default event.
1127 var eventBlock = Output.RULES[type] || Output.RULES['navigate'];
1128 var roleBlock = eventBlock[node.role] || eventBlock['default'];
1129 var speakFormat = roleBlock.speak || eventBlock['default'].speak;
1130 this.format_(node, speakFormat, buff);
1134 * @param {!cursors.Range} range
1135 * @param {cursors.Range} prevRange
1136 * @param {chrome.automation.EventType|string} type
1137 * @param {!Array<cvox.Spannable>} buff
1140 subNode_: function(range, prevRange, type, buff) {
1143 var dir = cursors.Range.getDirection(prevRange, range);
1144 var prevNode = prevRange.getBound(dir).node;
1146 range.start.node, prevNode, type, buff,
1147 {stay: true, name: true, value: true});
1148 var startIndex = range.start.getIndex();
1149 var endIndex = range.end.getIndex();
1150 if (startIndex === endIndex)
1153 buff, range.start.getText().substring(startIndex, endIndex));
1157 * Appends output to the |buff|.
1158 * @param {!Array<cvox.Spannable>} buff
1159 * @param {string|!cvox.Spannable} value
1160 * @param {{isUnique: (boolean|undefined),
1161 * annotation: !Array<*>}=} opt_options
1163 append_: function(buff, value, opt_options) {
1164 opt_options = opt_options || {isUnique: false, annotation: []};
1166 // Reject empty values without annotations.
1167 if ((!value || value.length == 0) && opt_options.annotation.length == 0)
1170 var spannableToAdd = new cvox.Spannable(value);
1171 opt_options.annotation.forEach(function(a) {
1172 spannableToAdd.setSpan(a, 0, spannableToAdd.getLength());
1175 // |isUnique| specifies an annotation that cannot be duplicated.
1176 if (opt_options.isUnique) {
1177 var annotationSansNodes = opt_options.annotation.filter(
1178 function(annotation) {
1179 return !(annotation instanceof Output.NodeSpan);
1181 var alreadyAnnotated = buff.some(function(s) {
1182 return annotationSansNodes.some(function(annotation) {
1183 var start = s.getSpanStart(annotation);
1184 var end = s.getSpanEnd(annotation);
1185 if (start === undefined)
1187 return s.substring(start, end).toString() == value.toString();
1190 if (alreadyAnnotated)
1194 buff.push(spannableToAdd);
1198 * Parses the token containing a custom function and returns a tree.
1199 * @param {string} inputStr
1203 createParseTree_: function(inputStr) {
1204 var root = {value: ''};
1205 var currentNode = root;
1207 var braceNesting = 0;
1208 while (index < inputStr.length) {
1209 if (inputStr[index] == '(') {
1210 currentNode.firstChild = {value: ''};
1211 currentNode.firstChild.parent = currentNode;
1212 currentNode = currentNode.firstChild;
1213 } else if (inputStr[index] == ')') {
1214 currentNode = currentNode.parent;
1215 } else if (inputStr[index] == '{') {
1217 currentNode.value += inputStr[index];
1218 } else if (inputStr[index] == '}') {
1220 currentNode.value += inputStr[index];
1221 } else if (inputStr[index] == ',' && braceNesting === 0) {
1222 currentNode.nextSibling = {value: ''};
1223 currentNode.nextSibling.parent = currentNode.parent;
1224 currentNode = currentNode.nextSibling;
1226 currentNode.value += inputStr[index];
1231 if (currentNode != root)
1232 throw 'Unbalanced parenthesis.';
1238 * Converts the currently rendered braille buffers to a single spannable.
1239 * @return {!cvox.Spannable}
1242 createBrailleOutput_: function() {
1243 var result = new cvox.Spannable();
1244 var separator = ''; // Changes to space as appropriate.
1245 this.brailleBuffer_.forEach(function(cur) {
1246 // If this chunk is empty, don't add it since it won't result
1247 // in any output on the braille display, but node spans would
1248 // start before the separator in that case, which is not desired.
1249 // The exception is if this chunk contains a selectionm, in which
1250 // case it will result in a cursor which has to be preserved.
1251 // In this case, having separators, potentially both before and after
1252 // the empty string is correct.
1253 if (cur.getLength() == 0 && !cur.getSpanInstanceOf(Output.SelectionSpan))
1255 var spansToExtend = [];
1256 var spansToRemove = [];
1257 // Nodes that have node spans both on the character to the left
1258 // of the separator and to the right should also cover the separator.
1259 // We extend the left span to cover both the separator and what the
1260 // right span used to cover, removing the right span, mostly for
1261 // ease of writing tests and debug.
1262 // Note that getSpan(position) never returns zero length spans
1263 // (because they don't cover any position). Still, we want to include
1264 // these because they can be included (the selection span in an empty
1265 // text field is an example), which is why we write the below code
1266 // using getSpansInstanceOf and check the endpoints (isntead of doing
1268 result.getSpansInstanceOf(Output.NodeSpan).forEach(function(leftSpan) {
1269 if (result.getSpanEnd(leftSpan) < result.getLength())
1271 var newEnd = result.getLength();
1272 cur.getSpansInstanceOf(Output.NodeSpan).forEach(function(rightSpan) {
1273 if (cur.getSpanStart(rightSpan) == 0 &&
1274 leftSpan.node === rightSpan.node) {
1277 result.getLength() + separator.length +
1278 cur.getSpanEnd(rightSpan));
1279 spansToRemove.push(rightSpan);
1282 if (newEnd > result.getLength())
1283 spansToExtend.push({span: leftSpan, end: newEnd});
1285 result.append(separator);
1287 spansToExtend.forEach(function(elem) {
1290 // Cast ok, since span is known to exist.
1291 /** @type {number} */ (result.getSpanStart(elem.span)),
1294 spansToRemove.forEach(result.removeSpan.bind(result));
1295 separator = Output.SPACE;