| 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 | } |