subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
271 lines (227 loc) • 7.03 kB
text/typescript
import type { SubtitleDocument, SubtitleEvent, TextSegment, InlineStyle } from '../../../core/types.ts'
import { formatTime } from './time.ts'
/**
* Options for TTML serialization
*/
export interface TTMLSerializeOptions {
/** Include XML namespace declarations (default: true) */
xmlns?: boolean
/** Include head section with styles and regions (default: true) */
includeHead?: boolean
/** Time format - 'clock' for HH:MM:SS.mmm or 'offset' for seconds (default: 'clock') */
format?: 'clock' | 'offset'
}
/**
* Serialize subtitle document to TTML format
*
* @param doc - Subtitle document to serialize
* @param opts - Serialization options
* @returns TTML formatted string
*
* @example
* ```ts
* const ttml = toTTML(doc, {
* xmlns: true,
* includeHead: true,
* format: 'clock'
* })
* ```
*/
export function toTTML(doc: SubtitleDocument, opts: TTMLSerializeOptions = {}): string {
const {
xmlns = true,
includeHead = true,
format = 'clock'
} = opts
const lines: string[] = []
// XML declaration
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
// Root element
if (xmlns) {
lines.push('<tt xmlns="http://www.w3.org/ns/ttml" xmlns:tts="http://www.w3.org/ns/ttml#styling">')
} else {
lines.push('<tt>')
}
// Head section
if (includeHead) {
lines.push(' <head>')
// Styling section
const styles = extractStyles(doc)
if (styles.length > 0) {
lines.push(' <styling>')
for (const style of styles) {
lines.push(` ${style}`)
}
lines.push(' </styling>')
}
// Layout section (regions)
const regions = extractRegions(doc)
if (regions.length > 0) {
lines.push(' <layout>')
for (const region of regions) {
lines.push(` ${region}`)
}
lines.push(' </layout>')
}
lines.push(' </head>')
}
// Body section
lines.push(' <body>')
lines.push(' <div>')
for (const event of doc.events) {
const p = serializeEvent(event, format)
lines.push(` ${p}`)
}
lines.push(' </div>')
lines.push(' </body>')
lines.push('</tt>')
return lines.join('\n')
}
function extractStyles(doc: SubtitleDocument): string[] {
const styles: string[] = []
const seenStyles = new Set<string>()
// Extract unique styles from document
for (const [name, style] of doc.styles.entries()) {
if (seenStyles.has(name)) continue
seenStyles.add(name)
const attrs: string[] = []
attrs.push(`xml:id="${escapeXml(name)}"`)
if (style.fontName) {
attrs.push(`tts:fontFamily="${escapeXml(style.fontName)}"`)
}
if (style.fontSize) {
attrs.push(`tts:fontSize="${style.fontSize}px"`)
}
if (style.bold) {
attrs.push(`tts:fontWeight="bold"`)
}
if (style.italic) {
attrs.push(`tts:fontStyle="italic"`)
}
if (style.underline || style.strikeout) {
const decorations = []
if (style.underline) decorations.push('underline')
if (style.strikeout) decorations.push('line-through')
attrs.push(`tts:textDecoration="${decorations.join(' ')}"`)
}
// Colors (convert from ABGR to #RRGGBB)
if (style.primaryColor) {
const color = formatColor(style.primaryColor)
attrs.push(`tts:color="${color}"`)
}
if (style.backColor) {
const color = formatColor(style.backColor)
attrs.push(`tts:backgroundColor="${color}"`)
}
styles.push(`<style ${attrs.join(' ')}/>`)
}
return styles
}
function extractRegions(doc: SubtitleDocument): string[] {
const regions: string[] = []
const seenRegions = new Set<string>()
// Extract unique regions from events
for (const event of doc.events) {
if (!event.region) continue
if (seenRegions.has(event.region)) continue
seenRegions.add(event.region)
const attrs: string[] = []
attrs.push(`xml:id="${escapeXml(event.region)}"`)
// Default region positioning
attrs.push(`tts:origin="10% 80%"`)
attrs.push(`tts:extent="80% 20%"`)
regions.push(`<region ${attrs.join(' ')}/>`)
}
return regions
}
function serializeEvent(event: SubtitleEvent, format: 'clock' | 'offset'): string {
const attrs: string[] = []
// Timing
attrs.push(`begin="${formatTime(event.start, format)}"`)
attrs.push(`end="${formatTime(event.end, format)}"`)
// Style reference
if (event.style && event.style !== 'Default') {
attrs.push(`style="${escapeXml(event.style)}"`)
}
// Region reference
if (event.region) {
attrs.push(`region="${escapeXml(event.region)}"`)
}
// Serialize text with segments
const content = serializeSegments(event.segments.length > 0 ? event.segments : [{ text: event.text, style: null, effects: [] }])
return `<p ${attrs.join(' ')}>${content}</p>`
}
function serializeSegments(segments: TextSegment[]): string {
let result = ''
for (const segment of segments) {
const text = escapeXml(segment.text)
if (!segment.style) {
// Plain text
result += text
} else {
// Text with inline styling
const attrs = serializeInlineStyle(segment.style)
if (attrs.length > 0) {
result += `<span ${attrs.join(' ')}>${text}</span>`
} else {
result += text
}
}
}
return result
}
function serializeInlineStyle(style: InlineStyle): string[] {
const attrs: string[] = []
if (style.fontName) {
attrs.push(`tts:fontFamily="${escapeXml(style.fontName)}"`)
}
if (style.fontSize) {
attrs.push(`tts:fontSize="${style.fontSize}px"`)
}
if (style.bold) {
const weight = typeof style.bold === 'number' ? style.bold : 700
attrs.push(`tts:fontWeight="${weight}"`)
}
if (style.italic) {
attrs.push(`tts:fontStyle="italic"`)
}
if (style.underline || style.strikeout) {
const decorations = []
if (style.underline) decorations.push('underline')
if (style.strikeout) decorations.push('line-through')
attrs.push(`tts:textDecoration="${decorations.join(' ')}"`)
}
if (style.primaryColor !== undefined) {
const color = formatColor(style.primaryColor)
attrs.push(`tts:color="${color}"`)
}
if (style.backColor !== undefined) {
const color = formatColor(style.backColor)
attrs.push(`tts:backgroundColor="${color}"`)
}
return attrs
}
function formatColor(color: number): string {
// Convert ABGR to #RRGGBB or #RRGGBBAA
const a = (color >>> 24) & 0xff
const b = (color >>> 16) & 0xff
const g = (color >>> 8) & 0xff
const r = color & 0xff
const rHex = r.toString(16).padStart(2, '0')
const gHex = g.toString(16).padStart(2, '0')
const bHex = b.toString(16).padStart(2, '0')
if (a === 0xff) {
return `#${rHex}${gHex}${bHex}`
} else {
const aHex = a.toString(16).padStart(2, '0')
return `#${rHex}${gHex}${bHex}${aHex}`
}
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}