UNPKG

subforge

Version:

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

523 lines (519 loc) 14.6 kB
// src/formats/text/srt/time.ts function parseTime(s) { if (s.length !== 12) throw new Error(`Invalid SRT timestamp: ${s}`); const h = (s.charCodeAt(0) - 48) * 10 + (s.charCodeAt(1) - 48); const m = (s.charCodeAt(3) - 48) * 10 + (s.charCodeAt(4) - 48); const ss = (s.charCodeAt(6) - 48) * 10 + (s.charCodeAt(7) - 48); const ms = (s.charCodeAt(9) - 48) * 100 + (s.charCodeAt(10) - 48) * 10 + (s.charCodeAt(11) - 48); 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.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")},${millis.toString().padStart(3, "0")}`; } // src/formats/text/srt/tags.ts function parseTags(raw) { const segments = []; const stateStack = [{ bold: false, italic: false, underline: false, strikeout: false }]; let textStart = 0; let i = 0; while (i < raw.length) { if (raw[i] === "<") { const closeIdx = raw.indexOf(">", i); if (closeIdx === -1) { i++; continue; } if (i > textStart) { const state = stateStack[stateStack.length - 1]; const style = buildStyle(state); segments[segments.length] = { text: raw.slice(textStart, i), style, effects: [] }; } const tag = raw.slice(i + 1, closeIdx).toLowerCase(); processTag(tag, stateStack); i = closeIdx + 1; textStart = i; } else { i++; } } if (textStart < raw.length) { const state = stateStack[stateStack.length - 1]; const style = buildStyle(state); segments[segments.length] = { text: raw.slice(textStart), style, effects: [] }; } return segments; } function processTag(tag, stateStack) { const currentState = stateStack[stateStack.length - 1]; if (tag === "b") { stateStack[stateStack.length] = { ...currentState, bold: true }; } else if (tag === "/b") { if (stateStack.length > 1) stateStack.pop(); } else if (tag === "i") { stateStack[stateStack.length] = { ...currentState, italic: true }; } else if (tag === "/i") { if (stateStack.length > 1) stateStack.pop(); } else if (tag === "u") { stateStack[stateStack.length] = { ...currentState, underline: true }; } else if (tag === "/u") { if (stateStack.length > 1) stateStack.pop(); } else if (tag === "s") { stateStack[stateStack.length] = { ...currentState, strikeout: true }; } else if (tag === "/s") { if (stateStack.length > 1) stateStack.pop(); } else if (tag.startsWith("font")) { const colorMatch = tag.match(/color\s*=\s*["']?#?([0-9a-f]{6})["']?/i); if (colorMatch) { const hex = colorMatch[1]; const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); const color = (b & 255) << 16 | (g & 255) << 8 | r & 255; stateStack[stateStack.length] = { ...currentState, color }; } else { stateStack[stateStack.length] = { ...currentState }; } } else if (tag === "/font") { if (stateStack.length > 1) stateStack.pop(); } } function buildStyle(state) { if (!state.bold && !state.italic && !state.underline && !state.strikeout && state.color === undefined) { return null; } const style = {}; if (state.bold) style.bold = true; if (state.italic) style.italic = true; if (state.underline) style.underline = true; if (state.strikeout) style.strikeout = true; if (state.color !== undefined) style.primaryColor = state.color; return style; } function serializeTags(segments) { let result = ""; for (const seg of segments) { let text = seg.text; const openTags = []; const closeTags = []; if (seg.style?.bold) { openTags[openTags.length] = "<b>"; closeTags.unshift("</b>"); } if (seg.style?.italic) { openTags[openTags.length] = "<i>"; closeTags.unshift("</i>"); } if (seg.style?.underline) { openTags[openTags.length] = "<u>"; closeTags.unshift("</u>"); } if (seg.style?.strikeout) { openTags[openTags.length] = "<s>"; closeTags.unshift("</s>"); } if (seg.style?.primaryColor !== undefined) { const color = seg.style.primaryColor; const r = color & 255; const g = color >> 8 & 255; const b = color >> 16 & 255; const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; openTags[openTags.length] = `<font color="${hex}">`; closeTags.unshift("</font>"); } result += openTags.join("") + text + closeTags.join(""); } return result; } function stripTags(raw) { return raw.replace(/<[^>]*>/g, ""); } // 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/srt/parser.ts class SRTParser { 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; } } } isDigit(c) { return c >= 48 && c <= 57; } parseSubtitle() { const indexLineStart = this.pos; let nlPos = this.src.indexOf(` `, this.pos); if (nlPos === -1) nlPos = this.len; let indexLineEnd = nlPos; if (indexLineEnd > indexLineStart && this.src.charCodeAt(indexLineEnd - 1) === 13) indexLineEnd--; 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++; if (firstNonWs >= indexLineEnd || !this.isDigit(this.src.charCodeAt(firstNonWs))) { return null; } if (this.pos >= this.len) return null; const timeLineStart = this.pos; const arrowPos = this.src.indexOf(" --> ", timeLineStart); 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 (arrowPos === -1 || arrowPos >= timeLineEnd) { this.addError("INVALID_TIMESTAMP", `Invalid time line`); return null; } 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); 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); 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 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 }; } addError(code, message, raw) { if (this.opts.onError === "skip") return; this.errors.push({ line: this.lineNum, column: 1, code, message, raw }); } } function parseSRT(input, opts) { try { const parser = new SRTParser(input, opts); return parser.parse(); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } // src/formats/text/srt/serializer.ts function toSRT(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.dirty && event.segments.length > 0 ? serializeTags(event.segments) : event.text; result += i + 1 + ` `; result += formatTime(event.start) + " --> " + formatTime(event.end) + ` `; result += text + ` `; } return result; } export { toSRT, stripTags, serializeTags, parseTime, parseTags, parseSRT, formatTime };