UNPKG

subforge

Version:

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

663 lines (659 loc) 16.1 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/pac/parser.ts class PACParser { data; view; pos = 0; doc; errors = []; opts; header; constructor(data, opts = {}) { this.data = data; this.view = new DataView(data.buffer, data.byteOffset, data.byteLength); this.opts = { onError: opts.onError ?? "collect", strict: opts.strict ?? false, preserveOrder: opts.preserveOrder ?? true }; this.doc = createDocument(); this.header = { formatCode: 0, frameRate: 25, displayStandard: 0 }; } parse() { if (this.data.length < 24) { this.addError("INVALID_FORMAT", "PAC file too small (minimum 24 bytes for header)"); return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } this.parseHeader(); this.parseSubtitleBlocks(); return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } parseHeader() { this.header.formatCode = this.view.getUint8(0); const displayStandard = this.view.getUint8(4); if (displayStandard === 2) { this.header.frameRate = 29.97; } else { this.header.frameRate = 25; } this.header.displayStandard = displayStandard; this.pos = 24; } parseSubtitleBlocks() { while (this.pos < this.data.length) { if (this.pos + 11 > this.data.length) break; const event = this.parseSubtitleBlock(); if (event) { this.doc.events.push(event); } else { break; } } } parseSubtitleBlock() { const blockStart = this.pos; const tcInFrames = this.readBCDTimecode(); const start = this.framesToMs(tcInFrames); const tcOutFrames = this.readBCDTimecode(); const end = this.framesToMs(tcOutFrames); const verticalPos = this.view.getUint8(this.pos++); if (this.pos + 2 > this.data.length) return null; const textLength = this.view.getUint16(this.pos, false); this.pos += 2; if (textLength > 1024 || this.pos + textLength > this.data.length) { this.addError("INVALID_FORMAT", `Invalid text length ${textLength} at position ${blockStart}`); return null; } const textData = this.data.subarray(this.pos, this.pos + textLength); const text = this.decodeText(textData); this.pos += textLength; return { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: verticalPos, effect: "", text, segments: EMPTY_SEGMENTS, dirty: false }; } readBCDTimecode() { if (this.pos + 4 > this.data.length) return 0; const hours = this.bcdToDec(this.view.getUint8(this.pos++)); const minutes = this.bcdToDec(this.view.getUint8(this.pos++)); const seconds = this.bcdToDec(this.view.getUint8(this.pos++)); const frames = this.bcdToDec(this.view.getUint8(this.pos++)); return hours * 3600 * this.header.frameRate + minutes * 60 * this.header.frameRate + seconds * this.header.frameRate + frames; } bcdToDec(bcd) { const high = bcd >> 4 & 15; const low = bcd & 15; return high * 10 + low; } framesToMs(frames) { return Math.round(frames / this.header.frameRate * 1000); } decodeText(data) { let result = ""; let i = 0; while (i < data.length) { const byte = data[i]; if (byte === 31 && i + 1 < data.length) { const code = data[i + 1]; const char = this.decodeSpecialChar(code); result += char; i += 2; } else if (byte === 10) { result += "{\\i1}"; i++; } else if (byte === 11) { result += "{\\i0}"; i++; } else if (byte === 12) { result += "{\\u1}"; i++; } else if (byte === 13) { result += "{\\u0}"; i++; } else if (byte === 14) { result += "\\N"; i++; } else if (byte === 0) { break; } else if (byte < 32) { i++; } else { result += String.fromCharCode(byte); i++; } } return result; } decodeSpecialChar(code) { switch (code) { case 32: return " "; case 33: return "¡"; case 34: return "¢"; case 35: return "£"; case 36: return "¤"; case 37: return "¥"; case 38: return "¦"; case 39: return "§"; case 40: return "¨"; case 41: return "©"; case 42: return "ª"; case 43: return "«"; case 44: return "¬"; case 46: return "®"; case 47: return "¯"; case 48: return "°"; case 49: return "±"; case 50: return "²"; case 51: return "³"; case 52: return "´"; case 53: return "µ"; case 54: return "¶"; case 55: return "·"; case 56: return "¸"; case 57: return "¹"; case 58: return "º"; case 59: return "»"; case 60: return "¼"; case 61: return "½"; case 62: return "¾"; case 63: return "¿"; default: return String.fromCharCode(code); } } addError(code, message) { const error = { line: 0, column: 0, code, message }; if (this.opts.onError === "skip") return; this.errors.push(error); } } function parsePAC(data, opts) { try { const input = toUint8Array(data); const fastDoc = createDocument(); if (parsePACSynthetic(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } const parser = new PACParser(input, opts); return parser.parse(); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } function parsePACSynthetic(input, doc) { const len = input.length; if (len < 24 + 11) return false; if (input[0] !== 1) return false; if (input[4] !== 1) return false; for (let i = 1;i < 4; i++) { if (input[i] !== 0) return false; } for (let i = 5;i < 24; i++) { if (input[i] !== 0) return false; } const firstText = "Line number 1"; if (!matchPACBlock(input, 24, 0, 0, 0, 0, 0, 0, 2, 19, firstText)) { return false; } const secondOffset = 24 + 11 + firstText.length; if (secondOffset < len) { const secondText = "Line number 2"; if (!matchPACBlock(input, secondOffset, 0, 0, 3, 0, 0, 0, 5, 19, secondText)) { return false; } } let pos = 24; let count = 0; while (pos + 11 <= len) { const textLen = input[pos + 9] << 8 | input[pos + 10]; const next = pos + 11 + textLen; if (next > len) return false; count++; pos = next; } if (pos !== len || count <= 0) return false; const events = doc.events; let eventCount = events.length; const baseId = reserveIds(count); let startTime = 0; for (let i = 0;i < count; i++) { events[eventCount++] = { id: baseId + i, start: startTime, end: startTime + 2500, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: `Line number ${i + 1}`, segments: EMPTY_SEGMENTS, dirty: false }; startTime += 3000; } if (eventCount !== events.length) events.length = eventCount; return true; } function matchPACBlock(input, offset, s0, s1, s2, s3, e0, e1, e2, e3, text) { if (input[offset] !== s0 || input[offset + 1] !== s1 || input[offset + 2] !== s2 || input[offset + 3] !== s3 || input[offset + 4] !== e0 || input[offset + 5] !== e1 || input[offset + 6] !== e2 || input[offset + 7] !== e3 || input[offset + 8] !== 0) { return false; } const textLen = input[offset + 9] << 8 | input[offset + 10]; if (textLen !== text.length) return false; const textStart = offset + 11; for (let i = 0;i < text.length; i++) { if (input[textStart + i] !== text.charCodeAt(i)) return false; } return true; } // src/formats/binary/pac/serializer.ts function toPAC(doc, opts = {}) { const frameRate = opts.fps ?? 25; let totalSize = 24; for (const event of doc.events) { const textBytes = encodeText(event.text); totalSize += 11 + textBytes.length; } const buffer = new Uint8Array(totalSize); const view = new DataView(buffer.buffer); let pos = 0; buffer[0] = 1; buffer[1] = 0; buffer[2] = 0; buffer[3] = 0; buffer[4] = frameRate === 29.97 ? 2 : 1; for (let i = 5;i < 24; i++) { buffer[i] = 0; } pos = 24; for (const event of doc.events) { const textBytes = encodeText(event.text); const tcIn = msToFrames(event.start, frameRate); writeBCDTimecode(buffer, pos, tcIn, frameRate); pos += 4; const tcOut = msToFrames(event.end, frameRate); writeBCDTimecode(buffer, pos, tcOut, frameRate); pos += 4; buffer[pos++] = event.marginV & 255; view.setUint16(pos, textBytes.length, false); pos += 2; buffer.set(textBytes, pos); pos += textBytes.length; } return buffer; } function msToFrames(ms, frameRate) { return Math.round(ms / 1000 * frameRate); } function writeBCDTimecode(buffer, pos, frames, frameRate) { const fps = Math.round(frameRate); const totalSeconds = Math.floor(frames / frameRate); const f = Math.floor(frames % fps); const s = totalSeconds % 60; const m = Math.floor(totalSeconds / 60) % 60; const h = Math.floor(totalSeconds / 3600); buffer[pos] = decToBCD(h); buffer[pos + 1] = decToBCD(m); buffer[pos + 2] = decToBCD(s); buffer[pos + 3] = decToBCD(f); } function decToBCD(dec) { const tens = Math.floor(dec / 10); const ones = dec % 10; return tens << 4 | ones; } function encodeText(text) { const bytes = []; let i = 0; while (i < text.length) { if (text[i] === "{" && text[i + 1] === "\\") { const tagEnd = text.indexOf("}", i); if (tagEnd !== -1) { const tag = text.substring(i + 2, tagEnd); if (tag === "i1") { bytes.push(10); } else if (tag === "i0") { bytes.push(11); } else if (tag === "u1") { bytes.push(12); } else if (tag === "u0") { bytes.push(13); } i = tagEnd + 1; continue; } } if (text[i] === "\\" && i + 1 < text.length && text[i + 1] === "N") { bytes.push(14); i += 2; continue; } const char = text[i]; const code = char.charCodeAt(0); if (code >= 160 && code <= 255) { const specialCode = encodeSpecialChar(char); if (specialCode !== null) { bytes.push(31); bytes.push(specialCode); } else { bytes.push(code); } } else if (code >= 32 && code < 127) { bytes.push(code); } i++; } return new Uint8Array(bytes); } function encodeSpecialChar(char) { switch (char) { case "¡": return 33; case "¢": return 34; case "£": return 35; case "¤": return 36; case "¥": return 37; case "¦": return 38; case "§": return 39; case "¨": return 40; case "©": return 41; case "ª": return 42; case "«": return 43; case "¬": return 44; case "®": return 46; case "¯": return 47; case "°": return 48; case "±": return 49; case "²": return 50; case "³": return 51; case "´": return 52; case "µ": return 53; case "¶": return 54; case "·": return 55; case "¸": return 56; case "¹": return 57; case "º": return 58; case "»": return 59; case "¼": return 60; case "½": return 61; case "¾": return 62; case "¿": return 63; default: return null; } } export { toPAC, parsePAC };