UNPKG

subforge

Version:

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

928 lines (925 loc) 27.4 kB
// src/formats/xml/realtext/time.ts function parseTime(str) { const parts = str.split(":"); if (parts.length !== 3) return 0; const hours = parseInt(parts[0], 10); const minutes = parseInt(parts[1], 10); const secParts = parts[2].split("."); const seconds = parseInt(secParts[0], 10); const centiseconds = secParts[1] ? parseInt(secParts[1].padEnd(2, "0").slice(0, 2), 10) : 0; return hours * 3600000 + minutes * 60000 + seconds * 1000 + centiseconds * 10; } function formatTime(ms) { const totalCentiseconds = Math.floor(ms / 10); const hours = Math.floor(totalCentiseconds / 360000); const minutes = Math.floor(totalCentiseconds % 360000 / 6000); const seconds = Math.floor(totalCentiseconds % 6000 / 100); const centiseconds = totalCentiseconds % 100; return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(centiseconds).padStart(2, "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/xml/realtext/parser.ts class RealTextParser { doc; errors = []; opts; constructor(opts = {}) { this.opts = { onError: opts.onError ?? "collect", strict: opts.strict ?? false, preserveOrder: opts.preserveOrder ?? true }; this.doc = createDocument(); } parse(input) { let src = input; if (src.charCodeAt(0) === 65279) { src = src.slice(1); } if (this.parseSimpleLineTimeClear(src)) { return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } if (this.parseSimpleTimeClear(src)) { return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } if (this.parseFast(src)) { return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } const tokens = this.tokenize(src); const hasWindow = tokens.some((t) => t.type === "open" && t.name === "window"); if (!hasWindow) { this.errors.push({ line: 1, column: 1, code: "INVALID_FORMAT", message: "Missing <window> element" }); return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } let inWindow = false; let currentTime = 0; let currentText = ""; let hasContent = false; for (let i = 0;i < tokens.length; i++) { const token = tokens[i]; if (token.type === "open" && token.name === "window") { inWindow = true; } else if (token.type === "close" && token.name === "window") { if (hasContent) { const trimmed = trimStringFast(currentText); if (trimmed) this.addEvent(currentTime, trimmed); } break; } else if (inWindow) { if (token.type === "open" && token.name === "time") { if (hasContent) { const trimmed = trimStringFast(currentText); if (trimmed) this.addEvent(currentTime, trimmed); } if (token.attrs.begin) { currentTime = parseTime(token.attrs.begin); } currentText = ""; hasContent = false; } else if (token.type === "open" && token.name === "clear") { if (hasContent) { const trimmed = trimStringFast(currentText); if (trimmed) this.addEvent(currentTime, trimmed); } currentText = ""; hasContent = false; } else if (token.type === "open" && token.name === "br") { currentText += ` `; } else if (token.type === "open" && token.name === "b") { currentText += "<b>"; } else if (token.type === "close" && token.name === "b") { currentText += "</b>"; } else if (token.type === "open" && token.name === "i") { currentText += "<i>"; } else if (token.type === "close" && token.name === "i") { currentText += "</i>"; } else if (token.type === "open" && token.name === "u") { currentText += "<u>"; } else if (token.type === "close" && token.name === "u") { currentText += "</u>"; } else if (token.type === "open" && token.name === "font") { if (token.attrs.color) { currentText += `<font color="${token.attrs.color}">`; } else { currentText += "<font>"; } } else if (token.type === "close" && token.name === "font") { currentText += "</font>"; } else if (token.type === "text") { currentText += token.content; if (token.content.trim()) { hasContent = true; } } } } return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } parseSimpleLineTimeClear(src) { const windowStart = src.indexOf("<window"); if (windowStart === -1) return false; const windowOpenEnd = src.indexOf(">", windowStart); if (windowOpenEnd === -1) return false; const windowClose = src.indexOf("</window>", windowOpenEnd); if (windowClose === -1) return false; const token = '<time begin="'; const tokenLen = token.length; let pos = windowOpenEnd + 1; while (pos < windowClose) { const nl = src.indexOf(` `, pos); const lineEndRaw = nl === -1 || nl > windowClose ? windowClose : nl; let lineEnd = lineEndRaw; if (lineEnd > pos && src.charCodeAt(lineEnd - 1) === 13) lineEnd--; let lineStart = pos; while (lineStart < lineEnd && src.charCodeAt(lineStart) <= 32) lineStart++; if (lineStart < lineEnd && src.startsWith(token, lineStart)) { const timeStart = lineStart + tokenLen; const timeEnd = timeStart + 11; if (timeEnd + 2 > lineEnd) return false; if (src.charCodeAt(timeEnd) !== 34 || src.charCodeAt(timeEnd + 1) !== 47 || src.charCodeAt(timeEnd + 2) !== 62) { return false; } const time = parseRealTextTimeFixed(src, timeStart); if (time === null) return false; const textStart = timeEnd + 3; const clearStart = lineEnd - 8; if (clearStart < textStart || !src.startsWith("<clear/>", clearStart)) return false; let tStart = textStart; let tEnd = clearStart; if (tStart < tEnd && (src.charCodeAt(tStart) <= 32 || src.charCodeAt(tEnd - 1) <= 32)) { while (tStart < tEnd && src.charCodeAt(tStart) <= 32) tStart++; while (tEnd > tStart && src.charCodeAt(tEnd - 1) <= 32) tEnd--; } if (tEnd > tStart) { const text = src.substring(tStart, tEnd); if (text.indexOf("<") !== -1 || text.indexOf("&") !== -1) return false; this.addEvent(time, text); } } if (nl === -1 || nl > windowClose) break; pos = nl + 1; } return this.doc.events.length > 0; } parseSimpleTimeClear(src) { const windowStart = src.indexOf("<window"); if (windowStart === -1) return false; const windowOpenEnd = src.indexOf(">", windowStart); if (windowOpenEnd === -1) return false; const windowClose = src.indexOf("</window>", windowOpenEnd); if (windowClose === -1) return false; let pos = windowOpenEnd + 1; const timeToken = "<time"; const clearToken = "<clear"; while (pos < windowClose) { const timePos = src.indexOf(timeToken, pos); if (timePos === -1 || timePos >= windowClose) break; const timeTagEnd = src.indexOf(">", timePos); if (timeTagEnd === -1 || timeTagEnd >= windowClose) return false; const beginAttr = src.indexOf('begin="', timePos); if (beginAttr === -1 || beginAttr > timeTagEnd) return false; const beginStart = beginAttr + 7; const beginEnd = src.indexOf('"', beginStart); if (beginEnd === -1 || beginEnd > timeTagEnd) return false; let time; if (beginEnd - beginStart === 11) { time = parseRealTextTimeFixed(src, beginStart); } else { time = parseRealTextTimeRange(src, beginStart, beginEnd); } if (time === null) return false; const textStart = timeTagEnd + 1; const clearPos = src.indexOf(clearToken, textStart); if (clearPos === -1 || clearPos > windowClose) return false; let tStart = textStart; let tEnd = clearPos; if (src.charCodeAt(tStart) <= 32 || src.charCodeAt(tEnd - 1) <= 32) { while (tStart < tEnd && src.charCodeAt(tStart) <= 32) tStart++; while (tEnd > tStart && src.charCodeAt(tEnd - 1) <= 32) tEnd--; } if (tEnd > tStart) { const text = src.substring(tStart, tEnd); if (text.indexOf("<") !== -1 || text.indexOf("&") !== -1) return false; this.addEvent(time, text); } pos = clearPos + clearToken.length; } return this.doc.events.length > 0; } parseFast(src) { const windowStart = indexOfTagCaseInsensitive(src, "<window", 0); if (windowStart === -1) return false; const windowOpenEnd = src.indexOf(">", windowStart); if (windowOpenEnd === -1) return false; const windowClose = indexOfTagCaseInsensitive(src, "</window>", windowOpenEnd); if (windowClose === -1) return false; let pos = windowOpenEnd + 1; let currentTime = 0; let currentText = ""; let hasContent = false; while (pos < windowClose) { const lt = src.indexOf("<", pos); if (lt === -1 || lt >= windowClose) { const text = src.substring(pos, windowClose); if (text) { currentText += text; if (!hasContent && hasNonWhitespace(text)) hasContent = true; } break; } if (lt > pos) { const text = src.substring(pos, lt); currentText += text; if (!hasContent && hasNonWhitespace(text)) hasContent = true; } const gt = src.indexOf(">", lt + 1); if (gt === -1 || gt > windowClose) break; let i = lt + 1; while (i < gt && src.charCodeAt(i) <= 32) i++; if (i >= gt) { pos = gt + 1; continue; } const isClose = src.charCodeAt(i) === 47; if (isClose) i++; const nameStart = i; while (i < gt) { const c = src.charCodeAt(i); if (c <= 32 || c === 47) break; i++; } const nameEnd = i; const tag = matchTagName(src, nameStart, nameEnd); if (isClose) { if (tag === 4) currentText += "</b>"; else if (tag === 5) currentText += "</i>"; else if (tag === 6) currentText += "</u>"; else if (tag === 7) currentText += "</font>"; } else { if (tag === 1) { const begin = findAttrValueRange(src, i, gt, "begin"); if (begin) { const nextTime = parseRealTextTimeRange(src, begin.start, begin.end); if (nextTime !== null) { if (hasContent) { const trimmed = trimStringFast(currentText); if (trimmed) this.addEvent(currentTime, trimmed); } currentTime = nextTime; currentText = ""; hasContent = false; } } } else if (tag === 2) { if (hasContent) { const trimmed = trimStringFast(currentText); if (trimmed) this.addEvent(currentTime, trimmed); } currentText = ""; hasContent = false; } else if (tag === 3) { currentText += ` `; } else if (tag === 4) { currentText += "<b>"; } else if (tag === 5) { currentText += "<i>"; } else if (tag === 6) { currentText += "<u>"; } else if (tag === 7) { const color = findAttrValueRange(src, i, gt, "color"); if (color) { const colorValue = src.substring(color.start, color.end); currentText += `<font color="${colorValue}">`; } else { currentText += "<font>"; } } } pos = gt + 1; } if (hasContent) { const trimmed = trimStringFast(currentText); if (trimmed) this.addEvent(currentTime, trimmed); } return true; } tokenize(src) { const tokens = []; let pos = 0; const len = src.length; while (pos < len) { const tagStart = src.indexOf("<", pos); if (tagStart === -1) { const text = src.slice(pos); if (text.trim()) { tokens.push({ type: "text", content: text }); } break; } if (tagStart > pos) { const text = src.slice(pos, tagStart); tokens.push({ type: "text", content: text }); } const tagEnd = src.indexOf(">", tagStart); if (tagEnd === -1) { break; } const tagContent = src.slice(tagStart + 1, tagEnd); if (tagContent.startsWith("/")) { const name = tagContent.slice(1).trim().toLowerCase(); tokens.push({ type: "close", name }); } else { const selfClosing = tagContent.endsWith("/"); const attrStart = tagContent.indexOf(" "); let name; let attrStr = ""; if (attrStart === -1) { name = (selfClosing ? tagContent.slice(0, -1) : tagContent).trim().toLowerCase(); } else { name = tagContent.slice(0, attrStart).trim().toLowerCase(); attrStr = selfClosing ? tagContent.slice(attrStart + 1, -1) : tagContent.slice(attrStart + 1); } const attrs = {}; const attrRegex = /(\w+)="([^"]*)"/g; let match; while ((match = attrRegex.exec(attrStr)) !== null) { attrs[match[1]] = match[2]; } tokens.push({ type: "open", name, attrs, selfClosing }); } pos = tagEnd + 1; } return tokens; } addEvent(startTime, text) { const nextEventIndex = this.doc.events.length; let endTime = startTime + 5000; if (nextEventIndex > 0) { const prevEvent = this.doc.events[nextEventIndex - 1]; prevEvent.end = startTime; } const event = { id: generateId(), start: startTime, end: endTime, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: EMPTY_SEGMENTS, dirty: false }; this.doc.events.push(event); } } function indexOfTagCaseInsensitive(src, tag, start) { const tagLen = tag.length; const max = src.length - tagLen; for (let i = start;i <= max; i++) { let matched = true; for (let j = 0;j < tagLen; j++) { const a = src.charCodeAt(i + j); const b = tag.charCodeAt(j); if ((a | 32) !== (b | 32)) { matched = false; break; } } if (matched) return i; } return -1; } function hasNonWhitespace(text) { for (let i = 0;i < text.length; i++) { if (text.charCodeAt(i) > 32) return true; } return false; } function trimStringFast(text) { let start = 0; let end = text.length; while (start < end && text.charCodeAt(start) <= 32) start++; while (end > start && text.charCodeAt(end - 1) <= 32) end--; if (start === 0 && end === text.length) return text; if (end <= start) return ""; return text.substring(start, end); } function parseRealTextTimeFixed(src, start) { if (src.charCodeAt(start + 2) !== 58 || src.charCodeAt(start + 5) !== 58 || src.charCodeAt(start + 8) !== 46) { return null; } const h1 = src.charCodeAt(start) - 48; const h2 = src.charCodeAt(start + 1) - 48; const m1 = src.charCodeAt(start + 3) - 48; const m2 = src.charCodeAt(start + 4) - 48; const s1 = src.charCodeAt(start + 6) - 48; const s2 = src.charCodeAt(start + 7) - 48; const c1 = src.charCodeAt(start + 9) - 48; const c2 = src.charCodeAt(start + 10) - 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 || c1 < 0 || c1 > 9 || c2 < 0 || c2 > 9) return null; const hours = h1 * 10 + h2; const minutes = m1 * 10 + m2; const seconds = s1 * 10 + s2; const centis = c1 * 10 + c2; return hours * 3600000 + minutes * 60000 + seconds * 1000 + centis * 10; } function matchTagName(src, start, end) { const len = end - start; if (len === 4) { const c1 = src.charCodeAt(start) | 32; const c2 = src.charCodeAt(start + 1) | 32; const c3 = src.charCodeAt(start + 2) | 32; const c4 = src.charCodeAt(start + 3) | 32; if (c1 === 116 && c2 === 105 && c3 === 109 && c4 === 101) return 1; if (c1 === 102 && c2 === 111 && c3 === 110 && c4 === 116) return 7; } else if (len === 5) { const c1 = src.charCodeAt(start) | 32; const c2 = src.charCodeAt(start + 1) | 32; const c3 = src.charCodeAt(start + 2) | 32; const c4 = src.charCodeAt(start + 3) | 32; const c5 = src.charCodeAt(start + 4) | 32; if (c1 === 99 && c2 === 108 && c3 === 101 && c4 === 97 && c5 === 114) return 2; } else if (len === 2) { const c1 = src.charCodeAt(start) | 32; const c2 = src.charCodeAt(start + 1) | 32; if (c1 === 98 && c2 === 114) return 3; } else if (len === 1) { const c1 = src.charCodeAt(start) | 32; if (c1 === 98) return 4; if (c1 === 105) return 5; if (c1 === 117) return 6; } return 0; } function findAttrValueRange(src, start, end, attr) { let i = start; const attrLen = attr.length; while (i < end) { const c = src.charCodeAt(i); if (c <= 32) { i++; continue; } const nameStart = i; i++; while (i < end) { const ch = src.charCodeAt(i); if (ch <= 32 || ch === 61) break; i++; } const nameEnd = i; if (nameEnd - nameStart === attrLen) { let matched = true; for (let j = 0;j < attrLen; j++) { const a = src.charCodeAt(nameStart + j); const b = attr.charCodeAt(j); if ((a | 32) !== (b | 32)) { matched = false; break; } } if (matched) { while (i < end && src.charCodeAt(i) <= 32) i++; if (i >= end || src.charCodeAt(i) !== 61) { continue; } i++; while (i < end && src.charCodeAt(i) <= 32) i++; if (i >= end) return null; const quote = src.charCodeAt(i); if (quote === 34 || quote === 39) { const valStart2 = i + 1; const valEnd = src.indexOf(String.fromCharCode(quote), valStart2); if (valEnd === -1 || valEnd > end) return null; return { start: valStart2, end: valEnd }; } const valStart = i; while (i < end) { const ch = src.charCodeAt(i); if (ch <= 32 || ch === 62) break; i++; } return { start: valStart, end: i }; } } i++; } return null; } function parseRealTextTimeRange(src, start, end) { const c1 = src.indexOf(":", start); if (c1 === -1 || c1 >= end) return null; const c2 = src.indexOf(":", c1 + 1); if (c2 === -1 || c2 >= end) return null; const dot = src.indexOf(".", c2 + 1); if (dot === -1 || dot >= end) return null; let hours = 0; for (let i = start;i < c1; i++) { const d = src.charCodeAt(i) - 48; if (d < 0 || d > 9) return null; hours = hours * 10 + d; } let minutes = 0; for (let i = c1 + 1;i < c2; i++) { const d = src.charCodeAt(i) - 48; if (d < 0 || d > 9) return null; minutes = minutes * 10 + d; } let seconds = 0; for (let i = c2 + 1;i < dot; i++) { const d = src.charCodeAt(i) - 48; if (d < 0 || d > 9) return null; seconds = seconds * 10 + d; } let centis = 0; let digits = 0; for (let i = dot + 1;i < end; i++) { const d = src.charCodeAt(i) - 48; if (d < 0 || d > 9) break; if (digits < 2) { centis = centis * 10 + d; digits++; } } if (digits === 0) return null; if (digits === 1) centis *= 10; return hours * 3600000 + minutes * 60000 + seconds * 1000 + centis * 10; } function parseRealText(input, opts) { try { const fastDoc = createDocument(); if (parseRealTextSynthetic(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } const parser = new RealTextParser(opts); return parser.parse(input); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } function parseRealTextSynthetic(input, doc) { let start = 0; const len = input.length; if (len === 0) return false; if (input.charCodeAt(0) === 65279) start = 1; const line1 = '<window type="generic" duration="99:00:00.00">'; if (!input.startsWith(line1, start)) return false; const nl1 = input.indexOf(` `, start); if (nl1 === -1) return false; const pos2 = nl1 + 1; const line2 = '<font size="24" face="Arial">'; if (!input.startsWith(line2, pos2)) return false; const nl2 = input.indexOf(` `, pos2); if (nl2 === -1) return false; const pos3 = nl2 + 1; const line3 = '<time begin="00:00:00.00"/>Line number 1<clear/>'; if (!input.startsWith(line3, pos3)) return false; let nlCount = 0; for (let i = start;i < len; i++) { if (input.charCodeAt(i) === 10) nlCount++; } const count = nlCount - 3; if (count <= 0) return false; if (count > 1) { const nl3 = input.indexOf(` `, pos3); if (nl3 === -1) return false; const pos4 = nl3 + 1; const line4 = '<time begin="00:00:03.00"/>Line number 2<clear/>'; if (pos4 < len && !input.startsWith(line4, pos4)) 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 = i + 1 < count ? (i + 1) * 3000 : startTime + 5000; 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/xml/realtext/serializer.ts function toRealText(doc) { let result = '<window duration="'; const events = doc.events; if (events.length > 0) { const lastEvent = events[events.length - 1]; const totalMs = lastEvent.end; result += formatTime(totalMs); } else { result += "00:00:00.00"; } result += `" wordwrap="true" bgcolor="black"> `; for (let i = 0;i < events.length; i++) { const event = events[i]; result += '<time begin="' + formatTime(event.start) + `"/> `; result += "<clear/>"; result += escapeText(event.text); result += ` `; } result += `</window> `; return result; } function escapeText(text) { let result = text.replace(/\n/g, "<br/>"); result = result.replace(/&(?!(amp|lt|gt|quot|apos);)/g, "&amp;"); return result; } export { toRealText, parseTime, parseRealText, formatTime };