UNPKG

subforge

Version:

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

677 lines (595 loc) 21.8 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' // Time parsing is inlined below for performance import { parseColor } from './color.ts' import { parseTags } from './tags.ts' class ASSLexer { 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 // Check for \r before \n 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 ASSParser { private lexer: ASSLexer private doc: SubtitleDocument private errors: ParseError[] = [] private opts: ParseOptions private eventIndex = 0 constructor(input: string, opts: Partial<ParseOptions> = {}) { this.lexer = new ASSLexer(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': 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 'outlinecolour': case 'outlinecolor': case 'tertiarycolour': 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 '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) as Alignment; 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 } private parseEvents(): void { let format: string[] = [] // Check if standard format for fast path let isStandardFormat = false while (!this.lexer.isEOF()) { const line = this.lexer.readLine() // Skip empty lines 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) // Check for section start if (firstChar === 91) { // '[' this.lexer.unreadLine() break } // Skip comments if (firstChar === 59) continue // ';' // Check for Format: (70=F, 102=f) 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 } // Check for Dialogue: (68=D, 100=d) 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++ } } // Check for Comment: (67=C, 99=c) 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 } } } } 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 // lowercase const prefixChar = prefix.charCodeAt(i) | 32 if (lineChar !== prefixChar) return false } return true } private parseDialogueLine(data: string, format: string[]): SubtitleEvent | null { // Fast path: standard format with fixed positions // Standard: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text if (format.length === 10 && format[0] === 'layer' && format[1] === 'start' && format[9] === 'text') { return this.parseDialogueFast(data) } // Slow path: dynamic format (rare) const values = this.splitFields(data, format.length) 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 } 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 } // Fast path for standard ASS format - works directly on line to avoid substring private parseDialogueFast(line: string, dataStart: number): SubtitleEvent | null { // indexOf is SIMD optimized - faster than char-by-char scanning 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) // Inline parseInt for layer 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) } // Time parsing for start 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 // Time parsing for end 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 // Inline parseInt for margins 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 } } 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 }) } // Inline time parsing for performance // ASS format: H:MM:SS.cc or H:MM:SS.ccc (variable hours, 2-3 digit fraction) // Returns -1 on error private parseTimeInline(s: string): number { const len = s.length if (len < 10) return -1 // Find first colon (hours are variable length) const colon1 = s.indexOf(':') if (colon1 === -1) return -1 // Parse hours let h = 0 for (let i = 0; i < colon1; i++) { h = h * 10 + (s.charCodeAt(i) - 48) } // Fixed format after colon1: MM:SS.ff 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) // Fractional: 2 digits = centiseconds (x10), 3 digits = milliseconds 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 } } /** * Parses an ASS (Advanced SubStation Alpha) subtitle file into a SubtitleDocument. * * This function handles the complete ASS format including script info, styles, events, * and embedded fonts/graphics. It uses optimized parsing with SIMD-accelerated string * operations for maximum performance. * * @param input - The raw ASS file content as a string * @returns ParseResult containing the document and any errors/warnings * * @example * ```ts * const assContent = await Bun.file('subtitle.ass').text() * const result = parseASS(assContent) * console.log(result.document.events.length) // Number of dialogue lines * ``` */ export function parseASS(input: string, opts?: Partial<ParseOptions>): ParseResult { 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: 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: 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('\n', 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('\n', 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 = 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 }