UNPKG

subforge

Version:

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

646 lines (641 loc) 18.2 kB
// src/formats/text/vtt/time.ts function parseTime(s) { const len = s.length; if (len === 12 && s.charCodeAt(8) === 46) { 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; } if (len === 9 && s.charCodeAt(5) === 46) { const m = (s.charCodeAt(0) - 48) * 10 + (s.charCodeAt(1) - 48); const ss = (s.charCodeAt(3) - 48) * 10 + (s.charCodeAt(4) - 48); const ms = (s.charCodeAt(6) - 48) * 100 + (s.charCodeAt(7) - 48) * 10 + (s.charCodeAt(8) - 48); return m * 60000 + ss * 1000 + ms; } throw new Error(`Invalid VTT timestamp: ${s}`); } 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/vtt/tags.ts function parseTags(raw) { const segments = []; const stateStack = [{ bold: false, italic: false, underline: 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); 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]; const tagLower = tag.toLowerCase(); if (tagLower === "b") { stateStack[stateStack.length] = { ...currentState, bold: true }; } else if (tagLower === "/b") { if (stateStack.length > 1) stateStack.pop(); } else if (tagLower === "i") { stateStack[stateStack.length] = { ...currentState, italic: true }; } else if (tagLower === "/i") { if (stateStack.length > 1) stateStack.pop(); } else if (tagLower === "u") { stateStack[stateStack.length] = { ...currentState, underline: true }; } else if (tagLower === "/u") { if (stateStack.length > 1) stateStack.pop(); } else if (tagLower.startsWith("v ") || tagLower.startsWith("v.")) { stateStack[stateStack.length] = { ...currentState }; } else if (tagLower === "/v") { if (stateStack.length > 1) stateStack.pop(); } else if (tagLower.startsWith("c.") || tagLower === "c") { stateStack[stateStack.length] = { ...currentState }; } else if (tagLower === "/c") { if (stateStack.length > 1) stateStack.pop(); } else if (tagLower.startsWith("lang ")) { stateStack[stateStack.length] = { ...currentState }; } else if (tagLower === "/lang") { if (stateStack.length > 1) stateStack.pop(); } } function buildStyle(state) { if (!state.bold && !state.italic && !state.underline) { return null; } const style = {}; if (state.bold) style.bold = true; if (state.italic) style.italic = true; if (state.underline) style.underline = true; 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>"); } 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/vtt/parser.ts class VTTParser { 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(); this.doc.regions = []; } parse() { this.parseHeader(); while (this.pos < this.len) { this.skipEmptyLines(); if (this.pos >= this.len) break; const c0 = this.src.charCodeAt(this.pos); const c1 = this.pos + 1 < this.len ? this.src.charCodeAt(this.pos + 1) : 0; const c2 = this.pos + 2 < this.len ? this.src.charCodeAt(this.pos + 2) : 0; const c3 = this.pos + 3 < this.len ? this.src.charCodeAt(this.pos + 3) : 0; if (c0 === 78 && c1 === 79 && c2 === 84 && c3 === 69) { this.parseNote(); } else if (c0 === 83 && c1 === 84 && c2 === 89 && c3 === 76) { this.parseStyle(); } else if (c0 === 82 && c1 === 69 && c2 === 71 && c3 === 73) { this.parseRegion(); } else { const event = this.parseCue(); if (event) { this.doc.events[this.doc.events.length] = event; } } } return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } readLine() { const start = this.pos; let nlPos = this.src.indexOf(` `, this.pos); if (nlPos === -1) nlPos = this.len; let end = nlPos; if (end > start && this.src.charCodeAt(end - 1) === 13) end--; this.pos = nlPos < this.len ? nlPos + 1 : this.len; this.lineNum++; return this.src.substring(start, end); } 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; } } } parseHeader() { if (this.pos >= this.len) return; const line = this.readLine().trim(); if (!line.startsWith("WEBVTT")) { this.addError("INVALID_SECTION", "File must start with WEBVTT"); } } parseNote() { while (this.pos < this.len) { const line = this.readLine(); if (line.trim() === "") break; } } parseStyle() { this.readLine(); while (this.pos < this.len) { const line = this.readLine(); if (line.trim() === "") break; } } parseRegion() { this.readLine(); const region = { id: "", width: "100%", lines: 3, regionAnchor: "0%,100%", viewportAnchor: "0%,100%", scroll: "none" }; while (this.pos < this.len) { const line = this.readLine().trim(); if (line === "") break; const colonIdx = line.indexOf(":"); if (colonIdx !== -1) { const key = line.substring(0, colonIdx).trim(); const value = line.substring(colonIdx + 1).trim(); switch (key) { case "id": region.id = value; break; case "width": region.width = value; break; case "lines": region.lines = parseInt(value) || 3; break; case "regionanchor": region.regionAnchor = value; break; case "viewportanchor": region.viewportAnchor = value; break; case "scroll": region.scroll = value === "up" ? "up" : "none"; break; } } } this.doc.regions[this.doc.regions.length] = region; } parseCue() { let lineStart = this.pos; let arrowPos = this.src.indexOf(" --> ", lineStart); let nlPos = this.src.indexOf(` `, lineStart); if (nlPos === -1) nlPos = this.len; let lineEnd = nlPos; if (lineEnd > lineStart && this.src.charCodeAt(lineEnd - 1) === 13) lineEnd--; this.pos = nlPos < this.len ? nlPos + 1 : this.len; this.lineNum++; if (arrowPos === -1 || arrowPos >= lineEnd) { if (this.pos >= this.len) return null; lineStart = this.pos; arrowPos = this.src.indexOf(" --> ", lineStart); nlPos = this.src.indexOf(` `, lineStart); if (nlPos === -1) nlPos = this.len; lineEnd = nlPos; if (lineEnd > lineStart && this.src.charCodeAt(lineEnd - 1) === 13) lineEnd--; this.pos = nlPos < this.len ? nlPos + 1 : this.len; this.lineNum++; } if (arrowPos === -1 || arrowPos >= lineEnd) { this.addError("INVALID_TIMESTAMP", `Invalid time line`); this.skipToNextCue(); return null; } const afterArrowStart = arrowPos + 5; const spacePos = this.src.indexOf(" ", afterArrowStart); const endPos = spacePos === -1 || spacePos > lineEnd ? lineEnd : spacePos; const startLen = arrowPos - lineStart; let start; if (startLen === 12) { const s = this.src; const o = lineStart; 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); } else if (startLen === 9) { const s = this.src; const o = lineStart; start = ((s.charCodeAt(o) - 48) * 10 + (s.charCodeAt(o + 1) - 48)) * 60000 + ((s.charCodeAt(o + 3) - 48) * 10 + (s.charCodeAt(o + 4) - 48)) * 1000 + (s.charCodeAt(o + 6) - 48) * 100 + (s.charCodeAt(o + 7) - 48) * 10 + (s.charCodeAt(o + 8) - 48); } else { this.addError("INVALID_TIMESTAMP", `Invalid timestamp`); this.skipToNextCue(); return null; } const endLen = endPos - afterArrowStart; let end; if (endLen === 12) { const s = this.src; const o = afterArrowStart; 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); } else if (endLen === 9) { const s = this.src; const o = afterArrowStart; end = ((s.charCodeAt(o) - 48) * 10 + (s.charCodeAt(o + 1) - 48)) * 60000 + ((s.charCodeAt(o + 3) - 48) * 10 + (s.charCodeAt(o + 4) - 48)) * 1000 + (s.charCodeAt(o + 6) - 48) * 100 + (s.charCodeAt(o + 7) - 48) * 10 + (s.charCodeAt(o + 8) - 48); } else { this.addError("INVALID_TIMESTAMP", `Invalid timestamp`); this.skipToNextCue(); return null; } const textStart = this.pos; let textEnd = this.pos; while (this.pos < this.len) { const ls = this.pos; let nl = this.src.indexOf(` `, this.pos); if (nl === -1) nl = this.len; let le = nl; if (le > ls && this.src.charCodeAt(le - 1) === 13) le--; let isEmpty = true; for (let i = ls;i < le; i++) { const c = this.src.charCodeAt(i); if (c !== 32 && c !== 9) { isEmpty = false; break; } } if (isEmpty) break; textEnd = le; this.pos = nl < this.len ? nl + 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 }; } skipToNextCue() { while (this.pos < this.len) { const line = this.readLine(); if (line.trim() === "") break; } } addError(code, message, raw) { if (this.opts.onError === "skip") return; this.errors.push({ line: this.lineNum, column: 1, code, message, raw }); } } function parseVTT(input, opts) { try { const parser = new VTTParser(input, opts); return parser.parse(); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } // src/formats/text/vtt/serializer.ts function toVTT(doc) { let result = `WEBVTT `; if (doc.regions && doc.regions.length > 0) { const regions = doc.regions; const regLen = regions.length; for (let i = 0;i < regLen; i++) { const region = regions[i]; result += `REGION `; result += "id:" + region.id + ` `; result += "width:" + region.width + ` `; result += "lines:" + region.lines + ` `; result += "regionanchor:" + region.regionAnchor + ` `; result += "viewportanchor:" + region.viewportAnchor + ` `; if (region.scroll !== "none") { result += "scroll:" + region.scroll + ` `; } 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 += formatTime(event.start) + " --> " + formatTime(event.end) + ` `; result += text + ` `; } return result; } export { toVTT, stripTags, serializeTags, parseVTT, parseTime, parseTags, formatTime };