subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
136 lines (120 loc) • 5.44 kB
text/typescript
import type { SubtitleDocument, SubtitleEvent, Style } from '../../../core/types.ts'
import { formatTime } from '../ass/time.ts'
import { formatColor } from '../ass/color.ts'
import { serializeTags } from '../ass/tags.ts'
/**
* Serializes a SubtitleDocument into SSA (SubStation Alpha) v4 format.
*
* This function converts a subtitle document into the SSA v4 text format.
* SSA v4 has some differences from ASS including different style field ordering
* and alignment values, which are handled automatically during serialization.
*
* @param doc - The subtitle document to serialize
* @returns The complete SSA v4 file content as a string
*
* @example
* ```ts
* const doc = parseASS(assContent)
* // Convert to SSA v4 format
* const ssaContent = toSSA(doc)
* await Bun.write('output.ssa', ssaContent)
* ```
*/
export function toSSA(doc: SubtitleDocument): string {
let result = '[Script Info]\n'
if (doc.info.title) result += 'Title: ' + doc.info.title + '\n'
if (doc.info.author) result += 'Original Author: ' + doc.info.author + '\n'
result += 'PlayResX: ' + doc.info.playResX + '\n'
result += 'PlayResY: ' + doc.info.playResY + '\n'
result += 'ScaledBorderAndShadow: ' + (doc.info.scaleBorderAndShadow ? 'yes' : 'no') + '\n'
result += 'WrapStyle: ' + doc.info.wrapStyle + '\n\n'
result += '[V4 Styles]\n'
result += 'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding\n'
const styleValues = Array.from(doc.styles.values())
const styleLen = styleValues.length
for (let i = 0; i < styleLen; i++) {
result += serializeStyle(styleValues[i]!) + '\n'
}
result += '\n'
result += '[Events]\n'
result += 'Format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n'
const comments = doc.comments
const commentLen = comments.length
const events = doc.events
const eventLen = events.length
let eventIndex = 0
for (let i = 0; i < eventLen; i++) {
for (let j = 0; j < commentLen; j++) {
if (comments[j]!.beforeEventIndex === eventIndex) {
result += 'Comment: 0,0:00:00.00,0:00:00.00,Default,,0,0,0,,' + comments[j]!.text + '\n'
}
}
result += serializeEvent(events[i]!) + '\n'
eventIndex++
}
for (let j = 0; j < commentLen; j++) {
if (comments[j]!.beforeEventIndex === eventIndex) {
result += 'Comment: 0,0:00:00.00,0:00:00.00,Default,,0,0,0,,' + comments[j]!.text + '\n'
}
}
result += '\n'
if (doc.fonts && doc.fonts.length > 0) {
result += '[Fonts]\n'
const fonts = doc.fonts
const fontLen = fonts.length
for (let i = 0; i < fontLen; i++) {
const font = fonts[i]!
result += 'fontname: ' + font.name + '\n'
const chunkSize = 80
const dataLen = font.data.length
for (let j = 0; j < dataLen; j += chunkSize) {
result += font.data.slice(j, j + chunkSize) + '\n'
}
}
result += '\n'
}
if (doc.graphics && doc.graphics.length > 0) {
result += '[Graphics]\n'
const graphics = doc.graphics
const graphicLen = graphics.length
for (let i = 0; i < graphicLen; i++) {
const graphic = graphics[i]!
result += 'filename: ' + graphic.name + '\n'
const chunkSize = 80
const dataLen = graphic.data.length
for (let j = 0; j < dataLen; j += chunkSize) {
result += graphic.data.slice(j, j + chunkSize) + '\n'
}
}
result += '\n'
}
return result
}
function serializeStyle(style: Style): string {
// Convert ASS numpad alignment to SSA v4 alignment
const ssaAlignment = convertASSToSSAAlignment(style.alignment)
// SSA v4 Style format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding
return `Style: ${style.name},${style.fontName},${style.fontSize},${formatColor(style.primaryColor)},${formatColor(style.secondaryColor)},${formatColor(style.outlineColor)},${formatColor(style.backColor)},${style.bold ? -1 : 0},${style.italic ? -1 : 0},${style.borderStyle},${style.outline},${style.shadow},${ssaAlignment},${style.marginL},${style.marginR},${style.marginV},0,${style.encoding}`
}
function convertASSToSSAAlignment(assAlign: number): number {
// ASS numpad: 1=bottom-left, 2=bottom-center, 3=bottom-right, 4=mid-left, 5=mid-center, 6=mid-right, 7=top-left, 8=top-center, 9=top-right
// SSA v4: 1=left, 2=center, 3=right, 9=top-left, 10=top-center, 11=top-right
switch (assAlign) {
case 1: return 1 // bottom-left -> left
case 2: return 2 // bottom-center -> center
case 3: return 3 // bottom-right -> right
case 7: return 9 // top-left
case 8: return 10 // top-center
case 9: return 11 // top-right
default: return 2
}
}
function serializeEvent(event: SubtitleEvent): string {
const text = event.dirty && event.segments.length > 0
? serializeTags(event.segments)
: event.text
const effect = event.effect ?? ''
// SSA v4 Event format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
// Marked is always 0 (unmarked)
return `Dialogue: 0,${formatTime(event.start)},${formatTime(event.end)},${event.style},${event.actor},${event.marginL},${event.marginR},${event.marginV},${effect},${text}`
}