diff --git a/AUTHORS b/AUTHORS index e878c89a0b..7538f2a6ff 100644 --- a/AUTHORS +++ b/AUTHORS @@ -14,6 +14,7 @@ # Please keep the list sorted. AdsWizz <*@adswizz.com> +Adrián Gómez Llorente Alugha GmbH <*@alugha.com> Alvaro Velad Galvan Bonnier Broadcasting <*@bonnierbroadcasting.com> @@ -51,4 +52,6 @@ Tomas Tichy Toshihiro Suzuki uStudio Inc. <*@ustudio.com> Verizon Digital Media Services <*@verizondigitalmedia.com> -Adrián Gómez Llorente +Vincent Valot + + diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 2fb5a99ad8..e3e3607aa6 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -83,5 +83,6 @@ Torbjörn Einarsson Toshihiro Suzuki Vasanth Polipelli Vignesh Venkatasubramanian +Vincent Valot Yohann Connell Adrián Gómez Llorente diff --git a/externs/shaka/text.js b/externs/shaka/text.js index 08ce8f5b5c..6ce7d952b8 100644 --- a/externs/shaka/text.js +++ b/externs/shaka/text.js @@ -316,6 +316,20 @@ shaka.extern.Cue = class { * @exportDoc */ this.id; + + /** + * Nested cues + * @type {Array.} + * @exportDoc + */ + this.nestedCues; + + /** + * Whether or not the cue only acts as a spacer between two cues + * @type {boolean} + * @exportDoc + */ + this.spacer; } }; diff --git a/lib/text/cue.js b/lib/text/cue.js index 4fbb9d4659..cf36c828e4 100644 --- a/lib/text/cue.js +++ b/lib/text/cue.js @@ -182,6 +182,18 @@ shaka.text.Cue = class { * @exportInterface */ this.id = ''; + + /** + * @override + * @exportInterface + */ + this.nestedCues = []; + + /** + * @override + * @exportInterface + */ + this.spacer = false; } }; diff --git a/lib/text/ttml_text_parser.js b/lib/text/ttml_text_parser.js index e98e9543c1..4689a999d3 100644 --- a/lib/text/ttml_text_parser.js +++ b/lib/text/ttml_text_parser.js @@ -109,12 +109,12 @@ shaka.text.TtmlTextParser = class { } } - const textNodes = TtmlTextParser.getLeafNodes_( + const textNodes = TtmlTextParser.getLeafCues_( tt.getElementsByTagName('body')[0]); for (const node of textNodes) { const cue = TtmlTextParser.parseCue_( node, time.periodStart, rateInfo, metadataElements, styles, - regionElements, cueRegions, whitespaceTrim); + regionElements, cueRegions, whitespaceTrim, false); if (cue) { ret.push(cue); } @@ -139,18 +139,18 @@ shaka.text.TtmlTextParser = class { } for (const node of element.childNodes) { - // Currently we don't support styles applicable to span - // elements, so they are ignored. - const isSpanChildOfP = node.nodeName == 'span' && element.nodeName == 'p'; - if (node.nodeType == Node.ELEMENT_NODE && - node.nodeName != 'br' && !isSpanChildOfP) { + if ( + node.nodeType == Node.ELEMENT_NODE && + node.nodeName !== 'br' + ) { // Get the leaves the child might contain. - goog.asserts.assert( - node instanceof Element, 'Node should be Element!'); + goog.asserts.assert(node instanceof Element, + 'Node should be Element!'); const leafChildren = shaka.text.TtmlTextParser.getLeafNodes_( /** @type {Element} */(node)); goog.asserts.assert(leafChildren.length > 0, 'Only a null Element should return no leaves!'); + result = result.concat(leafChildren); } } @@ -159,33 +159,59 @@ shaka.text.TtmlTextParser = class { if (!result.length) { result.push(element); } + return result; } /** - * Inserts \n where
tags are found. + * Get the leaf nodes that can act as cues + * (at least begin attribute) + * + * @param {Element} element + * @return {!Array.} + * @private + */ + static getLeafCues_(element) { + if (!element) { + return []; + } + + return Array.from(element.querySelectorAll('[begin]')); + } + + + /** + * Trims and removes multiple spaces from a string * - * @param {!Node} element + * @param {Element} element * @param {boolean} whitespaceTrim + * @return {string} * @private */ - static addNewLines_(element, whitespaceTrim) { - let prevNode = null; + static sanitizeTextContent(element, whitespaceTrim) { + let payload = ''; + for (const node of element.childNodes) { - if (node.nodeName == 'br' && prevNode) { - prevNode.textContent += '\n'; - } else if (node.childNodes.length > 0) { - shaka.text.TtmlTextParser.addNewLines_(node, whitespaceTrim); + if (node.nodeName == 'br' && element.childNodes[0] !== node) { + payload += '\n'; + } else if (node.childNodes && node.childNodes.length > 0) { + payload += shaka.text.TtmlTextParser.sanitizeTextContent( + /** @type {!Element} */ (node), + whitespaceTrim + ); } else if (whitespaceTrim) { // Trim leading and trailing whitespace. let trimmed = node.textContent.trim(); // Collapse multiple spaces into one. trimmed = trimmed.replace(/\s+/g, ' '); - node.textContent = trimmed; + payload += trimmed; + } else { + payload += node.textContent; } - prevNode = node; } + + return payload; } /** @@ -199,24 +225,35 @@ shaka.text.TtmlTextParser = class { * @param {!Array.} regionElements * @param {!Array.} cueRegions * @param {boolean} whitespaceTrim + * @param {boolean} isNested * @return {shaka.text.Cue} * @private */ static parseCue_( cueElement, offset, rateInfo, metadataElements, styles, regionElements, - cueRegions, whitespaceTrim) { + cueRegions, whitespaceTrim, isNested) { + if (isNested && cueElement.nodeName == 'br') { + const cue = new shaka.text.Cue(0, 0, ''); + cue.spacer = true; + + return cue; + } + // Disregard empty elements: // TTML allows for empty elements like
. // If cueElement has neither time attributes, nor // non-whitespace text, don't try to make a cue out of it. - if (!cueElement.hasAttribute('begin') && - !cueElement.hasAttribute('end') && - /^\s*$/.test(cueElement.textContent)) { + if ( + /^[\s\n]*$/.test(cueElement.textContent) || + ( + !isNested && + !cueElement.hasAttribute('begin') && + !cueElement.hasAttribute('end') + ) + ) { return null; } - shaka.text.TtmlTextParser.addNewLines_(cueElement, whitespaceTrim); - // Get time. let start = shaka.text.TtmlTextParser.parseTime_( cueElement.getAttribute('begin'), rateInfo); @@ -224,13 +261,12 @@ shaka.text.TtmlTextParser = class { cueElement.getAttribute('end'), rateInfo); const duration = shaka.text.TtmlTextParser.parseTime_( cueElement.getAttribute('dur'), rateInfo); - const payload = cueElement.textContent; if (end == null && duration != null) { end = start + duration; } - if (start == null || end == null) { + if (!isNested && (start == null || end == null)) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.TEXT, @@ -240,20 +276,52 @@ shaka.text.TtmlTextParser = class { start += offset; end += offset; + let payload = ''; + const nestedCues = []; + // If one of the children is text node type + // stop going down and write the payload + if ( + Array.from(cueElement.childNodes).find( + (childNode) => childNode.nodeType === Node.TEXT_NODE && + /\w+/.test(childNode.textContent) + ) + ) { + payload = shaka.text.TtmlTextParser.sanitizeTextContent( + cueElement, + whitespaceTrim, + ); + } else { + for (const childNode of cueElement.childNodes) { + const nestedCue = shaka.text.TtmlTextParser.parseCue_( + /** @type {!Element} */ (childNode), + offset, + rateInfo, + metadataElements, + styles, + regionElements, + cueRegions, + whitespaceTrim, + /* isNested */ true + ); + + if (nestedCue) { + nestedCues.push(nestedCue); + } + } + } + const cue = new shaka.text.Cue(start, end, payload); + cue.nestedCues = nestedCues; // Get other properties if available. - const regionElement = shaka.text.TtmlTextParser.getElementFromCollection_( - cueElement, 'region', regionElements, /* prefix= */ ''); + const regionElement = shaka.text.TtmlTextParser.getElementsFromCollection_( + cueElement, 'region', regionElements, /* prefix= */ '')[0]; if (regionElement && regionElement.getAttribute('xml:id')) { const regionId = regionElement.getAttribute('xml:id'); - const regionsWithId = cueRegions.filter((region) => { - return region.id == regionId; - }); - cue.region = regionsWithId[0]; + cue.region = cueRegions.filter((region) => region.id == regionId)[0]; } - const imageElement = shaka.text.TtmlTextParser.getElementFromCollection_( - cueElement, 'smpte:backgroundImage', metadataElements, '#'); + const imageElement = shaka.text.TtmlTextParser.getElementsFromCollection_( + cueElement, 'smpte:backgroundImage', metadataElements, '#')[0]; shaka.text.TtmlTextParser.addStyle_( cue, cueElement, @@ -581,11 +649,13 @@ shaka.text.TtmlTextParser = class { } } - const style = shaka.text.TtmlTextParser.getElementFromCollection_( - region, 'style', styles, /* prefix= */ ''); + const style = shaka.text.TtmlTextParser.getElementsFromCollection_( + region, 'style', styles, /* prefix= */ '')[0]; + if (style) { return XmlUtils.getAttributeNS(style, ttsNs, attribute); } + return null; } @@ -603,47 +673,85 @@ shaka.text.TtmlTextParser = class { const XmlUtils = shaka.util.XmlUtils; const ttsNs = shaka.text.TtmlTextParser.styleNs_; - const getElementFromCollection_ = - shaka.text.TtmlTextParser.getElementFromCollection_; - const style = getElementFromCollection_( - cueElement, 'style', styles, /* prefix= */ ''); - if (style) { - return XmlUtils.getAttributeNS(style, ttsNs, attribute); + // Styling on elements should take precedence + // over the main styling attributes + const elementAttribute = XmlUtils.getAttributeNS( + cueElement, + ttsNs, + attribute + ); + + if (elementAttribute) { + return elementAttribute; } - return null; + + const inheritedStyles = + shaka.text.TtmlTextParser.getElementsFromCollection_( + cueElement, '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++) { + const styleAttributeValue = XmlUtils.getAttributeNS( + inheritedStyles[i], + ttsNs, + attribute + ); + + if (styleAttributeValue) { + styleValue = styleAttributeValue; + } + } + + return styleValue; } + /** - * Selects an item from |collection| whose id matches |attributeName| + * Selects items from |collection| whose id matches |attributeName| * from |element|. * * @param {Element} element * @param {string} attributeName * @param {!Array.} collection * @param {string} prefixName - * @return {Element} + * @return {Array.} * @private */ - static getElementFromCollection_( + static getElementsFromCollection_( element, attributeName, collection, prefixName) { + const items = []; + if (!element || collection.length < 1) { - return null; + return items; } - let item = null; - const itemName = shaka.text.TtmlTextParser.getInheritedAttribute_( + + const attributeValue = shaka.text.TtmlTextParser.getInheritedAttribute_( element, attributeName); - if (itemName) { - for (const cur of collection) { - if ((prefixName + cur.getAttribute('xml:id')) == itemName) { - item = cur; - break; + + if (attributeValue) { + // There could be multiple items in one attribute + // A cue + const itemNames = attributeValue.split(' '); + + for (const name of itemNames) { + for (const item of collection) { + if ( + (prefixName + item.getAttribute('xml:id')) == name + ) { + items.push(item); + break; + } } } } - return item; + return items; } + /** * Traverses upwards from a given node until a given attribute is found. * diff --git a/test/text/ttml_text_parser_unit.js b/test/text/ttml_text_parser_unit.js index b62cd8785f..e68c53be00 100644 --- a/test/text/ttml_text_parser_unit.js +++ b/test/text/ttml_text_parser_unit.js @@ -44,7 +44,12 @@ describe('TtmlTextParser', () => { // When xml:space="default", ignore whitespace outside tags. verifyHelper( [ - {startTime: 62.03, endTime: 62.05, payload: 'A B C'}, + { + startTime: 62.03, + endTime: 62.05, + nestedCues: [{payload: 'A B C'}], + payload: '', + }, ], '' + ttBody + '', {periodStart: 0, segmentStart: 0, segmentEnd: 0}); @@ -54,7 +59,8 @@ describe('TtmlTextParser', () => { { startTime: 62.03, endTime: 62.05, - payload: '\n A B C \n ', + nestedCues: [{payload: ' A B C '}], + payload: '', }, ], '' + ttBody + '', @@ -62,7 +68,12 @@ describe('TtmlTextParser', () => { // The default value for xml:space is "default". verifyHelper( [ - {startTime: 62.03, endTime: 62.05, payload: 'A B C'}, + { + startTime: 62.03, + endTime: 62.05, + nestedCues: [{payload: 'A B C'}], + payload: '', + }, ], '' + ttBody + '', {periodStart: 0, segmentStart: 0, segmentEnd: 0}); @@ -78,9 +89,28 @@ describe('TtmlTextParser', () => { it('rejects invalid time format', () => { errorHelper(shaka.util.Error.Code.INVALID_TEXT_CUE, - '

'); + '

My very own cue

'); errorHelper(shaka.util.Error.Code.INVALID_TEXT_CUE, - '

'); + '

An invalid cue

'); + }); + + it('supports spans as nestedCues of paragraphs', () => { + verifyHelper( + [ + { + startTime: 62.05, + endTime: 3723.2, + payload: '', + nestedCues: [ + {payload: 'First cue'}, + {payload: '', spacer: true}, + {payload: 'Second cue'}, + ], + }, + ], + '

' + + 'First cue
Second cue

', + {periodStart: 0, segmentStart: 0, segmentEnd: 0}); }); it('supports colon formatted time', () => { @@ -654,7 +684,12 @@ describe('TtmlTextParser', () => { {periodStart: 0, segmentStart: 0, segmentEnd: 0}); verifyHelper( [ - {startTime: 62.05, endTime: 3723.2, payload: 'Line1\nLine2'}, + { + startTime: 62.05, + endTime: 3723.2, + nestedCues: [{payload: 'Line1\nLine2'}], + payload: '', + }, ], '

Line1
Line2

', @@ -813,6 +848,13 @@ describe('TtmlTextParser', () => { if (cue.region) { cue.region = jasmine.objectContaining(cue.region); } + + if (cue.nestedCues) { + cue.nestedCues = cue.nestedCues.map( + (nestedCue) => jasmine.objectContaining(nestedCue) + ); + } + return jasmine.objectContaining(cue); }); expect(result).toEqual(expected); diff --git a/ui/less/containers.less b/ui/less/containers.less index e61c4e33e7..cd8009aa05 100644 --- a/ui/less/containers.less +++ b/ui/less/containers.less @@ -217,10 +217,11 @@ /* Set the captions in the middle horizontally by default. */ display: flex; - justify-content: center; + flex-direction: column; + align-items: center; /* Set the captions at the bottom by default. */ - align-items: flex-end; + justify-content: flex-end; /* If the captions are too long to fit in the video container, hide the * overflow content. */ @@ -237,8 +238,7 @@ /* These are defaults which are overridden by JS or cue styles. */ background-color: rgba(0, 0, 0, 0.8); color: rgb(255, 255, 255); - display: block; - max-width: 95%; + display: inline-block; } } diff --git a/ui/text_displayer.js b/ui/text_displayer.js index 8dbba5fadc..26893f6cb5 100644 --- a/ui/text_displayer.js +++ b/ui/text_displayer.js @@ -181,10 +181,53 @@ shaka.ui.TextDisplayer = class { }); for (const cue of currentCues) { - const captions = shaka.util.Dom.createHTMLElement('span'); + this.displayCue_(this.textContainer_, cue); + } + } + + /** + * Displays a nested cue + * + * @param {Element} container + * @param {!shaka.extern.Cue} cue + * @return {Element} the created captions container + * @private + */ + displayNestedCue_(container, cue) { + const captions = shaka.util.Dom.createHTMLElement('span'); + + if (cue.spacer) { + captions.style.display = 'block'; + } else { this.setCaptionStyles_(captions, cue); - this.currentCuesMap_.set(cue, captions); - this.textContainer_.appendChild(captions); + } + + container.appendChild(captions); + + return captions; + } + + /** + * Displays a cue + * + * @param {Element} container + * @param {!shaka.extern.Cue} cue + * @private + */ + displayCue_(container, cue) { + if (cue.nestedCues.length) { + const nestedCuesContainer = shaka.util.Dom.createHTMLElement('p'); + nestedCuesContainer.style.width = '100%'; + this.setCaptionStyles_(nestedCuesContainer, cue); + + for (let i = 0; i < cue.nestedCues.length; i++) { + this.displayNestedCue_(nestedCuesContainer, cue.nestedCues[i]); + } + + container.appendChild(nestedCuesContainer); + this.currentCuesMap_.set(cue, nestedCuesContainer); + } else { + this.currentCuesMap_.set(cue, this.displayNestedCue_(container, cue)); } } @@ -223,11 +266,11 @@ shaka.ui.TextDisplayer = class { // captions inside the text container. Before means at the top of the // text container, and after means at the bottom. if (cue.displayAlign == Cue.displayAlign.BEFORE) { - panelStyle.alignItems = 'flex-start'; + panelStyle.justifyContent = 'flex-start'; } else if (cue.displayAlign == Cue.displayAlign.CENTER) { - panelStyle.alignItems = 'flex-top'; + panelStyle.justifyContent = 'center'; } else { - panelStyle.alignItems = 'flex-end'; + panelStyle.justifyContent = 'flex-end'; } captionsStyle.fontFamily = cue.fontFamily;