Source: lib/text/vtt_text_parser.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.text.VttTextParser');
  7. goog.require('goog.asserts');
  8. goog.require('shaka.log');
  9. goog.require('shaka.text.Cue');
  10. goog.require('shaka.text.CueRegion');
  11. goog.require('shaka.text.TextEngine');
  12. goog.require('shaka.util.Error');
  13. goog.require('shaka.util.StringUtils');
  14. goog.require('shaka.util.TextParser');
  15. goog.require('shaka.util.XmlUtils');
  16. /**
  17. * @implements {shaka.extern.TextParser}
  18. * @export
  19. */
  20. shaka.text.VttTextParser = class {
  21. /**
  22. * @override
  23. * @export
  24. */
  25. parseInit(data) {
  26. goog.asserts.assert(false, 'VTT does not have init segments');
  27. }
  28. /**
  29. * @override
  30. * @export
  31. */
  32. parseMedia(data, time) {
  33. const VttTextParser = shaka.text.VttTextParser;
  34. // Get the input as a string. Normalize newlines to \n.
  35. let str = shaka.util.StringUtils.fromUTF8(data);
  36. str = str.replace(/\r\n|\r(?=[^\n]|$)/gm, '\n');
  37. const blocks = str.split(/\n{2,}/m);
  38. if (!/^WEBVTT($|[ \t\n])/m.test(blocks[0])) {
  39. throw new shaka.util.Error(
  40. shaka.util.Error.Severity.CRITICAL,
  41. shaka.util.Error.Category.TEXT,
  42. shaka.util.Error.Code.INVALID_TEXT_HEADER);
  43. }
  44. // NOTE: "periodStart" is the timestamp offset applied via TextEngine.
  45. // It is no longer closely tied to periods, but the name stuck around.
  46. let offset = time.periodStart;
  47. if (blocks[0].includes('X-TIMESTAMP-MAP')) {
  48. // https://bit.ly/2K92l7y
  49. // The 'X-TIMESTAMP-MAP' header is used in HLS to align text with
  50. // the rest of the media.
  51. // The header format is 'X-TIMESTAMP-MAP=MPEGTS:n,LOCAL:m'
  52. // (the attributes can go in any order)
  53. // where n is MPEG-2 time and m is cue time it maps to.
  54. // For example 'X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:900000'
  55. // means an offset of 10 seconds
  56. // 900000/MPEG_TIMESCALE - cue time.
  57. const cueTimeMatch =
  58. blocks[0].match(/LOCAL:((?:(\d{1,}):)?(\d{2}):(\d{2})\.(\d{3}))/m);
  59. const mpegTimeMatch = blocks[0].match(/MPEGTS:(\d+)/m);
  60. if (cueTimeMatch && mpegTimeMatch) {
  61. const parser = new shaka.util.TextParser(cueTimeMatch[1]);
  62. const cueTime = shaka.text.VttTextParser.parseTime_(parser);
  63. if (cueTime == null) {
  64. throw new shaka.util.Error(
  65. shaka.util.Error.Severity.CRITICAL,
  66. shaka.util.Error.Category.TEXT,
  67. shaka.util.Error.Code.INVALID_TEXT_HEADER);
  68. }
  69. let mpegTime = Number(mpegTimeMatch[1]);
  70. const mpegTimescale = shaka.text.VttTextParser.MPEG_TIMESCALE_;
  71. const rolloverSeconds =
  72. shaka.text.VttTextParser.TS_ROLLOVER_ / mpegTimescale;
  73. let segmentStart = time.segmentStart;
  74. while (segmentStart >= rolloverSeconds) {
  75. segmentStart -= rolloverSeconds;
  76. mpegTime += shaka.text.VttTextParser.TS_ROLLOVER_;
  77. }
  78. // Apple-encoded HLS content uses absolute timestamps, so assume the
  79. // presence of the map tag means the content uses absolute timestamps.
  80. offset = time.periodStart + mpegTime / mpegTimescale - cueTime;
  81. }
  82. }
  83. // Parse VTT regions.
  84. /* !Array.<!shaka.extern.CueRegion> */
  85. const regions = [];
  86. for (const line of blocks[0].split('\n')) {
  87. if (/^Region:/.test(line)) {
  88. const region = VttTextParser.parseRegion_(line);
  89. regions.push(region);
  90. }
  91. }
  92. /** @type {!Map.<string, shaka.text.Cue>} */
  93. const styles = new Map();
  94. VttTextParser.addDefaultTextColor_(styles);
  95. // Parse cues.
  96. const ret = [];
  97. for (const block of blocks.slice(1)) {
  98. const lines = block.split('\n');
  99. VttTextParser.parseStyle_(lines, styles);
  100. const cue = VttTextParser.parseCue_(lines, offset, regions, styles);
  101. if (cue) {
  102. ret.push(cue);
  103. }
  104. }
  105. return ret;
  106. }
  107. /**
  108. * Add default color
  109. *
  110. * @param {!Map.<string, shaka.text.Cue>} styles
  111. * @private
  112. */
  113. static addDefaultTextColor_(styles) {
  114. const textColor = shaka.text.Cue.defaultTextColor;
  115. for (const [key, value] of Object.entries(textColor)) {
  116. const cue = new shaka.text.Cue(0, 0, '');
  117. cue.color = value;
  118. styles.set('.' + key, cue);
  119. }
  120. const bgColor = shaka.text.Cue.defaultTextBackgroundColor;
  121. for (const [key, value] of Object.entries(bgColor)) {
  122. const cue = new shaka.text.Cue(0, 0, '');
  123. cue.backgroundColor = value;
  124. styles.set('.' + key, cue);
  125. }
  126. }
  127. /**
  128. * Parses a string into a Region object.
  129. *
  130. * @param {string} text
  131. * @return {!shaka.extern.CueRegion}
  132. * @private
  133. */
  134. static parseRegion_(text) {
  135. const VttTextParser = shaka.text.VttTextParser;
  136. const parser = new shaka.util.TextParser(text);
  137. // The region string looks like this:
  138. // Region: id=fred width=50% lines=3 regionanchor=0%,100%
  139. // viewportanchor=10%,90% scroll=up
  140. const region = new shaka.text.CueRegion();
  141. // Skip 'Region:'
  142. parser.readWord();
  143. parser.skipWhitespace();
  144. let word = parser.readWord();
  145. while (word) {
  146. if (!VttTextParser.parseRegionSetting_(region, word)) {
  147. shaka.log.warning(
  148. 'VTT parser encountered an invalid VTTRegion setting: ', word,
  149. ' The setting will be ignored.');
  150. }
  151. parser.skipWhitespace();
  152. word = parser.readWord();
  153. }
  154. return region;
  155. }
  156. /**
  157. * Parses a style block into a Cue object.
  158. *
  159. * @param {!Array.<string>} text
  160. * @param {!Map.<string, shaka.text.Cue>} styles
  161. * @private
  162. */
  163. static parseStyle_(text, styles) {
  164. // Skip empty blocks.
  165. if (text.length == 1 && !text[0]) {
  166. return;
  167. }
  168. // Skip comment blocks.
  169. if (/^NOTE($|[ \t])/.test(text[0])) {
  170. return;
  171. }
  172. // Only style block are allowed.
  173. if (text[0] != 'STYLE') {
  174. return;
  175. }
  176. /** @type {!Array.<!Array.<string>>} */
  177. const styleBlocks = [];
  178. let lastBlockIndex = -1;
  179. for (let i = 1; i < text.length; i++) {
  180. if (text[i].includes('::cue')) {
  181. styleBlocks.push([]);
  182. lastBlockIndex = styleBlocks.length - 1;
  183. }
  184. if (lastBlockIndex == -1) {
  185. continue;
  186. }
  187. styleBlocks[lastBlockIndex].push(text[i]);
  188. if (text[i].includes('}')) {
  189. lastBlockIndex = -1;
  190. }
  191. }
  192. for (const styleBlock of styleBlocks) {
  193. let styleSelector = 'global';
  194. // Look for what is within parentheses. For example:
  195. // <code>:: cue (b) {</code>, what we are looking for is <code>b</code>
  196. const selector = styleBlock[0].match(/\((.*)\)/);
  197. if (selector) {
  198. styleSelector = selector.pop();
  199. }
  200. // We start at 1 to avoid '::cue' and end earlier to avoid '}'
  201. let propertyLines = styleBlock.slice(1, -1);
  202. if (styleBlock[0].includes('}')) {
  203. const payload = /\{(.*?)\}/.exec(styleBlock[0]);
  204. if (payload) {
  205. propertyLines = payload[1].split(';');
  206. }
  207. }
  208. // Continue styles over multiple selectors if necessary.
  209. // For example,
  210. // ::cue(b) { background: white; } ::cue(b) { color: blue; }
  211. // should set both the background and foreground of bold tags.
  212. let cue = styles.get(styleSelector);
  213. if (!cue) {
  214. cue = new shaka.text.Cue(0, 0, '');
  215. }
  216. let validStyle = false;
  217. for (let i = 0; i < propertyLines.length; i++) {
  218. // We look for CSS properties. As a general rule they are separated by
  219. // <code>:</code>. Eg: <code>color: red;</code>
  220. const lineParts = /^\s*([^:]+):\s*(.*)/.exec(propertyLines[i]);
  221. if (lineParts) {
  222. const name = lineParts[1].trim();
  223. const value = lineParts[2].trim().replace(';', '');
  224. switch (name) {
  225. case 'background-color':
  226. case 'background':
  227. validStyle = true;
  228. cue.backgroundColor = value;
  229. break;
  230. case 'color':
  231. validStyle = true;
  232. cue.color = value;
  233. break;
  234. case 'font-family':
  235. validStyle = true;
  236. cue.fontFamily = value;
  237. break;
  238. case 'font-size':
  239. validStyle = true;
  240. cue.fontSize = value;
  241. break;
  242. case 'font-weight':
  243. if (parseInt(value, 10) >= 700 || value == 'bold') {
  244. validStyle = true;
  245. cue.fontWeight = shaka.text.Cue.fontWeight.BOLD;
  246. }
  247. break;
  248. case 'font-style':
  249. switch (value) {
  250. case 'normal':
  251. validStyle = true;
  252. cue.fontStyle = shaka.text.Cue.fontStyle.NORMAL;
  253. break;
  254. case 'italic':
  255. validStyle = true;
  256. cue.fontStyle = shaka.text.Cue.fontStyle.ITALIC;
  257. break;
  258. case 'oblique':
  259. validStyle = true;
  260. cue.fontStyle = shaka.text.Cue.fontStyle.OBLIQUE;
  261. break;
  262. }
  263. break;
  264. case 'opacity':
  265. validStyle = true;
  266. cue.opacity = parseFloat(value);
  267. break;
  268. case 'text-shadow':
  269. validStyle = true;
  270. cue.textShadow = value;
  271. break;
  272. case 'white-space':
  273. validStyle = true;
  274. cue.wrapLine = value != 'noWrap';
  275. break;
  276. default:
  277. shaka.log.warning('VTT parser encountered an unsupported style: ',
  278. lineParts);
  279. break;
  280. }
  281. }
  282. }
  283. if (validStyle) {
  284. styles.set(styleSelector, cue);
  285. }
  286. }
  287. }
  288. /**
  289. * Parses a text block into a Cue object.
  290. *
  291. * @param {!Array.<string>} text
  292. * @param {number} timeOffset
  293. * @param {!Array.<!shaka.extern.CueRegion>} regions
  294. * @param {!Map.<string, shaka.text.Cue>} styles
  295. * @return {shaka.text.Cue}
  296. * @private
  297. */
  298. static parseCue_(text, timeOffset, regions, styles) {
  299. const VttTextParser = shaka.text.VttTextParser;
  300. // Skip empty blocks.
  301. if (text.length == 1 && !text[0]) {
  302. return null;
  303. }
  304. // Skip comment blocks.
  305. if (/^NOTE($|[ \t])/.test(text[0])) {
  306. return null;
  307. }
  308. // Skip style and region blocks.
  309. if (text[0] == 'STYLE' || text[0] == 'REGION') {
  310. return null;
  311. }
  312. let id = null;
  313. if (!text[0].includes('-->')) {
  314. id = text[0];
  315. text.splice(0, 1);
  316. }
  317. // Parse the times.
  318. const parser = new shaka.util.TextParser(text[0]);
  319. let start = VttTextParser.parseTime_(parser);
  320. const expect = parser.readRegex(/[ \t]+-->[ \t]+/g);
  321. let end = VttTextParser.parseTime_(parser);
  322. if (start == null || expect == null || end == null) {
  323. throw new shaka.util.Error(
  324. shaka.util.Error.Severity.CRITICAL,
  325. shaka.util.Error.Category.TEXT,
  326. shaka.util.Error.Code.INVALID_TEXT_CUE,
  327. 'Could not parse cue time range in WebVTT');
  328. }
  329. start += timeOffset;
  330. end += timeOffset;
  331. // Get the payload.
  332. const payload = text.slice(1).join('\n').trim();
  333. let cue = null;
  334. if (styles.has('global')) {
  335. cue = styles.get('global').clone();
  336. cue.startTime = start;
  337. cue.endTime = end;
  338. cue.payload = '';
  339. } else {
  340. cue = new shaka.text.Cue(start, end, '');
  341. }
  342. VttTextParser.parseCueStyles(payload, cue, styles);
  343. // Parse optional settings.
  344. parser.skipWhitespace();
  345. let word = parser.readWord();
  346. while (word) {
  347. if (!VttTextParser.parseCueSetting(cue, word, regions)) {
  348. shaka.log.warning('VTT parser encountered an invalid VTT setting: ',
  349. word,
  350. ' The setting will be ignored.');
  351. }
  352. parser.skipWhitespace();
  353. word = parser.readWord();
  354. }
  355. if (id != null) {
  356. cue.id = id;
  357. }
  358. return cue;
  359. }
  360. /**
  361. * Parses a WebVTT styles from the given payload.
  362. *
  363. * @param {string} payload
  364. * @param {!shaka.text.Cue} rootCue
  365. * @param {!Map.<string, shaka.text.Cue>} styles
  366. */
  367. static parseCueStyles(payload, rootCue, styles) {
  368. const VttTextParser = shaka.text.VttTextParser;
  369. if (styles.size === 0) {
  370. VttTextParser.addDefaultTextColor_(styles);
  371. }
  372. payload = VttTextParser.replaceColorPayload_(payload);
  373. payload = VttTextParser.replaceVoiceStylePayload_(payload);
  374. const xmlPayload = '<span>' + payload + '</span>';
  375. const element = shaka.util.XmlUtils.parseXmlString(xmlPayload, 'span');
  376. if (element) {
  377. /** @type {!Array.<!shaka.extern.Cue>} */
  378. const cues = [];
  379. const childNodes = element.childNodes;
  380. if (childNodes.length == 1) {
  381. const childNode = childNodes[0];
  382. if (childNode.nodeType == Node.TEXT_NODE ||
  383. childNode.nodeType == Node.CDATA_SECTION_NODE) {
  384. rootCue.payload = VttTextParser.htmlUnescape_(payload);
  385. return;
  386. }
  387. }
  388. for (const childNode of childNodes) {
  389. VttTextParser.generateCueFromElement_(
  390. childNode, rootCue, cues, styles);
  391. }
  392. rootCue.nestedCues = cues;
  393. } else {
  394. shaka.log.warning('The cue\'s markup could not be parsed: ', payload);
  395. rootCue.payload = VttTextParser.htmlUnescape_(payload);
  396. }
  397. }
  398. /**
  399. * Converts voice style tag to be valid for xml parsing
  400. * For example,
  401. * input: <v Shaka>Test
  402. * output: <v.voice-Shaka>Test</v.voice-Shaka>
  403. *
  404. * @param {string} payload
  405. * @return {string} processed payload
  406. * @private
  407. */
  408. static replaceVoiceStylePayload_(payload) {
  409. const voiceTag = 'v';
  410. const names = [];
  411. let nameStart = -1;
  412. let newPayload = '';
  413. let hasVoiceEndTag = false;
  414. for (let i = 0; i < payload.length; i++) {
  415. // This condition is used to manage tags that have end tags.
  416. if (payload[i] === '/') {
  417. const end = payload.indexOf('>', i);
  418. if (end === -1) {
  419. return payload;
  420. }
  421. const tagEnd = payload.substring(i + 1, end);
  422. if (!tagEnd || tagEnd != voiceTag) {
  423. newPayload += payload[i];
  424. continue;
  425. }
  426. hasVoiceEndTag = true;
  427. let tagStart = null;
  428. if (names.length) {
  429. tagStart = names[names.length -1];
  430. }
  431. if (!tagStart) {
  432. newPayload += payload[i];
  433. } else if (tagStart === tagEnd) {
  434. newPayload += '/' + tagEnd + '>';
  435. i += tagEnd.length + 1;
  436. } else {
  437. if (!tagStart.startsWith(voiceTag)) {
  438. newPayload += payload[i];
  439. continue;
  440. }
  441. newPayload += '/' + tagStart + '>';
  442. i += tagEnd.length + 1;
  443. }
  444. } else {
  445. // Here we only want the tag name, not any other payload.
  446. if (payload[i] === '<') {
  447. nameStart = i + 1;
  448. if (payload[nameStart] != voiceTag) {
  449. nameStart = -1;
  450. }
  451. } else if (payload[i] === '>') {
  452. if (nameStart > 0) {
  453. names.push(payload.substr(nameStart, i - nameStart));
  454. nameStart = -1;
  455. }
  456. }
  457. newPayload += payload[i];
  458. }
  459. }
  460. for (const name of names) {
  461. const newName = name.replace(' ', '.voice-');
  462. newPayload = newPayload.replace(`<${name}>`, `<${newName}>`);
  463. newPayload = newPayload.replace(`</${name}>`, `</${newName}>`);
  464. if (!hasVoiceEndTag) {
  465. newPayload += `</${newName}>`;
  466. }
  467. }
  468. return newPayload;
  469. }
  470. /**
  471. * Converts color end tag to be valid for xml parsing
  472. * For example,
  473. * input: <c.yellow.bg_blue>Yellow text on blue bg</c>
  474. * output: <c.yellow.bg_blue>Yellow text on blue bg</c.yellow.bg_blue>
  475. *
  476. * Returns original payload if invalid tag is found.
  477. * Invalid tag example: <c.yellow><b>Example</c></b>
  478. *
  479. * @param {string} payload
  480. * @return {string} processed payload
  481. * @private
  482. */
  483. static replaceColorPayload_(payload) {
  484. const names = [];
  485. let nameStart = -1;
  486. let newPayload = '';
  487. for (let i = 0; i < payload.length; i++) {
  488. if (payload[i] === '/' && i > 0 && payload[i - 1] === '<') {
  489. const end = payload.indexOf('>', i);
  490. if (end <= i) {
  491. return payload;
  492. }
  493. const tagEnd = payload.substring(i + 1, end);
  494. if (!tagEnd || tagEnd !== 'c') {
  495. newPayload += payload[i];
  496. continue;
  497. }
  498. const tagStart = names.pop();
  499. if (!tagStart) {
  500. newPayload += payload[i];
  501. } else if (tagStart === tagEnd) {
  502. newPayload += '/' + tagEnd + '>';
  503. i += tagEnd.length + 1;
  504. } else {
  505. if (!tagStart.startsWith('c.')) {
  506. newPayload += payload[i];
  507. continue;
  508. }
  509. i += tagEnd.length + 1;
  510. newPayload += '/' + tagStart + '>';
  511. }
  512. } else {
  513. if (payload[i] === '<') {
  514. nameStart = i + 1;
  515. if (payload[nameStart] != 'c') {
  516. nameStart = -1;
  517. }
  518. } else if (payload[i] === '>') {
  519. if (nameStart > 0) {
  520. names.push(payload.substr(nameStart, i - nameStart));
  521. nameStart = -1;
  522. }
  523. }
  524. newPayload += payload[i];
  525. }
  526. }
  527. return newPayload;
  528. }
  529. /**
  530. * @param {string} value
  531. * @param {string} defaultValue
  532. * @private
  533. */
  534. static getOrDefault_(value, defaultValue) {
  535. if (value && value.length > 0) {
  536. return value;
  537. }
  538. return defaultValue;
  539. }
  540. /**
  541. * Merges values created in parseStyle_
  542. * @param {!shaka.extern.Cue} cue
  543. * @param {shaka.extern.Cue} refCue
  544. * @private
  545. */
  546. static mergeStyle_(cue, refCue) {
  547. if (!refCue) {
  548. return;
  549. }
  550. const VttTextParser = shaka.text.VttTextParser;
  551. // Overwrites if new value string length > 0
  552. cue.backgroundColor = VttTextParser.getOrDefault_(
  553. refCue.backgroundColor, cue.backgroundColor);
  554. cue.color = VttTextParser.getOrDefault_(
  555. refCue.color, cue.color);
  556. cue.fontFamily = VttTextParser.getOrDefault_(
  557. refCue.fontFamily, cue.fontFamily);
  558. cue.fontSize = VttTextParser.getOrDefault_(
  559. refCue.fontSize, cue.fontSize);
  560. // Overwrite with new values as unable to determine
  561. // if new value is set or not
  562. cue.fontWeight = refCue.fontWeight;
  563. cue.fontStyle = refCue.fontStyle;
  564. cue.opacity = refCue.opacity;
  565. cue.wrapLine = refCue.wrapLine;
  566. }
  567. /**
  568. * @param {!Node} element
  569. * @param {!shaka.text.Cue} rootCue
  570. * @param {Array.<!shaka.extern.Cue>} cues
  571. * @param {!Map.<string, shaka.text.Cue>} styles
  572. * @private
  573. */
  574. static generateCueFromElement_(element, rootCue, cues, styles) {
  575. const VttTextParser = shaka.text.VttTextParser;
  576. const nestedCue = rootCue.clone();
  577. if (element.nodeType === Node.ELEMENT_NODE && element.nodeName) {
  578. const bold = shaka.text.Cue.fontWeight.BOLD;
  579. const italic = shaka.text.Cue.fontStyle.ITALIC;
  580. const underline = shaka.text.Cue.textDecoration.UNDERLINE;
  581. const tags = element.nodeName.split(/(?=[ .])+/g);
  582. for (const tag of tags) {
  583. let styleTag = tag;
  584. // White blanks at start indicate that the style is a voice
  585. if (styleTag.startsWith('.voice-')) {
  586. const voice = styleTag.split('-').pop();
  587. styleTag = `v[voice="${voice}"]`;
  588. // The specification allows to have quotes and not, so we check to
  589. // see which one is being used.
  590. if (!styles.has(styleTag)) {
  591. styleTag = `v[voice=${voice}]`;
  592. }
  593. }
  594. if (styles.has(styleTag)) {
  595. VttTextParser.mergeStyle_(nestedCue, styles.get(styleTag));
  596. }
  597. switch (tag) {
  598. case 'br': {
  599. const lineBreakCue = rootCue.clone();
  600. lineBreakCue.lineBreak = true;
  601. cues.push(lineBreakCue);
  602. break;
  603. }
  604. case 'b':
  605. nestedCue.fontWeight = bold;
  606. break;
  607. case 'i':
  608. nestedCue.fontStyle = italic;
  609. break;
  610. case 'u':
  611. nestedCue.textDecoration.push(underline);
  612. break;
  613. default:
  614. break;
  615. }
  616. }
  617. }
  618. const isTextNode = shaka.util.XmlUtils.isText(element);
  619. if (isTextNode) {
  620. // Trailing line breaks may lost when convert cue to HTML tag
  621. // Need to insert line break cue to preserve line breaks
  622. const textArr = element.textContent.split('\n');
  623. let isFirst = true;
  624. for (const text of textArr) {
  625. if (!isFirst) {
  626. const lineBreakCue = rootCue.clone();
  627. lineBreakCue.lineBreak = true;
  628. cues.push(lineBreakCue);
  629. }
  630. if (text.length > 0) {
  631. const textCue = nestedCue.clone();
  632. textCue.payload = VttTextParser.htmlUnescape_(text);
  633. cues.push(textCue);
  634. }
  635. isFirst = false;
  636. }
  637. } else {
  638. for (const childNode of element.childNodes) {
  639. VttTextParser.generateCueFromElement_(
  640. childNode, nestedCue, cues, styles);
  641. }
  642. }
  643. }
  644. /**
  645. * Parses a WebVTT setting from the given word.
  646. *
  647. * @param {!shaka.text.Cue} cue
  648. * @param {string} word
  649. * @param {!Array.<!shaka.text.CueRegion>} regions
  650. * @return {boolean} True on success.
  651. */
  652. static parseCueSetting(cue, word, regions) {
  653. const VttTextParser = shaka.text.VttTextParser;
  654. let results = null;
  655. if ((results = /^align:(start|middle|center|end|left|right)$/.exec(word))) {
  656. VttTextParser.setTextAlign_(cue, results[1]);
  657. } else if ((results = /^vertical:(lr|rl)$/.exec(word))) {
  658. VttTextParser.setVerticalWritingMode_(cue, results[1]);
  659. } else if ((results = /^size:([\d.]+)%$/.exec(word))) {
  660. cue.size = Number(results[1]);
  661. } else if ((results =
  662. /^position:([\d.]+)%(?:,(line-left|line-right|center|start|end))?$/
  663. .exec(word))) {
  664. cue.position = Number(results[1]);
  665. if (results[2]) {
  666. VttTextParser.setPositionAlign_(cue, results[2]);
  667. }
  668. } else if ((results = /^region:(.*)$/.exec(word))) {
  669. const region = VttTextParser.getRegionById_(regions, results[1]);
  670. if (region) {
  671. cue.region = region;
  672. }
  673. } else {
  674. return VttTextParser.parsedLineValueAndInterpretation_(cue, word);
  675. }
  676. return true;
  677. }
  678. /**
  679. *
  680. * @param {!Array.<!shaka.text.CueRegion>} regions
  681. * @param {string} id
  682. * @return {?shaka.text.CueRegion}
  683. * @private
  684. */
  685. static getRegionById_(regions, id) {
  686. const regionsWithId = regions.filter((region) => {
  687. return region.id == id;
  688. });
  689. if (!regionsWithId.length) {
  690. shaka.log.warning('VTT parser could not find a region with id: ',
  691. id,
  692. ' The region will be ignored.');
  693. return null;
  694. }
  695. goog.asserts.assert(regionsWithId.length == 1,
  696. 'VTTRegion ids should be unique!');
  697. return regionsWithId[0];
  698. }
  699. /**
  700. * Parses a WebVTTRegion setting from the given word.
  701. *
  702. * @param {!shaka.text.CueRegion} region
  703. * @param {string} word
  704. * @return {boolean} True on success.
  705. * @private
  706. */
  707. static parseRegionSetting_(region, word) {
  708. let results = null;
  709. if ((results = /^id=(.*)$/.exec(word))) {
  710. region.id = results[1];
  711. } else if ((results = /^width=(\d{1,2}|100)%$/.exec(word))) {
  712. region.width = Number(results[1]);
  713. } else if ((results = /^lines=(\d+)$/.exec(word))) {
  714. region.height = Number(results[1]);
  715. region.heightUnits = shaka.text.CueRegion.units.LINES;
  716. } else if ((results = /^regionanchor=(\d{1,2}|100)%,(\d{1,2}|100)%$/
  717. .exec(word))) {
  718. region.regionAnchorX = Number(results[1]);
  719. region.regionAnchorY = Number(results[2]);
  720. } else if ((results = /^viewportanchor=(\d{1,2}|100)%,(\d{1,2}|100)%$/
  721. .exec(word))) {
  722. region.viewportAnchorX = Number(results[1]);
  723. region.viewportAnchorY = Number(results[2]);
  724. } else if ((results = /^scroll=up$/.exec(word))) {
  725. region.scroll = shaka.text.CueRegion.scrollMode.UP;
  726. } else {
  727. return false;
  728. }
  729. return true;
  730. }
  731. /**
  732. * @param {!shaka.text.Cue} cue
  733. * @param {string} align
  734. * @private
  735. */
  736. static setTextAlign_(cue, align) {
  737. const Cue = shaka.text.Cue;
  738. if (align == 'middle') {
  739. cue.textAlign = Cue.textAlign.CENTER;
  740. } else {
  741. goog.asserts.assert(align.toUpperCase() in Cue.textAlign,
  742. align.toUpperCase() +
  743. ' Should be in Cue.textAlign values!');
  744. cue.textAlign = Cue.textAlign[align.toUpperCase()];
  745. }
  746. }
  747. /**
  748. * @param {!shaka.text.Cue} cue
  749. * @param {string} align
  750. * @private
  751. */
  752. static setPositionAlign_(cue, align) {
  753. const Cue = shaka.text.Cue;
  754. if (align == 'line-left' || align == 'start') {
  755. cue.positionAlign = Cue.positionAlign.LEFT;
  756. } else if (align == 'line-right' || align == 'end') {
  757. cue.positionAlign = Cue.positionAlign.RIGHT;
  758. } else {
  759. cue.positionAlign = Cue.positionAlign.CENTER;
  760. }
  761. }
  762. /**
  763. * @param {!shaka.text.Cue} cue
  764. * @param {string} value
  765. * @private
  766. */
  767. static setVerticalWritingMode_(cue, value) {
  768. const Cue = shaka.text.Cue;
  769. if (value == 'lr') {
  770. cue.writingMode = Cue.writingMode.VERTICAL_LEFT_TO_RIGHT;
  771. } else {
  772. cue.writingMode = Cue.writingMode.VERTICAL_RIGHT_TO_LEFT;
  773. }
  774. }
  775. /**
  776. * @param {!shaka.text.Cue} cue
  777. * @param {string} word
  778. * @return {boolean}
  779. * @private
  780. */
  781. static parsedLineValueAndInterpretation_(cue, word) {
  782. const Cue = shaka.text.Cue;
  783. let results = null;
  784. if ((results = /^line:([\d.]+)%(?:,(start|end|center))?$/.exec(word))) {
  785. cue.lineInterpretation = Cue.lineInterpretation.PERCENTAGE;
  786. cue.line = Number(results[1]);
  787. if (results[2]) {
  788. goog.asserts.assert(
  789. results[2].toUpperCase() in Cue.lineAlign,
  790. results[2].toUpperCase() + ' Should be in Cue.lineAlign values!');
  791. cue.lineAlign = Cue.lineAlign[results[2].toUpperCase()];
  792. }
  793. } else if ((results =
  794. /^line:(-?\d+)(?:,(start|end|center))?$/.exec(word))) {
  795. cue.lineInterpretation = Cue.lineInterpretation.LINE_NUMBER;
  796. cue.line = Number(results[1]);
  797. if (results[2]) {
  798. goog.asserts.assert(
  799. results[2].toUpperCase() in Cue.lineAlign,
  800. results[2].toUpperCase() + ' Should be in Cue.lineAlign values!');
  801. cue.lineAlign = Cue.lineAlign[results[2].toUpperCase()];
  802. }
  803. } else {
  804. return false;
  805. }
  806. return true;
  807. }
  808. /**
  809. * Parses a WebVTT time from the given parser.
  810. *
  811. * @param {!shaka.util.TextParser} parser
  812. * @return {?number}
  813. * @private
  814. */
  815. static parseTime_(parser) {
  816. // 00:00.000 or 00:00:00.000 or 0:00:00.000 or
  817. // 00:00.00 or 00:00:00.00 or 0:00:00.00
  818. const regexExpresion = /(?:(\d{1,}):)?(\d{2}):(\d{2})\.(\d{2,3})/g;
  819. const results = parser.readRegex(regexExpresion);
  820. if (results == null) {
  821. return null;
  822. }
  823. // This capture is optional, but will still be in the array as undefined,
  824. // in which case it is 0.
  825. const hours = Number(results[1]) || 0;
  826. const minutes = Number(results[2]);
  827. const seconds = Number(results[3]);
  828. const milliseconds = Number(results[4]);
  829. if (minutes > 59 || seconds > 59) {
  830. return null;
  831. }
  832. return (milliseconds / 1000) + seconds + (minutes * 60) + (hours * 3600);
  833. }
  834. /**
  835. * This method converts the HTML entities &amp;, &lt;, &gt;, &quot;, &#39;,
  836. * &nbsp;, &lrm; and &rlm; in string to their corresponding characters.
  837. *
  838. * @param {!string} input
  839. * @return {string}
  840. * @private
  841. */
  842. static htmlUnescape_(input) {
  843. // Used to map HTML entities to characters.
  844. const htmlUnescapes = {
  845. '&amp;': '&',
  846. '&lt;': '<',
  847. '&gt;': '>',
  848. '&quot;': '"',
  849. '&#39;': '\'',
  850. '&nbsp;': '\u{a0}',
  851. '&lrm;': '\u{200e}',
  852. '&rlm;': '\u{200f}',
  853. };
  854. // Used to match HTML entities and HTML characters.
  855. const reEscapedHtml = /&(?:amp|lt|gt|quot|#(0+)?39|nbsp|lrm|rlm);/g;
  856. const reHasEscapedHtml = RegExp(reEscapedHtml.source);
  857. // This check is an optimization, since replace always makes a copy
  858. if (input && reHasEscapedHtml.test(input)) {
  859. return input.replace(reEscapedHtml, (entity) => {
  860. // The only thing that might not match the dictionary above is the
  861. // single quote, which can be matched by many strings in the regex, but
  862. // only has a single entry in the dictionary.
  863. return htmlUnescapes[entity] || '\'';
  864. });
  865. }
  866. return input || '';
  867. }
  868. };
  869. /**
  870. * @const {number}
  871. * @private
  872. */
  873. shaka.text.VttTextParser.MPEG_TIMESCALE_ = 90000;
  874. /**
  875. * At this value, timestamps roll over in TS content.
  876. * @const {number}
  877. * @private
  878. */
  879. shaka.text.VttTextParser.TS_ROLLOVER_ = 0x200000000;
  880. shaka.text.TextEngine.registerParser(
  881. 'text/vtt', () => new shaka.text.VttTextParser());
  882. shaka.text.TextEngine.registerParser(
  883. 'text/vtt; codecs="vtt"', () => new shaka.text.VttTextParser());
  884. shaka.text.TextEngine.registerParser(
  885. 'text/vtt; codecs="wvtt"', () => new shaka.text.VttTextParser());