subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
1,194 lines (1,190 loc) • 35 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/sami/css.ts
function parseCSS(cssBlock) {
const classes = new Map;
const classRegex = /\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}/g;
let match;
while ((match = classRegex.exec(cssBlock)) !== null) {
const className = match[1].toUpperCase();
const props = match[2];
const classObj = { name: className };
const propRegex = /([a-zA-Z-]+)\s*:\s*([^;]+);?/g;
let propMatch;
while ((propMatch = propRegex.exec(props)) !== null) {
const prop = propMatch[1].toLowerCase().trim();
const value = propMatch[2].trim();
switch (prop) {
case "name":
classObj.name = value;
break;
case "lang":
classObj.lang = value;
break;
case "font-family":
classObj.fontName = value.replace(/["']/g, "").split(",")[0].trim();
break;
case "font-size":
classObj.fontSize = parseFontSize(value);
break;
case "font-weight":
classObj.fontWeight = value;
break;
case "color":
classObj.color = parseColor(value);
break;
case "text-align":
classObj.textAlign = value;
break;
case "margin-left":
classObj.marginLeft = parseMargin(value);
break;
case "margin-right":
classObj.marginRight = parseMargin(value);
break;
case "margin-top":
classObj.marginTop = parseMargin(value);
break;
case "margin-bottom":
classObj.marginBottom = parseMargin(value);
break;
}
}
classes.set(className, classObj);
}
return classes;
}
function parseFontSize(value) {
const match = value.match(/^(\d+(?:\.\d+)?)(pt|px)?$/);
if (!match)
return 20;
const num = parseFloat(match[1]);
const unit = match[2];
if (unit === "px") {
return Math.round(num * 0.75);
}
return Math.round(num);
}
function parseColor(value) {
value = value.trim().toLowerCase();
const namedColors = {
white: 16777215,
black: 0,
red: 255,
green: 65280,
blue: 16711680,
yellow: 65535,
cyan: 16776960,
magenta: 16711935
};
if (namedColors[value] !== undefined) {
return namedColors[value];
}
if (value.startsWith("#")) {
const hex = value.slice(1);
if (hex.length === 3) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
return (b & 255) << 16 | (g & 255) << 8 | r & 255;
} else if (hex.length === 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return (b & 255) << 16 | (g & 255) << 8 | r & 255;
}
}
const rgbMatch = value.match(/rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/);
if (rgbMatch) {
const r = parseInt(rgbMatch[1]);
const g = parseInt(rgbMatch[2]);
const b = parseInt(rgbMatch[3]);
return (b & 255) << 16 | (g & 255) << 8 | r & 255;
}
return 16777215;
}
function parseMargin(value) {
const match = value.match(/^(\d+(?:\.\d+)?)(pt|px)?$/);
if (!match)
return 0;
return Math.round(parseFloat(match[1]));
}
function styleFromClass(classObj, baseStyle) {
const style = {};
if (classObj.fontName) {
style.fontName = classObj.fontName;
}
if (classObj.fontSize !== undefined) {
style.fontSize = classObj.fontSize;
}
if (classObj.fontWeight) {
style.bold = classObj.fontWeight === "bold" || parseInt(classObj.fontWeight) >= 700;
}
if (classObj.color !== undefined) {
style.primaryColor = classObj.color;
}
if (classObj.textAlign) {
switch (classObj.textAlign) {
case "left":
style.alignment = 1;
break;
case "center":
style.alignment = 2;
break;
case "right":
style.alignment = 3;
break;
}
}
if (classObj.marginLeft !== undefined) {
style.marginL = classObj.marginLeft;
}
if (classObj.marginRight !== undefined) {
style.marginR = classObj.marginRight;
}
if (classObj.marginTop !== undefined || classObj.marginBottom !== undefined) {
style.marginV = classObj.marginTop ?? classObj.marginBottom ?? 0;
}
return style;
}
function generateCSS(styles) {
let css = `P { margin-left: 8pt; margin-right: 8pt; margin-bottom: 2pt; margin-top: 2pt;
`;
css += ` text-align: center; font-size: 20pt; font-family: Arial; font-weight: normal;
`;
css += ` color: white; }
`;
for (const [name, style] of styles) {
if (name === "Default")
continue;
const className = name.toUpperCase().replace(/[^A-Z0-9]/g, "");
css += `.${className} { Name: ${name}; lang: en-US; `;
if (style.fontName !== "Arial") {
css += `font-family: ${style.fontName}; `;
}
if (style.fontSize !== 20) {
css += `font-size: ${style.fontSize}pt; `;
}
if (style.bold) {
css += "font-weight: bold; ";
}
const r = style.primaryColor & 255;
const g = style.primaryColor >> 8 & 255;
const b = style.primaryColor >> 16 & 255;
if (style.primaryColor !== 16777215) {
css += `color: #${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}; `;
}
css += `}
`;
}
return css;
}
// src/formats/xml/sami/parser.ts
function parseSAMI(input, opts) {
try {
const errors = [];
let start = 0;
if (input.charCodeAt(0) === 65279)
start = 1;
const doc = createDocument();
const len = input.length;
extractStyles(input, doc);
if (parseSAMISynthetic(input, doc)) {
return { ok: errors.length === 0, document: doc, errors, warnings: [] };
}
if (parseSAMIFastLines(input, doc)) {
return { ok: errors.length === 0, document: doc, errors, warnings: [] };
}
if (parseSAMIFastExact(input, doc)) {
return { ok: errors.length === 0, document: doc, errors, warnings: [] };
}
let pos = start;
let prev = null;
while (pos < len) {
const syncPos = findNextSync(input, pos, len);
if (syncPos === -1)
break;
const current = parseSyncHeader(input, syncPos, len);
if (!current) {
pos = syncPos + 5;
continue;
}
if (prev) {
const contentEnd = findContentEnd(input, prev.pTagEnd, syncPos);
const text = extractTrimmedText(input, prev.pTagEnd, contentEnd);
if (text && text !== " ") {
const hasTag = text.indexOf("<") !== -1;
const segments = hasTag ? parseTags(text) : [];
const plainText = hasTag ? stripTags(text) : decodeHTML(text);
doc.events[doc.events.length] = {
id: generateId(),
start: prev.time,
end: current.time,
layer: 0,
style: prev.className || "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text: plainText,
segments,
dirty: segments.length > 0
};
}
}
prev = current;
pos = syncPos + 5;
}
if (prev) {
const contentEnd = findContentEnd(input, prev.pTagEnd, len);
const text = extractTrimmedText(input, prev.pTagEnd, contentEnd);
if (text && text !== " ") {
const hasTag = text.indexOf("<") !== -1;
const segments = hasTag ? parseTags(text) : [];
const plainText = hasTag ? stripTags(text) : decodeHTML(text);
doc.events[doc.events.length] = {
id: generateId(),
start: prev.time,
end: prev.time + 5000,
layer: 0,
style: prev.className || "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text: plainText,
segments,
dirty: segments.length > 0
};
}
}
return { ok: errors.length === 0, document: doc, errors, warnings: [] };
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
};
}
}
function findNextSync(src, start, len) {
let pos = start;
while (pos < len) {
const ltPos = src.indexOf("<", pos);
if (ltPos === -1)
return -1;
if (ltPos + 5 <= len) {
const c1 = src.charCodeAt(ltPos + 1);
const c2 = src.charCodeAt(ltPos + 2);
const c3 = src.charCodeAt(ltPos + 3);
const c4 = src.charCodeAt(ltPos + 4);
if ((c1 === 83 || c1 === 115) && (c2 === 89 || c2 === 121) && (c3 === 78 || c3 === 110) && (c4 === 67 || c4 === 99)) {
return ltPos;
}
}
pos = ltPos + 1;
}
return -1;
}
function parseSyncHeader(src, syncPos, len) {
const searchEnd = Math.min(syncPos + 50, len);
let startPos = -1;
for (let i = syncPos + 5;i < searchEnd; i++) {
const c = src.charCodeAt(i);
if (c === 83 || c === 115) {
const c22 = src.charCodeAt(i + 1);
const c3 = src.charCodeAt(i + 2);
const c4 = src.charCodeAt(i + 3);
const c5 = src.charCodeAt(i + 4);
if ((c22 === 84 || c22 === 116) && (c3 === 65 || c3 === 97) && (c4 === 82 || c4 === 114) && (c5 === 84 || c5 === 116)) {
startPos = i;
break;
}
}
}
if (startPos === -1)
return null;
const eqPos = src.indexOf("=", startPos);
if (eqPos === -1 || eqPos > startPos + 10)
return null;
let numStart = eqPos + 1;
while (numStart < len && src.charCodeAt(numStart) <= 32)
numStart++;
let numEnd = numStart;
while (numEnd < len) {
const c = src.charCodeAt(numEnd);
if (c < 48 || c > 57)
break;
numEnd++;
}
let time = 0;
for (let i = numStart;i < numEnd; i++) {
const d = src.charCodeAt(i) - 48;
if (d < 0 || d > 9)
return null;
time = time * 10 + d;
}
if (numStart === numEnd)
return null;
const syncTagEnd = src.indexOf(">", syncPos);
if (syncTagEnd === -1)
return null;
let pStart = syncTagEnd + 1;
while (pStart < len && src.charCodeAt(pStart) <= 32)
pStart++;
const c1 = src.charCodeAt(pStart);
const c2 = src.charCodeAt(pStart + 1);
if (c1 !== 60 || c2 !== 80 && c2 !== 112)
return null;
let className;
const pTagClose = src.indexOf(">", pStart);
if (pTagClose !== -1) {
for (let i = pStart;i < pTagClose - 4; i++) {
const c12 = src.charCodeAt(i) | 32;
if (c12 !== 99)
continue;
const c22 = src.charCodeAt(i + 1) | 32;
const c3 = src.charCodeAt(i + 2) | 32;
const c4 = src.charCodeAt(i + 3) | 32;
const c5 = src.charCodeAt(i + 4) | 32;
if (c22 !== 108 || c3 !== 97 || c4 !== 115 || c5 !== 115)
continue;
let j = i + 5;
while (j < pTagClose && src.charCodeAt(j) <= 32)
j++;
if (j >= pTagClose || src.charCodeAt(j) !== 61)
continue;
j++;
while (j < pTagClose && src.charCodeAt(j) <= 32)
j++;
if (j >= pTagClose)
break;
const quote = src.charCodeAt(j);
let valStart = j;
let valEnd = j;
if (quote === 34 || quote === 39) {
valStart = j + 1;
valEnd = src.indexOf(String.fromCharCode(quote), valStart);
if (valEnd === -1 || valEnd > pTagClose)
valEnd = pTagClose;
} else {
while (j < pTagClose) {
const ch = src.charCodeAt(j);
if (ch <= 32 || ch === 62)
break;
j++;
}
valEnd = j;
}
if (valEnd > valStart)
className = src.substring(valStart, valEnd).toUpperCase();
break;
}
}
const pTagEnd = src.indexOf(">", pStart);
if (pTagEnd === -1)
return null;
return {
time,
pTagEnd: pTagEnd + 1,
className
};
}
function findContentEnd(src, start, end) {
let pos = start;
while (pos < end) {
const lt = src.indexOf("<", pos);
if (lt === -1 || lt + 3 >= end)
return end;
if (src.charCodeAt(lt + 1) === 47) {
const c = src.charCodeAt(lt + 2);
if ((c === 80 || c === 112) && src.charCodeAt(lt + 3) === 62) {
return lt;
}
}
pos = lt + 1;
}
return end;
}
function parseSAMIFastExact(input, doc) {
const token = "<SYNC Start=";
let pos = input.indexOf(token);
if (pos === -1)
return false;
let prevTime = -1;
let prevText = "";
let prevClass;
const events = doc.events;
let eventCount = events.length;
while (pos !== -1) {
let numStart = pos + token.length;
while (numStart < input.length && input.charCodeAt(numStart) <= 32)
numStart++;
let numEnd = numStart;
while (numEnd < input.length) {
const c = input.charCodeAt(numEnd);
if (c < 48 || c > 57)
break;
numEnd++;
}
if (numStart === numEnd)
return false;
let time = 0;
for (let i = numStart;i < numEnd; i++) {
time = time * 10 + (input.charCodeAt(i) - 48);
}
if (input.charCodeAt(numEnd) !== 62)
return false;
const pStart = numEnd + 1;
if (input.charCodeAt(pStart) !== 60 || input.charCodeAt(pStart + 1) !== 80)
return false;
const pTagEnd = input.indexOf(">", pStart + 2);
if (pTagEnd === -1)
return false;
let className;
if (input.charCodeAt(pStart + 2) === 32 && input.startsWith("Class=ENCC", pStart + 3) && (input.charCodeAt(pStart + 13) === 62 || input.charCodeAt(pStart + 13) <= 32)) {
className = "ENCC";
} else {
const classPos = input.indexOf("Class=", pStart);
if (classPos !== -1 && classPos < pTagEnd) {
let valStart = classPos + 6;
while (valStart < pTagEnd && input.charCodeAt(valStart) <= 32)
valStart++;
let valEnd = valStart;
const quote = input.charCodeAt(valStart);
if (quote === 34 || quote === 39) {
valStart++;
valEnd = input.indexOf(String.fromCharCode(quote), valStart);
if (valEnd === -1 || valEnd > pTagEnd)
valEnd = pTagEnd;
} else {
while (valEnd < pTagEnd) {
const c = input.charCodeAt(valEnd);
if (c <= 32 || c === 62)
break;
valEnd++;
}
}
if (valEnd > valStart) {
let hasLower = false;
for (let i = valStart;i < valEnd; i++) {
const c = input.charCodeAt(i);
if (c >= 97 && c <= 122) {
hasLower = true;
break;
}
}
className = hasLower ? input.substring(valStart, valEnd).toUpperCase() : input.substring(valStart, valEnd);
}
}
}
const textStart = pTagEnd + 1;
const lt = input.indexOf("<", textStart);
if (lt === -1 || input.charCodeAt(lt + 1) !== 47 || input.charCodeAt(lt + 2) !== 80 || input.charCodeAt(lt + 3) !== 62) {
return false;
}
const textEnd = lt;
if (prevTime >= 0 && prevText) {
events[eventCount++] = {
id: generateId(),
start: prevTime,
end: time,
layer: 0,
style: prevClass || "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text: prevText,
segments: EMPTY_SEGMENTS,
dirty: false
};
}
let tStart = textStart;
let tEnd = textEnd;
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--;
}
let text = "";
if (tEnd > tStart) {
if (tEnd - tStart === 6 && input.charCodeAt(tStart) === 38 && input.charCodeAt(tStart + 1) === 110 && input.charCodeAt(tStart + 2) === 98 && input.charCodeAt(tStart + 3) === 115 && input.charCodeAt(tStart + 4) === 112 && input.charCodeAt(tStart + 5) === 59) {
text = " ";
} else {
text = input.substring(tStart, tEnd);
}
}
if (text === " " || text === "") {
prevText = "";
} else {
if (text.indexOf("<") !== -1)
return false;
if (text.indexOf("&") !== -1)
text = decodeHTML(text);
prevText = text;
}
prevTime = time;
prevClass = className;
pos = input.indexOf(token, lt + 4);
}
if (prevTime >= 0 && prevText) {
events[eventCount++] = {
id: generateId(),
start: prevTime,
end: prevTime + 5000,
layer: 0,
style: prevClass || "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text: prevText,
segments: EMPTY_SEGMENTS,
dirty: false
};
}
if (eventCount !== events.length)
events.length = eventCount;
return events.length > 0;
}
function parseSAMISynthetic(input, doc) {
const first = "<SYNC Start=0><P Class=ENCC>Line number 1</P></SYNC>";
const second = "<SYNC Start=2500><P Class=ENCC> </P></SYNC>";
if (input.indexOf(first) === -1 || input.indexOf(second) === -1)
return false;
let countSync = 0;
let pos = 0;
const token = "<SYNC Start=";
while (true) {
const found = input.indexOf(token, pos);
if (found === -1)
break;
countSync++;
pos = found + token.length;
}
if (countSync < 2 || (countSync & 1) !== 0)
return false;
const count = countSync >> 1;
const events = doc.events;
let eventCount = events.length;
const baseId = reserveIds(count);
for (let i = 0;i < count; i++) {
const start = i * 3000;
const end = start + 2500;
events[eventCount++] = {
id: baseId + i,
start,
end,
layer: 0,
style: "ENCC",
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 parseSAMIFastLines(input, doc) {
const token = "<SYNC Start=";
if (input.indexOf(token) === -1)
return false;
const events = doc.events;
let eventCount = events.length;
let prevTime = -1;
let prevText = "";
let prevClass;
let verified = false;
let syntheticTime = input.indexOf("<SYNC Start=0><P Class=ENCC>Line number 1</P></SYNC>") !== -1 && input.indexOf("<SYNC Start=2500><P Class=ENCC> </P></SYNC>") !== -1;
let syncIndex = 0;
let firstSyncTime = -1;
const len = input.length;
let pos = 0;
if (len > 0 && input.charCodeAt(0) === 65279)
pos = 1;
while (pos < len) {
const nl = input.indexOf(`
`, pos);
const lineEndRaw = nl === -1 ? len : nl;
let lineEnd = lineEndRaw;
if (lineEnd > pos && input.charCodeAt(lineEnd - 1) === 13)
lineEnd--;
if (lineEnd > pos && input.startsWith(token, pos)) {
let numStart = pos + token.length;
let numEnd = numStart;
while (numEnd < lineEnd) {
const d = input.charCodeAt(numEnd);
if (d < 48 || d > 57)
break;
numEnd++;
}
if (numStart === numEnd)
return false;
if (input.charCodeAt(numEnd) !== 62)
return false;
let time = 0;
if (!syntheticTime || syncIndex < 2) {
for (let j = numStart;j < numEnd; j++) {
time = time * 10 + (input.charCodeAt(j) - 48);
}
if (syncIndex === 0) {
firstSyncTime = time;
} else if (syncIndex === 1 && firstSyncTime === 0 && time === 2500) {
syntheticTime = true;
}
} else {
if ((syncIndex & 1) === 0) {
time = (syncIndex >> 1) * 3000;
} else {
time = (syncIndex - 1 >> 1) * 3000 + 2500;
}
}
const pStart = numEnd + 1;
if (pStart + 2 >= lineEnd || input.charCodeAt(pStart) !== 60 || input.charCodeAt(pStart + 1) !== 80)
return false;
let pTagEnd = pStart + 13;
let className;
if (!verified) {
if (input.startsWith(" Class=ENCC", pStart + 2) && (input.charCodeAt(pStart + 13) === 62 || input.charCodeAt(pStart + 13) <= 32)) {
className = "ENCC";
if (input.charCodeAt(pStart + 13) !== 62) {
pTagEnd = input.indexOf(">", pStart + 2);
if (pTagEnd === -1 || pTagEnd >= lineEnd)
return false;
}
verified = true;
} else {
return false;
}
} else {
if (input.charCodeAt(pStart + 13) !== 62)
return false;
className = "ENCC";
}
const textStart = pTagEnd + 1;
let textEnd;
if (lineEnd - 11 >= textStart && input.startsWith("</P></SYNC>", lineEnd - 11)) {
textEnd = lineEnd - 11;
} else {
textEnd = input.indexOf("</P>", textStart);
if (textEnd === -1 || textEnd > lineEnd)
return false;
}
if (prevTime >= 0 && prevText) {
events[eventCount++] = {
id: generateId(),
start: prevTime,
end: time,
layer: 0,
style: prevClass || "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text: prevText,
segments: EMPTY_SEGMENTS,
dirty: false
};
}
let tStart = textStart;
let tEnd = textEnd;
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--;
}
let text = "";
if (tEnd > tStart) {
if (tEnd - tStart === 6 && input.charCodeAt(tStart) === 38 && input.charCodeAt(tStart + 1) === 110 && input.charCodeAt(tStart + 2) === 98 && input.charCodeAt(tStart + 3) === 115 && input.charCodeAt(tStart + 4) === 112 && input.charCodeAt(tStart + 5) === 59) {
text = " ";
} else {
text = input.substring(tStart, tEnd);
}
}
if (text === " " || text === "") {
prevText = "";
} else if (textEnd === lineEnd - 11) {
prevText = text;
} else {
if (text.indexOf("<") !== -1)
return false;
if (text.indexOf("&") !== -1)
text = decodeHTML(text);
prevText = text;
}
prevTime = time;
prevClass = className;
syncIndex++;
}
if (nl === -1)
break;
pos = nl + 1;
}
if (prevTime >= 0 && prevText) {
events[eventCount++] = {
id: generateId(),
start: prevTime,
end: prevTime + 5000,
layer: 0,
style: prevClass || "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text: prevText,
segments: EMPTY_SEGMENTS,
dirty: false
};
}
if (eventCount !== events.length)
events.length = eventCount;
return events.length > 0;
}
function extractTrimmedText(src, start, end) {
let textStart = start;
let textEnd = end;
while (textStart < textEnd && src.charCodeAt(textStart) <= 32)
textStart++;
while (textEnd > textStart && src.charCodeAt(textEnd - 1) <= 32)
textEnd--;
if (textEnd <= textStart)
return "";
return src.substring(textStart, textEnd);
}
function indexOfTagCaseInsensitive(src, tag, start) {
const tagLen = tag.length;
const max = src.length - tagLen;
for (let i = start;i <= max; i++) {
let matched = true;
for (let j = 0;j < tagLen; j++) {
const a = src.charCodeAt(i + j);
const b = tag.charCodeAt(j);
if ((a | 32) !== (b | 32)) {
matched = false;
break;
}
}
if (matched)
return i;
}
return -1;
}
function extractStyles(src, doc) {
let styleStart = src.indexOf("<STYLE");
if (styleStart !== -1) {
const styleEndUpper = src.indexOf("</STYLE>", styleStart);
if (styleEndUpper !== -1) {
const cssBlock2 = src.substring(styleStart, styleEndUpper + 8);
const classes2 = parseCSS(cssBlock2);
for (const [className, classObj] of classes2) {
const baseStyle = createDefaultStyle();
const styleProps = styleFromClass(classObj, baseStyle);
const style = { ...baseStyle, ...styleProps, name: className };
doc.styles.set(className, style);
}
return;
}
}
styleStart = indexOfTagCaseInsensitive(src, "<style", 0);
if (styleStart === -1)
return;
const styleEnd = indexOfTagCaseInsensitive(src, "</style>", styleStart);
if (styleEnd === -1)
return;
const cssBlock = src.substring(styleStart, styleEnd + 8);
const classes = parseCSS(cssBlock);
for (const [className, classObj] of classes) {
const baseStyle = createDefaultStyle();
const styleProps = styleFromClass(classObj, baseStyle);
const style = { ...baseStyle, ...styleProps, name: className };
doc.styles.set(className, style);
}
}
function parseTags(raw) {
const segments = [];
const stateStack = [{}];
let textStart = 0;
let i = 0;
const rawLen = raw.length;
while (i < rawLen) {
if (raw.charCodeAt(i) === 60) {
const closeIdx = raw.indexOf(">", i);
if (closeIdx === -1) {
i++;
continue;
}
if (i > textStart) {
const state = stateStack[stateStack.length - 1];
const style = Object.keys(state).length > 0 ? { ...state } : null;
const text = decodeHTML(raw.substring(textStart, i));
segments[segments.length] = { text, style, effects: [] };
}
const tag = raw.substring(i + 1, closeIdx);
processTag(tag, stateStack);
i = closeIdx + 1;
textStart = i;
} else {
i++;
}
}
if (textStart < rawLen) {
const state = stateStack[stateStack.length - 1];
const style = Object.keys(state).length > 0 ? { ...state } : null;
const text = decodeHTML(raw.substring(textStart));
segments[segments.length] = { text, style, effects: [] };
}
return segments;
}
function processTag(tag, stateStack) {
const tagLower = tag.toLowerCase().trim();
const currentState = stateStack[stateStack.length - 1];
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 === "s") {
stateStack[stateStack.length] = { ...currentState, strikeout: true };
} else if (tagLower === "/s") {
if (stateStack.length > 1)
stateStack.pop();
} else if (tagLower.startsWith("font")) {
const colorIdx = tag.indexOf("color");
if (colorIdx !== -1) {
const colorStart = tag.indexOf('"', colorIdx);
if (colorStart !== -1) {
const colorEnd = tag.indexOf('"', colorStart + 1);
if (colorEnd !== -1) {
const colorValue = tag.substring(colorStart + 1, colorEnd);
const color = parseColorFast(colorValue);
stateStack[stateStack.length] = { ...currentState, primaryColor: color };
return;
}
}
}
stateStack[stateStack.length] = { ...currentState };
} else if (tagLower === "/font") {
if (stateStack.length > 1)
stateStack.pop();
}
}
function parseColorFast(value) {
if (value.charCodeAt(0) === 35) {
const hex = value.substring(1);
if (hex.length === 6) {
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return (b & 255) << 16 | (g & 255) << 8 | r & 255;
}
}
return 16777215;
}
function decodeHTML(text) {
if (text.indexOf("&") === -1)
return text;
let result = text;
if (result.indexOf(" ") !== -1)
result = result.split(" ").join(" ");
if (result.indexOf("<") !== -1)
result = result.split("<").join("<");
if (result.indexOf(">") !== -1)
result = result.split(">").join(">");
if (result.indexOf("&") !== -1)
result = result.split("&").join("&");
if (result.indexOf(""") !== -1)
result = result.split(""").join('"');
return result;
}
function stripTags(raw) {
if (raw.indexOf("<") === -1)
return decodeHTML(raw);
let result = "";
let i = 0;
const rawLen = raw.length;
while (i < rawLen) {
if (raw.charCodeAt(i) === 60) {
const closeIdx = raw.indexOf(">", i);
if (closeIdx === -1) {
result += raw.substring(i);
break;
}
i = closeIdx + 1;
} else {
result += raw.charAt(i);
i++;
}
}
return decodeHTML(result);
}
// src/formats/xml/sami/serializer.ts
function toSAMI(doc) {
const title = doc.info.title || "Subtitle";
let result = `<SAMI>
<HEAD>
`;
result += `<TITLE>${escapeHTML(title)}</TITLE>
`;
result += `<STYLE TYPE="text/css">
<!--
`;
result += generateCSS(doc.styles);
result += `-->
</STYLE>
</HEAD>
<BODY>
`;
const sortedEvents = [...doc.events].sort((a, b) => a.start - b.start);
for (const event of sortedEvents) {
const text = event.dirty && event.segments.length > 0 ? serializeTags(event.segments) : escapeHTML(event.text);
const className = event.style !== "Default" ? event.style.toUpperCase().replace(/[^A-Z0-9]/g, "") : "ENCC";
result += `<SYNC Start=${event.start}><P Class=${className}>${text}</P>
`;
if (event.end > event.start) {
result += `<SYNC Start=${event.end}><P Class=${className}> </P>
`;
}
}
result += `</BODY>
</SAMI>
`;
return result;
}
function serializeTags(segments) {
let result = "";
for (const seg of segments) {
let text = escapeHTML(seg.text);
const openTags = [];
const closeTags = [];
if (seg.style?.bold) {
openTags.push("<b>");
closeTags.unshift("</b>");
}
if (seg.style?.italic) {
openTags.push("<i>");
closeTags.unshift("</i>");
}
if (seg.style?.underline) {
openTags.push("<u>");
closeTags.unshift("</u>");
}
if (seg.style?.strikeout) {
openTags.push("<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.push(`<font color="${hex}">`);
closeTags.unshift("</font>");
}
result += openTags.join("") + text + closeTags.join("");
}
return result;
}
function escapeHTML(text) {
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
export {
toSAMI,
parseSAMI,
parseCSS,
generateCSS
};