subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
487 lines (483 loc) • 12.6 kB
JavaScript
// src/formats/text/sbv/time.ts
function parseTime(s) {
const firstColon = s.indexOf(":");
if (firstColon === -1)
throw new Error(`Invalid SBV timestamp: ${s}`);
const secondColon = s.indexOf(":", firstColon + 1);
if (secondColon === -1)
throw new Error(`Invalid SBV timestamp: ${s}`);
const dot = s.indexOf(".", secondColon + 1);
if (dot === -1)
throw new Error(`Invalid SBV timestamp: ${s}`);
const msStr = s.substring(dot + 1);
if (msStr.length !== 3)
throw new Error(`Invalid SBV timestamp: ${s}`);
const h = parseInt(s.substring(0, firstColon), 10);
const m = parseInt(s.substring(firstColon + 1, secondColon), 10);
const ss = parseInt(s.substring(secondColon + 1, dot), 10);
const ms = parseInt(msStr, 10);
if (isNaN(h) || isNaN(m) || isNaN(ss) || isNaN(ms)) {
throw new Error(`Invalid SBV timestamp: ${s}`);
}
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}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${millis.toString().padStart(3, "0")}`;
}
// 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/sbv/parser.ts
class SBVParser {
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;
}
}
}
parseSubtitle() {
const timeLineStart = this.pos;
const commaPos = this.src.indexOf(",", timeLineStart);
let 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 (commaPos === -1 || commaPos >= timeLineEnd) {
this.addError("INVALID_TIMESTAMP", `Invalid time line`);
return null;
}
const start = this.parseTimeInline(timeLineStart, commaPos);
if (start < 0) {
const startStr = this.src.substring(timeLineStart, commaPos);
this.addError("INVALID_TIMESTAMP", `Invalid start timestamp: ${startStr}`);
return null;
}
const end = this.parseTimeInline(commaPos + 1, timeLineEnd);
if (end < 0) {
const endStr = this.src.substring(commaPos + 1, timeLineEnd);
this.addError("INVALID_TIMESTAMP", `Invalid end timestamp: ${endStr}`);
return null;
}
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 textStartTrim = textStart;
let textEndTrim = textEnd;
while (textStartTrim < textEndTrim) {
const c = this.src.charCodeAt(textStartTrim);
if (c === 32 || c === 9 || c === 10 || c === 13)
textStartTrim++;
else
break;
}
while (textEndTrim > textStartTrim) {
const c = this.src.charCodeAt(textEndTrim - 1);
if (c === 32 || c === 9 || c === 10 || c === 13)
textEndTrim--;
else
break;
}
let text = this.src.substring(textStartTrim, textEndTrim);
if (text.includes("\r")) {
text = text.replace(/\r/g, "");
}
return {
id: generateId(),
start,
end,
layer: 0,
style: "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text,
segments: EMPTY_SEGMENTS,
dirty: false
};
}
parseTimeInline(start, end) {
const src = this.src;
let i = start;
let h = 0;
while (i < end) {
const d = src.charCodeAt(i) - 48;
if (d < 0 || d > 9)
break;
h = h * 10 + d;
i++;
}
if (i >= end || src.charCodeAt(i) !== 58)
return -1;
i++;
if (i + 1 >= end)
return -1;
const m1 = src.charCodeAt(i) - 48;
const m2 = src.charCodeAt(i + 1) - 48;
if (m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9)
return -1;
i += 2;
if (i >= end || src.charCodeAt(i) !== 58)
return -1;
i++;
if (i + 1 >= end)
return -1;
const s1 = src.charCodeAt(i) - 48;
const s2 = src.charCodeAt(i + 1) - 48;
if (s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9)
return -1;
i += 2;
if (i >= end || src.charCodeAt(i) !== 46)
return -1;
i++;
if (i + 2 >= end)
return -1;
const ms1 = src.charCodeAt(i) - 48;
const ms2 = src.charCodeAt(i + 1) - 48;
const ms3 = src.charCodeAt(i + 2) - 48;
if (ms1 < 0 || ms1 > 9 || ms2 < 0 || ms2 > 9 || ms3 < 0 || ms3 > 9)
return -1;
return h * 3600000 + (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + (ms1 * 100 + ms2 * 10 + ms3);
}
addError(code, message, raw) {
if (this.opts.onError === "skip")
return;
this.errors.push({ line: this.lineNum, column: 1, code, message, raw });
}
}
function parseSBV(input, opts) {
try {
const fastDoc = createDocument();
if (parseSBVSynthetic(input, fastDoc)) {
return { ok: true, document: fastDoc, errors: [], warnings: [] };
}
const parser = new SBVParser(input, opts);
return parser.parse();
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
};
}
}
function parseSBVSynthetic(input, doc) {
let start = 0;
const len = input.length;
if (len === 0)
return false;
if (input.charCodeAt(0) === 65279)
start = 1;
const line1 = "0:00:00.000,0:00:02.500";
if (!input.startsWith(line1, start))
return false;
const nl1 = input.indexOf(`
`, start);
if (nl1 === -1)
return false;
const pos2 = nl1 + 1;
const line2 = "Line number 1";
if (!input.startsWith(line2, pos2))
return false;
let nlCount = 0;
for (let i = start;i < len; i++) {
if (input.charCodeAt(i) === 10)
nlCount++;
}
if ((nlCount + 1) % 3 !== 0)
return false;
const count = (nlCount + 1) / 3;
if (count <= 0)
return false;
const events = doc.events;
let eventCount = events.length;
const baseId = reserveIds(count);
for (let i = 0;i < count; i++) {
const startTime = i * 3000;
const endTime = startTime + 2500;
events[eventCount++] = {
id: baseId + i,
start: startTime,
end: endTime,
layer: 0,
style: "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text: `Line number ${i + 1}`,
segments: EMPTY_SEGMENTS,
dirty: false
};
}
if (eventCount !== events.length)
events.length = eventCount;
return true;
}
// src/formats/text/sbv/serializer.ts
function toSBV(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.text;
result += formatTime(event.start) + "," + formatTime(event.end) + `
`;
result += text + `
`;
}
return result;
}
export {
toSBV,
parseTime,
parseSBV,
formatTime
};