@taml/encoder
Version:
Convert ANSI escape sequences to TAML (Terminal ANSI Markup Language) tags
319 lines (279 loc) • 7.9 kB
text/typescript
import {
convert256ToBackgroundColor,
convert256ToStandardColor,
convertBasicAnsiToBackgroundColor,
convertBasicAnsiToStandardColor,
convertRGBToBackgroundColor,
convertRGBToStandardColor,
} from "./color-converter.js";
import type { AnsiState, AnsiToken, ParsedAnsiSequence } from "./types.js";
// biome-ignore lint/suspicious/noControlCharactersInRegex: <explanation>
const ANSI_ESCAPE_REGEX = /(\u001b\[[0-9;]*m)/g;
/**
* Regular expression specifically for SGR (Select Graphic Rendition) sequences
* These control text formatting, colors, etc.
*/
// biome-ignore lint/suspicious/noControlCharactersInRegex: <explanation>
const SGR_REGEX = /^(\u001b)\[([0-9;]*)m$/;
/**
* Create an initial ANSI state with default values
*/
export function createInitialAnsiState(): AnsiState {
return {
foreground: null,
background: null,
bold: false,
dim: false,
italic: false,
underline: false,
strikethrough: false,
};
}
/**
* Parse ANSI text into tokens containing text and escape sequences
*/
export function tokenizeAnsiText(text: string): AnsiToken[] {
const tokens: AnsiToken[] = [];
let lastIndex = 0;
const matches = Array.from(text.matchAll(ANSI_ESCAPE_REGEX));
for (const match of matches) {
const matchIndex = match.index;
if (matchIndex === undefined) continue;
const matchText = match[0];
// Add text before this match as a text token
if (matchIndex > lastIndex) {
const textContent = text.slice(lastIndex, matchIndex);
if (textContent) {
tokens.push({
type: "text",
content: textContent,
});
}
}
// Parse the ANSI sequence
const sequence = parseAnsiSequence(matchText);
if (sequence) {
tokens.push({
type: "sequence",
content: matchText,
sequence,
});
}
lastIndex = matchIndex + matchText.length;
}
// Add remaining text
if (lastIndex < text.length) {
const textContent = text.slice(lastIndex);
if (textContent) {
tokens.push({
type: "text",
content: textContent,
});
}
}
return tokens;
}
/**
* Parse a single ANSI escape sequence
*/
function parseAnsiSequence(sequence: string): ParsedAnsiSequence | null {
const sgrMatch = sequence.match(SGR_REGEX);
if (!sgrMatch) {
// Not an SGR sequence, ignore
return null;
}
const params = sgrMatch[2];
if (!params) {
// Reset sequence (ESC[m or ESC[0m)
return {
type: "reset",
value: 0,
raw: sequence,
};
}
const codes = params.split(";").map((code) => Number.parseInt(code, 10) || 0);
// Handle multiple codes in one sequence
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
if (code === undefined) continue;
// Reset
if (code === 0) {
return {
type: "reset",
value: 0,
raw: sequence,
};
}
// Styles
if (code === 1) {
return { type: "style", value: "bold", raw: sequence };
}
if (code === 2) {
return { type: "style", value: "dim", raw: sequence };
}
if (code === 3) {
return { type: "style", value: "italic", raw: sequence };
}
if (code === 4) {
return { type: "style", value: "underline", raw: sequence };
}
if (code === 9) {
return { type: "style", value: "strikethrough", raw: sequence };
}
// Basic foreground colors (30-37, 90-97)
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
const color = convertBasicAnsiToStandardColor(code);
return {
type: "color",
value: color,
raw: sequence,
};
}
// Foreground color reset (39)
if (code === 39) {
return {
type: "foreground-reset",
value: null,
raw: sequence,
};
}
// Basic background colors (40-47, 100-107)
if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
const color = convertBasicAnsiToBackgroundColor(code);
return {
type: "background",
value: color,
raw: sequence,
};
}
// Background color reset (49)
if (code === 49) {
return {
type: "background-reset",
value: null,
raw: sequence,
};
}
// Extended foreground color (38;5;n or 38;2;r;g;b)
if (code === 38 && i + 1 < codes.length) {
const mode = codes[i + 1];
if (mode === undefined) continue;
if (mode === 5 && i + 2 < codes.length) {
// 256-color mode: 38;5;n
const colorIndex = codes[i + 2];
if (colorIndex === undefined) continue;
const color = convert256ToStandardColor(colorIndex);
return {
type: "color",
value: color,
raw: sequence,
};
}
if (mode === 2 && i + 4 < codes.length) {
// RGB mode: 38;2;r;g;b
const r = codes[i + 2];
const g = codes[i + 3];
const b = codes[i + 4];
if (r === undefined || g === undefined || b === undefined) continue;
const color = convertRGBToStandardColor(r, g, b);
return {
type: "color",
value: color,
raw: sequence,
};
}
}
// Extended background color (48;5;n or 48;2;r;g;b)
if (code === 48 && i + 1 < codes.length) {
const mode = codes[i + 1];
if (mode === undefined) continue;
if (mode === 5 && i + 2 < codes.length) {
// 256-color mode: 48;5;n
const colorIndex = codes[i + 2];
if (colorIndex === undefined) continue;
const color = convert256ToBackgroundColor(colorIndex);
return {
type: "background",
value: color,
raw: sequence,
};
}
if (mode === 2 && i + 4 < codes.length) {
// RGB mode: 48;2;r;g;b
const r = codes[i + 2];
const g = codes[i + 3];
const b = codes[i + 4];
if (r === undefined || g === undefined || b === undefined) continue;
const color = convertRGBToBackgroundColor(r, g, b);
return {
type: "background",
value: color,
raw: sequence,
};
}
}
}
// Unknown sequence, ignore
return null;
}
/**
* Apply a parsed ANSI sequence to the current state
*/
export function applyAnsiSequence(
state: AnsiState,
sequence: ParsedAnsiSequence,
): AnsiState {
const newState = { ...state };
switch (sequence.type) {
case "reset":
return createInitialAnsiState();
case "foreground-reset":
newState.foreground = null;
break;
case "background-reset":
newState.background = null;
break;
case "color":
newState.foreground = sequence.value as string;
break;
case "background":
newState.background = sequence.value as string;
break;
case "style": {
const styleValue = sequence.value as string;
switch (styleValue) {
case "bold":
newState.bold = true;
break;
case "dim":
newState.dim = true;
break;
case "italic":
newState.italic = true;
break;
case "underline":
newState.underline = true;
break;
case "strikethrough":
newState.strikethrough = true;
break;
}
break;
}
}
return newState;
}
/**
* Get the active style tags from the current ANSI state
*/
export function getActiveStyleTags(state: AnsiState): string[] {
const tags: string[] = [];
if (state.bold) tags.push("bold");
if (state.dim) tags.push("dim");
if (state.italic) tags.push("italic");
if (state.underline) tags.push("underline");
if (state.strikethrough) tags.push("strikethrough");
if (state.background) tags.push(state.background);
if (state.foreground) tags.push(state.foreground);
return tags;
}