source dump of claude code
at main 272 lines 8.2 kB view raw
1/** 2 * Converts ANSI-escaped terminal text to SVG format 3 * Supports basic ANSI color codes (foreground colors) 4 */ 5 6import { escapeXml } from './xml.js' 7 8export type AnsiColor = { 9 r: number 10 g: number 11 b: number 12} 13 14// Default terminal color palette (similar to most terminals) 15const ANSI_COLORS: Record<number, AnsiColor> = { 16 30: { r: 0, g: 0, b: 0 }, // black 17 31: { r: 205, g: 49, b: 49 }, // red 18 32: { r: 13, g: 188, b: 121 }, // green 19 33: { r: 229, g: 229, b: 16 }, // yellow 20 34: { r: 36, g: 114, b: 200 }, // blue 21 35: { r: 188, g: 63, b: 188 }, // magenta 22 36: { r: 17, g: 168, b: 205 }, // cyan 23 37: { r: 229, g: 229, b: 229 }, // white 24 // Bright colors 25 90: { r: 102, g: 102, b: 102 }, // bright black (gray) 26 91: { r: 241, g: 76, b: 76 }, // bright red 27 92: { r: 35, g: 209, b: 139 }, // bright green 28 93: { r: 245, g: 245, b: 67 }, // bright yellow 29 94: { r: 59, g: 142, b: 234 }, // bright blue 30 95: { r: 214, g: 112, b: 214 }, // bright magenta 31 96: { r: 41, g: 184, b: 219 }, // bright cyan 32 97: { r: 255, g: 255, b: 255 }, // bright white 33} 34 35export const DEFAULT_FG: AnsiColor = { r: 229, g: 229, b: 229 } // light gray 36export const DEFAULT_BG: AnsiColor = { r: 30, g: 30, b: 30 } // dark gray 37 38export type TextSpan = { 39 text: string 40 color: AnsiColor 41 bold: boolean 42} 43 44export type ParsedLine = TextSpan[] 45 46/** 47 * Parse ANSI escape sequences from text 48 * Supports: 49 * - Basic colors (30-37, 90-97) 50 * - 256-color mode (38;5;n) 51 * - 24-bit true color (38;2;r;g;b) 52 */ 53export function parseAnsi(text: string): ParsedLine[] { 54 const lines: ParsedLine[] = [] 55 const rawLines = text.split('\n') 56 57 for (const line of rawLines) { 58 const spans: TextSpan[] = [] 59 let currentColor = DEFAULT_FG 60 let bold = false 61 let i = 0 62 63 while (i < line.length) { 64 // Check for ANSI escape sequence 65 if (line[i] === '\x1b' && line[i + 1] === '[') { 66 // Find the end of the escape sequence 67 let j = i + 2 68 while (j < line.length && !/[A-Za-z]/.test(line[j]!)) { 69 j++ 70 } 71 72 if (line[j] === 'm') { 73 // Color/style code 74 const codes = line 75 .slice(i + 2, j) 76 .split(';') 77 .map(Number) 78 79 let k = 0 80 while (k < codes.length) { 81 const code = codes[k]! 82 if (code === 0) { 83 // Reset 84 currentColor = DEFAULT_FG 85 bold = false 86 } else if (code === 1) { 87 bold = true 88 } else if (code >= 30 && code <= 37) { 89 currentColor = ANSI_COLORS[code] || DEFAULT_FG 90 } else if (code >= 90 && code <= 97) { 91 currentColor = ANSI_COLORS[code] || DEFAULT_FG 92 } else if (code === 39) { 93 currentColor = DEFAULT_FG 94 } else if (code === 38) { 95 // Extended color - check next code 96 if (codes[k + 1] === 5 && codes[k + 2] !== undefined) { 97 // 256-color mode: 38;5;n 98 const colorIndex = codes[k + 2]! 99 currentColor = get256Color(colorIndex) 100 k += 2 101 } else if ( 102 codes[k + 1] === 2 && 103 codes[k + 2] !== undefined && 104 codes[k + 3] !== undefined && 105 codes[k + 4] !== undefined 106 ) { 107 // 24-bit true color: 38;2;r;g;b 108 currentColor = { 109 r: codes[k + 2]!, 110 g: codes[k + 3]!, 111 b: codes[k + 4]!, 112 } 113 k += 4 114 } 115 } 116 k++ 117 } 118 } 119 120 i = j + 1 121 continue 122 } 123 124 // Regular character - find extent of same-styled text 125 const textStart = i 126 while (i < line.length && line[i] !== '\x1b') { 127 i++ 128 } 129 130 const spanText = line.slice(textStart, i) 131 if (spanText) { 132 spans.push({ text: spanText, color: currentColor, bold }) 133 } 134 } 135 136 // Add empty span if line is empty (to preserve line) 137 if (spans.length === 0) { 138 spans.push({ text: '', color: DEFAULT_FG, bold: false }) 139 } 140 141 lines.push(spans) 142 } 143 144 return lines 145} 146 147/** 148 * Get color from 256-color palette 149 */ 150function get256Color(index: number): AnsiColor { 151 // Standard colors (0-15) 152 if (index < 16) { 153 const standardColors: AnsiColor[] = [ 154 { r: 0, g: 0, b: 0 }, // 0 black 155 { r: 128, g: 0, b: 0 }, // 1 red 156 { r: 0, g: 128, b: 0 }, // 2 green 157 { r: 128, g: 128, b: 0 }, // 3 yellow 158 { r: 0, g: 0, b: 128 }, // 4 blue 159 { r: 128, g: 0, b: 128 }, // 5 magenta 160 { r: 0, g: 128, b: 128 }, // 6 cyan 161 { r: 192, g: 192, b: 192 }, // 7 white 162 { r: 128, g: 128, b: 128 }, // 8 bright black 163 { r: 255, g: 0, b: 0 }, // 9 bright red 164 { r: 0, g: 255, b: 0 }, // 10 bright green 165 { r: 255, g: 255, b: 0 }, // 11 bright yellow 166 { r: 0, g: 0, b: 255 }, // 12 bright blue 167 { r: 255, g: 0, b: 255 }, // 13 bright magenta 168 { r: 0, g: 255, b: 255 }, // 14 bright cyan 169 { r: 255, g: 255, b: 255 }, // 15 bright white 170 ] 171 return standardColors[index] || DEFAULT_FG 172 } 173 174 // 216 color cube (16-231) 175 if (index < 232) { 176 const i = index - 16 177 const r = Math.floor(i / 36) 178 const g = Math.floor((i % 36) / 6) 179 const b = i % 6 180 return { 181 r: r === 0 ? 0 : 55 + r * 40, 182 g: g === 0 ? 0 : 55 + g * 40, 183 b: b === 0 ? 0 : 55 + b * 40, 184 } 185 } 186 187 // Grayscale (232-255) 188 const gray = (index - 232) * 10 + 8 189 return { r: gray, g: gray, b: gray } 190} 191 192export type AnsiToSvgOptions = { 193 fontFamily?: string 194 fontSize?: number 195 lineHeight?: number 196 paddingX?: number 197 paddingY?: number 198 backgroundColor?: string 199 borderRadius?: number 200} 201 202/** 203 * Convert ANSI text to SVG 204 * Uses <tspan> elements within a single <text> per line so the renderer 205 * handles character spacing natively (no manual charWidth calculation) 206 */ 207export function ansiToSvg( 208 ansiText: string, 209 options: AnsiToSvgOptions = {}, 210): string { 211 const { 212 fontFamily = 'Menlo, Monaco, monospace', 213 fontSize = 14, 214 lineHeight = 22, 215 paddingX = 24, 216 paddingY = 24, 217 backgroundColor = `rgb(${DEFAULT_BG.r}, ${DEFAULT_BG.g}, ${DEFAULT_BG.b})`, 218 borderRadius = 8, 219 } = options 220 221 const lines = parseAnsi(ansiText) 222 223 // Trim trailing empty lines 224 while ( 225 lines.length > 0 && 226 lines[lines.length - 1]!.every(span => span.text.trim() === '') 227 ) { 228 lines.pop() 229 } 230 231 // Estimate width based on max line length (for SVG dimensions only) 232 // For monospace fonts, character width is roughly 0.6 * fontSize 233 const charWidthEstimate = fontSize * 0.6 234 const maxLineLength = Math.max( 235 ...lines.map(spans => spans.reduce((acc, s) => acc + s.text.length, 0)), 236 ) 237 const width = Math.ceil(maxLineLength * charWidthEstimate + paddingX * 2) 238 const height = lines.length * lineHeight + paddingY * 2 239 240 // Build SVG - use tspan elements so renderer handles character positioning 241 let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">\n` 242 svg += ` <rect width="100%" height="100%" fill="${backgroundColor}" rx="${borderRadius}" ry="${borderRadius}"/>\n` 243 svg += ` <style>\n` 244 svg += ` text { font-family: ${fontFamily}; font-size: ${fontSize}px; white-space: pre; }\n` 245 svg += ` .b { font-weight: bold; }\n` 246 svg += ` </style>\n` 247 248 for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { 249 const spans = lines[lineIndex]! 250 const y = 251 paddingY + (lineIndex + 1) * lineHeight - (lineHeight - fontSize) / 2 252 253 // Build a single <text> element with <tspan> children for each colored segment 254 // xml:space="preserve" prevents SVG from collapsing whitespace 255 svg += ` <text x="${paddingX}" y="${y}" xml:space="preserve">` 256 257 for (const span of spans) { 258 if (!span.text) continue 259 260 const colorStr = `rgb(${span.color.r}, ${span.color.g}, ${span.color.b})` 261 const boldClass = span.bold ? ' class="b"' : '' 262 263 svg += `<tspan fill="${colorStr}"${boldClass}>${escapeXml(span.text)}</tspan>` 264 } 265 266 svg += `</text>\n` 267 } 268 269 svg += `</svg>` 270 271 return svg 272}