subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
523 lines (519 loc) • 14.6 kB
JavaScript
// src/formats/text/srt/time.ts
function parseTime(s) {
if (s.length !== 12)
throw new Error(`Invalid SRT timestamp: ${s}`);
const h = (s.charCodeAt(0) - 48) * 10 + (s.charCodeAt(1) - 48);
const m = (s.charCodeAt(3) - 48) * 10 + (s.charCodeAt(4) - 48);
const ss = (s.charCodeAt(6) - 48) * 10 + (s.charCodeAt(7) - 48);
const ms = (s.charCodeAt(9) - 48) * 100 + (s.charCodeAt(10) - 48) * 10 + (s.charCodeAt(11) - 48);
return h * 3600000 + m * 60000 + ss * 1000 + ms;
}
function formatTime(ms) {
const h = Math.floor(ms / 3600000);
const m = Math.floor(ms % 3600000 / 60000);
const s = Math.floor(ms % 60000 / 1000);
const millis = ms % 1000;
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")},${millis.toString().padStart(3, "0")}`;
}
// src/formats/text/srt/tags.ts
function parseTags(raw) {
const segments = [];
const stateStack = [{ bold: false, italic: false, underline: false, strikeout: false }];
let textStart = 0;
let i = 0;
while (i < raw.length) {
if (raw[i] === "<") {
const closeIdx = raw.indexOf(">", i);
if (closeIdx === -1) {
i++;
continue;
}
if (i > textStart) {
const state = stateStack[stateStack.length - 1];
const style = buildStyle(state);
segments[segments.length] = {
text: raw.slice(textStart, i),
style,
effects: []
};
}
const tag = raw.slice(i + 1, closeIdx).toLowerCase();
processTag(tag, stateStack);
i = closeIdx + 1;
textStart = i;
} else {
i++;
}
}
if (textStart < raw.length) {
const state = stateStack[stateStack.length - 1];
const style = buildStyle(state);
segments[segments.length] = {
text: raw.slice(textStart),
style,
effects: []
};
}
return segments;
}
function processTag(tag, stateStack) {
const currentState = stateStack[stateStack.length - 1];
if (tag === "b") {
stateStack[stateStack.length] = { ...currentState, bold: true };
} else if (tag === "/b") {
if (stateStack.length > 1)
stateStack.pop();
} else if (tag === "i") {
stateStack[stateStack.length] = { ...currentState, italic: true };
} else if (tag === "/i") {
if (stateStack.length > 1)
stateStack.pop();
} else if (tag === "u") {
stateStack[stateStack.length] = { ...currentState, underline: true };
} else if (tag === "/u") {
if (stateStack.length > 1)
stateStack.pop();
} else if (tag === "s") {
stateStack[stateStack.length] = { ...currentState, strikeout: true };
} else if (tag === "/s") {
if (stateStack.length > 1)
stateStack.pop();
} else if (tag.startsWith("font")) {
const colorMatch = tag.match(/color\s*=\s*["']?#?([0-9a-f]{6})["']?/i);
if (colorMatch) {
const hex = colorMatch[1];
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const color = (b & 255) << 16 | (g & 255) << 8 | r & 255;
stateStack[stateStack.length] = { ...currentState, color };
} else {
stateStack[stateStack.length] = { ...currentState };
}
} else if (tag === "/font") {
if (stateStack.length > 1)
stateStack.pop();
}
}
function buildStyle(state) {
if (!state.bold && !state.italic && !state.underline && !state.strikeout && state.color === undefined) {
return null;
}
const style = {};
if (state.bold)
style.bold = true;
if (state.italic)
style.italic = true;
if (state.underline)
style.underline = true;
if (state.strikeout)
style.strikeout = true;
if (state.color !== undefined)
style.primaryColor = state.color;
return style;
}
function serializeTags(segments) {
let result = "";
for (const seg of segments) {
let text = seg.text;
const openTags = [];
const closeTags = [];
if (seg.style?.bold) {
openTags[openTags.length] = "<b>";
closeTags.unshift("</b>");
}
if (seg.style?.italic) {
openTags[openTags.length] = "<i>";
closeTags.unshift("</i>");
}
if (seg.style?.underline) {
openTags[openTags.length] = "<u>";
closeTags.unshift("</u>");
}
if (seg.style?.strikeout) {
openTags[openTags.length] = "<s>";
closeTags.unshift("</s>");
}
if (seg.style?.primaryColor !== undefined) {
const color = seg.style.primaryColor;
const r = color & 255;
const g = color >> 8 & 255;
const b = color >> 16 & 255;
const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
openTags[openTags.length] = `<font color="${hex}">`;
closeTags.unshift("</font>");
}
result += openTags.join("") + text + closeTags.join("");
}
return result;
}
function stripTags(raw) {
return raw.replace(/<[^>]*>/g, "");
}
// src/core/errors.ts
class SubforgeError extends Error {
code;
line;
column;
constructor(code, message, position) {
super(`[${code}] ${message} at line ${position.line}, column ${position.column}`);
this.name = "SubforgeError";
this.code = code;
this.line = position.line;
this.column = position.column;
}
}
function toParseError(err) {
if (err instanceof SubforgeError) {
return {
line: err.line,
column: err.column,
code: err.code,
message: err.message
};
}
if (err instanceof Error) {
return {
line: 1,
column: 1,
code: "MALFORMED_EVENT",
message: err.message
};
}
return {
line: 1,
column: 1,
code: "MALFORMED_EVENT",
message: String(err)
};
}
function unwrap(result) {
if (result.ok)
return result.document;
const first = result.errors[0];
if (first) {
throw new SubforgeError(first.code, first.message, { line: first.line, column: first.column });
}
throw new Error("Parse failed");
}
// src/core/document.ts
var idCounter = 0;
var EMPTY_SEGMENTS = [];
function generateId() {
return ++idCounter;
}
function reserveIds(count) {
const start = idCounter + 1;
idCounter += count;
return start;
}
function createDocument(init) {
return {
info: {
title: "",
playResX: 1920,
playResY: 1080,
scaleBorderAndShadow: true,
wrapStyle: 0,
...init?.info
},
styles: init?.styles ?? new Map([["Default", createDefaultStyle()]]),
events: init?.events ?? [],
comments: init?.comments ?? [],
fonts: init?.fonts,
graphics: init?.graphics,
regions: init?.regions
};
}
function createDefaultStyle() {
return {
name: "Default",
fontName: "Arial",
fontSize: 48,
primaryColor: 16777215,
secondaryColor: 255,
outlineColor: 0,
backColor: 0,
bold: false,
italic: false,
underline: false,
strikeout: false,
scaleX: 100,
scaleY: 100,
spacing: 0,
angle: 0,
borderStyle: 1,
outline: 2,
shadow: 2,
alignment: 2,
marginL: 10,
marginR: 10,
marginV: 10,
encoding: 1
};
}
function createEvent(start, end, text, opts) {
return {
id: generateId(),
start,
end,
layer: 0,
style: "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text,
segments: [],
dirty: false,
...opts
};
}
function createKaraokeEvent(start, end, syllables, opts) {
const segments = syllables.map((syl) => ({
text: syl.text,
style: syl.style ? { ...syl.style } : null,
effects: [{ type: "karaoke", params: { duration: syl.duration, mode: "fill" } }]
}));
return {
id: generateId(),
start,
end,
layer: 0,
style: "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text: "",
segments,
dirty: true,
...opts
};
}
function cloneDocument(doc) {
return {
info: { ...doc.info },
styles: new Map(doc.styles),
events: doc.events.map((e) => ({
...e,
segments: [...e.segments],
image: e.image ? { ...e.image, data: e.image.data.slice() } : undefined,
vobsub: e.vobsub ? { ...e.vobsub } : undefined,
pgs: e.pgs ? { ...e.pgs } : undefined
})),
comments: [...doc.comments],
fonts: doc.fonts ? [...doc.fonts] : undefined,
graphics: doc.graphics ? [...doc.graphics] : undefined,
regions: doc.regions ? [...doc.regions] : undefined
};
}
function cloneEvent(event) {
return {
...event,
id: generateId(),
segments: event.segments.map((s) => ({ ...s, effects: [...s.effects] })),
image: event.image ? { ...event.image, data: event.image.data.slice() } : undefined,
vobsub: event.vobsub ? { ...event.vobsub } : undefined,
pgs: event.pgs ? { ...event.pgs } : undefined
};
}
// src/formats/text/srt/parser.ts
class SRTParser {
src;
pos = 0;
len;
doc;
errors = [];
opts;
lineNum = 1;
constructor(input, opts = {}) {
let start = 0;
if (input.charCodeAt(0) === 65279)
start = 1;
this.src = input;
this.pos = start;
this.len = input.length;
this.opts = {
onError: opts.onError ?? "collect",
strict: opts.strict ?? false,
preserveOrder: opts.preserveOrder ?? true
};
this.doc = createDocument();
}
parse() {
while (this.pos < this.len) {
this.skipEmptyLines();
if (this.pos >= this.len)
break;
const event = this.parseSubtitle();
if (event) {
this.doc.events[this.doc.events.length] = event;
}
}
return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] };
}
skipEmptyLines() {
while (this.pos < this.len) {
const c = this.src.charCodeAt(this.pos);
if (c === 10) {
this.pos++;
this.lineNum++;
} else if (c === 13) {
this.pos++;
if (this.pos < this.len && this.src.charCodeAt(this.pos) === 10)
this.pos++;
this.lineNum++;
} else if (c === 32 || c === 9) {
this.pos++;
} else {
break;
}
}
}
isDigit(c) {
return c >= 48 && c <= 57;
}
parseSubtitle() {
const indexLineStart = this.pos;
let nlPos = this.src.indexOf(`
`, this.pos);
if (nlPos === -1)
nlPos = this.len;
let indexLineEnd = nlPos;
if (indexLineEnd > indexLineStart && this.src.charCodeAt(indexLineEnd - 1) === 13)
indexLineEnd--;
let firstNonWs = indexLineStart;
while (firstNonWs < indexLineEnd && (this.src.charCodeAt(firstNonWs) === 32 || this.src.charCodeAt(firstNonWs) === 9)) {
firstNonWs++;
}
this.pos = nlPos < this.len ? nlPos + 1 : this.len;
this.lineNum++;
if (firstNonWs >= indexLineEnd || !this.isDigit(this.src.charCodeAt(firstNonWs))) {
return null;
}
if (this.pos >= this.len)
return null;
const timeLineStart = this.pos;
const arrowPos = this.src.indexOf(" --> ", timeLineStart);
nlPos = this.src.indexOf(`
`, timeLineStart);
if (nlPos === -1)
nlPos = this.len;
let timeLineEnd = nlPos;
if (timeLineEnd > timeLineStart && this.src.charCodeAt(timeLineEnd - 1) === 13)
timeLineEnd--;
this.pos = nlPos < this.len ? nlPos + 1 : this.len;
this.lineNum++;
if (arrowPos === -1 || arrowPos >= timeLineEnd) {
this.addError("INVALID_TIMESTAMP", `Invalid time line`);
return null;
}
const startLen = arrowPos - timeLineStart;
if (startLen !== 12) {
this.addError("INVALID_TIMESTAMP", `Invalid timestamp`);
return null;
}
const s = this.src;
let o = timeLineStart;
const start = ((s.charCodeAt(o) - 48) * 10 + (s.charCodeAt(o + 1) - 48)) * 3600000 + ((s.charCodeAt(o + 3) - 48) * 10 + (s.charCodeAt(o + 4) - 48)) * 60000 + ((s.charCodeAt(o + 6) - 48) * 10 + (s.charCodeAt(o + 7) - 48)) * 1000 + (s.charCodeAt(o + 9) - 48) * 100 + (s.charCodeAt(o + 10) - 48) * 10 + (s.charCodeAt(o + 11) - 48);
const endLen = timeLineEnd - (arrowPos + 5);
if (endLen !== 12) {
this.addError("INVALID_TIMESTAMP", `Invalid timestamp`);
return null;
}
o = arrowPos + 5;
const end = ((s.charCodeAt(o) - 48) * 10 + (s.charCodeAt(o + 1) - 48)) * 3600000 + ((s.charCodeAt(o + 3) - 48) * 10 + (s.charCodeAt(o + 4) - 48)) * 60000 + ((s.charCodeAt(o + 6) - 48) * 10 + (s.charCodeAt(o + 7) - 48)) * 1000 + (s.charCodeAt(o + 9) - 48) * 100 + (s.charCodeAt(o + 10) - 48) * 10 + (s.charCodeAt(o + 11) - 48);
const textStart = this.pos;
let textEnd = this.pos;
while (this.pos < this.len) {
const lineStart = this.pos;
nlPos = this.src.indexOf(`
`, this.pos);
if (nlPos === -1)
nlPos = this.len;
let lineEnd = nlPos;
if (lineEnd > lineStart && this.src.charCodeAt(lineEnd - 1) === 13)
lineEnd--;
let isEmpty = true;
for (let i = lineStart;i < lineEnd; i++) {
const c = this.src.charCodeAt(i);
if (c !== 32 && c !== 9) {
isEmpty = false;
break;
}
}
if (isEmpty)
break;
textEnd = lineEnd;
this.pos = nlPos < this.len ? nlPos + 1 : this.len;
this.lineNum++;
}
let text = this.src.substring(textStart, textEnd);
if (text.includes("\r")) {
text = text.replace(/\r/g, "");
}
text = text.trim();
return {
id: generateId(),
start,
end,
layer: 0,
style: "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text,
segments: EMPTY_SEGMENTS,
dirty: false
};
}
addError(code, message, raw) {
if (this.opts.onError === "skip")
return;
this.errors.push({ line: this.lineNum, column: 1, code, message, raw });
}
}
function parseSRT(input, opts) {
try {
const parser = new SRTParser(input, opts);
return parser.parse();
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
};
}
}
// src/formats/text/srt/serializer.ts
function toSRT(doc) {
let result = "";
const events = doc.events;
const len = events.length;
for (let i = 0;i < len; i++) {
const event = events[i];
const text = event.dirty && event.segments.length > 0 ? serializeTags(event.segments) : event.text;
result += i + 1 + `
`;
result += formatTime(event.start) + " --> " + formatTime(event.end) + `
`;
result += text + `
`;
}
return result;
}
export {
toSRT,
stripTags,
serializeTags,
parseTime,
parseTags,
parseSRT,
formatTime
};