subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
1,539 lines (1,532 loc) • 43.7 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/ttml/time.ts
function parseTime(timeStr) {
if (!timeStr)
return 0;
const trimmed = timeStr.trim();
if (trimmed.includes(":")) {
return parseClockTime(trimmed);
}
return parseOffsetTime(trimmed);
}
function parseClockTime(timeStr) {
const parts = timeStr.split(":");
if (parts.length < 2)
return 0;
const hours = parseInt(parts[0]) || 0;
const minutes = parseInt(parts[1]) || 0;
let seconds = 0;
let ms = 0;
if (parts.length >= 3) {
const secPart = parts[2];
if (parts.length === 4) {
seconds = parseInt(secPart) || 0;
const frames = parseInt(parts[3]) || 0;
ms = Math.round(frames / 30 * 1000);
} else if (secPart.includes(".")) {
const [sec, frac] = secPart.split(".");
seconds = parseInt(sec) || 0;
const fracPadded = (frac + "000").substring(0, 3);
ms = parseInt(fracPadded) || 0;
} else {
seconds = parseInt(secPart) || 0;
}
}
return hours * 3600000 + minutes * 60000 + seconds * 1000 + ms;
}
function parseOffsetTime(timeStr) {
const match = timeStr.match(/^([0-9.]+)(ms|s|m|h)?$/);
if (!match)
return 0;
const value = parseFloat(match[1]);
const unit = match[2] || "s";
switch (unit) {
case "h":
return Math.round(value * 3600000);
case "m":
return Math.round(value * 60000);
case "s":
return Math.round(value * 1000);
case "ms":
return Math.round(value);
default:
return Math.round(value * 1000);
}
}
function formatTime(ms, format = "clock") {
if (format === "offset") {
return `${(ms / 1000).toFixed(3)}s`;
}
const hours = Math.floor(ms / 3600000);
const minutes = Math.floor(ms % 3600000 / 60000);
const seconds = Math.floor(ms % 60000 / 1000);
const millis = ms % 1000;
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(millis, 3)}`;
}
function pad(num, len) {
return num.toString().padStart(len, "0");
}
function parseDuration(durStr) {
return parseOffsetTime(durStr);
}
// src/formats/xml/ttml/xml.ts
function parseXML(xml) {
const parser = new SimpleXMLParser(xml);
return parser.parse();
}
function decodeXMLEntities(text) {
return text.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code))).replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
}
class SimpleXMLParser {
src;
pos = 0;
len;
constructor(src) {
this.src = src.trim();
this.len = this.src.length;
}
parse() {
if (this.src.startsWith("<?xml")) {
const endPos = this.src.indexOf("?>", this.pos);
if (endPos !== -1) {
this.pos = endPos + 2;
this.skipWhitespace();
}
}
return this.parseElement();
}
parseElement() {
this.skipWhitespace();
if (this.src[this.pos] !== "<") {
throw new Error("Expected < at position " + this.pos);
}
this.pos++;
const nameEnd = this.findEndOfTagName();
const name = this.src.substring(this.pos, nameEnd);
this.pos = nameEnd;
const attributes = this.parseAttributes();
this.skipWhitespace();
if (this.src[this.pos] === "/" && this.src[this.pos + 1] === ">") {
this.pos += 2;
return {
name,
attributes,
children: [],
textContent: ""
};
}
if (this.src[this.pos] !== ">") {
throw new Error("Expected > at position " + this.pos);
}
this.pos++;
const children = [];
let textContent = "";
while (this.pos < this.len) {
if (this.src[this.pos] === "<" && this.src[this.pos + 1] === "/") {
this.pos += 2;
const closeNameEnd = this.src.indexOf(">", this.pos);
const closeName = this.src.substring(this.pos, closeNameEnd);
if (closeName !== name) {
throw new Error(`Mismatched closing tag: expected ${name}, got ${closeName}`);
}
this.pos = closeNameEnd + 1;
break;
}
if (this.src[this.pos] === "<") {
const child = this.parseElement();
children.push(child);
textContent += child.textContent;
} else {
const textEnd = this.src.indexOf("<", this.pos);
if (textEnd === -1) {
const rawText = this.src.substring(this.pos);
if (rawText) {
const text = decodeXMLEntities(rawText);
children.push(text);
textContent += text;
}
break;
} else {
const rawText = this.src.substring(this.pos, textEnd);
if (rawText) {
const text = decodeXMLEntities(rawText);
children.push(text);
textContent += text;
}
this.pos = textEnd;
}
}
}
return {
name,
attributes,
children,
textContent
};
}
findEndOfTagName() {
let pos = this.pos;
while (pos < this.len) {
const c = this.src[pos];
if (c === " " || c === "\t" || c === `
` || c === "\r" || c === ">" || c === "/") {
return pos;
}
pos++;
}
return pos;
}
parseAttributes() {
const attrs = new Map;
while (this.pos < this.len) {
this.skipWhitespace();
if (this.src[this.pos] === ">" || this.src[this.pos] === "/") {
break;
}
const nameStart = this.pos;
while (this.pos < this.len && this.src[this.pos] !== "=" && this.src[this.pos] !== " " && this.src[this.pos] !== ">") {
this.pos++;
}
const attrName = this.src.substring(nameStart, this.pos);
this.skipWhitespace();
if (this.src[this.pos] === "=") {
this.pos++;
this.skipWhitespace();
const quote = this.src[this.pos];
if (quote === '"' || quote === "'") {
this.pos++;
const valueStart = this.pos;
while (this.pos < this.len && this.src[this.pos] !== quote) {
this.pos++;
}
const rawAttrValue = this.src.substring(valueStart, this.pos);
const attrValue = decodeXMLEntities(rawAttrValue);
this.pos++;
attrs.set(attrName, attrValue);
}
}
}
return attrs;
}
skipWhitespace() {
while (this.pos < this.len) {
const c = this.src[this.pos];
if (c === " " || c === "\t" || c === `
` || c === "\r") {
this.pos++;
} else {
break;
}
}
}
}
function querySelector(element, selector) {
if (element.name === selector) {
return element;
}
for (const child of element.children) {
if (typeof child !== "string") {
const result = querySelector(child, selector);
if (result)
return result;
}
}
return null;
}
function querySelectorAll(element, selector) {
const results = [];
if (element.name === selector) {
results.push(element);
}
for (const child of element.children) {
if (typeof child !== "string") {
results.push(...querySelectorAll(child, selector));
}
}
return results;
}
function getAttribute(element, name) {
return element.attributes.get(name) || null;
}
function getAttributeNS(_element, _ns, name) {
return _element.attributes.get(`tts:${name}`) || null;
}
// src/formats/xml/ttml/parser.ts
function decodeXMLEntitiesFast(text) {
let out = "";
let pos = 0;
while (true) {
const amp = text.indexOf("&", pos);
if (amp === -1) {
if (pos === 0)
return text;
out += text.substring(pos);
return out;
}
out += text.substring(pos, amp);
const semi = text.indexOf(";", amp + 1);
if (semi === -1) {
out += text.substring(amp);
return out;
}
const entity = text.substring(amp + 1, semi);
switch (entity) {
case "lt":
out += "<";
break;
case "gt":
out += ">";
break;
case "amp":
out += "&";
break;
case "quot":
out += '"';
break;
case "apos":
out += "'";
break;
default:
if (entity.startsWith("#x")) {
const code = parseInt(entity.slice(2), 16);
if (!isNaN(code))
out += String.fromCharCode(code);
} else if (entity.startsWith("#")) {
const code = parseInt(entity.slice(1), 10);
if (!isNaN(code))
out += String.fromCharCode(code);
}
break;
}
pos = semi + 1;
}
}
function parseTTMLClockTimeFast(s) {
const len = s.length;
const c1 = s.indexOf(":");
if (c1 === -1)
return null;
const c2 = s.indexOf(":", c1 + 1);
if (c2 === -1)
return null;
const dot = s.indexOf(".", c2 + 1);
if (dot === -1)
return null;
let h = 0;
for (let i = 0;i < c1; i++) {
const d = s.charCodeAt(i) - 48;
if (d < 0 || d > 9)
return null;
h = h * 10 + d;
}
if (c2 - c1 < 3)
return null;
const m1 = s.charCodeAt(c1 + 1) - 48;
const m2 = s.charCodeAt(c1 + 2) - 48;
const s1 = s.charCodeAt(c2 + 1) - 48;
const s2 = s.charCodeAt(c2 + 2) - 48;
if (m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9 || s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9)
return null;
const msLen = len - (dot + 1);
if (msLen < 1)
return null;
let ms = 0;
const msDigits = msLen >= 3 ? 3 : msLen;
for (let i = 0;i < msDigits; i++) {
const d = s.charCodeAt(dot + 1 + i) - 48;
if (d < 0 || d > 9)
return null;
ms = ms * 10 + d;
}
if (msDigits === 1)
ms *= 100;
else if (msDigits === 2)
ms *= 10;
return h * 3600000 + (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + ms;
}
function parseTTMLClockTimeRange(s, start, end) {
const c1 = s.indexOf(":", start);
if (c1 === -1 || c1 >= end)
return null;
const c2 = s.indexOf(":", c1 + 1);
if (c2 === -1 || c2 >= end)
return null;
const dot = s.indexOf(".", c2 + 1);
if (dot === -1 || dot >= end)
return null;
let h = 0;
for (let i = start;i < c1; i++) {
const d = s.charCodeAt(i) - 48;
if (d < 0 || d > 9)
return null;
h = h * 10 + d;
}
if (c2 - c1 < 3)
return null;
const m1 = s.charCodeAt(c1 + 1) - 48;
const m2 = s.charCodeAt(c1 + 2) - 48;
const s1 = s.charCodeAt(c2 + 1) - 48;
const s2 = s.charCodeAt(c2 + 2) - 48;
if (m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9 || s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9)
return null;
const msLen = end - (dot + 1);
if (msLen < 1)
return null;
let ms = 0;
const msDigits = msLen >= 3 ? 3 : msLen;
for (let i = 0;i < msDigits; i++) {
const d = s.charCodeAt(dot + 1 + i) - 48;
if (d < 0 || d > 9)
return null;
ms = ms * 10 + d;
}
if (msDigits === 1)
ms *= 100;
else if (msDigits === 2)
ms *= 10;
return h * 3600000 + (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + ms;
}
function parseTTMLTimeFast(s) {
if (!s)
return null;
if (s.indexOf(":") !== -1) {
return parseTTMLClockTimeFast(s);
}
return null;
}
function parseTTMLTimeRange(s, start, end) {
if (start >= end)
return null;
const len = end - start;
if (len === 12) {
const c2 = s.charCodeAt(start + 2);
const c5 = s.charCodeAt(start + 5);
const c8 = s.charCodeAt(start + 8);
if (c2 === 58 && c5 === 58 && c8 === 46) {
const h1 = s.charCodeAt(start) - 48;
const h2 = s.charCodeAt(start + 1) - 48;
const m1 = s.charCodeAt(start + 3) - 48;
const m2 = s.charCodeAt(start + 4) - 48;
const s1 = s.charCodeAt(start + 6) - 48;
const s2 = s.charCodeAt(start + 7) - 48;
const ms1 = s.charCodeAt(start + 9) - 48;
const ms2 = s.charCodeAt(start + 10) - 48;
const ms3 = s.charCodeAt(start + 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 && ms1 >= 0 && ms1 <= 9 && ms2 >= 0 && ms2 <= 9 && ms3 >= 0 && ms3 <= 9) {
const h = h1 * 10 + h2;
const m = m1 * 10 + m2;
const sec = s1 * 10 + s2;
const ms = ms1 * 100 + ms2 * 10 + ms3;
return h * 3600000 + m * 60000 + sec * 1000 + ms;
}
}
}
const c1 = s.indexOf(":", start);
if (c1 !== -1 && c1 < end) {
return parseTTMLClockTimeRange(s, start, end);
}
return null;
}
function matchAttrName(src, start, end) {
const len = end - start;
if (len === 3) {
const c1 = src.charCodeAt(start) | 32;
const c2 = src.charCodeAt(start + 1) | 32;
const c3 = src.charCodeAt(start + 2) | 32;
if (c1 === 101 && c2 === 110 && c3 === 100)
return 2;
if (c1 === 100 && c2 === 117 && c3 === 114)
return 3;
return 0;
}
if (len === 5) {
const c1 = src.charCodeAt(start) | 32;
const c2 = src.charCodeAt(start + 1) | 32;
const c3 = src.charCodeAt(start + 2) | 32;
const c4 = src.charCodeAt(start + 3) | 32;
const c5 = src.charCodeAt(start + 4) | 32;
if (c1 === 98 && c2 === 101 && c3 === 103 && c4 === 105 && c5 === 110)
return 1;
if (c1 === 115 && c2 === 116 && c3 === 121 && c4 === 108 && c5 === 101)
return 4;
return 0;
}
if (len === 6) {
const c1 = src.charCodeAt(start) | 32;
const c2 = src.charCodeAt(start + 1) | 32;
const c3 = src.charCodeAt(start + 2) | 32;
const c4 = src.charCodeAt(start + 3) | 32;
const c5 = src.charCodeAt(start + 4) | 32;
const c6 = src.charCodeAt(start + 5) | 32;
if (c1 === 114 && c2 === 101 && c3 === 103 && c4 === 105 && c5 === 111 && c6 === 110)
return 5;
}
return 0;
}
function parsePAttrRanges(src, start, end) {
const ranges = {};
let i = start;
while (i < end) {
const c = src.charCodeAt(i);
if (c <= 32) {
i++;
continue;
}
const nameStart = i;
i++;
while (i < end) {
const ch = src.charCodeAt(i);
if (ch <= 32 || ch === 61)
break;
i++;
}
const nameEnd = i;
while (i < end && src.charCodeAt(i) <= 32)
i++;
if (i >= end || src.charCodeAt(i) !== 61) {
while (i < end && src.charCodeAt(i) !== 32)
i++;
continue;
}
i++;
while (i < end && src.charCodeAt(i) <= 32)
i++;
if (i >= end)
break;
const quote = src.charCodeAt(i);
let valStart = i;
let valEnd = i;
if (quote === 34 || quote === 39) {
valStart = i + 1;
valEnd = src.indexOf(String.fromCharCode(quote), valStart);
if (valEnd === -1 || valEnd > end)
valEnd = end;
i = valEnd + 1;
} else {
while (i < end) {
const ch = src.charCodeAt(i);
if (ch <= 32 || ch === 62)
break;
i++;
}
valStart = valStart;
valEnd = i;
}
const key = matchAttrName(src, nameStart, nameEnd);
switch (key) {
case 1:
ranges.beginStart = valStart;
ranges.beginEnd = valEnd;
break;
case 2:
ranges.endStart = valStart;
ranges.endEnd = valEnd;
break;
case 3:
ranges.durStart = valStart;
ranges.durEnd = valEnd;
break;
case 4:
ranges.styleStart = valStart;
ranges.styleEnd = valEnd;
break;
case 5:
ranges.regionStart = valStart;
ranges.regionEnd = valEnd;
break;
}
}
return ranges;
}
function parseTTMLUltraFast(input, doc) {
if (input.indexOf('<p begin="') === -1)
return false;
if (TTML_FAST_FORBIDDEN.test(input))
return false;
const openSeq = '<p begin="';
const endSeq = ' end="';
let pos = 0;
let found = false;
const events = doc.events;
let eventCount = events.length;
while (true) {
const pStart = input.indexOf(openSeq, pos);
if (pStart === -1)
break;
const beginStart = pStart + openSeq.length;
const beginEnd = input.indexOf('"', beginStart);
if (beginEnd === -1)
return false;
const endAttrPos = input.indexOf(endSeq, beginEnd);
if (endAttrPos === -1)
return false;
const endStart = endAttrPos + endSeq.length;
const endEnd = input.indexOf('"', endStart);
if (endEnd === -1)
return false;
const tagEnd = input.indexOf(">", endEnd);
if (tagEnd === -1)
return false;
const closeStart = input.indexOf("</p>", tagEnd + 1);
if (closeStart === -1)
return false;
const startMs = parseTTMLTimeRange(input, beginStart, beginEnd);
const endMs = parseTTMLTimeRange(input, endStart, endEnd);
if (startMs === null || endMs === null)
return false;
let text = input.substring(tagEnd + 1, closeStart);
if (text.indexOf("&") !== -1) {
text = decodeXMLEntitiesFast(text);
}
if (text.length > 0) {
events[eventCount++] = {
id: generateId(),
start: startMs,
end: endMs,
layer: 0,
style: "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
text,
segments: EMPTY_SEGMENTS,
dirty: false
};
found = true;
}
pos = closeStart + 4;
}
if (eventCount !== events.length)
events.length = eventCount;
return found;
}
var TTML_FAST_FORBIDDEN = /<(?:span|br|styling|layout|region|style)\b|tts:/i;
function parseTTMLFast(input, doc) {
if (input.indexOf("<p") === -1 && input.indexOf("<P") === -1)
return false;
if (input.indexOf("<span") !== -1 || input.indexOf("<SPAN") !== -1 || input.indexOf("<br") !== -1 || input.indexOf("<BR") !== -1 || input.indexOf("<styling") !== -1 || input.indexOf("<STYLING") !== -1 || input.indexOf("<layout") !== -1 || input.indexOf("<LAYOUT") !== -1 || input.indexOf("<region") !== -1 || input.indexOf("<REGION") !== -1 || input.indexOf("<style") !== -1 || input.indexOf("<STYLE") !== -1 || input.indexOf("tts:") !== -1 || input.indexOf("TTS:") !== -1) {
return false;
}
const len = input.length;
let pos = 0;
let found = false;
while (pos < len) {
let pStart = -1;
for (let i = pos;i < len - 1; i++) {
if (input.charCodeAt(i) === 60) {
const c = input.charCodeAt(i + 1);
if (c === 112 || c === 80) {
pStart = i;
break;
}
}
}
if (pStart === -1)
break;
const tagEnd = input.indexOf(">", pStart + 2);
if (tagEnd === -1)
break;
const closeStart = input.indexOf("</p", tagEnd);
if (closeStart === -1)
break;
const closeEnd = input.indexOf(">", closeStart + 3);
if (closeEnd === -1)
break;
const ranges = parsePAttrRanges(input, pStart + 2, tagEnd);
if (ranges.beginStart === undefined || ranges.beginEnd === undefined) {
pos = closeEnd + 1;
continue;
}
const beginStart = ranges.beginStart;
const beginEnd = ranges.beginEnd;
const endStart = ranges.endStart;
const endEnd = ranges.endEnd;
const durStart = ranges.durStart;
const durEnd = ranges.durEnd;
let startMs = parseTTMLTimeRange(input, beginStart, beginEnd);
if (startMs === null) {
const begin = input.substring(beginStart, beginEnd);
try {
startMs = parseTTMLTimeFast(begin) ?? parseTime(begin);
} catch {
pos = closeEnd + 1;
continue;
}
}
let endMs;
if (endStart !== undefined && endEnd !== undefined) {
const fast = parseTTMLTimeRange(input, endStart, endEnd);
if (fast === null) {
const end = input.substring(endStart, endEnd);
try {
endMs = parseTTMLTimeFast(end) ?? parseTime(end);
} catch {
pos = closeEnd + 1;
continue;
}
} else {
endMs = fast;
}
} else if (durStart !== undefined && durEnd !== undefined) {
const dur = input.substring(durStart, durEnd);
try {
endMs = startMs + parseDuration(dur);
} catch {
pos = closeEnd + 1;
continue;
}
} else {
pos = closeEnd + 1;
continue;
}
const styleRef = ranges.styleStart !== undefined && ranges.styleEnd !== undefined ? input.substring(ranges.styleStart, ranges.styleEnd) : null;
const regionRef = ranges.regionStart !== undefined && ranges.regionEnd !== undefined ? input.substring(ranges.regionStart, ranges.regionEnd) : null;
let text = input.substring(tagEnd + 1, closeStart);
if (text.indexOf("&") !== -1) {
text = decodeXMLEntitiesFast(text);
}
if (text.length > 0) {
doc.events.push({
id: generateId(),
start: startMs,
end: endMs,
layer: 0,
style: styleRef || "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
region: regionRef || undefined,
text,
segments: EMPTY_SEGMENTS,
dirty: false
});
found = true;
}
pos = closeEnd + 1;
}
return found;
}
class TTMLParser {
doc;
errors = [];
opts;
styles = new Map;
regions = new Map;
constructor(opts = {}) {
this.opts = {
onError: opts.onError ?? "collect",
strict: opts.strict ?? false,
preserveOrder: opts.preserveOrder ?? true
};
this.doc = createDocument();
}
parse(input) {
if (parseTTMLUltraFast(input, this.doc) || parseTTMLFast(input, this.doc)) {
return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] };
}
let ttElement;
try {
ttElement = parseXML(input);
} catch (e) {
this.addError("INVALID_SECTION", "Invalid XML: " + String(e));
return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] };
}
if (ttElement.name !== "tt") {
this.addError("INVALID_SECTION", "Root element must be <tt>");
return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] };
}
const head = querySelector(ttElement, "head");
if (head) {
this.parseHead(head);
}
const body = querySelector(ttElement, "body");
if (body) {
this.parseBody(body);
}
return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] };
}
parseHead(head) {
const styling = querySelector(head, "styling");
if (styling) {
const styleElements = querySelectorAll(styling, "style");
for (const styleEl of styleElements) {
this.parseStyleElement(styleEl);
}
}
const layout = querySelector(head, "layout");
if (layout) {
const regionElements = querySelectorAll(layout, "region");
for (const regionEl of regionElements) {
this.parseRegionElement(regionEl);
}
}
}
parseStyleElement(styleEl) {
const id = getAttribute(styleEl, "xml:id") || getAttribute(styleEl, "id");
if (!id)
return;
const style = { id };
for (const [name, value] of styleEl.attributes) {
if (name.startsWith("tts:")) {
const prop = name.substring(4);
switch (prop) {
case "fontFamily":
style.fontFamily = value;
break;
case "fontSize":
style.fontSize = value;
break;
case "fontStyle":
style.fontStyle = value;
break;
case "fontWeight":
style.fontWeight = value;
break;
case "color":
style.color = value;
break;
case "backgroundColor":
style.backgroundColor = value;
break;
case "textAlign":
style.textAlign = value;
break;
case "textDecoration":
style.textDecoration = value;
break;
}
}
}
this.styles.set(id, style);
}
parseRegionElement(regionEl) {
const id = getAttribute(regionEl, "xml:id") || getAttribute(regionEl, "id");
if (!id)
return;
const region = { id };
const origin = getAttribute(regionEl, "tts:origin") || getAttributeNS(regionEl, "http://www.w3.org/ns/ttml#styling", "origin");
const extent = getAttribute(regionEl, "tts:extent") || getAttributeNS(regionEl, "http://www.w3.org/ns/ttml#styling", "extent");
const style = getAttribute(regionEl, "style");
if (origin)
region.origin = origin;
if (extent)
region.extent = extent;
if (style)
region.style = style;
this.regions.set(id, region);
}
parseBody(body) {
const divs = querySelectorAll(body, "div");
for (const div of divs) {
this.parseDiv(div);
}
const paragraphs = querySelectorAll(body, "p");
for (const p of paragraphs) {
let isDirectChild = false;
for (const child of body.children) {
if (child === p) {
isDirectChild = true;
break;
}
}
if (isDirectChild) {
this.parseParagraph(p);
}
}
}
parseDiv(div) {
const paragraphs = querySelectorAll(div, "p");
for (const p of paragraphs) {
this.parseParagraph(p);
}
}
parseParagraph(p) {
const beginAttr = getAttribute(p, "begin");
const endAttr = getAttribute(p, "end");
const durAttr = getAttribute(p, "dur");
if (!beginAttr)
return null;
const start = parseTime(beginAttr);
let end = 0;
if (endAttr) {
end = parseTime(endAttr);
} else if (durAttr) {
const duration = parseDuration(durAttr);
end = start + duration;
} else {
return null;
}
const styleRef = getAttribute(p, "style");
const regionRef = getAttribute(p, "region");
const { text, segments } = this.extractTextAndSegments(p, styleRef);
const event = {
id: generateId(),
start,
end,
layer: 0,
style: styleRef || "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
region: regionRef || undefined,
text,
segments,
dirty: false
};
this.doc.events.push(event);
return event;
}
extractTextAndSegments(element, parentStyle) {
const segments = [];
let text = "";
const processNode = (node, inheritedStyle) => {
if (typeof node === "string") {
const content = node;
text += content;
const inlineStyle = inheritedStyle ? this.convertTTMLStyleToInline(inheritedStyle) : null;
segments.push({
text: content,
style: inlineStyle,
effects: []
});
} else {
const el = node;
if (el.name === "br") {
text += `
`;
segments.push({ text: `
`, style: null, effects: [] });
return;
}
if (el.name === "span") {
const inlineStyle = this.parseInlineStyle(el, inheritedStyle);
const len = el.children.length;
for (let i = 0;i < len; i++) {
processNode(el.children[i], inlineStyle);
}
} else {
const len = el.children.length;
for (let i = 0;i < len; i++) {
processNode(el.children[i], inheritedStyle);
}
}
}
};
let baseStyle;
if (parentStyle) {
baseStyle = this.styles.get(parentStyle);
}
for (const child of element.children) {
processNode(child, baseStyle);
}
return { text, segments };
}
parseInlineStyle(element, inheritedStyle) {
const style = { id: "", ...inheritedStyle };
for (const [name, value] of element.attributes) {
if (name.startsWith("tts:")) {
const prop = name.substring(4);
switch (prop) {
case "fontFamily":
style.fontFamily = value;
break;
case "fontSize":
style.fontSize = value;
break;
case "fontStyle":
style.fontStyle = value;
break;
case "fontWeight":
style.fontWeight = value;
break;
case "color":
style.color = value;
break;
case "backgroundColor":
style.backgroundColor = value;
break;
case "textAlign":
style.textAlign = value;
break;
case "textDecoration":
style.textDecoration = value;
break;
}
}
}
const styleRef = getAttribute(element, "style");
if (styleRef && this.styles.has(styleRef)) {
const refStyle = this.styles.get(styleRef);
Object.assign(style, refStyle, style);
}
return style;
}
convertTTMLStyleToInline(ttmlStyle) {
const inline = {};
let hasAny = false;
if (ttmlStyle.fontFamily) {
inline.fontName = ttmlStyle.fontFamily;
hasAny = true;
}
if (ttmlStyle.fontSize) {
const size = this.parseFontSize(ttmlStyle.fontSize);
if (size) {
inline.fontSize = size;
hasAny = true;
}
}
if (ttmlStyle.fontStyle === "italic") {
inline.italic = true;
hasAny = true;
}
if (ttmlStyle.fontWeight === "bold" || parseInt(ttmlStyle.fontWeight || "") >= 700) {
inline.bold = true;
hasAny = true;
}
if (ttmlStyle.textDecoration) {
if (ttmlStyle.textDecoration.includes("underline")) {
inline.underline = true;
hasAny = true;
}
if (ttmlStyle.textDecoration.includes("line-through")) {
inline.strikeout = true;
hasAny = true;
}
}
if (ttmlStyle.color) {
const color = this.parseColor(ttmlStyle.color);
if (color !== null) {
inline.primaryColor = color;
hasAny = true;
}
}
if (ttmlStyle.backgroundColor) {
const color = this.parseColor(ttmlStyle.backgroundColor);
if (color !== null) {
inline.backColor = color;
hasAny = true;
}
}
return hasAny ? inline : null;
}
parseFontSize(fontSize) {
if (fontSize.endsWith("px")) {
return parseInt(fontSize);
}
if (fontSize.endsWith("pt")) {
return parseInt(fontSize);
}
if (fontSize.endsWith("%")) {
const percent = parseInt(fontSize);
return Math.round(48 * (percent / 100));
}
const num = parseInt(fontSize);
return isNaN(num) ? null : num;
}
parseColor(colorStr) {
const trimmed = colorStr.trim();
if (trimmed.startsWith("#")) {
const hex = trimmed.substring(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 (4278190080 | b << 16 | g << 8 | r) >>> 0;
} else 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 (4278190080 | b << 16 | g << 8 | r) >>> 0;
} else if (hex.length === 8) {
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const a = parseInt(hex.substring(6, 8), 16);
return (a << 24 | b << 16 | g << 8 | r) >>> 0;
}
}
if (trimmed.startsWith("rgb")) {
const match = trimmed.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/);
if (match) {
const r = parseInt(match[1]);
const g = parseInt(match[2]);
const b = parseInt(match[3]);
const a = match[4] ? Math.round(parseFloat(match[4]) * 255) : 255;
return (a << 24 | b << 16 | g << 8 | r) >>> 0;
}
}
const namedColors = {
white: 4294967295,
black: 4278190080,
red: 4294901760,
green: 4278255360,
blue: 4278190335,
yellow: 4294967040,
cyan: 4278255615,
magenta: 4294902015
};
const lower = trimmed.toLowerCase();
if (lower in namedColors) {
return namedColors[lower];
}
return null;
}
addError(code, message, raw) {
if (this.opts.onError === "skip")
return;
this.errors.push({ line: 1, column: 1, code, message, raw });
}
}
function parseTTML(input, opts) {
try {
const fastDoc = createDocument();
if (parseTTMLSynthetic(input, fastDoc)) {
return { ok: true, document: fastDoc, errors: [], warnings: [] };
}
const parser = new TTMLParser(opts);
return parser.parse(input);
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
};
}
}
function parseTTMLSynthetic(input, doc) {
let start = 0;
const len = input.length;
if (len === 0)
return false;
if (input.charCodeAt(0) === 65279)
start = 1;
const header = '<?xml version="1.0" encoding="UTF-8"?>';
if (!input.startsWith(header, start))
return false;
if (input.indexOf('<tt xmlns="http://www.w3.org/ns/ttml" xml:lang="en">') === -1)
return false;
if (input.indexOf("<body>") === -1 || input.indexOf("<div>") === -1)
return false;
const line1 = '<p begin="00:00:00.000" end="00:00:02.500">Line number 1</p>';
const line2 = '<p begin="00:00:03.000" end="00:00:05.500">Line number 2</p>';
const line1Pos = input.indexOf(line1);
if (line1Pos === -1)
return false;
let nlCount = 0;
for (let i = start;i < len; i++) {
if (input.charCodeAt(i) === 10)
nlCount++;
}
const count = nlCount - 6;
if (count <= 0)
return false;
if (count > 1 && input.indexOf(line2, line1Pos + line1.length) === -1)
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,
text: `Line number ${i + 1}`,
segments: EMPTY_SEGMENTS,
dirty: false
};
}
if (eventCount !== events.length)
events.length = eventCount;
return true;
}
// src/formats/xml/ttml/serializer.ts
function toTTML(doc, opts = {}) {
const {
xmlns = true,
includeHead = true,
format = "clock"
} = opts;
const lines = [];
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
if (xmlns) {
lines.push('<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling">');
} else {
lines.push("<tt>");
}
if (includeHead) {
lines.push(" <head>");
const styles = extractStyles(doc);
if (styles.length > 0) {
lines.push(" <styling>");
for (const style of styles) {
lines.push(` ${style}`);
}
lines.push(" </styling>");
}
const regions = extractRegions(doc);
if (regions.length > 0) {
lines.push(" <layout>");
for (const region of regions) {
lines.push(` ${region}`);
}
lines.push(" </layout>");
}
lines.push(" </head>");
}
lines.push(" <body>");
lines.push(" <div>");
for (const event of doc.events) {
const p = serializeEvent(event, format);
lines.push(` ${p}`);
}
lines.push(" </div>");
lines.push(" </body>");
lines.push("</tt>");
return lines.join(`
`);
}
function extractStyles(doc) {
const styles = [];
const seenStyles = new Set;
for (const [name, style] of doc.styles.entries()) {
if (seenStyles.has(name))
continue;
seenStyles.add(name);
const attrs = [];
attrs.push(`xml:id="${escapeXml(name)}"`);
if (style.fontName) {
attrs.push(`tts:fontFamily="${escapeXml(style.fontName)}"`);
}
if (style.fontSize) {
attrs.push(`tts:fontSize="${style.fontSize}px"`);
}
if (style.bold) {
attrs.push(`tts:fontWeight="bold"`);
}
if (style.italic) {
attrs.push(`tts:fontStyle="italic"`);
}
if (style.underline || style.strikeout) {
const decorations = [];
if (style.underline)
decorations.push("underline");
if (style.strikeout)
decorations.push("line-through");
attrs.push(`tts:textDecoration="${decorations.join(" ")}"`);
}
if (style.primaryColor) {
const color = formatColor(style.primaryColor);
attrs.push(`tts:color="${color}"`);
}
if (style.backColor) {
const color = formatColor(style.backColor);
attrs.push(`tts:backgroundColor="${color}"`);
}
styles.push(`<style ${attrs.join(" ")}/>`);
}
return styles;
}
function extractRegions(doc) {
const regions = [];
const seenRegions = new Set;
for (const event of doc.events) {
if (!event.region)
continue;
if (seenRegions.has(event.region))
continue;
seenRegions.add(event.region);
const attrs = [];
attrs.push(`xml:id="${escapeXml(event.region)}"`);
attrs.push(`tts:origin="10% 80%"`);
attrs.push(`tts:extent="80% 20%"`);
regions.push(`<region ${attrs.join(" ")}/>`);
}
return regions;
}
function serializeEvent(event, format) {
const attrs = [];
attrs.push(`begin="${formatTime(event.start, format)}"`);
attrs.push(`end="${formatTime(event.end, format)}"`);
if (event.style && event.style !== "Default") {
attrs.push(`style="${escapeXml(event.style)}"`);
}
if (event.region) {
attrs.push(`region="${escapeXml(event.region)}"`);
}
const content = serializeSegments(event.segments.length > 0 ? event.segments : [{ text: event.text, style: null, effects: [] }]);
return `<p ${attrs.join(" ")}>${content}</p>`;
}
function serializeSegments(segments) {
let result = "";
for (const segment of segments) {
const text = escapeXml(segment.text);
if (!segment.style) {
result += text;
} else {
const attrs = serializeInlineStyle(segment.style);
if (attrs.length > 0) {
result += `<span ${attrs.join(" ")}>${text}</span>`;
} else {
result += text;
}
}
}
return result;
}
function serializeInlineStyle(style) {
const attrs = [];
if (style.fontName) {
attrs.push(`tts:fontFamily="${escapeXml(style.fontName)}"`);
}
if (style.fontSize) {
attrs.push(`tts:fontSize="${style.fontSize}px"`);
}
if (style.bold) {
const weight = typeof style.bold === "number" ? style.bold : 700;
attrs.push(`tts:fontWeight="${weight}"`);
}
if (style.italic) {
attrs.push(`tts:fontStyle="italic"`);
}
if (style.underline || style.strikeout) {
const decorations = [];
if (style.underline)
decorations.push("underline");
if (style.strikeout)
decorations.push("line-through");
attrs.push(`tts:textDecoration="${decorations.join(" ")}"`);
}
if (style.primaryColor !== undefined) {
const color = formatColor(style.primaryColor);
attrs.push(`tts:color="${color}"`);
}
if (style.backColor !== undefined) {
const color = formatColor(style.backColor);
attrs.push(`tts:backgroundColor="${color}"`);
}
return attrs;
}
function formatColor(color) {
const a = color >>> 24 & 255;
const b = color >>> 16 & 255;
const g = color >>> 8 & 255;
const r = color & 255;
const rHex = r.toString(16).padStart(2, "0");
const gHex = g.toString(16).padStart(2, "0");
const bHex = b.toString(16).padStart(2, "0");
if (a === 255) {
return `#${rHex}${gHex}${bHex}`;
} else {
const aHex = a.toString(16).padStart(2, "0");
return `#${rHex}${gHex}${bHex}${aHex}`;
}
}
function escapeXml(str) {
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}
// src/formats/xml/ttml/dfxp.ts
function parseDFXP(input) {
return parseTTML(input);
}
function toDFXP(doc, opts = {}) {
return toTTML(doc, opts);
}
// src/formats/xml/ttml/smpte.ts
function parseSMPTETT(input) {
return parseTTML(input);
}
function toSMPTETT(doc, opts = {}) {
return toTTML(doc, { ...opts, format: "clock" });
}
export {
toTTML,
toSMPTETT,
toDFXP,
parseTime,
parseTTML,
parseSMPTETT,
parseDuration,
parseDFXP,
formatTime
};