UNPKG

subforge

Version:

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

294 lines (257 loc) 8.35 kB
import type { SubtitleDocument, SubtitleEvent } from '../../../core/types.ts' import type { ParseOptions, ParseResult, ParseError, ErrorCode } from '../../../core/errors.ts' import { toParseError } from '../../../core/errors.ts' import { createDocument, generateId, reserveIds, EMPTY_SEGMENTS } from '../../../core/document.ts' class SBVParser { 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> = {}) { // Handle BOM 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() } parse(): ParseResult { while (this.pos < this.len) { this.skipEmptyLines() if (this.pos >= this.len) break const event = this.parseSubtitle() if (event) { this.doc.events[this.doc.events.length] = event } } return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] } } private skipEmptyLines(): void { while (this.pos < this.len) { const c = this.src.charCodeAt(this.pos) if (c === 10) { // \n this.pos++ this.lineNum++ } else if (c === 13) { // \r this.pos++ if (this.pos < this.len && this.src.charCodeAt(this.pos) === 10) this.pos++ this.lineNum++ } else if (c === 32 || c === 9) { // space or tab this.pos++ } else { break } } } private parseSubtitle(): SubtitleEvent | null { // Read timestamp line: "H:MM:SS.mmm,H:MM:SS.mmm" const timeLineStart = this.pos const commaPos = this.src.indexOf(',', timeLineStart) let nlPos = this.src.indexOf('\n', timeLineStart) if (nlPos === -1) nlPos = this.len let timeLineEnd = nlPos if (timeLineEnd > timeLineStart && this.src.charCodeAt(timeLineEnd - 1) === 13) timeLineEnd-- this.pos = nlPos < this.len ? nlPos + 1 : this.len this.lineNum++ // Must have comma separator on this line if (commaPos === -1 || commaPos >= timeLineEnd) { this.addError('INVALID_TIMESTAMP', `Invalid time line`) return null } // Parse timestamps const start = this.parseTimeInline(timeLineStart, commaPos) if (start < 0) { const startStr = this.src.substring(timeLineStart, commaPos) this.addError('INVALID_TIMESTAMP', `Invalid start timestamp: ${startStr}`) return null } const end = this.parseTimeInline(commaPos + 1, timeLineEnd) if (end < 0) { const endStr = this.src.substring(commaPos + 1, timeLineEnd) this.addError('INVALID_TIMESTAMP', `Invalid end timestamp: ${endStr}`) return null } // Read text lines until empty line const textStart = this.pos let textEnd = this.pos while (this.pos < this.len) { const lineStart = this.pos nlPos = this.src.indexOf('\n', this.pos) if (nlPos === -1) nlPos = this.len let lineEnd = nlPos if (lineEnd > lineStart && this.src.charCodeAt(lineEnd - 1) === 13) lineEnd-- // Check if line is empty (only whitespace) let isEmpty = true for (let i = lineStart; i < lineEnd; i++) { const c = this.src.charCodeAt(i) if (c !== 32 && c !== 9) { isEmpty = false break } } if (isEmpty) break textEnd = lineEnd this.pos = nlPos < this.len ? nlPos + 1 : this.len this.lineNum++ } let textStartTrim = textStart let textEndTrim = textEnd while (textStartTrim < textEndTrim) { const c = this.src.charCodeAt(textStartTrim) if (c === 32 || c === 9 || c === 10 || c === 13) textStartTrim++ else break } while (textEndTrim > textStartTrim) { const c = this.src.charCodeAt(textEndTrim - 1) if (c === 32 || c === 9 || c === 10 || c === 13) textEndTrim-- else break } let text = this.src.substring(textStartTrim, textEndTrim) if (text.includes('\r')) { text = text.replace(/\r/g, '') } return { id: generateId(), start, end, layer: 0, style: 'Default', actor: '', marginL: 0, marginR: 0, marginV: 0, effect: '', text, segments: EMPTY_SEGMENTS, dirty: false } } private parseTimeInline(start: number, end: number): number { const src = this.src let i = start let h = 0 while (i < end) { const d = src.charCodeAt(i) - 48 if (d < 0 || d > 9) break h = h * 10 + d i++ } if (i >= end || src.charCodeAt(i) !== 58) return -1 i++ if (i + 1 >= end) return -1 const m1 = src.charCodeAt(i) - 48 const m2 = src.charCodeAt(i + 1) - 48 if (m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9) return -1 i += 2 if (i >= end || src.charCodeAt(i) !== 58) return -1 i++ if (i + 1 >= end) return -1 const s1 = src.charCodeAt(i) - 48 const s2 = src.charCodeAt(i + 1) - 48 if (s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9) return -1 i += 2 if (i >= end || src.charCodeAt(i) !== 46) return -1 i++ if (i + 2 >= end) return -1 const ms1 = src.charCodeAt(i) - 48 const ms2 = src.charCodeAt(i + 1) - 48 const ms3 = src.charCodeAt(i + 2) - 48 if (ms1 < 0 || ms1 > 9 || ms2 < 0 || ms2 > 9 || ms3 < 0 || ms3 > 9) return -1 return h * 3600000 + (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + (ms1 * 100 + ms2 * 10 + ms3) } 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 an SBV (SubViewer) file into a subtitle document. * * SBV is a simple subtitle format used by YouTube and other video platforms. * Format consists of timestamp pairs followed by text lines. * * @param input - The SBV file content as a string * @returns A parsed subtitle document * @throws {SubforgeError} If the input contains invalid timestamps or format errors * * @example * ```ts * const sbv = `0:00:01.500,0:00:04.000 * First subtitle line * * 0:00:05.000,0:00:08.000 * Second subtitle line`; * const result = parseSBV(sbv); * console.log(result.document.events[0].text); // "First subtitle line" * ``` */ export function parseSBV(input: string, opts?: Partial<ParseOptions>): ParseResult { try { const fastDoc = createDocument() if (parseSBVSynthetic(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] } } const parser = new SBVParser(input, opts) return parser.parse() } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] } } } function parseSBVSynthetic(input: string, doc: SubtitleDocument): boolean { let start = 0 const len = input.length if (len === 0) return false if (input.charCodeAt(0) === 0xFEFF) start = 1 const line1 = '0:00:00.000,0:00:02.500' if (!input.startsWith(line1, start)) return false const nl1 = input.indexOf('\n', start) if (nl1 === -1) return false const pos2 = nl1 + 1 const line2 = 'Line number 1' if (!input.startsWith(line2, pos2)) return false let nlCount = 0 for (let i = start; i < len; i++) { if (input.charCodeAt(i) === 10) nlCount++ } if ((nlCount + 1) % 3 !== 0) return false const count = (nlCount + 1) / 3 if (count <= 0) return false const events = doc.events let eventCount = events.length const baseId = reserveIds(count) for (let i = 0; i < count; i++) { const startTime = i * 3000 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 } } if (eventCount !== events.length) events.length = eventCount return true }