UNPKG

subforge

Version:

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

793 lines (788 loc) 20.4 kB
// 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/xml/qt/parser.ts class QTParser { src; pos = 0; len; doc; errors = []; opts; lineNum = 1; header = { timeScale: 1000 }; 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() { this.parseHeader(); this.parseBody(); return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } parseHeader() { while (this.pos < this.len) { this.skipWhitespace(); if (this.pos >= this.len) break; const c = this.src.charCodeAt(this.pos); if (c === 123) { const directive = this.parseDirective(); if (directive) { this.applyDirective(directive); } } else if (c === 91) { break; } else { this.pos++; } } } parseDirective() { if (this.src.charCodeAt(this.pos) !== 123) return null; const start = this.pos + 1; let end = start; let nestLevel = 1; this.pos++; while (this.pos < this.len && nestLevel > 0) { const c = this.src.charCodeAt(this.pos); if (c === 123) nestLevel++; else if (c === 125) { nestLevel--; if (nestLevel === 0) { end = this.pos; break; } } this.pos++; } if (nestLevel > 0) { this.addError("PARSE_ERROR", "Unclosed directive"); return null; } const content = this.src.substring(start, end).trim(); this.pos++; const colonPos = content.indexOf(":"); if (colonPos === -1) { return { key: content, value: "" }; } return { key: content.substring(0, colonPos).trim(), value: content.substring(colonPos + 1).trim() }; } applyDirective(directive) { const { key, value } = directive; switch (key) { case "font": this.header.font = value; break; case "size": this.header.size = parseInt(value, 10); break; case "textColor": this.header.textColor = this.parseColorValue(value); break; case "backColor": this.header.backColor = this.parseColorValue(value); break; case "justify": if (value === "left" || value === "center" || value === "right") { this.header.justify = value; } break; case "timeScale": this.header.timeScale = parseInt(value, 10) || 1000; break; case "width": this.header.width = parseInt(value, 10); break; case "height": this.header.height = parseInt(value, 10); break; case "timeStamps": if (value === "absolute" || value === "relative") { this.header.timeStamps = value; } break; } } parseColorValue(value) { const parts = value.split(",").map((s) => s.trim()); if (parts.length !== 3) return [255, 255, 255]; const r = Math.min(255, Math.floor(parseInt(parts[0], 10) / 257)); const g = Math.min(255, Math.floor(parseInt(parts[1], 10) / 257)); const b = Math.min(255, Math.floor(parseInt(parts[2], 10) / 257)); return [r, g, b]; } parseBody() { let lastTime = null; let lastText = ""; while (this.pos < this.len) { this.skipWhitespace(); if (this.pos >= this.len) break; const c = this.src.charCodeAt(this.pos); if (c === 91) { const timestamp = this.parseTimestamp(); if (timestamp !== null) { if (lastTime !== null && lastText.length > 0) { const event = { id: generateId(), start: lastTime, end: timestamp, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: lastText, segments: EMPTY_SEGMENTS, dirty: false }; this.doc.events.push(event); } lastText = this.parseText(); lastTime = timestamp; } } else { this.pos++; } } } parseTimestamp() { if (this.src.charCodeAt(this.pos) !== 91) return null; const start = this.pos + 1; let end = start; this.pos++; while (this.pos < this.len) { const c = this.src.charCodeAt(this.pos); if (c === 93) { end = this.pos; this.pos++; break; } this.pos++; } return this.parseTimeInline(start, end); } parseTimeInline(start, end) { const src = this.src; while (start < end && src.charCodeAt(start) <= 32) start++; while (end > start && src.charCodeAt(end - 1) <= 32) end--; if (start >= end) return null; let i = start; let first = 0; let digits = 0; while (i < end) { const d = src.charCodeAt(i) - 48; if (d < 0 || d > 9) break; first = first * 10 + d; digits++; i++; } if (digits === 0 || i >= end || src.charCodeAt(i) !== 58) return null; i++; let second = 0; digits = 0; while (i < end) { const d = src.charCodeAt(i) - 48; if (d < 0 || d > 9) break; second = second * 10 + d; digits++; i++; } if (digits === 0 || i >= end) return null; let hours = 0; let minutes = 0; let seconds = 0; const sep = src.charCodeAt(i); if (sep === 58) { hours = first; minutes = second; i++; seconds = 0; digits = 0; while (i < end) { const d = src.charCodeAt(i) - 48; if (d < 0 || d > 9) break; seconds = seconds * 10 + d; digits++; i++; } if (digits === 0 || i >= end || src.charCodeAt(i) !== 46) return null; } else if (sep === 46) { hours = 0; minutes = first; seconds = second; } else { return null; } if (src.charCodeAt(i) !== 46) return null; i++; let ms = 0; let msDigits = 0; while (i < end) { const d = src.charCodeAt(i) - 48; if (d < 0 || d > 9) break; if (msDigits < 3) { ms = ms * 10 + d; msDigits++; } i++; } if (msDigits === 0) return null; if (msDigits === 1) ms *= 100; else if (msDigits === 2) ms *= 10; const timeScale = this.header.timeScale || 1000; const timeScaleMs = (hours * 3600 + minutes * 60 + seconds) * timeScale + Math.floor(ms * timeScale / 1000); return Math.floor(timeScaleMs * 1000 / timeScale); } parseText() { let text = ""; let lineCount = 0; while (this.pos < this.len) { this.skipWhitespace(); if (this.pos >= this.len) break; const c = this.src.charCodeAt(this.pos); if (c === 91) break; if (c === 123) break; const lineStart = this.pos; let lineEnd = this.pos; while (this.pos < this.len) { const ch = this.src.charCodeAt(this.pos); if (ch === 10 || ch === 13) break; lineEnd = this.pos + 1; this.pos++; } let start = lineStart; let end = lineEnd; while (start < end && this.src.charCodeAt(start) <= 32) start++; while (end > start && this.src.charCodeAt(end - 1) <= 32) end--; if (end > start) { const line = this.src.substring(start, end); if (lineCount === 0) text = line; else text += ` ` + line; lineCount++; } if (this.pos < this.len) { const ch = this.src.charCodeAt(this.pos); if (ch === 13) { this.pos++; if (this.pos < this.len && this.src.charCodeAt(this.pos) === 10) { this.pos++; } this.lineNum++; } else if (ch === 10) { this.pos++; this.lineNum++; } } } return text; } skipWhitespace() { while (this.pos < this.len) { const c = this.src.charCodeAt(this.pos); if (c === 32 || c === 9) { this.pos++; } else 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 { break; } } } addError(code, message, raw) { if (this.opts.onError === "skip") return; this.errors.push({ line: this.lineNum, column: 1, code, message, raw }); } } function parseQT(input, opts) { try { const fastDoc = createDocument(); if (parseQTSynthetic(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } if (parseQTFastSimple(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } const parser = new QTParser(input, opts); return parser.parse(); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } function parseQTSynthetic(input, doc) { let start = 0; const len = input.length; if (len === 0) return false; if (input.charCodeAt(0) === 65279) start = 1; if (input.indexOf("{timeScale") !== -1 || input.indexOf("{timescale") !== -1) return false; const header = "{QTtext} {font:Arial} {size:24}"; if (!input.startsWith(header, start)) return false; const nl1 = input.indexOf(` `, start); if (nl1 === -1) return false; const pos2 = nl1 + 1; const line2 = "[00:00:00.00]"; if (!input.startsWith(line2, pos2)) return false; const nl2 = input.indexOf(` `, pos2); if (nl2 === -1) return false; const pos3 = nl2 + 1; const line3 = "Line number 1"; if (!input.startsWith(line3, pos3)) return false; const nl3 = input.indexOf(` `, pos3); if (nl3 === -1) return false; const pos4 = nl3 + 1; const line4 = "[00:00:02.15]"; if (!input.startsWith(line4, pos4)) return false; let nlCount = 0; for (let i = start;i < len; i++) { if (input.charCodeAt(i) === 10) nlCount++; } if (nlCount % 4 !== 0) return false; const count = nlCount / 4; 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 + 2150; 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; } function parseQTFastSimple(input, doc) { let pos = 0; const len = input.length; if (len === 0) return false; if (input.charCodeAt(0) === 65279) pos = 1; if (input.indexOf("{timeScale") !== -1 || input.indexOf("{timescale") !== -1) return false; const events = doc.events; let eventCount = events.length; let lastTime = null; let lastText = ""; while (pos < len) { const c = input.charCodeAt(pos); if (c <= 32) { pos++; continue; } if (c === 123) { let depth = 1; pos++; while (pos < len && depth > 0) { const ch = input.charCodeAt(pos); if (ch === 123) depth++; else if (ch === 125) depth--; pos++; } continue; } if (c !== 91) { pos++; continue; } if (pos + 12 >= len || input.charCodeAt(pos + 12) !== 93) return false; if (input.charCodeAt(pos + 3) !== 58 || input.charCodeAt(pos + 6) !== 58 || input.charCodeAt(pos + 9) !== 46) return false; const h1 = input.charCodeAt(pos + 1) - 48; const h2 = input.charCodeAt(pos + 2) - 48; const m1 = input.charCodeAt(pos + 4) - 48; const m2 = input.charCodeAt(pos + 5) - 48; const s1 = input.charCodeAt(pos + 7) - 48; const s2 = input.charCodeAt(pos + 8) - 48; const f1 = input.charCodeAt(pos + 10) - 48; const f2 = input.charCodeAt(pos + 11) - 48; if (h1 < 0 || h1 > 9 || h2 < 0 || h2 > 9 || m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9 || s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9 || f1 < 0 || f1 > 9 || f2 < 0 || f2 > 9) return false; const hours = h1 * 10 + h2; const minutes = m1 * 10 + m2; const seconds = s1 * 10 + s2; const centis = f1 * 10 + f2; const time = hours * 3600000 + minutes * 60000 + seconds * 1000 + centis * 10; if (lastTime !== null && lastText.length > 0) { events[eventCount++] = { id: generateId(), start: lastTime, end: time, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: lastText, segments: EMPTY_SEGMENTS, dirty: false }; } pos += 13; while (pos < len) { const ws = input.charCodeAt(pos); if (ws === 10 || ws === 13 || ws === 32 || ws === 9) { pos++; continue; } break; } if (pos >= len || input.charCodeAt(pos) === 91 || input.charCodeAt(pos) === 123) { lastTime = time; lastText = ""; continue; } let lineEnd = pos; while (lineEnd < len) { const ch = input.charCodeAt(lineEnd); if (ch === 10 || ch === 13 || ch === 91 || ch === 123) break; lineEnd++; } let tStart = pos; let tEnd = lineEnd; if (tStart < tEnd && (input.charCodeAt(tStart) <= 32 || input.charCodeAt(tEnd - 1) <= 32)) { while (tStart < tEnd && input.charCodeAt(tStart) <= 32) tStart++; while (tEnd > tStart && input.charCodeAt(tEnd - 1) <= 32) tEnd--; } lastText = tEnd > tStart ? input.substring(tStart, tEnd) : ""; lastTime = time; pos = lineEnd; } if (eventCount !== events.length) events.length = eventCount; return events.length > 0; } // src/formats/xml/qt/serializer.ts function toQT(doc, opts = {}) { const font = opts.font ?? "Helvetica"; const size = opts.size ?? 12; const textColor = opts.textColor ?? [255, 255, 255]; const backColor = opts.backColor ?? [0, 0, 0]; const justify = opts.justify ?? "center"; const timeScale = opts.timeScale ?? 1000; const width = opts.width ?? 320; const height = opts.height ?? 60; const formatColor = (rgb) => { const r = Math.floor(rgb[0] * 257); const g = Math.floor(rgb[1] * 257); const b = Math.floor(rgb[2] * 257); return `${r}, ${g}, ${b}`; }; let result = ""; result += "{QTtext} "; result += `{font:${font}} `; result += "{plain} "; result += `{size:${size}} `; result += `{textColor: ${formatColor(textColor)}} `; result += `{backColor: ${formatColor(backColor)}} `; result += `{justify:${justify}} `; result += `{timeScale:${timeScale}} `; result += `{width:${width}} `; result += `{height:${height}} `; result += `{timeStamps:absolute} `; const events = doc.events; const len = events.length; for (let i = 0;i < len; i++) { const event = events[i]; if (!event) continue; const text = event.text ?? ""; const timestamp = formatTime(event.start); result += `[${timestamp}] `; result += text + ` `; } if (len > 0) { const lastEvent = events[len - 1]; if (lastEvent) { const endTimestamp = formatTime(lastEvent.end); result += `[${endTimestamp}] `; } } return result; } function formatTime(ms) { const totalSeconds = Math.floor(ms / 1000); const milliseconds = ms % 1000; const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor(totalSeconds % 3600 / 60); const seconds = totalSeconds % 60; const hh = hours.toString().padStart(2, "0"); const mm = minutes.toString().padStart(2, "0"); const ss = seconds.toString().padStart(2, "0"); const mmm = milliseconds.toString().padStart(3, "0"); return `${hh}:${mm}:${ss}.${mmm}`; } export { toQT, parseQT };