UNPKG

shaka-player

Version:
1,471 lines (1,312 loc) 48.5 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shaka.text.TtmlTextParser'); goog.require('goog.asserts'); goog.require('goog.Uri'); goog.require('shaka.log'); goog.require('shaka.text.Cue'); goog.require('shaka.text.CueRegion'); goog.require('shaka.text.TextEngine'); goog.require('shaka.util.ArrayUtils'); goog.require('shaka.util.Error'); goog.require('shaka.util.StringUtils'); goog.require('shaka.util.TXml'); /** * @implements {shaka.extern.TextParser} * @export */ shaka.text.TtmlTextParser = class { /** * @override * @export */ parseInit(data) { goog.asserts.assert(false, 'TTML does not have init segments'); } /** * @override * @export */ setSequenceMode(sequenceMode) { // Unused. } /** * @override * @export */ setManifestType(manifestType) { // Unused. } /** * @override * @export */ parseMedia(data, time, uri, images) { const TtmlTextParser = shaka.text.TtmlTextParser; const TXml = shaka.util.TXml; const ttpNs = TtmlTextParser.parameterNs_; const ttsNs = TtmlTextParser.styleNs_; const str = shaka.util.StringUtils.fromUTF8(data); const cues = []; // dont try to parse empty string as // DOMParser will not throw error but return an errored xml if (str == '') { return cues; } const tt = TXml.parseXmlString(str, 'tt', /* includeParent= */ true); if (!tt) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.TEXT, shaka.util.Error.Code.INVALID_XML, 'Failed to parse TTML.'); } const body = TXml.getElementsByTagName(tt, 'body')[0]; if (!body) { return []; } // Get the framerate, subFrameRate and frameRateMultiplier if applicable. const frameRate = TXml.getAttributeNSList(tt, ttpNs, 'frameRate'); const subFrameRate = TXml.getAttributeNSList( tt, ttpNs, 'subFrameRate'); const frameRateMultiplier = TXml.getAttributeNSList(tt, ttpNs, 'frameRateMultiplier'); const tickRate = TXml.getAttributeNSList(tt, ttpNs, 'tickRate'); const cellResolution = TXml.getAttributeNSList( tt, ttpNs, 'cellResolution'); const spaceStyle = tt.attributes['xml:space'] || 'default'; const extent = TXml.getAttributeNSList(tt, ttsNs, 'extent'); if (spaceStyle != 'default' && spaceStyle != 'preserve') { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.TEXT, shaka.util.Error.Code.INVALID_XML, 'Invalid xml:space value: ' + spaceStyle); } const collapseMultipleSpaces = spaceStyle == 'default'; const rateInfo = new TtmlTextParser.RateInfo_( frameRate, subFrameRate, frameRateMultiplier, tickRate); const cellResolutionInfo = TtmlTextParser.getCellResolution_(cellResolution); const metadata = TXml.getElementsByTagName(tt, 'metadata')[0]; const metadataElements = (metadata ? metadata.children : []).filter((c) => c != '\n'); const styles = TXml.getElementsByTagName(tt, 'style'); const regionElements = TXml.getElementsByTagName(tt, 'region'); const cueRegions = []; for (const region of regionElements) { const cueRegion = TtmlTextParser.parseCueRegion_(region, styles, extent); if (cueRegion) { cueRegions.push(cueRegion); } } // A <body> element should only contain <div> elements, not <p> or <span> // elements. We used to allow this, but it is non-compliant, and the // loose nature of our previous parser made it difficult to implement TTML // nesting more fully. if (TXml.findChildren(body, 'p').length) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.TEXT, shaka.util.Error.Code.INVALID_TEXT_CUE, '<p> can only be inside <div> in TTML'); } for (const div of TXml.findChildren(body, 'div')) { // A <div> element should only contain <p>, not <span>. if (TXml.findChildren(div, 'span').length) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.TEXT, shaka.util.Error.Code.INVALID_TEXT_CUE, '<span> can only be inside <p> in TTML'); } } const cue = TtmlTextParser.parseCue_( body, time, rateInfo, metadataElements, styles, regionElements, cueRegions, collapseMultipleSpaces, cellResolutionInfo, /* parentCueElement= */ null, /* isContent= */ false, uri, images); if (cue) { // According to the TTML spec, backgrounds default to transparent. // So default the background of the top-level element to transparent. // Nested elements may override that background color already. if (!cue.backgroundColor) { cue.backgroundColor = 'transparent'; } cues.push(cue); } return cues; } /** * Parses a TTML node into a Cue. * * @param {!shaka.extern.xml.Node} cueNode * @param {shaka.extern.TextParser.TimeContext} timeContext * @param {!shaka.text.TtmlTextParser.RateInfo_} rateInfo * @param {!Array<!shaka.extern.xml.Node>} metadataElements * @param {!Array<!shaka.extern.xml.Node>} styles * @param {!Array<!shaka.extern.xml.Node>} regionElements * @param {!Array<!shaka.text.CueRegion>} cueRegions * @param {boolean} collapseMultipleSpaces * @param {?{columns: number, rows: number}} cellResolution * @param {?shaka.extern.xml.Node} parentCueElement * @param {boolean} isContent * @param {?(string|undefined)} uri * @param {!Array<string>} images * @return {shaka.text.Cue} * @private */ static parseCue_( cueNode, timeContext, rateInfo, metadataElements, styles, regionElements, cueRegions, collapseMultipleSpaces, cellResolution, parentCueElement, isContent, uri, images) { const TXml = shaka.util.TXml; const StringUtils = shaka.util.StringUtils; /** @type {shaka.extern.xml.Node} */ let cueElement; /** @type {?shaka.extern.xml.Node} */ let parentElement = parentCueElement; if (TXml.isText(cueNode)) { if (!isContent) { // Ignore text elements outside the content. For example, whitespace // on the same lexical level as the <p> elements, in a document with // xml:space="preserve", should not be renderer. return null; } // This should generate an "anonymous span" according to the TTML spec. // So pretend the element was a <span>. parentElement was set above, so // we should still be able to correctly traverse up for timing // information later. /** @type {shaka.extern.xml.Node} */ const span = { tagName: 'span', children: [TXml.getTextContents(cueNode)], attributes: {}, parent: null, }; cueElement = span; } else { cueElement = cueNode; } goog.asserts.assert(cueElement, 'cueElement should be non-null!'); let imageElement = null; for (const nameSpace of shaka.text.TtmlTextParser.smpteNsList_) { imageElement = shaka.text.TtmlTextParser.getElementsFromCollection_( cueElement, 'backgroundImage', metadataElements, '#', nameSpace)[0]; if (imageElement) { break; } } let imageUri = null; const backgroundImage = TXml.getAttributeNSList( cueElement, shaka.text.TtmlTextParser.smpteNsList_, 'backgroundImage'); const imsc1ImgUrnTester = /^(urn:)(mpeg:[a-z0-9][a-z0-9-]{0,31}:)(subs:)([0-9]+)$/; if (backgroundImage && imsc1ImgUrnTester.test(backgroundImage)) { const index = parseInt(backgroundImage.split(':').pop(), 10) -1; if (index >= images.length) { return null; } imageUri = images[index]; } else if (uri && backgroundImage && !backgroundImage.startsWith('#')) { const baseUri = new goog.Uri(uri); const relativeUri = new goog.Uri(backgroundImage); const newUri = baseUri.resolve(relativeUri).toString(); if (newUri) { imageUri = newUri; } } if (cueNode.tagName == 'p' || imageElement || imageUri) { isContent = true; } const parentIsContent = isContent; const spaceStyle = cueElement.attributes['xml:space'] || (collapseMultipleSpaces ? 'default' : 'preserve'); const localCollapseMultipleSpaces = spaceStyle == 'default'; // Parse any nested cues first. const isLeafNode = cueElement.children.every(TXml.isText); const nestedCues = []; if (!isLeafNode) { // Otherwise, recurse into the children. Text nodes will convert into // anonymous spans, which will then be leaf nodes. for (const childNode of cueElement.children) { const nestedCue = shaka.text.TtmlTextParser.parseCue_( childNode, timeContext, rateInfo, metadataElements, styles, regionElements, cueRegions, localCollapseMultipleSpaces, cellResolution, cueElement, isContent, uri, images, ); // This node may or may not generate a nested cue. if (nestedCue) { nestedCues.push(nestedCue); } } } const isNested = /** @type {boolean} */ (parentCueElement != null); const textContent = TXml.getTextContents(cueElement); // In this regex, "\S" means "non-whitespace character". const hasTextContent = cueElement.children.length && textContent && /\S/.test(textContent); const hasTimeAttributes = cueElement.attributes['begin'] || cueElement.attributes['end'] || cueElement.attributes['dur']; if (!hasTimeAttributes && !hasTextContent && cueElement.tagName != 'br' && nestedCues.length == 0) { if (!isNested) { // Disregards empty <p> elements without time attributes nor content. // <p begin="..." smpte:backgroundImage="..." /> will go through, // as some information could be held by its attributes. // <p /> won't, as it would not be displayed. return null; } else if (localCollapseMultipleSpaces) { // Disregards empty anonymous spans when (local) trim is true. return null; } } // Get local time attributes. let {start, end} = shaka.text.TtmlTextParser.parseTime_( cueElement, rateInfo); // Resolve local time relative to parent elements. Time elements can appear // all the way up to 'body', but not 'tt'. while (parentElement && TXml.isNode(parentElement) && parentElement.tagName != 'tt') { ({start, end} = shaka.text.TtmlTextParser.resolveTime_( parentElement, rateInfo, start, end)); parentElement = /** @type {shaka.extern.xml.Node} */ (parentElement.parent); } if (start == null) { start = 0; } start += timeContext.periodStart; // If end is null, that means the duration is effectively infinite. if (end == null) { end = Infinity; } else { end += timeContext.periodStart; } // Clip times to segment boundaries. // https://github.com/shaka-project/shaka-player/issues/4631 start = Math.max(start, timeContext.segmentStart); end = Math.min(end, timeContext.segmentEnd); if (!hasTimeAttributes && nestedCues.length > 0) { // If no time is defined for this cue, base the timing information on // the time of the nested cues. In the case of multiple nested cues with // different start times, it is the text displayer's responsibility to // make sure that only the appropriate nested cue is drawn at any given // time. start = Infinity; end = 0; for (const cue of nestedCues) { start = Math.min(start, cue.startTime); end = Math.max(end, cue.endTime); } } if (cueElement.tagName == 'br') { const cue = new shaka.text.Cue(start, end, ''); cue.lineBreak = true; return cue; } let payload = ''; if (isLeafNode) { // If the childNodes are all text, this is a leaf node. Get the payload. payload = StringUtils.htmlUnescape( shaka.util.TXml.getTextContents(cueElement) || ''); if (localCollapseMultipleSpaces) { // Collapse multiple spaces into one. payload = payload.replace(/\s+/g, ' '); } } const cue = new shaka.text.Cue(start, end, payload); cue.nestedCues = nestedCues; if (!isContent) { // If this is not a <p> element or a <div> with images, and it has no // parent that was a <p> element, then it's part of the outer containers // (e.g. the <body> or a normal <div> element within it). cue.isContainer = true; } if (cellResolution) { cue.cellResolution = cellResolution; } // Get other properties if available. const regionElement = shaka.text.TtmlTextParser.getElementsFromCollection_( cueElement, 'region', regionElements, /* prefix= */ '')[0]; // Do not actually apply that region unless it is non-inherited, though. // This makes it so that, if a parent element has a region, the children // don't also all independently apply the positioning of that region. if (cueElement.attributes['region']) { if (regionElement && regionElement.attributes['xml:id']) { const regionId = regionElement.attributes['xml:id']; cue.region = cueRegions.filter((region) => region.id == regionId)[0]; } } let regionElementForStyle = regionElement; if (parentCueElement && isNested && !cueElement.attributes['region'] && !cueElement.attributes['style']) { regionElementForStyle = shaka.text.TtmlTextParser.getElementsFromCollection_( parentCueElement, 'region', regionElements, /* prefix= */ '')[0]; } shaka.text.TtmlTextParser.addStyle_( cue, cueElement, regionElementForStyle, /** @type {!shaka.extern.xml.Node} */(imageElement), imageUri, styles, /** isNested= */ parentIsContent, // "nested in a <div>" doesn't count. /** isLeaf= */ (nestedCues.length == 0)); return cue; } /** * Parses an Element into a TextTrackCue or VTTCue. * * @param {!shaka.extern.xml.Node} regionElement * @param {!Array<!shaka.extern.xml.Node>} styles * Defined in the top of tt element and used principally for images. * @param {?string} globalExtent * @return {shaka.text.CueRegion} * @private */ static parseCueRegion_(regionElement, styles, globalExtent) { const TtmlTextParser = shaka.text.TtmlTextParser; const region = new shaka.text.CueRegion(); const id = regionElement.attributes['xml:id']; if (!id) { shaka.log.warning('TtmlTextParser parser encountered a region with ' + 'no id. Region will be ignored.'); return null; } region.id = id; let globalResults = null; if (globalExtent) { globalResults = TtmlTextParser.percentValues_.exec(globalExtent) || TtmlTextParser.pixelValues_.exec(globalExtent); } const globalWidth = globalResults ? Number(globalResults[1]) : null; const globalHeight = globalResults ? Number(globalResults[2]) : null; let results = null; let percentage = null; const extent = TtmlTextParser.getStyleAttributeFromRegion_( regionElement, styles, 'extent'); if (extent) { percentage = TtmlTextParser.percentValues_.exec(extent); results = percentage || TtmlTextParser.pixelValues_.exec(extent); if (results != null) { region.width = Number(results[1]); region.height = Number(results[2]); if (!percentage) { if (globalWidth != null) { region.width = region.width * 100 / globalWidth; } if (globalHeight != null) { region.height = region.height * 100 / globalHeight; } } region.widthUnits = percentage || globalWidth != null ? shaka.text.CueRegion.units.PERCENTAGE : shaka.text.CueRegion.units.PX; region.heightUnits = percentage || globalHeight != null ? shaka.text.CueRegion.units.PERCENTAGE : shaka.text.CueRegion.units.PX; } } const origin = TtmlTextParser.getStyleAttributeFromRegion_( regionElement, styles, 'origin'); if (origin) { percentage = TtmlTextParser.percentValues_.exec(origin); results = percentage || TtmlTextParser.pixelValues_.exec(origin); if (results != null) { region.viewportAnchorX = Number(results[1]); region.viewportAnchorY = Number(results[2]); if (!percentage) { if (globalHeight != null) { region.viewportAnchorY = region.viewportAnchorY * 100 / globalHeight; } if (globalWidth != null) { region.viewportAnchorX = region.viewportAnchorX * 100 / globalWidth; } } else if (!extent) { region.width = 100 - region.viewportAnchorX; region.widthUnits = shaka.text.CueRegion.units.PERCENTAGE; region.height = 100 - region.viewportAnchorY; region.heightUnits = shaka.text.CueRegion.units.PERCENTAGE; } region.viewportAnchorUnits = percentage || globalWidth != null ? shaka.text.CueRegion.units.PERCENTAGE : shaka.text.CueRegion.units.PX; } } return region; } /** * Ensures any TTML RGBA's alpha range of 0-255 is converted to 0-1. * @param {string} color * @return {string} * @private */ static convertTTMLrgbaToHTMLrgba_(color) { const rgba = color.match(/rgba\(([^)]+)\)/); if (rgba) { const values = rgba[1].split(','); if (values.length == 4) { values[3] = String(Number(values[3]) / 255); return 'rgba(' + values.join(',') + ')'; } } return color; } /** * Adds applicable style properties to a cue. * * @param {!shaka.text.Cue} cue * @param {!shaka.extern.xml.Node} cueElement * @param {shaka.extern.xml.Node} region * @param {shaka.extern.xml.Node} imageElement * @param {?string} imageUri * @param {!Array<!shaka.extern.xml.Node>} styles * @param {boolean} isNested * @param {boolean} isLeaf * @private */ static addStyle_( cue, cueElement, region, imageElement, imageUri, styles, isNested, isLeaf) { const TtmlTextParser = shaka.text.TtmlTextParser; const TXml = shaka.util.TXml; const Cue = shaka.text.Cue; // Styles should be inherited from regions, if a style property is not // associated with a Content element (or an anonymous span). const shouldInheritRegionStyles = isNested || isLeaf; const direction = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'direction', shouldInheritRegionStyles); if (direction == 'rtl') { cue.direction = Cue.direction.HORIZONTAL_RIGHT_TO_LEFT; } // Direction attribute specifies one-dimensional writing direction // (left to right or right to left). Writing mode specifies that // plus whether text is vertical or horizontal. // They should not contradict each other. If they do, we give // preference to writing mode. const writingMode = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'writingMode', shouldInheritRegionStyles); // Set cue's direction if the text is horizontal, and cue's writingMode if // it's vertical. if (writingMode == 'tb' || writingMode == 'tblr') { cue.writingMode = Cue.writingMode.VERTICAL_LEFT_TO_RIGHT; } else if (writingMode == 'tbrl') { cue.writingMode = Cue.writingMode.VERTICAL_RIGHT_TO_LEFT; } else if (writingMode == 'rltb' || writingMode == 'rl') { cue.direction = Cue.direction.HORIZONTAL_RIGHT_TO_LEFT; } else if (writingMode) { cue.direction = Cue.direction.HORIZONTAL_LEFT_TO_RIGHT; } const align = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'textAlign', true); if (align) { cue.positionAlign = TtmlTextParser.textAlignToPositionAlign_.get(align); cue.lineAlign = TtmlTextParser.textAlignToLineAlign_.get(align); goog.asserts.assert(align.toUpperCase() in Cue.textAlign, align.toUpperCase() + ' Should be in Cue.textAlign values!'); cue.textAlign = Cue.textAlign[align.toUpperCase()]; } else { // Default value is START in the TTML spec: https://bit.ly/32OGmvo // But to make the subtitle render consistent with other players and the // shaka.text.Cue we use CENTER cue.textAlign = Cue.textAlign.CENTER; } const displayAlign = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'displayAlign', true); if (displayAlign) { goog.asserts.assert(displayAlign.toUpperCase() in Cue.displayAlign, displayAlign.toUpperCase() + ' Should be in Cue.displayAlign values!'); cue.displayAlign = Cue.displayAlign[displayAlign.toUpperCase()]; } const color = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'color', shouldInheritRegionStyles); if (color) { cue.color = TtmlTextParser.convertTTMLrgbaToHTMLrgba_(color); } // Background color should not be set on a container. If this is a nested // cue, you can set the background. If it's a top-level that happens to // also be a leaf, you can set the background. // See https://github.com/shaka-project/shaka-player/issues/2623 // This used to be handled in the displayer, but that is confusing. The Cue // structure should reflect what you want to happen in the displayer, and // the displayer shouldn't have to know about TTML. const backgroundColor = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'backgroundColor', shouldInheritRegionStyles); if (backgroundColor) { cue.backgroundColor = TtmlTextParser.convertTTMLrgbaToHTMLrgba_(backgroundColor); } const border = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'border', shouldInheritRegionStyles); if (border) { cue.border = border; } const fontFamily = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'fontFamily', shouldInheritRegionStyles); // See https://github.com/sandflow/imscJS/blob/1.1.3/src/main/js/html.js#L1384 if (fontFamily) { switch (fontFamily) { case 'monospaceSerif': cue.fontFamily = 'Courier New,Liberation Mono,Courier,monospace'; break; case 'proportionalSansSerif': cue.fontFamily = 'Arial,Helvetica,Liberation Sans,sans-serif'; break; case 'sansSerif': cue.fontFamily = 'sans-serif'; break; case 'monospaceSansSerif': // cspell: disable-next-line cue.fontFamily = 'Consolas,monospace'; break; case 'proportionalSerif': cue.fontFamily = 'serif'; break; default: cue.fontFamily = fontFamily.split(',').filter((font) => { return font != 'default'; }).join(','); break; } } const fontWeight = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'fontWeight', shouldInheritRegionStyles); if (fontWeight && fontWeight == 'bold') { cue.fontWeight = Cue.fontWeight.BOLD; } const wrapOption = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'wrapOption', shouldInheritRegionStyles); if (wrapOption && wrapOption == 'noWrap') { cue.wrapLine = false; } else { cue.wrapLine = true; } const lineHeight = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'lineHeight', shouldInheritRegionStyles); if (lineHeight && lineHeight.match(TtmlTextParser.unitValues_)) { cue.lineHeight = lineHeight; } const fontSize = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'fontSize', shouldInheritRegionStyles); if (fontSize) { const isValidFontSizeUnit = fontSize.match(TtmlTextParser.unitValues_) || fontSize.match(TtmlTextParser.percentValue_); if (isValidFontSizeUnit) { cue.fontSize = fontSize; } } const fontStyle = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'fontStyle', shouldInheritRegionStyles); if (fontStyle) { goog.asserts.assert(fontStyle.toUpperCase() in Cue.fontStyle, fontStyle.toUpperCase() + ' Should be in Cue.fontStyle values!'); cue.fontStyle = Cue.fontStyle[fontStyle.toUpperCase()]; } if (imageElement) { // According to the spec, we should use imageType (camelCase), but // historically we have checked for imagetype (lowercase). // This was the case since background image support was first introduced // in PR #1859, in April 2019, and first released in v2.5.0. // Now we check for both, although only imageType (camelCase) is to spec. const backgroundImageType = imageElement.attributes['imageType'] || imageElement.attributes['imagetype']; const backgroundImageEncoding = imageElement.attributes['encoding']; const backgroundImageData = (TXml.getTextContents(imageElement)).trim(); if (backgroundImageType == 'PNG' && backgroundImageEncoding == 'Base64' && backgroundImageData) { cue.backgroundImage = 'data:image/png;base64,' + backgroundImageData; } } else if (imageUri) { cue.backgroundImage = imageUri; } const textOutline = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'textOutline', shouldInheritRegionStyles); if (textOutline) { // tts:textOutline isn't natively supported by browsers, but it can be // mostly replicated using the non-standard -webkit-text-stroke-width and // -webkit-text-stroke-color properties. const split = textOutline.split(' '); if (split[0].match(TtmlTextParser.unitValues_)) { // There is no defined color, so default to the text color. cue.textStrokeColor = cue.color; } else { cue.textStrokeColor = TtmlTextParser.convertTTMLrgbaToHTMLrgba_(split[0]); split.shift(); } if (split[0] && split[0].match(TtmlTextParser.unitValues_)) { cue.textStrokeWidth = split[0]; } else { // If there is no width, or the width is not a number, don't draw a // border. cue.textStrokeColor = ''; } // There is an optional blur radius also, but we have no way of // replicating that, so ignore it. } const letterSpacing = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'letterSpacing', shouldInheritRegionStyles); if (letterSpacing && letterSpacing.match(TtmlTextParser.unitValues_)) { cue.letterSpacing = letterSpacing; } const linePadding = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'linePadding', shouldInheritRegionStyles); if (linePadding && linePadding.match(TtmlTextParser.unitValues_)) { cue.linePadding = linePadding; } const opacity = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'opacity', shouldInheritRegionStyles); if (opacity) { cue.opacity = parseFloat(opacity); } // Text decoration is an array of values which can come both from the // element's style or be inherited from elements' parent nodes. All of those // values should be applied as long as they don't contradict each other. If // they do, elements' own style gets preference. const textDecorationRegion = TtmlTextParser.getStyleAttributeFromRegion_( region, styles, 'textDecoration'); if (textDecorationRegion) { TtmlTextParser.addTextDecoration_(cue, textDecorationRegion); } const textDecorationElement = TtmlTextParser.getStyleAttributeFromElement_( cueElement, styles, 'textDecoration'); if (textDecorationElement) { TtmlTextParser.addTextDecoration_(cue, textDecorationElement); } const textCombine = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'textCombine', shouldInheritRegionStyles); if (textCombine) { cue.textCombineUpright = textCombine; } const ruby = TtmlTextParser.getStyleAttribute_( cueElement, region, styles, 'ruby', shouldInheritRegionStyles); switch (ruby) { case 'container': cue.rubyTag = 'ruby'; break; case 'text': cue.rubyTag = 'rt'; break; } } /** * Parses text decoration values and adds/removes them to/from the cue. * * @param {!shaka.text.Cue} cue * @param {string} decoration * @private */ static addTextDecoration_(cue, decoration) { const Cue = shaka.text.Cue; for (const value of decoration.split(' ')) { switch (value) { case 'underline': if (!cue.textDecoration.includes(Cue.textDecoration.UNDERLINE)) { cue.textDecoration.push(Cue.textDecoration.UNDERLINE); } break; case 'noUnderline': if (cue.textDecoration.includes(Cue.textDecoration.UNDERLINE)) { shaka.util.ArrayUtils.remove(cue.textDecoration, Cue.textDecoration.UNDERLINE); } break; case 'lineThrough': if (!cue.textDecoration.includes(Cue.textDecoration.LINE_THROUGH)) { cue.textDecoration.push(Cue.textDecoration.LINE_THROUGH); } break; case 'noLineThrough': if (cue.textDecoration.includes(Cue.textDecoration.LINE_THROUGH)) { shaka.util.ArrayUtils.remove(cue.textDecoration, Cue.textDecoration.LINE_THROUGH); } break; case 'overline': if (!cue.textDecoration.includes(Cue.textDecoration.OVERLINE)) { cue.textDecoration.push(Cue.textDecoration.OVERLINE); } break; case 'noOverline': if (cue.textDecoration.includes(Cue.textDecoration.OVERLINE)) { shaka.util.ArrayUtils.remove(cue.textDecoration, Cue.textDecoration.OVERLINE); } break; } } } /** * Finds a specified attribute on either the original cue element or its * associated region and returns the value if the attribute was found. * * @param {!shaka.extern.xml.Node} cueElement * @param {shaka.extern.xml.Node} region * @param {!Array<!shaka.extern.xml.Node>} styles * @param {string} attribute * @param {boolean=} shouldInheritRegionStyles * @return {?string} * @private */ static getStyleAttribute_(cueElement, region, styles, attribute, shouldInheritRegionStyles=true) { // An attribute can be specified on region level or in a styling block // associated with the region or original element. const TtmlTextParser = shaka.text.TtmlTextParser; const attr = TtmlTextParser.getStyleAttributeFromElement_( cueElement, styles, attribute); if (attr) { return attr; } if (shouldInheritRegionStyles) { return TtmlTextParser.getStyleAttributeFromRegion_( region, styles, attribute); } return null; } /** * Finds a specified attribute on the element's associated region * and returns the value if the attribute was found. * * @param {shaka.extern.xml.Node} region * @param {!Array<!shaka.extern.xml.Node>} styles * @param {string} attribute * @return {?string} * @private */ static getStyleAttributeFromRegion_(region, styles, attribute) { const TXml = shaka.util.TXml; const ttsNs = shaka.text.TtmlTextParser.styleNs_; if (!region) { return null; } const attr = TXml.getAttributeNSList(region, ttsNs, attribute); if (attr) { return attr; } return shaka.text.TtmlTextParser.getInheritedStyleAttribute_( region, styles, attribute); } /** * Finds a specified attribute on the cue element and returns the value * if the attribute was found. * * @param {!shaka.extern.xml.Node} cueElement * @param {!Array<!shaka.extern.xml.Node>} styles * @param {string} attribute * @return {?string} * @private */ static getStyleAttributeFromElement_(cueElement, styles, attribute) { const TXml = shaka.util.TXml; const ttsNs = shaka.text.TtmlTextParser.styleNs_; // Styling on elements should take precedence // over the main styling attributes const elementAttribute = TXml.getAttributeNSList( cueElement, ttsNs, attribute); if (elementAttribute) { return elementAttribute; } return shaka.text.TtmlTextParser.getInheritedStyleAttribute_( cueElement, styles, attribute); } /** * Finds a specified attribute on an element's styles and the styles those * styles inherit from. * * @param {!shaka.extern.xml.Node} element * @param {!Array<!shaka.extern.xml.Node>} styles * @param {string} attribute * @return {?string} * @private */ static getInheritedStyleAttribute_(element, styles, attribute) { const TXml = shaka.util.TXml; const ttsNs = shaka.text.TtmlTextParser.styleNs_; const ebuttsNs = shaka.text.TtmlTextParser.styleEbuttsNs_; const inheritedStyles = shaka.text.TtmlTextParser.getElementsFromCollection_( element, 'style', styles, /* prefix= */ ''); let styleValue = null; // The last value in our styles stack takes the precedence over the others for (let i = 0; i < inheritedStyles.length; i++) { // Check ebu namespace first. let styleAttributeValue = TXml.getAttributeNS( inheritedStyles[i], ebuttsNs, attribute); if (!styleAttributeValue) { // Fall back to tts namespace. styleAttributeValue = TXml.getAttributeNSList( inheritedStyles[i], ttsNs, attribute); } if (!styleAttributeValue) { // Next, check inheritance. // Styles can inherit from other styles, so traverse up that chain. styleAttributeValue = shaka.text.TtmlTextParser.getStyleAttributeFromElement_( inheritedStyles[i], styles, attribute); } if (styleAttributeValue) { styleValue = styleAttributeValue; } } return styleValue; } /** * Selects items from |collection| whose id matches |attributeName| * from |element|. * * @param {shaka.extern.xml.Node} element * @param {string} attributeName * @param {!Array<shaka.extern.xml.Node>} collection * @param {string} prefixName * @param {string=} nsName * @return {!Array<!shaka.extern.xml.Node>} * @private */ static getElementsFromCollection_( element, attributeName, collection, prefixName, nsName) { const items = []; if (!element || collection.length < 1) { return items; } const attributeValue = shaka.text.TtmlTextParser.getInheritedAttribute_( element, attributeName, nsName); if (attributeValue) { // There could be multiple items in one attribute // <span style="style1 style2">A cue</span> const itemNames = attributeValue.split(' '); for (const name of itemNames) { for (const item of collection) { if ((prefixName + item.attributes['xml:id']) == name) { items.push(item); break; } } } } return items; } /** * Traverses upwards from a given node until a given attribute is found. * * @param {!shaka.extern.xml.Node} element * @param {string} attributeName * @param {string=} nsName * @return {?string} * @private */ static getInheritedAttribute_(element, attributeName, nsName) { let ret = null; const TXml = shaka.util.TXml; while (!ret) { ret = nsName ? TXml.getAttributeNS(element, nsName, attributeName) : element.attributes[attributeName]; if (ret) { break; } // Element.parentNode can lead to XMLDocument, which is not an Element and // has no getAttribute(). const parentNode = element.parent; if (parentNode) { element = parentNode; } else { break; } } return ret; } /** * Factor parent/ancestor time attributes into the parsed time of a * child/descendent. * * @param {!shaka.extern.xml.Node} parentElement * @param {!shaka.text.TtmlTextParser.RateInfo_} rateInfo * @param {?number} start The child's start time * @param {?number} end The child's end time * @return {{start: ?number, end: ?number}} * @private */ static resolveTime_(parentElement, rateInfo, start, end) { const parentTime = shaka.text.TtmlTextParser.parseTime_( parentElement, rateInfo); if (start == null) { // No start time of your own? Inherit from the parent. start = parentTime.start; } else { // Otherwise, the start time is relative to the parent's start time. if (parentTime.start != null) { start += parentTime.start; } } if (end == null) { // No end time of your own? Inherit from the parent. end = parentTime.end; } else { // Otherwise, the end time is relative to the parent's _start_ time. // This is not a typo. Both times are relative to the parent's _start_. if (parentTime.start != null) { end += parentTime.start; } } return {start, end}; } /** * Parse TTML time attributes from the given element. * * @param {!shaka.extern.xml.Node} element * @param {!shaka.text.TtmlTextParser.RateInfo_} rateInfo * @return {{start: ?number, end: ?number}} * @private */ static parseTime_(element, rateInfo) { const start = shaka.text.TtmlTextParser.parseTimeAttribute_( element.attributes['begin'], rateInfo); let end = shaka.text.TtmlTextParser.parseTimeAttribute_( element.attributes['end'], rateInfo); const duration = shaka.text.TtmlTextParser.parseTimeAttribute_( element.attributes['dur'], rateInfo); if (end == null && duration != null) { end = start + duration; } return {start, end}; } /** * Parses a TTML time from the given attribute text. * * @param {string} text * @param {!shaka.text.TtmlTextParser.RateInfo_} rateInfo * @return {?number} * @private */ static parseTimeAttribute_(text, rateInfo) { let ret = null; const TtmlTextParser = shaka.text.TtmlTextParser; if (TtmlTextParser.timeColonFormatFrames_.test(text)) { ret = TtmlTextParser.parseColonTimeWithFrames_(rateInfo, text); } else if (TtmlTextParser.timeColonFormat_.test(text)) { ret = TtmlTextParser.parseTimeFromRegex_( TtmlTextParser.timeColonFormat_, text); } else if (TtmlTextParser.timeColonFormatMilliseconds_.test(text)) { ret = TtmlTextParser.parseTimeFromRegex_( TtmlTextParser.timeColonFormatMilliseconds_, text); } else if (TtmlTextParser.timeFramesFormat_.test(text)) { ret = TtmlTextParser.parseFramesTime_(rateInfo, text); } else if (TtmlTextParser.timeTickFormat_.test(text)) { ret = TtmlTextParser.parseTickTime_(rateInfo, text); } else if (TtmlTextParser.timeHMSFormat_.test(text)) { ret = TtmlTextParser.parseTimeFromRegex_( TtmlTextParser.timeHMSFormat_, text); } else if (text) { // It's not empty or null, but it doesn't match a known format. throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.TEXT, shaka.util.Error.Code.INVALID_TEXT_CUE, 'Could not parse cue time range in TTML'); } return ret; } /** * Parses a TTML time in frame format. * * @param {!shaka.text.TtmlTextParser.RateInfo_} rateInfo * @param {string} text * @return {?number} * @private */ static parseFramesTime_(rateInfo, text) { // 75f or 75.5f const results = shaka.text.TtmlTextParser.timeFramesFormat_.exec(text); const frames = Number(results[1]); return frames / rateInfo.frameRate; } /** * Parses a TTML time in tick format. * * @param {!shaka.text.TtmlTextParser.RateInfo_} rateInfo * @param {string} text * @return {?number} * @private */ static parseTickTime_(rateInfo, text) { // 50t or 50.5t const results = shaka.text.TtmlTextParser.timeTickFormat_.exec(text); const ticks = Number(results[1]); return ticks / rateInfo.tickRate; } /** * Parses a TTML colon formatted time containing frames. * * @param {!shaka.text.TtmlTextParser.RateInfo_} rateInfo * @param {string} text * @return {?number} * @private */ static parseColonTimeWithFrames_(rateInfo, text) { // 01:02:43:07 ('07' is frames) or 01:02:43:07.1 (subframes) const results = shaka.text.TtmlTextParser.timeColonFormatFrames_.exec(text); const hours = Number(results[1]); const minutes = Number(results[2]); let seconds = Number(results[3]); let frames = Number(results[4]); const subframes = Number(results[5]) || 0; frames += subframes / rateInfo.subFrameRate; seconds += frames / rateInfo.frameRate; return seconds + (minutes * 60) + (hours * 3600); } /** * Parses a TTML time with a given regex. Expects regex to be some * sort of a time-matcher to match hours, minutes, seconds and milliseconds * * @param {!RegExp} regex * @param {string} text * @return {?number} * @private */ static parseTimeFromRegex_(regex, text) { const results = regex.exec(text); if (results == null || results[0] == '') { return null; } // This capture is optional, but will still be in the array as undefined, // in which case it is 0. const hours = Number(results[1]) || 0; const minutes = Number(results[2]) || 0; const seconds = Number(results[3]) || 0; const milliseconds = Number(results[4]) || 0; return (milliseconds / 1000) + seconds + (minutes * 60) + (hours * 3600); } /** * If ttp:cellResolution provided returns cell resolution info * with number of columns and rows into which the Root Container * Region area is divided * * @param {?string} cellResolution * @return {?{columns: number, rows: number}} * @private */ static getCellResolution_(cellResolution) { if (!cellResolution) { return null; } const matches = /^(\d+) (\d+)$/.exec(cellResolution); if (!matches) { return null; } const columns = parseInt(matches[1], 10); const rows = parseInt(matches[2], 10); return {columns, rows}; } }; /** * @summary * Contains information about frame/subframe rate * and frame rate multiplier for time in frame format. * * @example 01:02:03:04(4 frames) or 01:02:03:04.1(4 frames, 1 subframe) * @private */ shaka.text.TtmlTextParser.RateInfo_ = class { /** * @param {?string} frameRate * @param {?string} subFrameRate * @param {?string} frameRateMultiplier * @param {?string} tickRate */ constructor(frameRate, subFrameRate, frameRateMultiplier, tickRate) { /** * @type {number} */ this.frameRate = Number(frameRate) || 30; /** * @type {number} */ this.subFrameRate = Number(subFrameRate) || 1; /** * @type {number} */ this.tickRate = Number(tickRate); if (this.tickRate == 0) { if (frameRate) { this.tickRate = this.frameRate * this.subFrameRate; } else { this.tickRate = 1; } } if (frameRateMultiplier) { const multiplierResults = /^(\d+) (\d+)$/g.exec(frameRateMultiplier); if (multiplierResults) { const numerator = Number(multiplierResults[1]); const denominator = Number(multiplierResults[2]); const multiplierNum = numerator / denominator; this.frameRate *= multiplierNum; } } } }; /** * @const * @private {!RegExp} * @example 50.17% 10% */ shaka.text.TtmlTextParser.percentValues_ = /^(\d{1,2}(?:\.\d+)?|100(?:\.0+)?)% (\d{1,2}(?:\.\d+)?|100(?:\.0+)?)%$/; /** * @const * @private {!RegExp} * @example 0.6% 90% 300% 1000% */ shaka.text.TtmlTextParser.percentValue_ = /^(\d{1,4}(?:\.\d+)?|100)%$/; /** * @const * @private {!RegExp} * @example 100px, 8em, 0.80c */ shaka.text.TtmlTextParser.unitValues_ = /^(\d+px|\d+em|\d*\.?\d+c)$/; /** * @const * @private {!RegExp} * @example 100px */ shaka.text.TtmlTextParser.pixelValues_ = /^(\d+)px (\d+)px$/; /** * @const * @private {!RegExp} * @example 00:00:40:07 (7 frames) or 00:00:40:07.1 (7 frames, 1 subframe) */ shaka.text.TtmlTextParser.timeColonFormatFrames_ = /^(\d{2,}):(\d{2}):(\d{2}):(\d{2})\.?(\d+)?$/; /** * @const * @private {!RegExp} * @example 00:00:40 or 00:40 */ shaka.text.TtmlTextParser.timeColonFormat_ = /^(?:(\d{2,}):)?(\d{2}):(\d{2})$/; /** * @const * @private {!RegExp} * @example 01:02:43.0345555 or 02:43.03 or 02:45.5 */ shaka.text.TtmlTextParser.timeColonFormatMilliseconds_ = /^(?:(\d{2,}):)?(\d{2}):(\d{2}\.\d+)$/; /** * @const * @private {!RegExp} * @example 75f or 75.5f */ shaka.text.TtmlTextParser.timeFramesFormat_ = /^(\d*(?:\.\d*)?)f$/; /** * @const * @private {!RegExp} * @example 50t or 50.5t */ shaka.text.TtmlTextParser.timeTickFormat_ = /^(\d*(?:\.\d*)?)t$/; /** * @const * @private {!RegExp} * @example 3.45h, 3m or 4.20s */ shaka.text.TtmlTextParser.timeHMSFormat_ = new RegExp(['^(?:(\\d*(?:\\.\\d*)?)h)?', '(?:(\\d*(?:\\.\\d*)?)m)?', '(?:(\\d*(?:\\.\\d*)?)s)?', '(?:(\\d*(?:\\.\\d*)?)ms)?$'].join('')); /** * @const * @private {!Map<string, shaka.text.Cue.lineAlign>} */ shaka.text.TtmlTextParser.textAlignToLineAlign_ = new Map() .set('left', shaka.text.Cue.lineAlign.START) .set('center', shaka.text.Cue.lineAlign.CENTER) .set('right', shaka.text.Cue.lineAlign.END) .set('start', shaka.text.Cue.lineAlign.START) .set('end', shaka.text.Cue.lineAlign.END); /** * @const * @private {!Map<string, shaka.text.Cue.positionAlign>} */ shaka.text.TtmlTextParser.textAlignToPositionAlign_ = new Map() .set('left', shaka.text.Cue.positionAlign.LEFT) .set('center', shaka.text.Cue.positionAlign.CENTER) .set('right', shaka.text.Cue.positionAlign.RIGHT); /** * The namespace URL for TTML parameters. Can be assigned any name in the TTML * document, not just "ttp:", so we use this with getAttributeNS() to ensure * that we support arbitrary namespace names. * * @const {!Array<string>} * @private */ shaka.text.TtmlTextParser.parameterNs_ = [ 'http://www.w3.org/ns/ttml#parameter', 'http://www.w3.org/2006/10/ttaf1#parameter', ]; /** * The namespace URL for TTML styles. Can be assigned any name in the TTML * document, not just "tts:", so we use this with getAttributeNS() to ensure * that we support arbitrary namespace names. * * @const {!Array<string>} * @private */ shaka.text.TtmlTextParser.styleNs_ = [ 'http://www.w3.org/ns/ttml#styling', 'http://www.w3.org/2006/10/ttaf1#styling', ]; /** * The namespace URL for EBU TTML styles. Can be assigned any name in the TTML * document, not just "ebutts:", so we use this with getAttributeNS() to ensure * that we support arbitrary namespace names. * * @const {string} * @private */ shaka.text.TtmlTextParser.styleEbuttsNs_ = 'urn:ebu:tt:style'; /** * The supported namespace URLs for SMPTE fields. * @const {!Array<string>} * @private */ shaka.text.TtmlTextParser.smpteNsList_ = [ 'http://www.smpte-ra.org/schemas/2052-1/2010/smpte-tt', 'http://www.smpte-ra.org/schemas/2052-1/2013/smpte-tt', ]; shaka.text.TextEngine.registerParser( 'application/ttml+xml', () => new shaka.text.TtmlTextParser());