Source: lib/text/srt_text_parser.js

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

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

goog.require('goog.asserts');
goog.require('shaka.text.TextEngine');
goog.require('shaka.text.Utils');
goog.require('shaka.text.VttTextParser');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.StringUtils');


/**
 * @implements {shaka.extern.TextParser}
 * @export
 */
shaka.text.SrtTextParser = class {
  constructor() {
    /**
     * @type {!shaka.extern.TextParser}
     * @private
     */
    this.parser_ = new shaka.text.VttTextParser();
  }

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

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

  /**
   * @override
   * @export
   */
  parseMedia(data, time, uri) {
    const BufferUtils = shaka.util.BufferUtils;
    const StringUtils = shaka.util.StringUtils;

    // Get the input as a string.
    const str = StringUtils.fromUTF8(data);

    const vvtText = this.srt2webvtt_(str);

    const newData = BufferUtils.toUint8(StringUtils.toUTF8(vvtText));

    return this.parser_.parseMedia(newData, time, uri, /* images= */ []);
  }

  /**
   * Convert a SRT format to WebVTT
   *
   * @param {string} data
   * @return {string}
   * @private
   */
  srt2webvtt_(data) {
    let result = 'WEBVTT\n\n';

    // Supports no cues
    if (data == '') {
      return result;
    }

    // remove dos newlines
    let srt = data.replace(/\r+/g, '');
    // trim white space start and end
    srt = srt.trim();

    // get cues
    const cuelist = srt.split('\n\n');
    for (const cue of cuelist) {
      result += this.convertSrtCue_(cue);
    }

    return result;
  }

  /**
   * Convert a single SRT cue into a WebVTT cue
   * Handles: timestamps, alignment, position, styles, colors.
   *
   * @param {string} caption
   * @return {string} WebVTT cue
   * @private
   */
  convertSrtCue_(caption) {
    // Split cue into non-empty trimmed lines
    const lines = caption.split('\n').map((l) => l.trim()).filter(Boolean);
    if (lines.length < 2) {
      return '';
    }

    // 1. Remove numeric ID if present
    if (/^\d+$/.test(lines[0])) {
      lines.shift();
    }

    if (lines.length < 2) {
      return '';
    }

    // 2. Parse time line (start --> end [settings])
    const timeRegex = /^([\d:,]+)\s*-->\s*([\d:,]+)(.*)?$/;
    const match = lines[0].match(timeRegex);
    if (!match) {
      return '';
    }

    const start = this.normalizeTime_(match[1]);
    const end = this.normalizeTime_(match[2]);
    let settings = '';

    // 3. Combine remaining lines as cue text
    let text = lines.slice(1).join('\n');

    // 4. Aegisub alignment {\anX} → WebVTT line & align settings
    const alignMatch = text.match(/{\\an(\d)}/);
    if (alignMatch) {
      const map = {
        1: 'line:-1 align:left',
        2: 'line:-1 align:center',
        3: 'line:-1 align:right',
        7: 'line:0 align:left',
        8: 'line:0 align:center',
        9: 'line:0 align:right',
      };
      settings += map[alignMatch[1]] ? ` ${map[alignMatch[1]]}` : '';
    }

    // 5. Aegisub position {\pos(x,y)} → WebVTT position & line
    const posMatch = text.match(/{\\pos\((\d+),(\d+)\)}/);
    if (posMatch) {
      // Convert coordinates to percentages (approximation)
      const x = Math.min(100, Math.round(parseFloat(posMatch[1]) / 19.2));
      const y = Math.min(100, Math.round(parseFloat(posMatch[2]) / 10.8));
      settings += ` position:${x}% line:${y}%`;
    }

    // 6. Remove all remaining Aegisub/unsupported tags
    text = text.replace(/{\\.*?}/g, '');

    // 7. Convert basic SRT style tags {b}{/b}, {i}{/i}, {u}{/u} → HTML
    text = text
        .replace(/{b}/gi, '<b>')
        .replace(/{\/b}/gi, '</b>')
        .replace(/{i}/gi, '<i>')
        .replace(/{\/i}/gi, '</i>')
        .replace(/{u}/gi, '<u>')
        .replace(/{\/u}/gi, '</u>');

    // 8. Convert <font color="#XXXXXX"> → <c.colorName> (WebVTT spec)
    text = this.convertColors_(text);

    // 9. Return formatted WebVTT cue
    return `${start} --> ${end}${settings}\n${text}\n\n`;
  }

  /**
   * Normalize timestamp for WebVTT
   * Supports MM:SS,mmm → 00:MM:SS.mmm
   *
   * @param {string} time
   * @return {string}
   * @private
   */
  normalizeTime_(time) {
    if (/^\d{2}:\d{2},\d{3}$/.test(time)) {
      return '00:' + time.replace(',', '.');
    }
    return time.replace(',', '.');
  }

  /**
   * Convert SRT <font color="#XXXXXX"> or <font color="name"> tags
   * into WebVTT <c.colorName>. Unknown colors are removed safely.
   *
   * @param {string} text
   * @return {string}
   * @private
   */
  convertColors_(text) {
    const openColors = [];

    text = text.replace(/<font color=["']?([^"'>]+)["']?>/gi, (_, color) => {
      const key = color.toLowerCase();
      const colorName = shaka.text.Utils.getColorName(key);
      if (colorName) {
        openColors.push(colorName);
        return `<c.${colorName}>`;
      }
      return '';
    });

    text = text.replace(/<\/font>/gi, () => {
      if (openColors.length) {
        openColors.pop();
        return '</c>';
      }
      return '';
    });

    return text;
  }
};


shaka.text.TextEngine.registerParser(
    'text/srt', () => new shaka.text.SrtTextParser());