Source: lib/text/ssa_text_parser.js

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

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

goog.require('shaka.log');
goog.require('shaka.text.Cue');
goog.require('shaka.text.TextEngine');
goog.require('shaka.util.StringUtils');


/**
 * Parser of SubStation Alpha subtitles (SSA and ASS, the V4 and V4+ versions).
 * It reads the text of a whole file, or of a header followed by events, and
 * gives a cue for each Dialogue line, with the properties of its style.
 *
 * What ASS can do beyond a line of text (positions, transitions, drawings,
 * karaoke) is not shown: the override blocks are dropped, and so are the
 * events that draw shapes.
 *
 * @see http://moodub.free.fr/video/ass-specs.doc
 * @see https://en.wikipedia.org/wiki/SubStation_Alpha
 *
 * @implements {shaka.extern.TextParser}
 * @export
 */
shaka.text.SsaTextParser = class {
  /**
   * @override
   * @export
   */
  parseInit(data) {
    // There is no initialization segment: a file has everything.
  }

  /**
   * @override
   * @export
   */
  setManifestType(manifestType) {
    // Unused.
  }

  /**
   * @override
   * @export
   */
  parseMedia(data, time, uri, images) {
    const SsaTextParser = shaka.text.SsaTextParser;
    const text = shaka.util.StringUtils.fromUTF8(data);
    const sections = SsaTextParser.splitSections_(text);

    const styles = [];
    let isV4Plus = false;
    for (const [name, lines] of sections) {
      const lowerName = name.toLowerCase();
      if (lowerName == SsaTextParser.SECTION_V4_STYLES_ ||
          lowerName == SsaTextParser.SECTION_V4_PLUS_STYLES_) {
        isV4Plus = lowerName == SsaTextParser.SECTION_V4_PLUS_STYLES_;
        styles.push(...SsaTextParser.parseTable_(lines, 'Style'));
      } else if (lowerName != SsaTextParser.SECTION_EVENTS_ &&
          lowerName != SsaTextParser.SECTION_SCRIPT_INFO_) {
        shaka.log.debug('SsaTextParser skips the section', name);
      }
    }

    /** @type {!Array<!shaka.text.Cue>} */
    const cues = [];
    for (const [name, lines] of sections) {
      if (name.toLowerCase() != SsaTextParser.SECTION_EVENTS_) {
        continue;
      }
      for (const event of SsaTextParser.parseTable_(lines, 'Dialogue')) {
        const cue = SsaTextParser.makeCue_(event, styles, isV4Plus);
        if (cue) {
          cues.push(cue);
        }
      }
    }
    return cues;
  }

  /**
   * Splits a file in its sections: the lines that follow each [Name] line.
   *
   * @param {string} text
   * @return {!Array<!Array>} Pairs of a name and its lines.
   * @private
   */
  static splitSections_(text) {
    const sections = [];
    let lines = null;
    for (const line of text.split(/\r?\n/)) {
      const match = shaka.text.SsaTextParser.SECTION_HEADER_.exec(line);
      if (match) {
        lines = [];
        sections.push([match[1], lines]);
      } else if (lines) {
        lines.push(line);
      }
    }
    return sections;
  }

  /**
   * Reads the lines of a section that are a table: a Format line, which names
   * the columns, and the lines of a kind (Style, Dialogue) that have the
   * values.
   *
   * @param {!Array<string>} lines
   * @param {string} kind
   * @return {!Array<!Object<string, string>>} The values of each row by the
   *   name of their column.
   * @private
   */
  static parseTable_(lines, kind) {
    const SsaTextParser = shaka.text.SsaTextParser;
    /** @type {?Array<string>} */
    let columns = null;
    const rows = [];
    for (const line of lines) {
      // A line that starts with ; is a comment.
      if (SsaTextParser.COMMENT_.test(line)) {
        continue;
      }
      const match = SsaTextParser.KEY_VALUE_.exec(line);
      if (!match) {
        continue;
      }
      const key = match[1].trim();
      if (key == 'Format') {
        columns = match[2].split(',').map((column) => column.trim());
      } else if (key == kind && columns) {
        // The last column, the text, can have commas of its own, so it takes
        // all that is left.
        const values = SsaTextParser.splitFirst(
            match[2], ',', columns.length);
        /** @type {!Object<string, string>} */
        const row = {};
        for (let i = 0; i < columns.length && i < values.length; i++) {
          row[columns[i]] = values[i];
        }
        rows.push(row);
      }
    }
    return rows;
  }

  /**
   * Splits a string in at most |count| parts by a separator, the last of which
   * has what is left.  Some formats have the commas of the text of the last
   * field among the ones that separate the fields.
   *
   * @param {string} text
   * @param {string} separator
   * @param {number} count
   * @return {!Array<string>}
   */
  static splitFirst(text, separator, count) {
    const parts = [];
    let start = 0;
    while (parts.length < count - 1) {
      const end = text.indexOf(separator, start);
      if (end < 0) {
        break;
      }
      parts.push(text.substring(start, end));
      start = end + separator.length;
    }
    parts.push(text.substring(start));
    return parts;
  }

  /**
   * @param {!Object<string, string>} event The values of a Dialogue line.
   * @param {!Array<!Object<string, string>>} styles
   * @param {boolean} isV4Plus
   * @return {?shaka.text.Cue}
   * @private
   */
  static makeCue_(event, styles, isV4Plus) {
    const SsaTextParser = shaka.text.SsaTextParser;
    const startTime = SsaTextParser.parseTime_(event['Start']);
    const endTime = SsaTextParser.parseTime_(event['End']);
    if (startTime == null || endTime == null) {
      shaka.log.debug('SsaTextParser skips an event with no times');
      return null;
    }
    const raw = event['Text'] || '';
    // An event that draws a shape has the drawing commands as its text.
    if (SsaTextParser.DRAWING_.test(raw)) {
      return null;
    }
    const payload = raw
        // A hard line break.
        .replace(/\\N/g, '\n')
        // A soft line break and a hard space.
        .replace(/\\n/g, ' ')
        .replace(/\\h/g, ' ')
        // The blocks of override tags, such as {\pos(400,570)}.
        .replace(/\{[^}]*\}/g, '');
    if (!payload.trim()) {
      return null;
    }

    const cue = new shaka.text.Cue(startTime, endTime, payload);
    const style = styles.find((entry) => entry['Name'] == event['Style']);
    if (style) {
      SsaTextParser.addStyle_(cue, style, isV4Plus);
    }
    return cue;
  }

  /**
   * Adds the properties of a style that a cue has.
   *
   * @param {shaka.text.Cue} cue
   * @param {!Object<string, string>} style
   * @param {boolean} isV4Plus
   * @private
   */
  static addStyle_(cue, style, isV4Plus) {
    const Cue = shaka.text.Cue;
    const SsaTextParser = shaka.text.SsaTextParser;
    if (style['Fontname']) {
      cue.fontFamily = style['Fontname'];
    }
    if (style['Fontsize']) {
      cue.fontSize = style['Fontsize'] + 'px';
    }
    if (style['PrimaryColour']) {
      const color = SsaTextParser.parseColor_(style['PrimaryColour']);
      if (color) {
        cue.color = color;
      }
    }
    if (style['BackColour']) {
      const color = SsaTextParser.parseColor_(style['BackColour']);
      if (color) {
        cue.backgroundColor = color;
      }
    }
    if (SsaTextParser.isSet_(style['Bold'])) {
      cue.fontWeight = Cue.fontWeight.BOLD;
    }
    if (SsaTextParser.isSet_(style['Italic'])) {
      cue.fontStyle = Cue.fontStyle.ITALIC;
    }
    if (SsaTextParser.isSet_(style['Underline'])) {
      cue.textDecoration.push(Cue.textDecoration.UNDERLINE);
    }
    if (style['Spacing']) {
      cue.letterSpacing = style['Spacing'] + 'px';
    }
    const alignment = SsaTextParser.parseAlignment_(
        style['Alignment'], isV4Plus);
    if (alignment) {
      cue.displayAlign = alignment.displayAlign;
      cue.textAlign = alignment.textAlign;
    }
    if (style['AlphaLevel']) {
      cue.opacity = parseFloat(style['AlphaLevel']);
    }
  }

  /**
   * Tells whether a flag of a style is set.  Styles write flags as -1 (set) and
   * 0 (not set).
   *
   * @param {(string|undefined)} value
   * @return {boolean}
   * @private
   */
  static isSet_(value) {
    const number = parseInt(value, 10);
    return !isNaN(number) && number != 0;
  }

  /**
   * Reads the alignment of a style.  ASS (V4+) numbers the nine positions like
   * the keys of a numeric keypad, from the bottom left.  SSA (V4) has its own
   * numbers: 1 to 3 at the bottom, 5 to 7 at the top and 9 to 11 in the
   * middle, and left, center and right in each.
   *
   * @param {(string|undefined)} value
   * @param {boolean} isV4Plus
   * @return {?{displayAlign: shaka.text.Cue.displayAlign,
   *   textAlign: shaka.text.Cue.textAlign}}
   * @private
   */
  static parseAlignment_(value, isV4Plus) {
    const Cue = shaka.text.Cue;
    const number = parseInt(value, 10);
    if (isNaN(number)) {
      return null;
    }
    let row;
    let column;
    if (isV4Plus) {
      if (number < 1 || number > 9) {
        return null;
      }
      row = Math.floor((number - 1) / 3);
      column = (number - 1) % 3;
    } else {
      // The ranges 1-3, 5-7 and 9-11 are the bottom, top and middle rows.
      const rowsOfSsa = [0, 2, 1];
      const group = Math.floor((number - 1) / 4);
      column = (number - 1) % 4;
      if (group > 2 || column > 2) {
        return null;
      }
      row = rowsOfSsa[group];
    }
    // Row 0 is the bottom one.
    const displayAligns = [
      Cue.displayAlign.AFTER,
      Cue.displayAlign.CENTER,
      Cue.displayAlign.BEFORE,
    ];
    const textAligns = [
      Cue.textAlign.START,
      Cue.textAlign.CENTER,
      Cue.textAlign.END,
    ];
    return {displayAlign: displayAligns[row], textAlign: textAligns[column]};
  }

  /**
   * Reads a color.  It is a number in hexadecimal (&HAABBGGRR) or in decimal,
   * with the bytes in the order alpha, blue, green and red, and its alpha is
   * the other way around: 0xFF is transparent.
   *
   * @param {string} colorString
   * @return {?string} A CSS color.
   * @private
   */
  static parseColor_(colorString) {
    const trimmed = colorString.trim();
    const isHex = /^&H/i.test(trimmed);
    const abgr = isHex ? parseInt(trimmed.replace(/^&H/i, ''), 16) :
        parseInt(trimmed, 10);
    if (isNaN(abgr)) {
      return null;
    }
    // Do not use >> on the whole number: it is a signed 32-bit shift, and the
    // number can have 32 bits of value.
    const unsigned = abgr >>> 0;
    const alpha = (((unsigned >>> 24) & 0xff) ^ 0xff) / 255;
    const blue = (unsigned >>> 16) & 0xff;
    const green = (unsigned >>> 8) & 0xff;
    const red = unsigned & 0xff;
    return 'rgba(' + red + ',' + green + ',' + blue + ',' + alpha + ')';
  }

  /**
   * Reads a time: H:MM:SS.cc, with one to three digits after the dot.
   *
   * @param {(string|undefined)} string
   * @return {?number} The time in seconds.
   * @private
   */
  static parseTime_(string) {
    const match = shaka.text.SsaTextParser.TIME_.exec((string || '').trim());
    if (!match) {
      return null;
    }
    const hours = match[1] ? parseInt(match[1], 10) : 0;
    return hours * 3600 + parseInt(match[2], 10) * 60 + parseFloat(match[3]);
  }
};


/**
 * The line that starts a section, such as [V4+ Styles].
 * @private @const {!RegExp}
 */
shaka.text.SsaTextParser.SECTION_HEADER_ = /^\s*\[([^\]]+)\]\s*$/;

/**
 * A line that has a key, a colon and a value, such as Style: Default,Arial.
 * @private @const {!RegExp}
 */
shaka.text.SsaTextParser.KEY_VALUE_ = /^\s*([^:]+):\s*(.*)$/;

/**
 * A comment.
 * @private @const {!RegExp}
 */
shaka.text.SsaTextParser.COMMENT_ = /^\s*;/;

/**
 * A time: 0:00:01.1, 0:00:01.18 or 0:00:01.180.
 * @private @const {!RegExp}
 */
shaka.text.SsaTextParser.TIME_ =
    /^(?:(\d+):)?(\d{1,2}):(\d{1,2}(?:\.\d{1,3})?)$/;

/**
 * The tag that turns the text of an event into commands to draw a shape.
 * @private @const {!RegExp}
 */
shaka.text.SsaTextParser.DRAWING_ = /\{[^}]*\\p[1-9]/;

/**
 * The names of the sections that are read, in lower case.
 * @private @const {string}
 */
shaka.text.SsaTextParser.SECTION_SCRIPT_INFO_ = 'script info';

/** @private @const {string} */
shaka.text.SsaTextParser.SECTION_V4_STYLES_ = 'v4 styles';

/** @private @const {string} */
shaka.text.SsaTextParser.SECTION_V4_PLUS_STYLES_ = 'v4+ styles';

/** @private @const {string} */
shaka.text.SsaTextParser.SECTION_EVENTS_ = 'events';


shaka.text.TextEngine.registerParser(
    'text/x-ssa', () => new shaka.text.SsaTextParser());