blob: ea113a8041ea14d58c7fbd0961e75d95c003de2a [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Class which allows construction of annotated strings.
*/
goog.provide('cvox.Spannable');
goog.require('goog.object');
/**
* @constructor
* @param {string=} opt_string Initial value of the spannable.
* @param {*=} opt_annotation Initial annotation for the entire string.
*/
cvox.Spannable = function(opt_string, opt_annotation) {
/**
* Underlying string.
* @type {string}
* @private
*/
this.string_ = opt_string || '';
/**
* Spans (annotations).
* @type {!Array.<!{ value: *, start: number, end: number }>}
* @private
*/
this.spans_ = [];
// Optionally annotate the entire string.
if (goog.isDef(opt_annotation)) {
var len = this.string_.length;
this.spans_.push({ value: opt_annotation, start: 0, end: len });
}
};
/** @override */
cvox.Spannable.prototype.toString = function() {
return this.string_;
};
/**
* Returns the length of the string.
* @return {number} Length of the string.
*/
cvox.Spannable.prototype.getLength = function() {
return this.string_.length;
};
/**
* Adds a span to some region of the string.
* @param {*} value Annotation.
* @param {number} start Starting index (inclusive).
* @param {number} end Ending index (exclusive).
*/
cvox.Spannable.prototype.setSpan = function(value, start, end) {
this.removeSpan(value);
if (0 <= start && start <= end && end <= this.string_.length) {
// Zero-length spans are explicitly allowed, because it is possible to
// query for position by annotation as well as the reverse.
this.spans_.push({ value: value, start: start, end: end });
} else {
throw new RangeError('span out of range (start=' + start +
', end=' + end + ', len=' + this.string_.length + ')');
}
};
/**
* Removes a span.
* @param {*} value Annotation.
*/
cvox.Spannable.prototype.removeSpan = function(value) {
for (var i = this.spans_.length - 1; i >= 0; i--) {
if (this.spans_[i].value === value) {
this.spans_.splice(i, 1);
}
}
};
/**
* Appends another Spannable or string to this one.
* @param {string|!cvox.Spannable} other String or spannable to concatenate.
*/
cvox.Spannable.prototype.append = function(other) {
if (other instanceof cvox.Spannable) {
var otherSpannable = /** @type {!cvox.Spannable} */ (other);
var originalLength = this.getLength();
this.string_ += otherSpannable.string_;
other.spans_.forEach(goog.bind(function(span) {
this.setSpan(
span.value,
span.start + originalLength,
span.end + originalLength);
}, this));
} else if (typeof other === 'string') {
this.string_ += /** @type {string} */ (other);
}
};
/**
* Returns the first value matching a position.
* @param {number} position Position to query.
* @return {*} Value annotating that position, or undefined if none is found.
*/
cvox.Spannable.prototype.getSpan = function(position) {
for (var i = 0; i < this.spans_.length; i++) {
var span = this.spans_[i];
if (span.start <= position && position < span.end) {
return span.value;
}
}
};
/**
* Returns the first span value which is an instance of a given constructor.
* @param {!Function} constructor Constructor.
* @return {!Object|undefined} Object if found; undefined otherwise.
*/
cvox.Spannable.prototype.getSpanInstanceOf = function(constructor) {
for (var i = 0; i < this.spans_.length; i++) {
var span = this.spans_[i];
if (span.value instanceof constructor) {
return span.value;
}
}
};
/**
* Returns all spans matching a position.
* @param {number} position Position to query.
* @return {!Array} Values annotating that position.
*/
cvox.Spannable.prototype.getSpans = function(position) {
var results = [];
for (var i = 0; i < this.spans_.length; i++) {
var span = this.spans_[i];
if (span.start <= position && position < span.end) {
results.push(span.value);
}
}
return results;
};
/**
* Returns the start of the requested span.
* @param {*} value Annotation.
* @return {number|undefined} Start of the span, or undefined if not attached.
*/
cvox.Spannable.prototype.getSpanStart = function(value) {
for (var i = 0; i < this.spans_.length; i++) {
var span = this.spans_[i];
if (span.value === value) {
return span.start;
}
}
return undefined;
};
/**
* Returns the end of the requested span.
* @param {*} value Annotation.
* @return {number|undefined} End of the span, or undefined if not attached.
*/
cvox.Spannable.prototype.getSpanEnd = function(value) {
for (var i = 0; i < this.spans_.length; i++) {
var span = this.spans_[i];
if (span.value === value) {
return span.end;
}
}
return undefined;
};
/**
* Returns a substring of this spannable.
* Note that while similar to String#substring, this function is much less
* permissive about its arguments. It does not accept arguments in the wrong
* order or out of bounds.
*
* @param {number} start Start index, inclusive.
* @param {number=} opt_end End index, exclusive.
* If excluded, the length of the string is used instead.
* @return {!cvox.Spannable} Substring requested.
*/
cvox.Spannable.prototype.substring = function(start, opt_end) {
var end = goog.isDef(opt_end) ? opt_end : this.string_.length;
if (start < 0 || end > this.string_.length || start > end) {
throw new RangeError('substring indices out of range');
}
var result = new cvox.Spannable(this.string_.substring(start, end));
for (var i = 0; i < this.spans_.length; i++) {
var span = this.spans_[i];
if (span.start <= end && span.end >= start) {
var newStart = Math.max(0, span.start - start);
var newEnd = Math.min(end - start, span.end - start);
result.spans_.push({ value: span.value, start: newStart, end: newEnd });
}
}
return result;
};
/**
* Trims whitespace from the beginning.
* @return {!cvox.Spannable} String with whitespace removed.
*/
cvox.Spannable.prototype.trimLeft = function() {
return this.trim_(true, false);
};
/**
* Trims whitespace from the end.
* @return {!cvox.Spannable} String with whitespace removed.
*/
cvox.Spannable.prototype.trimRight = function() {
return this.trim_(false, true);
};
/**
* Trims whitespace from the beginning and end.
* @return {!cvox.Spannable} String with whitespace removed.
*/
cvox.Spannable.prototype.trim = function() {
return this.trim_(true, true);
};
/**
* Trims whitespace from either the beginning and end or both.
* @param {boolean} trimStart Trims whitespace from the start of a string.
* @param {boolean} trimEnd Trims whitespace from the end of a string.
* @return {!cvox.Spannable} String with whitespace removed.
* @private
*/
cvox.Spannable.prototype.trim_ = function(trimStart, trimEnd) {
if (!trimStart && !trimEnd) {
return this;
}
// Special-case whitespace-only strings, including the empty string.
// As an arbitrary decision, we treat this as trimming the whitespace off the
// end, rather than the beginning, of the string.
// This choice affects which spans are kept.
if (/^\s*$/.test(this.string_)) {
return this.substring(0, 0);
}
// Otherwise, we have at least one non-whitespace character to use as an
// anchor when trimming.
var trimmedStart = trimStart ? this.string_.match(/^\s*/)[0].length : 0;
var trimmedEnd = trimEnd ?
this.string_.match(/\s*$/).index : this.string_.length;
return this.substring(trimmedStart, trimmedEnd);
};
/**
* Returns this spannable to a json serializable form, including the text and
* span objects whose types have been registered with registerSerializableSpan
* or registerStatelessSerializableSpan.
* @return {!cvox.Spannable.SerializedSpannable_} the json serializable form.
*/
cvox.Spannable.prototype.toJson = function() {
var result = {};
result.string = this.string_;
result.spans = [];
for (var i = 0; i < this.spans_.length; ++i) {
var span = this.spans_[i];
// Use linear search, since using functions as property keys
// is not reliable.
var serializeInfo = goog.object.findValue(
cvox.Spannable.serializableSpansByName_,
function(v) { return v.ctor === span.value.constructor; });
if (serializeInfo) {
var spanObj = {type: serializeInfo.name,
start: span.start,
end: span.end};
if (serializeInfo.toJson) {
spanObj.value = serializeInfo.toJson.apply(span.value);
}
result.spans.push(spanObj);
}
}
return result;
};
/**
* Creates a spannable from a json serializable representation.
* @param {!cvox.Spannable.SerializedSpannable_} obj object containing the
* serializable representation.
* @return {!cvox.Spannable}
*/
cvox.Spannable.fromJson = function(obj) {
if (typeof obj.string !== 'string') {
throw 'Invalid spannable json object: string field not a string';
}
if (!(obj.spans instanceof Array)) {
throw 'Invalid spannable json object: no spans array';
}
var result = new cvox.Spannable(obj.string);
for (var i = 0, span; span = obj.spans[i]; ++i) {
if (typeof span.type !== 'string') {
throw 'Invalid span in spannable json object: type not a string';
}
if (typeof span.start !== 'number' || typeof span.end !== 'number') {
throw 'Invalid span in spannable json object: start or end not a number';
}
var serializeInfo = cvox.Spannable.serializableSpansByName_[span.type];
var value = serializeInfo.fromJson(span.value);
result.setSpan(value, span.start, span.end);
}
return result;
};
/**
* Registers a type that can be converted to a json serializable format.
* @param {!Function} constructor The type of object that can be converted.
* @param {string} name String identifier used in the serializable format.
* @param {function(!Object): !Object} fromJson A function that converts
* the serializable object to an actual object of this type.
* @param {function(!Object): !Object} toJson A function that converts
* this object to a json serializable object. The function will
* be called with this set to the object to convert.
*/
cvox.Spannable.registerSerializableSpan = function(
constructor, name, fromJson, toJson) {
var obj = {name: name, ctor: constructor,
fromJson: fromJson, toJson: toJson};
cvox.Spannable.serializableSpansByName_[name] = obj;
};
/**
* Registers an object type that can be converted to/from a json serializable
* form. Objects of this type carry no state that will be preserved
* when serialized.
* @param {!Function} constructor The type of the object that can be converted.
* This constructor will be called with no arguments to construct
* new objects.
* @param {string} name Name of the type used in the serializable object.
*/
cvox.Spannable.registerStatelessSerializableSpan = function(
constructor, name) {
var obj = {name: name, ctor: constructor, toJson: undefined};
/**
* @param {!Object} obj
* @return {!Object}
*/
obj.fromJson = function(obj) {
return new constructor();
};
cvox.Spannable.serializableSpansByName_[name] = obj;
};
/**
* Describes how to convert a span type to/from serializable json.
* @typedef {{ctor: !Function, name: string,
* fromJson: function(!Object): !Object,
* toJson: ((function(!Object): !Object)|undefined)}}
* @private
*/
cvox.Spannable.SerializeInfo_;
/**
* The serialized format of a spannable.
* @typedef {{string: string, spans: Array.<cvox.Spannable.SerializedSpan_>}}
* @private
*/
cvox.Spannable.SerializedSpannable_;
/**
* The format of a single annotation in a serialized spannable.
* @typedef {{type: string, value: !Object, start: number, end: number}}
* @private
*/
cvox.Spannable.SerializedSpan_;
/**
* Maps type names to serialization info objects.
* @type {Object.<string, cvox.Spannable.SerializeInfo_>}
* @private
*/
cvox.Spannable.serializableSpansByName_ = {};