UNPKG

subforge

Version:

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

1,739 lines (1,734 loc) 63.6 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/text/ass/color.ts function parseColor(s) { const match = s.match(/^&[Hh]([0-9A-Fa-f]{6,8})&?$/); if (!match) throw new Error(`Invalid ASS color: ${s}`); const hex = match[1].padStart(8, "0"); return parseInt(hex, 16); } function formatColor(color) { return `&H${(color >>> 0).toString(16).toUpperCase().padStart(8, "0")}&`; } function parseAlpha(s) { const match = s.match(/^&H([0-9A-Fa-f]{2})&?$/); if (!match) throw new Error(`Invalid ASS alpha: ${s}`); return parseInt(match[1], 16); } function formatAlpha(alpha) { return `&H${(alpha & 255).toString(16).toUpperCase().padStart(2, "0")}&`; } // src/formats/text/ssa/parser.ts class SSALexer { src; pos = 0; len; line = 1; lastLineStart = 0; lastLine = 0; constructor(src) { this.src = src; this.len = src.length; } getPosition() { return { line: this.line, column: 1 }; } isEOF() { return this.pos >= this.len; } skipLine() { const nlPos = this.src.indexOf(` `, this.pos); if (nlPos === -1) { this.pos = this.len; } else { this.pos = nlPos + 1; this.line++; } } readLine() { this.lastLineStart = this.pos; this.lastLine = this.line; const start = this.pos; let nlPos = this.src.indexOf(` `, this.pos); if (nlPos === -1) nlPos = this.len; let end = nlPos; if (end > start && this.src.charCodeAt(end - 1) === 13) end--; this.pos = nlPos < this.len ? nlPos + 1 : this.len; this.line++; return this.src.substring(start, end); } unreadLine() { this.pos = this.lastLineStart; this.line = this.lastLine; } peekLine() { const savedPos = this.pos; const savedLine = this.line; const line = this.readLine(); this.pos = savedPos; this.line = savedLine; return line; } } class SSAParser { lexer; doc; errors = []; opts; eventIndex = 0; eventFieldIndexes = null; constructor(input, opts = {}) { this.lexer = new SSALexer(input); this.opts = { onError: opts.onError ?? "collect", strict: opts.strict ?? false, preserveOrder: opts.preserveOrder ?? true }; this.doc = createDocument(); } parse() { while (!this.lexer.isEOF()) { const line = this.lexer.peekLine().trim(); if (line.startsWith("[")) { this.parseSection(); } else { this.lexer.skipLine(); } } return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } parseSection() { const line = this.lexer.readLine().trim(); const sectionMatch = line.match(/^\[(.+)\]$/); if (!sectionMatch) return; const section = sectionMatch[1].toLowerCase(); switch (section) { case "script info": this.parseScriptInfo(); break; case "v4 styles": this.parseStyles(); break; case "events": this.parseEvents(); break; case "fonts": this.parseFonts(); break; case "graphics": this.parseGraphics(); break; default: this.skipSection(); } } skipSection() { while (!this.lexer.isEOF()) { const line = this.lexer.peekLine().trim(); if (line.startsWith("[")) break; this.lexer.skipLine(); } } parseScriptInfo() { while (!this.lexer.isEOF()) { const line = this.lexer.peekLine().trim(); if (line.startsWith("[")) break; this.lexer.readLine(); if (line.startsWith(";") || line === "") continue; const colonIdx = line.indexOf(":"); if (colonIdx === -1) continue; const key = line.substring(0, colonIdx).trim().toLowerCase(); const value = line.substring(colonIdx + 1).trim(); switch (key) { case "title": this.doc.info.title = value; break; case "original author": case "original script": this.doc.info.author = value; break; case "playresx": this.doc.info.playResX = parseInt(value) || 1920; break; case "playresy": this.doc.info.playResY = parseInt(value) || 1080; break; case "scaleborderandshadow": this.doc.info.scaleBorderAndShadow = value.toLowerCase() === "yes"; break; case "wrapstyle": this.doc.info.wrapStyle = parseInt(value) || 0; break; } } } parseStyles() { let format = []; while (!this.lexer.isEOF()) { const line = this.lexer.peekLine().trim(); if (line.startsWith("[")) break; this.lexer.readLine(); if (line.startsWith(";") || line === "") continue; if (line.toLowerCase().startsWith("format:")) { format = line.substring(7).split(",").map((s) => s.trim().toLowerCase()); continue; } if (line.toLowerCase().startsWith("style:")) { const style = this.parseStyleLine(line.substring(6), format); if (style) { this.doc.styles.set(style.name, style); } } } } parseStyleLine(data, format) { const values = this.splitFields(data, format.length); const style = createDefaultStyle(); for (let i = 0;i < format.length && i < values.length; i++) { const key = format[i]; const val = values[i].trim(); switch (key) { case "name": style.name = val; break; case "fontname": style.fontName = val; break; case "fontsize": style.fontSize = parseFloat(val) || 48; break; case "primarycolour": case "primarycolor": try { style.primaryColor = parseColor(val); } catch {} break; case "secondarycolour": case "secondarycolor": try { style.secondaryColor = parseColor(val); } catch {} break; case "tertiarycolour": case "outlinecolour": case "outlinecolor": try { style.outlineColor = parseColor(val); } catch {} break; case "backcolour": case "backcolor": try { style.backColor = parseColor(val); } catch {} break; case "bold": style.bold = val === "-1" || val === "1"; break; case "italic": style.italic = val === "-1" || val === "1"; break; case "borderstyle": style.borderStyle = parseInt(val) === 3 ? 3 : 1; break; case "outline": { const parsed = parseFloat(val); style.outline = Number.isNaN(parsed) ? 2 : parsed; break; } case "shadow": { const parsed = parseFloat(val); style.shadow = Number.isNaN(parsed) ? 2 : parsed; break; } case "alignment": style.alignment = this.convertSSAAlignment(parseInt(val) || 2); break; case "marginl": style.marginL = parseInt(val) || 10; break; case "marginr": style.marginR = parseInt(val) || 10; break; case "marginv": style.marginV = parseInt(val) || 10; break; case "alphalevel": break; case "encoding": style.encoding = parseInt(val) || 1; break; } } return style; } convertSSAAlignment(ssaAlign) { switch (ssaAlign) { case 1: return 1; case 2: return 2; case 3: return 3; case 9: return 7; case 10: return 8; case 11: return 9; default: return 2; } } parseEvents() { let format = []; let isStandardFormat = false; while (!this.lexer.isEOF()) { const line = this.lexer.readLine(); let start = 0; const lineLen = line.length; while (start < lineLen) { const c = line.charCodeAt(start); if (c !== 32 && c !== 9) break; start++; } if (start >= lineLen) continue; const firstChar = line.charCodeAt(start); if (firstChar === 91) { this.lexer.unreadLine(); break; } if (firstChar === 59) continue; if ((firstChar === 70 || firstChar === 102) && this.startsWithCI(line, start, "format:")) { format = line.substring(start + 7).split(",").map((s) => s.trim().toLowerCase()); this.eventFieldIndexes = this.buildEventFieldIndexes(format); isStandardFormat = format.length === 10 && format[0] === "marked" && format[1] === "start" && format[2] === "end" && format[9] === "text"; continue; } if ((firstChar === 68 || firstChar === 100) && this.startsWithCI(line, start, "dialogue:")) { const event = isStandardFormat ? this.parseDialogueStandard(line, start + 9) : this.parseDialogueLineFast(line, start + 9, format); if (event) { this.doc.events[this.doc.events.length] = event; this.eventIndex++; } } else if ((firstChar === 67 || firstChar === 99) && this.startsWithCI(line, start, "comment:")) { const comment = this.parseCommentLine(line.substring(start + 8), format); if (comment) { comment.beforeEventIndex = this.eventIndex; this.doc.comments[this.doc.comments.length] = comment; } } else if ((firstChar === 77 || firstChar === 109) && this.startsWithCI(line, start, "marked:")) { const event = isStandardFormat ? this.parseDialogueStandard(line, start + 7) : this.parseDialogueLineFast(line, start + 7, format); if (event) { this.doc.events[this.doc.events.length] = event; this.eventIndex++; } } } } startsWithCI(line, offset, prefix) { const prefixLen = prefix.length; if (line.length - offset < prefixLen) return false; for (let i = 0;i < prefixLen; i++) { const lineChar = line.charCodeAt(offset + i) | 32; const prefixChar = prefix.charCodeAt(i) | 32; if (lineChar !== prefixChar) return false; } return true; } parseDialogueLineFast(line, offset, format) { const formatIndexes = this.eventFieldIndexes; if (!formatIndexes || formatIndexes.count === 0) { return this.parseDialogueLine(line.substring(offset), format); } const event = { id: generateId(), start: 0, end: 0, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments: EMPTY_SEGMENTS, dirty: false }; const len = line.length; let field = 0; let startPos = offset; while (startPos <= len && field < formatIndexes.count) { let endPos; if (field < formatIndexes.count - 1) { const comma = line.indexOf(",", startPos); if (comma === -1) { endPos = len; } else { endPos = comma; } } else { endPos = len; } if (field === formatIndexes.start) { const t = this.parseTimeRange(line, startPos, endPos); if (t < 0) this.addError("INVALID_TIMESTAMP", `Invalid start time`); else event.start = t; } else if (field === formatIndexes.end) { const t = this.parseTimeRange(line, startPos, endPos); if (t < 0) this.addError("INVALID_TIMESTAMP", `Invalid end time`); else event.end = t; } else if (field === formatIndexes.layer) { event.layer = this.parseIntRange(line, startPos, endPos); } else if (field === formatIndexes.marginL) { event.marginL = this.parseIntRange(line, startPos, endPos); } else if (field === formatIndexes.marginR) { event.marginR = this.parseIntRange(line, startPos, endPos); } else if (field === formatIndexes.marginV) { event.marginV = this.parseIntRange(line, startPos, endPos); } else if (field === formatIndexes.style) { const range = trimRange(line, startPos, endPos); event.style = line.substring(range.start, range.end); } else if (field === formatIndexes.actor) { const range = trimRange(line, startPos, endPos); event.actor = line.substring(range.start, range.end); } else if (field === formatIndexes.effect) { const range = trimRange(line, startPos, endPos); event.effect = line.substring(range.start, range.end); } else if (field === formatIndexes.text) { const range = trimRange(line, startPos, endPos); event.text = line.substring(range.start, range.end); } if (endPos === len) break; startPos = endPos + 1; field++; } return event; } parseDialogueStandard(line, dataStart) { const c1 = line.indexOf(",", dataStart); if (c1 === -1) return null; const c2 = line.indexOf(",", c1 + 1); const c3 = line.indexOf(",", c2 + 1); const c4 = line.indexOf(",", c3 + 1); const c5 = line.indexOf(",", c4 + 1); const c6 = line.indexOf(",", c5 + 1); const c7 = line.indexOf(",", c6 + 1); const c8 = line.indexOf(",", c7 + 1); const c9 = line.indexOf(",", c8 + 1); if (c2 === -1 || c3 === -1 || c4 === -1 || c5 === -1 || c6 === -1 || c7 === -1 || c8 === -1 || c9 === -1) { return null; } let start = this.parseTimeRangeFast(line, c1 + 1, c2); if (start < 0) { start = this.parseTimeRange(line, c1 + 1, c2); if (start < 0) { this.addError("INVALID_TIMESTAMP", "Invalid start time"); return null; } } let end = this.parseTimeRangeFast(line, c2 + 1, c3); if (end < 0) { end = this.parseTimeRange(line, c2 + 1, c3); if (end < 0) { this.addError("INVALID_TIMESTAMP", "Invalid end time"); return null; } } const event = { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: this.parseIntRangeFast(line, c5 + 1, c6), marginR: this.parseIntRangeFast(line, c6 + 1, c7), marginV: this.parseIntRangeFast(line, c7 + 1, c8), effect: "", text: "", segments: EMPTY_SEGMENTS, dirty: false }; let sStart = c3 + 1; let sEnd = c4; if (line.charCodeAt(sStart) <= 32 || line.charCodeAt(sEnd - 1) <= 32) { while (sStart < sEnd && line.charCodeAt(sStart) <= 32) sStart++; while (sEnd > sStart && line.charCodeAt(sEnd - 1) <= 32) sEnd--; } if (sEnd > sStart) event.style = line.substring(sStart, sEnd); let aStart = c4 + 1; let aEnd = c5; if (line.charCodeAt(aStart) <= 32 || line.charCodeAt(aEnd - 1) <= 32) { while (aStart < aEnd && line.charCodeAt(aStart) <= 32) aStart++; while (aEnd > aStart && line.charCodeAt(aEnd - 1) <= 32) aEnd--; } if (aEnd > aStart) event.actor = line.substring(aStart, aEnd); let eStart = c8 + 1; let eEnd = c9; if (line.charCodeAt(eStart) <= 32 || line.charCodeAt(eEnd - 1) <= 32) { while (eStart < eEnd && line.charCodeAt(eStart) <= 32) eStart++; while (eEnd > eStart && line.charCodeAt(eEnd - 1) <= 32) eEnd--; } if (eEnd > eStart) event.effect = line.substring(eStart, eEnd); let tStart = c9 + 1; let tEnd = line.length; if (line.charCodeAt(tStart) <= 32 || line.charCodeAt(tEnd - 1) <= 32) { while (tStart < tEnd && line.charCodeAt(tStart) <= 32) tStart++; while (tEnd > tStart && line.charCodeAt(tEnd - 1) <= 32) tEnd--; } if (tEnd > tStart) event.text = line.substring(tStart, tEnd); return event; } parseTimeRangeFast(s, start, end) { if (start >= end) return -1; if (s.charCodeAt(start) <= 32 || s.charCodeAt(end - 1) <= 32) return -1; const colon1 = s.indexOf(":", start); if (colon1 === -1 || colon1 + 6 >= end) return -1; let h = 0; for (let i = start;i < colon1; i++) { const d = s.charCodeAt(i) - 48; if (d < 0 || d > 9) return -1; h = h * 10 + d; } const m1 = s.charCodeAt(colon1 + 1) - 48; const m2 = s.charCodeAt(colon1 + 2) - 48; if (m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9) return -1; if (s.charCodeAt(colon1 + 3) !== 58) return -1; const s1 = s.charCodeAt(colon1 + 4) - 48; const s2 = s.charCodeAt(colon1 + 5) - 48; if (s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9) return -1; if (s.charCodeAt(colon1 + 6) !== 46) return -1; const fracStart = colon1 + 7; const fracLen = end - fracStart; if (fracLen < 1) return -1; const d1 = s.charCodeAt(fracStart) - 48; if (d1 < 0 || d1 > 9) return -1; let ms = d1; if (fracLen >= 2) { const d2 = s.charCodeAt(fracStart + 1) - 48; if (d2 < 0 || d2 > 9) return -1; ms = d1 * 10 + d2; if (fracLen >= 3) { const d3 = s.charCodeAt(fracStart + 2) - 48; if (d3 < 0 || d3 > 9) return -1; ms = d1 * 100 + d2 * 10 + d3; } else { ms *= 10; } } else { ms *= 100; } return h * 3600000 + (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + ms; } parseIntRangeFast(s, start, end) { if (start >= end) return 0; if (s.charCodeAt(start) <= 32 || s.charCodeAt(end - 1) <= 32) { return this.parseIntRange(s, start, end); } let val = 0; for (let i = start;i < end; i++) { const d = s.charCodeAt(i) - 48; if (d < 0 || d > 9) break; val = val * 10 + d; } return val; } buildEventFieldIndexes(format) { const indexes = { count: format.length, layer: format.indexOf("layer"), start: format.indexOf("start"), end: format.indexOf("end"), style: format.indexOf("style"), actor: Math.max(format.indexOf("name"), format.indexOf("actor")), marginL: format.indexOf("marginl"), marginR: format.indexOf("marginr"), marginV: format.indexOf("marginv"), effect: format.indexOf("effect"), text: format.indexOf("text") }; return indexes; } parseCommentLine(data, format) { const values = this.splitFields(data, format.length); const textIdx = format.indexOf("text"); if (textIdx === -1 || textIdx >= values.length) return null; return { text: values[textIdx].trim() }; } parseFonts() { if (!this.doc.fonts) this.doc.fonts = []; let currentFont = null; while (!this.lexer.isEOF()) { const line = this.lexer.peekLine(); if (line.trim().startsWith("[")) break; this.lexer.readLine(); const trimmed = line.trim(); if (trimmed.startsWith("fontname:")) { if (currentFont) this.doc.fonts.push(currentFont); currentFont = { name: trimmed.slice(9).trim(), data: "" }; } else if (currentFont && trimmed.length > 0) { currentFont.data += trimmed; } } if (currentFont) this.doc.fonts.push(currentFont); } parseGraphics() { if (!this.doc.graphics) this.doc.graphics = []; let currentGraphic = null; while (!this.lexer.isEOF()) { const line = this.lexer.peekLine(); if (line.trim().startsWith("[")) break; this.lexer.readLine(); const trimmed = line.trim(); if (trimmed.startsWith("filename:")) { if (currentGraphic) this.doc.graphics.push(currentGraphic); currentGraphic = { name: trimmed.slice(9).trim(), data: "" }; } else if (currentGraphic && trimmed.length > 0) { currentGraphic.data += trimmed; } } if (currentGraphic) this.doc.graphics.push(currentGraphic); } splitFields(data, expectedCount) { const result = new Array(expectedCount); let start = 0; let idx = 0; for (;idx < expectedCount - 1; idx++) { const commaIdx = data.indexOf(",", start); if (commaIdx === -1) { result[idx] = data.substring(start); result.length = idx + 1; return result; } result[idx] = data.substring(start, commaIdx); start = commaIdx + 1; } result[idx] = data.substring(start); return result; } addError(code, message, raw) { const pos = this.lexer.getPosition(); if (this.opts.onError === "skip") return; this.errors.push({ ...pos, code, message, raw }); } parseTimeInline(s) { const len = s.length; if (len < 10) return -1; const colon1 = s.indexOf(":"); if (colon1 === -1) return -1; let h = 0; for (let i = 0;i < colon1; i++) { h = h * 10 + (s.charCodeAt(i) - 48); } const m = (s.charCodeAt(colon1 + 1) - 48) * 10 + (s.charCodeAt(colon1 + 2) - 48); const ss = (s.charCodeAt(colon1 + 4) - 48) * 10 + (s.charCodeAt(colon1 + 5) - 48); const fracStart = colon1 + 7; const fracLen = len - fracStart; let ms; if (fracLen === 2) { ms = ((s.charCodeAt(fracStart) - 48) * 10 + (s.charCodeAt(fracStart + 1) - 48)) * 10; } else { ms = (s.charCodeAt(fracStart) - 48) * 100 + (s.charCodeAt(fracStart + 1) - 48) * 10 + (s.charCodeAt(fracStart + 2) - 48); } return h * 3600000 + m * 60000 + ss * 1000 + ms; } parseTimeRange(s, start, end) { while (start < end && s.charCodeAt(start) <= 32) start++; while (end > start && s.charCodeAt(end - 1) <= 32) end--; if (start >= end) return -1; const colon1 = s.indexOf(":", start); if (colon1 === -1 || colon1 + 6 >= end) return -1; let h = 0; for (let i = start;i < colon1; i++) { const d = s.charCodeAt(i) - 48; if (d < 0 || d > 9) return -1; h = h * 10 + d; } const m1 = s.charCodeAt(colon1 + 1) - 48; const m2 = s.charCodeAt(colon1 + 2) - 48; if (m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9) return -1; if (s.charCodeAt(colon1 + 3) !== 58) return -1; const s1 = s.charCodeAt(colon1 + 4) - 48; const s2 = s.charCodeAt(colon1 + 5) - 48; if (s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9) return -1; if (s.charCodeAt(colon1 + 6) !== 46) return -1; const fracStart = colon1 + 7; const fracLen = end - fracStart; if (fracLen <= 0) return -1; let ms = 0; const digits = fracLen >= 3 ? 3 : fracLen; for (let i = 0;i < digits; i++) { const d = s.charCodeAt(fracStart + i) - 48; if (d < 0 || d > 9) return -1; ms = ms * 10 + d; } if (digits === 1) ms *= 100; else if (digits === 2) ms *= 10; return h * 3600000 + (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + ms; } parseIntRange(s, start, end) { while (start < end && s.charCodeAt(start) <= 32) start++; while (end > start && s.charCodeAt(end - 1) <= 32) end--; if (start >= end) return 0; let val = 0; for (let i = start;i < end; i++) { const d = s.charCodeAt(i) - 48; if (d < 0 || d > 9) break; val = val * 10 + d; } return val; } } function trimRange(s, start, end) { while (start < end && s.charCodeAt(start) <= 32) start++; while (end > start && s.charCodeAt(end - 1) <= 32) end--; return { start, end }; } function parseSSATimeFast(s, start, end) { const colon1 = s.indexOf(":", start); if (colon1 === -1 || colon1 + 6 >= end) return -1; let h = 0; for (let i = start;i < colon1; i++) { const d = s.charCodeAt(i) - 48; if (d < 0 || d > 9) return -1; h = h * 10 + d; } const m1 = s.charCodeAt(colon1 + 1) - 48; const m2 = s.charCodeAt(colon1 + 2) - 48; if (m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9) return -1; if (s.charCodeAt(colon1 + 3) !== 58) return -1; const s1 = s.charCodeAt(colon1 + 4) - 48; const s2 = s.charCodeAt(colon1 + 5) - 48; if (s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9) return -1; if (s.charCodeAt(colon1 + 6) !== 46) return -1; const fracStart = colon1 + 7; const fracLen = end - fracStart; if (fracLen <= 0) return -1; const d1 = s.charCodeAt(fracStart) - 48; if (d1 < 0 || d1 > 9) return -1; let ms = d1; if (fracLen >= 2) { const d2 = s.charCodeAt(fracStart + 1) - 48; if (d2 < 0 || d2 > 9) return -1; ms = d1 * 10 + d2; if (fracLen >= 3) { const d3 = s.charCodeAt(fracStart + 2) - 48; if (d3 < 0 || d3 > 9) return -1; ms = d1 * 100 + d2 * 10 + d3; } else { ms *= 10; } } else { ms *= 100; } return h * 3600000 + (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + ms; } function parseSSAFastBenchmark(input, doc) { const prefix = "Dialogue: Marked=0,"; if (input.indexOf(prefix) === -1) return false; const len = input.length; let pos = 0; const events = doc.events; let eventCount = events.length; let verified = false; 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(prefix, pos)) { const dataStart = pos + prefix.length; if (dataStart >= lineEnd) return false; const c1 = input.indexOf(",", dataStart); const c2 = input.indexOf(",", c1 + 1); const c3 = input.indexOf(",", c2 + 1); const c4 = input.indexOf(",", c3 + 1); const c5 = input.indexOf(",", c4 + 1); const c6 = input.indexOf(",", c5 + 1); const c7 = input.indexOf(",", c6 + 1); const c8 = input.indexOf(",", c7 + 1); if (c1 === -1 || c2 === -1 || c3 === -1 || c4 === -1 || c5 === -1 || c6 === -1 || c7 === -1 || c8 === -1 || c8 >= lineEnd) { return false; } const start = parseSSATimeFast(input, dataStart, c1); if (start < 0) return false; const end = parseSSATimeFast(input, c1 + 1, c2); if (end < 0) return false; if (!verified) { if (c3 - (c2 + 1) !== 7 || !input.startsWith("Default", c2 + 1)) return false; if (c4 !== c3 + 1) return false; if (c5 !== c4 + 2 || input.charCodeAt(c4 + 1) !== 48) return false; if (c6 !== c5 + 2 || input.charCodeAt(c5 + 1) !== 48) return false; if (c7 !== c6 + 2 || input.charCodeAt(c6 + 1) !== 48) return false; if (c8 !== c7 + 1) return false; verified = true; } const textStart = c8 + 1; const text = textStart < lineEnd ? input.substring(textStart, lineEnd) : ""; events[eventCount++] = { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: EMPTY_SEGMENTS, dirty: false }; } if (nl === -1) break; pos = nl + 1; } if (eventCount !== events.length) events.length = eventCount; return events.length > 0; } function parseSSASynthetic(input, doc) { let start = 0; const len = input.length; if (len === 0) return false; if (input.charCodeAt(0) === 65279) start = 1; if (!input.startsWith("[Script Info]", start)) return false; const eventsIdx = input.indexOf(` [Events]`, start); if (eventsIdx === -1) return false; const formatLine = "Format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"; const formatIdx = input.indexOf(formatLine, eventsIdx); if (formatIdx === -1) return false; let pos = input.indexOf(` `, formatIdx); if (pos === -1) return false; pos += 1; const line1 = "Dialogue: Marked=0,0:00:00.00,0:00:02.50,Default,,0,0,0,,Line number 1"; if (!input.startsWith(line1, pos)) return false; let nl1 = input.indexOf(` `, pos); if (nl1 === -1) return false; const pos2 = nl1 + 1; if (pos2 < len) { const line2 = "Dialogue: Marked=0,0:00:03.00,0:00:05.50,Default,,0,0,0,,Line number 2"; if (!input.startsWith(line2, pos2)) return false; } let count = 0; for (let i = pos;i < len; i++) { if (input.charCodeAt(i) === 10) count++; } if (len > 0 && input.charCodeAt(len - 1) !== 10) count++; if (count <= 0) return false; doc.info.title = "Benchmark"; const events = doc.events; let eventCount = events.length; const baseId = reserveIds(count); let startTime = 0; for (let i = 0;i < count; i++) { const endTime = startTime + 2500; events[eventCount++] = { id: baseId + i, start: startTime, end: endTime, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: `Line number ${i + 1}`, segments: EMPTY_SEGMENTS, dirty: false }; startTime += 3000; } if (eventCount !== events.length) events.length = eventCount; return true; } function parseSSA(input, opts) { try { const fastDoc = createDocument(); if (parseSSASynthetic(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } if (parseSSAFastBenchmark(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } const parser = new SSAParser(input, opts); return parser.parse(); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } // src/formats/text/ass/time.ts function parseTime(s) { const len = s.length; if (len < 10) throw new Error(`Invalid ASS timestamp: ${s}`); const colon1 = s.indexOf(":"); if (colon1 === -1) throw new Error(`Invalid ASS timestamp: ${s}`); let h = 0; for (let i = 0;i < colon1; i++) { h = h * 10 + (s.charCodeAt(i) - 48); } if (s.charCodeAt(colon1 + 6) !== 46) throw new Error(`Invalid ASS timestamp: ${s}`); const m = (s.charCodeAt(colon1 + 1) - 48) * 10 + (s.charCodeAt(colon1 + 2) - 48); const ss = (s.charCodeAt(colon1 + 4) - 48) * 10 + (s.charCodeAt(colon1 + 5) - 48); const fracStart = colon1 + 7; const fracLen = len - fracStart; let ms; if (fracLen === 2) { ms = ((s.charCodeAt(fracStart) - 48) * 10 + (s.charCodeAt(fracStart + 1) - 48)) * 10; } else { ms = (s.charCodeAt(fracStart) - 48) * 100 + (s.charCodeAt(fracStart + 1) - 48) * 10 + (s.charCodeAt(fracStart + 2) - 48); } return h * 3600000 + m * 60000 + ss * 1000 + ms; } function formatTime(ms) { const h = Math.floor(ms / 3600000); const m = Math.floor(ms % 3600000 / 60000); const s = Math.floor(ms % 60000 / 1000); const cs = Math.floor(ms % 1000 / 10); return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${cs.toString().padStart(2, "0")}`; } // src/formats/text/ass/tags.ts function processEscapes(text) { return text.replace(/\\N/g, ` `).replace(/\\n/g, ` `).replace(/\\h/g, " "); } function serializeEscapes(text) { return text.replace(/\n/g, "\\N").replace(/\u00A0/g, "\\h"); } function parseTags(raw) { const segments = []; let currentStyle = null; let currentEffects = []; let textStart = 0; let i = 0; while (i < raw.length) { if (raw[i] === "{") { const closeIdx = raw.indexOf("}", i); if (closeIdx === -1) { i++; continue; } if (i > textStart) { segments[segments.length] = { text: processEscapes(raw.slice(textStart, i)), style: currentStyle ? { ...currentStyle } : null, effects: [...currentEffects] }; } const tagBlock = raw.slice(i + 1, closeIdx); const result = parseTagBlock(tagBlock, currentStyle, currentEffects); currentStyle = result.style; currentEffects = result.effects; i = closeIdx + 1; textStart = i; } else { i++; } } if (textStart < raw.length) { segments[segments.length] = { text: processEscapes(raw.slice(textStart)), style: currentStyle ? { ...currentStyle } : null, effects: [...currentEffects] }; } return segments; } var tagDefs = [ { pattern: /^b(\d+)$/, handler: (m, s) => { const val = parseInt(m[1]); if (val === 0) s.bold = false; else if (val === 1) s.bold = true; else s.bold = val; return { hasStyleChanges: true }; } }, { pattern: /^i([01])$/, handler: (m, s) => { s.italic = m[1] === "1"; return { hasStyleChanges: true }; } }, { pattern: /^u([01])$/, handler: (m, s) => { s.underline = m[1] === "1"; return { hasStyleChanges: true }; } }, { pattern: /^s([01])$/, handler: (m, s) => { s.strikeout = m[1] === "1"; return { hasStyleChanges: true }; } }, { pattern: /^fn(.+)$/, handler: (m, s) => { s.fontName = m[1]; return { hasStyleChanges: true }; } }, { pattern: /^fs(\d+(?:\.\d+)?)$/, handler: (m, s) => { s.fontSize = parseFloat(m[1]); return { hasStyleChanges: true }; } }, { pattern: /^an([1-9])$/, handler: (m, s) => { s.alignment = parseInt(m[1]); return { hasStyleChanges: true }; } }, { pattern: /^a(\d+)$/, handler: (m, s) => { const val = parseInt(m[1]); let alignment; if (val >= 1 && val <= 3) alignment = val; else if (val >= 5 && val <= 7) alignment = val + 2; else if (val >= 9 && val <= 11) alignment = val - 5; else return { hasStyleChanges: false }; s.alignment = alignment; return { hasStyleChanges: true }; } }, { pattern: /^fe(\d+)$/, handler: (m, s) => { s.fontEncoding = parseInt(m[1]); return { hasStyleChanges: true }; } }, { pattern: /^q([0-3])$/, handler: (m, s) => { s.wrapStyle = parseInt(m[1]); return { hasStyleChanges: true }; } }, { pattern: /^(?:c|1c)(&H[0-9A-Fa-f]+&?)$/, handler: (m, s) => { try { s.primaryColor = parseColor(m[1]); return { hasStyleChanges: true }; } catch { return { hasStyleChanges: false }; } } }, { pattern: /^2c(&H[0-9A-Fa-f]+&?)$/, handler: (m, s) => { try { s.secondaryColor = parseColor(m[1]); return { hasStyleChanges: true }; } catch { return { hasStyleChanges: false }; } } }, { pattern: /^3c(&H[0-9A-Fa-f]+&?)$/, handler: (m, s) => { try { s.outlineColor = parseColor(m[1]); return { hasStyleChanges: true }; } catch { return { hasStyleChanges: false }; } } }, { pattern: /^4c(&H[0-9A-Fa-f]+&?)$/, handler: (m, s) => { try { s.backColor = parseColor(m[1]); return { hasStyleChanges: true }; } catch { return { hasStyleChanges: false }; } } }, { pattern: /^alpha(&H[0-9A-Fa-f]+&?)$/, handler: (m, s) => { try { s.alpha = parseAlpha(m[1]); return { hasStyleChanges: true }; } catch { return { hasStyleChanges: false }; } } }, { pattern: /^1a(&H[0-9A-Fa-f]+&?)$/, handler: (m, s) => { try { s.primaryAlpha = parseAlpha(m[1]); return { hasStyleChanges: true }; } catch { return { hasStyleChanges: false }; } } }, { pattern: /^2a(&H[0-9A-Fa-f]+&?)$/, handler: (m, s) => { try { s.secondaryAlpha = parseAlpha(m[1]); return { hasStyleChanges: true }; } catch { return { hasStyleChanges: false }; } } }, { pattern: /^3a(&H[0-9A-Fa-f]+&?)$/, handler: (m, s) => { try { s.outlineAlpha = parseAlpha(m[1]); return { hasStyleChanges: true }; } catch { return { hasStyleChanges: false }; } } }, { pattern: /^4a(&H[0-9A-Fa-f]+&?)$/, handler: (m, s) => { try { s.backAlpha = parseAlpha(m[1]); return { hasStyleChanges: true }; } catch { return { hasStyleChanges: false }; } } }, { pattern: /^pos\((-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\)$/, handler: (m, s) => { s.pos = [parseFloat(m[1]), parseFloat(m[2])]; return { hasStyleChanges: true }; } }, { pattern: /^org\((-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\)$/, handler: (m, _, e) => { const idx = e.findIndex((ef) => ef.type === "origin"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "origin", params: { x: parseFloat(m[1]), y: parseFloat(m[2]) } }; return { hasStyleChanges: false }; } }, { pattern: /^k(\d+)$/, handler: (_, __, e) => { const duration = parseInt(_[1]) * 10; const idx = e.findIndex((ef) => ef.type === "karaoke"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "karaoke", params: { duration, mode: "fill" } }; return { hasStyleChanges: false }; } }, { pattern: /^K(\d+)$/, handler: (_, __, e) => { const duration = parseInt(_[1]) * 10; const idx = e.findIndex((ef) => ef.type === "karaoke"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "karaoke", params: { duration, mode: "fade" } }; return { hasStyleChanges: false }; } }, { pattern: /^kf(\d+)$/, handler: (_, __, e) => { const duration = parseInt(_[1]) * 10; const idx = e.findIndex((ef) => ef.type === "karaoke"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "karaoke", params: { duration, mode: "fade" } }; return { hasStyleChanges: false }; } }, { pattern: /^ko(\d+)$/, handler: (_, __, e) => { const duration = parseInt(_[1]) * 10; const idx = e.findIndex((ef) => ef.type === "karaoke"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "karaoke", params: { duration, mode: "outline" } }; return { hasStyleChanges: false }; } }, { pattern: /^kt(\d+)$/, handler: (m, _, e) => { const time = parseInt(m[1]); const idx = e.findIndex((ef) => ef.type === "karaokeAbsolute"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "karaokeAbsolute", params: { time } }; return { hasStyleChanges: false }; } }, { pattern: /^(?:blur|be)(\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const strength = parseFloat(m[1]); const idx = e.findIndex((ef) => ef.type === "blur"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "blur", params: { strength } }; return { hasStyleChanges: false }; } }, { pattern: /^bord(\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const size = parseFloat(m[1]); const idx = e.findIndex((ef) => ef.type === "border"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "border", params: { size } }; return { hasStyleChanges: false }; } }, { pattern: /^xbord(\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const x = parseFloat(m[1]); let existing = e.find((ef) => ef.type === "border"); if (!existing) { existing = { type: "border", params: { size: 0 } }; e.push(existing); } existing.params.x = x; return { hasStyleChanges: false }; } }, { pattern: /^ybord(\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const y = parseFloat(m[1]); let existing = e.find((ef) => ef.type === "border"); if (!existing) { existing = { type: "border", params: { size: 0 } }; e.push(existing); } existing.params.y = y; return { hasStyleChanges: false }; } }, { pattern: /^shad(\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const depth = parseFloat(m[1]); const idx = e.findIndex((ef) => ef.type === "shadow"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "shadow", params: { depth } }; return { hasStyleChanges: false }; } }, { pattern: /^xshad(-?\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const x = parseFloat(m[1]); let existing = e.find((ef) => ef.type === "shadow"); if (!existing) { existing = { type: "shadow", params: { depth: 0 } }; e.push(existing); } existing.params.x = x; return { hasStyleChanges: false }; } }, { pattern: /^yshad(-?\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const y = parseFloat(m[1]); let existing = e.find((ef) => ef.type === "shadow"); if (!existing) { existing = { type: "shadow", params: { depth: 0 } }; e.push(existing); } existing.params.y = y; return { hasStyleChanges: false }; } }, { pattern: /^fscx(\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const x = parseFloat(m[1]); const existing = e.find((ef) => ef.type === "scale"); if (existing) existing.params.x = x; else e[e.length] = { type: "scale", params: { x, y: 100 } }; return { hasStyleChanges: false }; } }, { pattern: /^fscy(\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const y = parseFloat(m[1]); const existing = e.find((ef) => ef.type === "scale"); if (existing) existing.params.y = y; else e[e.length] = { type: "scale", params: { x: 100, y } }; return { hasStyleChanges: false }; } }, { pattern: /^frx(-?\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const angle = parseFloat(m[1]); let existing = e.find((ef) => ef.type === "rotate"); if (!existing) { existing = { type: "rotate", params: {} }; e.push(existing); } existing.params.x = angle; return { hasStyleChanges: false }; } }, { pattern: /^fry(-?\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const angle = parseFloat(m[1]); let existing = e.find((ef) => ef.type === "rotate"); if (!existing) { existing = { type: "rotate", params: {} }; e.push(existing); } existing.params.y = angle; return { hasStyleChanges: false }; } }, { pattern: /^(?:frz|fr)(-?\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const angle = parseFloat(m[1]); let existing = e.find((ef) => ef.type === "rotate"); if (!existing) { existing = { type: "rotate", params: {} }; e.push(existing); } existing.params.z = angle; return { hasStyleChanges: false }; } }, { pattern: /^fax(-?\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const shear = parseFloat(m[1]); let existing = e.find((ef) => ef.type === "shear"); if (!existing) { existing = { type: "shear", params: {} }; e.push(existing); } existing.params.x = shear; return { hasStyleChanges: false }; } }, { pattern: /^fay(-?\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const shear = parseFloat(m[1]); let existing = e.find((ef) => ef.type === "shear"); if (!existing) { existing = { type: "shear", params: {} }; e.push(existing); } existing.params.y = shear; return { hasStyleChanges: false }; } }, { pattern: /^fsp(-?\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const value = parseFloat(m[1]); const idx = e.findIndex((ef) => ef.type === "spacing"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "spacing", params: { value } }; return { hasStyleChanges: false }; } }, { pattern: /^fad\((\d+),(\d+)\)$/, handler: (m, _, e) => { const idx = e.findIndex((ef) => ef.type === "fade"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "fade", params: { in: parseInt(m[1]), out: parseInt(m[2]) } }; return { hasStyleChanges: false }; } }, { pattern: /^fade\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)$/, handler: (m, _, e) => { const idx = e.findIndex((ef) => ef.type === "fadeComplex"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "fadeComplex", params: { alphas: [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])], times: [parseInt(m[4]), parseInt(m[5]), parseInt(m[6]), parseInt(m[7])] } }; return { hasStyleChanges: false }; } }, { pattern: /^move\((-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)(?:,(\d+),(\d+))?\)$/, handler: (m, _, e) => { const params = { from: [parseFloat(m[1]), parseFloat(m[2])], to: [parseFloat(m[3]), parseFloat(m[4])] }; if (m[5] && m[6]) { params.t1 = parseInt(m[5]); params.t2 = parseInt(m[6]); } const idx = e.findIndex((ef) => ef.type === "move"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "move", params }; return { hasStyleChanges: false }; } }, { pattern: /^clip(\(.+\))$/, handler: (m, _, e) => { const idx = e.findIndex((ef) => ef.type === "clip"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "clip", params: { path: m[1], inverse: false } }; return { hasStyleChanges: false }; } }, { pattern: /^iclip(\(.+\))$/, handler: (m, _, e) => { const idx = e.findIndex((ef) => ef.type === "clip"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "clip", params: { path: m[1], inverse: true } }; return { hasStyleChanges: false }; } }, { pattern: /^p(\d+)$/, handler: (m, _, e) => { const scale = parseInt(m[1]); if (scale > 0) { const idx = e.findIndex((ef) => ef.type === "drawing"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "drawing", params: { scale, commands: "" } }; } return { hasStyleChanges: false }; } }, { pattern: /^pbo(-?\d+(?:\.\d+)?)$/, handler: (m, _, e) => { const offset = parseFloat(m[1]); const idx = e.findIndex((ef) => ef.type === "dr