subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
501 lines (498 loc) • 13.2 kB
JavaScript
// src/formats/text/microdvd/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);
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.includes(":"))
return;
const colonIdx = tag.indexOf(":");
const prefix = tag.slice(0, colonIdx).toLowerCase();
const value = tag.slice(colonIdx + 1);
if (prefix === "y") {
const newState = { ...currentState };
for (const char of value.toLowerCase()) {
if (char === "b")
newState.bold = true;
else if (char === "i")
newState.italic = true;
else if (char === "u")
newState.underline = true;
else if (char === "s")
newState.strikeout = true;
}
stateStack[stateStack.length] = newState;
} else if (prefix === "c" || prefix === "C") {
if (value.startsWith("$") && value.length === 7) {
const hex = value.slice(1);
const bb = parseInt(hex.slice(0, 2), 16);
const gg = parseInt(hex.slice(2, 4), 16);
const rr = parseInt(hex.slice(4, 6), 16);
const color = (bb & 255) << 16 | (gg & 255) << 8 | rr & 255;
stateStack[stateStack.length] = { ...currentState, color };
}
} else if (prefix === "f") {
stateStack[stateStack.length] = { ...currentState, fontName: value };
} else if (prefix === "s") {
const size = parseInt(value, 10);
if (!isNaN(size)) {
stateStack[stateStack.length] = { ...currentState, fontSize: size };
}
}
}
function buildStyle(state) {
if (!state.bold && !state.italic && !state.underline && !state.strikeout && state.color === undefined && state.fontName === undefined && state.fontSize === 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;
if (state.fontName !== undefined)
style.fontName = state.fontName;
if (state.fontSize !== undefined)
style.fontSize = state.fontSize;
return style;
}
function serializeTags(segments) {
let result = "";
let activeStyles = [];
for (const seg of segments) {
let text = seg.text;
const tags = [];
if (seg.style) {
const formatChars = [];
if (seg.style.bold)
formatChars.push("b");
if (seg.style.italic)
formatChars.push("i");
if (seg.style.underline)
formatChars.push("u");
if (seg.style.strikeout)
formatChars.push("s");
if (formatChars.length > 0) {
tags.push(`{y:${formatChars.join("")}}`);
}
if (seg.style.primaryColor !== undefined) {
const color = seg.style.primaryColor;
const rr = color & 255;
const gg = color >> 8 & 255;
const bb = color >> 16 & 255;
const hex = `${bb.toString(16).padStart(2, "0")}${gg.toString(16).padStart(2, "0")}${rr.toString(16).padStart(2, "0")}`;
tags.push(`{C:$${hex}}`);
}
if (seg.style.fontName !== undefined) {
tags.push(`{f:${seg.style.fontName}}`);
}
if (seg.style.fontSize !== undefined) {
tags.push(`{s:${seg.style.fontSize}}`);
}
}
result += tags.join("") + text;
}
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/microdvd/parser.ts
class MicroDVDParser {
src;
pos = 0;
len;
doc;
errors = [];
opts;
lineNum = 1;
fps;
constructor(input, fps, opts = {}) {
let start = 0;
if (input.charCodeAt(0) === 65279)
start = 1;
this.src = input;
this.pos = start;
this.len = input.length;
this.fps = fps;
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() {
if (this.src.charCodeAt(this.pos) !== 123) {
const nlPos2 = this.src.indexOf(`
`, this.pos);
const endPos = nlPos2 === -1 ? this.len : nlPos2;
let hasContent = false;
for (let i = this.pos;i < endPos; i++) {
const code = this.src.charCodeAt(i);
if (code !== 32 && code !== 9 && code !== 13) {
hasContent = true;
break;
}
}
if (hasContent) {
this.addError("INVALID_FORMAT", "Expected {start}{end} frame markers");
}
if (nlPos2 === -1) {
this.pos = this.len;
} else {
this.pos = nlPos2 + 1;
this.lineNum++;
}
return null;
}
this.pos++;
let numStart = this.pos;
while (this.pos < this.len && this.isDigit(this.src.charCodeAt(this.pos))) {
this.pos++;
}
if (this.pos >= this.len || this.src.charCodeAt(this.pos) !== 125) {
this.addError("INVALID_FORMAT", "Invalid start frame");
return null;
}
const startFrame = parseInt(this.src.slice(numStart, this.pos), 10);
this.pos++;
if (this.pos >= this.len || this.src.charCodeAt(this.pos) !== 123) {
this.addError("INVALID_FORMAT", "Missing end frame");
return null;
}
this.pos++;
numStart = this.pos;
while (this.pos < this.len && this.isDigit(this.src.charCodeAt(this.pos))) {
this.pos++;
}
if (this.pos >= this.len || this.src.charCodeAt(this.pos) !== 125) {
this.addError("INVALID_FORMAT", "Invalid end frame");
return null;
}
const endFrame = parseInt(this.src.slice(numStart, this.pos), 10);
this.pos++;
const textStart = this.pos;
let nlPos = this.src.indexOf(`
`, this.pos);
if (nlPos === -1)
nlPos = this.len;
let textEnd = nlPos;
if (textEnd > textStart && this.src.charCodeAt(textEnd - 1) === 13)
textEnd--;
let text = this.src.slice(textStart, textEnd).trim();
if (text.includes("|")) {
text = text.replace(/\|/g, `
`);
}
this.pos = nlPos < this.len ? nlPos + 1 : this.len;
this.lineNum++;
const start = Math.round(startFrame / this.fps * 1000);
const end = Math.round(endFrame / this.fps * 1000);
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 parseMicroDVD(input, opts) {
try {
const parser = new MicroDVDParser(input, opts.fps, opts);
return parser.parse();
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
};
}
}
// src/formats/text/microdvd/serializer.ts
function toMicroDVD(doc, opts) {
const fps = opts.fps;
let result = "";
const events = doc.events;
const len = events.length;
for (let i = 0;i < len; i++) {
const event = events[i];
let text = event.dirty && event.segments.length > 0 ? serializeTags(event.segments) : event.text;
if (text.includes(`
`)) {
text = text.replace(/\n/g, "|");
}
const startFrame = Math.round(event.start / 1000 * fps);
const endFrame = Math.round(event.end / 1000 * fps);
result += `{${startFrame}}{${endFrame}}${text}
`;
}
return result;
}
export {
toMicroDVD,
stripTags,
serializeTags,
parseTags,
parseMicroDVD
};