/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * Code below is vtt.js the JS WebVTT implementation. * Current source code can be found at http://github.com/mozilla/vtt.js * * Code taken from commit b89bfd06cd788a68c67e03f44561afe833db0849 */ /** * Copyright 2013 vtt.js Contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; XPCOMUtils.defineLazyPreferenceGetter(lazy, "DEBUG_LOG", "media.webvtt.debug.logging", false); function LOG(message) { if (lazy.DEBUG_LOG) { dump("[vtt] " + message + "\n"); } } var _objCreate = Object.create || (function() { function F() {} return function(o) { if (arguments.length !== 1) { throw new Error('Object.create shim only accepts one parameter.'); } F.prototype = o; return new F(); }; })(); // Creates a new ParserError object from an errorData object. The errorData // object should have default code and message properties. The default message // property can be overriden by passing in a message parameter. // See ParsingError.Errors below for acceptable errors. function ParsingError(errorData, message) { this.name = "ParsingError"; this.code = errorData.code; this.message = message || errorData.message; } ParsingError.prototype = _objCreate(Error.prototype); ParsingError.prototype.constructor = ParsingError; // ParsingError metadata for acceptable ParsingErrors. ParsingError.Errors = { BadSignature: { code: 0, message: "Malformed WebVTT signature." }, BadTimeStamp: { code: 1, message: "Malformed time stamp." } }; // See spec, https://w3c.github.io/webvtt/#collect-a-webvtt-timestamp. function collectTimeStamp(input) { function computeSeconds(h, m, s, f) { if (m > 59 || s > 59) { return null; } // The attribute of the milli-seconds can only be three digits. if (f.length !== 3) { return null; } return (h | 0) * 3600 + (m | 0) * 60 + (s | 0) + (f | 0) / 1000; } let timestamp = input.match(/^(\d+:)?(\d{2}):(\d{2})\.(\d+)/); if (!timestamp || timestamp.length !== 5) { return null; } let hours = timestamp[1]? timestamp[1].replace(":", "") : 0; let minutes = timestamp[2]; let seconds = timestamp[3]; let milliSeconds = timestamp[4]; return computeSeconds(hours, minutes, seconds, milliSeconds); } // A settings object holds key/value pairs and will ignore anything but the first // assignment to a specific key. function Settings() { this.values = _objCreate(null); } Settings.prototype = { set: function(k, v) { if (v !== "") { this.values[k] = v; } }, // Return the value for a key, or a default value. // If 'defaultKey' is passed then 'dflt' is assumed to be an object with // a number of possible default values as properties where 'defaultKey' is // the key of the property that will be chosen; otherwise it's assumed to be // a single value. get: function(k, dflt, defaultKey) { if (defaultKey) { return this.has(k) ? this.values[k] : dflt[defaultKey]; } return this.has(k) ? this.values[k] : dflt; }, // Check whether we have a value for a key. has: function(k) { return k in this.values; }, // Accept a setting if its one of the given alternatives. alt: function(k, v, a) { for (let n = 0; n < a.length; ++n) { if (v === a[n]) { this.set(k, v); return true; } } return false; }, // Accept a setting if its a valid digits value (int or float) digitsValue: function(k, v) { if (/^-0+(\.[0]*)?$/.test(v)) { // special case for -0.0 this.set(k, 0.0); } else if (/^-?\d+(\.[\d]*)?$/.test(v)) { this.set(k, parseFloat(v)); } }, // Accept a setting if its a valid percentage. percent: function(k, v) { let m; if ((m = v.match(/^([\d]{1,3})(\.[\d]*)?%$/))) { v = parseFloat(v); if (v >= 0 && v <= 100) { this.set(k, v); return true; } } return false; }, // Delete a setting del: function (k) { if (this.has(k)) { delete this.values[k]; } }, }; // Helper function to parse input into groups separated by 'groupDelim', and // interprete each group as a key/value pair separated by 'keyValueDelim'. function parseOptions(input, callback, keyValueDelim, groupDelim) { let groups = groupDelim ? input.split(groupDelim) : [input]; for (let i in groups) { if (typeof groups[i] !== "string") { continue; } let kv = groups[i].split(keyValueDelim); if (kv.length !== 2) { continue; } let k = kv[0]; let v = kv[1]; callback(k, v); } } function parseCue(input, cue, regionList) { // Remember the original input if we need to throw an error. let oInput = input; // 4.1 WebVTT timestamp function consumeTimeStamp() { let ts = collectTimeStamp(input); if (ts === null) { throw new ParsingError(ParsingError.Errors.BadTimeStamp, "Malformed timestamp: " + oInput); } // Remove time stamp from input. input = input.replace(/^[^\s\uFFFDa-zA-Z-]+/, ""); return ts; } // 4.4.2 WebVTT cue settings function consumeCueSettings(input, cue) { let settings = new Settings(); parseOptions(input, function (k, v) { switch (k) { case "region": // Find the last region we parsed with the same region id. for (let i = regionList.length - 1; i >= 0; i--) { if (regionList[i].id === v) { settings.set(k, regionList[i].region); break; } } break; case "vertical": settings.alt(k, v, ["rl", "lr"]); break; case "line": { let vals = v.split(","); let vals0 = vals[0]; settings.digitsValue(k, vals0); settings.percent(k, vals0) ? settings.set("snapToLines", false) : null; settings.alt(k, vals0, ["auto"]); if (vals.length === 2) { settings.alt("lineAlign", vals[1], ["start", "center", "end"]); } break; } case "position": { let vals = v.split(","); if (settings.percent(k, vals[0])) { if (vals.length === 2) { if (!settings.alt("positionAlign", vals[1], ["line-left", "center", "line-right"])) { // Remove the "position" value because the "positionAlign" is not expected value. // It will be set to default value below. settings.del(k); } } } break; } case "size": settings.percent(k, v); break; case "align": settings.alt(k, v, ["start", "center", "end", "left", "right"]); break; } }, /:/, /\t|\n|\f|\r| /); // groupDelim is ASCII whitespace // Apply default values for any missing fields. // https://w3c.github.io/webvtt/#collect-a-webvtt-block step 11.4.1.3 cue.region = settings.get("region", null); cue.vertical = settings.get("vertical", ""); cue.line = settings.get("line", "auto"); cue.lineAlign = settings.get("lineAlign", "start"); cue.snapToLines = settings.get("snapToLines", true); cue.size = settings.get("size", 100); cue.align = settings.get("align", "center"); cue.position = settings.get("position", "auto"); cue.positionAlign = settings.get("positionAlign", "auto"); } function skipWhitespace() { input = input.replace(/^[ \f\n\r\t]+/, ""); } // 4.1 WebVTT cue timings. skipWhitespace(); cue.startTime = consumeTimeStamp(); // (1) collect cue start time skipWhitespace(); if (input.substr(0, 3) !== "-->") { // (3) next characters must match "-->" throw new ParsingError(ParsingError.Errors.BadTimeStamp, "Malformed time stamp (time stamps must be separated by '-->'): " + oInput); } input = input.substr(3); skipWhitespace(); cue.endTime = consumeTimeStamp(); // (5) collect cue end time // 4.1 WebVTT cue settings list. skipWhitespace(); consumeCueSettings(input, cue); } function emptyOrOnlyContainsWhiteSpaces(input) { return input == "" || /^[ \f\n\r\t]+$/.test(input); } function containsTimeDirectionSymbol(input) { return input.includes("-->"); } function maybeIsTimeStampFormat(input) { return /^\s*(\d+:)?(\d{2}):(\d{2})\.(\d+)\s*-->\s*(\d+:)?(\d{2}):(\d{2})\.(\d+)\s*/.test(input); } var ESCAPE = { "&": "&", "<": "<", ">": ">", "": "\u200e", "": "\u200f", " ": "\u00a0" }; var TAG_NAME = { c: "span", i: "i", b: "b", u: "u", ruby: "ruby", rt: "rt", v: "span", lang: "span" }; var TAG_ANNOTATION = { v: "title", lang: "lang" }; var NEEDS_PARENT = { rt: "ruby" }; const PARSE_CONTENT_MODE = { NORMAL_CUE: "normal_cue", DOCUMENT_FRAGMENT: "document_fragment", REGION_CUE: "region_cue", } // Parse content into a document fragment. function parseContent(window, input, mode) { function nextToken() { // Check for end-of-string. if (!input) { return null; } // Consume 'n' characters from the input. function consume(result) { input = input.substr(result.length); return result; } let m = input.match(/^([^<]*)(<[^>]+>?)?/); // The input doesn't contain a complete tag. if (!m[0]) { return null; } // If there is some text before the next tag, return it, otherwise return // the tag. return consume(m[1] ? m[1] : m[2]); } // Unescape a string 's'. function unescape1(e) { return ESCAPE[e]; } function unescape(s) { let m; while ((m = s.match(/&(amp|lt|gt|lrm|rlm|nbsp);/))) { s = s.replace(m[0], unescape1); } return s; } function shouldAdd(current, element) { return !NEEDS_PARENT[element.localName] || NEEDS_PARENT[element.localName] === current.localName; } // Create an element for this tag. function createElement(type, annotation) { let tagName = TAG_NAME[type]; if (!tagName) { return null; } let element = window.document.createElement(tagName); let name = TAG_ANNOTATION[type]; if (name) { element[name] = annotation ? annotation.trim() : ""; } return element; } // https://w3c.github.io/webvtt/#webvtt-timestamp-object // Return hhhhh:mm:ss.fff function normalizedTimeStamp(secondsWithFrag) { let totalsec = parseInt(secondsWithFrag, 10); let hours = Math.floor(totalsec / 3600); let minutes = Math.floor(totalsec % 3600 / 60); let seconds = Math.floor(totalsec % 60); if (hours < 10) { hours = "0" + hours; } if (minutes < 10) { minutes = "0" + minutes; } if (seconds < 10) { seconds = "0" + seconds; } let f = secondsWithFrag.toString().split("."); if (f[1]) { f = f[1].slice(0, 3).padEnd(3, "0"); } else { f = "000"; } return hours + ':' + minutes + ':' + seconds + '.' + f; } let root; switch (mode) { case PARSE_CONTENT_MODE.NORMAL_CUE: root = window.document.createElement("span", {pseudo: "::cue"}); break; case PARSE_CONTENT_MODE.REGION_CUE: root = window.document.createElement("span"); break; case PARSE_CONTENT_MODE.DOCUMENT_FRAGMENT: root = window.document.createDocumentFragment(); break; } if (!input) { root.appendChild(window.document.createTextNode("")); return root; } let current = root, t, tagStack = []; while ((t = nextToken()) !== null) { if (t[0] === '<') { if (t[1] === "/") { // If the closing tag matches, move back up to the parent node. if (tagStack.length && tagStack[tagStack.length - 1] === t.substr(2).replace(">", "")) { tagStack.pop(); current = current.parentNode; } // Otherwise just ignore the end tag. continue; } let ts = collectTimeStamp(t.substr(1, t.length - 1)); let node; if (ts) { // Timestamps are lead nodes as well. node = window.document.createProcessingInstruction("timestamp", normalizedTimeStamp(ts)); current.appendChild(node); continue; } let m = t.match(/^<([^.\s/0-9>]+)(\.[^\s\\>]+)?([^>\\]+)?(\\?)>?$/); // If we can't parse the tag, skip to the next tag. if (!m) { continue; } // Try to construct an element, and ignore the tag if we couldn't. node = createElement(m[1], m[3]); if (!node) { continue; } // Determine if the tag should be added based on the context of where it // is placed in the cuetext. if (!shouldAdd(current, node)) { continue; } // Set the class list (as a list of classes, separated by space). if (m[2]) { node.className = m[2].substr(1).replace('.', ' '); } // Append the node to the current node, and enter the scope of the new // node. tagStack.push(m[1]); current.appendChild(node); current = node; continue; } // Text nodes are leaf nodes. current.appendChild(window.document.createTextNode(unescape(t))); } return root; } function StyleBox() { } // Apply styles to a div. If there is no div passed then it defaults to the // div on 'this'. StyleBox.prototype.applyStyles = function(styles, div) { div = div || this.div; for (let prop in styles) { if (styles.hasOwnProperty(prop)) { div.style[prop] = styles[prop]; } } }; StyleBox.prototype.formatStyle = function(val, unit) { return val === 0 ? 0 : val + unit; }; // TODO(alwu): remove StyleBox and change other style box to class-based. class StyleBoxBase { applyStyles(styles, div) { div = div || this.div; Object.assign(div.style, styles); } formatStyle(val, unit) { return val === 0 ? 0 : val + unit; } } // Constructs the computed display state of the cue (a div). Places the div // into the overlay which should be a block level element (usually a div). class CueStyleBox extends StyleBoxBase { constructor(window, cue, containerBox) { super(); this.cue = cue; this.div = window.document.createElement("div"); this.cueDiv = parseContent(window, cue.text, PARSE_CONTENT_MODE.NORMAL_CUE); this.div.appendChild(this.cueDiv); this.containerHeight = containerBox.height; this.containerWidth = containerBox.width; this.fontSize = this._getFontSize(containerBox); this.isCueStyleBox = true; // As pseudo element won't inherit the parent div's style, so we have to // set the font size explicitly. this._applyDefaultStylesOnBackgroundNode(); this._applyDefaultStylesOnRootNode(); } getCueBoxPositionAndSize() { // As `top`, `left`, `width` and `height` are all represented by the // percentage of the container, we need to convert them to the actual // number according to the container's size. const isWritingDirectionHorizontal = this.cue.vertical == ""; let top = this.containerHeight * this._tranferPercentageToFloat(this.div.style.top), left = this.containerWidth * this._tranferPercentageToFloat(this.div.style.left), width = isWritingDirectionHorizontal ? this.containerWidth * this._tranferPercentageToFloat(this.div.style.width) : this.div.clientWidthDouble, height = isWritingDirectionHorizontal ? this.div.clientHeightDouble : this.containerHeight * this._tranferPercentageToFloat(this.div.style.height); return { top, left, width, height }; } getFirstLineBoxSize() { // This size would be automatically adjusted by writing direction. When // direction is horizontal, it represents box's height. When direction is // vertical, it represents box's width. return this.div.firstLineBoxBSize; } setBidiRule() { // This function is a workaround which is used to force the reflow in order // to use the correct alignment for bidi text. Now this function would be // called after calculating the final position of the cue box to ensure the // rendering result is correct. See bug1557882 comment3 for more details. // TODO : remove this function and set `unicode-bidi` when initiailizing // the CueStyleBox, after fixing bug1558431. this.applyStyles({ "unicode-bidi": "plaintext" }); } /** * Following methods are private functions, should not use them outside this * class. */ _tranferPercentageToFloat(input) { return input.replace("%", "") / 100.0; } _getFontSize(containerBox) { // In https://www.w3.org/TR/webvtt1/#applying-css-properties, the spec // said the font size is '5vh', which means 5% of the viewport height. // However, if we use 'vh' as a basic unit, it would eventually become // 5% of screen height, instead of video's viewport height. Therefore, we // have to use 'px' here to make sure we have the correct font size. return containerBox.height * 0.05 + "px"; } _applyDefaultStylesOnBackgroundNode() { // most of the properties have been defined in `::cue` in `html.css`, but // there are some css properties we have to set them dynamically. // FIXME(emilio): These are observable by content. Ideally the style // attribute will work like for ::part() and we wouldn't need this. this.cueDiv.style.setProperty("--cue-font-size", this.fontSize, "important"); this.cueDiv.style.setProperty("--cue-writing-mode", this._getCueWritingMode(), "important"); } // spec https://www.w3.org/TR/webvtt1/#applying-css-properties _applyDefaultStylesOnRootNode() { // The variables writing-mode, top, left, width, and height are calculated // in the spec 7.2, https://www.w3.org/TR/webvtt1/#processing-cue-settings // spec 7.2.1, calculate 'writing-mode'. const writingMode = this._getCueWritingMode(); // spec 7.2.2 ~ 7.2.7, calculate 'width', 'height', 'left' and 'top'. const {width, height, left, top} = this._getCueSizeAndPosition(); this.applyStyles({ "position": "absolute", // "unicode-bidi": "plaintext", (uncomment this line after fixing bug1558431) "writing-mode": writingMode, "top": top, "left": left, "width": width, "height": height, "overflow-wrap": "break-word", // "text-wrap": "balance", (we haven't supported this CSS attribute yet) "white-space": "pre-line", "font": this.fontSize + " sans-serif", "color": "rgba(255, 255, 255, 1)", "white-space": "pre-line", "text-align": this.cue.align, }); } _getCueWritingMode() { const cue = this.cue; if (cue.vertical == "") { return "horizontal-tb"; } return cue.vertical == "lr" ? "vertical-lr" : "vertical-rl"; } _getCueSizeAndPosition() { const cue = this.cue; // spec 7.2.2, determine the value of maximum size for cue as per the // appropriate rules from the following list. let maximumSize; let computedPosition = cue.computedPosition; switch (cue.computedPositionAlign) { case "line-left": maximumSize = 100 - computedPosition; break; case "line-right": maximumSize = computedPosition; break; case "center": maximumSize = computedPosition <= 50 ? computedPosition * 2 : (100 - computedPosition) * 2; break; } const size = Math.min(cue.size, maximumSize); // spec 7.2.5, determine the value of x-position or y-position for cue as // per the appropriate rules from the following list. let xPosition = 0.0, yPosition = 0.0; const isWritingDirectionHorizontal = cue.vertical == ""; switch (cue.computedPositionAlign) { case "line-left": if (isWritingDirectionHorizontal) { xPosition = cue.computedPosition; } else { yPosition = cue.computedPosition; } break; case "center": if (isWritingDirectionHorizontal) { xPosition = cue.computedPosition - (size / 2); } else { yPosition = cue.computedPosition - (size / 2); } break; case "line-right": if (isWritingDirectionHorizontal) { xPosition = cue.computedPosition - size; } else { yPosition = cue.computedPosition - size; } break; } // spec 7.2.6, determine the value of whichever of x-position or // y-position is not yet calculated for cue as per the appropriate rules // from the following list. if (!cue.snapToLines) { if (isWritingDirectionHorizontal) { yPosition = cue.computedLine; } else { xPosition = cue.computedLine; } } else { if (isWritingDirectionHorizontal) { yPosition = 0; } else { xPosition = 0; } } return { left: xPosition + "%", top: yPosition + "%", width: isWritingDirectionHorizontal ? size + "%" : "auto", height: isWritingDirectionHorizontal ? "auto" : size + "%", }; } } function RegionNodeBox(window, region, container) { StyleBox.call(this); let boxLineHeight = container.height * 0.0533 // 0.0533vh ? 5.33vh let boxHeight = boxLineHeight * region.lines; let boxWidth = container.width * region.width / 100; // convert percentage to px let regionNodeStyles = { position: "absolute", height: boxHeight + "px", width: boxWidth + "px", top: (region.viewportAnchorY * container.height / 100) - (region.regionAnchorY * boxHeight / 100) + "px", left: (region.viewportAnchorX * container.width / 100) - (region.regionAnchorX * boxWidth / 100) + "px", lineHeight: boxLineHeight + "px", writingMode: "horizontal-tb", backgroundColor: "rgba(0, 0, 0, 0.8)", wordWrap: "break-word", overflowWrap: "break-word", font: (boxLineHeight/1.3) + "px sans-serif", color: "rgba(255, 255, 255, 1)", overflow: "hidden", minHeight: "0px", maxHeight: boxHeight + "px", display: "inline-flex", flexFlow: "column", justifyContent: "flex-end", }; this.div = window.document.createElement("div"); this.div.id = region.id; // useless? this.applyStyles(regionNodeStyles); } RegionNodeBox.prototype = _objCreate(StyleBox.prototype); RegionNodeBox.prototype.constructor = RegionNodeBox; function RegionCueStyleBox(window, cue) { StyleBox.call(this); this.cueDiv = parseContent(window, cue.text, PARSE_CONTENT_MODE.REGION_CUE); let regionCueStyles = { position: "relative", writingMode: "horizontal-tb", unicodeBidi: "plaintext", width: "auto", height: "auto", textAlign: cue.align, }; // TODO: fix me, LTR and RTL ? using margin replace the "left/right" // 6.1.14.3.3 let offset = cue.computedPosition * cue.region.width / 100; // 6.1.14.3.4 switch (cue.align) { case "start": case "left": regionCueStyles.left = offset + "%"; regionCueStyles.right = "auto"; break; case "end": case "right": regionCueStyles.left = "auto"; regionCueStyles.right = offset + "%"; break; case "middle": break; } this.div = window.document.createElement("div"); this.applyStyles(regionCueStyles); this.div.appendChild(this.cueDiv); } RegionCueStyleBox.prototype = _objCreate(StyleBox.prototype); RegionCueStyleBox.prototype.constructor = RegionCueStyleBox; // Represents the co-ordinates of an Element in a way that we can easily // compute things with such as if it overlaps or intersects with other boxes. class BoxPosition { constructor(obj) { // Get dimensions by calling getCueBoxPositionAndSize on a CueStyleBox, by // getting offset properties from an HTMLElement (from the object or its // `div` property), otherwise look at the regular box properties on the // object. const isHTMLElement = !obj.isCueStyleBox && (obj.div || obj.tagName); obj = obj.isCueStyleBox ? obj.getCueBoxPositionAndSize() : obj.div || obj; this.top = isHTMLElement ? obj.offsetTop : obj.top; this.left = isHTMLElement ? obj.offsetLeft : obj.left; this.width = isHTMLElement ? obj.offsetWidth : obj.width; this.height = isHTMLElement ? obj.offsetHeight : obj.height; // This value is smaller than 1 app unit (~= 0.0166 px). this.fuzz = 0.01; } get bottom() { return this.top + this.height; } get right() { return this.left + this.width; } // This function is used for debugging, it will return the box's information. getBoxInfoInChars() { return `top=${this.top}, bottom=${this.bottom}, left=${this.left}, ` + `right=${this.right}, width=${this.width}, height=${this.height}`; } // Move the box along a particular axis. Optionally pass in an amount to move // the box. If no amount is passed then the default is the line height of the // box. move(axis, toMove) { switch (axis) { case "+x": LOG(`box's left moved from ${this.left} to ${this.left + toMove}`); this.left += toMove; break; case "-x": LOG(`box's left moved from ${this.left} to ${this.left - toMove}`); this.left -= toMove; break; case "+y": LOG(`box's top moved from ${this.top} to ${this.top + toMove}`); this.top += toMove; break; case "-y": LOG(`box's top moved from ${this.top} to ${this.top - toMove}`); this.top -= toMove; break; } } // Check if this box overlaps another box, b2. overlaps(b2) { return (this.left < b2.right - this.fuzz) && (this.right > b2.left + this.fuzz) && (this.top < b2.bottom - this.fuzz) && (this.bottom > b2.top + this.fuzz); } // Check if this box overlaps any other boxes in boxes. overlapsAny(boxes) { for (let i = 0; i < boxes.length; i++) { if (this.overlaps(boxes[i])) { return true; } } return false; } // Check if this box is within another box. within(container) { return (this.top >= container.top - this.fuzz) && (this.bottom <= container.bottom + this.fuzz) && (this.left >= container.left - this.fuzz) && (this.right <= container.right + this.fuzz); } // Check whether this box is passed over the specfic axis boundary. The axis // is based on the canvas coordinates, the `+x` is rightward and `+y` is // downward. isOutsideTheAxisBoundary(container, axis) { switch (axis) { case "+x": return this.right > container.right + this.fuzz; case "-x": return this.left < container.left - this.fuzz; case "+y": return this.bottom > container.bottom + this.fuzz; case "-y": return this.top < container.top - this.fuzz; } } // Find the percentage of the area that this box is overlapping with another // box. intersectPercentage(b2) { let x = Math.max(0, Math.min(this.right, b2.right) - Math.max(this.left, b2.left)), y = Math.max(0, Math.min(this.bottom, b2.bottom) - Math.max(this.top, b2.top)), intersectArea = x * y; return intersectArea / (this.height * this.width); } } BoxPosition.prototype.clone = function(){ return new BoxPosition(this); }; function adjustBoxPosition(styleBox, containerBox, controlBarBox, outputBoxes) { const cue = styleBox.cue; const isWritingDirectionHorizontal = cue.vertical == ""; let box = new BoxPosition(styleBox); if (!box.width || !box.height) { LOG(`No way to adjust a box with zero width or height.`); return; } // Spec 7.2.10, adjust the positions of boxes according to the appropriate // steps from the following list. Also, we use offsetHeight/offsetWidth here // in order to prevent the incorrect positioning caused by CSS transform // scale. const fullDimension = isWritingDirectionHorizontal ? containerBox.height : containerBox.width; if (cue.snapToLines) { LOG(`Adjust position when 'snap-to-lines' is true.`); // The step is the height or width of the line box. We should use font // size directly, instead of using text box's width or height, because the // width or height of the box would be changed when the text is wrapped to // different line. Ex. if text is wrapped to two line, the height or width // of the box would become 2 times of font size. let step = styleBox.getFirstLineBoxSize(); if (step == 0) { return; } // spec 7.2.10.4 ~ 7.2.10.6 let line = Math.floor(cue.computedLine + 0.5); if (cue.vertical == "rl") { line = -1 * (line + 1); } // spec 7.2.10.7 ~ 7.2.10.8 let position = step * line; if (cue.vertical == "rl") { position = position - box.width + step; } // spec 7.2.10.9 if (line < 0) { position += fullDimension; step = -1 * step; } // spec 7.2.10.10, move the box to the specific position along the direction. const movingDirection = isWritingDirectionHorizontal ? "+y" : "+x"; box.move(movingDirection, position); // spec 7.2.10.11, remember the position as specified position. let specifiedPosition = box.clone(); // spec 7.2.10.12, let title area be a box that covers all of the video’s // rendering area. const titleAreaBox = containerBox.clone(); if (controlBarBox) { titleAreaBox.height -= controlBarBox.height; } function isBoxOutsideTheRenderingArea() { if (isWritingDirectionHorizontal) { // the top side of the box is above the rendering area, or the bottom // side of the box is below the rendering area. return step < 0 && box.top < 0 || step > 0 && box.bottom > fullDimension; } // the left side of the box is outside the left side of the rendering // area, or the right side of the box is outside the right side of the // rendering area. return step < 0 && box.left < 0 || step > 0 && box.right > fullDimension; } // spec 7.2.10.13, if none of the boxes in boxes would overlap any of the // boxes in output, and all of the boxes in boxes are entirely within the // title area box. let switched = false; while (!box.within(titleAreaBox) || box.overlapsAny(outputBoxes)) { // spec 7.2.10.14, check if we need to switch the direction. if (isBoxOutsideTheRenderingArea()) { // spec 7.2.10.17, if `switched` is true, remove all the boxes in // `boxes`, which means we shouldn't apply any CSS boxes for this cue. // Therefore, returns null box. if (switched) { return null; } // spec 7.2.10.18 ~ 7.2.10.20 switched = true; box = specifiedPosition.clone(); step = -1 * step; } // spec 7.2.10.15, moving box along the specific direction. box.move(movingDirection, step); } if (isWritingDirectionHorizontal) { styleBox.applyStyles({ top: getPercentagePosition(box.top, fullDimension), }); } else { styleBox.applyStyles({ left: getPercentagePosition(box.left, fullDimension), }); } } else { LOG(`Adjust position when 'snap-to-lines' is false.`); // (snap-to-lines if false) spec 7.2.10.1 ~ 7.2.10.2 if (cue.lineAlign != "start") { const isCenterAlign = cue.lineAlign == "center"; const movingDirection = isWritingDirectionHorizontal ? "-y" : "-x"; if (isWritingDirectionHorizontal) { box.move(movingDirection, isCenterAlign ? box.height : box.height / 2); } else { box.move(movingDirection, isCenterAlign ? box.width : box.width / 2); } } // spec 7.2.10.3 let bestPosition = {}, specifiedPosition = box.clone(), outsideAreaPercentage = 1; // Highest possible so the first thing we get is better. let hasFoundBestPosition = false; // For the different writing directions, we should have different priority // for the moving direction. For example, if the writing direction is // horizontal, which means the cues will grow from the top to the bottom, // then moving cues along the `y` axis should be more important than moving // cues along the `x` axis, and vice versa for those cues growing from the // left to right, or from the right to the left. We don't follow the exact // way which the spec requires, see the reason in bug1575460. function getAxis(writingDirection) { if (writingDirection == "") { return ["+y", "-y", "+x", "-x"]; } // Growing from left to right. if (writingDirection == "lr") { return ["+x", "-x", "+y", "-y"]; } // Growing from right to left. return ["-x", "+x", "+y", "-y"]; } const axis = getAxis(cue.vertical); // This factor effects the granularity of the moving unit, when using the // factor=1 often moves too much and results in too many redudant spaces // between boxes. So we can increase the factor to slightly reduce the // move we do every time, but still can preverse the reasonable spaces // between boxes. const factor = 4; const toMove = styleBox.getFirstLineBoxSize() / factor; for (let i = 0; i < axis.length && !hasFoundBestPosition; i++) { while (!box.isOutsideTheAxisBoundary(containerBox, axis[i]) && (!box.within(containerBox) || box.overlapsAny(outputBoxes))) { box.move(axis[i], toMove); } // We found a spot where we aren't overlapping anything. This is our // best position. if (box.within(containerBox)) { bestPosition = box.clone(); hasFoundBestPosition = true; break; } let p = box.intersectPercentage(containerBox); // If we're outside the container box less then we were on our last try // then remember this position as the best position. if (outsideAreaPercentage > p) { bestPosition = box.clone(); outsideAreaPercentage = p; } // Reset the box position to the specified position. box = specifiedPosition.clone(); } // Can not find a place to place this box inside the rendering area. if (!box.within(containerBox)) { return null; } styleBox.applyStyles({ top: getPercentagePosition(box.top, containerBox.height), left: getPercentagePosition(box.left, containerBox.width), }); } // In order to not be affected by CSS scale, so we use '%' to make sure the // cue can stick in the right position. function getPercentagePosition(position, fullDimension) { return (position / fullDimension) * 100 + "%"; } return box; } export function WebVTT() { this.isProcessingCues = false; // Nothing } // Helper to allow strings to be decoded instead of the default binary utf8 data. WebVTT.StringDecoder = function() { return { decode: function(data) { if (!data) { return ""; } if (typeof data !== "string") { throw new Error("Error - expected string data."); } return decodeURIComponent(encodeURIComponent(data)); } }; }; WebVTT.convertCueToDOMTree = function(window, cuetext) { if (!window) { return null; } return parseContent(window, cuetext, PARSE_CONTENT_MODE.DOCUMENT_FRAGMENT); }; function clearAllCuesDiv(overlay) { while (overlay.firstChild) { overlay.firstChild.remove(); } } // It's used to record how many cues we process in the last `processCues` run. var lastDisplayedCueNums = 0; const DIV_COMPUTING_STATE = { REUSE : 0, REUSE_AND_CLEAR : 1, COMPUTE_AND_CLEAR : 2 }; // Runs the processing model over the cues and regions passed to it. // Spec https://www.w3.org/TR/webvtt1/#processing-model // @parem window : JS window // @param cues : the VTT cues are going to be displayed. // @param overlay : A block level element (usually a div) that the computed cues // and regions will be placed into. // @param controls : A Control bar element. Cues' position will be // affected and repositioned according to it. function processCuesInternal(window, cues, overlay, controls) { LOG(`=== processCues ===`); if (!cues) { LOG(`clear display and abort processing because of no cue.`); clearAllCuesDiv(overlay); lastDisplayedCueNums = 0; return; } let controlBar, controlBarShown; if (controls) { // controls is a