subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
211 lines (185 loc) • 5.94 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'
class MicroDVDParser {
private src: string
private pos = 0
private len: number
private doc: SubtitleDocument
private errors: ParseError[] = []
private opts: ParseOptions
private lineNum = 1
private fps: number
constructor(input: string, fps: number, 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.fps = fps
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 {
// MicroDVD format: {start}{end}text
// Find first {
if (this.src.charCodeAt(this.pos) !== 123) { // {
// Skip to next line
const nlPos = this.src.indexOf('\n', this.pos)
const endPos = nlPos === -1 ? this.len : nlPos
let hasContent = false
for (let i = this.pos; i < endPos; i++) {
const code = this.src.charCodeAt(i)
if (code !== 32 && code !== 9 && code !== 13) {
hasContent = true
break
}
}
if (hasContent) {
this.addError('INVALID_FORMAT', 'Expected {start}{end} frame markers')
}
if (nlPos === -1) {
this.pos = this.len
} else {
this.pos = nlPos + 1
this.lineNum++
}
return null
}
// Parse start frame
this.pos++ // skip {
let numStart = this.pos
while (this.pos < this.len && this.isDigit(this.src.charCodeAt(this.pos))) {
this.pos++
}
if (this.pos >= this.len || this.src.charCodeAt(this.pos) !== 125) { // }
this.addError('INVALID_FORMAT', 'Invalid start frame')
return null
}
const startFrame = parseInt(this.src.slice(numStart, this.pos), 10)
this.pos++ // skip }
// Parse end frame
if (this.pos >= this.len || this.src.charCodeAt(this.pos) !== 123) { // {
this.addError('INVALID_FORMAT', 'Missing end frame')
return null
}
this.pos++ // skip {
numStart = this.pos
while (this.pos < this.len && this.isDigit(this.src.charCodeAt(this.pos))) {
this.pos++
}
if (this.pos >= this.len || this.src.charCodeAt(this.pos) !== 125) { // }
this.addError('INVALID_FORMAT', 'Invalid end frame')
return null
}
const endFrame = parseInt(this.src.slice(numStart, this.pos), 10)
this.pos++ // skip }
// Parse text until end of line
const textStart = this.pos
let nlPos = this.src.indexOf('\n', this.pos)
if (nlPos === -1) nlPos = this.len
let textEnd = nlPos
if (textEnd > textStart && this.src.charCodeAt(textEnd - 1) === 13) textEnd--
let text = this.src.slice(textStart, textEnd).trim()
// Convert pipe | to line breaks
if (text.includes('|')) {
text = text.replace(/\|/g, '\n')
}
this.pos = nlPos < this.len ? nlPos + 1 : this.len
this.lineNum++
// Convert frames to milliseconds
const start = Math.round((startFrame / this.fps) * 1000)
const end = Math.round((endFrame / this.fps) * 1000)
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 })
}
}
export interface MicroDVDParseOptions extends ParseOptions {
/** Frame rate (frames per second) for converting frame numbers to time */
fps: number
}
/**
* Parses a MicroDVD subtitle file into a ParseResult.
*
* MicroDVD is a frame-based subtitle format that uses curly braces for timing.
* Format: {startFrame}{endFrame}Text
*
* @param input - The MicroDVD file content as a string
* @param opts - Parse options including fps
* @returns ParseResult containing the document and any errors/warnings
*
* @example
* ```ts
* const mdvd = `{0}{100}First subtitle
* {100}{200}Second subtitle`;
* const result = parseMicroDVD(mdvd, { fps: 23.976 });
* console.log(result.document.events[0].text); // "First subtitle"
* ```
*/
export function parseMicroDVD(input: string, opts: MicroDVDParseOptions): ParseResult {
try {
const parser = new MicroDVDParser(input, opts.fps, opts)
return parser.parse()
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
}
}
}