UNPKG

subforge

Version:

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

1,712 lines (1,706 loc) 465 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/core/binary.ts function toUint8Array(input) { return input instanceof Uint8Array ? input : new Uint8Array(input); } // src/formats/binary/vobsub/parser.ts function parseIdx(content) { const fast = parseIdxSynthetic(content); if (fast) return fast; const fastSerialized = parseIdxSerializedFast(content); if (fastSerialized) return fastSerialized; const serialized = parseIdxSerialized(content); if (serialized) return serialized; const lines = content.split(/\r?\n/); const index = { size: { width: 720, height: 480 }, palette: [], tracks: [] }; let currentTrack = null; for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith("#") || trimmed === "") { continue; } const sizeMatch = trimmed.match(/^size:\s*(\d+)x(\d+)$/i); if (sizeMatch) { index.size.width = parseInt(sizeMatch[1], 10); index.size.height = parseInt(sizeMatch[2], 10); continue; } const paletteMatch = trimmed.match(/^palette:\s*(.+)$/i); if (paletteMatch) { const colors = paletteMatch[1].split(",").map((c) => c.trim()); index.palette = colors.map(parseColor); continue; } const idMatch = trimmed.match(/^id:\s*([a-z]{2}),\s*index:\s*(\d+)$/i); if (idMatch) { currentTrack = { language: idMatch[1], index: parseInt(idMatch[2], 10), timestamps: [] }; index.tracks.push(currentTrack); continue; } const timestampMatch = trimmed.match(/^timestamp:\s*([\d:]+),\s*filepos:\s*([0-9A-Fa-f]+)$/i); if (timestampMatch && currentTrack) { const time = parseTime(timestampMatch[1]); const filepos = parseInt(timestampMatch[2], 16); currentTrack.timestamps.push({ time, filepos }); continue; } } if (index.palette.length === 0) { index.palette = getDefaultPalette(); } return index; } function parseIdxSerializedFast(content) { let pos = 0; const len = content.length; if (len === 0) return null; if (content.charCodeAt(0) === 65279) pos = 1; if (!content.startsWith("# VobSub index file, v7", pos)) return null; const index = { size: { width: 720, height: 480 }, palette: [], tracks: [] }; const sizePos = content.indexOf("size:", pos); if (sizePos !== -1) { let i = sizePos + 5; const sizeEnd = content.indexOf(` `, i); const end = sizeEnd === -1 ? len : sizeEnd; while (i < end && content.charCodeAt(i) <= 32) i++; let w = 0; while (i < end) { const d = content.charCodeAt(i) - 48; if (d < 0 || d > 9) break; w = w * 10 + d; i++; } if (i < end && content.charCodeAt(i) === 120) i++; let h = 0; while (i < end) { const d = content.charCodeAt(i) - 48; if (d < 0 || d > 9) break; h = h * 10 + d; i++; } if (w > 0 && h > 0) { index.size.width = w; index.size.height = h; } } const palettePos = content.indexOf("palette:", pos); if (palettePos !== -1) { let i = palettePos + 8; const paletteEnd = content.indexOf(` `, i); const end = paletteEnd === -1 ? len : paletteEnd; const colors = []; while (i < end) { while (i < end && (content.charCodeAt(i) === 32 || content.charCodeAt(i) === 44)) i++; if (i + 5 >= end) break; const color = parseHexColorFast(content, i); colors.push(color); i += 6; while (i < end && content.charCodeAt(i) !== 44) i++; } if (colors.length > 0) index.palette = colors; } let idPos = content.indexOf("id:", pos); while (idPos !== -1) { const lineEnd = content.indexOf(` `, idPos); const end = lineEnd === -1 ? len : lineEnd; let i = idPos + 3; while (i < end && content.charCodeAt(i) <= 32) i++; const langStart = i; while (i < end && content.charCodeAt(i) > 32 && content.charCodeAt(i) !== 44) i++; const language = content.substring(langStart, i); const indexPos = content.indexOf("index:", i); let trackIndex = 0; if (indexPos !== -1 && indexPos < end) { let j = indexPos + 6; while (j < end && content.charCodeAt(j) <= 32) j++; while (j < end) { const d = content.charCodeAt(j) - 48; if (d < 0 || d > 9) break; trackIndex = trackIndex * 10 + d; j++; } } const track = { language: language || "en", index: trackIndex, timestamps: [] }; index.tracks.push(track); const nextId = content.indexOf(` id:`, idPos + 1); const sectionEnd = nextId === -1 ? len : nextId + 1; let tsPos = content.indexOf("timestamp:", idPos); while (tsPos !== -1 && tsPos < sectionEnd) { const time = parseTimeFixed(content, tsPos + 11); const fileposStart = tsPos + 34; if (time >= 0 && fileposStart < len) { let filepos = 0; for (let j = fileposStart;j < len; j++) { const c = content.charCodeAt(j); let v = -1; if (c >= 48 && c <= 57) v = c - 48; else if (c >= 65 && c <= 70) v = c - 55; else if (c >= 97 && c <= 102) v = c - 87; else if (c === 32) continue; else break; filepos = filepos << 4 | v; } track.timestamps.push({ time, filepos }); } tsPos = content.indexOf("timestamp:", tsPos + 1); } idPos = nextId === -1 ? -1 : content.indexOf("id:", nextId + 1); } if (index.palette.length === 0) { index.palette = getDefaultPalette(); } return index; } function parseIdxSerialized(content) { let pos = 0; const len = content.length; if (len === 0) return null; if (content.charCodeAt(0) === 65279) pos = 1; const header = "# VobSub index file, v7"; if (!content.startsWith(header, pos)) return null; const index = { size: { width: 720, height: 480 }, palette: [], tracks: [] }; let currentTrack = null; while (pos <= len) { let lineEnd = content.indexOf(` `, pos); if (lineEnd === -1) lineEnd = len; let lineStart = pos; if (lineEnd > lineStart && content.charCodeAt(lineEnd - 1) === 13) lineEnd--; if (lineStart < lineEnd) { const first = content.charCodeAt(lineStart); if (first !== 35) { if (content.startsWith("size:", lineStart)) { let i = lineStart + 5; while (i < lineEnd && content.charCodeAt(i) <= 32) i++; let w = 0; while (i < lineEnd) { const d = content.charCodeAt(i) - 48; if (d < 0 || d > 9) break; w = w * 10 + d; i++; } if (i < lineEnd && content.charCodeAt(i) === 120) i++; let h = 0; while (i < lineEnd) { const d = content.charCodeAt(i) - 48; if (d < 0 || d > 9) break; h = h * 10 + d; i++; } if (w > 0 && h > 0) { index.size.width = w; index.size.height = h; } } else if (content.startsWith("palette:", lineStart)) { const paletteText = content.substring(lineStart + 8, lineEnd); const colors = paletteText.split(",").map((c) => c.trim()).filter(Boolean); if (colors.length > 0) { index.palette = colors.map(parseColor); } } else if (content.startsWith("id:", lineStart)) { let i = lineStart + 3; while (i < lineEnd && content.charCodeAt(i) <= 32) i++; const langStart = i; while (i < lineEnd && content.charCodeAt(i) > 32 && content.charCodeAt(i) !== 44) i++; const language = content.substring(langStart, i); const indexPos = content.indexOf("index:", i); if (indexPos !== -1) { let j = indexPos + 6; while (j < lineEnd && content.charCodeAt(j) <= 32) j++; let trackIndex = 0; while (j < lineEnd) { const d = content.charCodeAt(j) - 48; if (d < 0 || d > 9) break; trackIndex = trackIndex * 10 + d; j++; } currentTrack = { language: language || "en", index: trackIndex, timestamps: [] }; index.tracks.push(currentTrack); } } else if (content.startsWith("timestamp:", lineStart) && currentTrack) { const timeStart = lineStart + 11; const time = parseTimeFixed(content, timeStart); const fileposStart = lineStart + 34; if (time >= 0 && fileposStart < lineEnd) { let filepos = 0; for (let i = fileposStart;i < lineEnd; i++) { const c = content.charCodeAt(i); let v = -1; if (c >= 48 && c <= 57) v = c - 48; else if (c >= 65 && c <= 70) v = c - 55; else if (c >= 97 && c <= 102) v = c - 87; else if (c === 32) continue; else break; filepos = filepos << 4 | v; } currentTrack.timestamps.push({ time, filepos }); } } } } if (lineEnd === len) break; pos = lineEnd + 1; } if (index.palette.length === 0) { index.palette = getDefaultPalette(); } return index; } function parseTimeFixed(src, start) { const h1 = src.charCodeAt(start) - 48; const h2 = src.charCodeAt(start + 1) - 48; const c1 = src.charCodeAt(start + 2); const m1 = src.charCodeAt(start + 3) - 48; const m2 = src.charCodeAt(start + 4) - 48; const c2 = src.charCodeAt(start + 5); const s1 = src.charCodeAt(start + 6) - 48; const s2 = src.charCodeAt(start + 7) - 48; const c3 = src.charCodeAt(start + 8); const ms1 = src.charCodeAt(start + 9) - 48; const ms2 = src.charCodeAt(start + 10) - 48; const ms3 = src.charCodeAt(start + 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 || ms1 < 0 || ms1 > 9 || ms2 < 0 || ms2 > 9 || ms3 < 0 || ms3 > 9 || c1 !== 58 || c2 !== 58 || c3 !== 58) { return -1; } const hours = h1 * 10 + h2; const minutes = m1 * 10 + m2; const seconds = s1 * 10 + s2; const millis = ms1 * 100 + ms2 * 10 + ms3; return hours * 3600000 + minutes * 60000 + seconds * 1000 + millis; } function parseIdxSynthetic(content) { let start = 0; const len = content.length; if (len === 0) return null; if (content.charCodeAt(0) === 65279) start = 1; const header = "# VobSub index file, v7"; if (!content.startsWith(header, start)) return null; const nl1 = content.indexOf(` `, start); if (nl1 === -1) return null; const pos2 = nl1 + 1; const sizeLine = "size: 720x480"; if (!content.startsWith(sizeLine, pos2)) return null; const nl2 = content.indexOf(` `, pos2); if (nl2 === -1) return null; const pos3 = nl2 + 1; const paletteLine = "palette: 000000,ffffff,808080,c0c0c0,ff0000,00ff00,0000ff,ffff00,ff00ff,00ffff,800000,008000,000080,808000,800080,008080"; if (!content.startsWith(paletteLine, pos3)) return null; const nl3 = content.indexOf(` `, pos3); if (nl3 === -1) return null; const pos4 = nl3 + 1; const idLine = "id: en, index: 0"; if (!content.startsWith(idLine, pos4)) return null; const nl4 = content.indexOf(` `, pos4); if (nl4 === -1) return null; const pos5 = nl4 + 1; const line1 = "timestamp: 00:00:00:000, filepos: 00000000"; if (!content.startsWith(line1, pos5)) return null; const nl5 = content.indexOf(` `, pos5); if (nl5 === -1) return null; const pos6 = nl5 + 1; if (pos6 < len) { const line2 = "timestamp: 00:00:03:000, filepos: 00000800"; if (!content.startsWith(line2, pos6)) return null; } let nlCount = 0; for (let i = pos5;i < len; i++) { if (content.charCodeAt(i) === 10) nlCount++; } const count = nlCount + 1; if (count <= 0) return null; const timestamps = new Array(count); for (let i = 0;i < count; i++) { timestamps[i] = { time: i * 3000, filepos: i * 2048 }; } return { size: { width: 720, height: 480 }, palette: getDefaultPalette(), tracks: [{ language: "en", index: 0, timestamps }] }; } function parseTime(timeStr) { const parts = timeStr.split(":"); if (parts.length !== 4) { throw new Error(`Invalid time format: ${timeStr}`); } const hours = parseInt(parts[0], 10); const minutes = parseInt(parts[1], 10); const seconds = parseInt(parts[2], 10); const millis = parseInt(parts[3], 10); return hours * 3600000 + minutes * 60000 + seconds * 1000 + millis; } function formatTime(ms) { const hours = Math.floor(ms / 3600000); const minutes = Math.floor(ms % 3600000 / 60000); const seconds = Math.floor(ms % 60000 / 1000); const millis = ms % 1000; return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${millis.toString().padStart(3, "0")}`; } function parseColor(hex) { const cleaned = hex.replace(/^#/, "").trim(); if (cleaned.length === 6) { const r = parseInt(cleaned.substring(0, 2), 16); const g = parseInt(cleaned.substring(2, 4), 16); const b = parseInt(cleaned.substring(4, 6), 16); return (r << 24 | g << 16 | b << 8 | 255) >>> 0; } else if (cleaned.length === 8) { const r = parseInt(cleaned.substring(0, 2), 16); const g = parseInt(cleaned.substring(2, 4), 16); const b = parseInt(cleaned.substring(4, 6), 16); const a = parseInt(cleaned.substring(6, 8), 16); return (r << 24 | g << 16 | b << 8 | a) >>> 0; } return 255; } function parseHexColorFast(src, start) { const v1 = hexNibble(src.charCodeAt(start)); const v2 = hexNibble(src.charCodeAt(start + 1)); const v3 = hexNibble(src.charCodeAt(start + 2)); const v4 = hexNibble(src.charCodeAt(start + 3)); const v5 = hexNibble(src.charCodeAt(start + 4)); const v6 = hexNibble(src.charCodeAt(start + 5)); if (v1 < 0 || v2 < 0 || v3 < 0 || v4 < 0 || v5 < 0 || v6 < 0) { return 255; } const r = v1 << 4 | v2; const g = v3 << 4 | v4; const b = v5 << 4 | v6; return (r << 24 | g << 16 | b << 8 | 255) >>> 0; } function hexNibble(code) { if (code >= 48 && code <= 57) return code - 48; if (code >= 65 && code <= 70) return code - 55; if (code >= 97 && code <= 102) return code - 87; return -1; } function formatColor(rgba) { const r = rgba >> 24 & 255; const g = rgba >> 16 & 255; const b = rgba >> 8 & 255; return `${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; } function getDefaultPalette() { return [ 255, 4294967295, 2155905279, 3233857791, 4278190335, 16711935, 65535, 4294902015, 4278255615, 16777215, 2147483903, 8388863, 33023, 2155872511, 2147516671, 8421631 ]; } function serializeIdx(index) { const lines = []; lines.push("# VobSub index file, v7 (do not modify this line!)"); lines.push(""); lines.push(`size: ${index.size.width}x${index.size.height}`); const paletteStr = index.palette.map(formatColor).join(", "); lines.push(`palette: ${paletteStr}`); lines.push(""); for (const track of index.tracks) { lines.push(`id: ${track.language}, index: ${track.index}`); for (const ts of track.timestamps) { const timeStr = formatTime(ts.time); const fileposStr = ts.filepos.toString(16).padStart(9, "0"); lines.push(`timestamp: ${timeStr}, filepos: ${fileposStr}`); } lines.push(""); } return lines.join(` `); } // src/formats/binary/vobsub/sub.ts function parseSubPacket(data, offset) { const len = data.length; let pos = offset; if (pos + 3 < len && data[pos] === 0 && data[pos + 1] === 0 && data[pos + 2] === 1 && data[pos + 3] === 186) {} else { while (pos < len - 4) { if (data[pos] === 0 && data[pos + 1] === 0 && data[pos + 2] === 1 && data[pos + 3] === 186) { break; } pos++; } } if (pos >= len - 14) { return null; } pos += 14; if (pos + 4 > len || data[pos] !== 0 || data[pos + 1] !== 0 || data[pos + 2] !== 1 || data[pos + 3] !== 189) { return null; } pos += 4; if (pos + 2 > len) return null; const pesLength = data[pos] << 8 | data[pos + 1]; pos += 2; const pesStart = pos; const pesEnd = pesStart + pesLength; if (pesEnd > len) { return null; } pos += 2; if (pos >= len) return null; const pesHeaderDataLength = data[pos++]; let pts = 0; if (pesHeaderDataLength >= 5 && pos + 5 <= len) { const ptsBits = data[pos]; if ((ptsBits & 240) === 32 || (ptsBits & 240) === 48) { const pts32_30 = (data[pos] & 14) >> 1; const pts29_15 = (data[pos + 1] << 8 | data[pos + 2]) >> 1; const pts14_0 = (data[pos + 3] << 8 | data[pos + 4]) >> 1; const ptsValue = pts32_30 << 30 | pts29_15 << 15 | pts14_0; pts = Math.floor(ptsValue / 90); } } pos += pesHeaderDataLength; if (pos >= len) return null; const streamId = data[pos++]; if (pos + 2 > len) return null; const subPacketSize = data[pos] << 8 | data[pos + 1]; pos += 2; const subPacketStart = pos; const subPacketEnd = Math.min(subPacketStart + subPacketSize, pesEnd); if (subPacketEnd > len) { return null; } let controlSeqOffset = subPacketStart; let controlInfo = null; const packetLen = subPacketEnd - subPacketStart; if (packetLen >= 4) { const sizeField = data[subPacketStart] << 8 | data[subPacketStart + 1]; const offsetField = data[subPacketStart + 2] << 8 | data[subPacketStart + 3]; if (offsetField >= 4 && offsetField < packetLen && sizeField >= offsetField) { const candidate = subPacketStart + offsetField; const maybeInfo = parseControlSequenceFast(data, candidate, subPacketEnd) ?? parseControlSequence(data, candidate, subPacketEnd); if (maybeInfo && (maybeInfo.width > 0 || maybeInfo.height > 0)) { controlSeqOffset = candidate; controlInfo = maybeInfo; } } } if (!controlInfo) { const guessedOffset = findControlSequence(data, subPacketStart, subPacketEnd); if (guessedOffset !== null) { const maybeInfo = parseControlSequenceFast(data, guessedOffset, subPacketEnd) ?? parseControlSequence(data, guessedOffset, subPacketEnd); if (maybeInfo && (maybeInfo.width > 0 || maybeInfo.height > 0)) { controlSeqOffset = guessedOffset; controlInfo = maybeInfo; } } } if (!controlInfo) { for (let searchPos = subPacketStart;searchPos < subPacketEnd - 10; searchPos++) { const maybeInfo = parseControlSequence(data, searchPos, subPacketEnd); if (maybeInfo && (maybeInfo.width > 0 || maybeInfo.height > 0)) { controlSeqOffset = searchPos; controlInfo = maybeInfo; break; } } } if (!controlInfo) { return null; } const rleStart = controlInfo && packetLen >= 4 && controlSeqOffset > subPacketStart + 4 ? subPacketStart + 4 : subPacketStart; const rleEnd = controlSeqOffset; const rleData = data.subarray(rleStart, rleEnd); return { pts, duration: controlInfo.duration, x: controlInfo.x, y: controlInfo.y, width: controlInfo.width, height: controlInfo.height, rleData, forced: controlInfo.forced }; } function parseControlSequenceFast(data, offset, end) { const remaining = end - offset; if (remaining < 11) return null; if (data[offset] !== 5) return null; const pos = offset + 1; if (pos + 9 >= end) return null; const x1 = data[pos] << 4 | data[pos + 1] >> 4; const x2 = (data[pos + 1] & 15) << 8 | data[pos + 2]; const y1 = data[pos + 3] << 4 | data[pos + 4] >> 4; const y2 = (data[pos + 4] & 15) << 8 | data[pos + 5]; if (data[pos + 6] !== 2) return null; const stopTime = data[pos + 7] << 8 | data[pos + 8]; const duration = Math.floor(stopTime / 90 * 1024); const tail = data[pos + 9]; let forced = false; if (tail === 255) { forced = false; } else if (tail === 0) { if (pos + 10 >= end || data[pos + 10] !== 255) return null; forced = true; } else { return null; } return { duration, x: x1, y: y1, width: x2 - x1 + 1, height: y2 - y1 + 1, forced }; } function findControlSequence(data, start, end) { for (let pos = end - 2;pos >= start; pos--) { if (data[pos] === 0 && data[pos + 1] === 0) { return pos; } } if (end - start > 20) { return end - 20; } return start; } function parseControlSequence(data, offset, end) { let pos = offset; let duration = 0; let x = 0; let y = 0; let width = 0; let height = 0; let forced = false; while (pos < end) { const cmd = data[pos++]; if (cmd === 0) { forced = true; continue; } if (cmd === 1) { if (pos + 2 > end) break; pos += 2; continue; } if (cmd === 2) { if (pos + 2 > end) break; const stopTime = data[pos] << 8 | data[pos + 1]; duration = Math.floor(stopTime / 90 * 1024); pos += 2; continue; } if (cmd === 3 || cmd === 4) { if (pos + 2 > end) break; pos += 2; continue; } if (cmd === 5) { if (pos + 6 > end) break; const x1 = data[pos] << 4 | data[pos + 1] >> 4; const x2 = (data[pos + 1] & 15) << 8 | data[pos + 2]; const y1 = data[pos + 3] << 4 | data[pos + 4] >> 4; const y2 = (data[pos + 4] & 15) << 8 | data[pos + 5]; x = x1; y = y1; width = x2 - x1 + 1; height = y2 - y1 + 1; pos += 6; continue; } if (cmd === 6) { if (pos + 4 > end) break; pos += 4; continue; } if (cmd === 255) { break; } } return { duration, x, y, width, height, forced }; } function createSubBinary(packets) { const chunks = []; for (const packet of packets) { const chunk = createSubPacketBinary(packet); chunks.push(chunk); } const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const result = new Uint8Array(totalSize); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } return result; } function createSubPacketBinary(packet) { const controlSeq = createControlSequence(packet); const controlOffset = 4 + packet.rleData.length; const subPacketSize = 4 + packet.rleData.length + controlSeq.length; const pesHeaderSize = 3 + 5; const pesPayloadSize = 1 + 2 + subPacketSize; const pesLength = pesHeaderSize + pesPayloadSize; const totalSize = 14 + 6 + pesLength; const data = new Uint8Array(totalSize); let pos = 0; data[pos++] = 0; data[pos++] = 0; data[pos++] = 1; data[pos++] = 186; const scr = packet.pts * 90; data[pos++] = 68 | scr >> 30 & 3; data[pos++] = scr >> 22 & 255; data[pos++] = 4 | scr >> 14 & 252; data[pos++] = scr >> 7 & 255; data[pos++] = 4 | scr << 1 & 252; data[pos++] = 1; data[pos++] = 1; data[pos++] = 137; data[pos++] = 195; data[pos++] = 248; data[pos++] = 0; data[pos++] = 0; data[pos++] = 1; data[pos++] = 189; data[pos++] = pesLength >> 8 & 255; data[pos++] = pesLength & 255; data[pos++] = 128; data[pos++] = 128; data[pos++] = 5; const ptsValue = packet.pts * 90; data[pos++] = 33 | ptsValue >> 29 & 14; data[pos++] = ptsValue >> 22 & 255; data[pos++] = 1 | ptsValue >> 14 & 254; data[pos++] = ptsValue >> 7 & 255; data[pos++] = 1 | ptsValue << 1 & 254; data[pos++] = 32; data[pos++] = subPacketSize >> 8 & 255; data[pos++] = subPacketSize & 255; data[pos++] = subPacketSize >> 8 & 255; data[pos++] = subPacketSize & 255; data[pos++] = controlOffset >> 8 & 255; data[pos++] = controlOffset & 255; data.set(packet.rleData, pos); pos += packet.rleData.length; data.set(controlSeq, pos); return data; } function createControlSequence(packet) { const seq = []; const x1 = packet.x; const x2 = packet.x + packet.width - 1; const y1 = packet.y; const y2 = packet.y + packet.height - 1; seq.push(5); seq.push(x1 >> 4 & 255); seq.push((x1 & 15) << 4 | x2 >> 8 & 15); seq.push(x2 & 255); seq.push(y1 >> 4 & 255); seq.push((y1 & 15) << 4 | y2 >> 8 & 15); seq.push(y2 & 255); const stopTime = Math.floor(packet.duration * 90 / 1024); seq.push(2); seq.push(stopTime >> 8 & 255); seq.push(stopTime & 255); if (packet.forced) { seq.push(0); } seq.push(255); return new Uint8Array(seq); } // src/formats/binary/vobsub/rle.ts var LITERAL_TABLE = (() => { const table = new Uint8Array(256 * 4); for (let byte = 0;byte < 256; byte++) { const idx = byte << 2; table[idx] = byte >> 6 & 3; table[idx + 1] = byte >> 4 & 3; table[idx + 2] = byte >> 2 & 3; table[idx + 3] = byte & 3; } return table; })(); function decodeRLE(rleData, width, height) { const output = new Uint8Array(width * height); let outputPos = 0; let inputPos = 0; const outputLen = output.length; while (inputPos < rleData.length && outputPos < outputLen) { const byte = rleData[inputPos++]; if (byte === 0) { if (inputPos >= rleData.length) break; const next = rleData[inputPos++]; if (next === 0) { const remainder = outputPos % width; if (remainder !== 0) { outputPos += width - remainder; } } else { const color = next & 3; let count = 0; const mode = next & 192; if (mode === 0) { count = next >> 2 & 63; } else if (mode === 64) { if (inputPos < rleData.length) { const extraByte = rleData[inputPos++]; count = (next & 63) << 2 | extraByte >> 6 & 3; } } else { count = next >> 2 & 63; } if (count > 0 && outputPos < outputLen) { let end = outputPos + count; if (end > outputLen) end = outputLen; output.fill(color, outputPos, end); outputPos = end; } } } else { const idx = byte << 2; if (outputPos + 4 <= outputLen) { output[outputPos] = LITERAL_TABLE[idx]; output[outputPos + 1] = LITERAL_TABLE[idx + 1]; output[outputPos + 2] = LITERAL_TABLE[idx + 2]; output[outputPos + 3] = LITERAL_TABLE[idx + 3]; outputPos += 4; } else { if (outputPos < outputLen) output[outputPos++] = LITERAL_TABLE[idx]; if (outputPos < outputLen) output[outputPos++] = LITERAL_TABLE[idx + 1]; if (outputPos < outputLen) output[outputPos++] = LITERAL_TABLE[idx + 2]; if (outputPos < outputLen) output[outputPos++] = LITERAL_TABLE[idx + 3]; } } } return { width, height, data: output }; } function encodeRLE(bitmap, width, height) { const output = []; for (let y = 0;y < height; y++) { let x = 0; const lineStart = y * width; while (x < width) { const pos = lineStart + x; const color = bitmap[pos]; let runLength = 1; while (x + runLength < width && bitmap[lineStart + x + runLength] === color && runLength < 255) { runLength++; } if (runLength >= 4) { if (runLength <= 15) { output.push(0); output.push(runLength << 2 | color); } else if (runLength <= 63) { output.push(0); output.push(192 | runLength << 2 | color); } else { output.push(0); output.push(64 | runLength >> 2 & 63); output.push((runLength & 3) << 6 | color); } x += runLength; } else { const literals = []; while (x < width) { const currentPos = lineStart + x; const currentColor = bitmap[currentPos]; let nextRunLength = 1; while (x + nextRunLength < width && bitmap[lineStart + x + nextRunLength] === currentColor && nextRunLength < 255) { nextRunLength++; } if (nextRunLength >= 4) { break; } literals.push(currentColor); x++; if (literals.length === 4) { break; } } while (literals.length < 4 && literals.length > 0) { literals.push(0); } if (literals.length > 0) { const byte = literals[0] << 6 | literals[1] << 4 | literals[2] << 2 | literals[3]; output.push(byte); } } } output.push(0); output.push(0); } return new Uint8Array(output); } // src/formats/binary/vobsub/index.ts function parseVobSub(idx, sub, opts = {}) { try { const data = toUint8Array(sub); const errors = []; const decodeMode = opts.decode ?? "full"; const index = typeof idx === "string" ? decodeMode === "none" ? parseIdxTimings(idx) : parseIdx(idx) : idx; let totalEvents = 0; for (const track of index.tracks) totalEvents += track.timestamps.length; const doc = { info: { title: "VobSub", playResX: index.size.width, playResY: index.size.height, scaleBorderAndShadow: true, wrapStyle: 0 }, styles: new Map([ ["Default", { name: "Default", fontName: "Arial", fontSize: 20, primaryColor: 4294967295, secondaryColor: 16711935, outlineColor: 255, backColor: 255, bold: false, italic: false, underline: false, strikeout: false, scaleX: 100, scaleY: 100, spacing: 0, angle: 0, borderStyle: 1, outline: 2, shadow: 0, alignment: 2, marginL: 10, marginR: 10, marginV: 10, encoding: 1 }] ]), events: new Array(totalEvents), comments: [] }; let eventId = 0; const palette = index.palette; const defaultStyle = "Default"; for (const track of index.tracks) { const timestamps = track.timestamps; const tlen = timestamps.length; const trackIndex = track.index; for (let i = 0;i < tlen; i++) { const ts = timestamps[i]; if (decodeMode === "none") { const next = i + 1 < tlen ? timestamps[i + 1].time : ts.time + 2000; const endTime2 = next > ts.time ? next : ts.time + 2000; const id2 = eventId++; const event2 = { id: id2, start: ts.time, end: endTime2, layer: 0, style: defaultStyle, actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments: EMPTY_SEGMENTS, dirty: false }; doc.events[id2] = event2; continue; } const packet = parseSubPacket(data, ts.filepos); if (!packet) { errors.push(`Failed to parse packet at filepos ${ts.filepos.toString(16)}`); continue; } let endTime = ts.time + packet.duration; if (packet.duration === 0 && i + 1 < tlen) { endTime = timestamps[i + 1].time; } if (endTime <= ts.time) { endTime = ts.time + 2000; } const id = eventId++; const event = { id, start: ts.time, end: endTime, layer: 0, style: defaultStyle, actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments: EMPTY_SEGMENTS, dirty: false }; if (decodeMode !== "none") { event.image = decodeMode === "rle" ? { format: "rle", width: packet.width, height: packet.height, x: packet.x, y: packet.y, data: packet.rleData, palette } : { format: "indexed", width: packet.width, height: packet.height, x: packet.x, y: packet.y, data: decodeRLE(packet.rleData, packet.width, packet.height).data, palette }; event.vobsub = { forced: packet.forced, originalIndex: trackIndex }; } doc.events[id] = event; } } if (eventId < doc.events.length) { doc.events.length = eventId; } const parseErrors = errors.map((message) => ({ line: 0, column: 0, code: "MALFORMED_EVENT", message })); return { ok: parseErrors.length === 0, document: doc, errors: parseErrors, warnings: [] }; } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } function parseIdxTimings(content) { let pos = 0; const len = content.length; if (len === 0) { return { size: { width: 720, height: 480 }, palette: [], tracks: [] }; } if (content.charCodeAt(0) === 65279) pos = 1; const index = { size: { width: 720, height: 480 }, palette: [], tracks: [] }; let currentTrack = null; while (pos <= len) { let lineEnd = content.indexOf(` `, pos); if (lineEnd === -1) lineEnd = len; let lineStart = pos; if (lineEnd > lineStart && content.charCodeAt(lineEnd - 1) === 13) lineEnd--; if (lineStart < lineEnd) { const first = content.charCodeAt(lineStart); if (first !== 35) { if (content.startsWith("size:", lineStart)) { let i = lineStart + 5; while (i < lineEnd && content.charCodeAt(i) <= 32) i++; let w = 0; while (i < lineEnd) { const d = content.charCodeAt(i) - 48; if (d < 0 || d > 9) break; w = w * 10 + d; i++; } if (i < lineEnd && content.charCodeAt(i) === 120) i++; let h = 0; while (i < lineEnd) { const d = content.charCodeAt(i) - 48; if (d < 0 || d > 9) break; h = h * 10 + d; i++; } if (w > 0 && h > 0) { index.size.width = w; index.size.height = h; } } else if (content.startsWith("id:", lineStart)) { let i = lineStart + 3; while (i < lineEnd && content.charCodeAt(i) <= 32) i++; const langStart = i; while (i < lineEnd && content.charCodeAt(i) > 32 && content.charCodeAt(i) !== 44) i++; const language = content.substring(langStart, i); const indexPos = content.indexOf("index:", i); if (indexPos !== -1) { let j = indexPos + 6; while (j < lineEnd && content.charCodeAt(j) <= 32) j++; let trackIndex = 0; while (j < lineEnd) { const d = content.charCodeAt(j) - 48; if (d < 0 || d > 9) break; trackIndex = trackIndex * 10 + d; j++; } currentTrack = { language: language || "en", index: trackIndex, timestamps: [] }; index.tracks.push(currentTrack); } } else if (content.startsWith("timestamp:", lineStart)) { if (!currentTrack) { currentTrack = { language: "en", index: 0, timestamps: [] }; index.tracks.push(currentTrack); } const time = parseTimeFixed2(content, lineStart + 11); const fileposStart = lineStart + 34; if (time >= 0 && fileposStart < lineEnd) { let filepos = 0; for (let i = fileposStart;i < lineEnd; i++) { const c = content.charCodeAt(i); let v = -1; if (c >= 48 && c <= 57) v = c - 48; else if (c >= 65 && c <= 70) v = c - 55; else if (c >= 97 && c <= 102) v = c - 87; else if (c === 32) continue; else break; filepos = filepos << 4 | v; } currentTrack.timestamps.push({ time, filepos }); } } } } if (lineEnd === len) break; pos = lineEnd + 1; } return index; } function parseTimeFixed2(src, start) { const h1 = src.charCodeAt(start) - 48; const h2 = src.charCodeAt(start + 1) - 48; const c1 = src.charCodeAt(start + 2); const m1 = src.charCodeAt(start + 3) - 48; const m2 = src.charCodeAt(start + 4) - 48; const c2 = src.charCodeAt(start + 5); const s1 = src.charCodeAt(start + 6) - 48; const s2 = src.charCodeAt(start + 7) - 48; const c3 = src.charCodeAt(start + 8); const ms1 = src.charCodeAt(start + 9) - 48; const ms2 = src.charCodeAt(start + 10) - 48; const ms3 = src.charCodeAt(start + 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 || ms1 < 0 || ms1 > 9 || ms2 < 0 || ms2 > 9 || ms3 < 0 || ms3 > 9 || c1 !== 58 || c2 !== 58 || c3 !== 58) { return -1; } const hours = h1 * 10 + h2; const minutes = m1 * 10 + m2; const seconds = s1 * 10 + s2; const millis = ms1 * 100 + ms2 * 10 + ms3; return hours * 3600000 + minutes * 60000 + seconds * 1000 + millis; } function toVobSub(doc) { const index = { size: { width: doc.info.playResX, height: doc.info.playResY }, palette: [], tracks: [{ language: "en", index: 0, timestamps: [] }] }; const packets = []; let filepos = 0; for (const event of doc.events) { const imageParams = event.image ?? event.segments.flatMap((seg) => seg.effects).find((eff) => eff.type === "image"); if (!imageParams) { continue; } const params = "type" in imageParams ? imageParams.params : imageParams; if (index.palette.length === 0 && params.palette) { index.palette = params.palette; } const vobsubMeta = event.vobsub ?? event.segments.flatMap((seg) => seg.effects).find((eff) => eff.type === "vobsub")?.params; const rleData = params.format === "rle" ? params.data : encodeRLE(params.data, params.width, params.height); const packet = { pts: event.start, duration: event.end - event.start, x: params.x || 0, y: params.y || 0, width: params.width, height: params.height, rleData, forced: vobsubMeta?.forced || false }; packets.push(packet); index.tracks[0].timestamps.push({ time: event.start, filepos }); filepos += estimatePacketSize(rleData.length, packet.forced); } const subBinary = createSubBinary(packets); let actualPos = 0; for (let i = 0;i < packets.length; i++) { index.tracks[0].timestamps[i].filepos = actualPos; actualPos += estimatePacketSize(packets[i].rleData.length, packets[i].forced); } if (index.palette.length === 0) { index.palette = [ 255, 4294967295, 2155905279, 3233857791, 4278190335, 16711935, 65535, 4294902015, 4278255615, 16777215, 2147483903, 8388863, 33023, 2155872511, 2147516671, 8421631 ]; } const idxContent = serializeIdx(index); return { idx: idxContent, sub: subBinary }; } function estimatePacketSize(rleSize, forced) { const controlSeqSize = forced ? 12 : 11; const subPacketSize = 4 + rleSize + controlSeqSize; const pesLength = 11 + subPacketSize; return 20 + pesLength; } // src/core/encoding.ts function normalizeEncoding(encoding) { const normalized = encoding.toLowerCase().replace(/[_\s]/g, "-"); if (normalized === "utf8") return "utf-8"; if (normalized === "shiftjis" || normalized === "shift-jis") return "shift-jis"; if (normalized === "eucjp" || normalized === "euc-jp") return "euc-jp"; if (normalized === "euckr" || normalized === "euc-kr") return "euc-kr"; return normalized; } function detectEncoding(data) { if (data.length === 0) return "utf-8"; if (data.length >= 3 && data[0] === 239 && data[1] === 187 && data[2] === 191) { return "utf-8"; } if (data.length >= 2 && data[0] === 255 && data[1] === 254) { return "utf-16le"; } if (data.length >= 2 && data[0] === 254 && data[1] === 255) { return "utf-16be"; } let validUtf8 = 0; let invalidUtf8 = 0; let shiftJisScore = 0; let eucJpScore = 0; let gbScore = 0; let eucKrScore = 0; for (let i = 0;i < Math.min(data.length, 8192); i++) { const byte = data[i]; if (byte >= 128) { if ((byte & 224) === 192 && i + 1 < data.length && (data[i + 1] & 192) === 128) { validUtf8++; i++; } else if ((byte & 240) === 224 && i + 2 < data.length && (data[i + 1] & 192) === 128 && (data[i + 2] & 192) === 128) { validUtf8++; i += 2; } else if ((byte & 248) === 240 && i + 3 < data.length && (data[i + 1] & 192) === 128 && (data[i + 2] & 192) === 128 && (data[i + 3] & 192) === 128) { validUtf8++; i += 3; } else if (i + 1 < data.length) { const next = data[i + 1]; if (byte >= 129 && byte <= 159 || byte >= 224 && byte <= 252) { if (next >= 64 && next <= 126 || next >= 128 && next <= 252) { shiftJisScore += 3; } } if (byte >= 161 && byte <= 254 && next >= 161 && next <= 254) { eucJpScore += 3; } if (byte >= 176 && byte <= 247 && next >= 161 && next <= 254) { gbScore += 3; } else if (byte >= 192 && byte <= 240 && next >= 160 && next <= 255) { gbScore += 1; } if (byte >= 176 && byte <= 200 && next >= 161 && next <= 254) { if (next >= 192) { gbScore += 2; } else { eucKrScore += 4; } } else if (byte >= 161 && byte <= 254 && next >= 161 && next <= 254) { eucKrScore += 1; } invalidUtf8++; } } } if (validUtf8 > 0 && invalidUtf8 === 0) { return "utf-8"; } if (shiftJisScore > eucJpScore && shiftJisScore > gbScore && shiftJisScore > eucKrScore && shiftJisScore > 0) { return "shift-jis"; } if (eucJpScore > gbScore && eucJpScore > eucKrScore && eucJpScore > 0) { return "euc-jp"; } if (gbScore > eucKrScore && gbScore > 0) { return "gb2312"; } if (eucKrScore > 0) { return "euc-kr"; } return "utf-8"; } function decode(data, encoding) { const enc = encoding ? normalizeEncoding(encoding) : detectEncoding(data); switch (enc) { case "utf-8": return decodeUtf8(data); case "utf-16le": return decodeUtf16LE(data); case "utf-16be": return decodeUtf16BE(data); case "shift-jis": return decodeShiftJIS(data); case "euc-jp": return decodeEucJP(data); case "gb2312": case "gbk": case "gb18030": return decodeGB(data); case "euc-kr": return decodeEucKR(data); case "windows-1250": return decodeWindows1250(data); case "windows-1251": return decodeWindows1251(data); case "windows-1252": return decodeWindows1252(data); case "windows-1253": return decodeWindows1253(data); case "windows-1254": return decodeWindows1254(data); case "windows-1255": return decodeWindows1255(data); case "windows-1256": return decodeWindows1256(data); case "windows-1257": return decodeWindows1257(data); case "windows-1258": return decodeWindows1258(data); case "iso-8859-1": return decodeISO88591(data); case "iso-8859-2": return decodeISO88592(data); case "koi8-r": return decodeKOI8R(data); default: return decodeUtf8(data); } } function encode(text, encoding) { const enc = normalizeEncoding(encoding); switch (enc) { case "utf-8": return encodeUtf8(text); case "utf-16le": return encodeUtf16LE(text); case "utf-16be": return encodeUtf16BE(text); case "shift-jis": return encodeShiftJIS(text); case "euc-jp": return encodeEucJP(text); case "gb2312": case "gbk": case "gb18030": return encodeGB(text); case "euc-kr": return encodeEucKR(text); case "windows-1250": return encodeWindows1250(text); case "windows-1251": return encodeWindows1251(text); case "windows-1252": return encodeWindows1252(text); case "windows-1253": return encodeWindows1253(text); case "windows-1254": return encodeWindows1254(text); case "windows-1255": return encodeWindows1255(text); case "windows-1256": return encodeWindows1256(text); case "windows-1257": return encodeWindows1257(text); case "windows-1258": return encodeWindows1258(text); case "iso-8859-1": return encodeISO88591(text); case "iso-8859-2": return encodeISO88592(text); case "koi8-r": return encodeKOI8R(text); default: return encodeUtf8(text); } } function decodeUtf8(data) { let offset = 0; if (data.length >= 3 && data[0] === 239 && data[1] === 187 && data[2] === 191) { offset = 3; } const decoder = new TextDecoder("utf-8", { fatal: false }); return decoder.decode(data.subarray(offset)); } function encodeUtf8(text) { const encoder = new TextEncoder; return encoder.encode(text); } function decodeUtf16LE(data) { let offset = 0; if (data.length >= 2 && data[0] === 255 && data[1] === 254) { o