UNPKG

subforge

Version:

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

487 lines (483 loc) 12.6 kB
// src/formats/text/sbv/time.ts function parseTime(s) { const firstColon = s.indexOf(":"); if (firstColon === -1) throw new Error(`Invalid SBV timestamp: ${s}`); const secondColon = s.indexOf(":", firstColon + 1); if (secondColon === -1) throw new Error(`Invalid SBV timestamp: ${s}`); const dot = s.indexOf(".", secondColon + 1); if (dot === -1) throw new Error(`Invalid SBV timestamp: ${s}`); const msStr = s.substring(dot + 1); if (msStr.length !== 3) throw new Error(`Invalid SBV timestamp: ${s}`); const h = parseInt(s.substring(0, firstColon), 10); const m = parseInt(s.substring(firstColon + 1, secondColon), 10); const ss = parseInt(s.substring(secondColon + 1, dot), 10); const ms = parseInt(msStr, 10); if (isNaN(h) || isNaN(m) || isNaN(ss) || isNaN(ms)) { throw new Error(`Invalid SBV timestamp: ${s}`); } return h * 3600000 + m * 60000 + ss * 1000 + ms; } function formatTime(ms) { const h = Math.floor(ms / 3600000); const m = Math.floor(ms % 3600000 / 60000); const s = Math.floor(ms % 60000 / 1000); const millis = ms % 1000; return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${millis.toString().padStart(3, "0")}`; } // src/core/errors.ts class SubforgeError extends Error { code; line; column; constructor(code, message, position) { super(`[${code}] ${message} at line ${position.line}, column ${position.column}`); this.name = "SubforgeError"; this.code = code; this.line = position.line; this.column = position.column; } } function toParseError(err) { if (err instanceof SubforgeError) { return { line: err.line, column: err.column, code: err.code, message: err.message }; } if (err instanceof Error) { return { line: 1, column: 1, code: "MALFORMED_EVENT", message: err.message }; } return { line: 1, column: 1, code: "MALFORMED_EVENT", message: String(err) }; } function unwrap(result) { if (result.ok) return result.document; const first = result.errors[0]; if (first) { throw new SubforgeError(first.code, first.message, { line: first.line, column: first.column }); } throw new Error("Parse failed"); } // src/core/document.ts var idCounter = 0; var EMPTY_SEGMENTS = []; function generateId() { return ++idCounter; } function reserveIds(count) { const start = idCounter + 1; idCounter += count; return start; } function createDocument(init) { return { info: { title: "", playResX: 1920, playResY: 1080, scaleBorderAndShadow: true, wrapStyle: 0, ...init?.info }, styles: init?.styles ?? new Map([["Default", createDefaultStyle()]]), events: init?.events ?? [], comments: init?.comments ?? [], fonts: init?.fonts, graphics: init?.graphics, regions: init?.regions }; } function createDefaultStyle() { return { name: "Default", fontName: "Arial", fontSize: 48, primaryColor: 16777215, secondaryColor: 255, outlineColor: 0, backColor: 0, bold: false, italic: false, underline: false, strikeout: false, scaleX: 100, scaleY: 100, spacing: 0, angle: 0, borderStyle: 1, outline: 2, shadow: 2, alignment: 2, marginL: 10, marginR: 10, marginV: 10, encoding: 1 }; } function createEvent(start, end, text, opts) { return { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: [], dirty: false, ...opts }; } function createKaraokeEvent(start, end, syllables, opts) { const segments = syllables.map((syl) => ({ text: syl.text, style: syl.style ? { ...syl.style } : null, effects: [{ type: "karaoke", params: { duration: syl.duration, mode: "fill" } }] })); return { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments, dirty: true, ...opts }; } function cloneDocument(doc) { return { info: { ...doc.info }, styles: new Map(doc.styles), events: doc.events.map((e) => ({ ...e, segments: [...e.segments], image: e.image ? { ...e.image, data: e.image.data.slice() } : undefined, vobsub: e.vobsub ? { ...e.vobsub } : undefined, pgs: e.pgs ? { ...e.pgs } : undefined })), comments: [...doc.comments], fonts: doc.fonts ? [...doc.fonts] : undefined, graphics: doc.graphics ? [...doc.graphics] : undefined, regions: doc.regions ? [...doc.regions] : undefined }; } function cloneEvent(event) { return { ...event, id: generateId(), segments: event.segments.map((s) => ({ ...s, effects: [...s.effects] })), image: event.image ? { ...event.image, data: event.image.data.slice() } : undefined, vobsub: event.vobsub ? { ...event.vobsub } : undefined, pgs: event.pgs ? { ...event.pgs } : undefined }; } // src/formats/text/sbv/parser.ts class SBVParser { src; pos = 0; len; doc; errors = []; opts; lineNum = 1; constructor(input, opts = {}) { let start = 0; if (input.charCodeAt(0) === 65279) 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() { 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: [] }; } skipEmptyLines() { while (this.pos < this.len) { const c = this.src.charCodeAt(this.pos); if (c === 10) { this.pos++; this.lineNum++; } else if (c === 13) { this.pos++; if (this.pos < this.len && this.src.charCodeAt(this.pos) === 10) this.pos++; this.lineNum++; } else if (c === 32 || c === 9) { this.pos++; } else { break; } } } parseSubtitle() { const timeLineStart = this.pos; const commaPos = this.src.indexOf(",", timeLineStart); let nlPos = this.src.indexOf(` `, 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++; if (commaPos === -1 || commaPos >= timeLineEnd) { this.addError("INVALID_TIMESTAMP", `Invalid time line`); return null; } 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; } const textStart = this.pos; let textEnd = this.pos; while (this.pos < this.len) { const lineStart = this.pos; nlPos = this.src.indexOf(` `, this.pos); if (nlPos === -1) nlPos = this.len; let lineEnd = nlPos; if (lineEnd > lineStart && this.src.charCodeAt(lineEnd - 1) === 13) lineEnd--; 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 }; } parseTimeInline(start, end) { 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); } addError(code, message, raw) { if (this.opts.onError === "skip") return; this.errors.push({ line: this.lineNum, column: 1, code, message, raw }); } } function parseSBV(input, opts) { 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, doc) { let start = 0; const len = input.length; if (len === 0) return false; if (input.charCodeAt(0) === 65279) start = 1; const line1 = "0:00:00.000,0:00:02.500"; if (!input.startsWith(line1, start)) return false; const nl1 = input.indexOf(` `, 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; } // src/formats/text/sbv/serializer.ts function toSBV(doc) { let result = ""; const events = doc.events; const len = events.length; for (let i = 0;i < len; i++) { const event = events[i]; const text = event.text; result += formatTime(event.start) + "," + formatTime(event.end) + ` `; result += text + ` `; } return result; } export { toSBV, parseTime, parseSBV, formatTime };