diff --git a/src/ansi.ts b/src/ansi.ts new file mode 100644 index 0000000..56b4b86 --- /dev/null +++ b/src/ansi.ts @@ -0,0 +1,302 @@ +// ANSI to HTML converter - processes escape sequences and renders as inline styled spans + +const colors: Record = { + 30: "#0d1117", + 31: "#f85149", + 32: "#3fb950", + 33: "#d29922", + 34: "#58a6ff", + 35: "#bc8cff", + 36: "#39c5cf", + 37: "#c9d1d9", + 90: "#6e7681", + 91: "#ff7b72", + 92: "#7ee787", + 93: "#e3b341", + 94: "#79c0ff", + 95: "#d2a8ff", + 96: "#56d4dd", + 97: "#ffffff", +}; + +const bgColors: Record = { + 40: "#0d1117", + 41: "#f85149", + 42: "#3fb950", + 43: "#d29922", + 44: "#58a6ff", + 45: "#bc8cff", + 46: "#39c5cf", + 47: "#c9d1d9", + 100: "#6e7681", + 101: "#ff7b72", + 102: "#7ee787", + 103: "#e3b341", + 104: "#79c0ff", + 105: "#d2a8ff", + 106: "#56d4dd", + 107: "#ffffff", +}; + +interface AnsiState { + fg: string | null; + bg: string | null; + bold: boolean; + dim: boolean; + italic: boolean; + underline: boolean; + inverse: boolean; +} + +interface ExtendedColorResult { + color: string; + skip: number; +} + +// Trim trailing whitespace from each line to prevent background color bleeding. +// Terminal buffers often pad lines with spaces. Preserve trailing ANSI resets. +// ESC character code +const ESC = "\x1b"; + +export function trimLineEndPreserveAnsi(line: string): string { + // Peel off any ANSI sequences at the end of the line + let end = line.length; + let ansiSuffix = ""; + while (end > 0) { + // Check for ANSI escape sequence at end: ESC [ params letter + const suffix = line.slice(0, end); + if (!suffix.endsWith("]")) { + // Look for the closing letter (A-Z, a-z) + const lastChar = suffix[suffix.length - 1]; + if (!lastChar || !/[A-Za-z]/.test(lastChar)) break; + + // Walk back to find ESC [ + let seqStart = suffix.length - 2; + while (seqStart >= 0 && /[0-9;?]/.test(suffix[seqStart] || "")) { + seqStart--; + } + if (seqStart < 0 || suffix[seqStart] !== "[") break; + seqStart--; + if (seqStart < 0 || suffix[seqStart] !== ESC) break; + + // Found a valid ANSI sequence + const seq = suffix.slice(seqStart); + ansiSuffix = seq + ansiSuffix; + end = seqStart; + } else { + break; + } + } + const trimmed = line.slice(0, end).trimEnd(); + return trimmed + ansiSuffix; +} + +// Parse extended color: 38;2;R;G;B (24-bit) or 38;5;N (256-color) +// Returns { color, skip } where skip is additional codes to skip +function parseExtendedColor( + codes: number[], + idx: number, +): ExtendedColorResult | null { + const mode = codes[idx + 1]; + + // 24-bit RGB: 38;2;R;G;B + if (mode === 2 && codes[idx + 4] !== undefined) { + const r = codes[idx + 2]; + const g = codes[idx + 3]; + const b = codes[idx + 4]; + return { color: `rgb(${r},${g},${b})`, skip: 4 }; + } + + // 256-color palette: 38;5;N + if (mode === 5) { + const colorNum = codes[idx + 2]; + if (colorNum === undefined) return null; + + let color: string; + if (colorNum < 16) { + const basic = [ + "#0d1117", + "#cd3131", + "#0dbc79", + "#e5e510", + "#2472c8", + "#bc3fbc", + "#11a8cd", + "#e5e5e5", + "#666666", + "#f14c4c", + "#23d18b", + "#f5f543", + "#3b8eea", + "#d670d6", + "#29b8db", + "#ffffff", + ]; + color = basic[colorNum] || "#ffffff"; + } else if (colorNum < 232) { + const n = colorNum - 16; + const ri = Math.floor(n / 36); + const gi = Math.floor((n % 36) / 6); + const bi = n % 6; + const r = ri === 0 ? 0 : ri * 40 + 55; + const g = gi === 0 ? 0 : gi * 40 + 55; + const b = bi === 0 ? 0 : bi * 40 + 55; + color = `rgb(${r},${g},${b})`; + } else { + const gray = (colorNum - 232) * 10 + 8; + color = `rgb(${gray},${gray},${gray})`; + } + return { color, skip: 2 }; + } + return null; +} + +function resetState(): AnsiState { + return { + fg: null, + bg: null, + bold: false, + dim: false, + italic: false, + underline: false, + inverse: false, + }; +} + +function buildStyle(state: AnsiState): string { + let fg = state.fg; + let bg = state.bg; + + // For inverse, swap fg and bg + if (state.inverse) { + const tmp = fg; + fg = bg || "#ffffff"; + bg = tmp || "#0d1117"; + } + + const styles: string[] = []; + if (fg) styles.push(`color:${fg}`); + if (bg) styles.push(`background-color:${bg}`); + if (state.bold) styles.push("font-weight:bold"); + if (state.dim) styles.push("opacity:0.5"); + if (state.italic) styles.push("font-style:italic"); + if (state.underline) styles.push("text-decoration:underline"); + return styles.join(";"); +} + +export function ansiToHtml(text: string): string { + if (!text) return ""; + + // Trim trailing whitespace from each line to prevent background color bleeding + text = text.split("\n").map(trimLineEndPreserveAnsi).join("\n"); + + let result = ""; + let inSpan = false; + let i = 0; + let currentStyle = ""; + let state = resetState(); + + function applyStyle(): void { + const nextStyle = buildStyle(state); + if (nextStyle === currentStyle) return; + if (inSpan) { + result += ""; + inSpan = false; + } + if (nextStyle) { + result += ``; + inSpan = true; + } + currentStyle = nextStyle; + } + + while (i < text.length) { + // Check for ESC character (char code 27) + if (text.charCodeAt(i) === 27 && text[i + 1] === "[") { + // Parse CSI sequence: ESC [ params command + let j = i + 2; + let params = ""; + while (j < text.length && /[0-9;]/.test(text[j] || "")) { + params += text[j]; + j++; + } + const command = text[j] || ""; + j++; + + if (command === "m") { + // SGR - Select Graphic Rendition + const codes = params ? params.split(";").map(Number) : [0]; + for (let k = 0; k < codes.length; k++) { + const code = codes[k]; + if (code === 0) { + state = resetState(); + } else if (code === 1) state.bold = true; + else if (code === 2) state.dim = true; + else if (code === 3) state.italic = true; + else if (code === 4) state.underline = true; + else if (code === 7) state.inverse = true; + else if (code === 22) { + state.bold = false; + state.dim = false; + } else if (code === 23) state.italic = false; + else if (code === 24) state.underline = false; + else if (code === 27) state.inverse = false; + else if (code === 39) state.fg = null; + else if (code === 49) state.bg = null; + else if (code === 38) { + const extColor = parseExtendedColor(codes, k); + if (extColor) { + state.fg = extColor.color; + k += extColor.skip; + } + } else if (code === 48) { + const extColor = parseExtendedColor(codes, k); + if (extColor) { + state.bg = extColor.color; + k += extColor.skip; + } + } else if (code !== undefined && colors[code]) { + state.fg = colors[code]; + } else if (code !== undefined && bgColors[code]) { + state.bg = bgColors[code]; + } + } + + applyStyle(); + } + // Skip other escape sequences (H, f, J, K, etc.) - they don't affect our line-based output + i = j; + continue; + } + + // Track newlines in the content + if (text[i] === "\n" || text[i] === "\r") { + if (text[i] === "\n") { + // Close span before newline to prevent background color bleeding + if (inSpan) { + result += ""; + } + result += "\n"; + // Reopen span after newline if we had styling + if (inSpan) { + result += ``; + } + } + // Skip carriage return - we only care about line feeds + i++; + continue; + } + + // Regular character - escape HTML + const ch = text[i]; + if (ch === "<") result += "<"; + else if (ch === ">") result += ">"; + else if (ch === "&") result += "&"; + else result += ch; + i++; + } + + if (inSpan) result += ""; + + return result; +}