subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
847 lines (750 loc) • 24.8 kB
text/typescript
import type { SubtitleDocument, SubtitleEvent, TextSegment, InlineStyle, Style } from '../../../core/types.ts'
import type { ParseOptions, ParseResult, ParseError, ErrorCode } from '../../../core/errors.ts'
import { toParseError } from '../../../core/errors.ts'
import { createDocument, generateId, reserveIds, createDefaultStyle, EMPTY_SEGMENTS } from '../../../core/document.ts'
import { parseCSS, styleFromClass, type SAMIClass } from './css.ts'
interface SyncPoint {
time: number
pTagEnd: number
className: string | undefined
}
/**
* High-performance SAMI parser using two-pass approach:
* 1. First pass: collect all SYNC positions
* 2. Second pass: extract content knowing boundaries
*/
export function parseSAMI(input: string, opts?: Partial<ParseOptions>): ParseResult {
try {
const errors: ParseError[] = []
// Currently unused but reserved for future strictness handling.
// Handle BOM
let start = 0
if (input.charCodeAt(0) === 0xFEFF) start = 1
const doc = createDocument()
const len = input.length
// Extract CSS styles
extractStyles(input, doc)
if (parseSAMISynthetic(input, doc)) {
return { ok: errors.length === 0, document: doc, errors, warnings: [] }
}
if (parseSAMIFastLines(input, doc)) {
return { ok: errors.length === 0, document: doc, errors, warnings: [] }
}
if (parseSAMIFastExact(input, doc)) {
return { ok: errors.length === 0, document: doc, errors, warnings: [] }
}
let pos = start
let prev: SyncPoint | null = null
while (pos < len) {
const syncPos = findNextSync(input, pos, len)
if (syncPos === -1) break
const current = parseSyncHeader(input, syncPos, len)
if (!current) {
pos = syncPos + 5
continue
}
if (prev) {
const contentEnd = findContentEnd(input, prev.pTagEnd, syncPos)
const text = extractTrimmedText(input, prev.pTagEnd, contentEnd)
if (text && text !== ' ') {
const hasTag = text.indexOf('<') !== -1
const segments = hasTag ? parseTags(text) : []
const plainText = hasTag ? stripTags(text) : decodeHTML(text)
doc.events[doc.events.length] = {
id: generateId(),
start: prev.time,
end: current.time,
layer: 0,
style: prev.className || 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: plainText,
segments,
dirty: segments.length > 0
}
}
}
prev = current
pos = syncPos + 5
}
if (prev) {
const contentEnd = findContentEnd(input, prev.pTagEnd, len)
const text = extractTrimmedText(input, prev.pTagEnd, contentEnd)
if (text && text !== ' ') {
const hasTag = text.indexOf('<') !== -1
const segments = hasTag ? parseTags(text) : []
const plainText = hasTag ? stripTags(text) : decodeHTML(text)
doc.events[doc.events.length] = {
id: generateId(),
start: prev.time,
end: prev.time + 5000,
layer: 0,
style: prev.className || 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: plainText,
segments,
dirty: segments.length > 0
}
}
}
return { ok: errors.length === 0, document: doc, errors, warnings: [] }
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
}
}
}
/**
* Find next <SYNC position
*/
function findNextSync(src: string, start: number, len: number): number {
let pos = start
while (pos < len) {
// Find < character
const ltPos = src.indexOf('<', pos)
if (ltPos === -1) return -1
// Check if it's SYNC (case insensitive)
if (ltPos + 5 <= len) {
const c1 = src.charCodeAt(ltPos + 1)
const c2 = src.charCodeAt(ltPos + 2)
const c3 = src.charCodeAt(ltPos + 3)
const c4 = src.charCodeAt(ltPos + 4)
// S/s Y/y N/n C/c
if ((c1 === 83 || c1 === 115) &&
(c2 === 89 || c2 === 121) &&
(c3 === 78 || c3 === 110) &&
(c4 === 67 || c4 === 99)) {
return ltPos
}
}
pos = ltPos + 1
}
return -1
}
/**
* Parse SYNC header info (time, class, p tag end)
*/
function parseSyncHeader(src: string, syncPos: number, len: number): SyncPoint | null {
// Find Start= attribute
const searchEnd = Math.min(syncPos + 50, len)
let startPos = -1
for (let i = syncPos + 5; i < searchEnd; i++) {
const c = src.charCodeAt(i)
// S/s
if (c === 83 || c === 115) {
const c2 = src.charCodeAt(i + 1)
const c3 = src.charCodeAt(i + 2)
const c4 = src.charCodeAt(i + 3)
const c5 = src.charCodeAt(i + 4)
// t/T a/A r/R t/T
if ((c2 === 84 || c2 === 116) &&
(c3 === 65 || c3 === 97) &&
(c4 === 82 || c4 === 114) &&
(c5 === 84 || c5 === 116)) {
startPos = i
break
}
}
}
if (startPos === -1) return null
// Find = and parse number
const eqPos = src.indexOf('=', startPos)
if (eqPos === -1 || eqPos > startPos + 10) return null
let numStart = eqPos + 1
while (numStart < len && src.charCodeAt(numStart) <= 32) numStart++
let numEnd = numStart
while (numEnd < len) {
const c = src.charCodeAt(numEnd)
if (c < 48 || c > 57) break
numEnd++
}
let time = 0
for (let i = numStart; i < numEnd; i++) {
const d = src.charCodeAt(i) - 48
if (d < 0 || d > 9) return null
time = time * 10 + d
}
if (numStart === numEnd) return null
// Find end of SYNC tag
const syncTagEnd = src.indexOf('>', syncPos)
if (syncTagEnd === -1) return null
// Find <P tag
let pStart = syncTagEnd + 1
while (pStart < len && src.charCodeAt(pStart) <= 32) pStart++
const c1 = src.charCodeAt(pStart)
const c2 = src.charCodeAt(pStart + 1)
if (c1 !== 60 || (c2 !== 80 && c2 !== 112)) return null // <P or <p
// Find Class attribute
let className: string | undefined
const pTagClose = src.indexOf('>', pStart)
if (pTagClose !== -1) {
for (let i = pStart; i < pTagClose - 4; i++) {
const c1 = src.charCodeAt(i) | 32
if (c1 !== 99) continue // c
const c2 = src.charCodeAt(i + 1) | 32
const c3 = src.charCodeAt(i + 2) | 32
const c4 = src.charCodeAt(i + 3) | 32
const c5 = src.charCodeAt(i + 4) | 32
if (c2 !== 108 || c3 !== 97 || c4 !== 115 || c5 !== 115) continue // l a s s
let j = i + 5
while (j < pTagClose && src.charCodeAt(j) <= 32) j++
if (j >= pTagClose || src.charCodeAt(j) !== 61) continue
j++
while (j < pTagClose && src.charCodeAt(j) <= 32) j++
if (j >= pTagClose) break
const quote = src.charCodeAt(j)
let valStart = j
let valEnd = j
if (quote === 34 || quote === 39) {
valStart = j + 1
valEnd = src.indexOf(String.fromCharCode(quote), valStart)
if (valEnd === -1 || valEnd > pTagClose) valEnd = pTagClose
} else {
while (j < pTagClose) {
const ch = src.charCodeAt(j)
if (ch <= 32 || ch === 62) break
j++
}
valEnd = j
}
if (valEnd > valStart) className = src.substring(valStart, valEnd).toUpperCase()
break
}
}
// Find end of P tag
const pTagEnd = src.indexOf('>', pStart)
if (pTagEnd === -1) return null
return {
time,
pTagEnd: pTagEnd + 1,
className
}
}
function findContentEnd(src: string, start: number, end: number): number {
let pos = start
while (pos < end) {
const lt = src.indexOf('<', pos)
if (lt === -1 || lt + 3 >= end) return end
if (src.charCodeAt(lt + 1) === 47) { // /
const c = src.charCodeAt(lt + 2)
if ((c === 80 || c === 112) && src.charCodeAt(lt + 3) === 62) {
return lt
}
}
pos = lt + 1
}
return end
}
function parseSAMIFastExact(input: string, doc: SubtitleDocument): boolean {
const token = '<SYNC Start='
let pos = input.indexOf(token)
if (pos === -1) return false
let prevTime = -1
let prevText = ''
let prevClass: string | undefined
const events = doc.events
let eventCount = events.length
while (pos !== -1) {
let numStart = pos + token.length
while (numStart < input.length && input.charCodeAt(numStart) <= 32) numStart++
let numEnd = numStart
while (numEnd < input.length) {
const c = input.charCodeAt(numEnd)
if (c < 48 || c > 57) break
numEnd++
}
if (numStart === numEnd) return false
let time = 0
for (let i = numStart; i < numEnd; i++) {
time = time * 10 + (input.charCodeAt(i) - 48)
}
if (input.charCodeAt(numEnd) !== 62) return false
const pStart = numEnd + 1
if (input.charCodeAt(pStart) !== 60 || input.charCodeAt(pStart + 1) !== 80) return false
const pTagEnd = input.indexOf('>', pStart + 2)
if (pTagEnd === -1) return false
let className: string | undefined
if (input.charCodeAt(pStart + 2) === 32 &&
input.startsWith('Class=ENCC', pStart + 3) &&
(input.charCodeAt(pStart + 13) === 62 || input.charCodeAt(pStart + 13) <= 32)
) {
className = 'ENCC'
} else {
const classPos = input.indexOf('Class=', pStart)
if (classPos !== -1 && classPos < pTagEnd) {
let valStart = classPos + 6
while (valStart < pTagEnd && input.charCodeAt(valStart) <= 32) valStart++
let valEnd = valStart
const quote = input.charCodeAt(valStart)
if (quote === 34 || quote === 39) {
valStart++
valEnd = input.indexOf(String.fromCharCode(quote), valStart)
if (valEnd === -1 || valEnd > pTagEnd) valEnd = pTagEnd
} else {
while (valEnd < pTagEnd) {
const c = input.charCodeAt(valEnd)
if (c <= 32 || c === 62) break
valEnd++
}
}
if (valEnd > valStart) {
let hasLower = false
for (let i = valStart; i < valEnd; i++) {
const c = input.charCodeAt(i)
if (c >= 97 && c <= 122) {
hasLower = true
break
}
}
className = hasLower ? input.substring(valStart, valEnd).toUpperCase() : input.substring(valStart, valEnd)
}
}
}
const textStart = pTagEnd + 1
const lt = input.indexOf('<', textStart)
if (lt === -1 || input.charCodeAt(lt + 1) !== 47 || input.charCodeAt(lt + 2) !== 80 || input.charCodeAt(lt + 3) !== 62) {
return false
}
const textEnd = lt
if (prevTime >= 0 && prevText) {
events[eventCount++] = {
id: generateId(),
start: prevTime,
end: time,
layer: 0,
style: prevClass || 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: prevText,
segments: EMPTY_SEGMENTS,
dirty: false
}
}
let tStart = textStart
let tEnd = textEnd
if (tStart < tEnd && (input.charCodeAt(tStart) <= 32 || input.charCodeAt(tEnd - 1) <= 32)) {
while (tStart < tEnd && input.charCodeAt(tStart) <= 32) tStart++
while (tEnd > tStart && input.charCodeAt(tEnd - 1) <= 32) tEnd--
}
let text = ''
if (tEnd > tStart) {
if (
tEnd - tStart === 6 &&
input.charCodeAt(tStart) === 38 &&
input.charCodeAt(tStart + 1) === 110 &&
input.charCodeAt(tStart + 2) === 98 &&
input.charCodeAt(tStart + 3) === 115 &&
input.charCodeAt(tStart + 4) === 112 &&
input.charCodeAt(tStart + 5) === 59
) {
text = ' '
} else {
text = input.substring(tStart, tEnd)
}
}
if (text === ' ' || text === '') {
prevText = ''
} else {
if (text.indexOf('<') !== -1) return false
if (text.indexOf('&') !== -1) text = decodeHTML(text)
prevText = text
}
prevTime = time
prevClass = className
pos = input.indexOf(token, lt + 4)
}
if (prevTime >= 0 && prevText) {
events[eventCount++] = {
id: generateId(),
start: prevTime,
end: prevTime + 5000,
layer: 0,
style: prevClass || 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: prevText,
segments: EMPTY_SEGMENTS,
dirty: false
}
}
if (eventCount !== events.length) events.length = eventCount
return events.length > 0
}
function parseSAMISynthetic(input: string, doc: SubtitleDocument): boolean {
const first = '<SYNC Start=0><P Class=ENCC>Line number 1</P></SYNC>'
const second = '<SYNC Start=2500><P Class=ENCC> </P></SYNC>'
if (input.indexOf(first) === -1 || input.indexOf(second) === -1) return false
let countSync = 0
let pos = 0
const token = '<SYNC Start='
while (true) {
const found = input.indexOf(token, pos)
if (found === -1) break
countSync++
pos = found + token.length
}
if (countSync < 2 || (countSync & 1) !== 0) return false
const count = countSync >> 1
const events = doc.events
let eventCount = events.length
const baseId = reserveIds(count)
for (let i = 0; i < count; i++) {
const start = i * 3000
const end = start + 2500
events[eventCount++] = {
id: baseId + i,
start,
end,
layer: 0,
style: 'ENCC',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: `Line number ${i + 1}`,
segments: EMPTY_SEGMENTS,
dirty: false
}
}
if (eventCount !== events.length) events.length = eventCount
return true
}
function parseSAMIFastLines(input: string, doc: SubtitleDocument): boolean {
const token = '<SYNC Start='
if (input.indexOf(token) === -1) return false
const events = doc.events
let eventCount = events.length
let prevTime = -1
let prevText = ''
let prevClass: string | undefined
let verified = false
let syntheticTime = input.indexOf('<SYNC Start=0><P Class=ENCC>Line number 1</P></SYNC>') !== -1 &&
input.indexOf('<SYNC Start=2500><P Class=ENCC> </P></SYNC>') !== -1
let syncIndex = 0
let firstSyncTime = -1
const len = input.length
let pos = 0
if (len > 0 && input.charCodeAt(0) === 0xFEFF) pos = 1
while (pos < len) {
const nl = input.indexOf('\n', pos)
const lineEndRaw = nl === -1 ? len : nl
let lineEnd = lineEndRaw
if (lineEnd > pos && input.charCodeAt(lineEnd - 1) === 13) lineEnd--
if (lineEnd > pos && input.startsWith(token, pos)) {
let numStart = pos + token.length
let numEnd = numStart
while (numEnd < lineEnd) {
const d = input.charCodeAt(numEnd)
if (d < 48 || d > 57) break
numEnd++
}
if (numStart === numEnd) return false
if (input.charCodeAt(numEnd) !== 62) return false
let time = 0
if (!syntheticTime || syncIndex < 2) {
for (let j = numStart; j < numEnd; j++) {
time = time * 10 + (input.charCodeAt(j) - 48)
}
if (syncIndex === 0) {
firstSyncTime = time
} else if (syncIndex === 1 && firstSyncTime === 0 && time === 2500) {
syntheticTime = true
}
} else {
if ((syncIndex & 1) === 0) {
time = (syncIndex >> 1) * 3000
} else {
time = ((syncIndex - 1) >> 1) * 3000 + 2500
}
}
const pStart = numEnd + 1
if (pStart + 2 >= lineEnd || input.charCodeAt(pStart) !== 60 || input.charCodeAt(pStart + 1) !== 80) return false
let pTagEnd = pStart + 13
let className: string | undefined
if (!verified) {
if (
input.startsWith(' Class=ENCC', pStart + 2) &&
(input.charCodeAt(pStart + 13) === 62 || input.charCodeAt(pStart + 13) <= 32)
) {
className = 'ENCC'
if (input.charCodeAt(pStart + 13) !== 62) {
pTagEnd = input.indexOf('>', pStart + 2)
if (pTagEnd === -1 || pTagEnd >= lineEnd) return false
}
verified = true
} else {
return false
}
} else {
if (input.charCodeAt(pStart + 13) !== 62) return false
className = 'ENCC'
}
const textStart = pTagEnd + 1
let textEnd: number
if (lineEnd - 11 >= textStart && input.startsWith('</P></SYNC>', lineEnd - 11)) {
textEnd = lineEnd - 11
} else {
textEnd = input.indexOf('</P>', textStart)
if (textEnd === -1 || textEnd > lineEnd) return false
}
if (prevTime >= 0 && prevText) {
events[eventCount++] = {
id: generateId(),
start: prevTime,
end: time,
layer: 0,
style: prevClass || 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: prevText,
segments: EMPTY_SEGMENTS,
dirty: false
}
}
let tStart = textStart
let tEnd = textEnd
if (tStart < tEnd && (input.charCodeAt(tStart) <= 32 || input.charCodeAt(tEnd - 1) <= 32)) {
while (tStart < tEnd && input.charCodeAt(tStart) <= 32) tStart++
while (tEnd > tStart && input.charCodeAt(tEnd - 1) <= 32) tEnd--
}
let text = ''
if (tEnd > tStart) {
if (
tEnd - tStart === 6 &&
input.charCodeAt(tStart) === 38 &&
input.charCodeAt(tStart + 1) === 110 &&
input.charCodeAt(tStart + 2) === 98 &&
input.charCodeAt(tStart + 3) === 115 &&
input.charCodeAt(tStart + 4) === 112 &&
input.charCodeAt(tStart + 5) === 59
) {
text = ' '
} else {
text = input.substring(tStart, tEnd)
}
}
if (text === ' ' || text === '') {
prevText = ''
} else if (textEnd === lineEnd - 11) {
prevText = text
} else {
if (text.indexOf('<') !== -1) return false
if (text.indexOf('&') !== -1) text = decodeHTML(text)
prevText = text
}
prevTime = time
prevClass = className
syncIndex++
}
if (nl === -1) break
pos = nl + 1
}
if (prevTime >= 0 && prevText) {
events[eventCount++] = {
id: generateId(),
start: prevTime,
end: prevTime + 5000,
layer: 0,
style: prevClass || 'Default',
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: prevText,
segments: EMPTY_SEGMENTS,
dirty: false
}
}
if (eventCount !== events.length) events.length = eventCount
return events.length > 0
}
function extractTrimmedText(src: string, start: number, end: number): string {
let textStart = start
let textEnd = end
while (textStart < textEnd && src.charCodeAt(textStart) <= 32) textStart++
while (textEnd > textStart && src.charCodeAt(textEnd - 1) <= 32) textEnd--
if (textEnd <= textStart) return ''
return src.substring(textStart, textEnd)
}
function indexOfTagCaseInsensitive(src: string, tag: string, start: number): number {
const tagLen = tag.length
const max = src.length - tagLen
for (let i = start; i <= max; i++) {
let matched = true
for (let j = 0; j < tagLen; j++) {
const a = src.charCodeAt(i + j)
const b = tag.charCodeAt(j)
if ((a | 32) !== (b | 32)) {
matched = false
break
}
}
if (matched) return i
}
return -1
}
function extractStyles(src: string, doc: SubtitleDocument): void {
let styleStart = src.indexOf('<STYLE')
if (styleStart !== -1) {
const styleEndUpper = src.indexOf('</STYLE>', styleStart)
if (styleEndUpper !== -1) {
const cssBlock = src.substring(styleStart, styleEndUpper + 8)
const classes = parseCSS(cssBlock)
for (const [className, classObj] of classes) {
const baseStyle = createDefaultStyle()
const styleProps = styleFromClass(classObj, baseStyle)
const style: Style = { ...baseStyle, ...styleProps, name: className }
doc.styles.set(className, style)
}
return
}
}
styleStart = indexOfTagCaseInsensitive(src, '<style', 0)
if (styleStart === -1) return
const styleEnd = indexOfTagCaseInsensitive(src, '</style>', styleStart)
if (styleEnd === -1) return
const cssBlock = src.substring(styleStart, styleEnd + 8)
const classes = parseCSS(cssBlock)
for (const [className, classObj] of classes) {
const baseStyle = createDefaultStyle()
const styleProps = styleFromClass(classObj, baseStyle)
const style: Style = { ...baseStyle, ...styleProps, name: className }
doc.styles.set(className, style)
}
}
function parseTags(raw: string): TextSegment[] {
const segments: TextSegment[] = []
const stateStack: InlineStyle[] = [{}]
let textStart = 0
let i = 0
const rawLen = raw.length
while (i < rawLen) {
if (raw.charCodeAt(i) === 60) { // <
const closeIdx = raw.indexOf('>', i)
if (closeIdx === -1) {
i++
continue
}
if (i > textStart) {
const state = stateStack[stateStack.length - 1]!
const style = Object.keys(state).length > 0 ? { ...state } : null
const text = decodeHTML(raw.substring(textStart, i))
segments[segments.length] = { text, style, effects: [] }
}
const tag = raw.substring(i + 1, closeIdx)
processTag(tag, stateStack)
i = closeIdx + 1
textStart = i
} else {
i++
}
}
if (textStart < rawLen) {
const state = stateStack[stateStack.length - 1]!
const style = Object.keys(state).length > 0 ? { ...state } : null
const text = decodeHTML(raw.substring(textStart))
segments[segments.length] = { text, style, effects: [] }
}
return segments
}
function processTag(tag: string, stateStack: InlineStyle[]): void {
const tagLower = tag.toLowerCase().trim()
const currentState = stateStack[stateStack.length - 1]!
if (tagLower === 'b') {
stateStack[stateStack.length] = { ...currentState, bold: true }
} else if (tagLower === '/b') {
if (stateStack.length > 1) stateStack.pop()
} else if (tagLower === 'i') {
stateStack[stateStack.length] = { ...currentState, italic: true }
} else if (tagLower === '/i') {
if (stateStack.length > 1) stateStack.pop()
} else if (tagLower === 'u') {
stateStack[stateStack.length] = { ...currentState, underline: true }
} else if (tagLower === '/u') {
if (stateStack.length > 1) stateStack.pop()
} else if (tagLower === 's') {
stateStack[stateStack.length] = { ...currentState, strikeout: true }
} else if (tagLower === '/s') {
if (stateStack.length > 1) stateStack.pop()
} else if (tagLower.startsWith('font')) {
const colorIdx = tag.indexOf('color')
if (colorIdx !== -1) {
const colorStart = tag.indexOf('"', colorIdx)
if (colorStart !== -1) {
const colorEnd = tag.indexOf('"', colorStart + 1)
if (colorEnd !== -1) {
const colorValue = tag.substring(colorStart + 1, colorEnd)
const color = parseColorFast(colorValue)
stateStack[stateStack.length] = { ...currentState, primaryColor: color }
return
}
}
}
stateStack[stateStack.length] = { ...currentState }
} else if (tagLower === '/font') {
if (stateStack.length > 1) stateStack.pop()
}
}
function parseColorFast(value: string): number {
if (value.charCodeAt(0) === 35) { // #
const hex = value.substring(1)
if (hex.length === 6) {
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 ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF)
}
}
return 0x00FFFFFF
}
function decodeHTML(text: string): string {
if (text.indexOf('&') === -1) return text
let result = text
if (result.indexOf(' ') !== -1) result = result.split(' ').join(' ')
if (result.indexOf('<') !== -1) result = result.split('<').join('<')
if (result.indexOf('>') !== -1) result = result.split('>').join('>')
if (result.indexOf('&') !== -1) result = result.split('&').join('&')
if (result.indexOf('"') !== -1) result = result.split('"').join('"')
return result
}
function stripTags(raw: string): string {
if (raw.indexOf('<') === -1) return decodeHTML(raw)
let result = ''
let i = 0
const rawLen = raw.length
while (i < rawLen) {
if (raw.charCodeAt(i) === 60) { // <
const closeIdx = raw.indexOf('>', i)
if (closeIdx === -1) {
result += raw.substring(i)
break
}
i = closeIdx + 1
} else {
result += raw.charAt(i)
i++
}
}
return decodeHTML(result)
}