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