vtt.sys.mjs (56958B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /** 6 * Code below is vtt.js the JS WebVTT implementation. 7 * Current source code can be found at http://github.com/mozilla/vtt.js 8 * 9 * Code taken from commit b89bfd06cd788a68c67e03f44561afe833db0849 10 */ 11 /** 12 * Copyright 2013 vtt.js Contributors 13 * 14 * Licensed under the Apache License, Version 2.0 (the "License"); 15 * you may not use this file except in compliance with the License. 16 * You may obtain a copy of the License at 17 * 18 * http://www.apache.org/licenses/LICENSE-2.0 19 * 20 * Unless required by applicable law or agreed to in writing, software 21 * distributed under the License is distributed on an "AS IS" BASIS, 22 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 23 * See the License for the specific language governing permissions and 24 * limitations under the License. 25 */ 26 27 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 28 29 const lazy = {}; 30 31 XPCOMUtils.defineLazyPreferenceGetter(lazy, "DEBUG_LOG", 32 "media.webvtt.debug.logging", false); 33 34 function LOG(message) { 35 if (lazy.DEBUG_LOG) { 36 dump("[vtt] " + message + "\n"); 37 } 38 } 39 40 var _objCreate = Object.create || (function() { 41 function F() {} 42 return function(o) { 43 if (arguments.length !== 1) { 44 throw new Error('Object.create shim only accepts one parameter.'); 45 } 46 F.prototype = o; 47 return new F(); 48 }; 49 })(); 50 51 // Creates a new ParserError object from an errorData object. The errorData 52 // object should have default code and message properties. The default message 53 // property can be overriden by passing in a message parameter. 54 // See ParsingError.Errors below for acceptable errors. 55 function ParsingError(errorData, message) { 56 this.name = "ParsingError"; 57 this.code = errorData.code; 58 this.message = message || errorData.message; 59 } 60 ParsingError.prototype = _objCreate(Error.prototype); 61 ParsingError.prototype.constructor = ParsingError; 62 63 // ParsingError metadata for acceptable ParsingErrors. 64 ParsingError.Errors = { 65 BadSignature: { 66 code: 0, 67 message: "Malformed WebVTT signature." 68 }, 69 BadTimeStamp: { 70 code: 1, 71 message: "Malformed time stamp." 72 } 73 }; 74 75 // See spec, https://w3c.github.io/webvtt/#collect-a-webvtt-timestamp. 76 function collectTimeStamp(input) { 77 function computeSeconds(h, m, s, f) { 78 if (m > 59 || s > 59) { 79 return null; 80 } 81 // The attribute of the milli-seconds can only be three digits. 82 if (f.length !== 3) { 83 return null; 84 } 85 return (h | 0) * 3600 + (m | 0) * 60 + (s | 0) + (f | 0) / 1000; 86 } 87 88 let timestamp = input.match(/^(\d+:)?(\d{2}):(\d{2})\.(\d+)/); 89 if (!timestamp || timestamp.length !== 5) { 90 return null; 91 } 92 93 let hours = timestamp[1]? timestamp[1].replace(":", "") : 0; 94 let minutes = timestamp[2]; 95 let seconds = timestamp[3]; 96 let milliSeconds = timestamp[4]; 97 98 return computeSeconds(hours, minutes, seconds, milliSeconds); 99 } 100 101 // A settings object holds key/value pairs and will ignore anything but the first 102 // assignment to a specific key. 103 function Settings() { 104 this.values = _objCreate(null); 105 } 106 107 Settings.prototype = { 108 set: function(k, v) { 109 if (v !== "") { 110 this.values[k] = v; 111 } 112 }, 113 // Return the value for a key, or a default value. 114 // If 'defaultKey' is passed then 'dflt' is assumed to be an object with 115 // a number of possible default values as properties where 'defaultKey' is 116 // the key of the property that will be chosen; otherwise it's assumed to be 117 // a single value. 118 get: function(k, dflt, defaultKey) { 119 if (defaultKey) { 120 return this.has(k) ? this.values[k] : dflt[defaultKey]; 121 } 122 return this.has(k) ? this.values[k] : dflt; 123 }, 124 // Check whether we have a value for a key. 125 has: function(k) { 126 return k in this.values; 127 }, 128 // Accept a setting if its one of the given alternatives. 129 alt: function(k, v, a) { 130 for (let n = 0; n < a.length; ++n) { 131 if (v === a[n]) { 132 this.set(k, v); 133 return true; 134 } 135 } 136 return false; 137 }, 138 // Accept a setting if its a valid digits value (int or float) 139 digitsValue: function(k, v) { 140 if (/^-0+(\.[0]*)?$/.test(v)) { // special case for -0.0 141 this.set(k, 0.0); 142 } else if (/^-?\d+(\.[\d]*)?$/.test(v)) { 143 this.set(k, parseFloat(v)); 144 } 145 }, 146 // Accept a setting if its a valid percentage. 147 percent: function(k, v) { 148 let m; 149 if ((m = v.match(/^([\d]{1,3})(\.[\d]*)?%$/))) { 150 v = parseFloat(v); 151 if (v >= 0 && v <= 100) { 152 this.set(k, v); 153 return true; 154 } 155 } 156 return false; 157 }, 158 // Delete a setting 159 del: function (k) { 160 if (this.has(k)) { 161 delete this.values[k]; 162 } 163 }, 164 }; 165 166 // Helper function to parse input into groups separated by 'groupDelim', and 167 // interprete each group as a key/value pair separated by 'keyValueDelim'. 168 function parseOptions(input, callback, keyValueDelim, groupDelim) { 169 let groups = groupDelim ? input.split(groupDelim) : [input]; 170 for (let i in groups) { 171 if (typeof groups[i] !== "string") { 172 continue; 173 } 174 let kv = groups[i].split(keyValueDelim); 175 if (kv.length !== 2) { 176 continue; 177 } 178 let k = kv[0]; 179 let v = kv[1]; 180 callback(k, v); 181 } 182 } 183 184 function parseCue(input, cue, regionList) { 185 // Remember the original input if we need to throw an error. 186 let oInput = input; 187 // 4.1 WebVTT timestamp 188 function consumeTimeStamp() { 189 let ts = collectTimeStamp(input); 190 if (ts === null) { 191 throw new ParsingError(ParsingError.Errors.BadTimeStamp, 192 "Malformed timestamp: " + oInput); 193 } 194 // Remove time stamp from input. 195 input = input.replace(/^[^\s\uFFFDa-zA-Z-]+/, ""); 196 return ts; 197 } 198 199 // 4.4.2 WebVTT cue settings 200 function consumeCueSettings(input, cue) { 201 let settings = new Settings(); 202 parseOptions(input, function (k, v) { 203 switch (k) { 204 case "region": 205 // Find the last region we parsed with the same region id. 206 for (let i = regionList.length - 1; i >= 0; i--) { 207 if (regionList[i].id === v) { 208 settings.set(k, regionList[i].region); 209 break; 210 } 211 } 212 break; 213 case "vertical": 214 settings.alt(k, v, ["rl", "lr"]); 215 break; 216 case "line": { 217 let vals = v.split(","); 218 let vals0 = vals[0]; 219 settings.digitsValue(k, vals0); 220 settings.percent(k, vals0) ? settings.set("snapToLines", false) : null; 221 settings.alt(k, vals0, ["auto"]); 222 if (vals.length === 2) { 223 settings.alt("lineAlign", vals[1], ["start", "center", "end"]); 224 } 225 break; 226 } 227 case "position": { 228 let vals = v.split(","); 229 if (settings.percent(k, vals[0])) { 230 if (vals.length === 2) { 231 if (!settings.alt("positionAlign", vals[1], ["line-left", "center", "line-right"])) { 232 // Remove the "position" value because the "positionAlign" is not expected value. 233 // It will be set to default value below. 234 settings.del(k); 235 } 236 } 237 } 238 break; 239 } 240 case "size": 241 settings.percent(k, v); 242 break; 243 case "align": 244 settings.alt(k, v, ["start", "center", "end", "left", "right"]); 245 break; 246 } 247 }, /:/, /\t|\n|\f|\r| /); // groupDelim is ASCII whitespace 248 249 // Apply default values for any missing fields. 250 // https://w3c.github.io/webvtt/#collect-a-webvtt-block step 11.4.1.3 251 cue.region = settings.get("region", null); 252 cue.vertical = settings.get("vertical", ""); 253 cue.line = settings.get("line", "auto"); 254 cue.lineAlign = settings.get("lineAlign", "start"); 255 cue.snapToLines = settings.get("snapToLines", true); 256 cue.size = settings.get("size", 100); 257 cue.align = settings.get("align", "center"); 258 cue.position = settings.get("position", "auto"); 259 cue.positionAlign = settings.get("positionAlign", "auto"); 260 } 261 262 function skipWhitespace() { 263 input = input.replace(/^[ \f\n\r\t]+/, ""); 264 } 265 266 // 4.1 WebVTT cue timings. 267 skipWhitespace(); 268 cue.startTime = consumeTimeStamp(); // (1) collect cue start time 269 skipWhitespace(); 270 if (input.substr(0, 3) !== "-->") { // (3) next characters must match "-->" 271 throw new ParsingError(ParsingError.Errors.BadTimeStamp, 272 "Malformed time stamp (time stamps must be separated by '-->'): " + 273 oInput); 274 } 275 input = input.substr(3); 276 skipWhitespace(); 277 cue.endTime = consumeTimeStamp(); // (5) collect cue end time 278 279 // 4.1 WebVTT cue settings list. 280 skipWhitespace(); 281 consumeCueSettings(input, cue); 282 } 283 284 function emptyOrOnlyContainsWhiteSpaces(input) { 285 return input == "" || /^[ \f\n\r\t]+$/.test(input); 286 } 287 288 function containsTimeDirectionSymbol(input) { 289 return input.includes("-->"); 290 } 291 292 function maybeIsTimeStampFormat(input) { 293 return /^\s*(\d+:)?(\d{2}):(\d{2})\.(\d+)\s*-->\s*(\d+:)?(\d{2}):(\d{2})\.(\d+)\s*/.test(input); 294 } 295 296 var ESCAPE = { 297 "&": "&", 298 "<": "<", 299 ">": ">", 300 "‎": "\u200e", 301 "‏": "\u200f", 302 " ": "\u00a0" 303 }; 304 305 var TAG_NAME = { 306 c: "span", 307 i: "i", 308 b: "b", 309 u: "u", 310 ruby: "ruby", 311 rt: "rt", 312 v: "span", 313 lang: "span" 314 }; 315 316 var TAG_ANNOTATION = { 317 v: "title", 318 lang: "lang" 319 }; 320 321 var NEEDS_PARENT = { 322 rt: "ruby" 323 }; 324 325 const PARSE_CONTENT_MODE = { 326 NORMAL_CUE: "normal_cue", 327 DOCUMENT_FRAGMENT: "document_fragment", 328 REGION_CUE: "region_cue", 329 } 330 // Parse content into a document fragment. 331 function parseContent(window, input, mode) { 332 function nextToken() { 333 // Check for end-of-string. 334 if (!input) { 335 return null; 336 } 337 338 // Consume 'n' characters from the input. 339 function consume(result) { 340 input = input.substr(result.length); 341 return result; 342 } 343 344 let m = input.match(/^([^<]*)(<[^>]+>?)?/); 345 // The input doesn't contain a complete tag. 346 if (!m[0]) { 347 return null; 348 } 349 // If there is some text before the next tag, return it, otherwise return 350 // the tag. 351 return consume(m[1] ? m[1] : m[2]); 352 } 353 354 const unescapeHelper = window.document.createElement("div"); 355 function unescapeEntities(s) { 356 let match; 357 358 // Decimal numeric character reference 359 s = s.replace(/&#(\d+);?/g, (candidate, number) => { 360 try { 361 const codepoint = parseInt(number); 362 return String.fromCodePoint(codepoint); 363 } catch (_) { 364 return candidate; 365 } 366 }); 367 368 // Hexadecimal numeric character reference 369 s = s.replace(/&#x([\dA-Fa-f]+);?/g, (candidate, number) => { 370 try { 371 const codepoint = parseInt(number, 16); 372 return String.fromCodePoint(codepoint); 373 } catch (_) { 374 return candidate; 375 } 376 }); 377 378 // Named character references 379 s = s.replace(/&\w[\w\d]*;?/g, candidate => { 380 // The list of entities is huge, so we use innerHTML instead. 381 // We should probably use setHTML instead once that is available (bug 1650370). 382 // Ideally we would be able to use a faster/simpler variant of setHTML (bug 1731215). 383 unescapeHelper.innerHTML = candidate; 384 const unescaped = unescapeHelper.innerText; 385 if (unescaped == candidate) { // not a valid entity 386 return candidate; 387 } 388 return unescaped; 389 }); 390 unescapeHelper.innerHTML = ""; 391 392 return s; 393 } 394 395 function shouldAdd(current, element) { 396 return !NEEDS_PARENT[element.localName] || 397 NEEDS_PARENT[element.localName] === current.localName; 398 } 399 400 // Create an element for this tag. 401 function createElement(type, annotation) { 402 let tagName = TAG_NAME[type]; 403 if (!tagName) { 404 return null; 405 } 406 let element = window.document.createElement(tagName); 407 let name = TAG_ANNOTATION[type]; 408 if (name) { 409 element[name] = annotation ? annotation.trim() : ""; 410 } 411 return element; 412 } 413 414 // https://w3c.github.io/webvtt/#webvtt-timestamp-object 415 // Return hhhhh:mm:ss.fff 416 function normalizedTimeStamp(secondsWithFrag) { 417 let totalsec = parseInt(secondsWithFrag, 10); 418 let hours = Math.floor(totalsec / 3600); 419 let minutes = Math.floor(totalsec % 3600 / 60); 420 let seconds = Math.floor(totalsec % 60); 421 if (hours < 10) { 422 hours = "0" + hours; 423 } 424 if (minutes < 10) { 425 minutes = "0" + minutes; 426 } 427 if (seconds < 10) { 428 seconds = "0" + seconds; 429 } 430 let f = secondsWithFrag.toString().split("."); 431 if (f[1]) { 432 f = f[1].slice(0, 3).padEnd(3, "0"); 433 } else { 434 f = "000"; 435 } 436 return hours + ':' + minutes + ':' + seconds + '.' + f; 437 } 438 439 let root; 440 switch (mode) { 441 case PARSE_CONTENT_MODE.NORMAL_CUE: 442 root = window.document.createElement("span", {pseudo: "::cue"}); 443 break; 444 case PARSE_CONTENT_MODE.REGION_CUE: 445 root = window.document.createElement("span"); 446 break; 447 case PARSE_CONTENT_MODE.DOCUMENT_FRAGMENT: 448 root = window.document.createDocumentFragment(); 449 break; 450 } 451 452 if (!input) { 453 root.appendChild(window.document.createTextNode("")); 454 return root; 455 } 456 457 let current = root, 458 t, 459 tagStack = []; 460 461 while ((t = nextToken()) !== null) { 462 if (t[0] === '<') { 463 if (t[1] === "/") { 464 const endTag = t.slice(2, -1); 465 const stackEnd = tagStack.at(-1); 466 467 // If the closing tag matches, move back up to the parent node. 468 if (stackEnd == endTag) { 469 tagStack.pop(); 470 current = current.parentNode; 471 472 // If the closing tag is <ruby> and we're at an <rt>, move back up to 473 // the <ruby>'s parent node. 474 } else if (endTag == "ruby" && current.nodeName == "RT") { 475 tagStack.pop(); 476 current = current.parentNode.parentNode; 477 } 478 479 // Otherwise just ignore the end tag. 480 continue; 481 } 482 let ts = collectTimeStamp(t.substr(1, t.length - 1)); 483 let node; 484 if (ts) { 485 // Timestamps are lead nodes as well. 486 node = window.document.createProcessingInstruction("timestamp", normalizedTimeStamp(ts)); 487 current.appendChild(node); 488 continue; 489 } 490 let m = t.match(/^<([^.\s/0-9>]+)(\.[^\s\\>]+)?([^>\\]+)?(\\?)>?$/); 491 // If we can't parse the tag, skip to the next tag. 492 if (!m) { 493 continue; 494 } 495 // Try to construct an element, and ignore the tag if we couldn't. 496 node = createElement(m[1], m[3]); 497 if (!node) { 498 continue; 499 } 500 // Determine if the tag should be added based on the context of where it 501 // is placed in the cuetext. 502 if (!shouldAdd(current, node)) { 503 continue; 504 } 505 // Set the class list (as a list of classes, separated by space). 506 if (m[2]) { 507 node.className = m[2].substr(1).replace('.', ' '); 508 } 509 // Append the node to the current node, and enter the scope of the new 510 // node. 511 tagStack.push(m[1]); 512 current.appendChild(node); 513 current = node; 514 continue; 515 } 516 517 // Text nodes are leaf nodes. 518 current.appendChild(window.document.createTextNode(unescapeEntities(t))); 519 } 520 521 return root; 522 } 523 524 function StyleBox() { 525 } 526 527 // Apply styles to a div. If there is no div passed then it defaults to the 528 // div on 'this'. 529 StyleBox.prototype.applyStyles = function(styles, div) { 530 div = div || this.div; 531 for (let prop in styles) { 532 if (styles.hasOwnProperty(prop)) { 533 div.style[prop] = styles[prop]; 534 } 535 } 536 }; 537 538 StyleBox.prototype.formatStyle = function(val, unit) { 539 return val === 0 ? 0 : val + unit; 540 }; 541 542 // TODO(alwu): remove StyleBox and change other style box to class-based. 543 class StyleBoxBase { 544 applyStyles(styles, div) { 545 div = div || this.div; 546 Object.assign(div.style, styles); 547 } 548 549 formatStyle(val, unit) { 550 return val === 0 ? 0 : val + unit; 551 } 552 } 553 554 // Constructs the computed display state of the cue (a div). Places the div 555 // into the overlay which should be a block level element (usually a div). 556 class CueStyleBox extends StyleBoxBase { 557 constructor(window, cue, containerBox) { 558 super(); 559 this.cue = cue; 560 this.div = window.document.createElement("div"); 561 this.cueDiv = parseContent(window, cue.text, PARSE_CONTENT_MODE.NORMAL_CUE); 562 this.div.appendChild(this.cueDiv); 563 564 this.containerHeight = containerBox.height; 565 this.containerWidth = containerBox.width; 566 this.fontSize = this._getFontSize(containerBox); 567 this.isCueStyleBox = true; 568 569 // As pseudo element won't inherit the parent div's style, so we have to 570 // set the font size explicitly. 571 this._applyDefaultStylesOnBackgroundNode(); 572 this._applyDefaultStylesOnRootNode(); 573 } 574 575 getCueBoxPositionAndSize() { 576 // As `top`, `left`, `width` and `height` are all represented by the 577 // percentage of the container, we need to convert them to the actual 578 // number according to the container's size. 579 const isWritingDirectionHorizontal = this.cue.vertical == ""; 580 let top = 581 this.containerHeight * this._tranferPercentageToFloat(this.div.style.top), 582 left = 583 this.containerWidth * this._tranferPercentageToFloat(this.div.style.left), 584 width = isWritingDirectionHorizontal ? 585 this.containerWidth * this._tranferPercentageToFloat(this.div.style.width) : 586 this.div.clientWidthDouble, 587 height = isWritingDirectionHorizontal ? 588 this.div.clientHeightDouble : 589 this.containerHeight * this._tranferPercentageToFloat(this.div.style.height); 590 return { top, left, width, height }; 591 } 592 593 getFirstLineBoxSize() { 594 // This size would be automatically adjusted by writing direction. When 595 // direction is horizontal, it represents box's height. When direction is 596 // vertical, it represents box's width. 597 return this.div.firstLineBoxBSize; 598 } 599 600 setBidiRule() { 601 // This function is a workaround which is used to force the reflow in order 602 // to use the correct alignment for bidi text. Now this function would be 603 // called after calculating the final position of the cue box to ensure the 604 // rendering result is correct. See bug1557882 comment3 for more details. 605 // TODO : remove this function and set `unicode-bidi` when initiailizing 606 // the CueStyleBox, after fixing bug1558431. 607 this.applyStyles({ "unicode-bidi": "plaintext" }); 608 } 609 610 /** 611 * Following methods are private functions, should not use them outside this 612 * class. 613 */ 614 _tranferPercentageToFloat(input) { 615 return input.replace("%", "") / 100.0; 616 } 617 618 _getFontSize(containerBox) { 619 // In https://www.w3.org/TR/webvtt1/#applying-css-properties, the spec 620 // said the font size is '5vh', which means 5% of the viewport height. 621 // However, if we use 'vh' as a basic unit, it would eventually become 622 // 5% of screen height, instead of video's viewport height. Therefore, we 623 // have to use 'px' here to make sure we have the correct font size. 624 // 625 // Note Chromium uses min(width, height) instead of just height, to not 626 // make the font unexpectedly large on portrait videos. This matches that 627 // behavior. 628 // TODO: Update this when the spec has settled 629 // https://github.com/w3c/webvtt/issues/529. 630 return Math.min(containerBox.width, containerBox.height) * 0.05 + "px"; 631 } 632 633 _applyDefaultStylesOnBackgroundNode() { 634 // most of the properties have been defined in `::cue` in `html.css`, but 635 // there are some css properties we have to set them dynamically. 636 // FIXME(emilio): These are observable by content. Ideally the style 637 // attribute will work like for ::part() and we wouldn't need this. 638 this.cueDiv.style.setProperty("--cue-font-size", this.fontSize, "important"); 639 this.cueDiv.style.setProperty("--cue-writing-mode", this._getCueWritingMode(), "important"); 640 } 641 642 // spec https://www.w3.org/TR/webvtt1/#applying-css-properties 643 _applyDefaultStylesOnRootNode() { 644 // The variables writing-mode, top, left, width, and height are calculated 645 // in the spec 7.2, https://www.w3.org/TR/webvtt1/#processing-cue-settings 646 // spec 7.2.1, calculate 'writing-mode'. 647 const writingMode = this._getCueWritingMode(); 648 649 // spec 7.2.2 ~ 7.2.7, calculate 'width', 'height', 'left' and 'top'. 650 const {width, height, left, top} = this._getCueSizeAndPosition(); 651 652 // TODO: https://github.com/w3c/webvtt/issues/530 for potentially making 653 // the cue container's font-size 0. 654 // 655 // The inline cue element cannot make the size of the parent element, this 656 // container, smaller. If the inline cue element is larger, the line height 657 // grows. If it's smaller than the container, it's stuck with the 658 // container's height. This becomes a problem when the container's font-size 659 // is large and a site wants to style the ::cue pseudo element significantly 660 // smaller. This is less of a problem when using the equivalent of 5vmin 661 // instead of 5vh of course, but it's still a problem. It would be most 662 // visible in large videos with 1:1 aspect ratio that a site tries to scale 663 // down. 664 // 665 // All WebVTT use videos with min(width, height) 180, 5% of which is 9px. 666 // 9px font-size keeps tests passing. 667 const fontSize = "9px"; 668 669 this.applyStyles({ 670 "position": "absolute", 671 // "unicode-bidi": "plaintext", (uncomment this line after fixing bug1558431) 672 "writing-mode": writingMode, 673 "top": top, 674 "left": left, 675 "width": width, 676 "height": height, 677 "overflow-wrap": "break-word", 678 // "text-wrap": "balance", (we haven't supported this CSS attribute yet) 679 "white-space": "pre-line", 680 "font": `${fontSize} sans-serif`, 681 "color": "rgba(255, 255, 255, 1)", 682 "white-space": "pre-line", 683 "text-align": this.cue.align, 684 }); 685 } 686 687 _getCueWritingMode() { 688 const cue = this.cue; 689 if (cue.vertical == "") { 690 return "horizontal-tb"; 691 } 692 return cue.vertical == "lr" ? "vertical-lr" : "vertical-rl"; 693 } 694 695 _getCueSizeAndPosition() { 696 const cue = this.cue; 697 // spec 7.2.2, determine the value of maximum size for cue as per the 698 // appropriate rules from the following list. 699 let maximumSize; 700 let computedPosition = cue.computedPosition; 701 switch (cue.computedPositionAlign) { 702 case "line-left": 703 maximumSize = 100 - computedPosition; 704 break; 705 case "line-right": 706 maximumSize = computedPosition; 707 break; 708 case "center": 709 maximumSize = computedPosition <= 50 ? 710 computedPosition * 2 : (100 - computedPosition) * 2; 711 break; 712 } 713 const size = Math.min(cue.size, maximumSize); 714 715 // spec 7.2.5, determine the value of x-position or y-position for cue as 716 // per the appropriate rules from the following list. 717 let xPosition = 0.0, yPosition = 0.0; 718 const isWritingDirectionHorizontal = cue.vertical == ""; 719 switch (cue.computedPositionAlign) { 720 case "line-left": 721 if (isWritingDirectionHorizontal) { 722 xPosition = cue.computedPosition; 723 } else { 724 yPosition = cue.computedPosition; 725 } 726 break; 727 case "center": 728 if (isWritingDirectionHorizontal) { 729 xPosition = cue.computedPosition - (size / 2); 730 } else { 731 yPosition = cue.computedPosition - (size / 2); 732 } 733 break; 734 case "line-right": 735 if (isWritingDirectionHorizontal) { 736 xPosition = cue.computedPosition - size; 737 } else { 738 yPosition = cue.computedPosition - size; 739 } 740 break; 741 } 742 743 // spec 7.2.6, determine the value of whichever of x-position or 744 // y-position is not yet calculated for cue as per the appropriate rules 745 // from the following list. 746 if (!cue.snapToLines) { 747 if (isWritingDirectionHorizontal) { 748 yPosition = cue.computedLine; 749 } else { 750 xPosition = cue.computedLine; 751 } 752 } else { 753 if (isWritingDirectionHorizontal) { 754 yPosition = 0; 755 } else { 756 xPosition = 0; 757 } 758 } 759 return { 760 left: xPosition + "%", 761 top: yPosition + "%", 762 width: isWritingDirectionHorizontal ? size + "%" : "auto", 763 height: isWritingDirectionHorizontal ? "auto" : size + "%", 764 }; 765 } 766 } 767 768 function RegionNodeBox(window, region, container) { 769 StyleBox.call(this); 770 771 // TODO: Update this when the spec has settled 772 // https://github.com/w3c/webvtt/issues/529. 773 let boxLineHeight = Math.min(container.width, container.height) * 0.0533 // 0.0533vh ? 5.33vh 774 let boxHeight = boxLineHeight * region.lines; 775 let boxWidth = container.width * region.width / 100; // convert percentage to px 776 777 let regionNodeStyles = { 778 position: "absolute", 779 height: boxHeight + "px", 780 width: boxWidth + "px", 781 top: (region.viewportAnchorY * container.height / 100) - (region.regionAnchorY * boxHeight / 100) + "px", 782 left: (region.viewportAnchorX * container.width / 100) - (region.regionAnchorX * boxWidth / 100) + "px", 783 lineHeight: boxLineHeight + "px", 784 writingMode: "horizontal-tb", 785 backgroundColor: "rgba(0, 0, 0, 0.8)", 786 wordWrap: "break-word", 787 overflowWrap: "break-word", 788 font: (boxLineHeight/1.3) + "px sans-serif", 789 color: "rgba(255, 255, 255, 1)", 790 overflow: "hidden", 791 minHeight: "0px", 792 maxHeight: boxHeight + "px", 793 display: "inline-flex", 794 flexFlow: "column", 795 justifyContent: "flex-end", 796 }; 797 798 this.div = window.document.createElement("div"); 799 this.div.id = region.id; // useless? 800 this.applyStyles(regionNodeStyles); 801 } 802 RegionNodeBox.prototype = _objCreate(StyleBox.prototype); 803 RegionNodeBox.prototype.constructor = RegionNodeBox; 804 805 function RegionCueStyleBox(window, cue) { 806 StyleBox.call(this); 807 this.cueDiv = parseContent(window, cue.text, PARSE_CONTENT_MODE.REGION_CUE); 808 809 let regionCueStyles = { 810 position: "relative", 811 writingMode: "horizontal-tb", 812 unicodeBidi: "plaintext", 813 width: "auto", 814 height: "auto", 815 textAlign: cue.align, 816 }; 817 // TODO: fix me, LTR and RTL ? using margin replace the "left/right" 818 // 6.1.14.3.3 819 let offset = cue.computedPosition * cue.region.width / 100; 820 // 6.1.14.3.4 821 switch (cue.align) { 822 case "start": 823 case "left": 824 regionCueStyles.left = offset + "%"; 825 regionCueStyles.right = "auto"; 826 break; 827 case "end": 828 case "right": 829 regionCueStyles.left = "auto"; 830 regionCueStyles.right = offset + "%"; 831 break; 832 case "middle": 833 break; 834 } 835 836 this.div = window.document.createElement("div"); 837 this.applyStyles(regionCueStyles); 838 this.div.appendChild(this.cueDiv); 839 } 840 RegionCueStyleBox.prototype = _objCreate(StyleBox.prototype); 841 RegionCueStyleBox.prototype.constructor = RegionCueStyleBox; 842 843 // Represents the co-ordinates of an Element in a way that we can easily 844 // compute things with such as if it overlaps or intersects with other boxes. 845 class BoxPosition { 846 constructor(obj) { 847 // Get dimensions by calling getCueBoxPositionAndSize on a CueStyleBox, by 848 // getting offset properties from an HTMLElement (from the object or its 849 // `div` property), otherwise look at the regular box properties on the 850 // object. 851 const isHTMLElement = !obj.isCueStyleBox && (obj.div || obj.tagName); 852 obj = obj.isCueStyleBox ? obj.getCueBoxPositionAndSize() : obj.div || obj; 853 this.top = isHTMLElement ? obj.offsetTop : obj.top; 854 this.left = isHTMLElement ? obj.offsetLeft : obj.left; 855 this.width = isHTMLElement ? obj.offsetWidth : obj.width; 856 this.height = isHTMLElement ? obj.offsetHeight : obj.height; 857 // This value is smaller than 1 app unit (~= 0.0166 px). 858 this.fuzz = 0.01; 859 } 860 861 get bottom() { 862 return this.top + this.height; 863 } 864 865 get right() { 866 return this.left + this.width; 867 } 868 869 // This function is used for debugging, it will return the box's information. 870 getBoxInfoInChars() { 871 return `top=${this.top}, bottom=${this.bottom}, left=${this.left}, ` + 872 `right=${this.right}, width=${this.width}, height=${this.height}`; 873 } 874 875 // Move the box along a particular axis. Optionally pass in an amount to move 876 // the box. If no amount is passed then the default is the line height of the 877 // box. 878 move(axis, toMove) { 879 switch (axis) { 880 case "+x": 881 LOG(`box's left moved from ${this.left} to ${this.left + toMove}`); 882 this.left += toMove; 883 break; 884 case "-x": 885 LOG(`box's left moved from ${this.left} to ${this.left - toMove}`); 886 this.left -= toMove; 887 break; 888 case "+y": 889 LOG(`box's top moved from ${this.top} to ${this.top + toMove}`); 890 this.top += toMove; 891 break; 892 case "-y": 893 LOG(`box's top moved from ${this.top} to ${this.top - toMove}`); 894 this.top -= toMove; 895 break; 896 } 897 } 898 899 // Check if this box overlaps another box, b2. 900 overlaps(b2) { 901 return (this.left < b2.right - this.fuzz) && 902 (this.right > b2.left + this.fuzz) && 903 (this.top < b2.bottom - this.fuzz) && 904 (this.bottom > b2.top + this.fuzz); 905 } 906 907 // Check if this box overlaps any other boxes in boxes. 908 overlapsAny(boxes) { 909 for (let i = 0; i < boxes.length; i++) { 910 if (this.overlaps(boxes[i])) { 911 return true; 912 } 913 } 914 return false; 915 } 916 917 // Check if this box is within another box. 918 within(container) { 919 return (this.top >= container.top - this.fuzz) && 920 (this.bottom <= container.bottom + this.fuzz) && 921 (this.left >= container.left - this.fuzz) && 922 (this.right <= container.right + this.fuzz); 923 } 924 925 // Check whether this box is passed over the specfic axis boundary. The axis 926 // is based on the canvas coordinates, the `+x` is rightward and `+y` is 927 // downward. 928 isOutsideTheAxisBoundary(container, axis) { 929 switch (axis) { 930 case "+x": 931 return this.right > container.right + this.fuzz; 932 case "-x": 933 return this.left < container.left - this.fuzz; 934 case "+y": 935 return this.bottom > container.bottom + this.fuzz; 936 case "-y": 937 return this.top < container.top - this.fuzz; 938 } 939 } 940 941 // Find the percentage of the area that this box is overlapping with another 942 // box. 943 intersectPercentage(b2) { 944 let x = Math.max(0, Math.min(this.right, b2.right) - Math.max(this.left, b2.left)), 945 y = Math.max(0, Math.min(this.bottom, b2.bottom) - Math.max(this.top, b2.top)), 946 intersectArea = x * y; 947 return intersectArea / (this.height * this.width); 948 } 949 } 950 951 BoxPosition.prototype.clone = function(){ 952 return new BoxPosition(this); 953 }; 954 955 function adjustBoxPosition(styleBox, containerBox, controlBarBox, outputBoxes) { 956 const cue = styleBox.cue; 957 const isWritingDirectionHorizontal = cue.vertical == ""; 958 let box = new BoxPosition(styleBox); 959 if (!box.width || !box.height) { 960 LOG(`No way to adjust a box with zero width or height.`); 961 return; 962 } 963 964 // Spec 7.2.10, adjust the positions of boxes according to the appropriate 965 // steps from the following list. Also, we use offsetHeight/offsetWidth here 966 // in order to prevent the incorrect positioning caused by CSS transform 967 // scale. 968 const fullDimension = isWritingDirectionHorizontal ? 969 containerBox.height : containerBox.width; 970 if (cue.snapToLines) { 971 LOG(`Adjust position when 'snap-to-lines' is true.`); 972 // The step is the height or width of the line box. We should use font 973 // size directly, instead of using text box's width or height, because the 974 // width or height of the box would be changed when the text is wrapped to 975 // different line. Ex. if text is wrapped to two line, the height or width 976 // of the box would become 2 times of font size. 977 let step = styleBox.getFirstLineBoxSize(); 978 if (step == 0) { 979 return; 980 } 981 982 // spec 7.2.10.4 ~ 7.2.10.6 983 let line = Math.floor(cue.computedLine + 0.5); 984 if (cue.vertical == "rl") { 985 line = -1 * (line + 1); 986 } 987 988 // spec 7.2.10.7 ~ 7.2.10.8 989 let position = step * line; 990 if (cue.vertical == "rl") { 991 position = position - box.width + step; 992 } 993 994 // spec 7.2.10.9 995 if (line < 0) { 996 position += fullDimension; 997 step = -1 * step; 998 } 999 1000 // spec 7.2.10.10, move the box to the specific position along the direction. 1001 const movingDirection = isWritingDirectionHorizontal ? "+y" : "+x"; 1002 box.move(movingDirection, position); 1003 1004 // spec 7.2.10.11, remember the position as specified position. 1005 let specifiedPosition = box.clone(); 1006 1007 // spec 7.2.10.12, let title area be a box that covers all of the video’s 1008 // rendering area. 1009 const titleAreaBox = containerBox.clone(); 1010 if (controlBarBox) { 1011 titleAreaBox.height -= controlBarBox.height; 1012 } 1013 1014 function isBoxOutsideTheRenderingArea() { 1015 if (isWritingDirectionHorizontal) { 1016 // the top side of the box is above the rendering area, or the bottom 1017 // side of the box is below the rendering area. 1018 return step < 0 && box.top < 0 || 1019 step > 0 && box.bottom > fullDimension; 1020 } 1021 // the left side of the box is outside the left side of the rendering 1022 // area, or the right side of the box is outside the right side of the 1023 // rendering area. 1024 return step < 0 && box.left < 0 || 1025 step > 0 && box.right > fullDimension; 1026 } 1027 1028 // spec 7.2.10.13, if none of the boxes in boxes would overlap any of the 1029 // boxes in output, and all of the boxes in boxes are entirely within the 1030 // title area box. 1031 let switched = false; 1032 while (!box.within(titleAreaBox) || box.overlapsAny(outputBoxes)) { 1033 // spec 7.2.10.14, check if we need to switch the direction. 1034 if (isBoxOutsideTheRenderingArea()) { 1035 // spec 7.2.10.17, if `switched` is true, remove all the boxes in 1036 // `boxes`, which means we shouldn't apply any CSS boxes for this cue. 1037 // Therefore, returns null box. 1038 if (switched) { 1039 return null; 1040 } 1041 // spec 7.2.10.18 ~ 7.2.10.20 1042 switched = true; 1043 box = specifiedPosition.clone(); 1044 step = -1 * step; 1045 } 1046 // spec 7.2.10.15, moving box along the specific direction. 1047 box.move(movingDirection, step); 1048 } 1049 1050 if (isWritingDirectionHorizontal) { 1051 styleBox.applyStyles({ 1052 top: getPercentagePosition(box.top, fullDimension), 1053 }); 1054 } else { 1055 styleBox.applyStyles({ 1056 left: getPercentagePosition(box.left, fullDimension), 1057 }); 1058 } 1059 } else { 1060 LOG(`Adjust position when 'snap-to-lines' is false.`); 1061 // (snap-to-lines if false) spec 7.2.10.1 ~ 7.2.10.2 1062 if (cue.lineAlign != "start") { 1063 const isCenterAlign = cue.lineAlign == "center"; 1064 const movingDirection = isWritingDirectionHorizontal ? "-y" : "-x"; 1065 if (isWritingDirectionHorizontal) { 1066 box.move(movingDirection, isCenterAlign ? box.height : box.height / 2); 1067 } else { 1068 box.move(movingDirection, isCenterAlign ? box.width : box.width / 2); 1069 } 1070 } 1071 1072 // spec 7.2.10.3 1073 let bestPosition = {}, 1074 specifiedPosition = box.clone(), 1075 outsideAreaPercentage = 1; // Highest possible so the first thing we get is better. 1076 let hasFoundBestPosition = false; 1077 1078 // For the different writing directions, we should have different priority 1079 // for the moving direction. For example, if the writing direction is 1080 // horizontal, which means the cues will grow from the top to the bottom, 1081 // then moving cues along the `y` axis should be more important than moving 1082 // cues along the `x` axis, and vice versa for those cues growing from the 1083 // left to right, or from the right to the left. We don't follow the exact 1084 // way which the spec requires, see the reason in bug1575460. 1085 function getAxis(writingDirection) { 1086 if (writingDirection == "") { 1087 return ["+y", "-y", "+x", "-x"]; 1088 } 1089 // Growing from left to right. 1090 if (writingDirection == "lr") { 1091 return ["+x", "-x", "+y", "-y"]; 1092 } 1093 // Growing from right to left. 1094 return ["-x", "+x", "+y", "-y"]; 1095 } 1096 const axis = getAxis(cue.vertical); 1097 1098 // This factor effects the granularity of the moving unit, when using the 1099 // factor=1 often moves too much and results in too many redudant spaces 1100 // between boxes. So we can increase the factor to slightly reduce the 1101 // move we do every time, but still can preverse the reasonable spaces 1102 // between boxes. 1103 const factor = 4; 1104 const toMove = styleBox.getFirstLineBoxSize() / factor; 1105 for (let i = 0; i < axis.length && !hasFoundBestPosition; i++) { 1106 while (!box.isOutsideTheAxisBoundary(containerBox, axis[i]) && 1107 (!box.within(containerBox) || box.overlapsAny(outputBoxes))) { 1108 box.move(axis[i], toMove); 1109 } 1110 // We found a spot where we aren't overlapping anything. This is our 1111 // best position. 1112 if (box.within(containerBox)) { 1113 bestPosition = box.clone(); 1114 hasFoundBestPosition = true; 1115 break; 1116 } 1117 let p = box.intersectPercentage(containerBox); 1118 // If we're outside the container box less then we were on our last try 1119 // then remember this position as the best position. 1120 if (outsideAreaPercentage > p) { 1121 bestPosition = box.clone(); 1122 outsideAreaPercentage = p; 1123 } 1124 // Reset the box position to the specified position. 1125 box = specifiedPosition.clone(); 1126 } 1127 1128 // Can not find a place to place this box inside the rendering area. 1129 if (!box.within(containerBox)) { 1130 return null; 1131 } 1132 1133 styleBox.applyStyles({ 1134 top: getPercentagePosition(box.top, containerBox.height), 1135 left: getPercentagePosition(box.left, containerBox.width), 1136 }); 1137 } 1138 1139 // In order to not be affected by CSS scale, so we use '%' to make sure the 1140 // cue can stick in the right position. 1141 function getPercentagePosition(position, fullDimension) { 1142 return (position / fullDimension) * 100 + "%"; 1143 } 1144 1145 return box; 1146 } 1147 1148 export function WebVTT() { 1149 this.isProcessingCues = false; 1150 // Nothing 1151 } 1152 1153 // Helper to allow strings to be decoded instead of the default binary utf8 data. 1154 WebVTT.StringDecoder = function() { 1155 return { 1156 decode: function(data) { 1157 if (!data) { 1158 return ""; 1159 } 1160 if (typeof data !== "string") { 1161 throw new Error("Error - expected string data."); 1162 } 1163 return decodeURIComponent(encodeURIComponent(data)); 1164 } 1165 }; 1166 }; 1167 1168 WebVTT.convertCueToDOMTree = function(window, cuetext) { 1169 if (!window) { 1170 return null; 1171 } 1172 return parseContent(window, cuetext, PARSE_CONTENT_MODE.DOCUMENT_FRAGMENT); 1173 }; 1174 1175 function clearAllCuesDiv(overlay) { 1176 while (overlay.firstChild) { 1177 overlay.firstChild.remove(); 1178 } 1179 } 1180 1181 // It's used to record how many cues we process in the last `processCues` run. 1182 var lastDisplayedCueNums = 0; 1183 1184 const DIV_COMPUTING_STATE = { 1185 REUSE : 0, 1186 REUSE_AND_CLEAR : 1, 1187 COMPUTE_AND_CLEAR : 2 1188 }; 1189 1190 // Runs the processing model over the cues and regions passed to it. 1191 // Spec https://www.w3.org/TR/webvtt1/#processing-model 1192 // @parem window : JS window 1193 // @param cues : the VTT cues are going to be displayed. 1194 // @param overlay : A block level element (usually a div) that the computed cues 1195 // and regions will be placed into. 1196 // @param controls : A Control bar element. Cues' position will be 1197 // affected and repositioned according to it. 1198 function processCuesInternal(window, cues, overlay, controls) { 1199 LOG(`=== processCues ===`); 1200 if (!cues) { 1201 LOG(`clear display and abort processing because of no cue.`); 1202 clearAllCuesDiv(overlay); 1203 lastDisplayedCueNums = 0; 1204 return; 1205 } 1206 1207 let controlBar, controlBarShown; 1208 if (controls) { 1209 // controls is a <div> that is the children of the UA Widget Shadow Root. 1210 controlBar = controls.parentNode.getElementById("controlBar"); 1211 controlBarShown = controlBar ? !controlBar.hidden : false; 1212 } else { 1213 // There is no controls element. This only happen to UA Widget because 1214 // it is created lazily. 1215 controlBarShown = false; 1216 } 1217 1218 /** 1219 * This function is used to tell us if we have to recompute or reuse current 1220 * cue's display state. Display state is a DIV element with corresponding 1221 * CSS style to display cue on the screen. When the cue is being displayed 1222 * first time, we will compute its display state. After that, we could reuse 1223 * its state until following conditions happen. 1224 * (1) control changes : it means the rendering area changes so we should 1225 * recompute cues' position. 1226 * (2) cue's `hasBeenReset` flag is true : it means cues' line or position 1227 * property has been modified, we also need to recompute cues' position. 1228 * (3) the amount of showing cues changes : it means some cue would disappear 1229 * but other cues should stay at the same place without recomputing, so we 1230 * can resume their display state. 1231 */ 1232 function getDIVComputingState(cues) { 1233 if (overlay.lastControlBarShownStatus != controlBarShown) { 1234 return DIV_COMPUTING_STATE.COMPUTE_AND_CLEAR; 1235 } 1236 1237 for (let i = 0; i < cues.length; i++) { 1238 if (cues[i].hasBeenReset || !cues[i].displayState) { 1239 return DIV_COMPUTING_STATE.COMPUTE_AND_CLEAR; 1240 } 1241 } 1242 1243 if (lastDisplayedCueNums != cues.length) { 1244 return DIV_COMPUTING_STATE.REUSE_AND_CLEAR; 1245 } 1246 return DIV_COMPUTING_STATE.REUSE; 1247 } 1248 1249 const divState = getDIVComputingState(cues); 1250 overlay.lastControlBarShownStatus = controlBarShown; 1251 1252 if (divState == DIV_COMPUTING_STATE.REUSE) { 1253 LOG(`reuse current cue's display state and abort processing`); 1254 return; 1255 } 1256 1257 clearAllCuesDiv(overlay); 1258 let rootOfCues = window.document.createElement("div"); 1259 rootOfCues.style.position = "absolute"; 1260 rootOfCues.style.left = "0"; 1261 rootOfCues.style.right = "0"; 1262 rootOfCues.style.top = "0"; 1263 rootOfCues.style.bottom = "0"; 1264 overlay.appendChild(rootOfCues); 1265 1266 if (divState == DIV_COMPUTING_STATE.REUSE_AND_CLEAR) { 1267 LOG(`clear display but reuse cues' display state.`); 1268 for (let cue of cues) { 1269 rootOfCues.appendChild(cue.displayState); 1270 } 1271 } else if (divState == DIV_COMPUTING_STATE.COMPUTE_AND_CLEAR) { 1272 LOG(`clear display and recompute cues' display state.`); 1273 let boxPositions = [], 1274 containerBox = new BoxPosition(rootOfCues); 1275 1276 let styleBox, cue, controlBarBox; 1277 if (controlBarShown) { 1278 controlBarBox = new BoxPosition(controlBar); 1279 // Add an empty output box that cover the same region as video control bar. 1280 boxPositions.push(controlBarBox); 1281 } 1282 1283 // https://w3c.github.io/webvtt/#processing-model 6.1.12.1 1284 // Create regionNode 1285 let regionNodeBoxes = {}; 1286 let regionNodeBox; 1287 1288 LOG(`lastDisplayedCueNums=${lastDisplayedCueNums}, currentCueNums=${cues.length}`); 1289 lastDisplayedCueNums = cues.length; 1290 for (let i = 0; i < cues.length; i++) { 1291 cue = cues[i]; 1292 if (cue.region != null) { 1293 // 6.1.14.1 1294 styleBox = new RegionCueStyleBox(window, cue); 1295 1296 if (!regionNodeBoxes[cue.region.id]) { 1297 // create regionNode 1298 // Adjust the container hieght to exclude the controlBar 1299 let adjustContainerBox = new BoxPosition(rootOfCues); 1300 if (controlBarShown) { 1301 adjustContainerBox.height -= controlBarBox.height; 1302 adjustContainerBox.bottom += controlBarBox.height; 1303 } 1304 regionNodeBox = new RegionNodeBox(window, cue.region, adjustContainerBox); 1305 regionNodeBoxes[cue.region.id] = regionNodeBox; 1306 } 1307 // 6.1.14.3 1308 let currentRegionBox = regionNodeBoxes[cue.region.id]; 1309 let currentRegionNodeDiv = currentRegionBox.div; 1310 // 6.1.14.3.2 1311 // TODO: fix me, it looks like the we need to set/change "top" attribute at the styleBox.div 1312 // to do the "scroll up", however, we do not implement it yet? 1313 if (cue.region.scroll == "up" && currentRegionNodeDiv.childElementCount > 0) { 1314 styleBox.div.style.transitionProperty = "top"; 1315 styleBox.div.style.transitionDuration = "0.433s"; 1316 } 1317 1318 currentRegionNodeDiv.appendChild(styleBox.div); 1319 rootOfCues.appendChild(currentRegionNodeDiv); 1320 cue.displayState = styleBox.div; 1321 boxPositions.push(new BoxPosition(currentRegionBox)); 1322 } else { 1323 // Compute the intial position and styles of the cue div. 1324 styleBox = new CueStyleBox(window, cue, containerBox); 1325 rootOfCues.appendChild(styleBox.div); 1326 1327 // Move the cue to correct position, we might get the null box if the 1328 // result of algorithm doesn't want us to show the cue when we don't 1329 // have any room for this cue. 1330 let cueBox = adjustBoxPosition(styleBox, containerBox, controlBarBox, boxPositions); 1331 if (cueBox) { 1332 styleBox.setBidiRule(); 1333 // Remember the computed div so that we don't have to recompute it later 1334 // if we don't have too. 1335 cue.displayState = styleBox.div; 1336 boxPositions.push(cueBox); 1337 LOG(`cue ${i}, ` + cueBox.getBoxInfoInChars()); 1338 } else { 1339 LOG(`can not find a proper position to place cue ${i}`); 1340 // Clear the display state and clear the reset flag in the cue as well, 1341 // which controls whether the task for updating the cue display is 1342 // dispatched. 1343 cue.displayState = null; 1344 rootOfCues.removeChild(styleBox.div); 1345 } 1346 } 1347 } 1348 } else { 1349 LOG(`[ERROR] unknown div computing state`); 1350 } 1351 }; 1352 1353 WebVTT.processCues = function(window, cues, overlay, controls) { 1354 // When accessing `offsetXXX` attributes of element, it would trigger reflow 1355 // and might result in a re-entry of this function. In order to avoid doing 1356 // redundant computation, we would only do one processing at a time. 1357 if (this.isProcessingCues) { 1358 return; 1359 } 1360 this.isProcessingCues = true; 1361 processCuesInternal(window, cues, overlay, controls); 1362 this.isProcessingCues = false; 1363 }; 1364 1365 WebVTT.Parser = function(window, decoder) { 1366 this.window = window; 1367 this.state = "INITIAL"; 1368 this.substate = ""; 1369 this.substatebuffer = ""; 1370 this.buffer = ""; 1371 this.decoder = decoder || new TextDecoder("utf8"); 1372 this.regionList = []; 1373 this.isPrevLineBlank = false; 1374 }; 1375 1376 WebVTT.Parser.prototype = { 1377 // If the error is a ParsingError then report it to the consumer if 1378 // possible. If it's not a ParsingError then throw it like normal. 1379 reportOrThrowError: function(e) { 1380 if (e instanceof ParsingError) { 1381 this.onparsingerror && this.onparsingerror(e); 1382 } else { 1383 throw e; 1384 } 1385 }, 1386 parse: function (data) { 1387 // If there is no data then we won't decode it, but will just try to parse 1388 // whatever is in buffer already. This may occur in circumstances, for 1389 // example when flush() is called. 1390 if (data) { 1391 // Try to decode the data that we received. 1392 this.buffer += this.decoder.decode(data, {stream: true}); 1393 } 1394 1395 // This parser is line-based. Let's see if we have a line to parse. 1396 while (/\r\n|\n|\r/.test(this.buffer)) { 1397 let buffer = this.buffer; 1398 let pos = 0; 1399 while (buffer[pos] !== '\r' && buffer[pos] !== '\n') { 1400 ++pos; 1401 } 1402 let line = buffer.substr(0, pos); 1403 // Advance the buffer early in case we fail below. 1404 if (buffer[pos] === '\r') { 1405 ++pos; 1406 } 1407 if (buffer[pos] === '\n') { 1408 ++pos; 1409 } 1410 this.buffer = buffer.substr(pos); 1411 1412 // Spec defined replacement. 1413 line = line.replace(/[\u0000]/g, "\uFFFD"); 1414 1415 // Detect the comment. We parse line on the fly, so we only check if the 1416 // comment block is preceded by a blank line and won't check if it's 1417 // followed by another blank line. 1418 // https://www.w3.org/TR/webvtt1/#introduction-comments 1419 // TODO (1703895): according to the spec, the comment represents as a 1420 // comment block, so we need to refactor the parser in order to better 1421 // handle the comment block. 1422 if (this.isPrevLineBlank && /^NOTE($|[ \t])/.test(line)) { 1423 LOG("Ignore comment that starts with 'NOTE'"); 1424 } else { 1425 this.parseLine(line); 1426 } 1427 this.isPrevLineBlank = emptyOrOnlyContainsWhiteSpaces(line); 1428 } 1429 1430 return this; 1431 }, 1432 parseLine: function(line) { 1433 let self = this; 1434 1435 function createCueIfNeeded() { 1436 if (!self.cue) { 1437 self.cue = new self.window.VTTCue(0, 0, ""); 1438 } 1439 } 1440 1441 // Parsing cue identifier and the identifier should be unique. 1442 // Return true if the input is a cue identifier. 1443 function parseCueIdentifier(input) { 1444 if (maybeIsTimeStampFormat(input)) { 1445 self.state = "CUE"; 1446 return false; 1447 } 1448 1449 createCueIfNeeded(); 1450 // TODO : ensure the cue identifier is unique among all cue identifiers. 1451 self.cue.id = containsTimeDirectionSymbol(input) ? "" : input; 1452 self.state = "CUE"; 1453 return true; 1454 } 1455 1456 // Parsing the timestamp and cue settings. 1457 // See spec, https://w3c.github.io/webvtt/#collect-webvtt-cue-timings-and-settings 1458 function parseCueMayThrow(input) { 1459 try { 1460 createCueIfNeeded(); 1461 parseCue(input, self.cue, self.regionList); 1462 self.state = "CUETEXT"; 1463 } catch (e) { 1464 self.reportOrThrowError(e); 1465 // In case of an error ignore rest of the cue. 1466 self.cue = null; 1467 self.state = "BADCUE"; 1468 } 1469 } 1470 1471 // 3.4 WebVTT region and WebVTT region settings syntax 1472 function parseRegion(input) { 1473 let settings = new Settings(); 1474 parseOptions(input, function (k, v) { 1475 switch (k) { 1476 case "id": 1477 settings.set(k, v); 1478 break; 1479 case "width": 1480 settings.percent(k, v); 1481 break; 1482 case "lines": 1483 settings.digitsValue(k, v); 1484 break; 1485 case "regionanchor": 1486 case "viewportanchor": { 1487 let xy = v.split(','); 1488 if (xy.length !== 2) { 1489 break; 1490 } 1491 // We have to make sure both x and y parse, so use a temporary 1492 // settings object here. 1493 let anchor = new Settings(); 1494 anchor.percent("x", xy[0]); 1495 anchor.percent("y", xy[1]); 1496 if (!anchor.has("x") || !anchor.has("y")) { 1497 break; 1498 } 1499 settings.set(k + "X", anchor.get("x")); 1500 settings.set(k + "Y", anchor.get("y")); 1501 break; 1502 } 1503 case "scroll": 1504 settings.alt(k, v, ["up"]); 1505 break; 1506 } 1507 }, /:/, /\t|\n|\f|\r| /); // groupDelim is ASCII whitespace 1508 // https://infra.spec.whatwg.org/#ascii-whitespace, U+0009 TAB, U+000A LF, U+000C FF, U+000D CR, U+0020 SPACE 1509 1510 // Create the region, using default values for any values that were not 1511 // specified. 1512 if (settings.has("id")) { 1513 try { 1514 let region = new self.window.VTTRegion(); 1515 region.id = settings.get("id", ""); 1516 region.width = settings.get("width", 100); 1517 region.lines = settings.get("lines", 3); 1518 region.regionAnchorX = settings.get("regionanchorX", 0); 1519 region.regionAnchorY = settings.get("regionanchorY", 100); 1520 region.viewportAnchorX = settings.get("viewportanchorX", 0); 1521 region.viewportAnchorY = settings.get("viewportanchorY", 100); 1522 region.scroll = settings.get("scroll", ""); 1523 // Register the region. 1524 self.onregion && self.onregion(region); 1525 // Remember the VTTRegion for later in case we parse any VTTCues that 1526 // reference it. 1527 self.regionList.push({ 1528 id: settings.get("id"), 1529 region: region 1530 }); 1531 } catch(e) { 1532 dump("VTTRegion Error " + e + "\n"); 1533 } 1534 } 1535 } 1536 1537 // Parsing the WebVTT signature, it contains parsing algo step1 to step9. 1538 // See spec, https://w3c.github.io/webvtt/#file-parsing 1539 function parseSignatureMayThrow(signature) { 1540 if (!/^WEBVTT([ \t].*)?$/.test(signature)) { 1541 throw new ParsingError(ParsingError.Errors.BadSignature); 1542 } else { 1543 self.state = "HEADER"; 1544 } 1545 } 1546 1547 function parseRegionOrStyle(input) { 1548 switch (self.substate) { 1549 case "REGION": 1550 parseRegion(input); 1551 break; 1552 case "STYLE": 1553 // TODO : not supported yet. 1554 break; 1555 } 1556 } 1557 // Parsing the region and style information. 1558 // See spec, https://w3c.github.io/webvtt/#collect-a-webvtt-block 1559 // 1560 // There are sereval things would appear in header, 1561 // 1. Region or Style setting 1562 // 2. Garbage (meaningless string) 1563 // 3. Empty line 1564 // 4. Cue's timestamp 1565 // The case 4 happens when there is no line interval between the header 1566 // and the cue blocks. In this case, we should preserve the line for the 1567 // next phase parsing, returning "true". 1568 function parseHeader(line) { 1569 if (!self.substate && /^REGION|^STYLE/.test(line)) { 1570 self.substate = /^REGION/.test(line) ? "REGION" : "STYLE"; 1571 return false; 1572 } 1573 1574 if (self.substate === "REGION" || self.substate === "STYLE") { 1575 if (maybeIsTimeStampFormat(line) || 1576 emptyOrOnlyContainsWhiteSpaces(line) || 1577 containsTimeDirectionSymbol(line)) { 1578 parseRegionOrStyle(self.substatebuffer); 1579 self.substatebuffer = ""; 1580 self.substate = null; 1581 1582 // This is the end of the region or style state. 1583 return parseHeader(line); 1584 } 1585 1586 if (/^REGION|^STYLE/.test(line)) { 1587 // The line is another REGION/STYLE, parse and reset substatebuffer. 1588 // Don't break the while loop to parse the next REGION/STYLE. 1589 parseRegionOrStyle(self.substatebuffer); 1590 self.substatebuffer = ""; 1591 self.substate = /^REGION/.test(line) ? "REGION" : "STYLE"; 1592 return false; 1593 } 1594 1595 // We weren't able to parse the line as a header. Accumulate and 1596 // return. 1597 self.substatebuffer += " " + line; 1598 return false; 1599 } 1600 1601 if (emptyOrOnlyContainsWhiteSpaces(line)) { 1602 // empty line, whitespaces, nothing to do. 1603 return false; 1604 } 1605 1606 if (maybeIsTimeStampFormat(line)) { 1607 self.state = "CUE"; 1608 // We want to process the same line again. 1609 return true; 1610 } 1611 1612 // string contains "-->" or an ID 1613 self.state = "ID"; 1614 return true; 1615 } 1616 1617 try { 1618 LOG(`state=${self.state}, line=${line}`) 1619 // 5.1 WebVTT file parsing. 1620 if (self.state === "INITIAL") { 1621 parseSignatureMayThrow(line); 1622 return; 1623 } 1624 1625 if (self.state === "HEADER") { 1626 // parseHeader returns false if the same line doesn't need to be 1627 // parsed again. 1628 if (!parseHeader(line)) { 1629 return; 1630 } 1631 } 1632 1633 if (self.state === "ID") { 1634 // If there is no cue identifier, read the next line. 1635 if (line == "") { 1636 return; 1637 } 1638 1639 // If there is no cue identifier, parse the line again. 1640 if (!parseCueIdentifier(line)) { 1641 return self.parseLine(line); 1642 } 1643 return; 1644 } 1645 1646 if (self.state === "CUE") { 1647 parseCueMayThrow(line); 1648 return; 1649 } 1650 1651 if (self.state === "CUETEXT") { 1652 // Report the cue when (1) get an empty line (2) get the "-->"" 1653 if (emptyOrOnlyContainsWhiteSpaces(line) || 1654 containsTimeDirectionSymbol(line)) { 1655 // We are done parsing self cue. 1656 self.oncue && self.oncue(self.cue); 1657 self.cue = null; 1658 self.state = "ID"; 1659 1660 if (emptyOrOnlyContainsWhiteSpaces(line)) { 1661 return; 1662 } 1663 1664 // Reuse the same line. 1665 return self.parseLine(line); 1666 } 1667 if (self.cue.text) { 1668 self.cue.text += "\n"; 1669 } 1670 self.cue.text += line; 1671 return; 1672 } 1673 1674 if (self.state === "BADCUE") { 1675 // 54-62 - Collect and discard the remaining cue. 1676 self.state = "ID"; 1677 return self.parseLine(line); 1678 } 1679 } catch (e) { 1680 self.reportOrThrowError(e); 1681 1682 // If we are currently parsing a cue, report what we have. 1683 if (self.state === "CUETEXT" && self.cue && self.oncue) { 1684 self.oncue(self.cue); 1685 } 1686 self.cue = null; 1687 // Enter BADWEBVTT state if header was not parsed correctly otherwise 1688 // another exception occurred so enter BADCUE state. 1689 self.state = self.state === "INITIAL" ? "BADWEBVTT" : "BADCUE"; 1690 } 1691 return this; 1692 }, 1693 flush: function () { 1694 let self = this; 1695 try { 1696 // Finish decoding the stream. 1697 self.buffer += self.decoder.decode(); 1698 self.buffer += "\n\n"; 1699 self.parse(); 1700 } catch(e) { 1701 self.reportOrThrowError(e); 1702 } 1703 self.isPrevLineBlank = false; 1704 self.onflush && self.onflush(); 1705 return this; 1706 } 1707 };