• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
9goog.provide('cvox.Spannable');
10
11goog.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 */
18cvox.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 */
42cvox.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 */
51cvox.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 */
62cvox.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 */
79cvox.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 */
92cvox.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 */
114cvox.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 */
129cvox.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 */
144cvox.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 */
161cvox.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 */
177cvox.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 */
199cvox.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 */
223cvox.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 */
232cvox.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 */
241cvox.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 */
253cvox.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 */
281cvox.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 */
312cvox.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 */
345cvox.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 */
362cvox.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 */
383cvox.Spannable.SerializeInfo_;
384
385
386/**
387 * The serialized format of a spannable.
388 * @typedef {{string: string, spans: Array.<cvox.Spannable.SerializedSpan_>}}
389 * @private
390 */
391cvox.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 */
399cvox.Spannable.SerializedSpan_;
400
401/**
402 * Maps type names to serialization info objects.
403 * @type {Object.<string, cvox.Spannable.SerializeInfo_>}
404 * @private
405 */
406cvox.Spannable.serializableSpansByName_ = {};
407