clarc/src/ansi.ts

279 lines
7.3 KiB
TypeScript

// ANSI to HTML converter - processes escape sequences and renders as inline styled spans
const colors: Record<number, string> = {
30: "#0d1117",
31: "#f85149",
32: "#3fb950",
33: "#d29922",
34: "#58a6ff",
35: "#bc8cff",
36: "#39c5cf",
37: "#c9d1d9",
90: "#6e7681",
91: "#ff7b72",
92: "#7ee787",
93: "#e3b341",
94: "#79c0ff",
95: "#d2a8ff",
96: "#56d4dd",
97: "#ffffff",
};
const bgColors: Record<number, string> = {
40: "#0d1117",
41: "#f85149",
42: "#3fb950",
43: "#d29922",
44: "#58a6ff",
45: "#bc8cff",
46: "#39c5cf",
47: "#c9d1d9",
100: "#6e7681",
101: "#ff7b72",
102: "#7ee787",
103: "#e3b341",
104: "#79c0ff",
105: "#d2a8ff",
106: "#56d4dd",
107: "#ffffff",
};
interface AnsiState {
fg: string | null;
bg: string | null;
bold: boolean;
dim: boolean;
italic: boolean;
underline: boolean;
inverse: boolean;
}
interface ExtendedColorResult {
color: string;
skip: number;
}
// Trim trailing whitespace from each line to prevent background color bleeding.
// Terminal buffers often pad lines with spaces. Preserve trailing ANSI resets.
export function trimLineEndPreserveAnsi(line: string): string {
let end = line.length;
let ansiSuffix = "";
while (end > 0) {
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC character is intentional for ANSI sequences
const match = line.slice(0, end).match(/\u001b\[[0-9;?]*[A-Za-z]$/);
if (!match) break;
ansiSuffix = match[0] + ansiSuffix;
end -= match[0].length;
}
return line.slice(0, end).trimEnd() + ansiSuffix;
}
// Parse extended color: 38;2;R;G;B (24-bit) or 38;5;N (256-color)
// Returns { color, skip } where skip is additional codes to skip
function parseExtendedColor(
codes: number[],
idx: number,
): ExtendedColorResult | null {
const mode = codes[idx + 1];
// 24-bit RGB: 38;2;R;G;B
if (mode === 2 && codes[idx + 4] !== undefined) {
const r = codes[idx + 2];
const g = codes[idx + 3];
const b = codes[idx + 4];
return { color: `rgb(${r},${g},${b})`, skip: 4 };
}
// 256-color palette: 38;5;N
if (mode === 5) {
const colorNum = codes[idx + 2];
if (colorNum === undefined) return null;
let color: string;
if (colorNum < 16) {
const basic = [
"#0d1117",
"#cd3131",
"#0dbc79",
"#e5e510",
"#2472c8",
"#bc3fbc",
"#11a8cd",
"#e5e5e5",
"#666666",
"#f14c4c",
"#23d18b",
"#f5f543",
"#3b8eea",
"#d670d6",
"#29b8db",
"#ffffff",
];
color = basic[colorNum] || "#ffffff";
} else if (colorNum < 232) {
const n = colorNum - 16;
const ri = Math.floor(n / 36);
const gi = Math.floor((n % 36) / 6);
const bi = n % 6;
const r = ri === 0 ? 0 : ri * 40 + 55;
const g = gi === 0 ? 0 : gi * 40 + 55;
const b = bi === 0 ? 0 : bi * 40 + 55;
color = `rgb(${r},${g},${b})`;
} else {
const gray = (colorNum - 232) * 10 + 8;
color = `rgb(${gray},${gray},${gray})`;
}
return { color, skip: 2 };
}
return null;
}
function resetState(): AnsiState {
return {
fg: null,
bg: null,
bold: false,
dim: false,
italic: false,
underline: false,
inverse: false,
};
}
function buildStyle(state: AnsiState): string {
let fg = state.fg;
let bg = state.bg;
// For inverse, swap fg and bg
if (state.inverse) {
const tmp = fg;
fg = bg || "#ffffff";
bg = tmp || "#0d1117";
}
const styles: string[] = [];
if (fg) styles.push(`color:${fg}`);
if (bg) styles.push(`background-color:${bg}`);
if (state.bold) styles.push("font-weight:bold");
if (state.dim) styles.push("opacity:0.5");
if (state.italic) styles.push("font-style:italic");
if (state.underline) styles.push("text-decoration:underline");
return styles.join(";");
}
export function ansiToHtml(text: string): string {
if (!text) return "";
// Trim trailing whitespace from each line to prevent background color bleeding
text = text.split("\n").map(trimLineEndPreserveAnsi).join("\n");
let result = "";
let inSpan = false;
let i = 0;
let currentStyle = "";
let state = resetState();
function applyStyle(): void {
const nextStyle = buildStyle(state);
if (nextStyle === currentStyle) return;
if (inSpan) {
result += "</span>";
inSpan = false;
}
if (nextStyle) {
result += `<span style="${nextStyle}">`;
inSpan = true;
}
currentStyle = nextStyle;
}
while (i < text.length) {
// Check for ESC character (char code 27)
if (text.charCodeAt(i) === 27 && text[i + 1] === "[") {
// Parse CSI sequence: ESC [ params command
let j = i + 2;
let params = "";
while (j < text.length && /[0-9;]/.test(text[j] || "")) {
params += text[j];
j++;
}
const command = text[j] || "";
j++;
if (command === "m") {
// SGR - Select Graphic Rendition
const codes = params ? params.split(";").map(Number) : [0];
for (let k = 0; k < codes.length; k++) {
const code = codes[k];
if (code === 0) {
state = resetState();
} else if (code === 1) state.bold = true;
else if (code === 2) state.dim = true;
else if (code === 3) state.italic = true;
else if (code === 4) state.underline = true;
else if (code === 7) state.inverse = true;
else if (code === 22) {
state.bold = false;
state.dim = false;
} else if (code === 23) state.italic = false;
else if (code === 24) state.underline = false;
else if (code === 27) state.inverse = false;
else if (code === 39) state.fg = null;
else if (code === 49) state.bg = null;
else if (code === 38) {
const extColor = parseExtendedColor(codes, k);
if (extColor) {
state.fg = extColor.color;
k += extColor.skip;
}
} else if (code === 48) {
const extColor = parseExtendedColor(codes, k);
if (extColor) {
state.bg = extColor.color;
k += extColor.skip;
}
} else if (code !== undefined && colors[code]) {
state.fg = colors[code];
} else if (code !== undefined && bgColors[code]) {
state.bg = bgColors[code];
}
}
applyStyle();
}
// Skip other escape sequences (H, f, J, K, etc.) - they don't affect our line-based output
i = j;
continue;
}
// Track newlines in the content
if (text[i] === "\n" || text[i] === "\r") {
if (text[i] === "\n") {
// Close span before newline to prevent background color bleeding
if (inSpan) {
result += "</span>";
}
result += "\n";
// Reopen span after newline if we had styling
if (inSpan) {
result += `<span style="${currentStyle}">`;
}
}
// Skip carriage return - we only care about line feeds
i++;
continue;
}
// Regular character - escape HTML
const ch = text[i];
if (ch === "<") result += "&lt;";
else if (ch === ">") result += "&gt;";
else if (ch === "&") result += "&amp;";
else result += ch;
i++;
}
if (inSpan) result += "</span>";
return result;
}