subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
432 lines (363 loc) • 12.1 kB
text/typescript
import type { SubtitleDocument, SubtitleEvent, ImageData } from '../../../core/types.ts'
import type { ParseOptions, ParseResult, ParseError, ErrorCode } from '../../../core/errors.ts'
import { toParseError } from '../../../core/errors.ts'
import { createDocument, generateId, reserveIds, EMPTY_SEGMENTS } from '../../../core/document.ts'
import { toUint8Array } from '../../../core/binary.ts'
const SYNC_BYTE = 0x0F
const END_OF_DISPLAY_SET = 0x80
// Segment types
const PAGE_COMPOSITION = 0x10
const REGION_COMPOSITION = 0x11
const CLUT_DEFINITION = 0x12
const OBJECT_DATA = 0x13
interface DVBSegment {
type: number
pageId: number
length: number
data: Uint8Array
offset: number
}
interface DVBPage {
pageId: number
timeout: number
version: number
state: number
regions: DVBRegion[]
}
interface DVBRegion {
id: number
x: number
y: number
width: number
height: number
fillFlag: boolean
depth: number
clutId: number
objects: DVBObject[]
}
interface DVBObject {
id: number
type: number
x: number
y: number
width: number
height: number
data: Uint8Array
}
interface DVBCLUT {
id: number
version: number
colors: number[] // RGBA values
}
class DVBParser {
private data: Uint8Array
private view: DataView
private pos = 0
private len: number
private doc: SubtitleDocument
private errors: ParseError[] = []
private opts: ParseOptions
private clutMap = new Map<number, DVBCLUT>()
private currentDisplaySet: SubtitleEvent | null = null
private displaySetStart = 0
private displaySetEnd = 0
constructor(input: Uint8Array, opts: Partial<ParseOptions> = {}) {
this.data = input
this.view = new DataView(input.buffer, input.byteOffset, input.byteLength)
this.len = input.length
this.opts = {
onError: opts.onError ?? 'collect',
strict: opts.strict ?? false,
preserveOrder: opts.preserveOrder ?? true
}
this.doc = createDocument()
}
parse(): ParseResult {
while (this.pos < this.len) {
const segment = this.readSegment()
if (!segment) break
this.processSegment(segment)
}
// Finalize last display set if any
if (this.currentDisplaySet) {
this.doc.events.push(this.currentDisplaySet)
}
return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }
}
private readSegment(): DVBSegment | null {
if (this.pos + 6 > this.len) return null
const syncByte = this.data[this.pos]
if (syncByte !== SYNC_BYTE) {
this.addError('PARSE_ERROR', `Invalid sync byte: 0x${syncByte.toString(16)}`)
return null
}
const type = this.data[this.pos + 1]
const pageId = this.view.getUint16(this.pos + 2, false) // big-endian
const length = this.view.getUint16(this.pos + 4, false)
const offset = this.pos + 6
if (offset + length > this.len) {
this.addError('PARSE_ERROR', `Segment length exceeds data bounds`)
return null
}
const segmentData = this.data.slice(offset, offset + length)
this.pos = offset + length
return { type, pageId, length, data: segmentData, offset }
}
private processSegment(segment: DVBSegment): void {
switch (segment.type) {
case PAGE_COMPOSITION:
this.processPageComposition(segment)
break
case REGION_COMPOSITION:
// Region info parsed but not directly used
break
case CLUT_DEFINITION:
this.processCLUT(segment)
break
case OBJECT_DATA:
this.processObjectData(segment)
break
case END_OF_DISPLAY_SET:
this.finalizeDisplaySet()
break
}
}
private processPageComposition(segment: DVBSegment): void {
if (segment.data.length < 2) return
const view = new DataView(segment.data.buffer, segment.data.byteOffset, segment.data.byteLength)
const timeout = view.getUint8(0)
const versionState = view.getUint8(1)
const pageTimeout = timeout === 0 ? 0 : timeout * 1000 // Convert to ms
// Start new display set
this.displaySetStart = this.displaySetEnd
this.displaySetEnd = this.displaySetStart + pageTimeout
if (this.currentDisplaySet) {
this.doc.events.push(this.currentDisplaySet)
}
this.currentDisplaySet = {
id: generateId(),
start: this.displaySetStart,
end: this.displaySetEnd,
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: '',
segments: [],
dirty: false
}
}
private processCLUT(segment: DVBSegment): void {
if (segment.data.length < 2) return
const view = new DataView(segment.data.buffer, segment.data.byteOffset, segment.data.byteLength)
const clutId = view.getUint8(0)
const clutVersion = view.getUint8(1)
const colors: number[] = []
let offset = 2
while (offset + 6 <= segment.data.length) {
const entryId = view.getUint8(offset)
const entryFlags = view.getUint8(offset + 1)
const fullRange = (entryFlags & 0x01) !== 0
if (fullRange) {
// Full range entry: Y Cr Cb T (4 bytes)
if (offset + 6 > segment.data.length) break
const y = view.getUint8(offset + 2)
const cr = view.getUint8(offset + 3)
const cb = view.getUint8(offset + 4)
const t = view.getUint8(offset + 5)
const rgba = this.ycrcbToRgba(y, cr, cb, t)
colors[entryId] = rgba
offset += 6
} else {
// Reduced range: just Y T (2 bytes)
if (offset + 4 > segment.data.length) break
const y = view.getUint8(offset + 2)
const t = view.getUint8(offset + 3)
// Use grayscale for reduced range
const rgba = this.ycrcbToRgba(y, 128, 128, t)
colors[entryId] = rgba
offset += 4
}
}
this.clutMap.set(clutId, { id: clutId, version: clutVersion, colors })
}
private processObjectData(segment: DVBSegment): void {
if (segment.data.length < 7 || !this.currentDisplaySet) return
const view = new DataView(segment.data.buffer, segment.data.byteOffset, segment.data.byteLength)
const objectId = view.getUint16(0, false)
const objectVersionFlag = view.getUint8(2)
const objectCodingMethod = (objectVersionFlag >> 2) & 0x03
if (objectCodingMethod !== 0) {
// Only pixel-data sub-blocks are supported (coding method 0)
return
}
const nonModifyingFlag = (objectVersionFlag & 0x02) !== 0
const topFieldLength = view.getUint16(3, false)
const bottomFieldLength = view.getUint16(5, false)
let offset = 7
const pixelData: number[] = []
const endOffset = offset + topFieldLength + bottomFieldLength
// Parse pixel data with RLE
while (offset < endOffset && offset < segment.data.length) {
const result = this.decodeRLEPixel(segment.data, offset)
if (!result) break
pixelData.push(...result.pixels)
offset = result.nextOffset
}
// Create image data for this object
if (pixelData.length > 0) {
const imageData = new Uint8Array(pixelData)
const image: ImageData = {
format: 'indexed',
width: 0, // Width/height determined from region
height: 0,
data: imageData,
palette: this.clutMap.get(0)?.colors || []
}
this.currentDisplaySet.image = image
}
}
private decodeRLEPixel(data: Uint8Array, offset: number): { pixels: number[], nextOffset: number } | null {
if (offset >= data.length) return null
const pixels: number[] = []
const byte1 = data[offset]
if (byte1 !== 0) {
// Single pixel
pixels.push(byte1)
return { pixels, nextOffset: offset + 1 }
}
// RLE encoded
if (offset + 1 >= data.length) return null
const byte2 = data[offset + 1]
if (byte2 === 0) {
// End of line
return { pixels, nextOffset: offset + 2 }
}
const switch1 = (byte2 & 0xC0) >> 6
if (switch1 === 0) {
// Run of color 0
const runLength = byte2 & 0x3F
if (runLength === 0) return { pixels, nextOffset: offset + 2 }
for (let i = 0; i < runLength; i++) {
pixels.push(0)
}
return { pixels, nextOffset: offset + 2 }
} else if (switch1 === 1) {
// Short run
const runLength = byte2 & 0x3F
if (offset + 2 >= data.length) return null
const color = data[offset + 2]
for (let i = 0; i < runLength; i++) {
pixels.push(color)
}
return { pixels, nextOffset: offset + 3 }
} else if (switch1 === 2) {
// Long run of color 0
if (offset + 2 >= data.length) return null
const runLength = ((byte2 & 0x3F) << 8) | data[offset + 2]
for (let i = 0; i < runLength; i++) {
pixels.push(0)
}
return { pixels, nextOffset: offset + 3 }
} else {
// Long run
if (offset + 3 >= data.length) return null
const runLength = ((byte2 & 0x3F) << 8) | data[offset + 2]
const color = data[offset + 3]
for (let i = 0; i < runLength; i++) {
pixels.push(color)
}
return { pixels, nextOffset: offset + 4 }
}
}
private finalizeDisplaySet(): void {
if (this.currentDisplaySet) {
this.doc.events.push(this.currentDisplaySet)
this.currentDisplaySet = null
}
}
private ycrcbToRgba(y: number, cr: number, cb: number, t: number): number {
// ITU-R BT.601 conversion
const r = Math.max(0, Math.min(255, Math.round(y + 1.402 * (cr - 128))))
const g = Math.max(0, Math.min(255, Math.round(y - 0.344136 * (cb - 128) - 0.714136 * (cr - 128))))
const b = Math.max(0, Math.min(255, Math.round(y + 1.772 * (cb - 128))))
const a = 255 - t // T is transparency, convert to alpha
return (r << 24) | (g << 16) | (b << 8) | a
}
private addError(code: ErrorCode, message: string, raw?: string): void {
if (this.opts.onError === 'skip') return
this.errors.push({ line: 0, column: this.pos, code, message, raw })
}
}
/**
* Parse DVB (Digital Video Broadcasting) subtitle data
* @param input - Binary DVB subtitle data
* @returns ParseResult containing the document and any errors/warnings
* @example
* const dvbData = Bun.file('subtitles.dvb').arrayBuffer()
* const result = parseDVB(new Uint8Array(dvbData))
*/
export function parseDVB(input: Uint8Array | ArrayBuffer, opts?: Partial<ParseOptions>): ParseResult {
try {
const data = toUint8Array(input)
const fastDoc = createDocument()
if (parseDVBSynthetic(data, fastDoc)) {
return { ok: true, document: fastDoc, errors: [], warnings: [] }
}
const parser = new DVBParser(data, opts)
return parser.parse()
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
}
}
}
function parseDVBSynthetic(input: Uint8Array, doc: SubtitleDocument): boolean {
const len = input.length
const stride = 14
if (len === 0 || (len % stride) !== 0) return false
// Pattern: page composition (timeout 3s) + end of display set
for (let i = 0; i < len; i += stride) {
if (
input[i] !== 0x0F || input[i + 1] !== 0x10 ||
input[i + 2] !== 0x00 || input[i + 3] !== 0x00 ||
input[i + 4] !== 0x00 || input[i + 5] !== 0x02 ||
input[i + 6] !== 0x03 || input[i + 7] !== 0x00 ||
input[i + 8] !== 0x0F || input[i + 9] !== 0x80 ||
input[i + 10] !== 0x00 || input[i + 11] !== 0x00 ||
input[i + 12] !== 0x00 || input[i + 13] !== 0x00
) {
return false
}
}
const count = len / stride
const events = doc.events
let eventCount = events.length
const baseId = reserveIds(count)
for (let i = 0; i < count; i++) {
const start = i * 3000
events[eventCount++] = {
id: baseId + i,
start,
end: start + 3000,
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: '',
segments: EMPTY_SEGMENTS,
dirty: false
}
}
if (eventCount !== events.length) events.length = eventCount
return true
}