/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.text.Cue');
goog.require('shaka.log');
goog.require('shaka.text.CueRegion');
goog.require('shaka.util.ArrayUtils');
goog.require('shaka.util.StringUtils');
goog.require('shaka.util.TextParser');
goog.require('shaka.util.TXml');
/**
* @export
*/
shaka.text.Cue = class {
/**
* @param {number} startTime
* @param {number} endTime
* @param {string} payload
*/
constructor(startTime, endTime, payload) {
const Cue = shaka.text.Cue;
/**
* The start time of the cue in seconds, relative to the start of the
* presentation.
* @type {number}
* @export
*/
this.startTime = startTime;
/**
* The end time of the cue in seconds, relative to the start of the
* presentation.
* @type {number}
* @export
*/
this.endTime = endTime;
/**
* The text payload of the cue. If nestedCues is non-empty, this should be
* empty. Top-level block containers should have no payload of their own.
* @type {string}
* @export
*/
this.payload = payload;
/**
* The region to render the cue into. Only supported on top-level cues,
* because nested cues are inline elements.
* @type {shaka.text.CueRegion}
* @export
*/
this.region = new shaka.text.CueRegion();
/**
* The indent (in percent) of the cue box in the direction defined by the
* writing direction.
* @type {?number}
* @export
*/
this.position = null;
/**
* Position alignment of the cue.
* @type {shaka.text.Cue.positionAlign}
* @export
*/
this.positionAlign = Cue.positionAlign.AUTO;
/**
* Size of the cue box (in percents), where 0 means "auto".
* @type {number}
* @export
*/
this.size = 0;
/**
* Alignment of the text inside the cue box.
* @type {shaka.text.Cue.textAlign}
* @export
*/
this.textAlign = Cue.textAlign.CENTER;
/**
* Text direction of the cue.
* @type {shaka.text.Cue.direction}
* @export
*/
this.direction = Cue.direction.HORIZONTAL_LEFT_TO_RIGHT;
/**
* Text writing mode of the cue.
* @type {shaka.text.Cue.writingMode}
* @export
*/
this.writingMode = Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM;
/**
* The way to interpret line field. (Either as an integer line number or
* percentage from the display box).
* @type {shaka.text.Cue.lineInterpretation}
* @export
*/
this.lineInterpretation = Cue.lineInterpretation.LINE_NUMBER;
/**
* The offset from the display box in either number of lines or
* percentage depending on the value of lineInterpretation.
* @type {?number}
* @export
*/
this.line = null;
/**
* Separation between line areas inside the cue box in px or em
* (e.g. '100px'/'100em'). If not specified, this should be no less than
* the largest font size applied to the text in the cue.
* @type {string}.
* @export
*/
this.lineHeight = '';
/**
* Line alignment of the cue box.
* Start alignment means the cue box’s top side (for horizontal cues), left
* side (for vertical growing right), or right side (for vertical growing
* left) is aligned at the line.
* Center alignment means the cue box is centered at the line.
* End alignment The cue box’s bottom side (for horizontal cues), right side
* (for vertical growing right), or left side (for vertical growing left) is
* aligned at the line.
* @type {shaka.text.Cue.lineAlign}
* @export
*/
this.lineAlign = Cue.lineAlign.START;
/**
* Vertical alignments of the cues within their extents.
* 'BEFORE' means displaying the captions at the top of the text display
* container box, 'CENTER' means in the middle, 'AFTER' means at the bottom.
* @type {shaka.text.Cue.displayAlign}
* @export
*/
this.displayAlign = Cue.displayAlign.AFTER;
/**
* Text color as a CSS color, e.g. "#FFFFFF" or "white".
* @type {string}
* @export
*/
this.color = '';
/**
* Text background color as a CSS color, e.g. "#FFFFFF" or "white".
* @type {string}
* @export
*/
this.backgroundColor = '';
/**
* The URL of the background image, e.g. "data:[mime type];base64,[data]".
* @type {string}
* @export
*/
this.backgroundImage = '';
/**
* The border around this cue as a CSS border.
* @type {string}
* @export
*/
this.border = '';
/**
* Text font size in px or em (e.g. '100px'/'100em').
* @type {string}
* @export
*/
this.fontSize = '';
/**
* Text font weight. Either normal or bold.
* @type {shaka.text.Cue.fontWeight}
* @export
*/
this.fontWeight = Cue.fontWeight.NORMAL;
/**
* Text font style. Normal, italic or oblique.
* @type {shaka.text.Cue.fontStyle}
* @export
*/
this.fontStyle = Cue.fontStyle.NORMAL;
/**
* Text font family.
* @type {string}
* @export
*/
this.fontFamily = '';
/**
* Text letter spacing as a CSS letter-spacing value.
* @type {string}
* @export
*/
this.letterSpacing = '';
/**
* Text line padding as a CSS line-padding value.
* @type {string}
* @export
*/
this.linePadding = '';
/**
* Opacity of the cue element, from 0-1.
* @type {number}
* @export
*/
this.opacity = 1;
/**
* Text combine upright as a CSS text-combine-upright value.
* @type {string}
* @export
*/
this.textCombineUpright = '';
/**
* Text decoration. A combination of underline, overline
* and line through. Empty array means no decoration.
* @type {!Array.<!shaka.text.Cue.textDecoration>}
* @export
*/
this.textDecoration = [];
/**
* Text shadow color as a CSS text-shadow value.
* @type {string}
* @export
*/
this.textShadow = '';
/**
* Text stroke color as a CSS color, e.g. "#FFFFFF" or "white".
* @type {string}
* @export
*/
this.textStrokeColor = '';
/**
* Text stroke width as a CSS stroke-width value.
* @type {string}
* @export
*/
this.textStrokeWidth = '';
/**
* Whether or not line wrapping should be applied to the cue.
* @type {boolean}
* @export
*/
this.wrapLine = true;
/**
* Id of the cue.
* @type {string}
* @export
*/
this.id = '';
/**
* Nested cues, which should be laid out horizontally in one block.
* Top-level cues are blocks, and nested cues are inline elements.
* Cues can be nested arbitrarily deeply.
* @type {!Array.<!shaka.text.Cue>}
* @export
*/
this.nestedCues = [];
/**
* If true, this represents a container element that is "above" the main
* cues. For example, the <body> and <div> tags that contain the <p> tags
* in a TTML file. This controls the flow of the final cues; any nested cues
* within an "isContainer" cue will be laid out as separate lines.
* @type {boolean}
* @export
*/
this.isContainer = false;
/**
* Whether or not the cue only acts as a line break between two nested cues.
* Should only appear in nested cues.
* @type {boolean}
* @export
*/
this.lineBreak = false;
/**
* Used to indicate the type of ruby tag that should be used when rendering
* the cue. Valid values: ruby, rp, rt.
* @type {?string}
* @export
*/
this.rubyTag = null;
/**
* The number of horizontal and vertical cells into which the Root Container
* Region area is divided.
*
* @type {{ columns: number, rows: number }}
* @export
*/
this.cellResolution = {
columns: 32,
rows: 15,
};
}
/**
* @param {number} start
* @param {number} end
* @return {!shaka.text.Cue}
*/
static lineBreak(start, end) {
const cue = new shaka.text.Cue(start, end, '');
cue.lineBreak = true;
return cue;
}
/**
* Create a copy of the cue with the same properties.
* @return {!shaka.text.Cue}
* @suppress {checkTypes} since we must use [] and "in" with a struct type.
* @export
*/
clone() {
const clone = new shaka.text.Cue(0, 0, '');
for (const k in this) {
clone[k] = this[k];
// Make copies of array fields, but only one level deep. That way, if we
// change, for instance, textDecoration on the clone, we don't affect the
// original.
if (clone[k] && clone[k].constructor == Array) {
clone[k] = /** @type {!Array} */(clone[k]).slice();
}
}
return clone;
}
/**
* Check if two Cues have all the same values in all properties.
* @param {!shaka.text.Cue} cue1
* @param {!shaka.text.Cue} cue2
* @return {boolean}
* @suppress {checkTypes} since we must use [] and "in" with a struct type.
* @export
*/
static equal(cue1, cue2) {
// Compare the start time, end time and payload of the cues first for
// performance optimization. We can avoid the more expensive recursive
// checks if the top-level properties don't match.
// See: https://github.com/shaka-project/shaka-player/issues/3018
if (cue1.startTime != cue2.startTime || cue1.endTime != cue2.endTime ||
cue1.payload != cue2.payload) {
return false;
}
for (const k in cue1) {
if (k == 'startTime' || k == 'endTime' || k == 'payload') {
// Already compared.
} else if (k == 'nestedCues') {
// This uses shaka.text.Cue.equal rather than just this.equal, since
// otherwise recursing here will unbox the method and cause "this" to be
// undefined in deeper recursion.
if (!shaka.util.ArrayUtils.equal(
cue1.nestedCues, cue2.nestedCues, shaka.text.Cue.equal)) {
return false;
}
} else if (k == 'region' || k == 'cellResolution') {
for (const k2 in cue1[k]) {
if (cue1[k][k2] != cue2[k][k2]) {
return false;
}
}
} else if (Array.isArray(cue1[k])) {
if (!shaka.util.ArrayUtils.equal(cue1[k], cue2[k])) {
return false;
}
} else {
if (cue1[k] != cue2[k]) {
return false;
}
}
}
return true;
}
/**
* Parses cue payload, searches for styling entities and, if needed,
* modifies original payload and creates nested cues to better represent
* styling found in payload. All changes are done in-place.
* @param {!shaka.text.Cue} cue
* @param {!Map<string, !shaka.text.Cue>=} styles
* @export
*/
static parseCuePayload(cue, styles = new Map()) {
const StringUtils = shaka.util.StringUtils;
const TXml = shaka.util.TXml;
let payload = cue.payload;
if (!payload.includes('<')) {
cue.payload = StringUtils.htmlUnescape(payload);
return;
}
if (styles.size === 0) {
shaka.text.Cue.addDefaultTextColor(styles);
}
payload = shaka.text.Cue.replaceKaraokeStylePayload_(payload);
payload = shaka.text.Cue.replaceVoiceStylePayload_(payload);
payload = shaka.text.Cue.escapeInvalidChevrons_(payload);
cue.payload = '';
const xmlPayload = '<span>' + payload + '</span>';
let element;
try {
element = TXml.parseXmlString(xmlPayload, 'span');
} catch (e) {
shaka.log.warning('cue parse fail: ', e);
}
if (element) {
const childNodes = element.children;
if (childNodes.length == 1) {
const childNode = childNodes[0];
if (!TXml.isNode(childNode)) {
cue.payload = StringUtils.htmlUnescape(payload);
return;
}
}
for (const childNode of childNodes) {
shaka.text.Cue.generateCueFromElement_(childNode, cue, styles);
}
} else {
shaka.log.warning('The cue\'s markup could not be parsed: ', payload);
cue.payload = StringUtils.htmlUnescape(payload);
}
}
/**
* Add default color
*
* @param {!Map<string, !shaka.text.Cue>} styles
*/
static addDefaultTextColor(styles) {
const textColor = shaka.text.Cue.defaultTextColor;
for (const [key, value] of Object.entries(textColor)) {
const cue = new shaka.text.Cue(0, 0, '');
cue.color = value;
styles.set('.' + key, cue);
}
const bgColor = shaka.text.Cue.defaultTextBackgroundColor;
for (const [key, value] of Object.entries(bgColor)) {
const cue = new shaka.text.Cue(0, 0, '');
cue.backgroundColor = value;
styles.set('.' + key, cue);
}
}
/**
* Converts karaoke style tag to be valid for xml parsing
* For example,
* input: Text <00:00:00.450> time <00:00:01.450> 1
* output: Text <div time="00:00:00.450"> time
* <div time="00:00:01.450"> 1</div></div>
*
* @param {string} payload
* @return {string} processed payload
* @private
*/
static replaceKaraokeStylePayload_(payload) {
const names = [];
let nameStart = -1;
for (let i = 0; i < payload.length; i++) {
if (payload[i] === '<') {
nameStart = i + 1;
} else if (payload[i] === '>') {
if (nameStart > 0) {
const name = payload.substr(nameStart, i - nameStart);
if (name.match(shaka.text.Cue.timeFormat_)) {
names.push(name);
}
nameStart = -1;
}
}
}
let newPayload = payload;
for (const name of names) {
const replaceTag = '<' + name + '>';
const startTag = '<div time="' + name + '">';
const endTag = '</div>';
newPayload = newPayload.replace(replaceTag, startTag);
newPayload += endTag;
}
return newPayload;
}
/**
* Converts voice style tag to be valid for xml parsing
* For example,
* input: <v Shaka>Test
* output: <v.voice-Shaka>Test</v.voice-Shaka>
*
* @param {string} payload
* @return {string} processed payload
* @private
*/
static replaceVoiceStylePayload_(payload) {
const voiceTag = 'v';
const names = [];
let nameStart = -1;
let newPayload = '';
let hasVoiceEndTag = false;
for (let i = 0; i < payload.length; i++) {
// This condition is used to manage tags that have end tags.
if (payload[i] === '/') {
const end = payload.indexOf('>', i);
if (end === -1) {
return payload;
}
const tagEnd = payload.substring(i + 1, end);
if (!tagEnd || tagEnd != voiceTag) {
newPayload += payload[i];
continue;
}
hasVoiceEndTag = true;
let tagStart = null;
if (names.length) {
tagStart = names[names.length -1];
}
if (!tagStart) {
newPayload += payload[i];
} else if (tagStart === tagEnd) {
newPayload += '/' + tagEnd + '>';
i += tagEnd.length + 1;
} else {
if (!tagStart.startsWith(voiceTag)) {
newPayload += payload[i];
continue;
}
newPayload += '/' + tagStart + '>';
i += tagEnd.length + 1;
}
} else {
// Here we only want the tag name, not any other payload.
if (payload[i] === '<') {
nameStart = i + 1;
if (payload[nameStart] != voiceTag) {
nameStart = -1;
}
} else if (payload[i] === '>') {
if (nameStart > 0) {
names.push(payload.substr(nameStart, i - nameStart));
nameStart = -1;
}
}
newPayload += payload[i];
}
}
for (const name of names) {
const newName = name.replace(' ', '.voice-');
newPayload = newPayload.replace(`<${name}>`, `<${newName}>`);
newPayload = newPayload.replace(`</${name}>`, `</${newName}>`);
if (!hasVoiceEndTag) {
newPayload += `</${newName}>`;
}
}
return newPayload;
}
/**
* This method converts invalid > chevrons to HTML entities.
* It also removes < chevrons as per spec.
*
* @param {!string} input
* @return {string}
* @private
*/
static escapeInvalidChevrons_(input) {
// Used to map HTML entities to characters.
const htmlEscapes = {
'< ': '',
' >': ' >',
};
const reEscapedHtml = /(< +>|<\s|\s>)/g;
const reHasEscapedHtml = RegExp(reEscapedHtml.source);
// This check is an optimization, since replace always makes a copy
if (input && reHasEscapedHtml.test(input)) {
return input.replace(reEscapedHtml, (entity) => {
return htmlEscapes[entity] || '';
});
}
return input || '';
}
/**
* @param {!shaka.extern.xml.Node} element
* @param {!shaka.text.Cue} rootCue
* @param {!Map<string, !shaka.text.Cue>} styles
* @private
*/
static generateCueFromElement_(element, rootCue, styles) {
const TXml = shaka.util.TXml;
const nestedCue = rootCue.clone();
// We don't want propagate some properties.
nestedCue.nestedCues = [];
nestedCue.payload = '';
nestedCue.rubyTag = '';
// We don't want propagate some position settings
nestedCue.line = null;
nestedCue.region = new shaka.text.CueRegion();
nestedCue.position = null;
nestedCue.size = 0;
if (TXml.isNode(element)) {
const bold = shaka.text.Cue.fontWeight.BOLD;
const italic = shaka.text.Cue.fontStyle.ITALIC;
const underline = shaka.text.Cue.textDecoration.UNDERLINE;
const tags = element.tagName.split(/(?=[ .])+/g);
for (const tag of tags) {
let styleTag = tag;
// White blanks at start indicate that the style is a voice
if (styleTag.startsWith('.voice-')) {
const voice = styleTag.split('-').pop();
styleTag = `v[voice="${voice}"]`;
// The specification allows to have quotes and not, so we check to
// see which one is being used.
if (!styles.has(styleTag)) {
styleTag = `v[voice=${voice}]`;
}
}
if (styles.has(styleTag)) {
shaka.text.Cue.mergeStyle_(nestedCue, styles.get(styleTag));
}
switch (tag) {
case 'br': {
const lineBreakCue = shaka.text.Cue.lineBreak(
nestedCue.startTime, nestedCue.endTime);
rootCue.nestedCues.push(lineBreakCue);
return;
}
case 'b':
nestedCue.fontWeight = bold;
break;
case 'i':
nestedCue.fontStyle = italic;
break;
case 'u':
nestedCue.textDecoration.push(underline);
break;
case 'font': {
const color = element.attributes['color'];
if (color) {
nestedCue.color = color;
}
break;
}
case 'div': {
const time = element.attributes['time'];
if (!time) {
break;
}
const cueTime = shaka.util.TextParser.parseTime(time);
if (cueTime) {
nestedCue.startTime = cueTime;
}
break;
}
case 'ruby':
case 'rp':
case 'rt':
nestedCue.rubyTag = tag;
break;
default:
break;
}
}
}
const isTextNode = (item) => TXml.isText(item);
const childNodes = element.children;
if (isTextNode(element) ||
(childNodes.length == 1 && isTextNode(childNodes[0]))) {
// Trailing line breaks may lost when convert cue to HTML tag
// Need to insert line break cue to preserve line breaks
const textArr = TXml.getTextContents(element).split('\n');
let isFirst = true;
for (const text of textArr) {
if (!isFirst) {
const lineBreakCue = shaka.text.Cue.lineBreak(
nestedCue.startTime, nestedCue.endTime);
rootCue.nestedCues.push(lineBreakCue);
}
if (text.length > 0) {
const textCue = nestedCue.clone();
textCue.payload = shaka.util.StringUtils.htmlUnescape(text);
rootCue.nestedCues.push(textCue);
}
isFirst = false;
}
} else {
rootCue.nestedCues.push(nestedCue);
for (const childNode of childNodes) {
shaka.text.Cue.generateCueFromElement_(childNode, nestedCue, styles);
}
}
}
/**
* Merges values created in parseStyle_
* @param {!shaka.text.Cue} cue
* @param {shaka.text.Cue} refCue
* @private
*/
static mergeStyle_(cue, refCue) {
if (!refCue) {
return;
}
// Overwrites if new value string length > 0
cue.backgroundColor = shaka.text.Cue.getOrDefault_(
refCue.backgroundColor, cue.backgroundColor);
cue.color = shaka.text.Cue.getOrDefault_(
refCue.color, cue.color);
cue.fontFamily = shaka.text.Cue.getOrDefault_(
refCue.fontFamily, cue.fontFamily);
cue.fontSize = shaka.text.Cue.getOrDefault_(
refCue.fontSize, cue.fontSize);
cue.textShadow = shaka.text.Cue.getOrDefault_(
refCue.textShadow, cue.textShadow);
// Overwrite with new values as unable to determine
// if new value is set or not
cue.fontWeight = refCue.fontWeight;
cue.fontStyle = refCue.fontStyle;
cue.opacity = refCue.opacity;
cue.rubyTag = refCue.rubyTag;
cue.textCombineUpright = refCue.textCombineUpright;
cue.wrapLine = refCue.wrapLine;
}
/**
* @param {string} value
* @param {string} defaultValue
* @private
*/
static getOrDefault_(value, defaultValue) {
if (value && value.length > 0) {
return value;
}
return defaultValue;
}
};
/**
* @enum {string}
* @export
*/
shaka.text.Cue.positionAlign = {
'LEFT': 'line-left',
'RIGHT': 'line-right',
'CENTER': 'center',
'AUTO': 'auto',
};
/**
* @enum {string}
* @export
*/
shaka.text.Cue.textAlign = {
'LEFT': 'left',
'RIGHT': 'right',
'CENTER': 'center',
'START': 'start',
'END': 'end',
};
/**
* Vertical alignments of the cues within their extents.
* 'BEFORE' means displaying at the top of the captions container box, 'CENTER'
* means in the middle, 'AFTER' means at the bottom.
* @enum {string}
* @export
*/
shaka.text.Cue.displayAlign = {
'BEFORE': 'before',
'CENTER': 'center',
'AFTER': 'after',
};
/**
* @enum {string}
* @export
*/
shaka.text.Cue.direction = {
'HORIZONTAL_LEFT_TO_RIGHT': 'ltr',
'HORIZONTAL_RIGHT_TO_LEFT': 'rtl',
};
/**
* @enum {string}
* @export
*/
shaka.text.Cue.writingMode = {
'HORIZONTAL_TOP_TO_BOTTOM': 'horizontal-tb',
'VERTICAL_LEFT_TO_RIGHT': 'vertical-lr',
'VERTICAL_RIGHT_TO_LEFT': 'vertical-rl',
};
/**
* @enum {number}
* @export
*/
shaka.text.Cue.lineInterpretation = {
'LINE_NUMBER': 0,
'PERCENTAGE': 1,
};
/**
* @enum {string}
* @export
*/
shaka.text.Cue.lineAlign = {
'CENTER': 'center',
'START': 'start',
'END': 'end',
};
/**
* Default text color according to
* https://w3c.github.io/webvtt/#default-text-color
* @enum {string}
* @export
*/
shaka.text.Cue.defaultTextColor = {
'white': 'white',
'lime': 'lime',
'cyan': 'cyan',
'red': 'red',
'yellow': 'yellow',
'magenta': 'magenta',
'blue': 'blue',
'black': 'black',
};
/**
* Default text background color according to
* https://w3c.github.io/webvtt/#default-text-background
* @enum {string}
* @export
*/
shaka.text.Cue.defaultTextBackgroundColor = {
'bg_white': 'white',
'bg_lime': 'lime',
'bg_cyan': 'cyan',
'bg_red': 'red',
'bg_yellow': 'yellow',
'bg_magenta': 'magenta',
'bg_blue': 'blue',
'bg_black': 'black',
};
/**
* In CSS font weight can be a number, where 400 is normal and 700 is bold.
* Use these values for the enum for consistency.
* @enum {number}
* @export
*/
shaka.text.Cue.fontWeight = {
'NORMAL': 400,
'BOLD': 700,
};
/**
* @enum {string}
* @export
*/
shaka.text.Cue.fontStyle = {
'NORMAL': 'normal',
'ITALIC': 'italic',
'OBLIQUE': 'oblique',
};
/**
* @enum {string}
* @export
*/
shaka.text.Cue.textDecoration = {
'UNDERLINE': 'underline',
'LINE_THROUGH': 'lineThrough',
'OVERLINE': 'overline',
};
/** @private */
shaka.text.Cue.timeFormat_ = /(?:(\d{1,}):)?(\d{2}):(\d{2})\.(\d{2,3})/g;