Home | History | Annotate | Download | only in common
      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.
      4 
      5 /**
      6  * @fileoverview Class which allows construction of annotated strings.
      7  */
      8 
      9 goog.provide('cvox.Spannable');
     10 
     11 goog.require('goog.object');
     12 
     13 /**
     14  * @constructor
     15  * @param {string=} opt_string Initial value of the spannable.
     16  * @param {*=} opt_annotation Initial annotation for the entire string.
     17  */
     18 cvox.Spannable = function(opt_string, opt_annotation) {
     19   /**
     20    * Underlying string.
     21    * @type {string}
     22    * @private
     23    */
     24   this.string_ = opt_string || '';
     25 
     26   /**
     27    * Spans (annotations).
     28    * @type {!Array.<!{ value: *, start: number, end: number }>}
     29    * @private
     30    */
     31   this.spans_ = [];
     32 
     33   // Optionally annotate the entire string.
     34   if (goog.isDef(opt_annotation)) {
     35     var len = this.string_.length;
     36     this.spans_.push({ value: opt_annotation, start: 0, end: len });
     37   }
     38 };
     39 
     40 
     41 /** @override */
     42 cvox.Spannable.prototype.toString = function() {
     43   return this.string_;
     44 };
     45 
     46 
     47 /**
     48  * Returns the length of the string.
     49  * @return {number} Length of the string.
     50  */
     51 cvox.Spannable.prototype.getLength = function() {
     52   return this.string_.length;
     53 };
     54 
     55 
     56 /**
     57  * Adds a span to some region of the string.
     58  * @param {*} value Annotation.
     59  * @param {number} start Starting index (inclusive).
     60  * @param {number} end Ending index (exclusive).
     61  */
     62 cvox.Spannable.prototype.setSpan = function(value, start, end) {
     63   this.removeSpan(value);
     64   if (0 <= start && start <= end && end <= this.string_.length) {
     65     // Zero-length spans are explicitly allowed, because it is possible to
     66     // query for position by annotation as well as the reverse.
     67     this.spans_.push({ value: value, start: start, end: end });
     68   } else {
     69     throw new RangeError('span out of range (start=' + start +
     70         ', end=' + end + ', len=' + this.string_.length + ')');
     71   }
     72 };
     73 
     74 
     75 /**
     76  * Removes a span.
     77  * @param {*} value Annotation.
     78  */
     79 cvox.Spannable.prototype.removeSpan = function(value) {
     80   for (var i = this.spans_.length - 1; i >= 0; i--) {
     81     if (this.spans_[i].value === value) {
     82       this.spans_.splice(i, 1);
     83     }
     84   }
     85 };
     86 
     87 
     88 /**
     89  * Appends another Spannable or string to this one.
     90  * @param {string|!cvox.Spannable} other String or spannable to concatenate.
     91  */
     92 cvox.Spannable.prototype.append = function(other) {
     93   if (other instanceof cvox.Spannable) {
     94     var otherSpannable = /** @type {!cvox.Spannable} */ (other);
     95     var originalLength = this.getLength();
     96     this.string_ += otherSpannable.string_;
     97     other.spans_.forEach(goog.bind(function(span) {
     98       this.setSpan(
     99           span.value,
    100           span.start + originalLength,
    101           span.end + originalLength);
    102     }, this));
    103   } else if (typeof other === 'string') {
    104     this.string_ += /** @type {string} */ (other);
    105   }
    106 };
    107 
    108 
    109 /**
    110  * Returns the first value matching a position.
    111  * @param {number} position Position to query.
    112  * @return {*} Value annotating that position, or undefined if none is found.
    113  */
    114 cvox.Spannable.prototype.getSpan = function(position) {
    115   for (var i = 0; i < this.spans_.length; i++) {
    116     var span = this.spans_[i];
    117     if (span.start <= position && position < span.end) {
    118       return span.value;
    119     }
    120   }
    121 };
    122 
    123 
    124 /**
    125  * Returns the first span value which is an instance of a given constructor.
    126  * @param {!Function} constructor Constructor.
    127  * @return {!Object|undefined} Object if found; undefined otherwise.
    128  */
    129 cvox.Spannable.prototype.getSpanInstanceOf = function(constructor) {
    130   for (var i = 0; i < this.spans_.length; i++) {
    131     var span = this.spans_[i];
    132     if (span.value instanceof constructor) {
    133       return span.value;
    134     }
    135   }
    136 };
    137 
    138 
    139 /**
    140  * Returns all spans matching a position.
    141  * @param {number} position Position to query.
    142  * @return {!Array} Values annotating that position.
    143  */
    144 cvox.Spannable.prototype.getSpans = function(position) {
    145   var results = [];
    146   for (var i = 0; i < this.spans_.length; i++) {
    147     var span = this.spans_[i];
    148     if (span.start <= position && position < span.end) {
    149       results.push(span.value);
    150     }
    151   }
    152   return results;
    153 };
    154 
    155 
    156 /**
    157  * Returns the start of the requested span.
    158  * @param {*} value Annotation.
    159  * @return {number|undefined} Start of the span, or undefined if not attached.
    160  */
    161 cvox.Spannable.prototype.getSpanStart = function(value) {
    162   for (var i = 0; i < this.spans_.length; i++) {
    163     var span = this.spans_[i];
    164     if (span.value === value) {
    165       return span.start;
    166     }
    167   }
    168   return undefined;
    169 };
    170 
    171 
    172 /**
    173  * Returns the end of the requested span.
    174  * @param {*} value Annotation.
    175  * @return {number|undefined} End of the span, or undefined if not attached.
    176  */
    177 cvox.Spannable.prototype.getSpanEnd = function(value) {
    178   for (var i = 0; i < this.spans_.length; i++) {
    179     var span = this.spans_[i];
    180     if (span.value === value) {
    181       return span.end;
    182     }
    183   }
    184   return undefined;
    185 };
    186 
    187 
    188 /**
    189  * Returns a substring of this spannable.
    190  * Note that while similar to String#substring, this function is much less
    191  * permissive about its arguments. It does not accept arguments in the wrong
    192  * order or out of bounds.
    193  *
    194  * @param {number} start Start index, inclusive.
    195  * @param {number=} opt_end End index, exclusive.
    196  *     If excluded, the length of the string is used instead.
    197  * @return {!cvox.Spannable} Substring requested.
    198  */
    199 cvox.Spannable.prototype.substring = function(start, opt_end) {
    200   var end = goog.isDef(opt_end) ? opt_end : this.string_.length;
    201 
    202   if (start < 0 || end > this.string_.length || start > end) {
    203     throw new RangeError('substring indices out of range');
    204   }
    205 
    206   var result = new cvox.Spannable(this.string_.substring(start, end));
    207   for (var i = 0; i < this.spans_.length; i++) {
    208     var span = this.spans_[i];
    209     if (span.start <= end && span.end >= start) {
    210       var newStart = Math.max(0, span.start - start);
    211       var newEnd = Math.min(end - start, span.end - start);
    212       result.spans_.push({ value: span.value, start: newStart, end: newEnd });
    213     }
    214   }
    215   return result;
    216 };
    217 
    218 
    219 /**
    220  * Trims whitespace from the beginning.
    221  * @return {!cvox.Spannable} String with whitespace removed.
    222  */
    223 cvox.Spannable.prototype.trimLeft = function() {
    224   return this.trim_(true, false);
    225 };
    226 
    227 
    228 /**
    229  * Trims whitespace from the end.
    230  * @return {!cvox.Spannable} String with whitespace removed.
    231  */
    232 cvox.Spannable.prototype.trimRight = function() {
    233   return this.trim_(false, true);
    234 };
    235 
    236 
    237 /**
    238  * Trims whitespace from the beginning and end.
    239  * @return {!cvox.Spannable} String with whitespace removed.
    240  */
    241 cvox.Spannable.prototype.trim = function() {
    242   return this.trim_(true, true);
    243 };
    244 
    245 
    246 /**
    247  * Trims whitespace from either the beginning and end or both.
    248  * @param {boolean} trimStart Trims whitespace from the start of a string.
    249  * @param {boolean} trimEnd Trims whitespace from the end of a string.
    250  * @return {!cvox.Spannable} String with whitespace removed.
    251  * @private
    252  */
    253 cvox.Spannable.prototype.trim_ = function(trimStart, trimEnd) {
    254   if (!trimStart && !trimEnd) {
    255     return this;
    256   }
    257 
    258   // Special-case whitespace-only strings, including the empty string.
    259   // As an arbitrary decision, we treat this as trimming the whitespace off the
    260   // end, rather than the beginning, of the string.
    261   // This choice affects which spans are kept.
    262   if (/^\s*$/.test(this.string_)) {
    263     return this.substring(0, 0);
    264   }
    265 
    266   // Otherwise, we have at least one non-whitespace character to use as an
    267   // anchor when trimming.
    268   var trimmedStart = trimStart ? this.string_.match(/^\s*/)[0].length : 0;
    269   var trimmedEnd = trimEnd ?
    270       this.string_.match(/\s*$/).index : this.string_.length;
    271   return this.substring(trimmedStart, trimmedEnd);
    272 };
    273 
    274 
    275 /**
    276  * Returns this spannable to a json serializable form, including the text and
    277  * span objects whose types have been registered with registerSerializableSpan
    278  * or registerStatelessSerializableSpan.
    279  * @return {!cvox.Spannable.SerializedSpannable_} the json serializable form.
    280  */
    281 cvox.Spannable.prototype.toJson = function() {
    282   var result = {};
    283   result.string = this.string_;
    284   result.spans = [];
    285   for (var i = 0; i < this.spans_.length; ++i) {
    286     var span = this.spans_[i];
    287     // Use linear search, since using functions as property keys
    288     // is not reliable.
    289     var serializeInfo = goog.object.findValue(
    290         cvox.Spannable.serializableSpansByName_,
    291         function(v) { return v.ctor === span.value.constructor; });
    292     if (serializeInfo) {
    293       var spanObj = {type: serializeInfo.name,
    294                      start: span.start,
    295                      end: span.end};
    296       if (serializeInfo.toJson) {
    297         spanObj.value = serializeInfo.toJson.apply(span.value);
    298       }
    299       result.spans.push(spanObj);
    300     }
    301   }
    302   return result;
    303 };
    304 
    305 
    306 /**
    307  * Creates a spannable from a json serializable representation.
    308  * @param {!cvox.Spannable.SerializedSpannable_} obj object containing the
    309  *     serializable representation.
    310  * @return {!cvox.Spannable}
    311  */
    312 cvox.Spannable.fromJson = function(obj) {
    313   if (typeof obj.string !== 'string') {
    314     throw 'Invalid spannable json object: string field not a string';
    315   }
    316   if (!(obj.spans instanceof Array)) {
    317     throw 'Invalid spannable json object: no spans array';
    318   }
    319   var result = new cvox.Spannable(obj.string);
    320   for (var i = 0, span; span = obj.spans[i]; ++i) {
    321     if (typeof span.type !== 'string') {
    322       throw 'Invalid span in spannable json object: type not a string';
    323     }
    324     if (typeof span.start !== 'number' || typeof span.end !== 'number') {
    325       throw 'Invalid span in spannable json object: start or end not a number';
    326     }
    327     var serializeInfo = cvox.Spannable.serializableSpansByName_[span.type];
    328     var value = serializeInfo.fromJson(span.value);
    329     result.setSpan(value, span.start, span.end);
    330   }
    331   return result;
    332 };
    333 
    334 
    335 /**
    336  * Registers a type that can be converted to a json serializable format.
    337  * @param {!Function} constructor The type of object that can be converted.
    338  * @param {string} name String identifier used in the serializable format.
    339  * @param {function(!Object): !Object} fromJson A function that converts
    340  *     the serializable object to an actual object of this type.
    341  * @param {function(!Object): !Object} toJson A function that converts
    342  *     this object to a json serializable object.  The function will
    343  *     be called with this set to the object to convert.
    344  */
    345 cvox.Spannable.registerSerializableSpan = function(
    346     constructor, name, fromJson, toJson) {
    347   var obj = {name: name, ctor: constructor,
    348              fromJson: fromJson, toJson: toJson};
    349   cvox.Spannable.serializableSpansByName_[name] = obj;
    350 };
    351 
    352 
    353 /**
    354  * Registers an object type that can be converted to/from a json serializable
    355  * form.  Objects of this type carry no state that will be preserved
    356  * when serialized.
    357  * @param {!Function} constructor The type of the object that can be converted.
    358  *     This constructor will be called with no arguments to construct
    359  *     new objects.
    360  * @param {string} name Name of the type used in the serializable object.
    361  */
    362 cvox.Spannable.registerStatelessSerializableSpan = function(
    363     constructor, name) {
    364   var obj = {name: name, ctor: constructor, toJson: undefined};
    365   /**
    366    * @param {!Object} obj
    367    * @return {!Object}
    368    */
    369   obj.fromJson = function(obj) {
    370      return new constructor();
    371   };
    372   cvox.Spannable.serializableSpansByName_[name] = obj;
    373 };
    374 
    375 
    376 /**
    377  * Describes how to convert a span type to/from serializable json.
    378  * @typedef {{ctor: !Function, name: string,
    379  *             fromJson: function(!Object): !Object,
    380  *             toJson: ((function(!Object): !Object)|undefined)}}
    381  * @private
    382  */
    383 cvox.Spannable.SerializeInfo_;
    384 
    385 
    386 /**
    387  * The serialized format of a spannable.
    388  * @typedef {{string: string, spans: Array.<cvox.Spannable.SerializedSpan_>}}
    389  * @private
    390  */
    391 cvox.Spannable.SerializedSpannable_;
    392 
    393 
    394 /**
    395  * The format of a single annotation in a serialized spannable.
    396  * @typedef {{type: string, value: !Object, start: number, end: number}}
    397  * @private
    398  */
    399 cvox.Spannable.SerializedSpan_;
    400 
    401 /**
    402  * Maps type names to serialization info objects.
    403  * @type {Object.<string, cvox.Spannable.SerializeInfo_>}
    404  * @private
    405  */
    406 cvox.Spannable.serializableSpansByName_ = {};
    407