/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { Terminal } from '@xterm/headless'; import { type DOMElement } from 'ink'; export const generateSvgForTerminal = ( terminal: Terminal, options: { rootNode?: DOMElement } = {}, ): string => { const activeBuffer = terminal.buffer.active; const { rootNode } = options; const getHexColor = ( isRGB: boolean, isPalette: boolean, isDefault: boolean, colorCode: number, ): string | null => { if (isDefault) return null; if (isRGB) { return `#${colorCode.toString(16).padStart(6, '0')}`; } if (isPalette) { if (colorCode >= 0 && colorCode <= 15) { return ( [ '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5', '#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff', ][colorCode] || null ); } else if (colorCode >= 16 && colorCode <= 231) { const v = [0, 95, 135, 175, 215, 255]; const c = colorCode - 16; const b = v[c % 6]; const g = v[Math.floor(c / 6) % 6]; const r = v[Math.floor(c / 36) % 6]; return `#${[r, g, b].map((x) => x?.toString(16).padStart(2, '0')).join('')}`; } else if (colorCode >= 232 && colorCode <= 255) { const gray = 8 + (colorCode - 232) * 10; const hex = gray.toString(16).padStart(2, '0'); return `#${hex}${hex}${hex}`; } } return null; }; const escapeXml = (unsafe: string): string => // eslint-disable-next-line no-control-regex unsafe.replace(/[<>&'"\x00-\x08\x0B-\x0C\x0E-\x1F]/g, (c) => { switch (c) { case '<': return '<'; case '>': return '>'; case '&': return '&'; case "'": return '''; case '"': return '"'; default: return ''; } }); const charWidth = 9; const charHeight = 17; const padding = 10; // Find the actual number of rows with content to avoid rendering trailing blank space. let contentRows = terminal.rows; for (let y = terminal.rows - 1; y >= 0; y--) { const line = activeBuffer.getLine(y); if (line && line.translateToString(true).trim().length > 0) { contentRows = y + 1; break; } } if (contentRows === 0) contentRows = 1; // Minimum 1 row const width = terminal.cols * charWidth + padding * 2; const height = contentRows * charHeight + padding * 2; let svg = ` `; svg += ` `; svg += ` `; // Terminal background svg += ` `; for (let y = 0; y < contentRows; y++) { const line = activeBuffer.getLine(y); if (!line) continue; let currentFgHex: string | null = null; let currentBgHex: string | null = null; let currentIsBold = false; let currentIsItalic = false; let currentIsUnderline = false; let currentBlockStartCol = -1; let currentBlockText = ''; let currentBlockNumCells = 0; const finalizeBlock = (_endCol: number) => { if (currentBlockStartCol !== -1) { if (currentBlockText.length > 0) { const xPos = currentBlockStartCol * charWidth; const yPos = y * charHeight; if (currentBgHex) { const rectWidth = currentBlockNumCells * charWidth; svg += ` `; } if (currentBlockText.trim().length > 0 || currentIsUnderline) { const fill = currentFgHex || '#ffffff'; // Default text color const textWidth = currentBlockNumCells * charWidth; let extraAttrs = ''; if (currentIsBold) extraAttrs += ' font-weight="bold"'; if (currentIsItalic) extraAttrs += ' font-style="italic"'; if (currentIsUnderline) extraAttrs += ' text-decoration="underline"'; // Use textLength to ensure the block fits exactly into its designated cells const textElement = `${escapeXml(currentBlockText)}`; svg += ` ${textElement}\n`; } } } }; for (let x = 0; x < line.length; x++) { const cell = line.getCell(x); if (!cell) continue; const cellWidth = cell.getWidth(); if (cellWidth === 0) continue; // Skip continuation cells of wide characters let fgHex = getHexColor( cell.isFgRGB(), cell.isFgPalette(), cell.isFgDefault(), cell.getFgColor(), ); let bgHex = getHexColor( cell.isBgRGB(), cell.isBgPalette(), cell.isBgDefault(), cell.getBgColor(), ); if (cell.isInverse()) { const tempFgHex = fgHex; fgHex = bgHex || '#000000'; bgHex = tempFgHex || '#ffffff'; } const isBold = !!cell.isBold(); const isItalic = !!cell.isItalic(); const isUnderline = !!cell.isUnderline(); let chars = cell.getChars(); if (chars === '') chars = ' '.repeat(cellWidth); if ( fgHex !== currentFgHex || bgHex !== currentBgHex || isBold !== currentIsBold || isItalic !== currentIsItalic || isUnderline !== currentIsUnderline || currentBlockStartCol === -1 ) { finalizeBlock(x); currentFgHex = fgHex; currentBgHex = bgHex; currentIsBold = isBold; currentIsItalic = isItalic; currentIsUnderline = isUnderline; currentBlockStartCol = x; currentBlockText = chars; currentBlockNumCells = cellWidth; } else { currentBlockText += chars; currentBlockNumCells += cellWidth; } } finalizeBlock(line.length); } // Inject unique component names from the tree as comments for auditing if (rootNode) { const components = new Set(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const collect = (node: any) => { if (node.internal_componentName) components.add(node.internal_componentName); if (node.internal_testId) components.add(node.internal_testId); if (node.childNodes) { for (const child of node.childNodes) collect(child); } }; collect(rootNode); for (const name of components) { svg += ` \n`; } } svg += ` \n`; return svg; };