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