[sql] Remove _HAS_EXCEPTIONS=0 from build info.
[chromium-blink-merge.git] / chrome / browser / resources / chromeos / chromevox / cvox2 / background / output.js
blobdb3359974c8a15c6338639dbc59e47dc3e20f9ff
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('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');
21 goog.require('goog.i18n.MessageFormat');
23 goog.scope(function() {
24 var Dir = AutomationUtil.Dir;
26 /**
27  * An Output object formats a cursors.Range into speech, braille, or both
28  * representations. This is typically a cvox.Spannable.
29  *
30  * The translation from Range to these output representations rely upon format
31  * rules which specify how to convert AutomationNode objects into annotated
32  * strings.
33  * The format of these rules is as follows.
34  *
35  * $ prefix: used to substitute either an attribute or a specialized value from
36  *     an AutomationNode. Specialized values include role and state.
37  *     For example, $value $role $enabled
38  * @ prefix: used to substitute a message. Note the ability to specify params to
39  *     the message.  For example, '@tag_html' '@selected_index($text_sel_start,
40  *     $text_sel_end').
41  * @@ prefix: similar to @, used to substitute a message, but also pulls the
42  *     localized string through goog.i18n.MessageFormat to support locale
43  *     aware plural handling.  The first argument should be a number which will
44  *     be passed as a COUNT named parameter to MessageFormat.
45  *     TODO(plundblad): Make subsequent arguments normal placeholder arguments
46  *     when needed.
47  * = suffix: used to specify substitution only if not previously appended.
48  *     For example, $name= would insert the name attribute only if no name
49  * attribute had been inserted previously.
50  * @constructor
51  */
52 Output = function() {
53   // TODO(dtseng): Include braille specific rules.
54   /** @type {!Array<cvox.Spannable>} */
55   this.speechBuffer_ = [];
56   /** @type {!Array<cvox.Spannable>} */
57   this.brailleBuffer_ = [];
58   /** @type {!Array<Object>} */
59   this.locations_ = [];
60   /** @type {function(?)} */
61   this.speechEndCallback_;
63   /**
64    * Current global options.
65    * @type {{speech: boolean, braille: boolean, location: boolean}}
66    */
67   this.formatOptions_ = {speech: true, braille: false, location: true};
69   /**
70    * Speech properties to apply to the entire output.
71    * @type {!Object<*>}
72    */
73   this.speechProperties_ = {};
76 /**
77  * Delimiter to use between output values.
78  * @type {string}
79  */
80 Output.SPACE = ' ';
82 /**
83  * Metadata about supported automation roles.
84  * @const {Object<{msgId: string,
85  *                 earconId: (string|undefined),
86  *                 inherits: (string|undefined)}>}
87  * msgId: the message id of the role.
88  * earconId: an optional earcon to play when encountering the role.
89  * inherits: inherits rules from this role.
90  * @private
91  */
92 Output.ROLE_INFO_ = {
93   alert: {
94     msgId: 'aria_role_alert',
95     earconId: 'ALERT_NONMODAL',
96   },
97   alertDialog: {
98     msgId: 'aria_role_alertdialog'
99   },
100   article: {
101     msgId: 'aria_role_article',
102     inherits: 'abstractContainer'
103   },
104   application: {
105     msgId: 'aria_role_application',
106     inherits: 'abstractContainer'
107   },
108   banner: {
109     msgId: 'aria_role_banner',
110     inherits: 'abstractContainer'
111   },
112   button: {
113     msgId: 'tag_button',
114     earconId: 'BUTTON'
115   },
116   buttonDropDown: {
117     msgId: 'tag_button',
118     earconId: 'BUTTON'
119   },
120   cell: {
121     msgId: 'aria_role_gridcell'
122   },
123   checkBox: {
124     msgId: 'input_type_checkbox'
125   },
126   columnHeader: {
127     msgId: 'aria_role_columnheader',
128     inherits: 'abstractContainer'
129   },
130   comboBox: {
131     msgId: 'aria_role_combobox'
132   },
133   complementary: {
134     msgId: 'aria_role_complementary',
135     inherits: 'abstractContainer'
136   },
137   contentInfo: {
138     msgId: 'aria_role_contentinfo',
139     inherits: 'abstractContainer'
140   },
141   date: {
142     msgId: 'input_type_date',
143     inherits: 'abstractContainer'
144   },
145   definition: {
146     msgId: 'aria_role_definition',
147     inherits: 'abstractContainer'
148   },
149   dialog: {
150     msgId: 'dialog'
151   },
152   directory: {
153     msgId: 'aria_role_directory',
154     inherits: 'abstractContainer'
155   },
156   document: {
157     msgId: 'aria_role_document',
158     inherits: 'abstractContainer'
159   },
160   form: {
161     msgId: 'aria_role_form',
162     inherits: 'abstractContainer'
163   },
164   grid: {
165     msgId: 'aria_role_grid'
166   },
167   group: {
168     msgId: 'aria_role_group'
169   },
170   heading: {
171     msgId: 'aria_role_heading',
172   },
173   image: {
174     msgId: 'aria_role_img',
175   },
176   inputTime: {
177     msgId: 'input_type_time',
178     inherits: 'abstractContainer'
179   },
180   link: {
181     msgId: 'tag_link',
182     earconId: 'LINK'
183   },
184   listBox: {
185     msgId: 'aria_role_listbox',
186     earconId: 'LISTBOX'
187   },
188   listBoxOption: {
189     msgId: 'aria_role_listitem',
190     earconId: 'LIST_ITEM'
191   },
192   listItem: {
193     msgId: 'aria_role_listitem',
194     earconId: 'LIST_ITEM'
195   },
196   log: {
197     msgId: 'aria_role_log',
198   },
199   main: {
200     msgId: 'aria_role_main',
201     inherits: 'abstractContainer'
202   },
203   marquee: {
204     msgId: 'aria_role_marquee',
205   },
206   math: {
207     msgId: 'aria_role_math',
208     inherits: 'abstractContainer'
209   },
210   menu: {
211     msgId: 'aria_role_menu'
212   },
213   menuBar: {
214     msgId: 'aria_role_menubar',
215   },
216   menuItem: {
217     msgId: 'aria_role_menuitem'
218   },
219   menuItemCheckBox: {
220     msgId: 'aria_role_menuitemcheckbox'
221   },
222   menuItemRadio: {
223     msgId: 'aria_role_menuitemradio'
224   },
225   menuListOption: {
226     msgId: 'aria_role_menuitem'
227   },
228   menuListPopup: {
229     msgId: 'aria_role_menu'
230   },
231   navigation: {
232     msgId: 'aria_role_navigation',
233     inherits: 'abstractContainer'
234   },
235   note: {
236     msgId: 'aria_role_note',
237     inherits: 'abstractContainer'
238   },
239   popUpButton: {
240     msgId: 'tag_button',
241     earcon: 'LISTBOX'
242   },
243   radioButton: {
244     msgId: 'input_type_radio'
245   },
246   radioGroup: {
247     msgId: 'aria_role_radiogroup',
248   },
249   region: {
250     msgId: 'aria_role_region',
251     inherits: 'abstractContainer'
252   },
253   rowHeader: {
254     msgId: 'aria_role_rowheader',
255     inherits: 'abstractContainer'
256   },
257   scrollBar: {
258     msgId: 'aria_role_scrollbar',
259   },
260   search: {
261     msgId: 'aria_role_search',
262     inherits: 'abstractContainer'
263   },
264   separator: {
265     msgId: 'aria_role_separator',
266     inherits: 'abstractContainer'
267   },
268   spinButton: {
269     msgId: 'aria_role_spinbutton',
270     earconId: 'LISTBOX'
271   },
272   status: {
273     msgId: 'aria_role_status'
274   },
275   tab: {
276     msgId: 'aria_role_tab'
277   },
278   tabList: {
279     msgId: 'aria_role_tablist'
280   },
281   tabPanel: {
282     msgId: 'aria_role_tabpanel'
283   },
284   textBox: {
285     msgId: 'input_type_text',
286     earconId: 'EDITABLE_TEXT'
287   },
288   textField: {
289     msgId: 'input_type_text',
290     earconId: 'EDITABLE_TEXT'
291   },
292   time: {
293     msgId: 'tag_time',
294     inherits: 'abstractContainer'
295   },
296   timer: {
297     msgId: 'aria_role_timer'
298   },
299   toolbar: {
300     msgId: 'aria_role_toolbar'
301   },
302   tree: {
303     msgId: 'aria_role_tree'
304   },
305   treeItem: {
306     msgId: 'aria_role_treeitem'
307   }
311  * Metadata about supported automation states.
312  * @const {!Object<{on: {msgId: string, earconId: string},
313  *                  off: {msgId: string, earconId: string},
314  *                  omitted: {msgId: string, earconId: string}}>}
315  *     on: info used to describe a state that is set to true.
316  *     off: info used to describe a state that is set to false.
317  *     omitted: info used to describe a state that is undefined.
318  * @private
319  */
320 Output.STATE_INFO_ = {
321   checked: {
322     on: {
323       earconId: 'CHECK_ON',
324       msgId: 'checkbox_checked_state'
325     },
326     off: {
327       earconId: 'CHECK_OFF',
328       msgId: 'checkbox_unchecked_state'
329     },
330     omitted: {
331       earconId: 'CHECK_OFF',
332       msgId: 'checkbox_unchecked_state'
333     }
334   },
335   collapsed: {
336     on: {
337       msgId: 'aria_expanded_false'
338     },
339     off: {
340       msgId: 'aria_expanded_true'
341     }
342   },
343   expanded: {
344     on: {
345       msgId: 'aria_expanded_true'
346     },
347     off: {
348       msgId: 'aria_expanded_false'
349     }
350   },
351   visited: {
352     on: {
353       msgId: 'visited_state'
354     }
355   }
359  * Rules specifying format of AutomationNodes for output.
360  * @type {!Object<Object<Object<string>>>}
361  */
362 Output.RULES = {
363   navigate: {
364     'default': {
365       speak: '$name $value $description $help $role',
366       braille: ''
367     },
368     abstractContainer: {
369       enter: '$name $role',
370       leave: '@exited_container($role)'
371     },
372     alert: {
373       speak: '!doNotInterrupt $role $descendants'
374     },
375     alertDialog: {
376       enter: '$name $role $descendants'
377     },
378     cell: {
379       enter: '@column_granularity $tableCellColumnIndex'
380     },
381     checkBox: {
382       speak: '$name $role $checked'
383     },
384     dialog: {
385       enter: '$name $role'
386     },
387     grid: {
388       enter: '$name $role'
389     },
390     heading: {
391       enter: '@tag_h+$hierarchicalLevel',
392       speak: '@tag_h+$hierarchicalLevel $nameOrDescendants='
393     },
394     inlineTextBox: {
395       speak: '$value='
396     },
397     link: {
398       enter: '$name $if($visited, @visited_link, $role)',
399       stay: '$name= $if($visited, @visited_link, $role)',
400       speak: '$name= $if($visited, @visited_link, $role)'
401     },
402     list: {
403       enter: '$role @@list_with_items($countChildren(listItem))'
404     },
405     listBox: {
406       enter: '$name $role @@list_with_items($countChildren(listBoxOption))'
407     },
408     listBoxOption: {
409       speak: '$name $role @describe_index($indexInParent, $parentChildCount)'
410     },
411     listItem: {
412       enter: '$role'
413     },
414     menu: {
415       enter: '$name $role @@list_with_items($countChildren(menuItem))'
416     },
417     menuItem: {
418       speak: '$name $role $if($haspopup, @has_submenu) ' +
419           '@describe_index($indexInParent, $parentChildCount)'
420     },
421     menuListOption: {
422       speak: '$name $value @aria_role_menuitem ' +
423           '@describe_index($indexInParent, $parentChildCount)'
424     },
425     paragraph: {
426       speak: '$value'
427     },
428     popUpButton: {
429       speak: '$value $name $role @aria_has_popup ' +
430           '$if($collapsed, @aria_expanded_false, @aria_expanded_true)'
431     },
432     radioButton: {
433       speak: '$if($checked, @describe_radio_selected($name), ' +
434           '@describe_radio_unselected($name))'
435     },
436     radioGroup: {
437       enter: '$name $role'
438     },
439     row: {
440       enter: '@row_granularity $tableRowIndex'
441     },
442     slider: {
443       speak: '@describe_slider($value, $name) $help'
444     },
445     staticText: {
446       speak: '$value $name'
447     },
448     tab: {
449       speak: '@describe_tab($name)'
450     },
451     textField: {
452       speak: '$name $value $if(' +
453           '$inputType, @input_type_+$inputType, @input_type_text)',
454       braille: ''
455     },
456     toolbar: {
457       enter: '$name $role'
458     },
459     tree: {
460       enter: '$name $role @@list_with_items($countChildren(treeItem))'
461     },
462     treeItem: {
463       enter: '$role $expanded $collapsed ' +
464           '@describe_index($indexInParent, $parentChildCount) ' +
465           '@describe_depth($hierarchicalLevel)'
466     },
467     window: {
468       enter: '$name',
469       speak: '@describe_window($name) $earcon(OBJECT_OPEN)'
470     }
471   },
472   menuStart: {
473     'default': {
474       speak: '@chrome_menu_opened($name)  $earcon(OBJECT_OPEN)'
475     }
476   },
477   menuEnd: {
478     'default': {
479       speak: '@chrome_menu_closed $earcon(OBJECT_CLOSE)'
480     }
481   },
482   menuListValueChanged: {
483     'default': {
484       speak: '$value $name ' +
485           '$find({"state": {"selected": true, "invisible": false}}, ' +
486               '@describe_index($indexInParent, $parentChildCount)) '
487     }
488   },
489   alert: {
490     default: {
491       speak: '!doNotInterrupt ' +
492           '@aria_role_alert $name $earcon(ALERT_NONMODAL) $descendants'
493     }
494   }
498  * Custom actions performed while rendering an output string.
499  * @constructor
500  */
501 Output.Action = function() {
504 Output.Action.prototype = {
505   run: function() {
506   }
510  * Action to play an earcon.
511  * @param {string} earconId
512  * @constructor
513  * @extends {Output.Action}
514  */
515 Output.EarconAction = function(earconId) {
516   Output.Action.call(this);
517   /** @type {string} */
518   this.earconId = earconId;
521 Output.EarconAction.prototype = {
522   __proto__: Output.Action.prototype,
524   /** @override */
525   run: function() {
526     cvox.ChromeVox.earcons.playEarcon(cvox.AbstractEarcons[this.earconId]);
527   }
531  * Annotation for selection.
532  * @param {number} startIndex
533  * @param {number} endIndex
534  * @constructor
535  */
536 Output.SelectionSpan = function(startIndex, endIndex) {
537   // TODO(dtseng): Direction lost below; should preserve for braille panning.
538   this.startIndex = startIndex < endIndex ? startIndex : endIndex;
539   this.endIndex = endIndex > startIndex ? endIndex : startIndex;
543  * Possible events handled by ChromeVox internally.
544  * @enum {string}
545  */
546 Output.EventType = {
547   NAVIGATE: 'navigate'
550 Output.prototype = {
551   /**
552    * Gets the spoken output with separator '|'.
553    * @return {!cvox.Spannable}
554    */
555   get speechOutputForTest() {
556     return this.speechBuffer_.reduce(function(prev, cur) {
557       if (prev === null)
558         return cur;
559       prev.append('|');
560       prev.append(cur);
561       return prev;
562     }, null);
563   },
565   /**
566    * Gets the output buffer for braille.
567    * @return {!cvox.Spannable}
568    */
569   get brailleOutputForTest() {
570     return this.createBrailleOutput_();
571   },
573   /**
574    * Specify ranges for speech.
575    * @param {!cursors.Range} range
576    * @param {cursors.Range} prevRange
577    * @param {chrome.automation.EventType|Output.EventType} type
578    * @return {!Output}
579    */
580   withSpeech: function(range, prevRange, type) {
581     this.formatOptions_ = {speech: true, braille: false, location: true};
582     this.render_(range, prevRange, type, this.speechBuffer_);
583     return this;
584   },
586   /**
587    * Specify ranges for braille.
588    * @param {!cursors.Range} range
589    * @param {cursors.Range} prevRange
590    * @param {chrome.automation.EventType|Output.EventType} type
591    * @return {!Output}
592    */
593   withBraille: function(range, prevRange, type) {
594     this.formatOptions_ = {speech: false, braille: true, location: false};
595     this.render_(range, prevRange, type, this.brailleBuffer_);
596     return this;
597   },
599   /**
600    * Specify the same ranges for speech and braille.
601    * @param {!cursors.Range} range
602    * @param {cursors.Range} prevRange
603    * @param {chrome.automation.EventType|Output.EventType} type
604    * @return {!Output}
605    */
606   withSpeechAndBraille: function(range, prevRange, type) {
607     this.withSpeech(range, prevRange, type);
608     this.withBraille(range, prevRange, type);
609     return this;
610   },
612   /**
613    * Apply a format string directly to the output buffer. This lets you
614    * output a message directly to the buffer using the format syntax.
615    * @param {string} formatStr
616    * @return {!Output}
617    */
618   format: function(formatStr) {
619     this.formatOptions_ = {speech: true, braille: false, location: true};
620     this.format_(null, formatStr, this.speechBuffer_);
622     this.formatOptions_ = {speech: false, braille: true, location: false};
623     this.format_(null, formatStr, this.brailleBuffer_);
625     return this;
626   },
628   /**
629    * Triggers callback for a speech event.
630    * @param {function()} callback
631    */
632   onSpeechEnd: function(callback) {
633     this.speechEndCallback_ = function(opt_cleanupOnly) {
634       if (!opt_cleanupOnly)
635         callback();
636     }.bind(this);
637     return this;
638   },
640   /**
641    * Executes all specified output.
642    */
643   go: function() {
644     // Speech.
645     var queueMode = cvox.QueueMode.FLUSH;
646     this.speechBuffer_.forEach(function(buff, i, a) {
647       if (buff.toString()) {
648         (function() {
649           var scopedBuff = buff;
650           this.speechProperties_['startCallback'] = function() {
651             var actions = scopedBuff.getSpansInstanceOf(Output.Action);
652             if (actions) {
653               actions.forEach(function(a) {
654                 a.run();
655               });
656             }
657           };
658         }.bind(this)());
660         if (this.speechEndCallback_ && i == a.length - 1)
661           this.speechProperties_['endCallback'] = this.speechEndCallback_;
662         else
663           this.speechProperties_['endCallback'] = null;
664         cvox.ChromeVox.tts.speak(
665             buff.toString(), queueMode, this.speechProperties_);
666         queueMode = cvox.QueueMode.QUEUE;
667       }
668     }.bind(this));
670     // Braille.
671     if (this.brailleBuffer_.length) {
672       var buff = this.createBrailleOutput_();
673       var selSpan =
674           buff.getSpanInstanceOf(Output.SelectionSpan);
675       var startIndex = -1, endIndex = -1;
676       if (selSpan) {
677         // Casts ok, since the span is known to be in the spannable.
678         var valueStart =
679             /** @type {number} */ (buff.getSpanStart(selSpan));
680         var valueEnd =
681             /** @type {number} */ (buff.getSpanEnd(selSpan));
682         startIndex = valueStart + selSpan.startIndex;
683         endIndex = valueStart + selSpan.endIndex;
684         buff.setSpan(new cvox.ValueSpan(0), valueStart, valueEnd);
685         buff.setSpan(new cvox.ValueSelectionSpan(), startIndex, endIndex);
686       }
688       var output = new cvox.NavBraille({
689         text: buff,
690         startIndex: startIndex,
691         endIndex: endIndex
692       });
694       cvox.ChromeVox.braille.write(output);
695     }
697     // Display.
698     chrome.accessibilityPrivate.setFocusRing(this.locations_);
699   },
701   /**
702    * Renders the given range using optional context previous range and event
703    * type.
704    * @param {!cursors.Range} range
705    * @param {cursors.Range} prevRange
706    * @param {chrome.automation.EventType|string} type
707    * @param {!Array<cvox.Spannable>} buff Buffer to receive rendered output.
708    * @private
709    */
710   render_: function(range, prevRange, type, buff) {
711     if (range.isSubNode())
712       this.subNode_(range, prevRange, type, buff);
713     else
714       this.range_(range, prevRange, type, buff);
715   },
717   /**
718    * Format the node given the format specifier.
719    * @param {chrome.automation.AutomationNode} node
720    * @param {string|!Object} format The output format either specified as an
721    * output template string or a parsed output format tree.
722    * @param {!Array<cvox.Spannable>} buff Buffer to receive rendered output.
723    * @param {!Object=} opt_exclude A set of attributes to exclude.
724    * @private
725    */
726   format_: function(node, format, buff, opt_exclude) {
727     opt_exclude = opt_exclude || {};
728     var tokens = [];
729     var args = null;
731     // Hacky way to support args.
732     if (typeof(format) == 'string') {
733       format = format.replace(/([,:])\W/g, '$1');
734       tokens = format.split(' ');
735     } else {
736       tokens = [format];
737     }
739     tokens.forEach(function(token) {
740       // Ignore empty tokens.
741       if (!token)
742         return;
744       // Parse the token.
745       var tree;
746       if (typeof(token) == 'string')
747         tree = this.createParseTree_(token);
748       else
749         tree = token;
751       // Obtain the operator token.
752       token = tree.value;
754       // Set suffix options.
755       var options = {};
756       options.annotation = [];
757       options.isUnique = token[token.length - 1] == '=';
758       if (options.isUnique)
759         token = token.substring(0, token.length - 1);
761       // Process token based on prefix.
762       var prefix = token[0];
763       token = token.slice(1);
765       if (opt_exclude[token])
766         return;
768       // All possible tokens based on prefix.
769       if (prefix == '$') {
770         if (token == 'value') {
771           var text = node.value;
772           if (text !== undefined) {
773             if (node.textSelStart !== undefined) {
774               options.annotation.push(new Output.SelectionSpan(
775                   node.textSelStart,
776                   node.textSelEnd));
777             }
778           }
779           // Annotate this as a name so we don't duplicate names from ancestors.
780           if (node.role == chrome.automation.RoleType.inlineTextBox)
781             token = 'name';
782           options.annotation.push(token);
783           this.append_(buff, text, options);
784         } else if (token == 'name') {
785           options.annotation.push(token);
786           if (this.formatOptions_.speech) {
787             var earconFinder = node;
788             while (earconFinder) {
789               var info = Output.ROLE_INFO_[earconFinder.role];
790               if (info && info.earconId) {
791                 options.annotation.push(
792                     new Output.EarconAction(info.earconId));
793                 break;
794               }
795               earconFinder = earconFinder.parent;
796             }
797           }
799           // Pending finalization of name calculation; we must use the
800           // attributes property to access aria-label. See crbug.com/473220.
801           node.attributes = node.attributes || {};
802           var resolvedName = node.name || node.attributes['aria-label'];
803           this.append_(buff, resolvedName, options);
804         } else if (token == 'nameOrDescendants') {
805           options.annotation.push(token);
806           if (node.name)
807             this.append_(buff, node.name, options);
808           else
809             this.format_(node, '$descendants', buff);
810         } else if (token == 'indexInParent') {
811           options.annotation.push(token);
812           this.append_(buff, String(node.indexInParent + 1));
813         } else if (token == 'parentChildCount') {
814           options.annotation.push(token);
815           if (node.parent)
816             this.append_(buff, String(node.parent.children.length));
817         } else if (token == 'state') {
818           options.annotation.push(token);
819           Object.getOwnPropertyNames(node.state).forEach(function(s) {
820             this.append_(buff, s, options);
821           }.bind(this));
822         } else if (token == 'find') {
823           // Find takes two arguments: JSON query string and format string.
824           if (tree.firstChild) {
825             var jsonQuery = tree.firstChild.value;
826             node = node.find(
827                 /** @type {Object}*/(JSON.parse(jsonQuery)));
828             var formatString = tree.firstChild.nextSibling;
829             if (node)
830               this.format_(node, formatString, buff);
831           }
832         } else if (token == 'descendants') {
833           if (AutomationPredicate.leaf(node))
834             return;
836           // Construct a range to the leftmost and rightmost leaves.
837           var leftmost = AutomationUtil.findNodePre(
838               node, Dir.FORWARD, AutomationPredicate.leaf);
839           var rightmost = AutomationUtil.findNodePre(
840               node, Dir.BACKWARD, AutomationPredicate.leaf);
841           if (!leftmost || !rightmost)
842             return;
844           var subrange = new cursors.Range(
845               new cursors.Cursor(leftmost, 0),
846               new cursors.Cursor(rightmost, 0));
847           var prev = null;
848           if (node)
849             prev = cursors.Range.fromNode(node);
850           this.range_(subrange, prev, 'navigate', buff);
851         } else if (token == 'role') {
852           options.annotation.push(token);
853           var msg = node.role;
854           var info = Output.ROLE_INFO_[node.role];
855           if (info) {
856             if (this.formatOptions_.braille)
857               msg = cvox.ChromeVox.msgs.getMsg(info.msgId + '_brl');
858             else
859               msg = cvox.ChromeVox.msgs.getMsg(info.msgId);
860           } else {
861             console.error('Missing role info for ' + node.role);
862           }
863           this.append_(buff, msg, options);
864         } else if (token == 'tableRowIndex' ||
865             token == 'tableCellColumnIndex') {
866           var value = node[token];
867           if (!value)
868             return;
869           value = String(value + 1);
870           options.annotation.push(token);
871           this.append_(buff, value, options);
872         } else if (node[token] !== undefined) {
873           options.annotation.push(token);
874           var value = node[token];
875           if (typeof value == 'number')
876             value = String(value);
877           this.append_(buff, value, options);
878         } else if (Output.STATE_INFO_[token]) {
879           options.annotation.push('state');
880           var stateInfo = Output.STATE_INFO_[token];
881           var resolvedInfo = {};
882           if (node.state[token] === undefined)
883             resolvedInfo = stateInfo.omitted;
884           else
885             resolvedInfo = node.state[token] ? stateInfo.on : stateInfo.off;
886           if (!resolvedInfo)
887             return;
888           if (this.formatOptions_.speech && resolvedInfo.earconId) {
889             options.annotation.push(
890                 new Output.EarconAction(resolvedInfo.earconId));
891           }
892           var msgId =
893               this.formatOptions_.braille ? resolvedInfo.msgId + '_brl' :
894               resolvedInfo.msgId;
895           var msg = cvox.ChromeVox.msgs.getMsg(msgId);
896           this.append_(buff, msg, options);
897         } else if (tree.firstChild) {
898           // Custom functions.
899           if (token == 'if') {
900             var cond = tree.firstChild;
901             var attrib = cond.value.slice(1);
902             if (node[attrib] || node.state[attrib])
903               this.format_(node, cond.nextSibling, buff);
904             else
905               this.format_(node, cond.nextSibling.nextSibling, buff);
906           } else if (token == 'earcon') {
907             // Ignore unless we're generating speech output.
908             if (!this.formatOptions_.speech)
909               return;
910             // Assumes there's existing output in our buffer.
911             var lastBuff = buff[buff.length - 1];
912             if (!lastBuff)
913               return;
915             lastBuff.setSpan(
916                 new Output.EarconAction(tree.firstChild.value), 0, 0);
917           } else if (token == 'countChildren') {
918             var role = tree.firstChild.value;
919             var count = node.children.filter(function(e) {
920               return e.role == role;
921             }).length;
922             this.append_(buff, String(count));
923           }
924         }
925       } else if (prefix == '@') {
926         var isPluralized = (token[0] == '@');
927         if (isPluralized)
928           token = token.slice(1);
929         // Tokens can have substitutions.
930         var pieces = token.split('+');
931         token = pieces.reduce(function(prev, cur) {
932           var lookup = cur;
933           if (cur[0] == '$')
934             lookup = node[cur.slice(1)];
935           return prev + lookup;
936         }.bind(this), '');
937         var msgId = token;
938         var msgArgs = [];
939         if (!isPluralized) {
940           var curArg = tree.firstChild;
941           while (curArg) {
942             if (curArg.value[0] != '$') {
943               console.error('Unexpected value: ' + curArg.value);
944               return;
945             }
946             var msgBuff = [];
947             this.format_(node, curArg, msgBuff);
948             msgArgs = msgArgs.concat(msgBuff);
949             curArg = curArg.nextSibling;
950           }
951         }
952         var msg = cvox.ChromeVox.msgs.getMsg(msgId, msgArgs);
953         try {
954           if (this.formatOptions_.braille)
955             msg = cvox.ChromeVox.msgs.getMsg(msgId + '_brl', msgArgs) || msg;
956         } catch(e) {}
958         if (!msg) {
959           console.log('Could not get message ' + msgId);
960           return;
961         }
963         if (isPluralized) {
964           var arg = tree.firstChild;
965           if (!arg || arg.nextSibling) {
966             console.error('Pluralized messages take exactly one argument');
967             return;
968           }
969           if (arg.value[0] != '$') {
970             console.error('Unexpected value: ' + arg.value);
971             return;
972           }
973           var argBuff = [];
974           this.format_(node, arg, argBuff);
975           var namedArgs = {COUNT: Number(argBuff[0])};
976           msg = new goog.i18n.MessageFormat(msg).format(namedArgs);
977         }
979         this.append_(buff, msg, options);
980       } else if (prefix == '!') {
981         this.speechProperties_[token] = true;
982       }
983     }.bind(this));
984   },
986   /**
987    * @param {!cursors.Range} range
988    * @param {cursors.Range} prevRange
989    * @param {chrome.automation.EventType|string} type
990    * @param {!Array<cvox.Spannable>} rangeBuff
991    * @private
992    */
993   range_: function(range, prevRange, type, rangeBuff) {
994     if (!prevRange)
995       prevRange = cursors.Range.fromNode(range.start.node.root);
997     var cursor = range.start;
998     var prevNode = prevRange.start.node;
1000     var formatNodeAndAncestors = function(node, prevNode) {
1001       var buff = [];
1002       this.ancestry_(node, prevNode, type, buff);
1003       this.node_(node, prevNode, type, buff);
1004       if (this.formatOptions_.location)
1005         this.locations_.push(node.location);
1006       return buff;
1007     }.bind(this);
1009     while (cursor.node != range.end.node) {
1010       var node = cursor.node;
1011       rangeBuff.push.apply(rangeBuff, formatNodeAndAncestors(node, prevNode));
1012       prevNode = node;
1013       cursor = cursor.move(cursors.Unit.NODE,
1014                            cursors.Movement.DIRECTIONAL,
1015                            Dir.FORWARD);
1017       // Reached a boundary.
1018       if (cursor.node == prevNode)
1019         break;
1020     }
1021     var lastNode = range.end.node;
1022     rangeBuff.push.apply(rangeBuff, formatNodeAndAncestors(lastNode, prevNode));
1023   },
1025   /**
1026    * @param {!chrome.automation.AutomationNode} node
1027    * @param {!chrome.automation.AutomationNode} prevNode
1028    * @param {chrome.automation.EventType|string} type
1029    * @param {!Array<cvox.Spannable>} buff
1030    * @param {!Object=} opt_exclude A list of attributes to exclude from
1031    * processing.
1032    * @private
1033    */
1034   ancestry_: function(node, prevNode, type, buff, opt_exclude) {
1035     opt_exclude = opt_exclude || {};
1036     var prevUniqueAncestors =
1037         AutomationUtil.getUniqueAncestors(node, prevNode);
1038     var uniqueAncestors = AutomationUtil.getUniqueAncestors(prevNode, node);
1040     // First, look up the event type's format block.
1041     // Navigate is the default event.
1042     var eventBlock = Output.RULES[type] || Output.RULES['navigate'];
1044     var getMergedRoleBlock = function(role) {
1045       var parentRole = (Output.ROLE_INFO_[role] || {}).inherits;
1046       var roleBlock = eventBlock[role] || eventBlock['default'];
1047       var parentRoleBlock = parentRole ? eventBlock[parentRole] : {};
1048       var mergedRoleBlock = {};
1049       for (var key in parentRoleBlock)
1050         mergedRoleBlock[key] = parentRoleBlock[key];
1051       for (var key in roleBlock)
1052         mergedRoleBlock[key] = roleBlock[key];
1053       return mergedRoleBlock;
1054     };
1056     for (var i = 0, formatPrevNode;
1057          (formatPrevNode = prevUniqueAncestors[i]);
1058          i++) {
1059       var roleBlock = getMergedRoleBlock(formatPrevNode.role);
1060       if (roleBlock.leave)
1061         this.format_(formatPrevNode, roleBlock.leave, buff, opt_exclude);
1062     }
1064     var enterOutputs = [];
1065     var enterRole = {};
1066     for (var j = uniqueAncestors.length - 2, formatNode;
1067          (formatNode = uniqueAncestors[j]);
1068          j--) {
1069       var roleBlock = getMergedRoleBlock(formatNode.role);
1070       if (roleBlock.enter) {
1071         if (enterRole[formatNode.role])
1072           continue;
1073         enterRole[formatNode.role] = true;
1074         var tempBuff = [];
1075         this.format_(formatNode, roleBlock.enter, tempBuff, opt_exclude);
1076         enterOutputs.unshift(tempBuff);
1077       }
1078       if (formatNode.role == 'window')
1079         break;
1080     }
1081     enterOutputs.forEach(function(b) {
1082       buff.push.apply(buff, b);
1083     });
1085     if (!opt_exclude.stay) {
1086       var commonFormatNode = uniqueAncestors[0];
1087       while (commonFormatNode && commonFormatNode.parent) {
1088         commonFormatNode = commonFormatNode.parent;
1089         var roleBlock =
1090             eventBlock[commonFormatNode.role] || eventBlock['default'];
1091         if (roleBlock.stay)
1092           this.format_(commonFormatNode, roleBlock.stay, buff, opt_exclude);
1093       }
1094     }
1095   },
1097   /**
1098    * @param {!chrome.automation.AutomationNode} node
1099    * @param {!chrome.automation.AutomationNode} prevNode
1100    * @param {chrome.automation.EventType|string} type
1101    * @param {!Array<cvox.Spannable>} buff
1102    * @private
1103    */
1104   node_: function(node, prevNode, type, buff) {
1105     // Navigate is the default event.
1106     var eventBlock = Output.RULES[type] || Output.RULES['navigate'];
1107     var roleBlock = eventBlock[node.role] || eventBlock['default'];
1108     var speakFormat = roleBlock.speak || eventBlock['default'].speak;
1109     this.format_(node, speakFormat, buff);
1110   },
1112   /**
1113    * @param {!cursors.Range} range
1114    * @param {cursors.Range} prevRange
1115    * @param {chrome.automation.EventType|string} type
1116    * @param {!Array<cvox.Spannable>} buff
1117    * @private
1118    */
1119   subNode_: function(range, prevRange, type, buff) {
1120     if (!prevRange)
1121       prevRange = range;
1122     var dir = cursors.Range.getDirection(prevRange, range);
1123     var prevNode = prevRange.getBound(dir).node;
1124     this.ancestry_(
1125         range.start.node, prevNode, type, buff,
1126         {stay: true, name: true, value: true});
1127     var startIndex = range.start.getIndex();
1128     var endIndex = range.end.getIndex();
1129     if (startIndex === endIndex)
1130       endIndex++;
1131     this.append_(
1132         buff, range.start.getText().substring(startIndex, endIndex));
1133   },
1135   /**
1136    * Appends output to the |buff|.
1137    * @param {!Array<cvox.Spannable>} buff
1138    * @param {string|!cvox.Spannable} value
1139    * @param {{isUnique: (boolean|undefined),
1140    *      annotation: !Array<*>}=} opt_options
1141    */
1142   append_: function(buff, value, opt_options) {
1143     opt_options = opt_options || {isUnique: false, annotation: []};
1145     // Reject empty values without annotations.
1146     if ((!value || value.length == 0) && opt_options.annotation.length == 0)
1147       return;
1149     var spannableToAdd = new cvox.Spannable(value);
1150     opt_options.annotation.forEach(function(a) {
1151       spannableToAdd.setSpan(a, 0, spannableToAdd.getLength());
1152     });
1154     // Early return if the buffer is empty.
1155     if (buff.length == 0) {
1156       buff.push(spannableToAdd);
1157       return;
1158     }
1160     // |isUnique| specifies an annotation that cannot be duplicated.
1161     if (opt_options.isUnique) {
1162       var alreadyAnnotated = buff.some(function(s) {
1163         return opt_options.annotation.some(function(annotation) {
1164           return s.getSpanStart(annotation) != undefined;
1165         });
1166       });
1167       if (alreadyAnnotated)
1168         return;
1169     }
1171     buff.push(spannableToAdd);
1172   },
1174   /**
1175    * Parses the token containing a custom function and returns a tree.
1176    * @param {string} inputStr
1177    * @return {Object}
1178    * @private
1179    */
1180   createParseTree_: function(inputStr) {
1181     var root = {value: ''};
1182     var currentNode = root;
1183     var index = 0;
1184     var braceNesting = 0;
1185     while (index < inputStr.length) {
1186       if (inputStr[index] == '(') {
1187         currentNode.firstChild = {value: ''};
1188         currentNode.firstChild.parent = currentNode;
1189         currentNode = currentNode.firstChild;
1190       } else if (inputStr[index] == ')') {
1191         currentNode = currentNode.parent;
1192       } else if (inputStr[index] == '{') {
1193         braceNesting++;
1194         currentNode.value += inputStr[index];
1195       } else if (inputStr[index] == '}') {
1196         braceNesting--;
1197         currentNode.value += inputStr[index];
1198       } else if (inputStr[index] == ',' && braceNesting === 0) {
1199         currentNode.nextSibling = {value: ''};
1200         currentNode.nextSibling.parent = currentNode.parent;
1201         currentNode = currentNode.nextSibling;
1202       } else {
1203         currentNode.value += inputStr[index];
1204       }
1205       index++;
1206     }
1208     if (currentNode != root)
1209       throw 'Unbalanced parenthesis.';
1211     return root;
1212   },
1214   /**
1215    * Converts the currently rendered braille buffers to a single spannable.
1216    * @return {!cvox.Spannable}
1217    * @private
1218    */
1219   createBrailleOutput_: function() {
1220     return this.brailleBuffer_.reduce(function(prev, cur) {
1221       if (prev.getLength() > 0 && cur.getLength() > 0)
1222         prev.append(Output.SPACE);
1223       prev.append(cur);
1224       return prev;
1225     }, new cvox.Spannable());
1226   }
1229 });  // goog.scope