Source: lib/cea/cea708_service.js

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

goog.provide('shaka.cea.Cea708Service');

goog.require('shaka.cea.Cea708Window');
goog.require('shaka.cea.DtvccPacket');


/**
 * CEA-708 closed captions service as defined by CEA-708-E. A decoder can own up
 * to 63 services. Each service owns eight windows.
 */
shaka.cea.Cea708Service = class {
  /**
   * @param {number} serviceNumber
   */
  constructor(serviceNumber) {
    /**
     * Number for this specific service (1 - 63).
     * @private {number}
     */
    this.serviceNumber_ = serviceNumber;

    /**
     * Eight Cea708 Windows, as defined by the spec.
     * @private {!Array<?shaka.cea.Cea708Window>}
     */
    this.windows_ = [
      null, null, null, null, null, null, null, null,
    ];

    /**
     * The current window for which window command operate on.
     * @private {?shaka.cea.Cea708Window}
     */
    this.currentWindow_ = null;
  }

  /**
   * Processes a CEA-708 control code.
   * @param {!shaka.cea.DtvccPacket} dtvccPacket
   * @return {?shaka.extern.ICaptionDecoder.ClosedCaption}
   * @throws {!shaka.util.Error}
   */
  handleCea708ControlCode(dtvccPacket) {
    const blockData = dtvccPacket.readByte();
    let controlCode = blockData.value;
    const pts = blockData.pts;

    // Read extended control code if needed.
    if (controlCode === shaka.cea.Cea708Service.EXT_CEA708_CTRL_CODE_BYTE1) {
      const extendedControlCodeBlock = dtvccPacket.readByte();
      controlCode = (controlCode << 16) | extendedControlCodeBlock.value;
    }

    // Control codes are in 1 of 4 logical groups:
    // CL (C0, C2), CR (C1, C3), GL (G0, G2), GR (G1, G2).
    if (controlCode >= 0x00 && controlCode <= 0x1f) {
      return this.handleC0_(controlCode, pts);
    } else if (controlCode >= 0x80 && controlCode <= 0x9f) {
      return this.handleC1_(dtvccPacket, controlCode, pts);
    } else if (controlCode >= 0x1000 && controlCode <= 0x101f) {
      this.handleC2_(dtvccPacket, controlCode & 0xff);
    } else if (controlCode >= 0x1080 && controlCode <= 0x109f) {
      this.handleC3_(dtvccPacket, controlCode & 0xff);
    } else if (controlCode >= 0x20 && controlCode <= 0x7f) {
      this.handleG0_(controlCode);
    } else if (controlCode >= 0xa0 && controlCode <= 0xff) {
      this.handleG1_(controlCode);
    } else if (controlCode >= 0x1020 && controlCode <= 0x107f) {
      this.handleG2_(controlCode & 0xff);
    } else if (controlCode >= 0x10a0 && controlCode <= 0x10ff) {
      this.handleG3_(controlCode & 0xff);
    }

    return null;
  }

  /**
   * Handles G0 group data.
   * @param {number} controlCode
   * @private
   */
  handleG0_(controlCode) {
    if (!this.currentWindow_) {
      return;
    }
    // G0 contains ASCII from 0x20 to 0x7f, with the exception that 0x7f
    // is replaced by a musical note.
    if (controlCode === 0x7f) {
      this.currentWindow_.setCharacter('♪');
      return;
    }
    this.currentWindow_.setCharacter(String.fromCharCode(controlCode));
  }

  /**
   * Handles G1 group data.
   * @param {number} controlCode
   * @private
   */
  handleG1_(controlCode) {
    if (!this.currentWindow_) {
      return;
    }
    // G1 is the Latin-1 Character Set from 0xa0 to 0xff.
    this.currentWindow_.setCharacter(String.fromCharCode(controlCode));
  }

  /**
   * Handles G2 group data.
   * @param {number} controlCode
   * @private
   */
  handleG2_(controlCode) {
    if (!this.currentWindow_) {
      return;
    }
    if (!shaka.cea.Cea708Service.G2Charset.has(controlCode)) {
      // If the character is unsupported, the spec says to put an underline.
      this.currentWindow_.setCharacter('_');
      return;
    }

    const char = shaka.cea.Cea708Service.G2Charset.get(controlCode);
    this.currentWindow_.setCharacter(char);
  }

  /**
   * Handles G3 group data.
   * @param {number} controlCode
   * @private
   */
  handleG3_(controlCode) {
    if (!this.currentWindow_) {
      return;
    }

    // As of CEA-708-E, the G3 group only contains 1 character. It's a
    // [CC] character which has no unicode value on 0xa0.
    if (controlCode != 0xa0) {
      // Similar to G2, the spec decrees an underline if char is unsupported.
      this.currentWindow_.setCharacter('_');
      return;
    }

    this.currentWindow_.setCharacter('[CC]');
  }

  /**
   * Handles C0 group data.
   * @param {number} controlCode
   * @param {number} pts
   * @return {?shaka.extern.ICaptionDecoder.ClosedCaption}
   * @private
   */
  handleC0_(controlCode, pts) {
    // All these commands pertain to the current window, so ensure it exists.
    if (!this.currentWindow_) {
      return null;
    }

    const window = this.currentWindow_;
    let parsedClosedCaption = null;

    // Note: This decoder ignores the "ETX" (end of text) control code. Since
    // this is JavaScript, a '\0' is not needed to terminate a string.
    switch (controlCode) {
      case shaka.cea.Cea708Service.ASCII_BACKSPACE:
        window.backspace();
        break;
      case shaka.cea.Cea708Service.ASCII_CARRIAGE_RETURN:
        // Force out the buffer, since the top row could be lost.
        if (window.isVisible()) {
          parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_);
        }
        window.carriageReturn();
        break;
      case shaka.cea.Cea708Service.ASCII_HOR_CARRIAGE_RETURN:
        // Force out the buffer, a row will be erased.
        if (window.isVisible()) {
          parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_);
        }
        window.horizontalCarriageReturn();
        break;
      case shaka.cea.Cea708Service.ASCII_FORM_FEED:
        // Clear window and move pen to (0,0).
        // Force emit if the window is visible.
        if (window.isVisible()) {
          parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_);
        }
        window.resetMemory();
        window.setPenLocation(0, 0);
        break;
    }
    return parsedClosedCaption;
  }

  /**
   * Processes C1 group data.
   * These are caption commands.
   * @param {!shaka.cea.DtvccPacket} dtvccPacket
   * @param {number} captionCommand
   * @param {number} pts in seconds
   * @return {?shaka.extern.ICaptionDecoder.ClosedCaption}
   * @throws {!shaka.util.Error} a possible out-of-range buffer read.
   * @private
   */
  handleC1_(dtvccPacket, captionCommand, pts) {
    // Note: This decoder ignores delay and delayCancel control codes in the C1.
    // group. These control codes delay processing of data for a set amount of
    // time, however this decoder processes that data immediately.

    if (captionCommand >= 0x80 && captionCommand <= 0x87) {
      const windowNum = captionCommand & 0x07;
      this.setCurrentWindow_(windowNum);
    } else if (captionCommand === 0x88) {
      const bitmap = dtvccPacket.readByte().value;
      return this.clearWindows_(bitmap, pts);
    } else if (captionCommand === 0x89) {
      const bitmap = dtvccPacket.readByte().value;
      this.displayWindows_(bitmap, pts);
    } else if (captionCommand === 0x8a) {
      const bitmap = dtvccPacket.readByte().value;
      return this.hideWindows_(bitmap, pts);
    } else if (captionCommand === 0x8b) {
      const bitmap = dtvccPacket.readByte().value;
      return this.toggleWindows_(bitmap, pts);
    } else if (captionCommand === 0x8c) {
      const bitmap = dtvccPacket.readByte().value;
      return this.deleteWindows_(bitmap, pts);
    } else if (captionCommand === 0x8f) {
      return this.reset_(pts);
    } else if (captionCommand === 0x90) {
      this.setPenAttributes_(dtvccPacket);
    } else if (captionCommand === 0x91) {
      this.setPenColor_(dtvccPacket);
    } else if (captionCommand === 0x92) {
      this.setPenLocation_(dtvccPacket);
    } else if (captionCommand === 0x97) {
      this.setWindowAttributes_(dtvccPacket);
    } else if (captionCommand >= 0x98 && captionCommand <= 0x9f) {
      const windowNum = (captionCommand & 0x0f) - 8;
      this.defineWindow_(dtvccPacket, windowNum, pts);
    }
    return null;
  }

  /**
   * Handles C2 group data.
   * @param {!shaka.cea.DtvccPacket} dtvccPacket
   * @param {number} controlCode
   * @private
   */
  handleC2_(dtvccPacket, controlCode) {
    // As of the CEA-708-E spec there are no commands on the C2 table, but if
    // seen, then the appropriate number of bytes must be skipped as per spec.
    if (controlCode >= 0x08 && controlCode <= 0x0f) {
      dtvccPacket.skip(1);
    } else if (controlCode >= 0x10 && controlCode <= 0x17) {
      dtvccPacket.skip(2);
    } else if (controlCode >= 0x18 && controlCode <= 0x1f) {
      dtvccPacket.skip(3);
    }
  }

  /**
   * Handles C3 group data.
   * @param {!shaka.cea.DtvccPacket} dtvccPacket
   * @param {number} controlCode
   * @private
   */
  handleC3_(dtvccPacket, controlCode) {
    // As of the CEA-708-E spec there are no commands on the C3 table, but if
    // seen, then the appropriate number of bytes must be skipped as per spec.
    if (controlCode >= 0x80 && controlCode <= 0x87) {
      dtvccPacket.skip(4);
    } else if (controlCode >= 0x88 && controlCode <= 0x8f) {
      dtvccPacket.skip(5);
    }
  }

  /**
   * @param {number} windowNum
   * @private
   */
  setCurrentWindow_(windowNum) {
    // If the window isn't created, ignore the command.
    if (!this.windows_[windowNum]) {
      return;
    }
    this.currentWindow_ = this.windows_[windowNum];
  }

  /**
   * Yields each non-null window specified in the 8-bit bitmap.
   * @param {number} bitmap 8 bits corresponding to each of the 8 windows.
   * @return {!Array.<number>}
   * @private
   */
  getSpecifiedWindowIds_(bitmap) {
    const ids = [];
    for (let i = 0; i < 8; i++) {
      const windowSpecified = (bitmap & 0x01) === 0x01;
      if (windowSpecified && this.windows_[i]) {
        ids.push(i);
      }
      bitmap >>= 1;
    }
    return ids;
  }

  /**
   * @param {number} windowsBitmap
   * @param {number} pts
   * @return {?shaka.extern.ICaptionDecoder.ClosedCaption}
   * @private
   */
  clearWindows_(windowsBitmap, pts) {
    let parsedClosedCaption = null;

    // Clears windows from the 8 bit bitmap.
    for (const windowId of this.getSpecifiedWindowIds_(windowsBitmap)) {
      // If window visible and being cleared, emit buffer and reset start time!
      const window = this.windows_[windowId];
      if (window.isVisible()) {
        parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_);
      }
      window.resetMemory();
    }
    return parsedClosedCaption;
  }

  /**
   * @param {number} windowsBitmap
   * @param {number} pts
   * @private
   */
  displayWindows_(windowsBitmap, pts) {
    // Displays windows from the 8 bit bitmap.
    for (const windowId of this.getSpecifiedWindowIds_(windowsBitmap)) {
      const window = this.windows_[windowId];
      if (!window.isVisible()) {
        // We are turning on the visibility, set the start time.
        window.setStartTime(pts);
      }
      window.display();
    }
  }

  /**
   * @param {number} windowsBitmap
   * @param {number} pts
   * @return {?shaka.extern.ICaptionDecoder.ClosedCaption}
   * @private
   */
  hideWindows_(windowsBitmap, pts) {
    let parsedClosedCaption = null;

    // Hides windows from the 8 bit bitmap.
    for (const windowId of this.getSpecifiedWindowIds_(windowsBitmap)) {
      const window = this.windows_[windowId];
      if (window.isVisible()) {
        // We are turning off the visibility, emit!
        parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_);
      }
      window.hide();
    }
    return parsedClosedCaption;
  }

  /**
   * @param {number} windowsBitmap
   * @param {number} pts
   * @return {?shaka.extern.ICaptionDecoder.ClosedCaption}
   * @private
   */
  toggleWindows_(windowsBitmap, pts) {
    let parsedClosedCaption = null;

    // Toggles windows from the 8 bit bitmap.
    for (const windowId of this.getSpecifiedWindowIds_(windowsBitmap)) {
      const window = this.windows_[windowId];
      if (window.isVisible()) {
        // We are turning off the visibility, emit!
        parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_);
      } else {
        // We are turning on visibility, set the start time.
        window.setStartTime(pts);
      }

      window.toggle();
    }
    return parsedClosedCaption;
  }

  /**
   * @param {number} windowsBitmap
   * @param {number} pts
   * @return {?shaka.extern.ICaptionDecoder.ClosedCaption}
   * @private
   */
  deleteWindows_(windowsBitmap, pts) {
    let parsedClosedCaption = null;
    // Deletes windows from the 8 bit bitmap.
    for (const windowId of this.getSpecifiedWindowIds_(windowsBitmap)) {
      const window = this.windows_[windowId];
      if (window.isVisible()) {
        // We are turning off the visibility, emit!
        parsedClosedCaption = window.forceEmit(pts, this.serviceNumber_);
      }
      // Delete the window from the list of windows
      this.windows_[windowId] = null;
    }
    return parsedClosedCaption;
  }

  /**
   * Emits anything currently present in any of the windows, and then
   * deletes all windows, cancels all delays, reinitializes the service.
   * @param {number} pts
   * @return {?shaka.extern.ICaptionDecoder.ClosedCaption}
   * @private
   */
  reset_(pts) {
    const allWindowsBitmap = 0xff; // All windows should be deleted.
    const caption = this.deleteWindows_(allWindowsBitmap, pts);
    this.clear();
    return caption;
  }

  /**
   * Clears the state of the service completely.
   */
  clear() {
    this.currentWindow_ = null;
    this.windows_ = [null, null, null, null, null, null, null, null];
  }

  /**
   * @param {!shaka.cea.DtvccPacket} dtvccPacket
   * @throws {!shaka.util.Error}
   * @private
   */
  setPenAttributes_(dtvccPacket) {
    // Two bytes follow. For the purpose of this decoder, we are only concerned
    // with byte 2, which is of the form |I|U|EDTYP|FNTAG|.

    // I (1 bit): Italics toggle.
    // U (1 bit): Underline toggle.
    // EDTYP (3 bits): Edge type (unused in this decoder).
    // FNTAG (3 bits): Font tag (unused in this decoder).
    // More info at https://en.wikipedia.org/wiki/CEA-708#SetPenAttributes_(0x90_+_2_bytes)

    dtvccPacket.skip(1); // Skip first byte
    const attrByte2 = dtvccPacket.readByte().value;

    if (!this.currentWindow_) {
      return;
    }

    const italics = (attrByte2 & 0x80) > 0;
    const underline = (attrByte2 & 0x40) > 0;

    this.currentWindow_.setPenItalics(italics);
    this.currentWindow_.setPenUnderline(underline);
  }

  /**
   * @param {!shaka.cea.DtvccPacket} dtvccPacket
   * @throws {!shaka.util.Error}
   * @private
   */
  setPenColor_(dtvccPacket) {
    // Read foreground and background properties.
    const foregroundByte = dtvccPacket.readByte().value;
    const backgroundByte = dtvccPacket.readByte().value;
    dtvccPacket.skip(1); // Edge color not supported, skip it.

    if (!this.currentWindow_) {
      return;
    }

    // Byte semantics are described at the following link:
    // https://en.wikipedia.org/wiki/CEA-708#SetPenColor_(0x91_+_3_bytes)

    // Foreground color properties: |FOP|F_R|F_G|F_B|.
    const foregroundBlue = foregroundByte & 0x03;
    const foregroundGreen = (foregroundByte & 0x0c) >> 2;
    const foregroundRed = (foregroundByte & 0x30) >> 4;

    // Background color properties: |BOP|B_R|B_G|B_B|.
    const backgroundBlue = backgroundByte & 0x03;
    const backgroundGreen = (backgroundByte & 0x0c) >> 2;
    const backgroundRed = (backgroundByte & 0x30) >> 4;

    const foregroundColor = this.rgbColorToHex_(
        foregroundRed, foregroundGreen, foregroundBlue);

    const backgroundColor = this.rgbColorToHex_(
        backgroundRed, backgroundGreen, backgroundBlue);

    this.currentWindow_.setPenTextColor(foregroundColor);
    this.currentWindow_.setPenBackgroundColor(backgroundColor);
  }

  /**
   * @param {!shaka.cea.DtvccPacket} dtvccPacket
   * @throws {!shaka.util.Error}
   * @private
   */
  setPenLocation_(dtvccPacket) {
    // Following 2 bytes take the following form:
    // b1 = |0|0|0|0|ROW| and b2 = |0|0|COLUMN|
    const locationByte1 = dtvccPacket.readByte().value;
    const locationByte2 = dtvccPacket.readByte().value;

    if (!this.currentWindow_) {
      return;
    }

    const row = locationByte1 & 0x0f;
    const col = locationByte2 & 0x3f;
    this.currentWindow_.setPenLocation(row, col);
  }

  /**
   * @param {!shaka.cea.DtvccPacket} dtvccPacket
   * @throws {!shaka.util.Error}
   * @private
   */
  setWindowAttributes_(dtvccPacket) {
    // 4 bytes follow, with the following form:
    // Byte 1 contains fill-color information. Unused in this decoder.
    // Byte 2 contains border color information. Unused in this decoder.
    // Byte 3 contains justification information. In this decoder, we only use
    // the last 2 bits, which specifies text justification on the screen.
    // Byte 4 is special effects. Unused in this decoder.
    // More info at https://en.wikipedia.org/wiki/CEA-708#SetWindowAttributes_(0x97_+_4_bytes)
    dtvccPacket.skip(1); // Fill color not supported, skip.
    dtvccPacket.skip(1); // Border colors not supported, skip.
    const b3 = dtvccPacket.readByte().value;
    dtvccPacket.skip(1); // Effects not supported, skip.

    if (!this.currentWindow_) {
      return;
    }

    // Word wrap is outdated as of CEA-708-E, so we ignore those bits.
    // Extract the text justification and set it on the window.
    const justification =
      /** @type {!shaka.cea.Cea708Window.TextJustification} */ (b3 & 0x03);
    this.currentWindow_.setJustification(justification);
  }

  /**
   * @param {!shaka.cea.DtvccPacket} dtvccPacket
   * @param {number} windowNum
   * @param {number} pts
   * @throws {!shaka.util.Error}
   * @private
   */
  defineWindow_(dtvccPacket, windowNum, pts) {
    // Create the window if it doesn't exist.
    const windowAlreadyExists = this.windows_[windowNum] !== null;
    if (!windowAlreadyExists) {
      const window = new shaka.cea.Cea708Window(windowNum);
      window.setStartTime(pts);
      this.windows_[windowNum] = window;
    }

    // 6 Bytes follow, with the following form:
    // b1 = |0|0|V|R|C|PRIOR| , b2 = |P|VERT_ANCHOR| , b3 = |HOR_ANCHOR|
    // b4 = |ANC_ID|ROW_CNT| , b5 = |0|0|COL_COUNT| , b6 = |0|0|WNSTY|PNSTY|
    // Semantics of these bytes at https://en.wikipedia.org/wiki/CEA-708#DefineWindow07_(0x98-0x9F,_+_6_bytes)
    const b1 = dtvccPacket.readByte().value;
    const b2 = dtvccPacket.readByte().value;
    const b3 = dtvccPacket.readByte().value;
    const b4 = dtvccPacket.readByte().value;
    const b5 = dtvccPacket.readByte().value;
    const b6 = dtvccPacket.readByte().value;

    // As per 8.4.7 of CEA-708-E, row locks and column locks are to be ignored.
    // So this decoder will ignore these values.

    const visible = (b1 & 0x20) > 0;
    const verticalAnchor = b2 & 0x7f;
    const relativeToggle = (b2 & 0x80) > 0;
    const horAnchor = b3;
    const rowCount = (b4 & 0x0f) + 1; // Spec says to add 1.
    const anchorId = (b4 & 0xf0) >> 4;
    const colCount = (b5 & 0x3f) + 1; // Spec says to add 1.

    // If pen style = 0 AND window previously existed, keep its pen style.
    // Otherwise, change the pen style (For now, just reset to the default pen).
    // TODO add support for predefined pen styles and fonts.
    const penStyle = b6 & 0x07;
    if (!windowAlreadyExists || penStyle !== 0) {
      this.windows_[windowNum].resetPen();
    }

    this.windows_[windowNum].defineWindow(visible, verticalAnchor,
        horAnchor, anchorId, relativeToggle, rowCount, colCount);

    // Set the current window to the newly defined window.
    this.currentWindow_ = this.windows_[windowNum];
  }

  /**
   * Maps 64 possible CEA-708 colors to 8 CSS colors.
   * @param {number} red value from 0-3
   * @param {number} green value from 0-3
   * @param {number} blue value from 0-3
   * @return {string}
   * @private
   */
  rgbColorToHex_(red, green, blue) {
    // Rather than supporting 64 colors, this decoder supports 8 colors and
    // gets the closest color, as per 9.19 of CEA-708-E. This is because some
    // colors on television such as white, are often sent with lower intensity
    // and often appear dull/greyish on the browser, making them hard to read.

    // As per CEA-708-E 9.19, these mappings will map 64 colors to 8 colors.
    const colorMapping = {0: 0, 1: 0, 2: 1, 3: 1};
    red = colorMapping[red];
    green = colorMapping[green];
    blue = colorMapping[blue];

    const colorCode = (red << 2) | (green << 1) | blue;
    return shaka.cea.Cea708Service.Colors[colorCode];
  }
};

/**
 * @private @const {number}
 */
shaka.cea.Cea708Service.ASCII_BACKSPACE = 0x08;

/**
 * @private @const {number}
 */
shaka.cea.Cea708Service.ASCII_FORM_FEED = 0x0c;

/**
 * @private @const {number}
 */
shaka.cea.Cea708Service.ASCII_CARRIAGE_RETURN = 0x0d;

/**
 * @private @const {number}
 */
shaka.cea.Cea708Service.ASCII_HOR_CARRIAGE_RETURN = 0x0e;

/**
 * For extended control codes in block_data on CEA-708, byte 1 is 0x10.
 * @private @const {number}
 */
shaka.cea.Cea708Service.EXT_CEA708_CTRL_CODE_BYTE1 = 0x10;

/**
 * Holds characters mapping for bytes that are G2 control codes.
 * @private @const {!Map<number, string>}
 */
shaka.cea.Cea708Service.G2Charset = new Map([
  [0x20, ' '], [0x21, '\xa0'], [0x25, '…'], [0x2a, 'Š'], [0x2c, 'Œ'],
  [0x30, '█'], [0x31, '‘'], [0x32, '’'], [0x33, '“'], [0x34, '”'],
  [0x35, '•'], [0x39, '™'], [0x3a, 'š'], [0x3c, 'œ'], [0x3d, '℠'],
  [0x3f, 'Ÿ'], [0x76, '⅛'], [0x77, '⅜'], [0x78, '⅝'], [0x79, '⅞'],
  [0x7a, '│'], [0x7b, '┐'], [0x7c, '└'], [0x7d, '─'], [0x7e, '┘'], [0x7f, '┌'],
]);

/**
 * An array of 8 colors that 64 colors can be quantized to. Order here matters.
 * @private @const {!Array<string>}
 */
shaka.cea.Cea708Service.Colors = [
  'black', 'blue', 'green', 'cyan',
  'red', 'magenta', 'yellow', 'white',
];

/**
 * CEA-708 closed captions byte.
 * @typedef {{
 *   pts: number,
 *   type: number,
 *   value: number,
 *   order: number
 * }}
 *
 * @property {number} pts
 *   Presentation timestamp (in second) at which this packet was received.
 * @property {number} type
 *   Type of the byte. Either 2 or 3, DTVCC Packet Data or a DTVCC Packet Start.
 * @property {number} value The byte containing data relevant to the packet.
 * @property {number} order
 *   A number indicating the order this packet was received in a sequence
 *   of packets. Used to break ties in a stable sorting algorithm
 */
shaka.cea.Cea708Service.Cea708Byte;