Initial commit of new ChromeVox earcon engine.
[chromium-blink-merge.git] / chrome / browser / resources / chromeos / chromevox / cvox2 / background / output.js
blob2c28c94633400d3c0f08955aa4b6245d62bc9457
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.
5 /**
6  * @fileoverview Provides output services for ChromeVox.
7  */
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;
27 /**
28  * An Output object formats a cursors.Range into speech, braille, or both
29  * representations. This is typically a cvox.Spannable.
30  *
31  * The translation from Range to these output representations rely upon format
32  * rules which specify how to convert AutomationNode objects into annotated
33  * strings.
34  * The format of these rules is as follows.
35  *
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,
41  *     $text_sel_end').
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
47  *     when needed.
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.
51  * @constructor
52  */
53 Output = function() {
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>} */
60   this.locations_ = [];
61   /** @type {function(?)} */
62   this.speechEndCallback_;
64   /**
65    * Current global options.
66    * @type {{speech: boolean, braille: boolean, location: boolean}}
67    */
68   this.formatOptions_ = {speech: true, braille: false, location: true};
70   /**
71    * Speech properties to apply to the entire output.
72    * @type {!Object<*>}
73    */
74   this.speechProperties_ = {};
77 /**
78  * Delimiter to use between output values.
79  * @type {string}
80  */
81 Output.SPACE = ' ';
83 /**
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.
91  * @private
92  */
93 Output.ROLE_INFO_ = {
94   alert: {
95     msgId: 'aria_role_alert',
96     earconId: 'ALERT_NONMODAL',
97   },
98   alertDialog: {
99     msgId: 'aria_role_alertdialog'
100   },
101   article: {
102     msgId: 'aria_role_article',
103     inherits: 'abstractContainer'
104   },
105   application: {
106     msgId: 'aria_role_application',
107     inherits: 'abstractContainer'
108   },
109   banner: {
110     msgId: 'aria_role_banner',
111     inherits: 'abstractContainer'
112   },
113   button: {
114     msgId: 'tag_button',
115     earconId: 'BUTTON'
116   },
117   buttonDropDown: {
118     msgId: 'tag_button',
119     earconId: 'BUTTON'
120   },
121   cell: {
122     msgId: 'aria_role_gridcell'
123   },
124   checkBox: {
125     msgId: 'input_type_checkbox'
126   },
127   columnHeader: {
128     msgId: 'aria_role_columnheader',
129     inherits: 'abstractContainer'
130   },
131   comboBox: {
132     msgId: 'aria_role_combobox'
133   },
134   complementary: {
135     msgId: 'aria_role_complementary',
136     inherits: 'abstractContainer'
137   },
138   contentInfo: {
139     msgId: 'aria_role_contentinfo',
140     inherits: 'abstractContainer'
141   },
142   date: {
143     msgId: 'input_type_date',
144     inherits: 'abstractContainer'
145   },
146   definition: {
147     msgId: 'aria_role_definition',
148     inherits: 'abstractContainer'
149   },
150   dialog: {
151     msgId: 'dialog'
152   },
153   directory: {
154     msgId: 'aria_role_directory',
155     inherits: 'abstractContainer'
156   },
157   document: {
158     msgId: 'aria_role_document',
159     inherits: 'abstractContainer'
160   },
161   form: {
162     msgId: 'aria_role_form',
163     inherits: 'abstractContainer'
164   },
165   grid: {
166     msgId: 'aria_role_grid'
167   },
168   group: {
169     msgId: 'aria_role_group'
170   },
171   heading: {
172     msgId: 'aria_role_heading',
173   },
174   image: {
175     msgId: 'aria_role_img',
176   },
177   inputTime: {
178     msgId: 'input_type_time',
179     inherits: 'abstractContainer'
180   },
181   link: {
182     msgId: 'tag_link',
183     earconId: 'LINK'
184   },
185   listBox: {
186     msgId: 'aria_role_listbox',
187     earconId: 'LISTBOX'
188   },
189   listBoxOption: {
190     msgId: 'aria_role_listitem',
191     earconId: 'LIST_ITEM'
192   },
193   listItem: {
194     msgId: 'aria_role_listitem',
195     earconId: 'LIST_ITEM'
196   },
197   log: {
198     msgId: 'aria_role_log',
199   },
200   main: {
201     msgId: 'aria_role_main',
202     inherits: 'abstractContainer'
203   },
204   marquee: {
205     msgId: 'aria_role_marquee',
206   },
207   math: {
208     msgId: 'aria_role_math',
209     inherits: 'abstractContainer'
210   },
211   menu: {
212     msgId: 'aria_role_menu'
213   },
214   menuBar: {
215     msgId: 'aria_role_menubar',
216   },
217   menuItem: {
218     msgId: 'aria_role_menuitem'
219   },
220   menuItemCheckBox: {
221     msgId: 'aria_role_menuitemcheckbox'
222   },
223   menuItemRadio: {
224     msgId: 'aria_role_menuitemradio'
225   },
226   menuListOption: {
227     msgId: 'aria_role_menuitem'
228   },
229   menuListPopup: {
230     msgId: 'aria_role_menu'
231   },
232   navigation: {
233     msgId: 'aria_role_navigation',
234     inherits: 'abstractContainer'
235   },
236   note: {
237     msgId: 'aria_role_note',
238     inherits: 'abstractContainer'
239   },
240   popUpButton: {
241     msgId: 'tag_button',
242     earcon: 'LISTBOX'
243   },
244   radioButton: {
245     msgId: 'input_type_radio'
246   },
247   radioGroup: {
248     msgId: 'aria_role_radiogroup',
249   },
250   region: {
251     msgId: 'aria_role_region',
252     inherits: 'abstractContainer'
253   },
254   rowHeader: {
255     msgId: 'aria_role_rowheader',
256     inherits: 'abstractContainer'
257   },
258   scrollBar: {
259     msgId: 'aria_role_scrollbar',
260   },
261   search: {
262     msgId: 'aria_role_search',
263     inherits: 'abstractContainer'
264   },
265   separator: {
266     msgId: 'aria_role_separator',
267     inherits: 'abstractContainer'
268   },
269   spinButton: {
270     msgId: 'aria_role_spinbutton',
271     earconId: 'LISTBOX'
272   },
273   status: {
274     msgId: 'aria_role_status'
275   },
276   tab: {
277     msgId: 'aria_role_tab'
278   },
279   tabList: {
280     msgId: 'aria_role_tablist'
281   },
282   tabPanel: {
283     msgId: 'aria_role_tabpanel'
284   },
285   textBox: {
286     msgId: 'input_type_text',
287     earconId: 'EDITABLE_TEXT'
288   },
289   textField: {
290     msgId: 'input_type_text',
291     earconId: 'EDITABLE_TEXT'
292   },
293   time: {
294     msgId: 'tag_time',
295     inherits: 'abstractContainer'
296   },
297   timer: {
298     msgId: 'aria_role_timer'
299   },
300   toolbar: {
301     msgId: 'aria_role_toolbar'
302   },
303   tree: {
304     msgId: 'aria_role_tree'
305   },
306   treeItem: {
307     msgId: 'aria_role_treeitem'
308   }
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.
319  * @private
320  */
321 Output.STATE_INFO_ = {
322   checked: {
323     on: {
324       earconId: 'CHECK_ON',
325       msgId: 'checkbox_checked_state'
326     },
327     off: {
328       earconId: 'CHECK_OFF',
329       msgId: 'checkbox_unchecked_state'
330     },
331     omitted: {
332       earconId: 'CHECK_OFF',
333       msgId: 'checkbox_unchecked_state'
334     }
335   },
336   collapsed: {
337     on: {
338       msgId: 'aria_expanded_false'
339     },
340     off: {
341       msgId: 'aria_expanded_true'
342     }
343   },
344   expanded: {
345     on: {
346       msgId: 'aria_expanded_true'
347     },
348     off: {
349       msgId: 'aria_expanded_false'
350     }
351   },
352   visited: {
353     on: {
354       msgId: 'visited_state'
355     }
356   }
360  * Rules specifying format of AutomationNodes for output.
361  * @type {!Object<Object<Object<string>>>}
362  */
363 Output.RULES = {
364   navigate: {
365     'default': {
366       speak: '$name $value $help $role',
367       braille: ''
368     },
369     abstractContainer: {
370       enter: '$name $role',
371       leave: '@exited_container($role)'
372     },
373     alert: {
374       speak: '!doNotInterrupt $role $descendants'
375     },
376     alertDialog: {
377       enter: '$name $role $descendants'
378     },
379     cell: {
380       enter: '@column_granularity $tableCellColumnIndex'
381     },
382     checkBox: {
383       speak: '$name $role $checked'
384     },
385     dialog: {
386       enter: '$name $role'
387     },
388     grid: {
389       enter: '$name $role'
390     },
391     heading: {
392       enter: '@tag_h+$hierarchicalLevel',
393       speak: '@tag_h+$hierarchicalLevel $nameOrDescendants='
394     },
395     inlineTextBox: {
396       speak: '$value='
397     },
398     link: {
399       enter: '$name $if($visited, @visited_link, $role)',
400       stay: '$name= $if($visited, @visited_link, $role)',
401       speak: '$name= $if($visited, @visited_link, $role)'
402     },
403     list: {
404       enter: '$role @@list_with_items($countChildren(listItem))'
405     },
406     listBox: {
407       enter: '$name $role @@list_with_items($countChildren(listBoxOption))'
408     },
409     listBoxOption: {
410       speak: '$name $role @describe_index($indexInParent, $parentChildCount)'
411     },
412     listItem: {
413       enter: '$role'
414     },
415     menu: {
416       enter: '$name $role @@list_with_items($countChildren(menuItem))'
417     },
418     menuItem: {
419       speak: '$name $role $if($haspopup, @has_submenu) ' +
420           '@describe_index($indexInParent, $parentChildCount)'
421     },
422     menuListOption: {
423       speak: '$name $value @aria_role_menuitem ' +
424           '@describe_index($indexInParent, $parentChildCount)'
425     },
426     paragraph: {
427       speak: '$descendants'
428     },
429     popUpButton: {
430       speak: '$value $name $role @aria_has_popup ' +
431           '$if($collapsed, @aria_expanded_false, @aria_expanded_true)'
432     },
433     radioButton: {
434       speak: '$if($checked, @describe_radio_selected($name), ' +
435           '@describe_radio_unselected($name))'
436     },
437     radioGroup: {
438       enter: '$name $role'
439     },
440     rootWebArea: {
441       enter: '$name'
442     },
443     row: {
444       enter: '@row_granularity $tableRowIndex'
445     },
446     slider: {
447       speak: '@describe_slider($value, $name) $help'
448     },
449     staticText: {
450       speak: '$value='
451     },
452     tab: {
453       speak: '@describe_tab($name)'
454     },
455     textField: {
456       speak: '$name $value $if(' +
457           '$inputType, @input_type_+$inputType, @input_type_text)',
458       braille: ''
459     },
460     toolbar: {
461       enter: '$name $role'
462     },
463     tree: {
464       enter: '$name $role @@list_with_items($countChildren(treeItem))'
465     },
466     treeItem: {
467       enter: '$role $expanded $collapsed ' +
468           '@describe_index($indexInParent, $parentChildCount) ' +
469           '@describe_depth($hierarchicalLevel)'
470     },
471     window: {
472       enter: '$name',
473       speak: '@describe_window($name) $earcon(OBJECT_OPEN)'
474     }
475   },
476   menuStart: {
477     'default': {
478       speak: '@chrome_menu_opened($name)  $earcon(OBJECT_OPEN)'
479     }
480   },
481   menuEnd: {
482     'default': {
483       speak: '@chrome_menu_closed $earcon(OBJECT_CLOSE)'
484     }
485   },
486   menuListValueChanged: {
487     'default': {
488       speak: '$value $name ' +
489           '$find({"state": {"selected": true, "invisible": false}}, ' +
490               '@describe_index($indexInParent, $parentChildCount)) '
491     }
492   },
493   alert: {
494     default: {
495       speak: '!doNotInterrupt ' +
496           '@aria_role_alert $name $earcon(ALERT_NONMODAL) $descendants'
497     }
498   }
502  * Custom actions performed while rendering an output string.
503  * @constructor
504  */
505 Output.Action = function() {
508 Output.Action.prototype = {
509   run: function() {
510   }
514  * Action to play an earcon.
515  * @param {string} earconId
516  * @constructor
517  * @extends {Output.Action}
518  */
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,
528   /** @override */
529   run: function() {
530     cvox.ChromeVox.earcons.playEarcon(cvox.Earcon[this.earconId]);
531   }
535  * Annotation for selection.
536  * @param {number} startIndex
537  * @param {number} endIndex
538  * @constructor
539  */
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
550  * annotations.
551  @ @param {chrome.automation.AutomationNode} node
552  * @constructor
553  */
554 Output.NodeSpan = function(node) {
555   this.node = node;
559  * Possible events handled by ChromeVox internally.
560  * @enum {string}
561  */
562 Output.EventType = {
563   NAVIGATE: 'navigate'
566 Output.prototype = {
567   /**
568    * Gets the spoken output with separator '|'.
569    * @return {!cvox.Spannable}
570    */
571   get speechOutputForTest() {
572     return this.speechBuffer_.reduce(function(prev, cur) {
573       if (prev === null)
574         return cur;
575       prev.append('|');
576       prev.append(cur);
577       return prev;
578     }, null);
579   },
581   /**
582    * Gets the output buffer for braille.
583    * @return {!cvox.Spannable}
584    */
585   get brailleOutputForTest() {
586     return this.createBrailleOutput_();
587   },
589   /**
590    * Specify ranges for speech.
591    * @param {!cursors.Range} range
592    * @param {cursors.Range} prevRange
593    * @param {chrome.automation.EventType|Output.EventType} type
594    * @return {!Output}
595    */
596   withSpeech: function(range, prevRange, type) {
597     this.formatOptions_ = {speech: true, braille: false, location: true};
598     this.render_(range, prevRange, type, this.speechBuffer_);
599     return this;
600   },
602   /**
603    * Specify ranges for braille.
604    * @param {!cursors.Range} range
605    * @param {cursors.Range} prevRange
606    * @param {chrome.automation.EventType|Output.EventType} type
607    * @return {!Output}
608    */
609   withBraille: function(range, prevRange, type) {
610     this.formatOptions_ = {speech: false, braille: true, location: false};
611     this.render_(range, prevRange, type, this.brailleBuffer_);
612     return this;
613   },
615   /**
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
620    * @return {!Output}
621    */
622   withSpeechAndBraille: function(range, prevRange, type) {
623     this.withSpeech(range, prevRange, type);
624     this.withBraille(range, prevRange, type);
625     return this;
626   },
628   /**
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
632    * @return {!Output}
633    */
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_);
641     return this;
642   },
644   /**
645    * Triggers callback for a speech event.
646    * @param {function()} callback
647    */
648   onSpeechEnd: function(callback) {
649     this.speechEndCallback_ = function(opt_cleanupOnly) {
650       if (!opt_cleanupOnly)
651         callback();
652     }.bind(this);
653     return this;
654   },
656   /**
657    * Executes all specified output.
658    */
659   go: function() {
660     // Speech.
661     var queueMode = cvox.QueueMode.FLUSH;
662     this.speechBuffer_.forEach(function(buff, i, a) {
663       if (buff.toString()) {
664         (function() {
665           var scopedBuff = buff;
666           this.speechProperties_['startCallback'] = function() {
667             var actions = scopedBuff.getSpansInstanceOf(Output.Action);
668             if (actions) {
669               actions.forEach(function(a) {
670                 a.run();
671               });
672             }
673           };
674         }.bind(this)());
676         if (this.speechEndCallback_ && i == a.length - 1)
677           this.speechProperties_['endCallback'] = this.speechEndCallback_;
678         else
679           this.speechProperties_['endCallback'] = null;
680         cvox.ChromeVox.tts.speak(
681             buff.toString(), queueMode, this.speechProperties_);
682         queueMode = cvox.QueueMode.QUEUE;
683       }
684     }.bind(this));
686     // Braille.
687     if (this.brailleBuffer_.length) {
688       var buff = this.createBrailleOutput_();
689       var selSpan =
690           buff.getSpanInstanceOf(Output.SelectionSpan);
691       var startIndex = -1, endIndex = -1;
692       if (selSpan) {
693         // Casts ok, since the span is known to be in the spannable.
694         var valueStart =
695             /** @type {number} */ (buff.getSpanStart(selSpan));
696         var valueEnd =
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);
702       }
704       var output = new cvox.NavBraille({
705         text: buff,
706         startIndex: startIndex,
707         endIndex: endIndex
708       });
710       cvox.ChromeVox.braille.write(output);
711     }
713     // Display.
714     chrome.accessibilityPrivate.setFocusRing(this.locations_);
715   },
717   /**
718    * Renders the given range using optional context previous range and event
719    * type.
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.
724    * @private
725    */
726   render_: function(range, prevRange, type, buff) {
727     if (range.isSubNode())
728       this.subNode_(range, prevRange, type, buff);
729     else
730       this.range_(range, prevRange, type, buff);
731   },
733   /**
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.
740    * @private
741    */
742   format_: function(node, format, buff, opt_exclude) {
743     opt_exclude = opt_exclude || {};
744     var tokens = [];
745     var args = null;
747     // Hacky way to support args.
748     if (typeof(format) == 'string') {
749       format = format.replace(/([,:])\W/g, '$1');
750       tokens = format.split(' ');
751     } else {
752       tokens = [format];
753     }
755     tokens.forEach(function(token) {
756       // Ignore empty tokens.
757       if (!token)
758         return;
760       // Parse the token.
761       var tree;
762       if (typeof(token) == 'string')
763         tree = this.createParseTree_(token);
764       else
765         tree = token;
767       // Obtain the operator token.
768       token = tree.value;
770       // Set suffix options.
771       var 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])
787         return;
789       // All possible tokens based on prefix.
790       if (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(
796                   node.textSelStart,
797                   node.textSelEnd));
798             }
799           }
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)
803             token = 'name';
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));
815                 break;
816               }
817               earconFinder = earconFinder.parent;
818             }
819           }
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);
827           if (node.name)
828             this.append_(buff, node.name, options);
829           else
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);
836           if (node.parent)
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);
842           }.bind(this));
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;
847             node = node.find(
848                 /** @type {Object}*/(JSON.parse(jsonQuery)));
849             var formatString = tree.firstChild.nextSibling;
850             if (node)
851               this.format_(node, formatString, buff);
852           }
853         } else if (token == 'descendants') {
854           if (AutomationPredicate.leaf(node))
855             return;
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)
863             return;
865           var subrange = new cursors.Range(
866               new cursors.Cursor(leftmost, 0),
867               new cursors.Cursor(rightmost, 0));
868           var prev = null;
869           if (node)
870             prev = cursors.Range.fromNode(node);
871           this.range_(subrange, prev, 'navigate', buff);
872         } else if (token == 'role') {
873           options.annotation.push(token);
874           var msg = node.role;
875           var info = Output.ROLE_INFO_[node.role];
876           if (info) {
877             if (this.formatOptions_.braille)
878               msg = cvox.ChromeVox.msgs.getMsg(info.msgId + '_brl');
879             else
880               msg = cvox.ChromeVox.msgs.getMsg(info.msgId);
881           } else {
882             console.error('Missing role info for ' + node.role);
883           }
884           this.append_(buff, msg, options);
885         } else if (token == 'tableRowIndex' ||
886             token == 'tableCellColumnIndex') {
887           var value = node[token];
888           if (!value)
889             return;
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;
905           else
906             resolvedInfo = node.state[token] ? stateInfo.on : stateInfo.off;
907           if (!resolvedInfo)
908             return;
909           if (this.formatOptions_.speech && resolvedInfo.earconId) {
910             options.annotation.push(
911                 new Output.EarconAction(resolvedInfo.earconId));
912           }
913           var msgId =
914               this.formatOptions_.braille ? resolvedInfo.msgId + '_brl' :
915               resolvedInfo.msgId;
916           var msg = cvox.ChromeVox.msgs.getMsg(msgId);
917           this.append_(buff, msg, options);
918         } else if (tree.firstChild) {
919           // Custom functions.
920           if (token == 'if') {
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);
925             else
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)
930               return;
931             // Assumes there's existing output in our buffer.
932             var lastBuff = buff[buff.length - 1];
933             if (!lastBuff)
934               return;
936             lastBuff.setSpan(
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;
942             }).length;
943             this.append_(buff, String(count));
944           }
945         }
946       } else if (prefix == '@') {
947         var isPluralized = (token[0] == '@');
948         if (isPluralized)
949           token = token.slice(1);
950         // Tokens can have substitutions.
951         var pieces = token.split('+');
952         token = pieces.reduce(function(prev, cur) {
953           var lookup = cur;
954           if (cur[0] == '$')
955             lookup = node[cur.slice(1)];
956           return prev + lookup;
957         }.bind(this), '');
958         var msgId = token;
959         var msgArgs = [];
960         if (!isPluralized) {
961           var curArg = tree.firstChild;
962           while (curArg) {
963             if (curArg.value[0] != '$') {
964               console.error('Unexpected value: ' + curArg.value);
965               return;
966             }
967             var msgBuff = [];
968             this.format_(node, curArg, msgBuff);
969             msgArgs = msgArgs.concat(msgBuff);
970             curArg = curArg.nextSibling;
971           }
972         }
973         var msg = cvox.ChromeVox.msgs.getMsg(msgId, msgArgs);
974         try {
975           if (this.formatOptions_.braille)
976             msg = cvox.ChromeVox.msgs.getMsg(msgId + '_brl', msgArgs) || msg;
977         } catch(e) {}
979         if (!msg) {
980           console.error('Could not get message ' + msgId);
981           return;
982         }
984         if (isPluralized) {
985           var arg = tree.firstChild;
986           if (!arg || arg.nextSibling) {
987             console.error('Pluralized messages take exactly one argument');
988             return;
989           }
990           if (arg.value[0] != '$') {
991             console.error('Unexpected value: ' + arg.value);
992             return;
993           }
994           var argBuff = [];
995           this.format_(node, arg, argBuff);
996           var namedArgs = {COUNT: Number(argBuff[0])};
997           msg = new goog.i18n.MessageFormat(msg).format(namedArgs);
998         }
1000         this.append_(buff, msg, options);
1001       } else if (prefix == '!') {
1002         this.speechProperties_[token] = true;
1003       }
1004     }.bind(this));
1005   },
1007   /**
1008    * @param {!cursors.Range} range
1009    * @param {cursors.Range} prevRange
1010    * @param {chrome.automation.EventType|string} type
1011    * @param {!Array<cvox.Spannable>} rangeBuff
1012    * @private
1013    */
1014   range_: function(range, prevRange, type, rangeBuff) {
1015     if (!prevRange)
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) {
1022       var buff = [];
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);
1027       return buff;
1028     }.bind(this);
1030     while (cursor.node != range.end.node) {
1031       var node = cursor.node;
1032       rangeBuff.push.apply(rangeBuff, formatNodeAndAncestors(node, prevNode));
1033       prevNode = node;
1034       cursor = cursor.move(cursors.Unit.NODE,
1035                            cursors.Movement.DIRECTIONAL,
1036                            Dir.FORWARD);
1038       // Reached a boundary.
1039       if (cursor.node == prevNode)
1040         break;
1041     }
1042     var lastNode = range.end.node;
1043     rangeBuff.push.apply(rangeBuff, formatNodeAndAncestors(lastNode, prevNode));
1044   },
1046   /**
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
1052    * processing.
1053    * @private
1054    */
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;
1075     };
1077     for (var i = 0, formatPrevNode;
1078          (formatPrevNode = prevUniqueAncestors[i]);
1079          i++) {
1080       var roleBlock = getMergedRoleBlock(formatPrevNode.role);
1081       if (roleBlock.leave)
1082         this.format_(formatPrevNode, roleBlock.leave, buff, opt_exclude);
1083     }
1085     var enterOutputs = [];
1086     var enterRole = {};
1087     for (var j = uniqueAncestors.length - 2, formatNode;
1088          (formatNode = uniqueAncestors[j]);
1089          j--) {
1090       var roleBlock = getMergedRoleBlock(formatNode.role);
1091       if (roleBlock.enter) {
1092         if (enterRole[formatNode.role])
1093           continue;
1094         enterRole[formatNode.role] = true;
1095         var tempBuff = [];
1096         this.format_(formatNode, roleBlock.enter, tempBuff, opt_exclude);
1097         enterOutputs.unshift(tempBuff);
1098       }
1099       if (formatNode.role == 'window')
1100         break;
1101     }
1102     enterOutputs.forEach(function(b) {
1103       buff.push.apply(buff, b);
1104     });
1106     if (!opt_exclude.stay) {
1107       var commonFormatNode = uniqueAncestors[0];
1108       while (commonFormatNode && commonFormatNode.parent) {
1109         commonFormatNode = commonFormatNode.parent;
1110         var roleBlock =
1111             eventBlock[commonFormatNode.role] || eventBlock['default'];
1112         if (roleBlock.stay)
1113           this.format_(commonFormatNode, roleBlock.stay, buff, opt_exclude);
1114       }
1115     }
1116   },
1118   /**
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
1123    * @private
1124    */
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);
1131   },
1133   /**
1134    * @param {!cursors.Range} range
1135    * @param {cursors.Range} prevRange
1136    * @param {chrome.automation.EventType|string} type
1137    * @param {!Array<cvox.Spannable>} buff
1138    * @private
1139    */
1140   subNode_: function(range, prevRange, type, buff) {
1141     if (!prevRange)
1142       prevRange = range;
1143     var dir = cursors.Range.getDirection(prevRange, range);
1144     var prevNode = prevRange.getBound(dir).node;
1145     this.ancestry_(
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)
1151       endIndex++;
1152     this.append_(
1153         buff, range.start.getText().substring(startIndex, endIndex));
1154   },
1156   /**
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
1162    */
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)
1168       return;
1170     var spannableToAdd = new cvox.Spannable(value);
1171     opt_options.annotation.forEach(function(a) {
1172       spannableToAdd.setSpan(a, 0, spannableToAdd.getLength());
1173     });
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);
1180           });
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)
1186             return false;
1187           return s.substring(start, end).toString() == value.toString();
1188         });
1189       });
1190       if (alreadyAnnotated)
1191         return;
1192     }
1194     buff.push(spannableToAdd);
1195   },
1197   /**
1198    * Parses the token containing a custom function and returns a tree.
1199    * @param {string} inputStr
1200    * @return {Object}
1201    * @private
1202    */
1203   createParseTree_: function(inputStr) {
1204     var root = {value: ''};
1205     var currentNode = root;
1206     var index = 0;
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] == '{') {
1216         braceNesting++;
1217         currentNode.value += inputStr[index];
1218       } else if (inputStr[index] == '}') {
1219         braceNesting--;
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;
1225       } else {
1226         currentNode.value += inputStr[index];
1227       }
1228       index++;
1229     }
1231     if (currentNode != root)
1232       throw 'Unbalanced parenthesis.';
1234     return root;
1235   },
1237   /**
1238    * Converts the currently rendered braille buffers to a single spannable.
1239    * @return {!cvox.Spannable}
1240    * @private
1241    */
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))
1254         return;
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
1267       // the opposite).
1268       result.getSpansInstanceOf(Output.NodeSpan).forEach(function(leftSpan) {
1269         if (result.getSpanEnd(leftSpan) < result.getLength())
1270           return;
1271         var newEnd = result.getLength();
1272         cur.getSpansInstanceOf(Output.NodeSpan).forEach(function(rightSpan) {
1273           if (cur.getSpanStart(rightSpan) == 0 &&
1274               leftSpan.node === rightSpan.node) {
1275             newEnd = Math.max(
1276                 newEnd,
1277                 result.getLength() + separator.length +
1278                     cur.getSpanEnd(rightSpan));
1279             spansToRemove.push(rightSpan);
1280           }
1281         });
1282         if (newEnd > result.getLength())
1283           spansToExtend.push({span: leftSpan, end: newEnd});
1284       });
1285       result.append(separator);
1286       result.append(cur);
1287       spansToExtend.forEach(function(elem) {
1288         result.setSpan(
1289             elem.span,
1290             // Cast ok, since span is known to exist.
1291             /** @type {number} */ (result.getSpanStart(elem.span)),
1292             elem.end);
1293       });
1294       spansToRemove.forEach(result.removeSpan.bind(result));
1295       separator = Output.SPACE;
1296     });
1297     return result;
1298   }
1301 });  // goog.scope