UNPKG

subforge

Version:

High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.

1,539 lines (1,532 loc) 43.7 kB
// 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(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&apos;/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;"); } // 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 };