Download src/utils/ansiToSvg.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 8.21 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/utils/ansiToSvg.ts
- Command line
-
hf download hf://chenbhao/codev/src/utils/ansiToSvg.ts
-
curl -L -o ansiToSvg.ts https://huggingface.co/chenbhao/codev/resolve/main/src/utils/ansiToSvg.ts
8.21 kB
| /** | |
| * Converts ANSI-escaped terminal text to SVG format | |
| * Supports basic ANSI color codes (foreground colors) | |
| */ | |
| import { escapeXml } from './xml.js' | |
| export type AnsiColor = { | |
| r: number | |
| g: number | |
| b: number | |
| } | |
| // Default terminal color palette (similar to most terminals) | |
| const ANSI_COLORS: Record<number, AnsiColor> = { | |
| 30: { r: 0, g: 0, b: 0 }, // black | |
| 31: { r: 205, g: 49, b: 49 }, // red | |
| 32: { r: 13, g: 188, b: 121 }, // green | |
| 33: { r: 229, g: 229, b: 16 }, // yellow | |
| 34: { r: 36, g: 114, b: 200 }, // blue | |
| 35: { r: 188, g: 63, b: 188 }, // magenta | |
| 36: { r: 17, g: 168, b: 205 }, // cyan | |
| 37: { r: 229, g: 229, b: 229 }, // white | |
| // Bright colors | |
| 90: { r: 102, g: 102, b: 102 }, // bright black (gray) | |
| 91: { r: 241, g: 76, b: 76 }, // bright red | |
| 92: { r: 35, g: 209, b: 139 }, // bright green | |
| 93: { r: 245, g: 245, b: 67 }, // bright yellow | |
| 94: { r: 59, g: 142, b: 234 }, // bright blue | |
| 95: { r: 214, g: 112, b: 214 }, // bright magenta | |
| 96: { r: 41, g: 184, b: 219 }, // bright cyan | |
| 97: { r: 255, g: 255, b: 255 }, // bright white | |
| } | |
| export const DEFAULT_FG: AnsiColor = { r: 229, g: 229, b: 229 } // light gray | |
| export const DEFAULT_BG: AnsiColor = { r: 30, g: 30, b: 30 } // dark gray | |
| export type TextSpan = { | |
| text: string | |
| color: AnsiColor | |
| bold: boolean | |
| } | |
| export type ParsedLine = TextSpan[] | |
| /** | |
| * Parse ANSI escape sequences from text | |
| * Supports: | |
| * - Basic colors (30-37, 90-97) | |
| * - 256-color mode (38;5;n) | |
| * - 24-bit true color (38;2;r;g;b) | |
| */ | |
| export function parseAnsi(text: string): ParsedLine[] { | |
| const lines: ParsedLine[] = [] | |
| const rawLines = text.split('\n') | |
| for (const line of rawLines) { | |
| const spans: TextSpan[] = [] | |
| let currentColor = DEFAULT_FG | |
| let bold = false | |
| let i = 0 | |
| while (i < line.length) { | |
| // Check for ANSI escape sequence | |
| if (line[i] === '\x1b' && line[i + 1] === '[') { | |
| // Find the end of the escape sequence | |
| let j = i + 2 | |
| while (j < line.length && !/[A-Za-z]/.test(line[j]!)) { | |
| j++ | |
| } | |
| if (line[j] === 'm') { | |
| // Color/style code | |
| const codes = line | |
| .slice(i + 2, j) | |
| .split(';') | |
| .map(Number) | |
| let k = 0 | |
| while (k < codes.length) { | |
| const code = codes[k]! | |
| if (code === 0) { | |
| // Reset | |
| currentColor = DEFAULT_FG | |
| bold = false | |
| } else if (code === 1) { | |
| bold = true | |
| } else if (code >= 30 && code <= 37) { | |
| currentColor = ANSI_COLORS[code] || DEFAULT_FG | |
| } else if (code >= 90 && code <= 97) { | |
| currentColor = ANSI_COLORS[code] || DEFAULT_FG | |
| } else if (code === 39) { | |
| currentColor = DEFAULT_FG | |
| } else if (code === 38) { | |
| // Extended color - check next code | |
| if (codes[k + 1] === 5 && codes[k + 2] !== undefined) { | |
| // 256-color mode: 38;5;n | |
| const colorIndex = codes[k + 2]! | |
| currentColor = get256Color(colorIndex) | |
| k += 2 | |
| } else if ( | |
| codes[k + 1] === 2 && | |
| codes[k + 2] !== undefined && | |
| codes[k + 3] !== undefined && | |
| codes[k + 4] !== undefined | |
| ) { | |
| // 24-bit true color: 38;2;r;g;b | |
| currentColor = { | |
| r: codes[k + 2]!, | |
| g: codes[k + 3]!, | |
| b: codes[k + 4]!, | |
| } | |
| k += 4 | |
| } | |
| } | |
| k++ | |
| } | |
| } | |
| i = j + 1 | |
| continue | |
| } | |
| // Regular character - find extent of same-styled text | |
| const textStart = i | |
| while (i < line.length && line[i] !== '\x1b') { | |
| i++ | |
| } | |
| const spanText = line.slice(textStart, i) | |
| if (spanText) { | |
| spans.push({ text: spanText, color: currentColor, bold }) | |
| } | |
| } | |
| // Add empty span if line is empty (to preserve line) | |
| if (spans.length === 0) { | |
| spans.push({ text: '', color: DEFAULT_FG, bold: false }) | |
| } | |
| lines.push(spans) | |
| } | |
| return lines | |
| } | |
| /** | |
| * Get color from 256-color palette | |
| */ | |
| function get256Color(index: number): AnsiColor { | |
| // Standard colors (0-15) | |
| if (index < 16) { | |
| const standardColors: AnsiColor[] = [ | |
| { r: 0, g: 0, b: 0 }, // 0 black | |
| { r: 128, g: 0, b: 0 }, // 1 red | |
| { r: 0, g: 128, b: 0 }, // 2 green | |
| { r: 128, g: 128, b: 0 }, // 3 yellow | |
| { r: 0, g: 0, b: 128 }, // 4 blue | |
| { r: 128, g: 0, b: 128 }, // 5 magenta | |
| { r: 0, g: 128, b: 128 }, // 6 cyan | |
| { r: 192, g: 192, b: 192 }, // 7 white | |
| { r: 128, g: 128, b: 128 }, // 8 bright black | |
| { r: 255, g: 0, b: 0 }, // 9 bright red | |
| { r: 0, g: 255, b: 0 }, // 10 bright green | |
| { r: 255, g: 255, b: 0 }, // 11 bright yellow | |
| { r: 0, g: 0, b: 255 }, // 12 bright blue | |
| { r: 255, g: 0, b: 255 }, // 13 bright magenta | |
| { r: 0, g: 255, b: 255 }, // 14 bright cyan | |
| { r: 255, g: 255, b: 255 }, // 15 bright white | |
| ] | |
| return standardColors[index] || DEFAULT_FG | |
| } | |
| // 216 color cube (16-231) | |
| if (index < 232) { | |
| const i = index - 16 | |
| const r = Math.floor(i / 36) | |
| const g = Math.floor((i % 36) / 6) | |
| const b = i % 6 | |
| return { | |
| r: r === 0 ? 0 : 55 + r * 40, | |
| g: g === 0 ? 0 : 55 + g * 40, | |
| b: b === 0 ? 0 : 55 + b * 40, | |
| } | |
| } | |
| // Grayscale (232-255) | |
| const gray = (index - 232) * 10 + 8 | |
| return { r: gray, g: gray, b: gray } | |
| } | |
| export type AnsiToSvgOptions = { | |
| fontFamily?: string | |
| fontSize?: number | |
| lineHeight?: number | |
| paddingX?: number | |
| paddingY?: number | |
| backgroundColor?: string | |
| borderRadius?: number | |
| } | |
| /** | |
| * Convert ANSI text to SVG | |
| * Uses <tspan> elements within a single <text> per line so the renderer | |
| * handles character spacing natively (no manual charWidth calculation) | |
| */ | |
| export function ansiToSvg( | |
| ansiText: string, | |
| options: AnsiToSvgOptions = {}, | |
| ): string { | |
| const { | |
| fontFamily = 'Menlo, Monaco, monospace', | |
| fontSize = 14, | |
| lineHeight = 22, | |
| paddingX = 24, | |
| paddingY = 24, | |
| backgroundColor = `rgb(${DEFAULT_BG.r}, ${DEFAULT_BG.g}, ${DEFAULT_BG.b})`, | |
| borderRadius = 8, | |
| } = options | |
| const lines = parseAnsi(ansiText) | |
| // Trim trailing empty lines | |
| while ( | |
| lines.length > 0 && | |
| lines[lines.length - 1]!.every(span => span.text.trim() === '') | |
| ) { | |
| lines.pop() | |
| } | |
| // Estimate width based on max line length (for SVG dimensions only) | |
| // For monospace fonts, character width is roughly 0.6 * fontSize | |
| const charWidthEstimate = fontSize * 0.6 | |
| const maxLineLength = Math.max( | |
| ...lines.map(spans => spans.reduce((acc, s) => acc + s.text.length, 0)), | |
| ) | |
| const width = Math.ceil(maxLineLength * charWidthEstimate + paddingX * 2) | |
| const height = lines.length * lineHeight + paddingY * 2 | |
| // Build SVG - use tspan elements so renderer handles character positioning | |
| let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">\n` | |
| svg += ` <rect width="100%" height="100%" fill="${backgroundColor}" rx="${borderRadius}" ry="${borderRadius}"/>\n` | |
| svg += ` <style>\n` | |
| svg += ` text { font-family: ${fontFamily}; font-size: ${fontSize}px; white-space: pre; }\n` | |
| svg += ` .b { font-weight: bold; }\n` | |
| svg += ` </style>\n` | |
| for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { | |
| const spans = lines[lineIndex]! | |
| const y = | |
| paddingY + (lineIndex + 1) * lineHeight - (lineHeight - fontSize) / 2 | |
| // Build a single <text> element with <tspan> children for each colored segment | |
| // xml:space="preserve" prevents SVG from collapsing whitespace | |
| svg += ` <text x="${paddingX}" y="${y}" xml:space="preserve">` | |
| for (const span of spans) { | |
| if (!span.text) continue | |
| const colorStr = `rgb(${span.color.r}, ${span.color.g}, ${span.color.b})` | |
| const boldClass = span.bold ? ' class="b"' : '' | |
| svg += `<tspan fill="${colorStr}"${boldClass}>${escapeXml(span.text)}</tspan>` | |
| } | |
| svg += `</text>\n` | |
| } | |
| svg += `</svg>` | |
| return svg | |
| } | |