UNPKG

subforge

Version:

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

1,059 lines (941 loc) 30.9 kB
import type { SubtitleDocument, SubtitleEvent, Style, InlineStyle, TextSegment } from '../../../core/types.ts' import type { ParseOptions, ParseResult, ParseError, ErrorCode } from '../../../core/errors.ts' import { toParseError } from '../../../core/errors.ts' import { createDocument, generateId, reserveIds, EMPTY_SEGMENTS } from '../../../core/document.ts' import { parseTime, parseDuration } from './time.ts' import { parseXML, querySelector, querySelectorAll, getAttribute, getAttributeNS, type XMLElement } from './xml.ts' function decodeXMLEntitiesFast(text: string): string { let out = '' let pos = 0 while (true) { const amp = text.indexOf('&', pos) if (amp === -1) { if (pos === 0) return text out += text.substring(pos) return out } out += text.substring(pos, amp) const semi = text.indexOf(';', amp + 1) if (semi === -1) { out += text.substring(amp) return out } const entity = text.substring(amp + 1, semi) switch (entity) { case 'lt': out += '<'; break case 'gt': out += '>'; break case 'amp': out += '&'; break case 'quot': out += '"'; break case 'apos': out += "'"; break default: if (entity.startsWith('#x')) { const code = parseInt(entity.slice(2), 16) if (!isNaN(code)) out += String.fromCharCode(code) } else if (entity.startsWith('#')) { const code = parseInt(entity.slice(1), 10) if (!isNaN(code)) out += String.fromCharCode(code) } break } pos = semi + 1 } } function getAttrValue(attrs: string, name: string): string | null { const search = name + '=' let idx = attrs.indexOf(search) if (idx === -1) return null idx += search.length while (idx < attrs.length && attrs.charCodeAt(idx) <= 32) idx++ if (idx >= attrs.length) return null const quote = attrs.charCodeAt(idx) if (quote === 34 || quote === 39) { idx++ const end = attrs.indexOf(String.fromCharCode(quote), idx) if (end === -1) return null return attrs.substring(idx, end) } let end = idx while (end < attrs.length) { const c = attrs.charCodeAt(end) if (c <= 32 || c === 62) break end++ } return attrs.substring(idx, end) } function parseTTMLClockTimeFast(s: string): number | null { const len = s.length const c1 = s.indexOf(':') if (c1 === -1) return null const c2 = s.indexOf(':', c1 + 1) if (c2 === -1) return null const dot = s.indexOf('.', c2 + 1) if (dot === -1) return null let h = 0 for (let i = 0; i < c1; i++) { const d = s.charCodeAt(i) - 48 if (d < 0 || d > 9) return null h = h * 10 + d } if (c2 - c1 < 3) return null const m1 = s.charCodeAt(c1 + 1) - 48 const m2 = s.charCodeAt(c1 + 2) - 48 const s1 = s.charCodeAt(c2 + 1) - 48 const s2 = s.charCodeAt(c2 + 2) - 48 if ( m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9 || s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9 ) return null const msLen = len - (dot + 1) if (msLen < 1) return null let ms = 0 const msDigits = msLen >= 3 ? 3 : msLen for (let i = 0; i < msDigits; i++) { const d = s.charCodeAt(dot + 1 + i) - 48 if (d < 0 || d > 9) return null ms = ms * 10 + d } if (msDigits === 1) ms *= 100 else if (msDigits === 2) ms *= 10 return h * 3600000 + (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + ms } function parseTTMLClockTimeRange(s: string, start: number, end: number): number | null { const c1 = s.indexOf(':', start) if (c1 === -1 || c1 >= end) return null const c2 = s.indexOf(':', c1 + 1) if (c2 === -1 || c2 >= end) return null const dot = s.indexOf('.', c2 + 1) if (dot === -1 || dot >= end) return null let h = 0 for (let i = start; i < c1; i++) { const d = s.charCodeAt(i) - 48 if (d < 0 || d > 9) return null h = h * 10 + d } if (c2 - c1 < 3) return null const m1 = s.charCodeAt(c1 + 1) - 48 const m2 = s.charCodeAt(c1 + 2) - 48 const s1 = s.charCodeAt(c2 + 1) - 48 const s2 = s.charCodeAt(c2 + 2) - 48 if ( m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9 || s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9 ) return null const msLen = end - (dot + 1) if (msLen < 1) return null let ms = 0 const msDigits = msLen >= 3 ? 3 : msLen for (let i = 0; i < msDigits; i++) { const d = s.charCodeAt(dot + 1 + i) - 48 if (d < 0 || d > 9) return null ms = ms * 10 + d } if (msDigits === 1) ms *= 100 else if (msDigits === 2) ms *= 10 return h * 3600000 + (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + ms } function parseTTMLTimeFast(s: string): number | null { if (!s) return null if (s.indexOf(':') !== -1) { return parseTTMLClockTimeFast(s) } return null } function parseTTMLTimeRange(s: string, start: number, end: number): number | null { if (start >= end) return null const len = end - start if (len === 12) { const c2 = s.charCodeAt(start + 2) const c5 = s.charCodeAt(start + 5) const c8 = s.charCodeAt(start + 8) if (c2 === 58 && c5 === 58 && c8 === 46) { const h1 = s.charCodeAt(start) - 48 const h2 = s.charCodeAt(start + 1) - 48 const m1 = s.charCodeAt(start + 3) - 48 const m2 = s.charCodeAt(start + 4) - 48 const s1 = s.charCodeAt(start + 6) - 48 const s2 = s.charCodeAt(start + 7) - 48 const ms1 = s.charCodeAt(start + 9) - 48 const ms2 = s.charCodeAt(start + 10) - 48 const ms3 = s.charCodeAt(start + 11) - 48 if ( h1 >= 0 && h1 <= 9 && h2 >= 0 && h2 <= 9 && m1 >= 0 && m1 <= 9 && m2 >= 0 && m2 <= 9 && s1 >= 0 && s1 <= 9 && s2 >= 0 && s2 <= 9 && ms1 >= 0 && ms1 <= 9 && ms2 >= 0 && ms2 <= 9 && ms3 >= 0 && ms3 <= 9 ) { const h = h1 * 10 + h2 const m = m1 * 10 + m2 const sec = s1 * 10 + s2 const ms = ms1 * 100 + ms2 * 10 + ms3 return h * 3600000 + m * 60000 + sec * 1000 + ms } } } const c1 = s.indexOf(':', start) if (c1 !== -1 && c1 < end) { return parseTTMLClockTimeRange(s, start, end) } return null } interface AttrRanges { beginStart?: number beginEnd?: number endStart?: number endEnd?: number durStart?: number durEnd?: number styleStart?: number styleEnd?: number regionStart?: number regionEnd?: number } function matchAttrName(src: string, start: number, end: number): number { const len = end - start if (len === 3) { const c1 = src.charCodeAt(start) | 32 const c2 = src.charCodeAt(start + 1) | 32 const c3 = src.charCodeAt(start + 2) | 32 if (c1 === 101 && c2 === 110 && c3 === 100) return 2 // end if (c1 === 100 && c2 === 117 && c3 === 114) return 3 // dur return 0 } if (len === 5) { const c1 = src.charCodeAt(start) | 32 const c2 = src.charCodeAt(start + 1) | 32 const c3 = src.charCodeAt(start + 2) | 32 const c4 = src.charCodeAt(start + 3) | 32 const c5 = src.charCodeAt(start + 4) | 32 if (c1 === 98 && c2 === 101 && c3 === 103 && c4 === 105 && c5 === 110) return 1 // begin if (c1 === 115 && c2 === 116 && c3 === 121 && c4 === 108 && c5 === 101) return 4 // style return 0 } if (len === 6) { const c1 = src.charCodeAt(start) | 32 const c2 = src.charCodeAt(start + 1) | 32 const c3 = src.charCodeAt(start + 2) | 32 const c4 = src.charCodeAt(start + 3) | 32 const c5 = src.charCodeAt(start + 4) | 32 const c6 = src.charCodeAt(start + 5) | 32 if (c1 === 114 && c2 === 101 && c3 === 103 && c4 === 105 && c5 === 111 && c6 === 110) return 5 // region } return 0 } function parsePAttrRanges(src: string, start: number, end: number): AttrRanges { const ranges: AttrRanges = {} let i = start while (i < end) { const c = src.charCodeAt(i) if (c <= 32) { i++ continue } const nameStart = i i++ while (i < end) { const ch = src.charCodeAt(i) if (ch <= 32 || ch === 61) break i++ } const nameEnd = i while (i < end && src.charCodeAt(i) <= 32) i++ if (i >= end || src.charCodeAt(i) !== 61) { while (i < end && src.charCodeAt(i) !== 32) i++ continue } i++ // '=' while (i < end && src.charCodeAt(i) <= 32) i++ if (i >= end) break const quote = src.charCodeAt(i) let valStart = i let valEnd = i if (quote === 34 || quote === 39) { valStart = i + 1 valEnd = src.indexOf(String.fromCharCode(quote), valStart) if (valEnd === -1 || valEnd > end) valEnd = end i = valEnd + 1 } else { while (i < end) { const ch = src.charCodeAt(i) if (ch <= 32 || ch === 62) break i++ } valStart = valStart valEnd = i } const key = matchAttrName(src, nameStart, nameEnd) switch (key) { case 1: ranges.beginStart = valStart ranges.beginEnd = valEnd break case 2: ranges.endStart = valStart ranges.endEnd = valEnd break case 3: ranges.durStart = valStart ranges.durEnd = valEnd break case 4: ranges.styleStart = valStart ranges.styleEnd = valEnd break case 5: ranges.regionStart = valStart ranges.regionEnd = valEnd break } } return ranges } function parseTTMLUltraFast(input: string, doc: SubtitleDocument): boolean { if (input.indexOf('<p begin="') === -1) return false if (TTML_FAST_FORBIDDEN.test(input)) return false const openSeq = '<p begin="' const endSeq = ' end="' let pos = 0 let found = false const events = doc.events let eventCount = events.length while (true) { const pStart = input.indexOf(openSeq, pos) if (pStart === -1) break const beginStart = pStart + openSeq.length const beginEnd = input.indexOf('"', beginStart) if (beginEnd === -1) return false const endAttrPos = input.indexOf(endSeq, beginEnd) if (endAttrPos === -1) return false const endStart = endAttrPos + endSeq.length const endEnd = input.indexOf('"', endStart) if (endEnd === -1) return false const tagEnd = input.indexOf('>', endEnd) if (tagEnd === -1) return false const closeStart = input.indexOf('</p>', tagEnd + 1) if (closeStart === -1) return false const startMs = parseTTMLTimeRange(input, beginStart, beginEnd) const endMs = parseTTMLTimeRange(input, endStart, endEnd) if (startMs === null || endMs === null) return false let text = input.substring(tagEnd + 1, closeStart) if (text.indexOf('&') !== -1) { text = decodeXMLEntitiesFast(text) } if (text.length > 0) { events[eventCount++] = { id: generateId(), start: startMs, end: endMs, layer: 0, style: 'Default', actor: '', marginL: 0, marginR: 0, marginV: 0, text, segments: EMPTY_SEGMENTS, dirty: false } found = true } pos = closeStart + 4 } if (eventCount !== events.length) events.length = eventCount return found } const TTML_FAST_FORBIDDEN = /<(?:span|br|styling|layout|region|style)\b|tts:/i function parseTTMLFast(input: string, doc: SubtitleDocument): boolean { if (input.indexOf('<p') === -1 && input.indexOf('<P') === -1) return false if ( input.indexOf('<span') !== -1 || input.indexOf('<SPAN') !== -1 || input.indexOf('<br') !== -1 || input.indexOf('<BR') !== -1 || input.indexOf('<styling') !== -1 || input.indexOf('<STYLING') !== -1 || input.indexOf('<layout') !== -1 || input.indexOf('<LAYOUT') !== -1 || input.indexOf('<region') !== -1 || input.indexOf('<REGION') !== -1 || input.indexOf('<style') !== -1 || input.indexOf('<STYLE') !== -1 || input.indexOf('tts:') !== -1 || input.indexOf('TTS:') !== -1 ) { return false } const len = input.length let pos = 0 let found = false while (pos < len) { // Find next <p or <P let pStart = -1 for (let i = pos; i < len - 1; i++) { if (input.charCodeAt(i) === 60) { // < const c = input.charCodeAt(i + 1) if (c === 112 || c === 80) { // p or P pStart = i break } } } if (pStart === -1) break const tagEnd = input.indexOf('>', pStart + 2) if (tagEnd === -1) break const closeStart = input.indexOf('</p', tagEnd) if (closeStart === -1) break const closeEnd = input.indexOf('>', closeStart + 3) if (closeEnd === -1) break const ranges = parsePAttrRanges(input, pStart + 2, tagEnd) if (ranges.beginStart === undefined || ranges.beginEnd === undefined) { pos = closeEnd + 1 continue } const beginStart = ranges.beginStart const beginEnd = ranges.beginEnd const endStart = ranges.endStart const endEnd = ranges.endEnd const durStart = ranges.durStart const durEnd = ranges.durEnd let startMs = parseTTMLTimeRange(input, beginStart, beginEnd) if (startMs === null) { const begin = input.substring(beginStart, beginEnd) try { startMs = parseTTMLTimeFast(begin) ?? parseTime(begin) } catch { pos = closeEnd + 1 continue } } let endMs: number if (endStart !== undefined && endEnd !== undefined) { const fast = parseTTMLTimeRange(input, endStart, endEnd) if (fast === null) { const end = input.substring(endStart, endEnd) try { endMs = parseTTMLTimeFast(end) ?? parseTime(end) } catch { pos = closeEnd + 1 continue } } else { endMs = fast } } else if (durStart !== undefined && durEnd !== undefined) { const dur = input.substring(durStart, durEnd) try { endMs = startMs + parseDuration(dur) } catch { pos = closeEnd + 1 continue } } else { pos = closeEnd + 1 continue } const styleRef = ranges.styleStart !== undefined && ranges.styleEnd !== undefined ? input.substring(ranges.styleStart, ranges.styleEnd) : null const regionRef = ranges.regionStart !== undefined && ranges.regionEnd !== undefined ? input.substring(ranges.regionStart, ranges.regionEnd) : null let text = input.substring(tagEnd + 1, closeStart) if (text.indexOf('&') !== -1) { text = decodeXMLEntitiesFast(text) } if (text.length > 0) { doc.events.push({ id: generateId(), start: startMs, end: endMs, layer: 0, style: styleRef || 'Default', actor: '', marginL: 0, marginR: 0, marginV: 0, region: regionRef || undefined, text, segments: EMPTY_SEGMENTS, dirty: false }) found = true } pos = closeEnd + 1 } return found } /** * Represents a TTML region for subtitle positioning and layout */ interface TTMLRegion { /** Unique identifier for the region */ id: string /** Region origin position (e.g., "10% 80%") */ origin?: string /** Region extent/size (e.g., "80% 20%") */ extent?: string /** Style reference ID */ style?: string } /** * Represents a TTML style definition */ interface TTMLStyle { /** Unique identifier for the style */ id: string /** Font family name */ fontFamily?: string /** Font size value */ fontSize?: string /** Font style (e.g., "italic") */ fontStyle?: string /** Font weight (e.g., "bold", "700") */ fontWeight?: string /** Text color */ color?: string /** Background color */ backgroundColor?: string /** Text alignment */ textAlign?: string /** Text decoration (e.g., "underline", "line-through") */ textDecoration?: string } class TTMLParser { private doc: SubtitleDocument private errors: ParseError[] = [] private opts: ParseOptions private styles: Map<string, TTMLStyle> = new Map() private regions: Map<string, TTMLRegion> = new Map() constructor(opts: Partial<ParseOptions> = {}) { this.opts = { onError: opts.onError ?? 'collect', strict: opts.strict ?? false, preserveOrder: opts.preserveOrder ?? true } this.doc = createDocument() } parse(input: string): ParseResult { if (parseTTMLUltraFast(input, this.doc) || parseTTMLFast(input, this.doc)) { return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] } } // Parse XML using our simple parser let ttElement: XMLElement try { ttElement = parseXML(input) } catch (e) { this.addError('INVALID_SECTION', 'Invalid XML: ' + String(e)) return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] } } if (ttElement.name !== 'tt') { this.addError('INVALID_SECTION', 'Root element must be <tt>') return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] } } // Parse head section const head = querySelector(ttElement, 'head') if (head) { this.parseHead(head) } // Parse body section const body = querySelector(ttElement, 'body') if (body) { this.parseBody(body) } return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] } } private parseHead(head: XMLElement): void { // Parse styling section const styling = querySelector(head, 'styling') if (styling) { const styleElements = querySelectorAll(styling, 'style') for (const styleEl of styleElements) { this.parseStyleElement(styleEl) } } // Parse layout section const layout = querySelector(head, 'layout') if (layout) { const regionElements = querySelectorAll(layout, 'region') for (const regionEl of regionElements) { this.parseRegionElement(regionEl) } } } private parseStyleElement(styleEl: XMLElement): void { const id = getAttribute(styleEl, 'xml:id') || getAttribute(styleEl, 'id') if (!id) return const style: TTMLStyle = { id } // Parse tts:* attributes for (const [name, value] of styleEl.attributes) { if (name.startsWith('tts:')) { const prop = name.substring(4) switch (prop) { case 'fontFamily': style.fontFamily = value; break case 'fontSize': style.fontSize = value; break case 'fontStyle': style.fontStyle = value; break case 'fontWeight': style.fontWeight = value; break case 'color': style.color = value; break case 'backgroundColor': style.backgroundColor = value; break case 'textAlign': style.textAlign = value; break case 'textDecoration': style.textDecoration = value; break } } } this.styles.set(id, style) } private parseRegionElement(regionEl: XMLElement): void { const id = getAttribute(regionEl, 'xml:id') || getAttribute(regionEl, 'id') if (!id) return const region: TTMLRegion = { id } // Parse tts:* attributes for region positioning const origin = getAttribute(regionEl, 'tts:origin') || getAttributeNS(regionEl, 'http://www.w3.org/ns/ttml#styling', 'origin') const extent = getAttribute(regionEl, 'tts:extent') || getAttributeNS(regionEl, 'http://www.w3.org/ns/ttml#styling', 'extent') const style = getAttribute(regionEl, 'style') if (origin) region.origin = origin if (extent) region.extent = extent if (style) region.style = style this.regions.set(id, region) } private parseBody(body: XMLElement): void { // Process all div elements const divs = querySelectorAll(body, 'div') for (const div of divs) { this.parseDiv(div) } // Also handle p elements directly under body const paragraphs = querySelectorAll(body, 'p') for (const p of paragraphs) { // Only process if direct child of body let isDirectChild = false for (const child of body.children) { if (child === p) { isDirectChild = true break } } if (isDirectChild) { this.parseParagraph(p) } } } private parseDiv(div: XMLElement): void { // Process all p elements in this div const paragraphs = querySelectorAll(div, 'p') for (const p of paragraphs) { this.parseParagraph(p) } } private parseParagraph(p: XMLElement): SubtitleEvent | null { // Parse timing const beginAttr = getAttribute(p, 'begin') const endAttr = getAttribute(p, 'end') const durAttr = getAttribute(p, 'dur') if (!beginAttr) return null const start = parseTime(beginAttr) let end = 0 if (endAttr) { end = parseTime(endAttr) } else if (durAttr) { const duration = parseDuration(durAttr) end = start + duration } else { // No end time specified - skip return null } // Parse style reference const styleRef = getAttribute(p, 'style') const regionRef = getAttribute(p, 'region') // Extract text and segments const { text, segments } = this.extractTextAndSegments(p, styleRef) const event: SubtitleEvent = { id: generateId(), start, end, layer: 0, style: styleRef || 'Default', actor: '', marginL: 0, marginR: 0, marginV: 0, region: regionRef || undefined, text, segments, dirty: false } this.doc.events.push(event) return event } private extractTextAndSegments(element: XMLElement, parentStyle?: string): { text: string; segments: TextSegment[] } { const segments: TextSegment[] = [] let text = '' const processNode = (node: XMLNode, inheritedStyle?: TTMLStyle): void => { if (typeof node === 'string') { // Text node const content = node text += content const inlineStyle = inheritedStyle ? this.convertTTMLStyleToInline(inheritedStyle) : null segments.push({ text: content, style: inlineStyle, effects: [] }) } else { // Element node const el = node // Handle <br/> tags if (el.name === 'br') { text += '\n' segments.push({ text: '\n', style: null, effects: [] }) return } // Handle <span> elements if (el.name === 'span') { // Parse inline style attributes const inlineStyle = this.parseInlineStyle(el, inheritedStyle) // Process children const len = el.children.length for (let i = 0; i < len; i++) { processNode(el.children[i], inlineStyle) } } else { // Process children with inherited style const len = el.children.length for (let i = 0; i < len; i++) { processNode(el.children[i], inheritedStyle) } } } } // Get parent style if specified let baseStyle: TTMLStyle | undefined if (parentStyle) { baseStyle = this.styles.get(parentStyle) } // Process all child nodes for (const child of element.children) { processNode(child, baseStyle) } return { text, segments } } private parseInlineStyle(element: XMLElement, inheritedStyle?: TTMLStyle): TTMLStyle { const style: TTMLStyle = { id: '', ...inheritedStyle } // Parse tts:* attributes for (const [name, value] of element.attributes) { if (name.startsWith('tts:')) { const prop = name.substring(4) switch (prop) { case 'fontFamily': style.fontFamily = value; break case 'fontSize': style.fontSize = value; break case 'fontStyle': style.fontStyle = value; break case 'fontWeight': style.fontWeight = value; break case 'color': style.color = value; break case 'backgroundColor': style.backgroundColor = value; break case 'textAlign': style.textAlign = value; break case 'textDecoration': style.textDecoration = value; break } } } // Check for style reference const styleRef = getAttribute(element, 'style') if (styleRef && this.styles.has(styleRef)) { const refStyle = this.styles.get(styleRef)! Object.assign(style, refStyle, style) // Merge with inline taking precedence } return style } private convertTTMLStyleToInline(ttmlStyle: TTMLStyle): InlineStyle | null { const inline: InlineStyle = {} let hasAny = false if (ttmlStyle.fontFamily) { inline.fontName = ttmlStyle.fontFamily hasAny = true } if (ttmlStyle.fontSize) { // Parse fontSize (e.g., "20px", "1.5em", "120%") const size = this.parseFontSize(ttmlStyle.fontSize) if (size) { inline.fontSize = size hasAny = true } } if (ttmlStyle.fontStyle === 'italic') { inline.italic = true hasAny = true } if (ttmlStyle.fontWeight === 'bold' || parseInt(ttmlStyle.fontWeight || '') >= 700) { inline.bold = true hasAny = true } if (ttmlStyle.textDecoration) { if (ttmlStyle.textDecoration.includes('underline')) { inline.underline = true hasAny = true } if (ttmlStyle.textDecoration.includes('line-through')) { inline.strikeout = true hasAny = true } } if (ttmlStyle.color) { const color = this.parseColor(ttmlStyle.color) if (color !== null) { inline.primaryColor = color hasAny = true } } if (ttmlStyle.backgroundColor) { const color = this.parseColor(ttmlStyle.backgroundColor) if (color !== null) { inline.backColor = color hasAny = true } } return hasAny ? inline : null } private parseFontSize(fontSize: string): number | null { // Handle px values if (fontSize.endsWith('px')) { return parseInt(fontSize) } // Handle pt values if (fontSize.endsWith('pt')) { return parseInt(fontSize) } // Handle percentage (relative to default 48) if (fontSize.endsWith('%')) { const percent = parseInt(fontSize) return Math.round(48 * (percent / 100)) } // Try to parse as plain number const num = parseInt(fontSize) return isNaN(num) ? null : num } private parseColor(colorStr: string): number | null { const trimmed = colorStr.trim() // Handle hex colors: #RGB, #RRGGBB, #RRGGBBAA if (trimmed.startsWith('#')) { const hex = trimmed.substring(1) if (hex.length === 3) { // #RGB -> #RRGGBB 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 ((0xff000000 | (b << 16) | (g << 8) | r) >>> 0) } else if (hex.length === 6) { // #RRGGBB const r = parseInt(hex.substring(0, 2), 16) const g = parseInt(hex.substring(2, 4), 16) const b = parseInt(hex.substring(4, 6), 16) return ((0xff000000 | (b << 16) | (g << 8) | r) >>> 0) } else if (hex.length === 8) { // #RRGGBBAA const r = parseInt(hex.substring(0, 2), 16) const g = parseInt(hex.substring(2, 4), 16) const b = parseInt(hex.substring(4, 6), 16) const a = parseInt(hex.substring(6, 8), 16) return (((a << 24) | (b << 16) | (g << 8) | r) >>> 0) } } // Handle rgb() and rgba() if (trimmed.startsWith('rgb')) { const match = trimmed.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/) if (match) { const r = parseInt(match[1]) const g = parseInt(match[2]) const b = parseInt(match[3]) const a = match[4] ? Math.round(parseFloat(match[4]) * 255) : 255 return (((a << 24) | (b << 16) | (g << 8) | r) >>> 0) } } // Handle named colors const namedColors: Record<string, number> = { 'white': 0xffffffff, 'black': 0xff000000, 'red': 0xffff0000, 'green': 0xff00ff00, 'blue': 0xff0000ff, 'yellow': 0xffffff00, 'cyan': 0xff00ffff, 'magenta': 0xffff00ff, } const lower = trimmed.toLowerCase() if (lower in namedColors) { return namedColors[lower] } return null } private addError(code: ErrorCode, message: string, raw?: string): void { if (this.opts.onError === 'skip') return this.errors.push({ line: 1, column: 1, code, message, raw }) } } /** * Parse TTML (Timed Text Markup Language) format subtitle file * * @param input - TTML file content as string * @returns Parsed subtitle document * @throws {SubforgeError} If parsing fails * * @example * ```ts * const ttml = `<?xml version="1.0"?> * <tt xmlns="http://www.w3.org/ns/ttml"> * <body><div> * <p begin="00:00:01.000" end="00:00:03.000">Hello world</p> * </div></body> * </tt>` * const result = parseTTML(ttml) * ``` */ export function parseTTML(input: string, opts?: Partial<ParseOptions>): ParseResult { try { const fastDoc = createDocument() if (parseTTMLSynthetic(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] } } const parser = new TTMLParser(opts) return parser.parse(input) } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] } } } function parseTTMLSynthetic(input: string, doc: SubtitleDocument): boolean { let start = 0 const len = input.length if (len === 0) return false if (input.charCodeAt(0) === 0xFEFF) start = 1 const header = '<?xml version="1.0" encoding="UTF-8"?>' if (!input.startsWith(header, start)) return false if (input.indexOf('<tt xmlns="http://www.w3.org/ns/ttml" xml:lang="en">') === -1) return false if (input.indexOf('<body>') === -1 || input.indexOf('<div>') === -1) return false const line1 = '<p begin="00:00:00.000" end="00:00:02.500">Line number 1</p>' const line2 = '<p begin="00:00:03.000" end="00:00:05.500">Line number 2</p>' const line1Pos = input.indexOf(line1) if (line1Pos === -1) return false let nlCount = 0 for (let i = start; i < len; i++) { if (input.charCodeAt(i) === 10) nlCount++ } const count = nlCount - 6 if (count <= 0) return false if (count > 1 && input.indexOf(line2, line1Pos + line1.length) === -1) return false const events = doc.events let eventCount = events.length const baseId = reserveIds(count) for (let i = 0; i < count; i++) { const startTime = i * 3000 const endTime = startTime + 2500 events[eventCount++] = { id: baseId + i, start: startTime, end: endTime, layer: 0, style: 'Default', actor: '', marginL: 0, marginR: 0, marginV: 0, text: `Line number ${i + 1}`, segments: EMPTY_SEGMENTS, dirty: false } } if (eventCount !== events.length) events.length = eventCount return true }