subsrt-ts
Version:
Subtitle JavaScript library and command line tool with no dependencies.
205 lines (204 loc) • 8.68 kB
JavaScript
import { buildHandler } from "../handler.js";
const FORMAT_NAME = "ssa";
const helper = {
/**
* Converts a time string in format of hh:mm:ss.fff or hh:mm:ss,fff to milliseconds.
* @param s The time string to convert
* @throws {TypeError} If the time string is invalid
* @returns Milliseconds
*/
toMilliseconds: (s) => {
const match = /^\s*(\d+:)?(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?\s*$/.exec(s);
if (!match) {
throw new TypeError(`Invalid time format: ${s}`);
}
const hh = match[1] ? parseInt(match[1].replace(":", "")) : 0;
const mm = parseInt(match[2], 10);
const ss = parseInt(match[3], 10);
const ff = match[4] ? parseInt(match[4], 10) : 0;
const ms = hh * 3600 * 1000 + mm * 60 * 1000 + ss * 1000 + ff * 10;
return ms;
},
/**
* Converts milliseconds to a time string in format of hh:mm:ss.fff.
* @param ms Milliseconds
* @returns Time string in format of hh:mm:ss.fff
*/
toTimeString: (ms) => {
const hh = Math.floor(ms / 1000 / 3600);
const mm = Math.floor((ms / 1000 / 60) % 60);
const ss = Math.floor((ms / 1000) % 60);
const ff = Math.floor((ms % 1000) / 10); // 2 digits
const time = `${hh}:${mm < 10 ? "0" : ""}${mm}:${ss < 10 ? "0" : ""}${ss}.${ff < 10 ? "0" : ""}${ff}`;
return time;
},
};
/**
* Internal helper function for building caption data.
* @param columns Columns
* @param values Values
* @returns Caption data
* @private
*/
const _buildCaptionData = (columns, values) => {
const data = {};
for (let c = 0; c < columns.length && c < values.length; c++) {
data[columns[c]] = values[c];
}
return data;
};
/**
* Parses captions in SubStation Alpha format (.ssa).
* @param content The subtitle content
* @param options Parse options
* @throws {TypeError} If the meta data is in invalid format
* @returns Parsed captions
*/
const parse = (content, options) => {
var _a;
let meta;
let columns = null;
const captions = [];
const eol = (_a = options.eol) !== null && _a !== void 0 ? _a : "\r\n";
const parts = content.split(/\r?\n\s*\n/);
for (const part of parts) {
const regex = /^\s*\[([^\]]+)\]\r?\n([\s\S]*)$/;
const match = regex.exec(part);
if (match) {
const tag = match[1];
const lines = match[2].split(/\r?\n/);
for (const line of lines) {
if (/^\s*;/.test(line)) {
continue; // Skip comment
}
// FIXME: prevent backtracking
// eslint-disable-next-line regexp/no-super-linear-backtracking
const lineMatch = /^\s*([^\s:]+):\s*(.*)$/.exec(line);
if (!lineMatch) {
continue;
}
if (tag === "Script Info") {
if (!meta) {
meta = {};
meta.type = "meta";
meta.data = {};
captions.push(meta);
}
if (typeof meta.data === "object") {
const name = lineMatch[1].trim();
const value = lineMatch[2].trim();
meta.data[name] = value;
}
else {
throw new TypeError(`Invalid meta data: ${line}`);
}
}
else if (tag === "V4 Styles" || tag === "V4+ Styles") {
const name = lineMatch[1].trim();
const value = lineMatch[2].trim();
if (name === "Format") {
columns = value.split(/\s*,\s*/);
}
else if (name === "Style" && columns) {
const values = value.split(/\s*,\s*/);
const caption = {};
caption.type = "style";
caption.data = _buildCaptionData(columns, values);
captions.push(caption);
}
}
else if (tag === "Events") {
const name = lineMatch[1].trim();
const value = lineMatch[2].trim();
if (name === "Format") {
columns = value.split(/\s*,\s*/);
}
else if (name === "Dialogue" && columns) {
const values = value.split(/\s*,\s*/);
const caption = {};
caption.type = "caption";
caption.data = _buildCaptionData(columns, values);
caption.start = helper.toMilliseconds(caption.data.Start);
caption.end = helper.toMilliseconds(caption.data.End);
caption.duration = caption.end - caption.start;
caption.content = caption.data.Text;
// Work-around for missing text (when the text contains ',' char)
const indexOfText = value.split(",", columns.length - 1).join(",").length + 1 + 1;
caption.content = value.substring(indexOfText);
caption.data.Text = caption.content;
caption.text = caption.content
.replace(/\\N/g, eol) // "\N" for new line
.replace(/\{[^}]+\}/g, ""); // {\pos(400,570)}
captions.push(caption);
}
}
}
}
if (options.verbose) {
console.warn("Unknown part", part);
}
}
return captions;
};
/**
* Builds captions in SubStation Alpha format (.ssa).
* @param captions The captions to build
* @param options Build options
* @returns The built captions string in SubStation Alpha format
*/
const build = (captions, options) => {
var _a;
const eol = (_a = options.eol) !== null && _a !== void 0 ? _a : "\r\n";
const ass = options.format === "ass";
let content = "";
content += `[Script Info]${eol}`;
content += `; Script generated by subsrt ${eol}`;
content += `ScriptType: v4.00${ass ? "+" : ""}${eol}`;
content += `Collisions: Normal${eol}`;
content += eol;
if (ass) {
content += `[V4+ Styles]${eol}`;
content += `Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding${eol}`;
content += `Style: DefaultVCD, Arial,28,&H00B4FCFC,&H00B4FCFC,&H00000008,&H80000008,-1,0,0,0,100,100,0.00,0.00,1,1.00,2.00,2,30,30,30,0${eol}`;
}
else {
content += `[V4 Styles]${eol}`;
content += `Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding${eol}`;
content += `Style: DefaultVCD, Arial,28,11861244,11861244,11861244,-2147483640,-1,0,1,1,2,2,30,30,30,0,0${eol}`;
}
content += eol;
content += `[Events]${eol}`;
content += `Format: ${ass ? "Layer" : "Marked"}, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text${eol}`;
for (const caption of captions) {
if (caption.type === "meta") {
continue;
}
if (!caption.type || caption.type === "caption") {
content += `Dialogue: ${ass ? "0" : "Marked=0"},${helper.toTimeString(caption.start)},${helper.toTimeString(caption.end)},DefaultVCD, NTP,0000,0000,0000,,${caption.text.replace(/\r?\n/g, "\\N")}${eol}`;
continue;
}
if (options.verbose) {
console.log("SKIP:", caption);
}
}
return content;
};
/**
* Detects whether the content is in ASS or SSA format.
* @param content The subtitle content
* @returns Whether the content is in "ass", "ssa" or neither
*/
const detect = (content) => {
if (/^\s*\[Script Info\]\r?\n/.test(content) && /\s*\[Events\]\r?\n/.test(content)) {
/*
[Script Info]
...
[Events]
*/
// Advanced (V4+) styles for ASS format
return content.indexOf("[V4+ Styles]") > 0 ? "ass" : "ssa";
}
return false;
};
export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse });
export { FORMAT_NAME as name, build, detect, helper, parse };