Source: lib/cea/cea_utils.js

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

goog.provide('shaka.cea.CeaUtils');
goog.provide('shaka.cea.CeaUtils.StyledChar');

goog.require('shaka.text.Cue');


shaka.cea.CeaUtils = class {
  /**
   * Emits a closed caption based on the state of the buffer.
   * @param {!shaka.text.Cue} topLevelCue
   * @param {string} stream
   * @param {!Array<!Array<?shaka.cea.CeaUtils.StyledChar>>} memory
   * @param {number} startTime Start time of the cue.
   * @param {number} endTime End time of the cue.
   * @return {?shaka.extern.ICaptionDecoder.ClosedCaption}
   */
  static getParsedCaption(topLevelCue, stream, memory, startTime, endTime) {
    if (startTime >= endTime) {
      return null;
    }

    // Find the first and last row that contains characters.
    let firstNonEmptyRow = -1;
    let lastNonEmptyRow = -1;

    for (let i = 0; i < memory.length; i++) {
      if (memory[i].some((e) => e != null && e.getChar().trim() != '')) {
        firstNonEmptyRow = i;
        break;
      }
    }

    for (let i = memory.length - 1; i >= 0; i--) {
      if (memory[i].some((e) => e != null && e.getChar().trim() != '')) {
        lastNonEmptyRow = i;
        break;
      }
    }

    // Exit early if no non-empty row was found.
    if (firstNonEmptyRow === -1 || lastNonEmptyRow === -1) {
      return null;
    }

    // Keeps track of the current styles for a cue being emitted.
    let currentUnderline = false;
    let currentItalics = false;
    let currentTextColor = shaka.cea.CeaUtils.DEFAULT_TXT_COLOR;
    let currentBackgroundColor = shaka.cea.CeaUtils.DEFAULT_BG_COLOR;

    // Create first cue that will be nested in top level cue. Default styles.
    let currentCue = shaka.cea.CeaUtils.createStyledCue(
        startTime, endTime, currentUnderline, currentItalics,
        currentTextColor, currentBackgroundColor);

    // Logic: Reduce rows into a single top level cue containing nested cues.
    // Each nested cue corresponds either a style change or a line break.

    for (let i = firstNonEmptyRow; i <= lastNonEmptyRow; i++) {
      // Find the first and last non-empty characters in this row. We do this so
      // no styles creep in before/after the first and last non-empty chars.
      const row = memory[i];
      let firstNonEmptyCol = -1;
      let lastNonEmptyCol = -1;

      for (let j = 0; j < row.length; j++) {
        if (row[j] != null && row[j].getChar().trim() !== '') {
          firstNonEmptyCol = j;
          break;
        }
      }

      for (let j = row.length - 1; j >= 0; j--) {
        if (row[j] != null && row[j].getChar().trim() !== '') {
          lastNonEmptyCol = j;
          break;
        }
      }

      // If no non-empty char. was found in this row, it must be a linebreak.
      if (firstNonEmptyCol === -1 || lastNonEmptyCol === -1) {
        const linebreakCue = shaka.cea.CeaUtils
            .createLineBreakCue(startTime, endTime);
        topLevelCue.nestedCues.push(linebreakCue);
        continue;
      }

      for (let j = firstNonEmptyCol; j <= lastNonEmptyCol; j++) {
        const styledChar = row[j];

        // A null between non-empty cells in a row is handled as a space.
        if (!styledChar) {
          currentCue.payload += ' ';
          continue;
        }
        const underline = styledChar.isUnderlined();
        const italics = styledChar.isItalicized();
        const textColor = styledChar.getTextColor();
        const backgroundColor = styledChar.getBackgroundColor();

        // If any style properties have changed, we need to open a new cue.
        if (underline != currentUnderline || italics != currentItalics ||
            textColor != currentTextColor ||
            backgroundColor != currentBackgroundColor) {
          // Push the currently built cue and start a new cue, with new styles.
          if (currentCue.payload) {
            topLevelCue.nestedCues.push(currentCue);
          }
          currentCue = shaka.cea.CeaUtils.createStyledCue(
              startTime, endTime, underline,
              italics, textColor, backgroundColor);

          currentUnderline = underline;
          currentItalics = italics;
          currentTextColor = textColor;
          currentBackgroundColor = backgroundColor;
        }

        currentCue.payload += styledChar.getChar();
      }
      if (currentCue.payload) {
        topLevelCue.nestedCues.push(currentCue);
      }

      // Add a linebreak since the row just ended.
      if (i !== lastNonEmptyRow) {
        const linebreakCue = shaka.cea.CeaUtils
            .createLineBreakCue(startTime, endTime);
        topLevelCue.nestedCues.push(linebreakCue);
      }

      // Create a new cue.
      currentCue = shaka.cea.CeaUtils.createStyledCue(
          startTime, endTime, currentUnderline, currentItalics,
          currentTextColor, currentBackgroundColor);
    }

    if (topLevelCue.nestedCues.length) {
      return {
        cue: topLevelCue,
        stream,
      };
    }

    return null;
  }

  /**
   * @param {number} startTime
   * @param {number} endTime
   * @param {boolean} underline
   * @param {boolean} italics
   * @param {string} txtColor
   * @param {string} bgColor
   * @return {!shaka.text.Cue}
   */
  static createStyledCue(startTime, endTime, underline,
      italics, txtColor, bgColor) {
    const cue = new shaka.text.Cue(startTime, endTime, /* payload= */ '');
    if (underline) {
      cue.textDecoration.push(shaka.text.Cue.textDecoration.UNDERLINE);
    }
    if (italics) {
      cue.fontStyle = shaka.text.Cue.fontStyle.ITALIC;
    }
    cue.color = txtColor;
    cue.backgroundColor = bgColor;
    return cue;
  }

  /**
   * @param {number} startTime
   * @param {number} endTime
   * @return {!shaka.text.Cue}
   */
  static createLineBreakCue(startTime, endTime) {
    const linebreakCue = new shaka.text.Cue(
        startTime, endTime, /* payload= */ '');
    linebreakCue.lineBreak = true;
    return linebreakCue;
  }
};

shaka.cea.CeaUtils.StyledChar = class {
  /**
   * @param {string} character
   * @param {boolean} underline
   * @param {boolean} italics
   * @param {string} backgroundColor
   * @param {string} textColor
   */
  constructor(character, underline, italics, backgroundColor, textColor) {
    /**
     * @private {string}
     */
    this.character_ = character;

    /**
     * @private {boolean}
     */
    this.underline_ = underline;

    /**
     * @private {boolean}
     */
    this.italics_ = italics;

    /**
     * @private {string}
     */
    this.backgroundColor_ = backgroundColor;

    /**
     * @private {string}
     */
    this.textColor_ = textColor;
  }

  /**
   * @return {string}
   */
  getChar() {
    return this.character_;
  }

  /**
   * @return {boolean}
   */
  isUnderlined() {
    return this.underline_;
  }

  /**
   * @return {boolean}
   */
  isItalicized() {
    return this.italics_;
  }

  /**
   * @return {string}
   */
  getBackgroundColor() {
    return this.backgroundColor_;
  }

  /**
   * @return {string}
   */
  getTextColor() {
    return this.textColor_;
  }
};

/**
 * Default background color for text.
 * @const {string}
 */
shaka.cea.CeaUtils.DEFAULT_BG_COLOR = 'black';

/**
 * Default text color.
 * @const {string}
 */
shaka.cea.CeaUtils.DEFAULT_TXT_COLOR = 'white';

/**
 * NALU type for Supplemental Enhancement Information (SEI) for H.264.
 * @const {number}
 */
shaka.cea.CeaUtils.H264_NALU_TYPE_SEI = 0x06;

/**
 * NALU type for Supplemental Enhancement Information (SEI) for H.265.
 * @const {number}
 */
shaka.cea.CeaUtils.H265_PREFIX_NALU_TYPE_SEI = 0x27;

/**
 * NALU type for Supplemental Enhancement Information (SEI) for H.265.
 * @const {number}
 */
shaka.cea.CeaUtils.H265_SUFFIX_NALU_TYPE_SEI = 0x28;

/**
 * Default timescale value for a track.
 * @const {number}
 */
shaka.cea.CeaUtils.DEFAULT_TIMESCALE_VALUE = 90000;