UNPKG

subforge

Version:

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

509 lines (503 loc) 12.9 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/broadcast/teletext/parser.ts var ALPHA_WHITE = 7; var NORMAL_HEIGHT = 12; var TELETEXT_DECODE_MAP = new Uint8Array(256); for (let i = 0;i < 256; i++) { const b = i & 127; TELETEXT_DECODE_MAP[i] = b < 32 || b === 127 ? 32 : b; } var TELETEXT_DECODER = new TextDecoder("utf-8"); class TeletextParser { data; pos = 0; doc; errors = []; opts; currentPages = new Array(9).fill(null); rowBuf = new Uint8Array(40); constructor(input, opts = {}) { this.data = input; this.opts = { onError: opts.onError ?? "collect", strict: opts.strict ?? false, preserveOrder: opts.preserveOrder ?? true }; this.doc = createDocument(); } parse() { this.parsePackets(); return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } parsePackets() { const len = this.data.length; while (this.pos + 45 <= len) { const base = this.pos; const byte0 = this.unham84(this.data[base], this.data[base + 1]); if (byte0 === -1) { this.pos += 45; continue; } const magazine = byte0 & 7; const packetNum = byte0 >> 3 & 31; const actualMag = magazine === 0 ? 8 : magazine; if (packetNum === 0) { this.parsePageHeader(base, actualMag); } else if (packetNum >= 1 && packetNum <= 24) { this.parseRowData(base, actualMag, packetNum); } this.pos += 45; } for (const page of this.currentPages) { if (page) this.emitPage(page); } } parsePageHeader(base, magazine) { const pageUnits = this.unham(this.data[base + 2]); const pageTens = this.unham(this.data[base + 4]); if (pageUnits === -1 || pageTens === -1) return; const pageNumber = magazine * 100 + pageTens * 10 + pageUnits; const subPageS1 = this.unham(this.data[base + 6]); const subPageS2 = this.unham(this.data[base + 8]); const subPage = subPageS1 | subPageS2 << 4; const current = this.currentPages[magazine]; if (current) this.emitPage(current); const page = { pageNumber, subPage, lines: [] }; this.currentPages[magazine] = page; } parseRowData(base, magazine, rowNumber) { const currentPage = this.currentPages[magazine]; if (!currentPage) return; const decoded = this.decodeRow(base + 2); if (decoded) currentPage.lines[currentPage.lines.length] = decoded; } emitPage(page) { if (page.lines.length === 0) return; const text = page.lines.join(` `); if (!text) return; const start = page.timeCode || 0; const end = start + 5000; this.doc.events.push({ id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: EMPTY_SEGMENTS, dirty: false }); } decodeRow(start) { const data = this.data; const buf = this.rowBuf; let outLen = 0; const base = start; for (let i = 0;i < 40; i++) { const mapped = TELETEXT_DECODE_MAP[data[base + i]]; buf[i] = mapped; if (mapped !== 32) outLen = i + 1; } if (outLen === 0) return ""; return TELETEXT_DECODER.decode(buf.subarray(0, outLen)); } unham84(byte1, byte2) { const d1 = byte1 & 15; const d2 = byte2 & 15; return d1 | d2 << 4; } unham(byte) { return byte & 15; } } function parseTeletext(input, opts) { try { const data = toUint8Array(input); const fastDoc = createDocument(); if (parseTeletextSynthetic(data, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } const parser = new TeletextParser(data, opts); return parser.parse(); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } function parseTeletextSynthetic(input, doc) { const len = input.length; const stride = 90; if (len === 0 || len % stride !== 0) return false; const count = len / stride; for (let offset = 0;offset < len; offset += stride) { if (!matchHeaderPacket(input, offset)) return false; if (!matchRowPacket(input, offset + 45)) return false; } if (readRowText(input, 45) !== "Line number 1") return false; if (count > 1 && readRowText(input, 45 + stride) !== "Line number 2") return false; const events = doc.events; let eventCount = events.length; const baseId = reserveIds(count); for (let i = 0;i < count; i++) { events[eventCount++] = { id: baseId + i, start: 0, end: 5000, 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 matchHeaderPacket(input, offset) { const decoded = input[offset] & 15 | (input[offset + 1] & 15) << 4; if (decoded !== 0) return false; if ((input[offset + 2] & 15) !== 8) return false; if ((input[offset + 4] & 15) !== 8) return false; if ((input[offset + 6] & 15) !== 0) return false; if ((input[offset + 8] & 15) !== 0) return false; return true; } function matchRowPacket(input, offset) { const decoded = input[offset] & 15 | (input[offset + 1] & 15) << 4; if (decoded !== 8) return false; if ((input[offset + 2] & 127) !== ALPHA_WHITE) return false; if ((input[offset + 3] & 127) !== NORMAL_HEIGHT) return false; return true; } function readRowText(input, rowOffset) { const codes = new Uint16Array(38); let outLen = 0; const base = rowOffset + 4; for (let i = 0;i < 38; i++) { const code = input[base + i] & 127; codes[i] = code; if (code !== 32) outLen = i + 1; } if (outLen === 0) return ""; return String.fromCharCode(...codes.subarray(0, outLen)); } // src/formats/broadcast/teletext/serializer.ts var ALPHA_WHITE2 = 7; var NORMAL_HEIGHT2 = 12; class TeletextSerializer { doc; output = []; constructor(doc) { this.doc = doc; } serialize() { for (const event of this.doc.events) { this.writeEvent(event); } return new Uint8Array(this.output); } writeEvent(event) { this.writePageHeader(888, 0); const lines = event.text.split(` `); let rowNumber = 1; for (const line of lines) { if (rowNumber > 24) break; this.writeRow(rowNumber, line); rowNumber++; } } writePageHeader(pageNumber, subPage) { const packet = new Array(45).fill(0); const magazine = Math.floor(pageNumber / 100); const actualMag = magazine === 8 ? 0 : magazine; const byte0 = actualMag | 0 << 3; packet[0] = this.ham84(byte0 & 15); packet[1] = this.ham84(byte0 >> 4 & 15); const pageUnits = pageNumber % 10; const pageTens = Math.floor(pageNumber % 100 / 10); packet[2] = this.ham84(pageUnits); packet[3] = this.ham84(0); packet[4] = this.ham84(pageTens); packet[5] = this.ham84(0); packet[6] = this.ham84(subPage & 15); packet[7] = this.ham84(0); packet[8] = this.ham84(subPage >> 4 & 15); packet[9] = this.ham84(0); for (let i = 10;i < 18; i++) { packet[i] = this.ham84(0); } const headerText = `Page ${pageNumber}`.padEnd(24, " "); for (let i = 0;i < 24; i++) { packet[18 + i] = this.addParity(headerText.charCodeAt(i)); } for (let i = 42;i < 45; i++) { packet[i] = this.addParity(32); } this.output.push(...packet); } writeRow(rowNumber, text) { const packet = new Array(45).fill(0); const magazine = 8; const actualMag = 0; const byte0 = actualMag | rowNumber << 3; packet[0] = this.ham84(byte0 & 15); packet[1] = this.ham84(byte0 >> 4 & 15); const rowData = new Array(40); rowData[0] = ALPHA_WHITE2; rowData[1] = NORMAL_HEIGHT2; const maxLen = Math.min(text.length, 38); for (let i = 0;i < maxLen; i++) { rowData[2 + i] = text.charCodeAt(i) & 127; } for (let i = maxLen + 2;i < 40; i++) { rowData[i] = 32; } for (let i = 0;i < 40; i++) { packet[2 + i] = this.addParity(rowData[i]); } for (let i = 42;i < 45; i++) { packet[i] = this.addParity(32); } this.output.push(...packet); } ham84(nibble) { return this.addParity(nibble & 15); } addParity(byte) { byte = byte & 127; let bits = 0; let temp = byte; while (temp) { bits += temp & 1; temp >>= 1; } return byte | (bits & 1 ? 0 : 128); } } function toTeletext(doc) { const serializer = new TeletextSerializer(doc); return serializer.serialize(); } export { toTeletext, parseTeletext };