subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
991 lines (866 loc) • 31.1 kB
text/typescript
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: []
}
}
}