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