import { lerpHex } from "../src/ui/theme"; import { SHEEN_PEAK, SHEEN_TICK_MS, sheenCenter, sheenIntensity, sheenLoopTicks, sheenPeriod, } from "../src/ui/sheen"; export interface AnsiToSvgOptions { cols: number; bg?: string; title?: string; maxWidth?: number; /** Animate a sweeping highlight over progress-bar fills (rows with a ░ track). */ shimmer?: boolean; } interface Style { bold: boolean; dim: boolean; italic: boolean; inverse: boolean; fg: string | null; bg: string | null; } interface Run { col: number; text: string; style: Style; } const FONT_SIZE = 16; const CHAR_W = 9.6; const LINE_H = 22; const PAD = 32; const HEADER_H = 36; const RADIUS = 14; const BG = "#0a0810"; const FG_DEFAULT = "#ddd8ea"; const DIM_OPACITY = 0.55; const FONT_STACK = 'ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace'; const PALETTE16 = [ "#262626", "#ee7d92", "#86d6a2", "#f0c560", "#8fb4d8", "#c79bd8", "#7fc8c4", "#ece4da", "#7f7f7f", "#f59cab", "#a3e6bb", "#f7d68a", "#a9c8e8", "#d8b3e8", "#9adcd8", "#fff8ef", ]; function freshStyle(): Style { return { bold: false, dim: false, italic: false, inverse: false, fg: null, bg: null }; } function hex(n: number): string { return n.toString(16).padStart(2, "0"); } function color256(n: number): string { if (n < 16) return PALETTE16[n]!; if (n < 232) { const v = (x: number) => (x === 0 ? 0 : 55 + x * 40); const i = n - 16; const r = v(Math.floor(i / 36)); const g = v(Math.floor(i / 6) % 6); const b = v(i % 6); return `#${hex(r)}${hex(g)}${hex(b)}`; } const gray = 8 + 10 * (n - 232); return `#${hex(gray)}${hex(gray)}${hex(gray)}`; } function applySgr(style: Style, params: number[]): Style { const s = { ...style }; if (params.length === 0) params = [0]; for (let i = 0; i < params.length; i++) { const p = params[i]!; if (p === 0) Object.assign(s, freshStyle()); else if (p === 1) s.bold = true; else if (p === 2) s.dim = true; else if (p === 22) { s.bold = false; s.dim = false; } else if (p === 3) s.italic = true; else if (p === 23) s.italic = false; else if (p === 7) s.inverse = true; else if (p === 27) s.inverse = false; else if (p === 39) s.fg = null; else if (p === 49) s.bg = null; else if (p === 38 || p === 48) { const isFg = p === 38; const mode = params[i + 1]; if (mode === 2) { const [r, g, b] = [params[i + 2] ?? 0, params[i + 3] ?? 0, params[i + 4] ?? 0]; const c = `#${hex(r)}${hex(g)}${hex(b)}`; if (isFg) s.fg = c; else s.bg = c; i += 4; } else if (mode === 5) { const c = color256(params[i + 2] ?? 0); if (isFg) s.fg = c; else s.bg = c; i += 2; } } else if (p >= 30 && p <= 37) s.fg = PALETTE16[p - 30]!; else if (p >= 90 && p <= 97) s.fg = PALETTE16[p - 90 + 8]!; else if (p >= 40 && p <= 47) s.bg = PALETTE16[p - 40]!; else if (p >= 100 && p <= 107) s.bg = PALETTE16[p - 100 + 8]!; } return s; } function sameStyle(a: Style, b: Style): boolean { return ( a.bold === b.bold && a.dim === b.dim && a.italic === b.italic && a.inverse === b.inverse && a.fg === b.fg && a.bg === b.bg ); } function parseLine(line: string, state: { style: Style }, cols: number): Run[] { const cleaned = line.replace(/\x1b\[[0-9;?]*[A-HJKSTfhilsu]/g, ""); const parts = cleaned.split(/(\x1b\[[0-9;]*m)/); const runs: Run[] = []; let col = 0; for (const part of parts) { if (part === "") continue; const m = part.match(/^\x1b\[([0-9;]*)m$/); if (m) { const params = m[1] === "" ? [] : m[1]!.split(";").map(Number); state.style = applySgr(state.style, params); continue; } const text = Array.from(part); if (text.length === 0) continue; const last = runs[runs.length - 1]; if (last && sameStyle(last.style, state.style) && last.col + Array.from(last.text).length === col) { last.text += part; } else { runs.push({ col, text: part, style: { ...state.style } }); } col += text.length; } if (col < cols) { runs.push({ col, text: " ".repeat(cols - col), style: freshStyle() }); } return runs; } function escapeXml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } const fmt = (n: number): string => String(Math.round(n * 100) / 100); export function ansiToSvg(frame: string, opts: AnsiToSvgOptions): string { const { cols, bg = BG, title, maxWidth, shimmer = false } = opts; const lines = frame.replace(/\r/g, "").split("\n"); const isBlank = (l: string): boolean => l.replace(/\x1b\[[0-9;]*m/g, "").trim() === ""; while (lines.length > 0 && isBlank(lines[0]!)) lines.shift(); while (lines.length > 0 && isBlank(lines[lines.length - 1]!)) lines.pop(); const width = cols * CHAR_W + PAD * 2; const height = HEADER_H + lines.length * LINE_H + PAD * 2; const cap = maxWidth ?? width; const out: string[] = []; out.push(``); out.push( ``, ); out.push(` `); out.push( ` `, ); if (title) { out.push( ` ${escapeXml(title)}`, ); } out.push( ` `, ); const barRuns: { row: number; col: number; n: number; fg: string }[] = []; const trackLen = new Map(); const state = { style: freshStyle() }; lines.forEach((line, row) => { const runs = parseLine(line, state, cols); const baseline = HEADER_H + PAD + row * LINE_H + FONT_SIZE; for (const run of runs) { let { col, text } = run; const st = run.style; const cells = Array.from(text); const paintsBox = st.inverse || st.bg !== null; if (!paintsBox) { let start = 0; let end = cells.length; while (start < end && cells[start] === " ") start++; while (end > start && cells[end - 1] === " ") end--; if (start === end) continue; col += start; text = cells.slice(start, end).join(""); } const n = Array.from(text).length; const x = PAD + col * CHAR_W; const w = n * CHAR_W; const fg = st.fg ?? FG_DEFAULT; if (shimmer) { if (/^█+$/.test(text)) barRuns.push({ row, col, n, fg }); const dim = (text.match(/░/g) ?? []).length; if (dim > 0) trackLen.set(row, (trackLen.get(row) ?? 0) + dim); } if (/^[│┃]+$/.test(text)) { const barW = text[0] === "┃" ? 2.8 : 1.4; for (let k = 0; k < n; k++) { const cx = PAD + (col + k) * CHAR_W + (CHAR_W - barW) / 2; out.push( ` `, ); } continue; } if (/^[█▀▄▌▐]+$/.test(text)) { const top = baseline - FONT_SIZE; const half = LINE_H / 2; const dim = st.dim ? ` fill-opacity="${DIM_OPACITY}"` : ""; const bcells = Array.from(text); for (let k = 0; k < n; k++) { const ch = bcells[k]!; const cellLeft = PAD + (col + k) * CHAR_W; let rx = cellLeft; let ry = top; let rw = CHAR_W; let rh = LINE_H; if (ch === "▀") rh = half; else if (ch === "▄") { ry = top + half; rh = half; } else if (ch === "▌") rw = CHAR_W / 2; else if (ch === "▐") { rx = cellLeft + CHAR_W / 2; rw = CHAR_W / 2; } out.push( ` `, ); } continue; } const boxFill = st.inverse ? fg : st.bg; if (boxFill) { out.push( ` `, ); } if (text.trim() === "" && !st.inverse) continue; const attrs: string[] = [ `x="${fmt(x)}"`, `y="${fmt(baseline)}"`, `textLength="${fmt(w)}"`, `lengthAdjust="spacingAndGlyphs"`, `xml:space="preserve"`, ]; const fill = st.inverse ? bg : fg; if (fill !== FG_DEFAULT) attrs.push(`fill="${fill}"`); if (st.dim && !st.inverse) attrs.push(`fill-opacity="${DIM_OPACITY}"`); if (st.bold) attrs.push(`font-weight="600"`); if (st.italic) attrs.push(`font-style="italic"`); out.push(` ${escapeXml(text)}`); } }); out.push(" "); if (shimmer && barRuns.length > 0) { // Reproduce the live ProgressBar sheen 1:1: for each filled cell, animate // its fill through the exact bell-tinted colors, snapping frame-to-frame // (calcMode="discrete") like the terminal repaint, so it stays pixelated. const byRow = new Map(); for (const r of barRuns) { if (!trackLen.has(r.row)) continue; const arr = byRow.get(r.row) ?? []; arr.push({ col: r.col, n: r.n, fg: r.fg }); byRow.set(r.row, arr); } for (const [row, parts] of byRow) { parts.sort((a, b) => a.col - b.col); // Merge contiguous █ runs into one region, expanded to per-cell colors. const regions: { col: number; colors: string[] }[] = []; for (const p of parts) { const last = regions[regions.length - 1]; const cells = Array.from({ length: p.n }, () => p.fg); if (last && last.col + last.colors.length === p.col) last.colors.push(...cells); else regions.push({ col: p.col, colors: cells }); } const yTop = HEADER_H + PAD + row * LINE_H; for (const region of regions) { const len = region.colors.length; const fullCells = len + (trackLen.get(row) ?? 0); const period = sheenPeriod(fullCells); const frames = sheenLoopTicks(period); const dur = (frames * SHEEN_TICK_MS) / 1000; for (let i = 0; i < len; i++) { const base = region.colors[i]!; const values: string[] = []; for (let t = 0; t < frames; t++) { const intensity = sheenIntensity(i, sheenCenter(t, period)); values.push(intensity > 0 ? lerpHex(base, SHEEN_PEAK, intensity) : base); } const x = PAD + (region.col + i) * CHAR_W; out.push( ` `, ); out.push( ` `, ); out.push(` `); } } } } out.push(""); return out.join("\n"); }