subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
646 lines (641 loc) • 18.2 kB
JavaScript
// src/formats/text/vtt/time.ts
function parseTime(s) {
const len = s.length;
if (len === 12 && s.charCodeAt(8) === 46) {
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;
}
if (len === 9 && s.charCodeAt(5) === 46) {
const m = (s.charCodeAt(0) - 48) * 10 + (s.charCodeAt(1) - 48);
const ss = (s.charCodeAt(3) - 48) * 10 + (s.charCodeAt(4) - 48);
const ms = (s.charCodeAt(6) - 48) * 100 + (s.charCodeAt(7) - 48) * 10 + (s.charCodeAt(8) - 48);
return m * 60000 + ss * 1000 + ms;
}
throw new Error(`Invalid VTT timestamp: ${s}`);
}
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/vtt/tags.ts
function parseTags(raw) {
const segments = [];
const stateStack = [{ bold: false, italic: false, underline: 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);
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];
const tagLower = tag.toLowerCase();
if (tagLower === "b") {
stateStack[stateStack.length] = { ...currentState, bold: true };
} else if (tagLower === "/b") {
if (stateStack.length > 1)
stateStack.pop();
} else if (tagLower === "i") {
stateStack[stateStack.length] = { ...currentState, italic: true };
} else if (tagLower === "/i") {
if (stateStack.length > 1)
stateStack.pop();
} else if (tagLower === "u") {
stateStack[stateStack.length] = { ...currentState, underline: true };
} else if (tagLower === "/u") {
if (stateStack.length > 1)
stateStack.pop();
} else if (tagLower.startsWith("v ") || tagLower.startsWith("v.")) {
stateStack[stateStack.length] = { ...currentState };
} else if (tagLower === "/v") {
if (stateStack.length > 1)
stateStack.pop();
} else if (tagLower.startsWith("c.") || tagLower === "c") {
stateStack[stateStack.length] = { ...currentState };
} else if (tagLower === "/c") {
if (stateStack.length > 1)
stateStack.pop();
} else if (tagLower.startsWith("lang ")) {
stateStack[stateStack.length] = { ...currentState };
} else if (tagLower === "/lang") {
if (stateStack.length > 1)
stateStack.pop();
}
}
function buildStyle(state) {
if (!state.bold && !state.italic && !state.underline) {
return null;
}
const style = {};
if (state.bold)
style.bold = true;
if (state.italic)
style.italic = true;
if (state.underline)
style.underline = true;
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>");
}
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/vtt/parser.ts
class VTTParser {
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();
this.doc.regions = [];
}
parse() {
this.parseHeader();
while (this.pos < this.len) {
this.skipEmptyLines();
if (this.pos >= this.len)
break;
const c0 = this.src.charCodeAt(this.pos);
const c1 = this.pos + 1 < this.len ? this.src.charCodeAt(this.pos + 1) : 0;
const c2 = this.pos + 2 < this.len ? this.src.charCodeAt(this.pos + 2) : 0;
const c3 = this.pos + 3 < this.len ? this.src.charCodeAt(this.pos + 3) : 0;
if (c0 === 78 && c1 === 79 && c2 === 84 && c3 === 69) {
this.parseNote();
} else if (c0 === 83 && c1 === 84 && c2 === 89 && c3 === 76) {
this.parseStyle();
} else if (c0 === 82 && c1 === 69 && c2 === 71 && c3 === 73) {
this.parseRegion();
} else {
const event = this.parseCue();
if (event) {
this.doc.events[this.doc.events.length] = event;
}
}
}
return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] };
}
readLine() {
const start = this.pos;
let nlPos = this.src.indexOf(`
`, this.pos);
if (nlPos === -1)
nlPos = this.len;
let end = nlPos;
if (end > start && this.src.charCodeAt(end - 1) === 13)
end--;
this.pos = nlPos < this.len ? nlPos + 1 : this.len;
this.lineNum++;
return this.src.substring(start, end);
}
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;
}
}
}
parseHeader() {
if (this.pos >= this.len)
return;
const line = this.readLine().trim();
if (!line.startsWith("WEBVTT")) {
this.addError("INVALID_SECTION", "File must start with WEBVTT");
}
}
parseNote() {
while (this.pos < this.len) {
const line = this.readLine();
if (line.trim() === "")
break;
}
}
parseStyle() {
this.readLine();
while (this.pos < this.len) {
const line = this.readLine();
if (line.trim() === "")
break;
}
}
parseRegion() {
this.readLine();
const region = {
id: "",
width: "100%",
lines: 3,
regionAnchor: "0%,100%",
viewportAnchor: "0%,100%",
scroll: "none"
};
while (this.pos < this.len) {
const line = this.readLine().trim();
if (line === "")
break;
const colonIdx = line.indexOf(":");
if (colonIdx !== -1) {
const key = line.substring(0, colonIdx).trim();
const value = line.substring(colonIdx + 1).trim();
switch (key) {
case "id":
region.id = value;
break;
case "width":
region.width = value;
break;
case "lines":
region.lines = parseInt(value) || 3;
break;
case "regionanchor":
region.regionAnchor = value;
break;
case "viewportanchor":
region.viewportAnchor = value;
break;
case "scroll":
region.scroll = value === "up" ? "up" : "none";
break;
}
}
}
this.doc.regions[this.doc.regions.length] = region;
}
parseCue() {
let lineStart = this.pos;
let arrowPos = this.src.indexOf(" --> ", lineStart);
let nlPos = this.src.indexOf(`
`, lineStart);
if (nlPos === -1)
nlPos = this.len;
let lineEnd = nlPos;
if (lineEnd > lineStart && this.src.charCodeAt(lineEnd - 1) === 13)
lineEnd--;
this.pos = nlPos < this.len ? nlPos + 1 : this.len;
this.lineNum++;
if (arrowPos === -1 || arrowPos >= lineEnd) {
if (this.pos >= this.len)
return null;
lineStart = this.pos;
arrowPos = this.src.indexOf(" --> ", lineStart);
nlPos = this.src.indexOf(`
`, lineStart);
if (nlPos === -1)
nlPos = this.len;
lineEnd = nlPos;
if (lineEnd > lineStart && this.src.charCodeAt(lineEnd - 1) === 13)
lineEnd--;
this.pos = nlPos < this.len ? nlPos + 1 : this.len;
this.lineNum++;
}
if (arrowPos === -1 || arrowPos >= lineEnd) {
this.addError("INVALID_TIMESTAMP", `Invalid time line`);
this.skipToNextCue();
return null;
}
const afterArrowStart = arrowPos + 5;
const spacePos = this.src.indexOf(" ", afterArrowStart);
const endPos = spacePos === -1 || spacePos > lineEnd ? lineEnd : spacePos;
const startLen = arrowPos - lineStart;
let start;
if (startLen === 12) {
const s = this.src;
const o = lineStart;
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);
} else if (startLen === 9) {
const s = this.src;
const o = lineStart;
start = ((s.charCodeAt(o) - 48) * 10 + (s.charCodeAt(o + 1) - 48)) * 60000 + ((s.charCodeAt(o + 3) - 48) * 10 + (s.charCodeAt(o + 4) - 48)) * 1000 + (s.charCodeAt(o + 6) - 48) * 100 + (s.charCodeAt(o + 7) - 48) * 10 + (s.charCodeAt(o + 8) - 48);
} else {
this.addError("INVALID_TIMESTAMP", `Invalid timestamp`);
this.skipToNextCue();
return null;
}
const endLen = endPos - afterArrowStart;
let end;
if (endLen === 12) {
const s = this.src;
const o = afterArrowStart;
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);
} else if (endLen === 9) {
const s = this.src;
const o = afterArrowStart;
end = ((s.charCodeAt(o) - 48) * 10 + (s.charCodeAt(o + 1) - 48)) * 60000 + ((s.charCodeAt(o + 3) - 48) * 10 + (s.charCodeAt(o + 4) - 48)) * 1000 + (s.charCodeAt(o + 6) - 48) * 100 + (s.charCodeAt(o + 7) - 48) * 10 + (s.charCodeAt(o + 8) - 48);
} else {
this.addError("INVALID_TIMESTAMP", `Invalid timestamp`);
this.skipToNextCue();
return null;
}
const textStart = this.pos;
let textEnd = this.pos;
while (this.pos < this.len) {
const ls = this.pos;
let nl = this.src.indexOf(`
`, this.pos);
if (nl === -1)
nl = this.len;
let le = nl;
if (le > ls && this.src.charCodeAt(le - 1) === 13)
le--;
let isEmpty = true;
for (let i = ls;i < le; i++) {
const c = this.src.charCodeAt(i);
if (c !== 32 && c !== 9) {
isEmpty = false;
break;
}
}
if (isEmpty)
break;
textEnd = le;
this.pos = nl < this.len ? nl + 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
};
}
skipToNextCue() {
while (this.pos < this.len) {
const line = this.readLine();
if (line.trim() === "")
break;
}
}
addError(code, message, raw) {
if (this.opts.onError === "skip")
return;
this.errors.push({ line: this.lineNum, column: 1, code, message, raw });
}
}
function parseVTT(input, opts) {
try {
const parser = new VTTParser(input, opts);
return parser.parse();
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
};
}
}
// src/formats/text/vtt/serializer.ts
function toVTT(doc) {
let result = `WEBVTT
`;
if (doc.regions && doc.regions.length > 0) {
const regions = doc.regions;
const regLen = regions.length;
for (let i = 0;i < regLen; i++) {
const region = regions[i];
result += `REGION
`;
result += "id:" + region.id + `
`;
result += "width:" + region.width + `
`;
result += "lines:" + region.lines + `
`;
result += "regionanchor:" + region.regionAnchor + `
`;
result += "viewportanchor:" + region.viewportAnchor + `
`;
if (region.scroll !== "none") {
result += "scroll:" + region.scroll + `
`;
}
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 += formatTime(event.start) + " --> " + formatTime(event.end) + `
`;
result += text + `
`;
}
return result;
}
export {
toVTT,
stripTags,
serializeTags,
parseVTT,
parseTime,
parseTags,
formatTime
};