/ utils / ansiToSvg.ts
ansiToSvg.ts
  1  /**
  2   * Converts ANSI-escaped terminal text to SVG format
  3   * Supports basic ANSI color codes (foreground colors)
  4   */
  5  
  6  import { escapeXml } from './xml.js'
  7  
  8  export type AnsiColor = {
  9    r: number
 10    g: number
 11    b: number
 12  }
 13  
 14  // Default terminal color palette (similar to most terminals)
 15  const 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  
 35  export const DEFAULT_FG: AnsiColor = { r: 229, g: 229, b: 229 } // light gray
 36  export const DEFAULT_BG: AnsiColor = { r: 30, g: 30, b: 30 } // dark gray
 37  
 38  export type TextSpan = {
 39    text: string
 40    color: AnsiColor
 41    bold: boolean
 42  }
 43  
 44  export 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   */
 53  export 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   */
150  function 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  
192  export 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   */
207  export 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  }