UNPKG

subforge

Version:

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

344 lines (299 loc) 10.7 kB
import type { SubtitleDocument, SubtitleEvent, VTTRegion } from '../../../core/types.ts' import type { ParseOptions, ParseResult, ParseError, ErrorCode } from '../../../core/errors.ts' import { toParseError } from '../../../core/errors.ts' import { createDocument, generateId, EMPTY_SEGMENTS } from '../../../core/document.ts' // Time parsing is inlined in parseCue for performance class VTTParser { private src: string private pos = 0 private len: number private doc: SubtitleDocument private errors: ParseError[] = [] private opts: ParseOptions private lineNum = 1 constructor(input: string, opts: Partial<ParseOptions> = {}) { let start = 0 if (input.charCodeAt(0) === 0xFEFF) start = 1 this.src = input this.pos = start this.len = input.length this.opts = { onError: opts.onError ?? 'collect', strict: opts.strict ?? false, preserveOrder: opts.preserveOrder ?? true } this.doc = createDocument() this.doc.regions = [] } parse(): ParseResult { this.parseHeader() while (this.pos < this.len) { this.skipEmptyLines() if (this.pos >= this.len) break // Check first 4 chars to identify block type // NOTE, STYL, REGI are the prefixes we care about const c0 = this.src.charCodeAt(this.pos) const c1 = this.pos + 1 < this.len ? this.src.charCodeAt(this.pos + 1) : 0 const c2 = this.pos + 2 < this.len ? this.src.charCodeAt(this.pos + 2) : 0 const c3 = this.pos + 3 < this.len ? this.src.charCodeAt(this.pos + 3) : 0 // NOTE (78=N, 79=O, 84=T, 69=E) if (c0 === 78 && c1 === 79 && c2 === 84 && c3 === 69) { this.parseNote() // STYLE (83=S, 84=T, 89=Y, 76=L) } else if (c0 === 83 && c1 === 84 && c2 === 89 && c3 === 76) { this.parseStyle() // REGION (82=R, 69=E, 71=G, 73=I) } else if (c0 === 82 && c1 === 69 && c2 === 71 && c3 === 73) { this.parseRegion() } else { const event = this.parseCue() if (event) { this.doc.events[this.doc.events.length] = event } } } return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] } } private readLine(): string { 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.lineNum++ return this.src.substring(start, end) } private skipEmptyLines(): void { while (this.pos < this.len) { const c = this.src.charCodeAt(this.pos) if (c === 10) { this.pos++ this.lineNum++ } else if (c === 13) { this.pos++ if (this.pos < this.len && this.src.charCodeAt(this.pos) === 10) this.pos++ this.lineNum++ } else if (c === 32 || c === 9) { this.pos++ } else { break } } } private parseHeader(): void { if (this.pos >= this.len) return const line = this.readLine().trim() if (!line.startsWith('WEBVTT')) { this.addError('INVALID_SECTION', 'File must start with WEBVTT') } } private parseNote(): void { while (this.pos < this.len) { const line = this.readLine() if (line.trim() === '') break } } private parseStyle(): void { this.readLine() // consume STYLE line while (this.pos < this.len) { const line = this.readLine() if (line.trim() === '') break } } private parseRegion(): void { this.readLine() // consume REGION line const region: VTTRegion = { id: '', width: '100%', lines: 3, regionAnchor: '0%,100%', viewportAnchor: '0%,100%', scroll: 'none' } while (this.pos < this.len) { const line = this.readLine().trim() if (line === '') break const colonIdx = line.indexOf(':') if (colonIdx !== -1) { const key = line.substring(0, colonIdx).trim() const value = line.substring(colonIdx + 1).trim() switch (key) { case 'id': region.id = value; break case 'width': region.width = value; break case 'lines': region.lines = parseInt(value) || 3; break case 'regionanchor': region.regionAnchor = value; break case 'viewportanchor': region.viewportAnchor = value; break case 'scroll': region.scroll = value === 'up' ? 'up' : 'none'; break } } } this.doc.regions![this.doc.regions!.length] = region } private parseCue(): SubtitleEvent | null { // Read first line and check for --> directly using indexOf (avoids substring) let lineStart = this.pos let arrowPos = this.src.indexOf(' --> ', lineStart) let nlPos = this.src.indexOf('\n', lineStart) if (nlPos === -1) nlPos = this.len let lineEnd = nlPos if (lineEnd > lineStart && this.src.charCodeAt(lineEnd - 1) === 13) lineEnd-- // Consume line this.pos = nlPos < this.len ? nlPos + 1 : this.len this.lineNum++ // If no arrow on first line, this is a cue identifier - read next line if (arrowPos === -1 || arrowPos >= lineEnd) { if (this.pos >= this.len) return null lineStart = this.pos arrowPos = this.src.indexOf(' --> ', lineStart) nlPos = this.src.indexOf('\n', lineStart) if (nlPos === -1) nlPos = this.len lineEnd = nlPos if (lineEnd > lineStart && this.src.charCodeAt(lineEnd - 1) === 13) lineEnd-- this.pos = nlPos < this.len ? nlPos + 1 : this.len this.lineNum++ } if (arrowPos === -1 || arrowPos >= lineEnd) { this.addError('INVALID_TIMESTAMP', `Invalid time line`) this.skipToNextCue() return null } // Parse timestamps inline for speed (avoids function call overhead) const afterArrowStart = arrowPos + 5 const spacePos = this.src.indexOf(' ', afterArrowStart) const endPos = (spacePos === -1 || spacePos > lineEnd) ? lineEnd : spacePos // Inline start time parsing (HH:MM:SS.mmm or MM:SS.mmm) const startLen = arrowPos - lineStart let start: number if (startLen === 12) { const s = this.src const o = lineStart start = ((s.charCodeAt(o) - 48) * 10 + (s.charCodeAt(o + 1) - 48)) * 3600000 + ((s.charCodeAt(o + 3) - 48) * 10 + (s.charCodeAt(o + 4) - 48)) * 60000 + ((s.charCodeAt(o + 6) - 48) * 10 + (s.charCodeAt(o + 7) - 48)) * 1000 + (s.charCodeAt(o + 9) - 48) * 100 + (s.charCodeAt(o + 10) - 48) * 10 + (s.charCodeAt(o + 11) - 48) } else if (startLen === 9) { const s = this.src const o = lineStart start = ((s.charCodeAt(o) - 48) * 10 + (s.charCodeAt(o + 1) - 48)) * 60000 + ((s.charCodeAt(o + 3) - 48) * 10 + (s.charCodeAt(o + 4) - 48)) * 1000 + (s.charCodeAt(o + 6) - 48) * 100 + (s.charCodeAt(o + 7) - 48) * 10 + (s.charCodeAt(o + 8) - 48) } else { this.addError('INVALID_TIMESTAMP', `Invalid timestamp`) this.skipToNextCue() return null } // Inline end time parsing const endLen = endPos - afterArrowStart let end: number if (endLen === 12) { const s = this.src const o = afterArrowStart end = ((s.charCodeAt(o) - 48) * 10 + (s.charCodeAt(o + 1) - 48)) * 3600000 + ((s.charCodeAt(o + 3) - 48) * 10 + (s.charCodeAt(o + 4) - 48)) * 60000 + ((s.charCodeAt(o + 6) - 48) * 10 + (s.charCodeAt(o + 7) - 48)) * 1000 + (s.charCodeAt(o + 9) - 48) * 100 + (s.charCodeAt(o + 10) - 48) * 10 + (s.charCodeAt(o + 11) - 48) } else if (endLen === 9) { const s = this.src const o = afterArrowStart end = ((s.charCodeAt(o) - 48) * 10 + (s.charCodeAt(o + 1) - 48)) * 60000 + ((s.charCodeAt(o + 3) - 48) * 10 + (s.charCodeAt(o + 4) - 48)) * 1000 + (s.charCodeAt(o + 6) - 48) * 100 + (s.charCodeAt(o + 7) - 48) * 10 + (s.charCodeAt(o + 8) - 48) } else { this.addError('INVALID_TIMESTAMP', `Invalid timestamp`) this.skipToNextCue() return null } // Read text lines using indexOf for speed const textStart = this.pos let textEnd = this.pos while (this.pos < this.len) { const ls = this.pos let nl = this.src.indexOf('\n', this.pos) if (nl === -1) nl = this.len let le = nl if (le > ls && this.src.charCodeAt(le - 1) === 13) le-- // Check if line is empty (only whitespace) - inline check let isEmpty = true for (let i = ls; i < le; i++) { const c = this.src.charCodeAt(i) if (c !== 32 && c !== 9) { isEmpty = false break } } if (isEmpty) break textEnd = le this.pos = nl < this.len ? nl + 1 : this.len this.lineNum++ } let text = this.src.substring(textStart, textEnd) if (text.includes('\r')) { text = text.replace(/\r/g, '') } text = text.trim() return { id: generateId(), start, end, layer: 0, style: 'Default', actor: '', marginL: 0, marginR: 0, marginV: 0, effect: '', text, segments: EMPTY_SEGMENTS, dirty: false } } private skipToNextCue(): void { while (this.pos < this.len) { const line = this.readLine() if (line.trim() === '') break } } private addError(code: ErrorCode, message: string, raw?: string): void { if (this.opts.onError === 'skip') return this.errors.push({ line: this.lineNum, column: 1, code, message, raw }) } } /** * Parses a WebVTT subtitle file into a ParseResult. * * WebVTT (Web Video Text Tracks) is a modern subtitle format designed for HTML5. * Supports cues with timestamps, REGION blocks for positioning, STYLE blocks * for CSS styling, and NOTE blocks for comments. Timestamps use dot (.) separator * for milliseconds and support both HH:MM:SS.mmm and MM:SS.mmm formats. * * @param input - The WebVTT file content as a string * @returns ParseResult containing the document and any errors/warnings * * @example * ```ts * const vtt = `WEBVTT * * 00:00:01.000 --> 00:00:03.000 * Hello, world! * * 00:00:04.000 --> 00:00:06.000 * <b>Bold text</b>`; * * const result = parseVTT(vtt); * console.log(result.document.events.length); // 2 * ``` */ export function parseVTT(input: string, opts?: Partial<ParseOptions>): ParseResult { try { const parser = new VTTParser(input, opts) return parser.parse() } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] } } }