subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
229 lines (200 loc) • 7.06 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, EMPTY_SEGMENTS } from '../../../core/document.ts'
// Time parsing is inlined in parseSubtitle for performance
class SRTParser {
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 in one pass
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 isDigit(c: number): boolean {
return c >= 48 && c <= 57
}
private parseSubtitle(): SubtitleEvent | null {
// Read index line using indexOf
const indexLineStart = this.pos
let nlPos = this.src.indexOf('\n', this.pos)
if (nlPos === -1) nlPos = this.len
let indexLineEnd = nlPos
if (indexLineEnd > indexLineStart && this.src.charCodeAt(indexLineEnd - 1) === 13) indexLineEnd--
// Skip whitespace at start to find first non-whitespace
let firstNonWs = indexLineStart
while (firstNonWs < indexLineEnd && (this.src.charCodeAt(firstNonWs) === 32 || this.src.charCodeAt(firstNonWs) === 9)) {
firstNonWs++
}
this.pos = nlPos < this.len ? nlPos + 1 : this.len
this.lineNum++
// Check if line is empty or doesn't start with digit
if (firstNonWs >= indexLineEnd || !this.isDigit(this.src.charCodeAt(firstNonWs))) {
return null
}
if (this.pos >= this.len) return null
// Read time line using direct indexOf for arrow (avoids substring)
const timeLineStart = this.pos
const arrowPos = this.src.indexOf(' --> ', timeLineStart)
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++
// Arrow must be on this line
if (arrowPos === -1 || arrowPos >= timeLineEnd) {
this.addError('INVALID_TIMESTAMP', `Invalid time line`)
return null
}
// Inline time parsing for speed (SRT format: HH:MM:SS,mmm - exactly 12 chars)
// Start time
const startLen = arrowPos - timeLineStart
if (startLen !== 12) {
this.addError('INVALID_TIMESTAMP', `Invalid timestamp`)
return null
}
const s = this.src
let o = timeLineStart
const 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)
// End time
const endLen = timeLineEnd - (arrowPos + 5)
if (endLen !== 12) {
this.addError('INVALID_TIMESTAMP', `Invalid timestamp`)
return null
}
o = arrowPos + 5
const 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)
// Read text lines until empty line using indexOf
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) - inline check avoids substring
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 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 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 SRT subtitle file into a ParseResult.
*
* SRT (SubRip Text) is a simple subtitle format with sequential numbering,
* timestamps, and plain text with basic formatting tags (<b>, <i>, <u>, <s>, <font>).
*
* @param input - The SRT file content as a string
* @returns ParseResult containing the document and any errors/warnings
*
* @example
* ```ts
* const srt = `1
* 00:00:01,000 --> 00:00:03,000
* Hello, world!
*
* 2
* 00:00:04,000 --> 00:00:06,000
* <b>Bold text</b>`;
*
* const result = parseSRT(srt);
* console.log(result.document.events.length); // 2
* ```
*/
export function parseSRT(input: string, opts?: Partial<ParseOptions>): ParseResult {
try {
const parser = new SRTParser(input, opts)
return parser.parse()
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
}
}
}