Home Reference Source

src/utils/cea-608-parser.ts

import OutputFilter from './output-filter';
import { logger } from '../utils/logger';

/**
 *
 * This code was ported from the dash.js project at:
 *   https://github.com/Dash-Industry-Forum/dash.js/blob/development/externals/cea608-parser.js
 *   https://github.com/Dash-Industry-Forum/dash.js/commit/8269b26a761e0853bb21d78780ed945144ecdd4d#diff-71bc295a2d6b6b7093a1d3290d53a4b2
 *
 * The original copyright appears below:
 *
 * The copyright in this software is being made available under the BSD License,
 * included below. This software may be subject to other third party and contributor
 * rights, including patent rights, and no such rights are granted under this license.
 *
 * Copyright (c) 2015-2016, DASH Industry Forum.
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification,
 * are permitted provided that the following conditions are met:
 *  1. Redistributions of source code must retain the above copyright notice, this
 *  list of conditions and the following disclaimer.
 *  * Redistributions in binary form must reproduce the above copyright notice,
 *  this list of conditions and the following disclaimer in the documentation and/or
 *  other materials provided with the distribution.
 *  2. Neither the name of Dash Industry Forum nor the names of its
 *  contributors may be used to endorse or promote products derived from this software
 *  without specific prior written permission.
 *
 *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
 *  EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 *  WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
 *  IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
 *  INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
 *  NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 *  PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
 *  WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 *  POSSIBILITY OF SUCH DAMAGE.
 */
/**
 *  Exceptions from regular ASCII. CodePoints are mapped to UTF-16 codes
 */

const specialCea608CharsCodes = {
  0x2a: 0xe1, // lowercase a, acute accent
  0x5c: 0xe9, // lowercase e, acute accent
  0x5e: 0xed, // lowercase i, acute accent
  0x5f: 0xf3, // lowercase o, acute accent
  0x60: 0xfa, // lowercase u, acute accent
  0x7b: 0xe7, // lowercase c with cedilla
  0x7c: 0xf7, // division symbol
  0x7d: 0xd1, // uppercase N tilde
  0x7e: 0xf1, // lowercase n tilde
  0x7f: 0x2588, // Full block
  // THIS BLOCK INCLUDES THE 16 EXTENDED (TWO-BYTE) LINE 21 CHARACTERS
  // THAT COME FROM HI BYTE=0x11 AND LOW BETWEEN 0x30 AND 0x3F
  // THIS MEANS THAT \x50 MUST BE ADDED TO THE VALUES
  0x80: 0xae, // Registered symbol (R)
  0x81: 0xb0, // degree sign
  0x82: 0xbd, // 1/2 symbol
  0x83: 0xbf, // Inverted (open) question mark
  0x84: 0x2122, // Trademark symbol (TM)
  0x85: 0xa2, // Cents symbol
  0x86: 0xa3, // Pounds sterling
  0x87: 0x266a, // Music 8'th note
  0x88: 0xe0, // lowercase a, grave accent
  0x89: 0x20, // transparent space (regular)
  0x8a: 0xe8, // lowercase e, grave accent
  0x8b: 0xe2, // lowercase a, circumflex accent
  0x8c: 0xea, // lowercase e, circumflex accent
  0x8d: 0xee, // lowercase i, circumflex accent
  0x8e: 0xf4, // lowercase o, circumflex accent
  0x8f: 0xfb, // lowercase u, circumflex accent
  // THIS BLOCK INCLUDES THE 32 EXTENDED (TWO-BYTE) LINE 21 CHARACTERS
  // THAT COME FROM HI BYTE=0x12 AND LOW BETWEEN 0x20 AND 0x3F
  0x90: 0xc1, // capital letter A with acute
  0x91: 0xc9, // capital letter E with acute
  0x92: 0xd3, // capital letter O with acute
  0x93: 0xda, // capital letter U with acute
  0x94: 0xdc, // capital letter U with diaresis
  0x95: 0xfc, // lowercase letter U with diaeresis
  0x96: 0x2018, // opening single quote
  0x97: 0xa1, // inverted exclamation mark
  0x98: 0x2a, // asterisk
  0x99: 0x2019, // closing single quote
  0x9a: 0x2501, // box drawings heavy horizontal
  0x9b: 0xa9, // copyright sign
  0x9c: 0x2120, // Service mark
  0x9d: 0x2022, // (round) bullet
  0x9e: 0x201c, // Left double quotation mark
  0x9f: 0x201d, // Right double quotation mark
  0xa0: 0xc0, // uppercase A, grave accent
  0xa1: 0xc2, // uppercase A, circumflex
  0xa2: 0xc7, // uppercase C with cedilla
  0xa3: 0xc8, // uppercase E, grave accent
  0xa4: 0xca, // uppercase E, circumflex
  0xa5: 0xcb, // capital letter E with diaresis
  0xa6: 0xeb, // lowercase letter e with diaresis
  0xa7: 0xce, // uppercase I, circumflex
  0xa8: 0xcf, // uppercase I, with diaresis
  0xa9: 0xef, // lowercase i, with diaresis
  0xaa: 0xd4, // uppercase O, circumflex
  0xab: 0xd9, // uppercase U, grave accent
  0xac: 0xf9, // lowercase u, grave accent
  0xad: 0xdb, // uppercase U, circumflex
  0xae: 0xab, // left-pointing double angle quotation mark
  0xaf: 0xbb, // right-pointing double angle quotation mark
  // THIS BLOCK INCLUDES THE 32 EXTENDED (TWO-BYTE) LINE 21 CHARACTERS
  // THAT COME FROM HI BYTE=0x13 AND LOW BETWEEN 0x20 AND 0x3F
  0xb0: 0xc3, // Uppercase A, tilde
  0xb1: 0xe3, // Lowercase a, tilde
  0xb2: 0xcd, // Uppercase I, acute accent
  0xb3: 0xcc, // Uppercase I, grave accent
  0xb4: 0xec, // Lowercase i, grave accent
  0xb5: 0xd2, // Uppercase O, grave accent
  0xb6: 0xf2, // Lowercase o, grave accent
  0xb7: 0xd5, // Uppercase O, tilde
  0xb8: 0xf5, // Lowercase o, tilde
  0xb9: 0x7b, // Open curly brace
  0xba: 0x7d, // Closing curly brace
  0xbb: 0x5c, // Backslash
  0xbc: 0x5e, // Caret
  0xbd: 0x5f, // Underscore
  0xbe: 0x7c, // Pipe (vertical line)
  0xbf: 0x223c, // Tilde operator
  0xc0: 0xc4, // Uppercase A, umlaut
  0xc1: 0xe4, // Lowercase A, umlaut
  0xc2: 0xd6, // Uppercase O, umlaut
  0xc3: 0xf6, // Lowercase o, umlaut
  0xc4: 0xdf, // Esszett (sharp S)
  0xc5: 0xa5, // Yen symbol
  0xc6: 0xa4, // Generic currency sign
  0xc7: 0x2503, // Box drawings heavy vertical
  0xc8: 0xc5, // Uppercase A, ring
  0xc9: 0xe5, // Lowercase A, ring
  0xca: 0xd8, // Uppercase O, stroke
  0xcb: 0xf8, // Lowercase o, strok
  0xcc: 0x250f, // Box drawings heavy down and right
  0xcd: 0x2513, // Box drawings heavy down and left
  0xce: 0x2517, // Box drawings heavy up and right
  0xcf: 0x251b, // Box drawings heavy up and left
};

/**
 * Utils
 */
const getCharForByte = function (byte: number) {
  let charCode = byte;
  if (specialCea608CharsCodes.hasOwnProperty(byte)) {
    charCode = specialCea608CharsCodes[byte];
  }

  return String.fromCharCode(charCode);
};

const NR_ROWS = 15;
const NR_COLS = 100;
// Tables to look up row from PAC data
const rowsLowCh1 = {
  0x11: 1,
  0x12: 3,
  0x15: 5,
  0x16: 7,
  0x17: 9,
  0x10: 11,
  0x13: 12,
  0x14: 14,
};
const rowsHighCh1 = {
  0x11: 2,
  0x12: 4,
  0x15: 6,
  0x16: 8,
  0x17: 10,
  0x13: 13,
  0x14: 15,
};
const rowsLowCh2 = {
  0x19: 1,
  0x1a: 3,
  0x1d: 5,
  0x1e: 7,
  0x1f: 9,
  0x18: 11,
  0x1b: 12,
  0x1c: 14,
};
const rowsHighCh2 = {
  0x19: 2,
  0x1a: 4,
  0x1d: 6,
  0x1e: 8,
  0x1f: 10,
  0x1b: 13,
  0x1c: 15,
};

const backgroundColors = [
  'white',
  'green',
  'blue',
  'cyan',
  'red',
  'yellow',
  'magenta',
  'black',
  'transparent',
];

enum VerboseLevel {
  ERROR = 0,
  TEXT = 1,
  WARNING = 2,
  INFO = 2,
  DEBUG = 3,
  DATA = 3,
}

class CaptionsLogger {
  public time: number | null = null;
  public verboseLevel: VerboseLevel = VerboseLevel.ERROR;

  log(severity: VerboseLevel, msg: string): void {
    if (this.verboseLevel >= severity) {
      logger.log(`${this.time} [${severity}] ${msg}`);
    }
  }
}

const numArrayToHexArray = function (numArray: number[]): string[] {
  const hexArray: string[] = [];
  for (let j = 0; j < numArray.length; j++) {
    hexArray.push(numArray[j].toString(16));
  }

  return hexArray;
};

type PenStyles = {
  foreground: string | null;
  underline: boolean;
  italics: boolean;
  background: string;
  flash: boolean;
};

class PenState {
  public foreground: string;
  public underline: boolean;
  public italics: boolean;
  public background: string;
  public flash: boolean;

  constructor(
    foreground?: string,
    underline?: boolean,
    italics?: boolean,
    background?: string,
    flash?: boolean
  ) {
    this.foreground = foreground || 'white';
    this.underline = underline || false;
    this.italics = italics || false;
    this.background = background || 'black';
    this.flash = flash || false;
  }

  reset() {
    this.foreground = 'white';
    this.underline = false;
    this.italics = false;
    this.background = 'black';
    this.flash = false;
  }

  setStyles(styles: Partial<PenStyles>) {
    const attribs = [
      'foreground',
      'underline',
      'italics',
      'background',
      'flash',
    ];
    for (let i = 0; i < attribs.length; i++) {
      const style = attribs[i];
      if (styles.hasOwnProperty(style)) {
        this[style] = styles[style];
      }
    }
  }

  isDefault() {
    return (
      this.foreground === 'white' &&
      !this.underline &&
      !this.italics &&
      this.background === 'black' &&
      !this.flash
    );
  }

  equals(other: PenState) {
    return (
      this.foreground === other.foreground &&
      this.underline === other.underline &&
      this.italics === other.italics &&
      this.background === other.background &&
      this.flash === other.flash
    );
  }

  copy(newPenState: PenState) {
    this.foreground = newPenState.foreground;
    this.underline = newPenState.underline;
    this.italics = newPenState.italics;
    this.background = newPenState.background;
    this.flash = newPenState.flash;
  }

  toString(): string {
    return (
      'color=' +
      this.foreground +
      ', underline=' +
      this.underline +
      ', italics=' +
      this.italics +
      ', background=' +
      this.background +
      ', flash=' +
      this.flash
    );
  }
}

/**
 * Unicode character with styling and background.
 * @constructor
 */
class StyledUnicodeChar {
  uchar: string;
  penState: PenState;

  constructor(
    uchar?: string,
    foreground?: string,
    underline?: boolean,
    italics?: boolean,
    background?: string,
    flash?: boolean
  ) {
    this.uchar = uchar || ' '; // unicode character
    this.penState = new PenState(
      foreground,
      underline,
      italics,
      background,
      flash
    );
  }

  reset() {
    this.uchar = ' ';
    this.penState.reset();
  }

  setChar(uchar: string, newPenState: PenState) {
    this.uchar = uchar;
    this.penState.copy(newPenState);
  }

  setPenState(newPenState: PenState) {
    this.penState.copy(newPenState);
  }

  equals(other: StyledUnicodeChar) {
    return this.uchar === other.uchar && this.penState.equals(other.penState);
  }

  copy(newChar: StyledUnicodeChar) {
    this.uchar = newChar.uchar;
    this.penState.copy(newChar.penState);
  }

  isEmpty(): boolean {
    return this.uchar === ' ' && this.penState.isDefault();
  }
}

/**
 * CEA-608 row consisting of NR_COLS instances of StyledUnicodeChar.
 * @constructor
 */
export class Row {
  public chars: StyledUnicodeChar[];
  public pos: number;
  public currPenState: PenState;
  public cueStartTime?: number;
  logger: CaptionsLogger;

  constructor(logger: CaptionsLogger) {
    this.chars = [];
    for (let i = 0; i < NR_COLS; i++) {
      this.chars.push(new StyledUnicodeChar());
    }

    this.logger = logger;
    this.pos = 0;
    this.currPenState = new PenState();
  }

  equals(other: Row) {
    let equal = true;
    for (let i = 0; i < NR_COLS; i++) {
      if (!this.chars[i].equals(other.chars[i])) {
        equal = false;
        break;
      }
    }
    return equal;
  }

  copy(other: Row) {
    for (let i = 0; i < NR_COLS; i++) {
      this.chars[i].copy(other.chars[i]);
    }
  }

  isEmpty(): boolean {
    let empty = true;
    for (let i = 0; i < NR_COLS; i++) {
      if (!this.chars[i].isEmpty()) {
        empty = false;
        break;
      }
    }
    return empty;
  }

  /**
   *  Set the cursor to a valid column.
   */
  setCursor(absPos: number) {
    if (this.pos !== absPos) {
      this.pos = absPos;
    }

    if (this.pos < 0) {
      this.logger.log(
        VerboseLevel.DEBUG,
        'Negative cursor position ' + this.pos
      );
      this.pos = 0;
    } else if (this.pos > NR_COLS) {
      this.logger.log(
        VerboseLevel.DEBUG,
        'Too large cursor position ' + this.pos
      );
      this.pos = NR_COLS;
    }
  }

  /**
   * Move the cursor relative to current position.
   */
  moveCursor(relPos: number) {
    const newPos = this.pos + relPos;
    if (relPos > 1) {
      for (let i = this.pos + 1; i < newPos + 1; i++) {
        this.chars[i].setPenState(this.currPenState);
      }
    }
    this.setCursor(newPos);
  }

  /**
   * Backspace, move one step back and clear character.
   */
  backSpace() {
    this.moveCursor(-1);
    this.chars[this.pos].setChar(' ', this.currPenState);
  }

  insertChar(byte: number) {
    if (byte >= 0x90) {
      // Extended char
      this.backSpace();
    }
    const char = getCharForByte(byte);
    if (this.pos >= NR_COLS) {
      this.logger.log(
        VerboseLevel.ERROR,
        'Cannot insert ' +
          byte.toString(16) +
          ' (' +
          char +
          ') at position ' +
          this.pos +
          '. Skipping it!'
      );
      return;
    }
    this.chars[this.pos].setChar(char, this.currPenState);
    this.moveCursor(1);
  }

  clearFromPos(startPos: number) {
    let i: number;
    for (i = startPos; i < NR_COLS; i++) {
      this.chars[i].reset();
    }
  }

  clear() {
    this.clearFromPos(0);
    this.pos = 0;
    this.currPenState.reset();
  }

  clearToEndOfRow() {
    this.clearFromPos(this.pos);
  }

  getTextString() {
    const chars: string[] = [];
    let empty = true;
    for (let i = 0; i < NR_COLS; i++) {
      const char = this.chars[i].uchar;
      if (char !== ' ') {
        empty = false;
      }

      chars.push(char);
    }
    if (empty) {
      return '';
    } else {
      return chars.join('');
    }
  }

  setPenStyles(styles: Partial<PenStyles>) {
    this.currPenState.setStyles(styles);
    const currChar = this.chars[this.pos];
    currChar.setPenState(this.currPenState);
  }
}

/**
 * Keep a CEA-608 screen of 32x15 styled characters
 * @constructor
 */
export class CaptionScreen {
  rows: Row[];
  currRow: number;
  nrRollUpRows: number | null;
  lastOutputScreen: CaptionScreen | null;
  logger: CaptionsLogger;

  constructor(logger: CaptionsLogger) {
    this.rows = [];
    for (let i = 0; i < NR_ROWS; i++) {
      this.rows.push(new Row(logger));
    } // Note that we use zero-based numbering (0-14)

    this.logger = logger;
    this.currRow = NR_ROWS - 1;
    this.nrRollUpRows = null;
    this.lastOutputScreen = null;
    this.reset();
  }

  reset() {
    for (let i = 0; i < NR_ROWS; i++) {
      this.rows[i].clear();
    }

    this.currRow = NR_ROWS - 1;
  }

  equals(other: CaptionScreen): boolean {
    let equal = true;
    for (let i = 0; i < NR_ROWS; i++) {
      if (!this.rows[i].equals(other.rows[i])) {
        equal = false;
        break;
      }
    }
    return equal;
  }

  copy(other: CaptionScreen) {
    for (let i = 0; i < NR_ROWS; i++) {
      this.rows[i].copy(other.rows[i]);
    }
  }

  isEmpty(): boolean {
    let empty = true;
    for (let i = 0; i < NR_ROWS; i++) {
      if (!this.rows[i].isEmpty()) {
        empty = false;
        break;
      }
    }
    return empty;
  }

  backSpace() {
    const row = this.rows[this.currRow];
    row.backSpace();
  }

  clearToEndOfRow() {
    const row = this.rows[this.currRow];
    row.clearToEndOfRow();
  }

  /**
   * Insert a character (without styling) in the current row.
   */
  insertChar(char: number) {
    const row = this.rows[this.currRow];
    row.insertChar(char);
  }

  setPen(styles: Partial<PenStyles>) {
    const row = this.rows[this.currRow];
    row.setPenStyles(styles);
  }

  moveCursor(relPos: number) {
    const row = this.rows[this.currRow];
    row.moveCursor(relPos);
  }

  setCursor(absPos: number) {
    this.logger.log(VerboseLevel.INFO, 'setCursor: ' + absPos);
    const row = this.rows[this.currRow];
    row.setCursor(absPos);
  }

  setPAC(pacData: PACData) {
    this.logger.log(VerboseLevel.INFO, 'pacData = ' + JSON.stringify(pacData));
    let newRow = pacData.row - 1;
    if (this.nrRollUpRows && newRow < this.nrRollUpRows - 1) {
      newRow = this.nrRollUpRows - 1;
    }

    // Make sure this only affects Roll-up Captions by checking this.nrRollUpRows
    if (this.nrRollUpRows && this.currRow !== newRow) {
      // clear all rows first
      for (let i = 0; i < NR_ROWS; i++) {
        this.rows[i].clear();
      }

      // Copy this.nrRollUpRows rows from lastOutputScreen and place it in the newRow location
      // topRowIndex - the start of rows to copy (inclusive index)
      const topRowIndex = this.currRow + 1 - this.nrRollUpRows;
      // We only copy if the last position was already shown.
      // We use the cueStartTime value to check this.
      const lastOutputScreen = this.lastOutputScreen;
      if (lastOutputScreen) {
        const prevLineTime = lastOutputScreen.rows[topRowIndex].cueStartTime;
        const time = this.logger.time;
        if (prevLineTime && time !== null && prevLineTime < time) {
          for (let i = 0; i < this.nrRollUpRows; i++) {
            this.rows[newRow - this.nrRollUpRows + i + 1].copy(
              lastOutputScreen.rows[topRowIndex + i]
            );
          }
        }
      }
    }

    this.currRow = newRow;
    const row = this.rows[this.currRow];
    if (pacData.indent !== null) {
      const indent = pacData.indent;
      const prevPos = Math.max(indent - 1, 0);
      row.setCursor(pacData.indent);
      pacData.color = row.chars[prevPos].penState.foreground;
    }
    const styles: PenStyles = {
      foreground: pacData.color,
      underline: pacData.underline,
      italics: pacData.italics,
      background: 'black',
      flash: false,
    };
    this.setPen(styles);
  }

  /**
   * Set background/extra foreground, but first do back_space, and then insert space (backwards compatibility).
   */
  setBkgData(bkgData: Partial<PenStyles>) {
    this.logger.log(VerboseLevel.INFO, 'bkgData = ' + JSON.stringify(bkgData));
    this.backSpace();
    this.setPen(bkgData);
    this.insertChar(0x20); // Space
  }

  setRollUpRows(nrRows: number | null) {
    this.nrRollUpRows = nrRows;
  }

  rollUp() {
    if (this.nrRollUpRows === null) {
      this.logger.log(
        VerboseLevel.DEBUG,
        'roll_up but nrRollUpRows not set yet'
      );
      return; // Not properly setup
    }
    this.logger.log(VerboseLevel.TEXT, this.getDisplayText());
    const topRowIndex = this.currRow + 1 - this.nrRollUpRows;
    const topRow = this.rows.splice(topRowIndex, 1)[0];
    topRow.clear();
    this.rows.splice(this.currRow, 0, topRow);
    this.logger.log(VerboseLevel.INFO, 'Rolling up');
    // this.logger.log(VerboseLevel.TEXT, this.get_display_text())
  }

  /**
   * Get all non-empty rows with as unicode text.
   */
  getDisplayText(asOneRow?: boolean) {
    asOneRow = asOneRow || false;
    const displayText: string[] = [];
    let text = '';
    let rowNr = -1;
    for (let i = 0; i < NR_ROWS; i++) {
      const rowText = this.rows[i].getTextString();
      if (rowText) {
        rowNr = i + 1;
        if (asOneRow) {
          displayText.push('Row ' + rowNr + ": '" + rowText + "'");
        } else {
          displayText.push(rowText.trim());
        }
      }
    }
    if (displayText.length > 0) {
      if (asOneRow) {
        text = '[' + displayText.join(' | ') + ']';
      } else {
        text = displayText.join('\n');
      }
    }
    return text;
  }

  getTextAndFormat() {
    return this.rows;
  }
}

// var modes = ['MODE_ROLL-UP', 'MODE_POP-ON', 'MODE_PAINT-ON', 'MODE_TEXT'];

type CaptionModes =
  | 'MODE_ROLL-UP'
  | 'MODE_POP-ON'
  | 'MODE_PAINT-ON'
  | 'MODE_TEXT'
  | null;

class Cea608Channel {
  chNr: number;
  outputFilter: OutputFilter;
  mode: CaptionModes;
  verbose: number;
  displayedMemory: CaptionScreen;
  nonDisplayedMemory: CaptionScreen;
  lastOutputScreen: CaptionScreen;
  currRollUpRow: Row;
  writeScreen: CaptionScreen;
  cueStartTime: number | null;
  logger: CaptionsLogger;

  constructor(
    channelNumber: number,
    outputFilter: OutputFilter,
    logger: CaptionsLogger
  ) {
    this.chNr = channelNumber;
    this.outputFilter = outputFilter;
    this.mode = null;
    this.verbose = 0;
    this.displayedMemory = new CaptionScreen(logger);
    this.nonDisplayedMemory = new CaptionScreen(logger);
    this.lastOutputScreen = new CaptionScreen(logger);
    this.currRollUpRow = this.displayedMemory.rows[NR_ROWS - 1];
    this.writeScreen = this.displayedMemory;
    this.mode = null;
    this.cueStartTime = null; // Keeps track of where a cue started.
    this.logger = logger;
  }

  reset() {
    this.mode = null;
    this.displayedMemory.reset();
    this.nonDisplayedMemory.reset();
    this.lastOutputScreen.reset();
    this.outputFilter.reset();
    this.currRollUpRow = this.displayedMemory.rows[NR_ROWS - 1];
    this.writeScreen = this.displayedMemory;
    this.mode = null;
    this.cueStartTime = null;
  }

  getHandler(): OutputFilter {
    return this.outputFilter;
  }

  setHandler(newHandler: OutputFilter) {
    this.outputFilter = newHandler;
  }

  setPAC(pacData: PACData) {
    this.writeScreen.setPAC(pacData);
  }

  setBkgData(bkgData: Partial<PenStyles>) {
    this.writeScreen.setBkgData(bkgData);
  }

  setMode(newMode: CaptionModes) {
    if (newMode === this.mode) {
      return;
    }

    this.mode = newMode;
    this.logger.log(VerboseLevel.INFO, 'MODE=' + newMode);
    if (this.mode === 'MODE_POP-ON') {
      this.writeScreen = this.nonDisplayedMemory;
    } else {
      this.writeScreen = this.displayedMemory;
      this.writeScreen.reset();
    }
    if (this.mode !== 'MODE_ROLL-UP') {
      this.displayedMemory.nrRollUpRows = null;
      this.nonDisplayedMemory.nrRollUpRows = null;
    }
    this.mode = newMode;
  }

  insertChars(chars: number[]) {
    for (let i = 0; i < chars.length; i++) {
      this.writeScreen.insertChar(chars[i]);
    }

    const screen =
      this.writeScreen === this.displayedMemory ? 'DISP' : 'NON_DISP';
    this.logger.log(
      VerboseLevel.INFO,
      screen + ': ' + this.writeScreen.getDisplayText(true)
    );
    if (this.mode === 'MODE_PAINT-ON' || this.mode === 'MODE_ROLL-UP') {
      this.logger.log(
        VerboseLevel.TEXT,
        'DISPLAYED: ' + this.displayedMemory.getDisplayText(true)
      );
      this.outputDataUpdate();
    }
  }

  ccRCL() {
    // Resume Caption Loading (switch mode to Pop On)
    this.logger.log(VerboseLevel.INFO, 'RCL - Resume Caption Loading');
    this.setMode('MODE_POP-ON');
  }

  ccBS() {
    // BackSpace
    this.logger.log(VerboseLevel.INFO, 'BS - BackSpace');
    if (this.mode === 'MODE_TEXT') {
      return;
    }

    this.writeScreen.backSpace();
    if (this.writeScreen === this.displayedMemory) {
      this.outputDataUpdate();
    }
  }

  ccAOF() {
    // Reserved (formerly Alarm Off)
  }

  ccAON() {
    // Reserved (formerly Alarm On)
  }

  ccDER() {
    // Delete to End of Row
    this.logger.log(VerboseLevel.INFO, 'DER- Delete to End of Row');
    this.writeScreen.clearToEndOfRow();
    this.outputDataUpdate();
  }

  ccRU(nrRows: number | null) {
    // Roll-Up Captions-2,3,or 4 Rows
    this.logger.log(VerboseLevel.INFO, 'RU(' + nrRows + ') - Roll Up');
    this.writeScreen = this.displayedMemory;
    this.setMode('MODE_ROLL-UP');
    this.writeScreen.setRollUpRows(nrRows);
  }

  ccFON() {
    // Flash On
    this.logger.log(VerboseLevel.INFO, 'FON - Flash On');
    this.writeScreen.setPen({ flash: true });
  }

  ccRDC() {
    // Resume Direct Captioning (switch mode to PaintOn)
    this.logger.log(VerboseLevel.INFO, 'RDC - Resume Direct Captioning');
    this.setMode('MODE_PAINT-ON');
  }

  ccTR() {
    // Text Restart in text mode (not supported, however)
    this.logger.log(VerboseLevel.INFO, 'TR');
    this.setMode('MODE_TEXT');
  }

  ccRTD() {
    // Resume Text Display in Text mode (not supported, however)
    this.logger.log(VerboseLevel.INFO, 'RTD');
    this.setMode('MODE_TEXT');
  }

  ccEDM() {
    // Erase Displayed Memory
    this.logger.log(VerboseLevel.INFO, 'EDM - Erase Displayed Memory');
    this.displayedMemory.reset();
    this.outputDataUpdate(true);
  }

  ccCR() {
    // Carriage Return
    this.logger.log(VerboseLevel.INFO, 'CR - Carriage Return');
    this.writeScreen.rollUp();
    this.outputDataUpdate(true);
  }

  ccENM() {
    // Erase Non-Displayed Memory
    this.logger.log(VerboseLevel.INFO, 'ENM - Erase Non-displayed Memory');
    this.nonDisplayedMemory.reset();
  }

  ccEOC() {
    // End of Caption (Flip Memories)
    this.logger.log(VerboseLevel.INFO, 'EOC - End Of Caption');
    if (this.mode === 'MODE_POP-ON') {
      const tmp = this.displayedMemory;
      this.displayedMemory = this.nonDisplayedMemory;
      this.nonDisplayedMemory = tmp;
      this.writeScreen = this.nonDisplayedMemory;
      this.logger.log(
        VerboseLevel.TEXT,
        'DISP: ' + this.displayedMemory.getDisplayText()
      );
    }
    this.outputDataUpdate(true);
  }

  ccTO(nrCols: number) {
    // Tab Offset 1,2, or 3 columns
    this.logger.log(VerboseLevel.INFO, 'TO(' + nrCols + ') - Tab Offset');
    this.writeScreen.moveCursor(nrCols);
  }

  ccMIDROW(secondByte: number) {
    // Parse MIDROW command
    const styles: Partial<PenStyles> = { flash: false };
    styles.underline = secondByte % 2 === 1;
    styles.italics = secondByte >= 0x2e;
    if (!styles.italics) {
      const colorIndex = Math.floor(secondByte / 2) - 0x10;
      const colors = [
        'white',
        'green',
        'blue',
        'cyan',
        'red',
        'yellow',
        'magenta',
      ];
      styles.foreground = colors[colorIndex];
    } else {
      styles.foreground = 'white';
    }
    this.logger.log(VerboseLevel.INFO, 'MIDROW: ' + JSON.stringify(styles));
    this.writeScreen.setPen(styles);
  }

  outputDataUpdate(dispatch: boolean = false) {
    const time = this.logger.time;
    if (time === null) {
      return;
    }

    if (this.outputFilter) {
      if (this.cueStartTime === null && !this.displayedMemory.isEmpty()) {
        // Start of a new cue
        this.cueStartTime = time;
      } else {
        if (!this.displayedMemory.equals(this.lastOutputScreen)) {
          this.outputFilter.newCue(
            this.cueStartTime!,
            time,
            this.lastOutputScreen
          );
          if (dispatch && this.outputFilter.dispatchCue) {
            this.outputFilter.dispatchCue();
          }

          this.cueStartTime = this.displayedMemory.isEmpty() ? null : time;
        }
      }
      this.lastOutputScreen.copy(this.displayedMemory);
    }
  }

  cueSplitAtTime(t: number) {
    if (this.outputFilter) {
      if (!this.displayedMemory.isEmpty()) {
        if (this.outputFilter.newCue) {
          this.outputFilter.newCue(this.cueStartTime!, t, this.displayedMemory);
        }

        this.cueStartTime = t;
      }
    }
  }
}

interface PACData {
  row: number;
  indent: number | null;
  color: string | null;
  underline: boolean;
  italics: boolean;
}

type SupportedField = 1 | 3;

type Channels = 0 | 1 | 2; // Will be 1 or 2 when parsing captions

type CmdHistory = {
  a: number | null;
  b: number | null;
};

class Cea608Parser {
  channels: Array<Cea608Channel | null>;
  currentChannel: Channels = 0;
  cmdHistory: CmdHistory;
  logger: CaptionsLogger;

  constructor(field: SupportedField, out1: OutputFilter, out2: OutputFilter) {
    const logger = new CaptionsLogger();
    this.channels = [
      null,
      new Cea608Channel(field, out1, logger),
      new Cea608Channel(field + 1, out2, logger),
    ];
    this.cmdHistory = createCmdHistory();
    this.logger = logger;
  }

  getHandler(channel: number) {
    return (this.channels[channel] as Cea608Channel).getHandler();
  }

  setHandler(channel: number, newHandler: OutputFilter) {
    (this.channels[channel] as Cea608Channel).setHandler(newHandler);
  }

  /**
   * Add data for time t in forms of list of bytes (unsigned ints). The bytes are treated as pairs.
   */
  addData(time: number | null, byteList: number[]) {
    let cmdFound: boolean;
    let a: number;
    let b: number;
    let charsFound: number[] | boolean | null = false;

    this.logger.time = time;

    for (let i = 0; i < byteList.length; i += 2) {
      a = byteList[i] & 0x7f;
      b = byteList[i + 1] & 0x7f;
      if (a === 0 && b === 0) {
        continue;
      } else {
        this.logger.log(
          VerboseLevel.DATA,
          '[' +
            numArrayToHexArray([byteList[i], byteList[i + 1]]) +
            '] -> (' +
            numArrayToHexArray([a, b]) +
            ')'
        );
      }

      cmdFound = this.parseCmd(a, b);

      if (!cmdFound) {
        cmdFound = this.parseMidrow(a, b);
      }

      if (!cmdFound) {
        cmdFound = this.parsePAC(a, b);
      }

      if (!cmdFound) {
        cmdFound = this.parseBackgroundAttributes(a, b);
      }

      if (!cmdFound) {
        charsFound = this.parseChars(a, b);
        if (charsFound) {
          const currChNr = this.currentChannel;
          if (currChNr && currChNr > 0) {
            const channel = this.channels[currChNr] as Cea608Channel;
            channel.insertChars(charsFound);
          } else {
            this.logger.log(
              VerboseLevel.WARNING,
              'No channel found yet. TEXT-MODE?'
            );
          }
        }
      }
      if (!cmdFound && !charsFound) {
        this.logger.log(
          VerboseLevel.WARNING,
          "Couldn't parse cleaned data " +
            numArrayToHexArray([a, b]) +
            ' orig: ' +
            numArrayToHexArray([byteList[i], byteList[i + 1]])
        );
      }
    }
  }

  /**
   * Parse Command.
   * @returns {Boolean} Tells if a command was found
   */
  parseCmd(a: number, b: number) {
    const { cmdHistory } = this;
    const cond1 =
      (a === 0x14 || a === 0x1c || a === 0x15 || a === 0x1d) &&
      b >= 0x20 &&
      b <= 0x2f;
    const cond2 = (a === 0x17 || a === 0x1f) && b >= 0x21 && b <= 0x23;
    if (!(cond1 || cond2)) {
      return false;
    }

    if (hasCmdRepeated(a, b, cmdHistory)) {
      setLastCmd(null, null, cmdHistory);
      this.logger.log(
        VerboseLevel.DEBUG,
        'Repeated command (' + numArrayToHexArray([a, b]) + ') is dropped'
      );
      return true;
    }

    const chNr = a === 0x14 || a === 0x15 || a === 0x17 ? 1 : 2;
    const channel = this.channels[chNr] as Cea608Channel;

    if (a === 0x14 || a === 0x15 || a === 0x1c || a === 0x1d) {
      if (b === 0x20) {
        channel.ccRCL();
      } else if (b === 0x21) {
        channel.ccBS();
      } else if (b === 0x22) {
        channel.ccAOF();
      } else if (b === 0x23) {
        channel.ccAON();
      } else if (b === 0x24) {
        channel.ccDER();
      } else if (b === 0x25) {
        channel.ccRU(2);
      } else if (b === 0x26) {
        channel.ccRU(3);
      } else if (b === 0x27) {
        channel.ccRU(4);
      } else if (b === 0x28) {
        channel.ccFON();
      } else if (b === 0x29) {
        channel.ccRDC();
      } else if (b === 0x2a) {
        channel.ccTR();
      } else if (b === 0x2b) {
        channel.ccRTD();
      } else if (b === 0x2c) {
        channel.ccEDM();
      } else if (b === 0x2d) {
        channel.ccCR();
      } else if (b === 0x2e) {
        channel.ccENM();
      } else if (b === 0x2f) {
        channel.ccEOC();
      }
    } else {
      // a == 0x17 || a == 0x1F
      channel.ccTO(b - 0x20);
    }
    setLastCmd(a, b, cmdHistory);
    this.currentChannel = chNr;
    return true;
  }

  /**
   * Parse midrow styling command
   * @returns {Boolean}
   */
  parseMidrow(a: number, b: number) {
    let chNr: number = 0;

    if ((a === 0x11 || a === 0x19) && b >= 0x20 && b <= 0x2f) {
      if (a === 0x11) {
        chNr = 1;
      } else {
        chNr = 2;
      }

      if (chNr !== this.currentChannel) {
        this.logger.log(
          VerboseLevel.ERROR,
          'Mismatch channel in midrow parsing'
        );
        return false;
      }
      const channel = this.channels[chNr];
      if (!channel) {
        return false;
      }
      channel.ccMIDROW(b);
      this.logger.log(
        VerboseLevel.DEBUG,
        'MIDROW (' + numArrayToHexArray([a, b]) + ')'
      );
      return true;
    }
    return false;
  }

  /**
   * Parse Preable Access Codes (Table 53).
   * @returns {Boolean} Tells if PAC found
   */
  parsePAC(a: number, b: number): boolean {
    let row: number;
    const cmdHistory = this.cmdHistory;

    const case1 =
      ((a >= 0x11 && a <= 0x17) || (a >= 0x19 && a <= 0x1f)) &&
      b >= 0x40 &&
      b <= 0x7f;
    const case2 = (a === 0x10 || a === 0x18) && b >= 0x40 && b <= 0x5f;
    if (!(case1 || case2)) {
      return false;
    }

    if (hasCmdRepeated(a, b, cmdHistory)) {
      setLastCmd(null, null, cmdHistory);
      return true; // Repeated commands are dropped (once)
    }

    const chNr: Channels = a <= 0x17 ? 1 : 2;

    if (b >= 0x40 && b <= 0x5f) {
      row = chNr === 1 ? rowsLowCh1[a] : rowsLowCh2[a];
    } else {
      // 0x60 <= b <= 0x7F
      row = chNr === 1 ? rowsHighCh1[a] : rowsHighCh2[a];
    }
    const channel = this.channels[chNr];
    if (!channel) {
      return false;
    }
    channel.setPAC(this.interpretPAC(row, b));
    setLastCmd(a, b, cmdHistory);
    this.currentChannel = chNr;
    return true;
  }

  /**
   * Interpret the second byte of the pac, and return the information.
   * @returns {Object} pacData with style parameters.
   */
  interpretPAC(row: number, byte: number): PACData {
    let pacIndex;
    const pacData: PACData = {
      color: null,
      italics: false,
      indent: null,
      underline: false,
      row: row,
    };

    if (byte > 0x5f) {
      pacIndex = byte - 0x60;
    } else {
      pacIndex = byte - 0x40;
    }

    pacData.underline = (pacIndex & 1) === 1;
    if (pacIndex <= 0xd) {
      pacData.color = [
        'white',
        'green',
        'blue',
        'cyan',
        'red',
        'yellow',
        'magenta',
        'white',
      ][Math.floor(pacIndex / 2)];
    } else if (pacIndex <= 0xf) {
      pacData.italics = true;
      pacData.color = 'white';
    } else {
      pacData.indent = Math.floor((pacIndex - 0x10) / 2) * 4;
    }
    return pacData; // Note that row has zero offset. The spec uses 1.
  }

  /**
   * Parse characters.
   * @returns An array with 1 to 2 codes corresponding to chars, if found. null otherwise.
   */
  parseChars(a: number, b: number): number[] | null {
    let channelNr: Channels;
    let charCodes: number[] | null = null;
    let charCode1: number | null = null;

    if (a >= 0x19) {
      channelNr = 2;
      charCode1 = a - 8;
    } else {
      channelNr = 1;
      charCode1 = a;
    }
    if (charCode1 >= 0x11 && charCode1 <= 0x13) {
      // Special character
      let oneCode;
      if (charCode1 === 0x11) {
        oneCode = b + 0x50;
      } else if (charCode1 === 0x12) {
        oneCode = b + 0x70;
      } else {
        oneCode = b + 0x90;
      }

      this.logger.log(
        VerboseLevel.INFO,
        "Special char '" + getCharForByte(oneCode) + "' in channel " + channelNr
      );
      charCodes = [oneCode];
    } else if (a >= 0x20 && a <= 0x7f) {
      charCodes = b === 0 ? [a] : [a, b];
    }
    if (charCodes) {
      const hexCodes = numArrayToHexArray(charCodes);
      this.logger.log(
        VerboseLevel.DEBUG,
        'Char codes =  ' + hexCodes.join(',')
      );
      setLastCmd(a, b, this.cmdHistory);
    }
    return charCodes;
  }

  /**
   * Parse extended background attributes as well as new foreground color black.
   * @returns {Boolean} Tells if background attributes are found
   */
  parseBackgroundAttributes(a: number, b: number): boolean {
    const case1 = (a === 0x10 || a === 0x18) && b >= 0x20 && b <= 0x2f;
    const case2 = (a === 0x17 || a === 0x1f) && b >= 0x2d && b <= 0x2f;
    if (!(case1 || case2)) {
      return false;
    }
    let index: number;
    const bkgData: Partial<PenStyles> = {};
    if (a === 0x10 || a === 0x18) {
      index = Math.floor((b - 0x20) / 2);
      bkgData.background = backgroundColors[index];
      if (b % 2 === 1) {
        bkgData.background = bkgData.background + '_semi';
      }
    } else if (b === 0x2d) {
      bkgData.background = 'transparent';
    } else {
      bkgData.foreground = 'black';
      if (b === 0x2f) {
        bkgData.underline = true;
      }
    }
    const chNr: Channels = a <= 0x17 ? 1 : 2;
    const channel: Cea608Channel = this.channels[chNr] as Cea608Channel;
    channel.setBkgData(bkgData);
    setLastCmd(a, b, this.cmdHistory);
    return true;
  }

  /**
   * Reset state of parser and its channels.
   */
  reset() {
    for (let i = 0; i < Object.keys(this.channels).length; i++) {
      const channel = this.channels[i];
      if (channel) {
        channel.reset();
      }
    }
    this.cmdHistory = createCmdHistory();
  }

  /**
   * Trigger the generation of a cue, and the start of a new one if displayScreens are not empty.
   */
  cueSplitAtTime(t: number) {
    for (let i = 0; i < this.channels.length; i++) {
      const channel = this.channels[i];
      if (channel) {
        channel.cueSplitAtTime(t);
      }
    }
  }
}

function setLastCmd(
  a: number | null,
  b: number | null,
  cmdHistory: CmdHistory
) {
  cmdHistory.a = a;
  cmdHistory.b = b;
}

function hasCmdRepeated(a: number, b: number, cmdHistory: CmdHistory) {
  return cmdHistory.a === a && cmdHistory.b === b;
}

function createCmdHistory(): CmdHistory {
  return {
    a: null,
    b: null,
  };
}

export default Cea608Parser;