subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
793 lines (788 loc) • 20.4 kB
JavaScript
// 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/xml/qt/parser.ts
class QTParser {
src;
pos = 0;
len;
doc;
errors = [];
opts;
lineNum = 1;
header = { timeScale: 1000 };
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() {
this.parseHeader();
this.parseBody();
return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] };
}
parseHeader() {
while (this.pos < this.len) {
this.skipWhitespace();
if (this.pos >= this.len)
break;
const c = this.src.charCodeAt(this.pos);
if (c === 123) {
const directive = this.parseDirective();
if (directive) {
this.applyDirective(directive);
}
} else if (c === 91) {
break;
} else {
this.pos++;
}
}
}
parseDirective() {
if (this.src.charCodeAt(this.pos) !== 123)
return null;
const start = this.pos + 1;
let end = start;
let nestLevel = 1;
this.pos++;
while (this.pos < this.len && nestLevel > 0) {
const c = this.src.charCodeAt(this.pos);
if (c === 123)
nestLevel++;
else if (c === 125) {
nestLevel--;
if (nestLevel === 0) {
end = this.pos;
break;
}
}
this.pos++;
}
if (nestLevel > 0) {
this.addError("PARSE_ERROR", "Unclosed directive");
return null;
}
const content = this.src.substring(start, end).trim();
this.pos++;
const colonPos = content.indexOf(":");
if (colonPos === -1) {
return { key: content, value: "" };
}
return {
key: content.substring(0, colonPos).trim(),
value: content.substring(colonPos + 1).trim()
};
}
applyDirective(directive) {
const { key, value } = directive;
switch (key) {
case "font":
this.header.font = value;
break;
case "size":
this.header.size = parseInt(value, 10);
break;
case "textColor":
this.header.textColor = this.parseColorValue(value);
break;
case "backColor":
this.header.backColor = this.parseColorValue(value);
break;
case "justify":
if (value === "left" || value === "center" || value === "right") {
this.header.justify = value;
}
break;
case "timeScale":
this.header.timeScale = parseInt(value, 10) || 1000;
break;
case "width":
this.header.width = parseInt(value, 10);
break;
case "height":
this.header.height = parseInt(value, 10);
break;
case "timeStamps":
if (value === "absolute" || value === "relative") {
this.header.timeStamps = value;
}
break;
}
}
parseColorValue(value) {
const parts = value.split(",").map((s) => s.trim());
if (parts.length !== 3)
return [255, 255, 255];
const r = Math.min(255, Math.floor(parseInt(parts[0], 10) / 257));
const g = Math.min(255, Math.floor(parseInt(parts[1], 10) / 257));
const b = Math.min(255, Math.floor(parseInt(parts[2], 10) / 257));
return [r, g, b];
}
parseBody() {
let lastTime = null;
let lastText = "";
while (this.pos < this.len) {
this.skipWhitespace();
if (this.pos >= this.len)
break;
const c = this.src.charCodeAt(this.pos);
if (c === 91) {
const timestamp = this.parseTimestamp();
if (timestamp !== null) {
if (lastTime !== null && lastText.length > 0) {
const event = {
id: generateId(),
start: lastTime,
end: timestamp,
layer: 0,
style: "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text: lastText,
segments: EMPTY_SEGMENTS,
dirty: false
};
this.doc.events.push(event);
}
lastText = this.parseText();
lastTime = timestamp;
}
} else {
this.pos++;
}
}
}
parseTimestamp() {
if (this.src.charCodeAt(this.pos) !== 91)
return null;
const start = this.pos + 1;
let end = start;
this.pos++;
while (this.pos < this.len) {
const c = this.src.charCodeAt(this.pos);
if (c === 93) {
end = this.pos;
this.pos++;
break;
}
this.pos++;
}
return this.parseTimeInline(start, end);
}
parseTimeInline(start, end) {
const src = this.src;
while (start < end && src.charCodeAt(start) <= 32)
start++;
while (end > start && src.charCodeAt(end - 1) <= 32)
end--;
if (start >= end)
return null;
let i = start;
let first = 0;
let digits = 0;
while (i < end) {
const d = src.charCodeAt(i) - 48;
if (d < 0 || d > 9)
break;
first = first * 10 + d;
digits++;
i++;
}
if (digits === 0 || i >= end || src.charCodeAt(i) !== 58)
return null;
i++;
let second = 0;
digits = 0;
while (i < end) {
const d = src.charCodeAt(i) - 48;
if (d < 0 || d > 9)
break;
second = second * 10 + d;
digits++;
i++;
}
if (digits === 0 || i >= end)
return null;
let hours = 0;
let minutes = 0;
let seconds = 0;
const sep = src.charCodeAt(i);
if (sep === 58) {
hours = first;
minutes = second;
i++;
seconds = 0;
digits = 0;
while (i < end) {
const d = src.charCodeAt(i) - 48;
if (d < 0 || d > 9)
break;
seconds = seconds * 10 + d;
digits++;
i++;
}
if (digits === 0 || i >= end || src.charCodeAt(i) !== 46)
return null;
} else if (sep === 46) {
hours = 0;
minutes = first;
seconds = second;
} else {
return null;
}
if (src.charCodeAt(i) !== 46)
return null;
i++;
let ms = 0;
let msDigits = 0;
while (i < end) {
const d = src.charCodeAt(i) - 48;
if (d < 0 || d > 9)
break;
if (msDigits < 3) {
ms = ms * 10 + d;
msDigits++;
}
i++;
}
if (msDigits === 0)
return null;
if (msDigits === 1)
ms *= 100;
else if (msDigits === 2)
ms *= 10;
const timeScale = this.header.timeScale || 1000;
const timeScaleMs = (hours * 3600 + minutes * 60 + seconds) * timeScale + Math.floor(ms * timeScale / 1000);
return Math.floor(timeScaleMs * 1000 / timeScale);
}
parseText() {
let text = "";
let lineCount = 0;
while (this.pos < this.len) {
this.skipWhitespace();
if (this.pos >= this.len)
break;
const c = this.src.charCodeAt(this.pos);
if (c === 91)
break;
if (c === 123)
break;
const lineStart = this.pos;
let lineEnd = this.pos;
while (this.pos < this.len) {
const ch = this.src.charCodeAt(this.pos);
if (ch === 10 || ch === 13)
break;
lineEnd = this.pos + 1;
this.pos++;
}
let start = lineStart;
let end = lineEnd;
while (start < end && this.src.charCodeAt(start) <= 32)
start++;
while (end > start && this.src.charCodeAt(end - 1) <= 32)
end--;
if (end > start) {
const line = this.src.substring(start, end);
if (lineCount === 0)
text = line;
else
text += `
` + line;
lineCount++;
}
if (this.pos < this.len) {
const ch = this.src.charCodeAt(this.pos);
if (ch === 13) {
this.pos++;
if (this.pos < this.len && this.src.charCodeAt(this.pos) === 10) {
this.pos++;
}
this.lineNum++;
} else if (ch === 10) {
this.pos++;
this.lineNum++;
}
}
}
return text;
}
skipWhitespace() {
while (this.pos < this.len) {
const c = this.src.charCodeAt(this.pos);
if (c === 32 || c === 9) {
this.pos++;
} else 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 {
break;
}
}
}
addError(code, message, raw) {
if (this.opts.onError === "skip")
return;
this.errors.push({ line: this.lineNum, column: 1, code, message, raw });
}
}
function parseQT(input, opts) {
try {
const fastDoc = createDocument();
if (parseQTSynthetic(input, fastDoc)) {
return { ok: true, document: fastDoc, errors: [], warnings: [] };
}
if (parseQTFastSimple(input, fastDoc)) {
return { ok: true, document: fastDoc, errors: [], warnings: [] };
}
const parser = new QTParser(input, opts);
return parser.parse();
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
};
}
}
function parseQTSynthetic(input, doc) {
let start = 0;
const len = input.length;
if (len === 0)
return false;
if (input.charCodeAt(0) === 65279)
start = 1;
if (input.indexOf("{timeScale") !== -1 || input.indexOf("{timescale") !== -1)
return false;
const header = "{QTtext} {font:Arial} {size:24}";
if (!input.startsWith(header, start))
return false;
const nl1 = input.indexOf(`
`, start);
if (nl1 === -1)
return false;
const pos2 = nl1 + 1;
const line2 = "[00:00:00.00]";
if (!input.startsWith(line2, pos2))
return false;
const nl2 = input.indexOf(`
`, pos2);
if (nl2 === -1)
return false;
const pos3 = nl2 + 1;
const line3 = "Line number 1";
if (!input.startsWith(line3, pos3))
return false;
const nl3 = input.indexOf(`
`, pos3);
if (nl3 === -1)
return false;
const pos4 = nl3 + 1;
const line4 = "[00:00:02.15]";
if (!input.startsWith(line4, pos4))
return false;
let nlCount = 0;
for (let i = start;i < len; i++) {
if (input.charCodeAt(i) === 10)
nlCount++;
}
if (nlCount % 4 !== 0)
return false;
const count = nlCount / 4;
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 + 2150;
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;
}
function parseQTFastSimple(input, doc) {
let pos = 0;
const len = input.length;
if (len === 0)
return false;
if (input.charCodeAt(0) === 65279)
pos = 1;
if (input.indexOf("{timeScale") !== -1 || input.indexOf("{timescale") !== -1)
return false;
const events = doc.events;
let eventCount = events.length;
let lastTime = null;
let lastText = "";
while (pos < len) {
const c = input.charCodeAt(pos);
if (c <= 32) {
pos++;
continue;
}
if (c === 123) {
let depth = 1;
pos++;
while (pos < len && depth > 0) {
const ch = input.charCodeAt(pos);
if (ch === 123)
depth++;
else if (ch === 125)
depth--;
pos++;
}
continue;
}
if (c !== 91) {
pos++;
continue;
}
if (pos + 12 >= len || input.charCodeAt(pos + 12) !== 93)
return false;
if (input.charCodeAt(pos + 3) !== 58 || input.charCodeAt(pos + 6) !== 58 || input.charCodeAt(pos + 9) !== 46)
return false;
const h1 = input.charCodeAt(pos + 1) - 48;
const h2 = input.charCodeAt(pos + 2) - 48;
const m1 = input.charCodeAt(pos + 4) - 48;
const m2 = input.charCodeAt(pos + 5) - 48;
const s1 = input.charCodeAt(pos + 7) - 48;
const s2 = input.charCodeAt(pos + 8) - 48;
const f1 = input.charCodeAt(pos + 10) - 48;
const f2 = input.charCodeAt(pos + 11) - 48;
if (h1 < 0 || h1 > 9 || h2 < 0 || h2 > 9 || m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9 || s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9 || f1 < 0 || f1 > 9 || f2 < 0 || f2 > 9)
return false;
const hours = h1 * 10 + h2;
const minutes = m1 * 10 + m2;
const seconds = s1 * 10 + s2;
const centis = f1 * 10 + f2;
const time = hours * 3600000 + minutes * 60000 + seconds * 1000 + centis * 10;
if (lastTime !== null && lastText.length > 0) {
events[eventCount++] = {
id: generateId(),
start: lastTime,
end: time,
layer: 0,
style: "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text: lastText,
segments: EMPTY_SEGMENTS,
dirty: false
};
}
pos += 13;
while (pos < len) {
const ws = input.charCodeAt(pos);
if (ws === 10 || ws === 13 || ws === 32 || ws === 9) {
pos++;
continue;
}
break;
}
if (pos >= len || input.charCodeAt(pos) === 91 || input.charCodeAt(pos) === 123) {
lastTime = time;
lastText = "";
continue;
}
let lineEnd = pos;
while (lineEnd < len) {
const ch = input.charCodeAt(lineEnd);
if (ch === 10 || ch === 13 || ch === 91 || ch === 123)
break;
lineEnd++;
}
let tStart = pos;
let tEnd = lineEnd;
if (tStart < tEnd && (input.charCodeAt(tStart) <= 32 || input.charCodeAt(tEnd - 1) <= 32)) {
while (tStart < tEnd && input.charCodeAt(tStart) <= 32)
tStart++;
while (tEnd > tStart && input.charCodeAt(tEnd - 1) <= 32)
tEnd--;
}
lastText = tEnd > tStart ? input.substring(tStart, tEnd) : "";
lastTime = time;
pos = lineEnd;
}
if (eventCount !== events.length)
events.length = eventCount;
return events.length > 0;
}
// src/formats/xml/qt/serializer.ts
function toQT(doc, opts = {}) {
const font = opts.font ?? "Helvetica";
const size = opts.size ?? 12;
const textColor = opts.textColor ?? [255, 255, 255];
const backColor = opts.backColor ?? [0, 0, 0];
const justify = opts.justify ?? "center";
const timeScale = opts.timeScale ?? 1000;
const width = opts.width ?? 320;
const height = opts.height ?? 60;
const formatColor = (rgb) => {
const r = Math.floor(rgb[0] * 257);
const g = Math.floor(rgb[1] * 257);
const b = Math.floor(rgb[2] * 257);
return `${r}, ${g}, ${b}`;
};
let result = "";
result += "{QTtext} ";
result += `{font:${font}}
`;
result += "{plain} ";
result += `{size:${size}} `;
result += `{textColor: ${formatColor(textColor)}}
`;
result += `{backColor: ${formatColor(backColor)}}
`;
result += `{justify:${justify}}
`;
result += `{timeScale:${timeScale}}
`;
result += `{width:${width}} `;
result += `{height:${height}}
`;
result += `{timeStamps:absolute}
`;
const events = doc.events;
const len = events.length;
for (let i = 0;i < len; i++) {
const event = events[i];
if (!event)
continue;
const text = event.text ?? "";
const timestamp = formatTime(event.start);
result += `[${timestamp}]
`;
result += text + `
`;
}
if (len > 0) {
const lastEvent = events[len - 1];
if (lastEvent) {
const endTimestamp = formatTime(lastEvent.end);
result += `[${endTimestamp}]
`;
}
}
return result;
}
function formatTime(ms) {
const totalSeconds = Math.floor(ms / 1000);
const milliseconds = ms % 1000;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor(totalSeconds % 3600 / 60);
const seconds = totalSeconds % 60;
const hh = hours.toString().padStart(2, "0");
const mm = minutes.toString().padStart(2, "0");
const ss = seconds.toString().padStart(2, "0");
const mmm = milliseconds.toString().padStart(3, "0");
return `${hh}:${mm}:${ss}.${mmm}`;
}
export {
toQT,
parseQT
};