Add ANSI-to-HTML converter module
This commit is contained in:
parent
7f3ce24099
commit
3f226996e7
1 changed files with 302 additions and 0 deletions
302
src/ansi.ts
Normal file
302
src/ansi.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
// 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.
|
||||
// ESC character code
|
||||
const ESC = "\x1b";
|
||||
|
||||
export function trimLineEndPreserveAnsi(line: string): string {
|
||||
// Peel off any ANSI sequences at the end of the line
|
||||
let end = line.length;
|
||||
let ansiSuffix = "";
|
||||
while (end > 0) {
|
||||
// Check for ANSI escape sequence at end: ESC [ params letter
|
||||
const suffix = line.slice(0, end);
|
||||
if (!suffix.endsWith("]")) {
|
||||
// Look for the closing letter (A-Z, a-z)
|
||||
const lastChar = suffix[suffix.length - 1];
|
||||
if (!lastChar || !/[A-Za-z]/.test(lastChar)) break;
|
||||
|
||||
// Walk back to find ESC [
|
||||
let seqStart = suffix.length - 2;
|
||||
while (seqStart >= 0 && /[0-9;?]/.test(suffix[seqStart] || "")) {
|
||||
seqStart--;
|
||||
}
|
||||
if (seqStart < 0 || suffix[seqStart] !== "[") break;
|
||||
seqStart--;
|
||||
if (seqStart < 0 || suffix[seqStart] !== ESC) break;
|
||||
|
||||
// Found a valid ANSI sequence
|
||||
const seq = suffix.slice(seqStart);
|
||||
ansiSuffix = seq + ansiSuffix;
|
||||
end = seqStart;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const trimmed = line.slice(0, end).trimEnd();
|
||||
return trimmed + 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 += "<";
|
||||
else if (ch === ">") result += ">";
|
||||
else if (ch === "&") result += "&";
|
||||
else result += ch;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (inSpan) result += "</span>";
|
||||
|
||||
return result;
|
||||
}
|
||||
Loading…
Reference in a new issue