| 1 | const ESC = "\u001b"; |
| 2 | |
| 3 | const ANSI_COLORS = [ |
| 4 | "var(--clo-term-ansi-black, #000000)", |
| 5 | "var(--clo-term-ansi-red, #cd3131)", |
| 6 | "var(--clo-term-ansi-green, #0dbc79)", |
| 7 | "var(--clo-term-ansi-yellow, #e5e510)", |
| 8 | "var(--clo-term-ansi-blue, #2472c8)", |
| 9 | "var(--clo-term-ansi-magenta, #bc3fbc)", |
| 10 | "var(--clo-term-ansi-cyan, #11a8cd)", |
| 11 | "var(--clo-term-ansi-white, #e5e5e5)", |
| 12 | "var(--clo-term-ansi-bright-black, #666666)", |
| 13 | "var(--clo-term-ansi-bright-red, #f14c4c)", |
| 14 | "var(--clo-term-ansi-bright-green, #23d18b)", |
| 15 | "var(--clo-term-ansi-bright-yellow, #f5f543)", |
| 16 | "var(--clo-term-ansi-bright-blue, #3b8eea)", |
| 17 | "var(--clo-term-ansi-bright-magenta, #d670d6)", |
| 18 | "var(--clo-term-ansi-bright-cyan, #29b8db)", |
| 19 | "var(--clo-term-ansi-bright-white, #ffffff)", |
| 20 | ] as const; |
| 21 | |
| 22 | const UNDERLINES = ["", "solid", "double", "wavy", "dotted", "dashed"] as const; |
| 23 | |
| 24 | interface AnsiState { |
| 25 | bold: boolean; |
| 26 | faint: boolean; |
| 27 | italic: boolean; |
| 28 | underline: "" | "solid" | "double" | "wavy" | "dotted" | "dashed"; |
| 29 | inverse: boolean; |
| 30 | invisible: boolean; |
| 31 | strikethrough: boolean; |
| 32 | overline: boolean; |
| 33 | foreground: string; |
| 34 | background: string; |
| 35 | } |
| 36 | |
| 37 | function initialState(): AnsiState { |
| 38 | return { |
| 39 | bold: false, |
| 40 | faint: false, |
| 41 | italic: false, |
| 42 | underline: "", |
| 43 | inverse: false, |
| 44 | invisible: false, |
| 45 | strikethrough: false, |
| 46 | overline: false, |
| 47 | foreground: "", |
| 48 | background: "", |
| 49 | }; |
| 50 | } |
| 51 | |
| 52 | function byte(value: number): number { |
| 53 | return Math.max(0, Math.min(255, Number.isFinite(value) ? value : 0)); |
| 54 | } |
| 55 | |
| 56 | function colorByte(value: string | undefined): number | null { |
| 57 | if (value === undefined || !/^\d+$/.test(value)) return null; |
| 58 | const result = Number(value); |
| 59 | return result <= 255 ? result : null; |
| 60 | } |
| 61 | |
| 62 | function paletteColor(index: number): string { |
| 63 | index = Math.trunc(byte(index)); |
| 64 | const color = ANSI_COLORS[index]; |
| 65 | if (color !== undefined) return color; |
| 66 | if (index >= 232) { |
| 67 | const value = byte(8 + (Math.min(index, 255) - 232) * 10); |
| 68 | return `rgb(${value} ${value} ${value})`; |
| 69 | } |
| 70 | const value = Math.max(0, Math.min(215, index - 16)); |
| 71 | const levels = [0, 95, 135, 175, 215, 255] as const; |
| 72 | return `rgb(${levels[Math.floor(value / 36)]} ${levels[Math.floor((value % 36) / 6)]} ${levels[value % 6]})`; |
| 73 | } |
| 74 | |
| 75 | function setColor(state: AnsiState, background: boolean, color: string): void { |
| 76 | if (background) state.background = color; |
| 77 | else state.foreground = color; |
| 78 | } |
| 79 | |
| 80 | function applyCode(state: AnsiState, code: number): AnsiState { |
| 81 | if (code === 0) return initialState(); |
| 82 | if (code === 1) state.bold = true; |
| 83 | else if (code === 2) state.faint = true; |
| 84 | else if (code === 3) state.italic = true; |
| 85 | else if (code === 4) state.underline = "solid"; |
| 86 | else if (code === 7) state.inverse = true; |
| 87 | else if (code === 8) state.invisible = true; |
| 88 | else if (code === 9) state.strikethrough = true; |
| 89 | else if (code === 21) state.underline = "double"; |
| 90 | else if (code === 22) { |
| 91 | state.bold = false; |
| 92 | state.faint = false; |
| 93 | } else if (code === 23) state.italic = false; |
| 94 | else if (code === 24) state.underline = ""; |
| 95 | else if (code === 27) state.inverse = false; |
| 96 | else if (code === 28) state.invisible = false; |
| 97 | else if (code === 29) state.strikethrough = false; |
| 98 | else if (code === 39) state.foreground = ""; |
| 99 | else if (code === 49) state.background = ""; |
| 100 | else if (code === 53) state.overline = true; |
| 101 | else if (code === 55) state.overline = false; |
| 102 | else if (code >= 30 && code <= 37) state.foreground = paletteColor(code - 30); |
| 103 | else if (code >= 40 && code <= 47) state.background = paletteColor(code - 40); |
| 104 | else if (code >= 90 && code <= 97) state.foreground = paletteColor(code - 82); |
| 105 | else if (code >= 100 && code <= 107) state.background = paletteColor(code - 92); |
| 106 | return state; |
| 107 | } |
| 108 | |
| 109 | function applySgr(state: AnsiState, parameters: string): AnsiState { |
| 110 | const groups = parameters === "" ? ["0"] : parameters.split(";"); |
| 111 | for (let index = 0; index < groups.length; index++) { |
| 112 | const parts = groups[index]!.split(":"); |
| 113 | const code = Number(parts[0] || 0); |
| 114 | if ((code === 38 || code === 48) && parts.length > 1) { |
| 115 | const mode = Number(parts[1]); |
| 116 | if (mode === 5 && parts.length === 3) { |
| 117 | const color = colorByte(parts[2]); |
| 118 | if (color !== null) setColor(state, code === 48, paletteColor(color)); |
| 119 | } else if ( |
| 120 | mode === 2 && |
| 121 | (parts.length === 5 || (parts.length === 6 && (parts[2] === "" || /^\d+$/.test(parts[2]!)))) |
| 122 | ) { |
| 123 | const rgb = parts.slice(-3).map(colorByte); |
| 124 | if (rgb.every((value) => value !== null)) |
| 125 | setColor(state, code === 48, `rgb(${rgb[0]} ${rgb[1]} ${rgb[2]})`); |
| 126 | } |
| 127 | continue; |
| 128 | } |
| 129 | if (code === 4 && parts.length > 1) { |
| 130 | state.underline = UNDERLINES[Number(parts[1])] ?? ""; |
| 131 | continue; |
| 132 | } |
| 133 | if ((code === 38 || code === 48) && groups[index + 1] === "5") { |
| 134 | const color = colorByte(groups[index + 2]); |
| 135 | if (color !== null) setColor(state, code === 48, paletteColor(color)); |
| 136 | index += 2; |
| 137 | continue; |
| 138 | } |
| 139 | if ((code === 38 || code === 48) && groups[index + 1] === "2") { |
| 140 | const rgb = groups.slice(index + 2, index + 5).map(colorByte); |
| 141 | if (rgb.length === 3 && rgb.every((value) => value !== null)) |
| 142 | setColor(state, code === 48, `rgb(${rgb[0]} ${rgb[1]} ${rgb[2]})`); |
| 143 | index += 4; |
| 144 | continue; |
| 145 | } |
| 146 | state = applyCode(state, code); |
| 147 | } |
| 148 | return state; |
| 149 | } |
| 150 | |
| 151 | function styles(state: AnsiState): string { |
| 152 | const rules: string[] = []; |
| 153 | if (state.bold) rules.push("font-weight:700"); |
| 154 | if (state.faint) rules.push("opacity:0.68"); |
| 155 | if (state.italic) rules.push("font-style:italic"); |
| 156 | const decorations = [ |
| 157 | state.underline ? "underline" : "", |
| 158 | state.strikethrough ? "line-through" : "", |
| 159 | state.overline ? "overline" : "", |
| 160 | ].filter(Boolean); |
| 161 | if (decorations.length > 0) rules.push(`text-decoration-line:${decorations.join(" ")}`); |
| 162 | if (state.underline && state.underline !== "solid") |
| 163 | rules.push(`text-decoration-style:${state.underline}`); |
| 164 | |
| 165 | let foreground = state.foreground; |
| 166 | let background = state.background; |
| 167 | if (state.inverse) { |
| 168 | [foreground, background] = [ |
| 169 | background || "var(--clo-term-bg, #1e1e2e)", |
| 170 | foreground || "var(--clo-term-fg, #cdd6f4)", |
| 171 | ]; |
| 172 | } |
| 173 | if (state.invisible) foreground = "transparent"; |
| 174 | if (foreground) rules.push(`color:${foreground}`); |
| 175 | if (background) rules.push(`background-color:${background}`); |
| 176 | return rules.join(";"); |
| 177 | } |
| 178 | |
| 179 | function escapeHtml(text: string): string { |
| 180 | return text.replace(/[&<>"'\r]/g, (character) => { |
| 181 | if (character === "&") return "&amp;"; |
| 182 | if (character === "<") return "&lt;"; |
| 183 | if (character === ">") return "&gt;"; |
| 184 | if (character === '"') return "&quot;"; |
| 185 | if (character === "'") return "&#39;"; |
| 186 | return "&#13;"; |
| 187 | }); |
| 188 | } |
| 189 | |
| 190 | function stringEnd(text: string, start: number, bell: boolean): number { |
| 191 | for (let index = start; index < text.length; index++) { |
| 192 | const code = text.charCodeAt(index); |
| 193 | if (code === 0x18 || code === 0x1a || code === 0x9c || (bell && code === 0x07)) |
| 194 | return index + 1; |
| 195 | if (text[index] === ESC && text[index + 1] === "\\") return index + 2; |
| 196 | if (text[index] === ESC) return index; |
| 197 | } |
| 198 | return text.length; |
| 199 | } |
| 200 | |
| 201 | /** |
| 202 | * Convert styled ANSI text to escaped inline HTML. Terminal cursor, input, |
| 203 | * viewport, and scrolling behavior are intentionally absent; non-SGR control |
| 204 | * sequences are removed. Whitespace layout remains the caller's choice. |
| 205 | */ |
| 206 | export function ansiToHtml(text: string): string { |
| 207 | let state = initialState(); |
| 208 | let html = ""; |
| 209 | let plainStart = 0; |
| 210 | let index = 0; |
| 211 | |
| 212 | const append = (value: string) => { |
| 213 | if (value === "") return; |
| 214 | const escaped = escapeHtml(value); |
| 215 | const style = styles(state); |
| 216 | html += style === "" ? escaped : `<span style="${style}">${escaped}</span>`; |
| 217 | }; |
| 218 | const emit = (end: number) => append(text.slice(plainStart, end)); |
| 219 | |
| 220 | while (index < text.length) { |
| 221 | const code = text.charCodeAt(index); |
| 222 | const preservedWhitespace = code === 0x09 || code === 0x0a || code === 0x0d; |
| 223 | if ( |
| 224 | code !== 0x1b && |
| 225 | code !== 0x7f && |
| 226 | !(code < 0x20 && !preservedWhitespace) && |
| 227 | !(code >= 0x80 && code <= 0x9f) |
| 228 | ) { |
| 229 | index++; |
| 230 | continue; |
| 231 | } |
| 232 | |
| 233 | emit(index); |
| 234 | const next = text[index + 1]; |
| 235 | if ((code === 0x1b && next === "[") || code === 0x9b) { |
| 236 | let parameters = ""; |
| 237 | let end = index + (code === 0x9b ? 1 : 2); |
| 238 | while (end < text.length) { |
| 239 | const final = text.charCodeAt(end); |
| 240 | if (final === 0x18 || final === 0x1a) { |
| 241 | end++; |
| 242 | break; |
| 243 | } |
| 244 | if (final === 0x1b || (final >= 0x80 && final <= 0x9f)) break; |
| 245 | if (final < 0x20 || final === 0x7f) { |
| 246 | if (final === 0x09 || final === 0x0a || final === 0x0d) append(text[end]!); |
| 247 | end++; |
| 248 | continue; |
| 249 | } |
| 250 | if (final >= 0x40 && final <= 0x7e) { |
| 251 | if (final === 0x6d && /^[0-9:;]*$/.test(parameters)) state = applySgr(state, parameters); |
| 252 | end++; |
| 253 | break; |
| 254 | } |
| 255 | parameters += text[end]; |
| 256 | end++; |
| 257 | } |
| 258 | index = end; |
| 259 | } else if ((code === 0x1b && next === "]") || code === 0x9d) { |
| 260 | index = stringEnd(text, index + (code === 0x9d ? 1 : 2), true); |
| 261 | } else if ( |
| 262 | (code === 0x1b && (next === "P" || next === "X" || next === "^" || next === "_")) || |
| 263 | code === 0x90 || |
| 264 | code === 0x98 || |
| 265 | code === 0x9e || |
| 266 | code === 0x9f |
| 267 | ) { |
| 268 | index = stringEnd(text, index + (code === 0x1b ? 2 : 1), false); |
| 269 | } else if (code === 0x1b) { |
| 270 | index++; |
| 271 | while (index < text.length) { |
| 272 | const intermediate = text.charCodeAt(index); |
| 273 | if (intermediate < 0x20 || intermediate > 0x2f) break; |
| 274 | index++; |
| 275 | } |
| 276 | const final = text.charCodeAt(index); |
| 277 | if (final >= 0x30 && final <= 0x7e) index++; |
| 278 | } else index++; |
| 279 | plainStart = index; |
| 280 | } |
| 281 | emit(text.length); |
| 282 | return html; |
| 283 | } |