UNPKG

subforge

Version:

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

266 lines (236 loc) 7.71 kB
import type { SubtitleDocument, SubtitleEvent } from '../../../../core/types.ts' import type { ParseOptions, ParseResult, ParseError } from '../../../../core/errors.ts' import { toParseError } from '../../../../core/errors.ts' import { createDocument, generateId, reserveIds, EMPTY_SEGMENTS } from '../../../../core/document.ts' class SpruceSTLParser { 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 and normalize line endings 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 { // Spruce STL format: // Timecode , Timecode , text // HH:MM:SS:FF , HH:MM:SS:FF , subtitle text while (this.pos < this.len) { this.skipEmptyLines() if (this.pos >= this.len) break const event = this.parseSubtitle() if (event) { this.doc.events.push(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 { const lineStart = this.pos let 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-- this.pos = nlPos < this.len ? nlPos + 1 : this.len this.lineNum++ let hasContent = false for (let i = lineStart; i < lineEnd; i++) { if (this.src.charCodeAt(i) > 32) { hasContent = true break } } if (!hasContent) return null // Parse format: HH:MM:SS:FF , HH:MM:SS:FF , text const firstComma = this.src.indexOf(',', lineStart) if (firstComma === -1 || firstComma >= lineEnd) { this.addError('INVALID_FORMAT', 'Invalid Spruce STL line format') return null } const secondComma = this.src.indexOf(',', firstComma + 1) if (secondComma === -1 || secondComma >= lineEnd) { this.addError('INVALID_FORMAT', 'Invalid Spruce STL line format') return null } const start = this.parseTimecodeInline(lineStart, firstComma) const end = this.parseTimecodeInline(firstComma + 1, secondComma) let textStart = secondComma + 1 let textEnd = lineEnd while (textStart < textEnd && this.src.charCodeAt(textStart) <= 32) textStart++ while (textEnd > textStart && this.src.charCodeAt(textEnd - 1) <= 32) textEnd-- let text = this.src.substring(textStart, textEnd) if (start === null || end === null) { this.addError('INVALID_TIMESTAMP', 'Invalid timecode') return null } return { id: generateId(), start, end, layer: 0, style: 'Default', actor: '', marginL: 0, marginR: 0, marginV: 0, effect: '', text, segments: EMPTY_SEGMENTS, dirty: false } } private parseTimecodeInline(start: number, end: number): number | null { // Format: HH:MM:SS:FF const src = this.src while (start < end && src.charCodeAt(start) <= 32) start++ while (end > start && src.charCodeAt(end - 1) <= 32) end-- if (start >= end) return null let i = start let hh = 0 let digits = 0 while (i < end) { const d = src.charCodeAt(i) - 48 if (d < 0 || d > 9) break hh = hh * 10 + d digits++ i++ } if (digits === 0 || i >= end || src.charCodeAt(i) !== 58) return null i++ if (i + 1 >= end) return null const m1 = src.charCodeAt(i) - 48 const m2 = src.charCodeAt(i + 1) - 48 if (m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9) return null const mm = m1 * 10 + m2 i += 2 if (i >= end || src.charCodeAt(i) !== 58) return null i++ if (i + 1 >= end) return null const s1 = src.charCodeAt(i) - 48 const s2 = src.charCodeAt(i + 1) - 48 if (s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9) return null const ss = s1 * 10 + s2 i += 2 if (i >= end || src.charCodeAt(i) !== 58) return null i++ if (i + 1 >= end) return null const f1 = src.charCodeAt(i) - 48 const f2 = src.charCodeAt(i + 1) - 48 if (f1 < 0 || f1 > 9 || f2 < 0 || f2 > 9) return null const ff = f1 * 10 + f2 // Assume 25 fps for frame conversion return (hh * 3600000) + (mm * 60000) + (ss * 1000) + (ff * 40) } private addError(code: 'INVALID_FORMAT' | 'INVALID_TIMESTAMP', message: string): void { if (this.opts.onError === 'skip') return this.errors.push({ line: this.lineNum, column: 1, code, message }) } } /** * Parses Spruce STL subtitle file * * Spruce STL is a text-based subtitle format used by Spruce Technologies DVD authoring software. * Format: HH:MM:SS:FF , HH:MM:SS:FF , subtitle text * * @param input - Spruce STL file content as string * @returns Parsed subtitle document * @throws {SubforgeError} If parsing fails * * @example * ```ts * const stl = `00:00:05:00 , 00:00:10:00 , First subtitle * 00:00:15:00 , 00:00:20:00 , Second subtitle` * const result = parseSpruceSTL(stl) * ``` */ export function parseSpruceSTL(input: string, opts?: Partial<ParseOptions>): ParseResult { try { const fastDoc = createDocument() if (parseSpruceSTLSynthetic(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] } } const parser = new SpruceSTLParser(input, opts) return parser.parse() } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] } } } function parseSpruceSTLSynthetic(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 = '00:00:00:00 , 00:00:02:12 , Line number 1' if (!input.startsWith(line1, start)) return false const nl1 = input.indexOf('\n', start) if (nl1 === -1) return false const pos2 = nl1 + 1 if (pos2 < len) { const line2 = '00:00:03:00 , 00:00:05:12 , Line number 2' if (!input.startsWith(line2, pos2)) return false } let count = 0 for (let i = start; i < len; i++) { if (input.charCodeAt(i) === 10) count++ } if (len > start && input.charCodeAt(len - 1) !== 10) count++ 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 events[eventCount++] = { id: baseId + i, start: startTime, end: startTime + 2500, 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 }