ansiToSvg.ts 8.0 KB

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