subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
229 lines (217 loc) • 5.6 kB
text/typescript
import type { SubtitleDocument, SubtitleEvent, Style, TextSegment, InlineStyle } from './types.ts'
let idCounter = 0
/**
* Shared empty array for events without segments.
* Avoids allocation per event.
*/
export const EMPTY_SEGMENTS: TextSegment[] = []
/**
* Generates a unique sequential ID for events.
* @returns New unique ID
*/
export function generateId(): number {
return ++idCounter
}
/**
* Reserve a contiguous range of IDs and return the starting ID.
* Useful for fast-path bulk event creation.
*/
export function reserveIds(count: number): number {
const start = idCounter + 1
idCounter += count
return start
}
/**
* Creates a new subtitle document with default values.
* @param init - Optional partial document to override defaults
* @returns New subtitle document
* @example
* ```ts
* const doc = createDocument({
* info: { title: 'My Subtitles' }
* })
* ```
*/
export function createDocument(init?: Partial<SubtitleDocument>): SubtitleDocument {
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,
}
}
/**
* Creates a default style with standard settings.
* @returns Default style object
* @example
* ```ts
* const style = createDefaultStyle()
* style.fontSize = 60 // Customize as needed
* ```
*/
export function createDefaultStyle(): Style {
return {
name: 'Default',
fontName: 'Arial',
fontSize: 48,
primaryColor: 0x00FFFFFF,
secondaryColor: 0x000000FF,
outlineColor: 0x00000000,
backColor: 0x00000000,
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
}
}
/**
* Creates a new subtitle event with timing and text.
* @param start - Start time in milliseconds
* @param end - End time in milliseconds
* @param text - Subtitle text content
* @param opts - Optional properties to override defaults
* @returns New subtitle event
* @example
* ```ts
* const event = createEvent(1000, 3000, 'Hello, world!', {
* style: 'Title',
* layer: 1
* })
* ```
*/
export function createEvent(
start: number,
end: number,
text: string,
opts?: Partial<SubtitleEvent>
): SubtitleEvent {
return {
id: generateId(),
start,
end,
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text,
segments: [],
dirty: false,
...opts
}
}
/**
* Creates a karaoke event with syllable timing.
* @param start - Event start time in milliseconds
* @param end - Event end time in milliseconds
* @param syllables - Array of syllables with durations and optional styles
* @param opts - Optional properties to override defaults
* @returns New karaoke event with timed segments
* @example
* ```ts
* const karaoke = createKaraokeEvent(0, 3000, [
* { text: 'Hel', duration: 500 },
* { text: 'lo ', duration: 300 },
* { text: 'world', duration: 700 }
* ])
* ```
*/
export function createKaraokeEvent(
start: number,
end: number,
syllables: Array<{ text: string; duration: number; style?: Partial<InlineStyle> }>,
opts?: Partial<SubtitleEvent>
): SubtitleEvent {
const segments: TextSegment[] = syllables.map(syl => ({
text: syl.text,
style: syl.style ? { ...syl.style } : null,
effects: [{ type: 'karaoke', params: { duration: syl.duration, mode: 'fill' as const } }]
}))
return {
id: generateId(),
start,
end,
layer: 0,
style: 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: '',
segments,
dirty: true,
...opts
}
}
/**
* Creates a deep clone of a subtitle document.
* @param doc - Document to clone
* @returns Cloned document
* @example
* ```ts
* const backup = cloneDocument(originalDoc)
* // Modify backup without affecting original
* ```
*/
export function cloneDocument(doc: SubtitleDocument): SubtitleDocument {
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,
}
}
/**
* Creates a deep clone of a subtitle event with a new ID.
* @param event - Event to clone
* @returns Cloned event with new unique ID
* @example
* ```ts
* const duplicate = cloneEvent(originalEvent)
* duplicate.start += 5000 // Offset the duplicate
* ```
*/
export function cloneEvent(event: SubtitleEvent): SubtitleEvent {
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,
}
}