subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
509 lines (503 loc) • 12.9 kB
JavaScript
// 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
};