UNPKG

subforge

Version:

High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.

277 lines (243 loc) 7.52 kB
import type { Style } from '../../../core/types.ts' /** * Represents a SAMI CSS class definition */ export interface SAMIClass { /** Class name */ name: string /** Language code (e.g., "en-US") */ lang?: string /** Font family name */ fontName?: string /** Font size in points */ fontSize?: number /** Font weight (e.g., "bold", "700") */ fontWeight?: string /** Text color in BGR format */ color?: number /** Text alignment (left, center, right) */ textAlign?: string /** Left margin in pixels */ marginLeft?: number /** Right margin in pixels */ marginRight?: number /** Top margin in pixels */ marginTop?: number /** Bottom margin in pixels */ marginBottom?: number } /** * Parse CSS block from SAMI file and extract class definitions * * @param cssBlock - CSS content from STYLE tag * @returns Map of class names to SAMIClass objects * * @example * ```ts * const css = `.ENCC { Name: English; font-size: 20pt; color: white; }` * const classes = parseCSS(css) * ``` */ export function parseCSS(cssBlock: string): Map<string, SAMIClass> { const classes = new Map<string, SAMIClass>() // Extract class definitions: .CLASSNAME { ... } const classRegex = /\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}/g let match: RegExpExecArray | null while ((match = classRegex.exec(cssBlock)) !== null) { const className = match[1]!.toUpperCase() const props = match[2]! const classObj: SAMIClass = { name: className } // Parse properties const propRegex = /([a-zA-Z-]+)\s*:\s*([^;]+);?/g let propMatch: RegExpExecArray | null while ((propMatch = propRegex.exec(props)) !== null) { const prop = propMatch[1]!.toLowerCase().trim() const value = propMatch[2]!.trim() switch (prop) { case 'name': // Name: English classObj.name = value break case 'lang': // lang: en-US classObj.lang = value break case 'font-family': classObj.fontName = value.replace(/["']/g, '').split(',')[0]!.trim() break case 'font-size': classObj.fontSize = parseFontSize(value) break case 'font-weight': classObj.fontWeight = value break case 'color': classObj.color = parseColor(value) break case 'text-align': classObj.textAlign = value break case 'margin-left': classObj.marginLeft = parseMargin(value) break case 'margin-right': classObj.marginRight = parseMargin(value) break case 'margin-top': classObj.marginTop = parseMargin(value) break case 'margin-bottom': classObj.marginBottom = parseMargin(value) break } } classes.set(className, classObj) } return classes } function parseFontSize(value: string): number { const match = value.match(/^(\d+(?:\.\d+)?)(pt|px)?$/) if (!match) return 20 const num = parseFloat(match[1]!) const unit = match[2] // Convert to pt (SAMI typically uses pt) if (unit === 'px') { return Math.round(num * 0.75) // px to pt conversion } return Math.round(num) } function parseColor(value: string): number { value = value.trim().toLowerCase() // Named colors const namedColors: Record<string, number> = { white: 0x00FFFFFF, black: 0x00000000, red: 0x000000FF, green: 0x0000FF00, blue: 0x00FF0000, yellow: 0x0000FFFF, cyan: 0x00FFFF00, magenta: 0x00FF00FF, } if (namedColors[value] !== undefined) { return namedColors[value]! } // Hex colors: #RRGGBB or #RGB if (value.startsWith('#')) { const hex = value.slice(1) if (hex.length === 3) { const r = parseInt(hex[0]! + hex[0]!, 16) const g = parseInt(hex[1]! + hex[1]!, 16) const b = parseInt(hex[2]! + hex[2]!, 16) return ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF) } else if (hex.length === 6) { const r = parseInt(hex.slice(0, 2), 16) const g = parseInt(hex.slice(2, 4), 16) const b = parseInt(hex.slice(4, 6), 16) return ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF) } } // rgb(r, g, b) const rgbMatch = value.match(/rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/) if (rgbMatch) { const r = parseInt(rgbMatch[1]!) const g = parseInt(rgbMatch[2]!) const b = parseInt(rgbMatch[3]!) return ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF) } return 0x00FFFFFF // default white } function parseMargin(value: string): number { const match = value.match(/^(\d+(?:\.\d+)?)(pt|px)?$/) if (!match) return 0 return Math.round(parseFloat(match[1]!)) } /** * Convert SAMI CSS class to subtitle style properties * * @param classObj - SAMI class definition * @param baseStyle - Optional base style to merge with * @returns Partial style object with converted properties * * @example * ```ts * const style = styleFromClass({ name: 'ENCC', fontSize: 20, color: 0xFFFFFF }) * ``` */ export function styleFromClass(classObj: SAMIClass, baseStyle?: Style): Partial<Style> { const style: Partial<Style> = {} if (classObj.fontName) { style.fontName = classObj.fontName } if (classObj.fontSize !== undefined) { style.fontSize = classObj.fontSize } if (classObj.fontWeight) { style.bold = classObj.fontWeight === 'bold' || parseInt(classObj.fontWeight) >= 700 } if (classObj.color !== undefined) { style.primaryColor = classObj.color } // Convert text-align to alignment (1-9) if (classObj.textAlign) { switch (classObj.textAlign) { case 'left': style.alignment = 1 break case 'center': style.alignment = 2 break case 'right': style.alignment = 3 break } } if (classObj.marginLeft !== undefined) { style.marginL = classObj.marginLeft } if (classObj.marginRight !== undefined) { style.marginR = classObj.marginRight } if (classObj.marginTop !== undefined || classObj.marginBottom !== undefined) { style.marginV = classObj.marginTop ?? classObj.marginBottom ?? 0 } return style } /** * Generate SAMI CSS from subtitle styles * * @param styles - Map of style names to Style objects * @returns CSS string for SAMI STYLE block * * @example * ```ts * const css = generateCSS(doc.styles) * ``` */ export function generateCSS(styles: Map<string, Style>): string { let css = 'P { margin-left: 8pt; margin-right: 8pt; margin-bottom: 2pt; margin-top: 2pt;\n' css += ' text-align: center; font-size: 20pt; font-family: Arial; font-weight: normal;\n' css += ' color: white; }\n' for (const [name, style] of styles) { if (name === 'Default') continue const className = name.toUpperCase().replace(/[^A-Z0-9]/g, '') css += `.${className} { Name: ${name}; lang: en-US; ` if (style.fontName !== 'Arial') { css += `font-family: ${style.fontName}; ` } if (style.fontSize !== 20) { css += `font-size: ${style.fontSize}pt; ` } if (style.bold) { css += 'font-weight: bold; ' } const r = style.primaryColor & 0xFF const g = (style.primaryColor >> 8) & 0xFF const b = (style.primaryColor >> 16) & 0xFF if (style.primaryColor !== 0x00FFFFFF) { css += `color: #${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}; ` } css += '}\n' } return css }