UNPKG

subforge

Version:

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

1,670 lines (1,667 loc) 54.4 kB
// 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/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/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 === "drawingBaseline"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "drawingBaseline", params: { offset } }; return { hasStyleChanges: false }; } }, { pattern: /^r(.*)$/, handler: (m, s, e) => { const idx = e.findIndex((ef) => ef.type === "reset"); if (idx !== -1) e.splice(idx, 1); e[e.length] = { type: "reset", params: { style: m[1] || undefined } }; for (const key of Object.keys(s)) delete s[key]; return { hasStyleChanges: false }; } }, { pattern: /^t\((.+)\)$/, handler: (m, _, e) => { const inner = m[1]; const tMatch = inner.match(/^(?:(\d+),(\d+),)?(?:(\d+(?:\.\d+)?),)?(.+)$/); if (tMatch) { e[e.length] = { type: "animate", params: { start: tMatch[1] ? parseInt(tMatch[1]) : 0, end: tMatch[2] ? parseInt(tMatch[2]) : 0, accel: tMatch[3] ? parseFloat(tMatch[3]) : undefined, target: {} } }; } return { hasStyleChanges: false }; } } ]; function parseTagBlock(block, currentStyle, currentEffects) { const style = currentStyle ? { ...currentStyle } : {}; const effects = [...currentEffects]; let hasStyleChanges = currentStyle !== null; const tags = block.split("\\").filter((t) => t.length > 0); for (const tagStr of tags) { let matched = false; for (const def of tagDefs) { const match = tagStr.match(def.pattern); if (match) { const result = def.handler(match, style, effects); if (result.hasStyleChanges) hasStyleChanges = true; matched = true; break; } } if (!matched && tagStr.length > 0) { effects[effects.length] = { type: "unknown", params: { format: "ass", raw: `\\${tagStr}` } }; } } return { style: hasStyleChanges ? style : null, effects }; } function serializeTags(segments) { let result = ""; let prevStyle = null; for (const seg of segments) { const tags = []; if (seg.style) { if (seg.style.bold !== undefined && seg.style.bold !== prevStyle?.bold) { if (typeof seg.style.bold === "number") { tags[tags.length] = `\\b${seg.style.bold}`; } else { tags[tags.length] = `\\b${seg.style.bold ? "1" : "0"}`; } } if (seg.style.italic !== undefined && seg.style.italic !== prevStyle?.italic) { tags[tags.length] = `\\i${seg.style.italic ? "1" : "0"}`; } if (seg.style.underline !== undefined && seg.style.underline !== prevStyle?.underline) { tags[tags.length] = `\\u${seg.style.underline ? "1" : "0"}`; } if (seg.style.strikeout !== undefined && seg.style.strikeout !== prevStyle?.strikeout) { tags[tags.length] = `\\s${seg.style.strikeout ? "1" : "0"}`; } if (seg.style.fontName !== undefined && seg.style.fontName !== prevStyle?.fontName) { tags[tags.length] = `\\fn${seg.style.fontName}`; } if (seg.style.fontSize !== undefined && seg.style.fontSize !== prevStyle?.fontSize) { tags[tags.length] = `\\fs${seg.style.fontSize}`; } if (seg.style.fontEncoding !== undefined && seg.style.fontEncoding !== prevStyle?.fontEncoding) { tags[tags.length] = `\\fe${seg.style.fontEncoding}`; } if (seg.style.wrapStyle !== undefined && seg.style.wrapStyle !== prevStyle?.wrapStyle) { tags[tags.length] = `\\q${seg.style.wrapStyle}`; } if (seg.style.primaryColor !== undefined && seg.style.primaryColor !== prevStyle?.primaryColor) { tags[tags.length] = `\\c&H${(seg.style.primaryColor >>> 0).toString(16).toUpperCase().padStart(8, "0")}&`; } if (seg.style.secondaryColor !== undefined && seg.style.secondaryColor !== prevStyle?.secondaryColor) { tags[tags.length] = `\\2c&H${(seg.style.secondaryColor >>> 0).toString(16).toUpperCase().padStart(8, "0")}&`; } if (seg.style.outlineColor !== undefined && seg.style.outlineColor !== prevStyle?.outlineColor) { tags[tags.length] = `\\3c&H${(seg.style.outlineColor >>> 0).toString(16).toUpperCase().padStart(8, "0")}&`; } if (seg.style.backColor !== undefined && seg.style.backColor !== prevStyle?.backColor) { tags[tags.length] = `\\4c&H${(seg.style.backColor >>> 0).toString(16).toUpperCase().padStart(8, "0")}&`; } if (seg.style.alpha !== undefined && seg.style.alpha !== prevStyle?.alpha) { tags[tags.length] = `\\alpha&H${seg.style.alpha.toString(16).toUpperCase().padStart(2, "0")}&`; } if (seg.style.primaryAlpha !== undefined && seg.style.primaryAlpha !== prevStyle?.primaryAlpha) { tags[tags.length] = `\\1a&H${seg.style.primaryAlpha.toString(16).toUpperCase().padStart(2, "0")}&`; } if (seg.style.secondaryAlpha !== undefined && seg.style.secondaryAlpha !== prevStyle?.secondaryAlpha) { tags[tags.length] = `\\2a&H${seg.style.secondaryAlpha.toString(16).toUpperCase().padStart(2, "0")}&`; } if (seg.style.outlineAlpha !== undefined && seg.style.outlineAlpha !== prevStyle?.outlineAlpha) { tags[tags.length] = `\\3a&H${seg.style.outlineAlpha.toString(16).toUpperCase().padStart(2, "0")}&`; } if (seg.style.backAlpha !== undefined && seg.style.backAlpha !== prevStyle?.backAlpha) { tags[tags.length] = `\\4a&H${seg.style.backAlpha.toString(16).toUpperCase().padStart(2, "0")}&`; } if (seg.style.alignment !== undefined && seg.style.alignment !== prevStyle?.alignment) { tags[tags.length] = `\\an${seg.style.alignment}`; } if (seg.style.pos !== undefined) { tags[tags.length] = `\\pos(${seg.style.pos[0]},${seg.style.pos[1]})`; } } for (const effect of seg.effects) { switch (effect.type) { case "karaoke": { const p = effect.params; const prefix = p.mode === "fade" ? "kf" : p.mode === "outline" ? "ko" : "k"; tags[tags.length] = `\\${prefix}${p.duration / 10}`; break; } case "karaokeAbsolute": { const p = effect.params; tags[tags.length] = `\\kt${p.time}`; break; } case "blur": { const p = effect.params; tags[tags.length] = `\\blur${p.strength}`; break; } case "border": { const p = effect.params; if (p.size) tags[tags.length] = `\\bord${p.size}`; if (p.x !== undefined) tags[tags.length] = `\\xbord${p.x}`; if (p.y !== undefined) tags[tags.length] = `\\ybord${p.y}`; break; } case "shadow": { const p = effect.params; if (p.depth) tags[tags.length] = `\\shad${p.depth}`; if (p.x !== undefined) tags[tags.length] = `\\xshad${p.x}`; if (p.y !== undefined) tags[tags.length] = `\\yshad${p.y}`; break; } case "origin": { const p = effect.params; tags[tags.length] = `\\org(${p.x},${p.y})`; break; } case "scale": { const p = effect.params; if (p.x !== 100) tags[tags.length] = `\\fscx${p.x}`; if (p.y !== 100) tags[tags.length] = `\\fscy${p.y}`; break; } case "rotate": { const p = effect.params; if (p.x !== undefined) tags[tags.length] = `\\frx${p.x}`; if (p.y !== undefined) tags[tags.length] = `\\fry${p.y}`; if (p.z !== undefined) tags[tags.length] = `\\frz${p.z}`; break; } case "shear": { const p = effect.params; if (p.x !== undefined) tags[tags.length] = `\\fax${p.x}`; if (p.y !== undefined) tags[tags.length] = `\\fay${p.y}`; break; } case "spacing": { const p = effect.params; tags[tags.length] = `\\fsp${p.value}`; break; } case "fade": { const p = effect.params; tags[tags.length] = `\\fad(${p.in},${p.out}`; break; } case "fadeComplex": { const p = effect.params; tags[tags.length] = `\\fade(${p.alphas.join(",")},${p.times.join(",")}`; break; } case "move": { const p = effect.params; if (p.t1 !== undefined && p.t2 !== undefined) { tags[tags.length] = `\\move(${p.from[0]},${p.from[1]},${p.to[0]},${p.to[1]},${p.t1},${p.t2}`; } else { tags[tags.length] = `\\move(${p.from[0]},${p.from[1]},${p.to[0]},${p.to[1]}`; } break; } case "clip": { const p = effect.params; tags[tags.length] = `\\${p.inverse ? "iclip" : "clip"}${p.path}`; break; } case "drawing": { const p = effect.params; tags[tags.length] = `\\p${p.scale}`; break; } case "drawingBaseline": { const p = effect.params; tags[tags.length] = `\\pbo${p.offset}`; break; } case "reset": { const p = effect.params; tags[tags.length] = `\\r${p.style ?? ""}`; break; } case "unknown": { const p = effect.params; tags[tags.length] = p.raw; break; } } } if (tags.length > 0) { result += `{${tags.join("")}}`; } result += serializeEscapes(seg.text); prevStyle = seg.style; } return result; } function stripTags(raw) { return raw.replace(/\{[^}]*\}/g, ""); } // src/core/errors.ts class SubforgeError extends Error { code; line; column; constructor(code, message, position) { super(`[${code}] ${message} at line ${position.line}, column ${position.column}`); this.name = "SubforgeError"; this.code = code; this.line = position.line; this.column = position.column; } } function toParseError(err) { if (err instanceof SubforgeError) { return { line: err.line, column: err.column, code: err.code, message: err.message }; } if (err instanceof Error) { return { line: 1, column: 1, code: "MALFORMED_EVENT", message: err.message }; } return { line: 1, column: 1, code: "MALFORMED_EVENT", message: String(err) }; } function unwrap(result) { if (result.ok) return result.document; const first = result.errors[0]; if (first) { throw new SubforgeError(first.code, first.message, { line: first.line, column: first.column }); } throw new Error("Parse failed"); } // src/core/document.ts var idCounter = 0; var EMPTY_SEGMENTS = []; function generateId() { return ++idCounter; } function reserveIds(count) { const start = idCounter + 1; idCounter += count; return start; } function createDocument(init) { return { info: { title: "", playResX: 1920, playResY: 1080, scaleBorderAndShadow: true, wrapStyle: 0, ...init?.info }, styles: init?.styles ?? new Map([["Default", createDefaultStyle()]]), events: init?.events ?? [], comments: init?.comments ?? [], fonts: init?.fonts, graphics: init?.graphics, regions: init?.regions }; } function createDefaultStyle() { return { name: "Default", fontName: "Arial", fontSize: 48, primaryColor: 16777215, secondaryColor: 255, outlineColor: 0, backColor: 0, bold: false, italic: false, underline: false, strikeout: false, scaleX: 100, scaleY: 100, spacing: 0, angle: 0, borderStyle: 1, outline: 2, shadow: 2, alignment: 2, marginL: 10, marginR: 10, marginV: 10, encoding: 1 }; } function createEvent(start, end, text, opts) { return { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: [], dirty: false, ...opts }; } function createKaraokeEvent(start, end, syllables, opts) { const segments = syllables.map((syl) => ({ text: syl.text, style: syl.style ? { ...syl.style } : null, effects: [{ type: "karaoke", params: { duration: syl.duration, mode: "fill" } }] })); return { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments, dirty: true, ...opts }; } function cloneDocument(doc) { return { info: { ...doc.info }, styles: new Map(doc.styles), events: doc.events.map((e) => ({ ...e, segments: [...e.segments], image: e.image ? { ...e.image, data: e.image.data.slice() } : undefined, vobsub: e.vobsub ? { ...e.vobsub } : undefined, pgs: e.pgs ? { ...e.pgs } : undefined })), comments: [...doc.comments], fonts: doc.fonts ? [...doc.fonts] : undefined, graphics: doc.graphics ? [...doc.graphics] : undefined, regions: doc.regions ? [...doc.regions] : undefined }; } function cloneEvent(event) { return { ...event, id: generateId(), segments: event.segments.map((s) => ({ ...s, effects: [...s.effects] })), image: event.image ? { ...event.image, data: event.image.data.slice() } : undefined, vobsub: event.vobsub ? { ...event.vobsub } : undefined, pgs: event.pgs ? { ...event.pgs } : undefined }; } // src/formats/text/ass/parser.ts class ASSLexer { 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 ASSParser { lexer; doc; errors = []; opts; eventIndex = 0; constructor(input, opts = {}) { this.lexer = new ASSLexer(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": 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 "outlinecolour": case "outlinecolor": case "tertiarycolour": 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 "underline": style.underline = val === "-1" || val === "1"; break; case "strikeout": case "strikethrough": style.strikeout = val === "-1" || val === "1"; break; case "scalex": style.scaleX = parseFloat(val) || 100; break; case "scaley": style.scaleY = parseFloat(val) || 100; break; case "spacing": style.spacing = parseFloat(val) || 0; break; case "angle": style.angle = parseFloat(val) || 0; 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 = 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 "encoding": style.encoding = parseInt(val) || 1; break; } } return style; } 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()); isStandardFormat = format.length === 10 && format[0] === "layer" && format[1] === "start" && format[9] === "text"; continue; } if ((firstChar === 68 || firstChar === 100) && this.startsWithCI(line, start, "dialogue:")) { const event = isStandardFormat ? this.parseDialogueFast(line, start + 9) : this.parseDialogueLine(line.substring(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; } } } } 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; } parseDialogueLine(data, format) { if (format.length === 10 && format[0] === "layer" && format[1] === "start" && format[9] === "text") { return this.parseDialogueFast(data); } const values = this.splitFields(data, format.length); 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 }; for (let i = 0;i < format.length && i < values.length; i++) { const key = format[i]; const val = values[i].trim(); switch (key) { case "layer": event.layer = parseInt(val) || 0; break; case "start": { const t = this.parseTimeInline(val); if (t < 0) this.addError("INVALID_TIMESTAMP", `Invalid start time: ${val}`); else event.start = t; } break; case "end": { const t = this.parseTimeInline(val); if (t < 0) this.addError("INVALID_TIMESTAMP", `Invalid end time: ${val}`); else event.end = t; } break; case "style": event.style = val; break; case "name": case "actor": event.actor = val; break; case "marginl": event.marginL = parseInt(val) || 0; break; case "marginr": event.marginR = parseInt(val) || 0; break; case "marginv": event.marginV = parseInt(val) || 0; break; case "effect": event.effect = val; break; case "text": event.text = val; break; } } return event; } parseDialogueFast(line, dataStart) { const c1 = line.indexOf(",", dataStart); 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); let layer = 0; for (let i = dataStart;i < c1; i++) { const c = line.charCodeAt(i); if (c >= 48 && c <= 57) layer = layer * 10 + (c - 48); } let o = c1 + 1; const colon1 = line.indexOf(":", o); if (colon1 === -1 || colon1 >= c2) { this.addError("INVALID_TIMESTAMP", `Invalid start time`); return null; } let h = 0; for (let i = o;i < colon1; i++) h = h * 10 + (line.charCodeAt(i) - 48); const m = (line.charCodeAt(colon1 + 1) - 48) * 10 + (line.charCodeAt(colon1 + 2) - 48); const ss = (line.charCodeAt(colon1 + 4) - 48) * 10 + (line.charCodeAt(colon1 + 5) - 48); const fracStart = colon1 + 7; const fracLen = c2 - fracStart; const startMs = fracLen === 2 ? ((line.charCodeAt(fracStart) - 48) * 10 + (line.charCodeAt(fracStart + 1) - 48)) * 10 : (line.charCodeAt(fracStart) - 48) * 100 + (line.charCodeAt(fracStart + 1) - 48) * 10 + (line.charCodeAt(fracStart + 2) - 48); const start = h * 3600000 + m * 60000 + ss * 1000 + startMs; o = c2 + 1; const colon2 = line.indexOf(":", o); if (colon2 === -1 || colon2 >= c3) { this.addError("INVALID_TIMESTAMP", `Invalid end time`); return null; } let h2 = 0; for (let i = o;i < colon2; i++) h2 = h2 * 10 + (line.charCodeAt(i) - 48); const m2 = (line.charCodeAt(colon2 + 1) - 48) * 10 + (line.charCodeAt(colon2 + 2) - 48); const ss2 = (line.charCodeAt(colon2 + 4) - 48) * 10 + (line.charCodeAt(colon2 + 5) - 48); const fracStart2 = colon2 + 7; const fracLen2 = c3 - fracStart2; const endMs = fracLen2 === 2 ? ((line.charCodeAt(fracStart2) - 48) * 10 + (line.charCodeAt(fracStart2 + 1) - 48)) * 10 : (line.charCodeAt(fracStart2) - 48) * 100 + (line.charCodeAt(fracStart2 + 1) - 48) * 10 + (line.charCodeAt(fracStart2 + 2) - 48); const end = h2 * 3600000 + m2 * 60000 + ss2 * 1000 + endMs; let marginL = 0, marginR = 0, marginV = 0; for (let i = c5 + 1;i < c6; i++) { const c = line.charCodeAt(i); if (c >= 48 && c <= 57) marginL = marginL * 10 + (c - 48); } for (let i = c6 + 1;i < c7; i++) { const c = line.charCodeAt(i); if (c >= 48 && c <= 57) marginR = marginR * 10 + (c - 48); } for (let i = c7 + 1;i < c8; i++) { const c = line.charCodeAt(i); if (c >= 48 && c <= 57) marginV = marginV * 10 + (c - 48); } return { id: generateId(), start, end, layer, style: line.substring(c3 + 1, c4), actor: line.substring(c4 + 1, c5), marginL, marginR, marginV, effect: line.substring(c8 + 1, c9), text: line.substring(c9 + 1), segments: EMPTY_SEGMENTS, dirty: false }; } 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; } } function parseASS(input, opts) { try { const fastDoc = createDocument(); if (parseASSSynthetic(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } const parser = new ASSParser(input, opts); return parser.parse(); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } function parseASSSynthetic(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: Layer, 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: 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: 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 = res