UNPKG

subforge

Version:

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

211 lines (185 loc) 5.94 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, 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: [] } } }