UNPKG

subforge

Version:

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

991 lines (866 loc) 31.1 kB
import type { SubtitleDocument, SubtitleEvent, Style, ScriptInfo, Alignment, Comment, EmbeddedData } from '../../../core/types.ts' import type { ParseOptions, ParseResult, ParseError, ErrorCode } from '../../../core/errors.ts' import { toParseError } from '../../../core/errors.ts' import { createDocument, createDefaultStyle, generateId, reserveIds, EMPTY_SEGMENTS } from '../../../core/document.ts' import { parseColor } from '../ass/color.ts' import { parseTags } from '../ass/tags.ts' class SSALexer { private src: string private pos = 0 private len: number private line = 1 private lastLineStart = 0 private lastLine = 0 constructor(src: string) { this.src = src this.len = src.length } getPosition(): { line: number; column: number } { return { line: this.line, column: 1 } } isEOF(): boolean { return this.pos >= this.len } skipLine(): void { const nlPos = this.src.indexOf('\n', this.pos) if (nlPos === -1) { this.pos = this.len } else { this.pos = nlPos + 1 this.line++ } } readLine(): string { this.lastLineStart = this.pos this.lastLine = this.line const start = this.pos let nlPos = this.src.indexOf('\n', 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(): void { this.pos = this.lastLineStart this.line = this.lastLine } peekLine(): string { const savedPos = this.pos const savedLine = this.line const line = this.readLine() this.pos = savedPos this.line = savedLine return line } } class SSAParser { private lexer: SSALexer private doc: SubtitleDocument private errors: ParseError[] = [] private opts: ParseOptions private eventIndex = 0 private eventFieldIndexes: EventFieldIndexes | null = null constructor(input: string, opts: Partial<ParseOptions> = {}) { this.lexer = new SSALexer(input) this.opts = { onError: opts.onError ?? 'collect', strict: opts.strict ?? false, preserveOrder: opts.preserveOrder ?? true } this.doc = createDocument() } parse(): ParseResult { 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: [] } } private parseSection(): void { 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() } } private skipSection(): void { while (!this.lexer.isEOF()) { const line = this.lexer.peekLine().trim() if (line.startsWith('[')) break this.lexer.skipLine() } } private parseScriptInfo(): void { 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) as 0 | 1 | 2 | 3 break } } } private parseStyles(): void { let format: string[] = [] 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) } } } } private parseStyleLine(data: string, format: string[]): Style | null { 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 { /* ignore */ } break case 'secondarycolour': case 'secondarycolor': try { style.secondaryColor = parseColor(val) } catch { /* ignore */ } break case 'tertiarycolour': case 'outlinecolour': case 'outlinecolor': try { style.outlineColor = parseColor(val) } catch { /* ignore */ } break case 'backcolour': case 'backcolor': try { style.backColor = parseColor(val) } catch { /* ignore */ } 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': // SSA v4 uses old alignment (1=left, 2=center, 3=right, 9=top-left, 10=top-center, 11=top-right) // Convert to ASS numpad 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': // SSA v4 has AlphaLevel field - ignore for now as ASS uses per-color alpha break case 'encoding': style.encoding = parseInt(val) || 1; break } } return style } private convertSSAAlignment(ssaAlign: number): Alignment { // SSA v4 alignment: 1=left, 2=center, 3=right, 9=top-left, 10=top-center, 11=top-right // ASS numpad: 1=bottom-left, 2=bottom-center, 3=bottom-right, 4=mid-left, 5=mid-center, 6=mid-right, 7=top-left, 8=top-center, 9=top-right switch (ssaAlign) { case 1: return 1 // left -> bottom-left case 2: return 2 // center -> bottom-center case 3: return 3 // right -> bottom-right case 9: return 7 // top-left case 10: return 8 // top-center case 11: return 9 // top-right default: return 2 } } private parseEvents(): void { let format: string[] = [] 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:')) { // SSA v4 has Marked field - treat as dialogue 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++ } } } } private startsWithCI(line: string, offset: number, prefix: string): boolean { 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 } private parseDialogueLineFast(line: string, offset: number, format: string[]): SubtitleEvent | null { const formatIndexes = this.eventFieldIndexes if (!formatIndexes || formatIndexes.count === 0) { return this.parseDialogueLine(line.substring(offset), format) } const event: SubtitleEvent = { 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: number 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 } private parseDialogueStandard(line: string, dataStart: number): SubtitleEvent | null { 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: SubtitleEvent = { 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 } // Style 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) // Actor/Name 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) // Effect 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) // Text 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 } private parseTimeRangeFast(s: string, start: number, end: number): number { 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 } private parseIntRangeFast(s: string, start: number, end: number): number { 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 } private buildEventFieldIndexes(format: string[]): EventFieldIndexes { const indexes: EventFieldIndexes = { 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 } private parseCommentLine(data: string, format: string[]): Comment | null { 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() } } private parseFonts(): void { if (!this.doc.fonts) this.doc.fonts = [] let currentFont: EmbeddedData | null = 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) } private parseGraphics(): void { if (!this.doc.graphics) this.doc.graphics = [] let currentGraphic: EmbeddedData | null = 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) } private splitFields(data: string, expectedCount: number): string[] { const result: string[] = 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 } private addError(code: ErrorCode, message: string, raw?: string): void { const pos = this.lexer.getPosition() if (this.opts.onError === 'skip') return this.errors.push({ ...pos, code, message, raw }) } private parseTimeInline(s: string): number { 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: number 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 } private parseTimeRange(s: string, start: number, end: number): number { 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 } private parseIntRange(s: string, start: number, end: number): number { 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 } } interface EventFieldIndexes { count: number layer: number start: number end: number style: number actor: number marginL: number marginR: number marginV: number effect: number text: number } function trimRange(s: string, start: number, end: number): { start: number; end: number } { while (start < end && s.charCodeAt(start) <= 32) start++ while (end > start && s.charCodeAt(end - 1) <= 32) end-- return { start, end } } function parseSSATimeFast(s: string, start: number, end: number): number { 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: string, doc: SubtitleDocument): boolean { 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('\n', 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: string, doc: SubtitleDocument): boolean { let start = 0 const len = input.length if (len === 0) return false if (input.charCodeAt(0) === 0xFEFF) start = 1 if (!input.startsWith('[Script Info]', start)) return false const eventsIdx = input.indexOf('\n[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('\n', 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('\n', 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 } /** * Parses an SSA (SubStation Alpha) v4 subtitle file into a SubtitleDocument. * * This function handles the SSA v4 format, which is the predecessor to ASS. * SSA files use different style definitions and alignment values compared to ASS, * but are automatically converted to the unified SubtitleDocument format. * * @param input - The raw SSA file content as a string * @returns ParseResult containing the document and any errors/warnings * * @example * ```ts * const ssaContent = await Bun.file('subtitle.ssa').text() * const result = parseSSA(ssaContent) * console.log(result.document.events.length) // Number of dialogue lines * ``` */ export function parseSSA(input: string, opts?: Partial<ParseOptions>): ParseResult { 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: [] } } }