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