subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
657 lines (600 loc) • 22.6 kB
text/typescript
import type { SubtitleDocument, SubtitleEvent, TextSegment, InlineStyle, Effect } from './types.ts'
import type { ParseError, ParseWarning, ParseResult, ParseOptions } from './errors.ts'
import { createDocument } from './document.ts'
import { explodeKaraoke } from './ops.ts'
import { toASS } from '../formats/text/ass/serializer.ts'
import { toSSA } from '../formats/text/ssa/serializer.ts'
import { toSRT } from '../formats/text/srt/serializer.ts'
import { toVTT } from '../formats/text/vtt/serializer.ts'
import { toSBV } from '../formats/text/sbv/serializer.ts'
import { toLRC, type LRCSerializeOptions } from '../formats/text/lrc/serializer.ts'
import { toMicroDVD, type MicroDVDSerializeOptions } from '../formats/text/microdvd/serializer.ts'
import { toTTML, type TTMLSerializeOptions } from '../formats/xml/ttml/serializer.ts'
import { toDFXP } from '../formats/xml/ttml/dfxp.ts'
import { toSMPTETT } from '../formats/xml/ttml/smpte.ts'
import { toSAMI } from '../formats/xml/sami/serializer.ts'
import { toRealText } from '../formats/xml/realtext/serializer.ts'
import { toQT, type QTSerializeOptions } from '../formats/xml/qt/serializer.ts'
import { toEBUSTL, type EBUSTLSerializeOptions } from '../formats/binary/stl/ebu/serializer.ts'
import { toSpruceSTL, type SpruceSTLSerializeOptions } from '../formats/binary/stl/spruce/serializer.ts'
import { toPGS } from '../formats/binary/pgs/serializer.ts'
import { toDVB } from '../formats/binary/dvb/serializer.ts'
import { toVobSub } from '../formats/binary/vobsub/index.ts'
import { toPAC, type PACSerializeOptions } from '../formats/binary/pac/serializer.ts'
import { toSCC } from '../formats/broadcast/scc/serializer.ts'
import { toCAP, type CAPSerializerOptions } from '../formats/broadcast/cap/serializer.ts'
import { toTeletext } from '../formats/broadcast/teletext/serializer.ts'
import { parseASS } from '../formats/text/ass/parser.ts'
import { parseSSA } from '../formats/text/ssa/parser.ts'
import { parseSRT } from '../formats/text/srt/parser.ts'
import { parseVTT } from '../formats/text/vtt/parser.ts'
import { parseSBV } from '../formats/text/sbv/parser.ts'
import { parseLRC } from '../formats/text/lrc/parser.ts'
import { parseMicroDVD, type MicroDVDParseOptions } from '../formats/text/microdvd/parser.ts'
import { parseTTML } from '../formats/xml/ttml/parser.ts'
import { parseDFXP } from '../formats/xml/ttml/dfxp.ts'
import { parseSMPTETT } from '../formats/xml/ttml/smpte.ts'
import { parseSAMI } from '../formats/xml/sami/parser.ts'
import { parseRealText } from '../formats/xml/realtext/parser.ts'
import { parseQT } from '../formats/xml/qt/parser.ts'
import { parseEBUSTL } from '../formats/binary/stl/ebu/parser.ts'
import { parseSpruceSTL } from '../formats/binary/stl/spruce/parser.ts'
import { parsePGS } from '../formats/binary/pgs/parser.ts'
import { parseDVB } from '../formats/binary/dvb/parser.ts'
import { parseVobSub } from '../formats/binary/vobsub/index.ts'
import { parsePAC } from '../formats/binary/pac/parser.ts'
import { parseSCC } from '../formats/broadcast/scc/parser.ts'
import { parseCAP } from '../formats/broadcast/cap/parser.ts'
import { parseTeletext } from '../formats/broadcast/teletext/parser.ts'
import { serializeTags as serializeASSTags } from '../formats/text/ass/tags.ts'
export type FormatId =
| 'ass'
| 'ssa'
| 'srt'
| 'vtt'
| 'sbv'
| 'lrc'
| 'microdvd'
| 'ttml'
| 'dfxp'
| 'smpte-tt'
| 'sami'
| 'realtext'
| 'qt'
| 'ebu-stl'
| 'spruce-stl'
| 'pgs'
| 'dvb'
| 'vobsub'
| 'pac'
| 'scc'
| 'cap'
| 'teletext'
export interface FormatOutputMap {
ass: string
ssa: string
srt: string
vtt: string
sbv: string
lrc: string
microdvd: string
ttml: string
dfxp: string
'smpte-tt': string
sami: string
realtext: string
qt: string
'ebu-stl': Uint8Array
'spruce-stl': string
pgs: Uint8Array
dvb: Uint8Array
vobsub: { idx: string; sub: Uint8Array }
pac: Uint8Array
scc: string
cap: string
teletext: Uint8Array
}
export interface FormatOptionsMap {
lrc?: LRCSerializeOptions
microdvd?: MicroDVDSerializeOptions
ttml?: TTMLSerializeOptions
dfxp?: TTMLSerializeOptions
'smpte-tt'?: TTMLSerializeOptions
qt?: QTSerializeOptions
cap?: CAPSerializerOptions
pac?: PACSerializeOptions
'ebu-stl'?: EBUSTLSerializeOptions
'spruce-stl'?: SpruceSTLSerializeOptions
}
export interface FormatParseOptionsMap {
ass?: Partial<ParseOptions>
ssa?: Partial<ParseOptions>
srt?: Partial<ParseOptions>
vtt?: Partial<ParseOptions>
sbv?: Partial<ParseOptions>
lrc?: Partial<ParseOptions>
microdvd?: MicroDVDParseOptions
ttml?: Partial<ParseOptions>
dfxp?: Partial<ParseOptions>
'smpte-tt'?: Partial<ParseOptions>
sami?: Partial<ParseOptions>
realtext?: Partial<ParseOptions>
qt?: Partial<ParseOptions>
'ebu-stl'?: Partial<ParseOptions>
'spruce-stl'?: Partial<ParseOptions>
pgs?: Partial<ParseOptions>
dvb?: Partial<ParseOptions>
pac?: Partial<ParseOptions>
scc?: Partial<ParseOptions>
cap?: Partial<ParseOptions>
teletext?: Partial<ParseOptions>
}
/**
* Options for converting between subtitle formats.
*/
export interface ConvertOptions {
/** Target format */
to: FormatId
/** How to handle unsupported effects: drop them or keep them */
unsupported?: 'drop' | 'comment'
/** How to handle karaoke timing: preserve, split into events, or remove */
karaoke?: 'preserve' | 'explode' | 'strip'
/** How to handle positioning tags: keep or remove */
positioning?: 'preserve' | 'strip'
/** Whether to track and report lost features */
reportLoss?: boolean
/** Per-format serializer options */
formatOptions?: FormatOptionsMap
}
/**
* Result of a format conversion.
*/
export interface ConvertResult<T extends FormatId = FormatId> {
/** Converted subtitle file content */
output: FormatOutputMap[T]
/** Features that were lost in conversion */
lostFeatures: LostFeature[]
}
/**
* Options for end-to-end transcode (parse + convert).
*/
export interface TranscodeOptions<T extends FormatId = FormatId> extends Omit<ConvertOptions, 'to'> {
/** Source format or 'auto' for best-effort text detection */
from: FormatId | 'auto'
/** Target format */
to: T
/** Per-format parse options */
parseOptions?: FormatParseOptionsMap
}
/**
* Result of a transcode operation.
*/
export interface TranscodeResult<T extends FormatId = FormatId> {
ok: boolean
output?: FormatOutputMap[T]
document?: SubtitleDocument
errors: ParseError[]
warnings: ParseWarning[]
lostFeatures: LostFeature[]
}
/**
* A feature that was lost during conversion.
*/
export interface LostFeature {
/** Index of the event that lost features */
eventIndex: number
/** Feature type that was lost */
feature: string
/** Human-readable description */
description: string
}
const defaultOptions: Required<Pick<ConvertOptions, 'unsupported' | 'karaoke' | 'positioning' | 'reportLoss'>> = {
unsupported: 'drop',
karaoke: 'strip',
positioning: 'strip',
reportLoss: false
}
type InlineStyleKey = keyof InlineStyle
type TextMode = 'segments' | 'plain' | 'ass-tags' | 'images'
interface FormatProfile {
inlineStyles: Set<InlineStyleKey> | 'all'
effects: Set<string> | 'all'
textMode: TextMode
}
const srtStyles = new Set<InlineStyleKey>(['bold', 'italic', 'underline', 'strikeout', 'primaryColor'])
const vttStyles = new Set<InlineStyleKey>(['bold', 'italic', 'underline'])
const microDVDStyles = new Set<InlineStyleKey>(['bold', 'italic', 'underline', 'strikeout', 'primaryColor', 'fontName', 'fontSize'])
const samiStyles = srtStyles
const ttmlStyles = new Set<InlineStyleKey>(['fontName', 'fontSize', 'bold', 'italic', 'underline', 'strikeout', 'primaryColor', 'backColor'])
const pacStyles = new Set<InlineStyleKey>(['italic', 'underline'])
const profileMap: Record<FormatId, FormatProfile> = {
ass: { inlineStyles: 'all', effects: 'all', textMode: 'segments' },
ssa: { inlineStyles: 'all', effects: 'all', textMode: 'segments' },
srt: { inlineStyles: srtStyles, effects: new Set(), textMode: 'segments' },
vtt: { inlineStyles: vttStyles, effects: new Set(), textMode: 'segments' },
sbv: { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' },
lrc: { inlineStyles: new Set(), effects: new Set(['karaoke', 'karaokeAbsolute']), textMode: 'segments' },
microdvd: { inlineStyles: microDVDStyles, effects: new Set(), textMode: 'segments' },
ttml: { inlineStyles: ttmlStyles, effects: new Set(), textMode: 'segments' },
dfxp: { inlineStyles: ttmlStyles, effects: new Set(), textMode: 'segments' },
'smpte-tt': { inlineStyles: ttmlStyles, effects: new Set(), textMode: 'segments' },
sami: { inlineStyles: samiStyles, effects: new Set(), textMode: 'segments' },
realtext: { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' },
qt: { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' },
'ebu-stl': { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' },
'spruce-stl': { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' },
pgs: { inlineStyles: new Set(), effects: new Set(['image', 'pgs']), textMode: 'images' },
dvb: { inlineStyles: new Set(), effects: new Set(['image']), textMode: 'images' },
vobsub: { inlineStyles: new Set(), effects: new Set(['image', 'vobsub']), textMode: 'images' },
pac: { inlineStyles: pacStyles, effects: new Set(), textMode: 'ass-tags' },
scc: { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' },
cap: { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' },
teletext: { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' },
}
/**
* Converts a subtitle document to a different format.
* Handles feature compatibility and tracks lost features.
* @param doc - Subtitle document to convert
* @param opts - Conversion options
* @returns Converted output and list of lost features
* @example
* ```ts
* const result = convert(assDoc, {
* to: 'srt',
* karaoke: 'strip',
* positioning: 'strip',
* reportLoss: true
* })
* console.log(`Lost ${result.lostFeatures.length} features`)
* console.log(result.output)
* ```
*/
export function convert<T extends FormatId>(
doc: SubtitleDocument,
opts: ConvertOptions & { to: T }
): ConvertResult<T> {
const options = { ...defaultOptions, ...opts }
const lostFeatures: LostFeature[] = []
const profile = profileMap[options.to]
const sourceEvents = options.karaoke === 'explode'
? doc.events.flatMap(event => explodeKaraoke(event))
: doc.events
const convertedEvents = sourceEvents.map((event, idx) =>
convertEvent(event, idx, profile, options, lostFeatures)
)
const convertedDoc: SubtitleDocument = {
...doc,
events: convertedEvents
}
const output = serializeByFormat(options.to, convertedDoc, options.formatOptions)
return { output: output as FormatOutputMap[T], lostFeatures }
}
/**
* Parse and convert in one step.
*/
export function transcode<T extends FormatId>(
input: string | Uint8Array | ArrayBuffer | { idx: string; sub: Uint8Array | ArrayBuffer },
opts: TranscodeOptions & { to: T }
): TranscodeResult<T> {
const detected = opts.from === 'auto' ? detectFormat(input) : opts.from
if (!detected) {
return {
ok: false,
errors: [makeParseError('INVALID_SECTION', 'Unable to detect format from input')],
warnings: [],
lostFeatures: []
}
}
const parseResult = parseByFormat(detected, input, opts.parseOptions)
if (!parseResult.ok) {
return {
ok: false,
document: parseResult.document,
errors: parseResult.errors,
warnings: parseResult.warnings,
lostFeatures: []
}
}
const conversion = convert(parseResult.document, { ...opts, to: opts.to })
return {
ok: true,
output: conversion.output,
document: parseResult.document,
errors: parseResult.errors,
warnings: parseResult.warnings,
lostFeatures: conversion.lostFeatures
}
}
function convertEvent(
event: SubtitleEvent,
eventIndex: number,
profile: FormatProfile,
options: Required<Pick<ConvertOptions, 'unsupported' | 'karaoke' | 'positioning' | 'reportLoss'>>,
lostFeatures: LostFeature[]
): SubtitleEvent {
const imageSegments = event.segments.length === 0 ? buildImageSegments(event) : null
const baseSegments = event.segments.length > 0
? event.segments
: (imageSegments ?? [{ text: event.text, style: null, effects: [] }])
const convertedSegments = baseSegments.map(seg =>
convertSegment(seg, eventIndex, profile, options, lostFeatures)
)
let text = event.text
let dirty = event.dirty
switch (profile.textMode) {
case 'plain':
text = plainTextFromSegments(convertedSegments, event.text)
dirty = false
break
case 'ass-tags':
text = serializeASSTags(convertedSegments)
dirty = false
break
case 'segments':
dirty = convertedSegments.length > 0
break
case 'images':
dirty = false
break
}
return {
...event,
text,
segments: convertedSegments,
dirty
}
}
function buildImageSegments(event: SubtitleEvent): TextSegment[] | null {
if (!event.image) return null
const effects: Effect[] = [{
type: 'image',
params: event.image
}]
if (event.vobsub) {
effects.push({ type: 'vobsub', params: event.vobsub })
}
if (event.pgs) {
effects.push({ type: 'pgs', params: event.pgs })
}
return [{
text: '',
style: null,
effects
}]
}
function convertSegment(
seg: TextSegment,
eventIndex: number,
profile: FormatProfile,
options: Required<Pick<ConvertOptions, 'unsupported' | 'karaoke' | 'positioning' | 'reportLoss'>>,
lostFeatures: LostFeature[]
): TextSegment {
const newStyle = seg.style ? { ...seg.style } : null
const newEffects: Effect[] = []
if (newStyle) {
if (newStyle.pos !== undefined && options.positioning === 'strip') {
reportLoss(lostFeatures, eventIndex, 'positioning', `pos(${newStyle.pos[0]},${newStyle.pos[1]})`, options)
delete newStyle.pos
}
if (newStyle.alignment !== undefined && options.positioning === 'strip') {
reportLoss(lostFeatures, eventIndex, 'alignment', `an${newStyle.alignment}`, options)
delete newStyle.alignment
}
if (profile.inlineStyles !== 'all') {
for (const key of Object.keys(newStyle) as InlineStyleKey[]) {
if (!profile.inlineStyles.has(key)) {
reportLoss(lostFeatures, eventIndex, String(key), String(key), options)
delete (newStyle as Record<string, unknown>)[key]
}
}
}
}
for (const effect of seg.effects) {
if (isKaraokeEffect(effect) && options.karaoke === 'strip') {
reportLoss(lostFeatures, eventIndex, 'karaoke', 'karaoke timing', options)
continue
}
if (!isEffectSupported(effect.type, profile)) {
reportLoss(lostFeatures, eventIndex, effect.type, `\${effect.type}`, options)
if (options.unsupported === 'drop') {
continue
}
}
newEffects[newEffects.length] = effect
}
return {
text: seg.text,
style: newStyle && Object.keys(newStyle).length > 0 ? newStyle : null,
effects: newEffects
}
}
function isEffectSupported(type: string, profile: FormatProfile): boolean {
if (profile.effects === 'all') return true
return profile.effects.has(type)
}
function isKaraokeEffect(effect: Effect): boolean {
return effect.type === 'karaoke' || effect.type === 'karaokeAbsolute'
}
function plainTextFromSegments(segments: TextSegment[], fallback: string): string {
if (segments.length === 0) return fallback
return segments.map(seg => seg.text).join('')
}
function reportLoss(
lostFeatures: LostFeature[],
eventIndex: number,
feature: string,
description: string,
options: Required<Pick<ConvertOptions, 'reportLoss'>>
): void {
if (!options.reportLoss) return
lostFeatures[lostFeatures.length] = { eventIndex, feature, description }
}
function serializeByFormat(format: FormatId, doc: SubtitleDocument, formatOptions?: FormatOptionsMap): FormatOutputMap[FormatId] {
switch (format) {
case 'ass':
return toASS(doc)
case 'ssa':
return toSSA(doc)
case 'srt':
return toSRT(doc)
case 'vtt':
return toVTT(doc)
case 'sbv':
return toSBV(doc)
case 'lrc':
return toLRC(doc, formatOptions?.lrc)
case 'microdvd':
if (!formatOptions?.microdvd) {
throw new Error('microdvd requires formatOptions.microdvd')
}
return toMicroDVD(doc, formatOptions.microdvd)
case 'ttml':
return toTTML(doc, formatOptions?.ttml)
case 'dfxp':
return toDFXP(doc, formatOptions?.dfxp)
case 'smpte-tt':
return toSMPTETT(doc, formatOptions?.['smpte-tt'])
case 'sami':
return toSAMI(doc)
case 'realtext':
return toRealText(doc)
case 'qt':
return toQT(doc, formatOptions?.qt)
case 'ebu-stl':
return toEBUSTL(doc, formatOptions?.['ebu-stl'])
case 'spruce-stl':
return toSpruceSTL(doc, formatOptions?.['spruce-stl'])
case 'pgs':
return toPGS(doc)
case 'dvb':
return toDVB(doc)
case 'vobsub':
return toVobSub(doc)
case 'pac':
return toPAC(doc, formatOptions?.pac)
case 'scc':
return toSCC(doc)
case 'cap':
return toCAP(doc, formatOptions?.cap)
case 'teletext':
return toTeletext(doc)
}
}
function parseByFormat(
format: FormatId,
input: string | Uint8Array | ArrayBuffer | { idx: string; sub: Uint8Array | ArrayBuffer },
parseOptions?: FormatParseOptionsMap
): ParseResult {
switch (format) {
case 'ass':
return ensureString(input, format, () => parseASS(input, parseOptions?.ass))
case 'ssa':
return ensureString(input, format, () => parseSSA(input, parseOptions?.ssa))
case 'srt':
return ensureString(input, format, () => parseSRT(input, parseOptions?.srt))
case 'vtt':
return ensureString(input, format, () => parseVTT(input, parseOptions?.vtt))
case 'sbv':
return ensureString(input, format, () => parseSBV(input, parseOptions?.sbv))
case 'lrc':
return ensureString(input, format, () => parseLRC(input, parseOptions?.lrc))
case 'microdvd':
if (typeof input !== 'string') return parseFailure(format, 'Expected string input')
if (!parseOptions?.microdvd?.fps) {
return parseFailure(format, 'microdvd requires parseOptions.microdvd.fps')
}
return parseMicroDVD(input, parseOptions.microdvd)
case 'ttml':
return ensureString(input, format, () => parseTTML(input, parseOptions?.ttml))
case 'dfxp':
return ensureString(input, format, () => parseDFXP(input))
case 'smpte-tt':
return ensureString(input, format, () => parseSMPTETT(input))
case 'sami':
return ensureString(input, format, () => parseSAMI(input, parseOptions?.sami))
case 'realtext':
return ensureString(input, format, () => parseRealText(input, parseOptions?.realtext))
case 'qt':
return ensureString(input, format, () => parseQT(input, parseOptions?.qt))
case 'ebu-stl':
return ensureBinary(input, format, () => parseEBUSTL(asBinary(input), parseOptions?.['ebu-stl']))
case 'spruce-stl':
return ensureString(input, format, () => parseSpruceSTL(input, parseOptions?.['spruce-stl']))
case 'pgs':
return ensureBinary(input, format, () => parsePGS(asBinary(input), parseOptions?.pgs))
case 'dvb':
return ensureBinary(input, format, () => parseDVB(asBinary(input), parseOptions?.dvb))
case 'vobsub':
if (!isVobSubInput(input)) return parseFailure(format, 'Expected { idx, sub } input')
return parseVobSub(input.idx, input.sub)
case 'pac':
return ensureBinary(input, format, () => parsePAC(asBinary(input), parseOptions?.pac))
case 'scc':
return ensureString(input, format, () => parseSCC(input, parseOptions?.scc))
case 'cap':
return ensureString(input, format, () => parseCAP(input, parseOptions?.cap))
case 'teletext':
return ensureBinary(input, format, () => parseTeletext(asBinary(input), parseOptions?.teletext))
}
}
function detectFormat(
input: string | Uint8Array | ArrayBuffer | { idx: string; sub: Uint8Array | ArrayBuffer }
): FormatId | null {
if (typeof input !== 'string') return null
const text = input.trimStart()
if (text.startsWith('WEBVTT')) return 'vtt'
if (text.startsWith('Scenarist_SCC')) return 'scc'
if (text.startsWith('$CaptionMAX')) return 'cap'
if (text.startsWith('{QTtext}')) return 'qt'
if (text.startsWith('[Script Info]')) return text.includes('V4+ Styles') ? 'ass' : 'ssa'
if (text.startsWith('<SAMI') || text.startsWith('<SAMI>')) return 'sami'
if (text.startsWith('<tt') || text.includes('<tt ')) return 'ttml'
if (text.startsWith('<window')) return 'realtext'
if (text.startsWith('{') && text.includes('}{')) return 'microdvd'
if (text.includes('-->')) return text.includes(',') ? 'srt' : 'vtt'
if (text.includes(',') && /\d+:\d{2}:\d{2}\.\d{3}\s*,/.test(text)) return 'sbv'
if (text.includes('[') && /\[\d{2}:\d{2}\.\d{2,3}\]/.test(text)) return 'lrc'
return null
}
function ensureString<T>(
input: string | Uint8Array | ArrayBuffer | { idx: string; sub: Uint8Array | ArrayBuffer },
format: FormatId,
fn: () => ParseResult
): ParseResult {
if (typeof input !== 'string') {
return parseFailure(format, 'Expected string input')
}
return fn()
}
function ensureBinary(
input: string | Uint8Array | ArrayBuffer | { idx: string; sub: Uint8Array | ArrayBuffer },
format: FormatId,
fn: () => ParseResult
): ParseResult {
if (typeof input === 'string') {
return parseFailure(format, 'Expected binary input')
}
if (isVobSubInput(input)) {
return parseFailure(format, 'Expected binary input, not VobSub bundle')
}
return fn()
}
function isVobSubInput(
input: string | Uint8Array | ArrayBuffer | { idx: string; sub: Uint8Array | ArrayBuffer }
): input is { idx: string; sub: Uint8Array | ArrayBuffer } {
return typeof input === 'object' && input !== null && 'idx' in input && 'sub' in input
}
function asBinary(input: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer {
return input
}
function parseFailure(format: FormatId, message: string): ParseResult {
return {
ok: false,
document: createDocument(),
errors: [makeParseError('INVALID_SECTION', `${format}: ${message}`)],
warnings: []
}
}
function makeParseError(code: ParseError['code'], message: string): ParseError {
return { line: 1, column: 1, code, message }
}