UNPKG

subforge

Version:

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

501 lines (498 loc) 13.2 kB
// src/formats/text/microdvd/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); 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.includes(":")) return; const colonIdx = tag.indexOf(":"); const prefix = tag.slice(0, colonIdx).toLowerCase(); const value = tag.slice(colonIdx + 1); if (prefix === "y") { const newState = { ...currentState }; for (const char of value.toLowerCase()) { if (char === "b") newState.bold = true; else if (char === "i") newState.italic = true; else if (char === "u") newState.underline = true; else if (char === "s") newState.strikeout = true; } stateStack[stateStack.length] = newState; } else if (prefix === "c" || prefix === "C") { if (value.startsWith("$") && value.length === 7) { const hex = value.slice(1); const bb = parseInt(hex.slice(0, 2), 16); const gg = parseInt(hex.slice(2, 4), 16); const rr = parseInt(hex.slice(4, 6), 16); const color = (bb & 255) << 16 | (gg & 255) << 8 | rr & 255; stateStack[stateStack.length] = { ...currentState, color }; } } else if (prefix === "f") { stateStack[stateStack.length] = { ...currentState, fontName: value }; } else if (prefix === "s") { const size = parseInt(value, 10); if (!isNaN(size)) { stateStack[stateStack.length] = { ...currentState, fontSize: size }; } } } function buildStyle(state) { if (!state.bold && !state.italic && !state.underline && !state.strikeout && state.color === undefined && state.fontName === undefined && state.fontSize === 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; if (state.fontName !== undefined) style.fontName = state.fontName; if (state.fontSize !== undefined) style.fontSize = state.fontSize; return style; } function serializeTags(segments) { let result = ""; let activeStyles = []; for (const seg of segments) { let text = seg.text; const tags = []; if (seg.style) { const formatChars = []; if (seg.style.bold) formatChars.push("b"); if (seg.style.italic) formatChars.push("i"); if (seg.style.underline) formatChars.push("u"); if (seg.style.strikeout) formatChars.push("s"); if (formatChars.length > 0) { tags.push(`{y:${formatChars.join("")}}`); } if (seg.style.primaryColor !== undefined) { const color = seg.style.primaryColor; const rr = color & 255; const gg = color >> 8 & 255; const bb = color >> 16 & 255; const hex = `${bb.toString(16).padStart(2, "0")}${gg.toString(16).padStart(2, "0")}${rr.toString(16).padStart(2, "0")}`; tags.push(`{C:$${hex}}`); } if (seg.style.fontName !== undefined) { tags.push(`{f:${seg.style.fontName}}`); } if (seg.style.fontSize !== undefined) { tags.push(`{s:${seg.style.fontSize}}`); } } result += tags.join("") + text; } 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/microdvd/parser.ts class MicroDVDParser { src; pos = 0; len; doc; errors = []; opts; lineNum = 1; fps; constructor(input, fps, opts = {}) { let start = 0; if (input.charCodeAt(0) === 65279) 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() { 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() { if (this.src.charCodeAt(this.pos) !== 123) { const nlPos2 = this.src.indexOf(` `, this.pos); const endPos = nlPos2 === -1 ? this.len : nlPos2; 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 (nlPos2 === -1) { this.pos = this.len; } else { this.pos = nlPos2 + 1; this.lineNum++; } return null; } this.pos++; 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++; if (this.pos >= this.len || this.src.charCodeAt(this.pos) !== 123) { this.addError("INVALID_FORMAT", "Missing end frame"); return null; } this.pos++; 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++; const textStart = this.pos; let nlPos = this.src.indexOf(` `, 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(); if (text.includes("|")) { text = text.replace(/\|/g, ` `); } this.pos = nlPos < this.len ? nlPos + 1 : this.len; this.lineNum++; 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 }; } addError(code, message, raw) { if (this.opts.onError === "skip") return; this.errors.push({ line: this.lineNum, column: 1, code, message, raw }); } } function parseMicroDVD(input, opts) { try { const parser = new MicroDVDParser(input, opts.fps, opts); return parser.parse(); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } // src/formats/text/microdvd/serializer.ts function toMicroDVD(doc, opts) { const fps = opts.fps; let result = ""; const events = doc.events; const len = events.length; for (let i = 0;i < len; i++) { const event = events[i]; let text = event.dirty && event.segments.length > 0 ? serializeTags(event.segments) : event.text; if (text.includes(` `)) { text = text.replace(/\n/g, "|"); } const startFrame = Math.round(event.start / 1000 * fps); const endFrame = Math.round(event.end / 1000 * fps); result += `{${startFrame}}{${endFrame}}${text} `; } return result; } export { toMicroDVD, stripTags, serializeTags, parseTags, parseMicroDVD };