UNPKG

shaka-player

Version:
426 lines (395 loc) • 12.5 kB
/*! @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());