UNPKG

subforge

Version:

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

1,194 lines (1,190 loc) 35 kB
// src/core/errors.ts class SubforgeError extends Error { code; line; column; constructor(code, message, position) { super(`[${code}] ${message} at line ${position.line}, column ${position.column}`); this.name = "SubforgeError"; this.code = code; this.line = position.line; this.column = position.column; } } function toParseError(err) { if (err instanceof SubforgeError) { return { line: err.line, column: err.column, code: err.code, message: err.message }; } if (err instanceof Error) { return { line: 1, column: 1, code: "MALFORMED_EVENT", message: err.message }; } return { line: 1, column: 1, code: "MALFORMED_EVENT", message: String(err) }; } function unwrap(result) { if (result.ok) return result.document; const first = result.errors[0]; if (first) { throw new SubforgeError(first.code, first.message, { line: first.line, column: first.column }); } throw new Error("Parse failed"); } // src/core/document.ts var idCounter = 0; var EMPTY_SEGMENTS = []; function generateId() { return ++idCounter; } function reserveIds(count) { const start = idCounter + 1; idCounter += count; return start; } function createDocument(init) { return { info: { title: "", playResX: 1920, playResY: 1080, scaleBorderAndShadow: true, wrapStyle: 0, ...init?.info }, styles: init?.styles ?? new Map([["Default", createDefaultStyle()]]), events: init?.events ?? [], comments: init?.comments ?? [], fonts: init?.fonts, graphics: init?.graphics, regions: init?.regions }; } function createDefaultStyle() { return { name: "Default", fontName: "Arial", fontSize: 48, primaryColor: 16777215, secondaryColor: 255, outlineColor: 0, backColor: 0, bold: false, italic: false, underline: false, strikeout: false, scaleX: 100, scaleY: 100, spacing: 0, angle: 0, borderStyle: 1, outline: 2, shadow: 2, alignment: 2, marginL: 10, marginR: 10, marginV: 10, encoding: 1 }; } function createEvent(start, end, text, opts) { return { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: [], dirty: false, ...opts }; } function createKaraokeEvent(start, end, syllables, opts) { const segments = syllables.map((syl) => ({ text: syl.text, style: syl.style ? { ...syl.style } : null, effects: [{ type: "karaoke", params: { duration: syl.duration, mode: "fill" } }] })); return { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments, dirty: true, ...opts }; } function cloneDocument(doc) { return { info: { ...doc.info }, styles: new Map(doc.styles), events: doc.events.map((e) => ({ ...e, segments: [...e.segments], image: e.image ? { ...e.image, data: e.image.data.slice() } : undefined, vobsub: e.vobsub ? { ...e.vobsub } : undefined, pgs: e.pgs ? { ...e.pgs } : undefined })), comments: [...doc.comments], fonts: doc.fonts ? [...doc.fonts] : undefined, graphics: doc.graphics ? [...doc.graphics] : undefined, regions: doc.regions ? [...doc.regions] : undefined }; } function cloneEvent(event) { return { ...event, id: generateId(), segments: event.segments.map((s) => ({ ...s, effects: [...s.effects] })), image: event.image ? { ...event.image, data: event.image.data.slice() } : undefined, vobsub: event.vobsub ? { ...event.vobsub } : undefined, pgs: event.pgs ? { ...event.pgs } : undefined }; } // src/formats/xml/sami/css.ts function parseCSS(cssBlock) { const classes = new Map; const classRegex = /\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}/g; let match; while ((match = classRegex.exec(cssBlock)) !== null) { const className = match[1].toUpperCase(); const props = match[2]; const classObj = { name: className }; const propRegex = /([a-zA-Z-]+)\s*:\s*([^;]+);?/g; let propMatch; while ((propMatch = propRegex.exec(props)) !== null) { const prop = propMatch[1].toLowerCase().trim(); const value = propMatch[2].trim(); switch (prop) { case "name": classObj.name = value; break; case "lang": 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) { const match = value.match(/^(\d+(?:\.\d+)?)(pt|px)?$/); if (!match) return 20; const num = parseFloat(match[1]); const unit = match[2]; if (unit === "px") { return Math.round(num * 0.75); } return Math.round(num); } function parseColor(value) { value = value.trim().toLowerCase(); const namedColors = { white: 16777215, black: 0, red: 255, green: 65280, blue: 16711680, yellow: 65535, cyan: 16776960, magenta: 16711935 }; if (namedColors[value] !== undefined) { return namedColors[value]; } 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 & 255) << 16 | (g & 255) << 8 | r & 255; } 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 & 255) << 16 | (g & 255) << 8 | r & 255; } } 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 & 255) << 16 | (g & 255) << 8 | r & 255; } return 16777215; } function parseMargin(value) { const match = value.match(/^(\d+(?:\.\d+)?)(pt|px)?$/); if (!match) return 0; return Math.round(parseFloat(match[1])); } function styleFromClass(classObj, baseStyle) { const 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; } 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; } function generateCSS(styles) { let css = `P { margin-left: 8pt; margin-right: 8pt; margin-bottom: 2pt; margin-top: 2pt; `; css += ` text-align: center; font-size: 20pt; font-family: Arial; font-weight: normal; `; css += ` color: white; } `; 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 & 255; const g = style.primaryColor >> 8 & 255; const b = style.primaryColor >> 16 & 255; if (style.primaryColor !== 16777215) { css += `color: #${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}; `; } css += `} `; } return css; } // src/formats/xml/sami/parser.ts function parseSAMI(input, opts) { try { const errors = []; let start = 0; if (input.charCodeAt(0) === 65279) start = 1; const doc = createDocument(); const len = input.length; 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 = 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 !== "&nbsp;") { 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 !== "&nbsp;") { 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: [] }; } } function findNextSync(src, start, len) { let pos = start; while (pos < len) { const ltPos = src.indexOf("<", pos); if (ltPos === -1) return -1; 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); if ((c1 === 83 || c1 === 115) && (c2 === 89 || c2 === 121) && (c3 === 78 || c3 === 110) && (c4 === 67 || c4 === 99)) { return ltPos; } } pos = ltPos + 1; } return -1; } function parseSyncHeader(src, syncPos, len) { const searchEnd = Math.min(syncPos + 50, len); let startPos = -1; for (let i = syncPos + 5;i < searchEnd; i++) { const c = src.charCodeAt(i); if (c === 83 || c === 115) { const c22 = src.charCodeAt(i + 1); const c3 = src.charCodeAt(i + 2); const c4 = src.charCodeAt(i + 3); const c5 = src.charCodeAt(i + 4); if ((c22 === 84 || c22 === 116) && (c3 === 65 || c3 === 97) && (c4 === 82 || c4 === 114) && (c5 === 84 || c5 === 116)) { startPos = i; break; } } } if (startPos === -1) return null; 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; const syncTagEnd = src.indexOf(">", syncPos); if (syncTagEnd === -1) return null; 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; let className; const pTagClose = src.indexOf(">", pStart); if (pTagClose !== -1) { for (let i = pStart;i < pTagClose - 4; i++) { const c12 = src.charCodeAt(i) | 32; if (c12 !== 99) continue; const c22 = 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 (c22 !== 108 || c3 !== 97 || c4 !== 115 || c5 !== 115) continue; 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; } } const pTagEnd = src.indexOf(">", pStart); if (pTagEnd === -1) return null; return { time, pTagEnd: pTagEnd + 1, className }; } function findContentEnd(src, start, end) { 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, doc) { const token = "<SYNC Start="; let pos = input.indexOf(token); if (pos === -1) return false; let prevTime = -1; let prevText = ""; let prevClass; 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; 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 = "&nbsp;"; } else { text = input.substring(tStart, tEnd); } } if (text === "&nbsp;" || 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, doc) { const first = "<SYNC Start=0><P Class=ENCC>Line number 1</P></SYNC>"; const second = "<SYNC Start=2500><P Class=ENCC>&nbsp;</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, doc) { 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; 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>&nbsp;</P></SYNC>") !== -1; let syncIndex = 0; let firstSyncTime = -1; const len = input.length; let pos = 0; if (len > 0 && input.charCodeAt(0) === 65279) pos = 1; while (pos < len) { const nl = input.indexOf(` `, 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; 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; 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 = "&nbsp;"; } else { text = input.substring(tStart, tEnd); } } if (text === "&nbsp;" || 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, start, end) { 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, tag, start) { 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, doc) { let styleStart = src.indexOf("<STYLE"); if (styleStart !== -1) { const styleEndUpper = src.indexOf("</STYLE>", styleStart); if (styleEndUpper !== -1) { const cssBlock2 = src.substring(styleStart, styleEndUpper + 8); const classes2 = parseCSS(cssBlock2); for (const [className, classObj] of classes2) { const baseStyle = createDefaultStyle(); const styleProps = styleFromClass(classObj, baseStyle); const 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 = { ...baseStyle, ...styleProps, name: className }; doc.styles.set(className, style); } } function parseTags(raw) { const segments = []; const stateStack = [{}]; 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, stateStack) { 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) { 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 & 255) << 16 | (g & 255) << 8 | r & 255; } } return 16777215; } function decodeHTML(text) { if (text.indexOf("&") === -1) return text; let result = text; if (result.indexOf("&nbsp;") !== -1) result = result.split("&nbsp;").join(" "); if (result.indexOf("&lt;") !== -1) result = result.split("&lt;").join("<"); if (result.indexOf("&gt;") !== -1) result = result.split("&gt;").join(">"); if (result.indexOf("&amp;") !== -1) result = result.split("&amp;").join("&"); if (result.indexOf("&quot;") !== -1) result = result.split("&quot;").join('"'); return result; } function stripTags(raw) { 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); } // src/formats/xml/sami/serializer.ts function toSAMI(doc) { const title = doc.info.title || "Subtitle"; let result = `<SAMI> <HEAD> `; result += `<TITLE>${escapeHTML(title)}</TITLE> `; result += `<STYLE TYPE="text/css"> <!-- `; result += generateCSS(doc.styles); result += `--> </STYLE> </HEAD> <BODY> `; const sortedEvents = [...doc.events].sort((a, b) => a.start - b.start); for (const event of sortedEvents) { const text = event.dirty && event.segments.length > 0 ? serializeTags(event.segments) : escapeHTML(event.text); const className = event.style !== "Default" ? event.style.toUpperCase().replace(/[^A-Z0-9]/g, "") : "ENCC"; result += `<SYNC Start=${event.start}><P Class=${className}>${text}</P> `; if (event.end > event.start) { result += `<SYNC Start=${event.end}><P Class=${className}>&nbsp;</P> `; } } result += `</BODY> </SAMI> `; return result; } function serializeTags(segments) { let result = ""; for (const seg of segments) { let text = escapeHTML(seg.text); const openTags = []; const closeTags = []; if (seg.style?.bold) { openTags.push("<b>"); closeTags.unshift("</b>"); } if (seg.style?.italic) { openTags.push("<i>"); closeTags.unshift("</i>"); } if (seg.style?.underline) { openTags.push("<u>"); closeTags.unshift("</u>"); } if (seg.style?.strikeout) { openTags.push("<s>"); closeTags.unshift("</s>"); } if (seg.style?.primaryColor !== undefined) { const color = seg.style.primaryColor; const r = color & 255; const g = color >> 8 & 255; const b = color >> 16 & 255; const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; openTags.push(`<font color="${hex}">`); closeTags.unshift("</font>"); } result += openTags.join("") + text + closeTags.join(""); } return result; } function escapeHTML(text) { return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); } export { toSAMI, parseSAMI, parseCSS, generateCSS };