Source: lib/text/vtt_text_parser.js

/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

goog.provide('shaka.text.VttTextParser');

goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.media.ManifestParser');
goog.require('shaka.text.Cue');
goog.require('shaka.text.CueRegion');
goog.require('shaka.text.TextEngine');
goog.require('shaka.util.Error');
goog.require('shaka.util.StringUtils');
goog.require('shaka.util.TextParser');


/**
 * @implements {shaka.extern.TextParser}
 * @export
 */
shaka.text.VttTextParser = class {
  /** Constructs a VTT parser. */
  constructor() {
    /** @private {string} */
    this.manifestType_ = shaka.media.ManifestParser.UNKNOWN;
  }

  /**
   * @override
   * @export
   */
  parseInit(data) {
    goog.asserts.assert(false, 'VTT does not have init segments');
  }

  /**
   * @override
   * @export
   */
  setManifestType(manifestType) {
    this.manifestType_ = manifestType;
  }

  /**
   * @override
   * @export
   */
  parseMedia(data, time, uri, images) {
    const VttTextParser = shaka.text.VttTextParser;
    // Get the input as a string.  Normalize newlines to \n.
    let str = shaka.util.StringUtils.fromUTF8(data);
    str = str.replace(/\r\n|\r(?=[^\n]|$)/gm, '\n');
    const blocks = str.split(/\n{2,}/m);

    if (!/^WEBVTT($|[ \t\n])/m.test(blocks[0])) {
      throw new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.TEXT,
          shaka.util.Error.Code.INVALID_TEXT_HEADER);
    }

    // Depending on "segmentRelativeVttTiming" configuration,
    // "vttOffset" will correspond to either "periodStart" (default)
    // or "segmentStart", for segmented VTT where timings are relative
    // to the beginning of each segment.
    // NOTE: "periodStart" is the timestamp offset applied via TextEngine.
    // It is no longer closely tied to periods, but the name stuck around.
    // NOTE: This offset and the flag choosing its meaning have no effect on
    // HLS content, which should use X-TIMESTAMP-MAP and periodStart instead.
    let offset = time.vttOffset;

    if (this.manifestType_ == shaka.media.ManifestParser.HLS) {
      // Only use 'X-TIMESTAMP-MAP' with HLS.
      if (blocks[0].includes('X-TIMESTAMP-MAP')) {
        offset = this.computeHlsOffset_(blocks[0], time, blocks.slice(1));
      }
    }

    /** @type {!Array<!shaka.text.CueRegion>} */
    const regions = VttTextParser.parseLegacyRegions_(blocks[0]);

    /** @type {!Map<string, !shaka.text.Cue>} */
    const styles = new Map();
    shaka.text.Cue.addDefaultTextColor(styles);

    // Parse cues.
    const ret = [];
    let seenCue = false;
    for (const block of blocks.slice(1)) {
      const lines = block.split('\n');
      if (VttTextParser.isRegionBlock_(lines)) {
        // Region definitions are only allowed before the first cue.
        if (!seenCue) {
          regions.push(VttTextParser.parseRegionBlock_(lines));
        }
        continue;
      }
      VttTextParser.parseStyle_(lines, styles);
      const cue = VttTextParser.parseCue_(lines, offset, regions, styles);
      if (cue) {
        seenCue = true;
        ret.push(cue);
      }
    }

    return ret;
  }

  /**
   * Parses the region definitions of a WebVTT header: everything before the
   * first cue, such as the contents of the WebVTTConfigurationBox of WebVTT in
   * MP4.
   *
   * @param {string} header
   * @return {!Array<!shaka.text.CueRegion>}
   */
  static parseRegions(header) {
    const VttTextParser = shaka.text.VttTextParser;
    const str = header.replace(/\r\n|\r(?=[^\n]|$)/gm, '\n');
    const blocks = str.split(/\n{2,}/m);
    const regions = VttTextParser.parseLegacyRegions_(blocks[0]);
    // The header might not start with the WEBVTT signature, so its first block
    // can be a region definition too.
    for (const block of blocks) {
      const lines = block.split('\n');
      if (lines.some((line) => line.includes('-->'))) {
        // A cue: no region definitions after it.
        break;
      }
      if (VttTextParser.isRegionBlock_(lines)) {
        regions.push(VttTextParser.parseRegionBlock_(lines));
      }
    }
    return regions;
  }

  /**
   * @param {string} headerBlock Contains X-TIMESTAMP-MAP.
   * @param {shaka.extern.TextParser.TimeContext} time
   * @param {!Array<string>} cueBlocks
   * @return {number}
   * @private
   */
  computeHlsOffset_(headerBlock, time, cueBlocks) {
    // https://bit.ly/2K92l7y
    // The 'X-TIMESTAMP-MAP' header is used in HLS to align text with
    // the rest of the media.
    // The header format is 'X-TIMESTAMP-MAP=MPEGTS:n,LOCAL:m'
    // (the attributes can go in any order)
    // where n is MPEG-2 time and m is cue time it maps to.
    // For example 'X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:900000'
    // means an offset of 10 seconds
    // 900000/MPEG_TIMESCALE - cue time.
    const cueTimeMatch = headerBlock.match(
        /LOCAL:((?:(\d{1,}):)?(\d{2}):(\d{2})\.(\d{3}))/m);
    const mpegTimeMatch = headerBlock.match(/MPEGTS:(\d+)/m);

    if (!cueTimeMatch || !mpegTimeMatch) {
      throw new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.TEXT,
          shaka.util.Error.Code.INVALID_TEXT_HEADER);
    }

    const cueTime = shaka.util.TextParser.parseTime(cueTimeMatch[1]);
    if (cueTime == null) {
      throw new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.TEXT,
          shaka.util.Error.Code.INVALID_TEXT_HEADER);
    }

    let mpegTime = Number(mpegTimeMatch[1]);
    const mpegTimescale = shaka.text.VttTextParser.MPEG_TIMESCALE_;

    const rolloverSeconds =
        shaka.text.VttTextParser.TS_ROLLOVER_ / mpegTimescale;
    // The MPEGTS value is a 33-bit timestamp that may have rolled over
    // while the media timeline has not. Count the rollovers to add by
    // comparing it with the media time of the LOCAL anchor, estimated from
    // the segment start and corrected by how far the first cue sits from
    // LOCAL. Without that correction, a packager that writes absolute cue
    // times with MPEGTS:0,LOCAL:00:00:00.000 next to fMP4 media with
    // wall-clock based timestamps gets thousands of rollovers added, and
    // every cue lands far in the future.
    let anchorTime = time.segmentStart - time.periodStart;
    const firstCueTime =
        shaka.text.VttTextParser.firstCueStartTime_(cueBlocks);
    if (firstCueTime != null) {
      anchorTime -= firstCueTime - cueTime;
    }
    const rollovers = Math.max(0,
        Math.round((anchorTime - mpegTime / mpegTimescale) / rolloverSeconds));
    mpegTime += rollovers * shaka.text.VttTextParser.TS_ROLLOVER_;

    return time.periodStart + mpegTime / mpegTimescale - cueTime;
  }

  /**
   * @param {!Array<string>} cueBlocks
   * @return {?number} The start time of the first cue, in cue time.
   * @private
   */
  static firstCueStartTime_(cueBlocks) {
    const match = cueBlocks.join('\n').match(
        /^[ \t]*((?:\d+:)?\d{2}:\d{2}\.\d{3})[ \t]+-->/m);
    return match ? shaka.util.TextParser.parseTime(match[1]) : null;
  }

  /**
   * Parses the regions defined with the syntax of early WebVTT drafts, as
   * header lines like:
   * Region: id=fred width=50% lines=3 regionanchor=0%,100%
   *         viewportanchor=10%,90% scroll=up
   *
   * @param {string} headerBlock
   * @return {!Array<!shaka.text.CueRegion>}
   * @private
   */
  static parseLegacyRegions_(headerBlock) {
    const VttTextParser = shaka.text.VttTextParser;
    const regions = [];
    for (const line of headerBlock.split('\n')) {
      if (/^Region:/.test(line)) {
        // Skip 'Region:'
        const settings = line.substring('Region:'.length);
        regions.push(VttTextParser.parseRegionSettings_(settings, '='));
      }
    }
    return regions;
  }

  /**
   * @param {!Array<string>} lines
   * @return {boolean} True if the block is a REGION definition block.
   * @private
   */
  static isRegionBlock_(lines) {
    // A block starting with "REGION" whose second line has a timing is a cue
    // with "REGION" as its identifier.
    return /^REGION[ \t]*$/.test(lines[0]) &&
        !(lines.length > 1 && lines[1].includes('-->'));
  }

  /**
   * Parses a REGION definition block:
   * REGION
   * id:fred
   * width:40%
   * lines:3
   * regionanchor:0%,100%
   * viewportanchor:10%,90%
   * scroll:up
   *
   * @param {!Array<string>} lines
   * @return {!shaka.text.CueRegion}
   * @private
   */
  static parseRegionBlock_(lines) {
    // Settings can be separated by spaces, tabs or line terminators.
    return shaka.text.VttTextParser.parseRegionSettings_(
        lines.slice(1).join('\n'), ':');
  }

  /**
   * Parses a list of region settings into a Region object.
   * See https://www.w3.org/TR/webvtt1/#collect-webvtt-region-settings
   *
   * @param {string} settings
   * @param {string} separator The separator between the name and the value of
   *   each setting.
   * @return {!shaka.text.CueRegion}
   * @private
   */
  static parseRegionSettings_(settings, separator) {
    const region = new shaka.text.CueRegion();
    // WebVTT region defaults, which differ from the CueRegion ones.
    region.height = 3;
    region.heightUnits = shaka.text.CueRegion.units.LINES;
    region.regionAnchorX = 0;
    region.regionAnchorY = 100;
    region.viewportAnchorX = 0;
    region.viewportAnchorY = 100;

    for (const setting of settings.split(/[ \t\n]+/)) {
      if (!setting) {
        continue;
      }
      if (!shaka.text.VttTextParser.parseRegionSetting_(
          region, setting, separator)) {
        shaka.log.warning(
            'VTT parser encountered an invalid VTTRegion setting: ', setting,
            ' The setting will be ignored.');
      }
    }

    return region;
  }

  /**
   * Parses a style block into a Cue object.
   *
   * @param {!Array<string>} text
   * @param {!Map<string, !shaka.text.Cue>} styles
   * @private
   */
  static parseStyle_(text, styles) {
    // Skip empty blocks.
    if (text.length == 1 && !text[0]) {
      return;
    }

    // Skip comment blocks.
    if (shaka.text.VttTextParser.REGEX_NOTE_.test(text[0])) {
      return;
    }

    // Only style block are allowed.
    if (text[0] != 'STYLE') {
      return;
    }

    /** @type {!Array<!Array<string>>} */
    const styleBlocks = [];
    let lastBlockIndex = -1;
    for (let i = 1; i < text.length; i++) {
      if (text[i].includes('::cue')) {
        styleBlocks.push([]);
        lastBlockIndex = styleBlocks.length - 1;
      }
      if (lastBlockIndex == -1) {
        continue;
      }
      styleBlocks[lastBlockIndex].push(text[i]);
      if (text[i].includes('}')) {
        lastBlockIndex = -1;
      }
    }

    for (const styleBlock of styleBlocks) {
      let styleSelector = 'global';
      // Look for what is within parentheses. For example:
      // <code>:: cue (b) {</code>, what we are looking for is <code>b</code>
      const selector = styleBlock[0].match(/\((.*)\)/);
      if (selector) {
        styleSelector = selector.pop();
      }

      // We start at 1 to avoid '::cue' and end earlier to avoid '}'
      let propertyLines = styleBlock.slice(1, -1);
      if (styleBlock[0].includes('}')) {
        const payload = /\{(.*?)\}/.exec(styleBlock[0]);
        if (payload) {
          propertyLines = payload[1].split(';');
        }
      }

      // Continue styles over multiple selectors if necessary.
      // For example,
      //   ::cue(b) { background: white; } ::cue(b) { color: blue; }
      // should set both the background and foreground of bold tags.
      let cue = styles.get(styleSelector);
      if (!cue) {
        cue = new shaka.text.Cue(0, 0, '');
      }

      let validStyle = false;
      for (let i = 0; i < propertyLines.length; i++) {
        // We look for CSS properties. As a general rule they are separated by
        // <code>:</code>. Eg: <code>color: red;</code>
        const lineParts = /^\s*([^:]+):\s*(.*)/.exec(propertyLines[i]);
        if (lineParts) {
          const name = lineParts[1].trim();
          const value = lineParts[2].trim().replace(';', '');
          switch (name) {
            case 'background-color':
            case 'background':
              validStyle = true;
              cue.backgroundColor = value;
              break;
            case 'color':
              validStyle = true;
              cue.color = value;
              break;
            case 'font-family':
              validStyle = true;
              cue.fontFamily = value;
              break;
            case 'font-size':
              validStyle = true;
              cue.fontSize = value;
              break;
            case 'font-weight':
              if (parseInt(value, 10) >= 700 || value == 'bold') {
                validStyle = true;
                cue.fontWeight = shaka.text.Cue.fontWeight.BOLD;
              }
              break;
            case 'font-style':
              switch (value) {
                case 'normal':
                  validStyle = true;
                  cue.fontStyle = shaka.text.Cue.fontStyle.NORMAL;
                  break;
                case 'italic':
                  validStyle = true;
                  cue.fontStyle = shaka.text.Cue.fontStyle.ITALIC;
                  break;
                case 'oblique':
                  validStyle = true;
                  cue.fontStyle = shaka.text.Cue.fontStyle.OBLIQUE;
                  break;
              }
              break;
            case 'opacity':
              validStyle = true;
              cue.opacity = parseFloat(value);
              break;
            case 'text-combine-upright':
              validStyle = true;
              cue.textCombineUpright = value;
              break;
            case 'text-shadow':
              validStyle = true;
              cue.textShadow = value;
              break;
            case 'white-space':
              validStyle = true;
              cue.wrapLine = value != 'noWrap';
              break;
            default:
              shaka.log.warning('VTT parser encountered an unsupported style: ',
                  lineParts);
              break;
          }
        }
      }

      if (validStyle) {
        styles.set(styleSelector, cue);
      }
    }
  }

  /**
   * Parses a text block into a Cue object.
   *
   * @param {!Array<string>} text
   * @param {number} timeOffset
   * @param {!Array<!shaka.text.CueRegion>} regions
   * @param {!Map<string, !shaka.text.Cue>} styles
   * @return {shaka.text.Cue}
   * @private
   */
  static parseCue_(text, timeOffset, regions, styles) {
    const VttTextParser = shaka.text.VttTextParser;

    // Skip empty blocks.
    if (text.length == 1 && !text[0]) {
      return null;
    }

    // Skip comment blocks.
    if (shaka.text.VttTextParser.REGEX_NOTE_.test(text[0])) {
      return null;
    }

    // Skip style and region blocks.
    if (text[0] == 'STYLE' || shaka.text.VttTextParser.isRegionBlock_(text)) {
      return null;
    }

    let id = null;
    let lineIndex = 0;
    if (!text[0].includes('-->')) {
      id = text[0];
      lineIndex = 1;
    }

    // Parse the times.
    const parser = new shaka.util.TextParser(text[lineIndex]);
    let start = parser.parseTime();
    const expect = parser.readRegex(/[ \t]+-->[ \t]+/g);
    let end = parser.parseTime();

    if (start == null || expect == null || end == null) {
      shaka.log.alwaysWarn(
          'Failed to parse VTT time code. Cue skipped:', id, text);
      return null;
    }

    start += timeOffset;
    end += timeOffset;

    if (start < 0) {
      start = 0;
    }

    // Get the payload.
    let payload = '';
    for (let i = lineIndex + 1; i < text.length; i++) {
      if (i > lineIndex + 1) {
        payload += '\n';
      }
      payload += text[i];
    }
    payload = payload.trim();

    let cue;
    if (styles.has('global')) {
      cue = styles.get('global').clone();
      cue.startTime = start;
      cue.endTime = end;
      cue.payload = payload;
    } else {
      cue = new shaka.text.Cue(start, end, payload);
    }

    // Parse optional settings.
    parser.skipWhitespace();
    VttTextParser.parseCueSettings(cue, parser, regions);

    shaka.text.Cue.parseCuePayload(cue, styles);

    if (id != null) {
      cue.id = id;
    }
    return cue;
  }

  /**
   * Parses all the WebVTT settings of a cue.
   *
   * @param {!shaka.text.Cue} cue
   * @param {!shaka.util.TextParser} parser Positioned at the first setting.
   * @param {!Array<!shaka.text.CueRegion>} regions
   */
  static parseCueSettings(cue, parser, regions) {
    parser.forEachWord((word) => {
      if (!shaka.text.VttTextParser.parseCueSetting(cue, word, regions)) {
        shaka.log.warning('VTT parser encountered an invalid VTT setting: ',
            word,
            ' The setting will be ignored.');
      }
    });

    // A cue explicitly positioned with a line, sized, or vertical drops out of
    // its region.  This is checked once all the settings are parsed, so the
    // result doesn't depend on their order.
    // See https://www.w3.org/TR/webvtt1/#parse-the-webvtt-cue-settings
    const horizontal = shaka.text.Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM;
    if (cue.region.id && (cue.line != null ||
        (cue.size && cue.size != 100) || cue.writingMode != horizontal)) {
      cue.region = new shaka.text.CueRegion();
    }
  }

  /**
   * Parses a WebVTT setting from the given word.
   *
   * @param {!shaka.text.Cue} cue
   * @param {string} word
   * @param {!Array<!shaka.text.CueRegion>} regions
   * @return {boolean} True on success.
   */
  static parseCueSetting(cue, word, regions) {
    const VttTextParser = shaka.text.VttTextParser;
    let results = null;
    if ((results = VttTextParser.REGEX_ALIGN_.exec(word))) {
      VttTextParser.setTextAlign_(cue, results[1]);
    } else if ((results = VttTextParser.REGEX_VERTICAL_.exec(word))) {
      VttTextParser.setVerticalWritingMode_(cue, results[1]);
    } else if ((results = VttTextParser.REGEX_SIZE_.exec(word))) {
      cue.size = Number(results[1]);
    } else if ((results = VttTextParser.REGEX_POSITION_.exec(word))) {
      cue.position = Number(results[1]);
      if (results[2]) {
        VttTextParser.setPositionAlign_(cue, results[2]);
      }
    } else if ((results = VttTextParser.REGEX_REGION_.exec(word))) {
      const region = VttTextParser.getRegionById_(regions, results[1]);
      if (region) {
        cue.region = region;
      }
    } else {
      return VttTextParser.parsedLineValueAndInterpretation_(cue, word);
    }

    return true;
  }

  /**
   *
   * @param {!Array<!shaka.text.CueRegion>} regions
   * @param {string} id
   * @return {?shaka.text.CueRegion}
   * @private
   */
  static getRegionById_(regions, id) {
    // If several regions share the id, the last one wins.
    for (let i = regions.length - 1; i >= 0; i--) {
      if (regions[i].id == id) {
        return regions[i];
      }
    }
    shaka.log.warning('VTT parser could not find a region with id: ',
        id,
        ' The region will be ignored.');
    return null;
  }

  /**
   * Parses a WebVTTRegion setting from the given word.
   *
   * @param {!shaka.text.CueRegion} region
   * @param {string} word
   * @param {string} separator The separator between the name and the value.
   * @return {boolean} True on success.
   * @private
   */
  static parseRegionSetting_(region, word, separator) {
    const VttTextParser = shaka.text.VttTextParser;
    const index = word.indexOf(separator);
    if (index <= 0 || index == word.length - 1) {
      return false;
    }
    const name = word.substring(0, index);
    const value = word.substring(index + 1);

    switch (name) {
      case 'id':
        region.id = value;
        return true;
      case 'width': {
        const width = VttTextParser.parsePercentage_(value);
        if (width == null) {
          return false;
        }
        region.width = width;
        return true;
      }
      case 'lines':
        if (!/^\d+$/.test(value)) {
          return false;
        }
        region.height = Number(value);
        region.heightUnits = shaka.text.CueRegion.units.LINES;
        return true;
      case 'regionanchor': {
        const anchor = VttTextParser.parsePercentagePair_(value);
        if (!anchor) {
          return false;
        }
        region.regionAnchorX = anchor[0];
        region.regionAnchorY = anchor[1];
        return true;
      }
      case 'viewportanchor': {
        const anchor = VttTextParser.parsePercentagePair_(value);
        if (!anchor) {
          return false;
        }
        region.viewportAnchorX = anchor[0];
        region.viewportAnchorY = anchor[1];
        return true;
      }
      case 'scroll':
        if (value != 'up') {
          return false;
        }
        region.scroll = shaka.text.CueRegion.scrollMode.UP;
        return true;
    }
    return false;
  }

  /**
   * Parses two comma separated WebVTT percentages, such as "10%,90%".
   *
   * @param {string} value
   * @return {?Array<number>}
   * @private
   */
  static parsePercentagePair_(value) {
    const VttTextParser = shaka.text.VttTextParser;
    const index = value.indexOf(',');
    if (index < 0) {
      return null;
    }
    const x = VttTextParser.parsePercentage_(value.substring(0, index));
    const y = VttTextParser.parsePercentage_(value.substring(index + 1));
    if (x == null || y == null) {
      return null;
    }
    return [x, y];
  }

  /**
   * Parses a WebVTT percentage, such as "12.5%".
   * See https://www.w3.org/TR/webvtt1/#parse-a-percentage-string
   *
   * @param {string} value
   * @return {?number} A number between 0 and 100, or null if invalid.
   * @private
   */
  static parsePercentage_(value) {
    if (!/^\d+(?:\.\d+)?%$/.test(value)) {
      return null;
    }
    const percentage = parseFloat(value);
    if (percentage > 100) {
      return null;
    }
    return percentage;
  }

  /**
   * @param {!shaka.text.Cue} cue
   * @param {string} align
   * @private
   */
  static setTextAlign_(cue, align) {
    const Cue = shaka.text.Cue;
    if (align == 'middle') {
      cue.textAlign = Cue.textAlign.CENTER;
    } else {
      goog.asserts.assert(align.toUpperCase() in Cue.textAlign,
          align.toUpperCase() +
                          ' Should be in Cue.textAlign values!');

      cue.textAlign = Cue.textAlign[align.toUpperCase()];
    }
  }

  /**
   * @param {!shaka.text.Cue} cue
   * @param {string} align
   * @private
   */
  static setPositionAlign_(cue, align) {
    const Cue = shaka.text.Cue;
    if (align == 'line-left' || align == 'start') {
      cue.positionAlign = Cue.positionAlign.LEFT;
    } else if (align == 'line-right' || align == 'end') {
      cue.positionAlign = Cue.positionAlign.RIGHT;
    } else if (align == 'center' || align == 'middle') {
      cue.positionAlign = Cue.positionAlign.CENTER;
    } else {
      cue.positionAlign = Cue.positionAlign.AUTO;
    }
  }

  /**
   * @param {!shaka.text.Cue} cue
   * @param {string} value
   * @private
   */
  static setVerticalWritingMode_(cue, value) {
    const Cue = shaka.text.Cue;
    if (value == 'lr') {
      cue.writingMode = Cue.writingMode.VERTICAL_LEFT_TO_RIGHT;
    } else {
      cue.writingMode = Cue.writingMode.VERTICAL_RIGHT_TO_LEFT;
    }
  }

  /**
   * @param {!shaka.text.Cue} cue
   * @param {string} word
   * @return {boolean}
   * @private
   */
  static parsedLineValueAndInterpretation_(cue, word) {
    const Cue = shaka.text.Cue;
    let results = null;
    if ((results = shaka.text.VttTextParser.REGEX_LINE_PCT_.exec(word))) {
      cue.lineInterpretation = Cue.lineInterpretation.PERCENTAGE;
    } else if ((results =
                    shaka.text.VttTextParser.REGEX_LINE_NUM_.exec(word))) {
      cue.lineInterpretation = Cue.lineInterpretation.LINE_NUMBER;
    } else {
      return false;
    }

    cue.line = Number(results[1]);
    if (results[2]) {
      goog.asserts.assert(
          results[2].toUpperCase() in Cue.lineAlign,
          results[2].toUpperCase() + ' Should be in Cue.lineAlign values!');
      cue.lineAlign = Cue.lineAlign[results[2].toUpperCase()];
    }

    return true;
  }
};

/**
 * @const {number}
 * @private
 */
shaka.text.VttTextParser.MPEG_TIMESCALE_ = 90000;

/**
 * At this value, timestamps roll over in TS content.
 * @const {number}
 * @private
 */
shaka.text.VttTextParser.TS_ROLLOVER_ = 0x200000000;

/** @private @const {!RegExp} */
shaka.text.VttTextParser.REGEX_NOTE_ = /^NOTE($|[ \t])/;

/** @private @const {!RegExp} */
shaka.text.VttTextParser.REGEX_ALIGN_ =
    /^align:(start|middle|center|end|left|right)$/;

/** @private @const {!RegExp} */
shaka.text.VttTextParser.REGEX_VERTICAL_ = /^vertical:(lr|rl)$/;

/** @private @const {!RegExp} */
shaka.text.VttTextParser.REGEX_SIZE_ = /^size:([\d.]+)%$/;

/** @private @const {!RegExp} */
shaka.text.VttTextParser.REGEX_POSITION_ =
    // eslint-disable-next-line @stylistic/max-len
    /^position:([\d.]+)%(?:,(line-left|line-right|middle|center|start|end|auto))?$/;

/** @private @const {!RegExp} */
shaka.text.VttTextParser.REGEX_REGION_ = /^region:(.+)$/;

/** @private @const {!RegExp} */
shaka.text.VttTextParser.REGEX_LINE_PCT_ =
    /^line:([\d.]+)%(?:,(start|end|center))?$/;

/** @private @const {!RegExp} */
shaka.text.VttTextParser.REGEX_LINE_NUM_ =
    /^line:(-?\d+)(?:,(start|end|center))?$/;

shaka.text.TextEngine.registerParser(
    'text/vtt', () => new shaka.text.VttTextParser());

shaka.text.TextEngine.registerParser(
    'text/vtt; codecs="vtt"', () => new shaka.text.VttTextParser());

shaka.text.TextEngine.registerParser(
    'text/vtt; codecs="wvtt"', () => new shaka.text.VttTextParser());