diff --git a/core/src/ansi/ansiColors.ts b/core/src/ansi/ansiColors.ts new file mode 100644 index 0000000..6dc0f51 --- /dev/null +++ b/core/src/ansi/ansiColors.ts @@ -0,0 +1,67 @@ +/* + * @nevware21/chromacon + * https://github.com/nevware21/chromacon + * + * Copyright (c) 2025 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { mathFloor, mathMax, mathMin } from "@nevware21/ts-utils"; +import { AnsiColor } from "./parseAnsiStyles"; + +/** + * The default palette (xterm) used for the colors 0-15. + * @since 0.1.5 + */ +export const DEFAULT_ANSI_PALETTE: string[] = [ + "#000000", "#cd0000", "#00cd00", "#cdcd00", "#0000ee", "#cd00cd", "#00cdcd", "#e5e5e5", + "#7f7f7f", "#ff0000", "#00ff00", "#ffff00", "#5c5cff", "#ff00ff", "#00ffff", "#ffffff" +]; + +function _level(n: number): number { + return n ? 55 + n * 40 : 0; +} + +function _hexToRgb(hex: string): { r: number; g: number; b: number } { + let h = hex.replace("#", ""); + if (h.length === 3) { + h = h.charAt(0) + h.charAt(0) + h.charAt(1) + h.charAt(1) + h.charAt(2) + h.charAt(2); + } + + return { r: parseInt(h.substring(0, 2), 16), g: parseInt(h.substring(2, 4), 16), b: parseInt(h.substring(4, 6), 16) }; +} + +/** + * Convert an xterm 256 color index to RGB. + * @param index - The color index (0-255). + * @param palette - Optional hex colors (`#rrggbb`) overriding the default colors 0-15. + * @since 0.1.5 + */ +export function ansi256ToRgb(index: number, palette?: string[]): { r: number; g: number; b: number } { + index = mathMax(0, mathMin(255, mathFloor(index) || 0)); + if (index < 16) { + return _hexToRgb((palette && palette[index]) || DEFAULT_ANSI_PALETTE[index]); + } + + if (index < 232) { + // 6x6x6 color cube, levels are 0, 95, 135, 175, 215, 255 + const n = index - 16; + return { r: _level(mathFloor(n / 36)), g: _level(mathFloor(n / 6) % 6), b: _level(n % 6) }; + } + + // 24 step greyscale ramp + const v = 8 + (index - 232) * 10; + return { r: v, g: v, b: v }; +} + +/** + * Convert an {@link AnsiColor} to a CSS `rgb(...)` string. + * @param color - The color to convert. + * @param palette - Optional hex colors (`#rrggbb`) used for the `basic` colors. + * @since 0.1.5 + */ +export function ansiColorToCss(color: AnsiColor, palette?: string[]): string { + const rgb = color.type === "rgb" ? color : ansi256ToRgb(color.index, palette); + + return "rgb(" + rgb.r + ", " + rgb.g + ", " + rgb.b + ")"; +} diff --git a/core/src/ansi/ansiToHtml.ts b/core/src/ansi/ansiToHtml.ts new file mode 100644 index 0000000..c9bdfe5 --- /dev/null +++ b/core/src/ansi/ansiToHtml.ts @@ -0,0 +1,95 @@ +/* + * @nevware21/chromacon + * https://github.com/nevware21/chromacon + * + * Copyright (c) 2025 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { ansiColorToCss } from "./ansiColors"; +import { AnsiColor, IAnsiStyle, parseAnsiStyles } from "./parseAnsiStyles"; + +/** + * Options for {@link ansiToHtml}. + * @since 0.1.5 + */ +export interface IAnsiToHtmlOptions { + /** Emit inline styles instead of classes. Default false. */ + inlineStyles?: boolean; + /** Class prefix when using classes, default `ansi-`. */ + classPrefix?: string; + /** Hex palette for the colors 0-15. */ + palette?: string[]; + /** Set to false to not emit OSC 8 links as anchors. Default true. */ + links?: boolean; +} + +function _escape(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + +function _colorClass(prefix: string, kind: string, c: AnsiColor): string { + return prefix + kind + "-" + (c.type === "rgb" ? c.r + "-" + c.g + "-" + c.b : (c.type === "basic" ? "" : "256-") + c.index); +} + +function _safeLink(url: string): boolean { + return !/^\s*(?:javascript|data|vbscript):/i.test(url); +} + +/** + * Convert a string containing ANSI SGR sequences to HTML. + * @param value - The string to convert. + * @param options - Optional settings. + * @since 0.1.5 + */ +export function ansiToHtml(value: string, options?: IAnsiToHtmlOptions): string { + const opts = options || {}; + const prefix = opts.classPrefix === undefined ? "ansi-" : opts.classPrefix; + let out = ""; + + parseAnsiStyles(value).forEach((run) => { + const s: IAnsiStyle = run.style; + const classes: string[] = []; + const css: string[] = []; + const flags: [string, boolean, string][] = [ + ["bold", !!s.bold, "font-weight:bold"], ["dim", !!s.dim, "opacity:0.6"], ["italic", !!s.italic, "font-style:italic"], + ["underline", !!s.underline, "text-decoration:underline"], ["blink", !!s.blink, ""], ["inverse", !!s.inverse, ""], + ["hidden", !!s.hidden, "visibility:hidden"], ["strikethrough", !!s.strikethrough, "text-decoration:line-through"], + ["overlined", !!s.overlined, "text-decoration:overline"] + ]; + flags.forEach((f) => { + if (f[1]) { + classes.push(prefix + f[0]); + if (f[2]) { + css.push(f[2]); + } + } + }); + if (s.underline === "double") { + classes.push(prefix + "double-underline"); + } + if (s.fg) { + classes.push(_colorClass(prefix, "fg", s.fg)); + css.push("color:" + ansiColorToCss(s.fg, opts.palette)); + } + if (s.bg) { + classes.push(_colorClass(prefix, "bg", s.bg)); + css.push("background-color:" + ansiColorToCss(s.bg, opts.palette)); + } + + let html = _escape(run.text); + if (classes.length) { + html = opts.inlineStyles + ? (css.length ? "" + html + "" : html) + : "" + html + ""; + } + + if (s.link && opts.links !== false && _safeLink(s.link)) { + html = "" + html + ""; + } + + out += html; + }); + + return out; +} diff --git a/core/src/ansi/parseAnsiStyles.ts b/core/src/ansi/parseAnsiStyles.ts new file mode 100644 index 0000000..6f4e888 --- /dev/null +++ b/core/src/ansi/parseAnsiStyles.ts @@ -0,0 +1,264 @@ +/* + * @nevware21/chromacon + * https://github.com/nevware21/chromacon + * + * Copyright (c) 2025 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { isNullOrUndefined } from "@nevware21/ts-utils"; + +/** + * A color resolved from an SGR sequence. + * @since 0.1.5 + */ +export type AnsiColor = + | { type: "basic"; index: number } + | { type: "ansi256"; index: number } + | { type: "rgb"; r: number; g: number; b: number }; + +/** + * The style resolved from SGR codes, only the attributes that are on are present. + * @since 0.1.5 + */ +export interface IAnsiStyle { + fg?: AnsiColor; + bg?: AnsiColor; + ul?: AnsiColor; + bold?: boolean; + dim?: boolean; + italic?: boolean; + underline?: boolean | "double"; + blink?: boolean; + inverse?: boolean; + hidden?: boolean; + strikethrough?: boolean; + overlined?: boolean; + link?: string; +} + +/** + * A run of text with the style that applies to it. + * @since 0.1.5 + */ +export interface IAnsiStyledRun { + text: string; + style: IAnsiStyle; +} + +// 1: CSI params (7-bit or 8-bit introducer), 2: CSI final byte, 3: OSC payload, 4: other escape +const TOKEN_REGEXP = "(?:\\x1b\\[|\\x9b)([0-?]*)[ -\\/]*([@-~])|(?:\\x1b\\]|\\x9d)([^\\x07\\x1b\\x9c]*)(?:\\x07|\\x1b\\\\|\\x9c)|\\x1b[ -\\/]*[0-~]"; + +function _copy(src: T): T { + const result: any = {}; + Object.keys(src).forEach((k) => { + result[k] = (src as any)[k]; + }); + + return result; +} + +function _cloneStyle(style: IAnsiStyle): IAnsiStyle { + const result: any = _copy(style); + ["fg", "bg", "ul"].forEach((key) => { + if (result[key]) { + result[key] = _copy(result[key]); + } + }); + + return result; +} + +function _sameColor(a?: AnsiColor, b?: AnsiColor): boolean { + if (!a || !b) { + return a === b; + } + + return a.type === b.type && (a as any).index === (b as any).index && + (a as any).r === (b as any).r && (a as any).g === (b as any).g && (a as any).b === (b as any).b; +} + +function _sameStyle(a: IAnsiStyle, b: IAnsiStyle): boolean { + return _sameColor(a.fg, b.fg) && _sameColor(a.bg, b.bg) && _sameColor(a.ul, b.ul) && + a.bold === b.bold && a.dim === b.dim && a.italic === b.italic && a.underline === b.underline && + a.blink === b.blink && a.inverse === b.inverse && a.hidden === b.hidden && + a.strikethrough === b.strikethrough && a.overlined === b.overlined && a.link === b.link; +} + +function _toNum(value: string | undefined): number { + return value === undefined || value === "" ? NaN : +value; +} + +function _inRange(value: number, max: number): boolean { + return value === value && value >= 0 && value <= max && value % 1 === 0; +} + +function _makeColor(mode: number, args: number[]): AnsiColor | undefined { + if (mode === 5) { + const n = args[0]; + if (args.length >= 1 && _inRange(n, 255)) { + return n < 16 ? { type: "basic", index: n } : { type: "ansi256", index: n }; + } + } else if (mode === 2 && args.length >= 3 && _inRange(args[0], 255) && _inRange(args[1], 255) && _inRange(args[2], 255)) { + return { type: "rgb", r: args[0], g: args[1], b: args[2] }; + } + + return undefined; +} + +const SGR_ON: { [code: number]: string } = { + 1: "bold", 2: "dim", 3: "italic", 5: "blink", 6: "blink", 7: "inverse", 8: "hidden", + 9: "strikethrough", 21: "underline", 53: "overlined" +}; + +const SGR_OFF: { [code: number]: string } = { + 22: "bold,dim", 23: "italic", 24: "underline", 25: "blink", 27: "inverse", 28: "hidden", + 29: "strikethrough", 39: "fg", 49: "bg", 55: "overlined", 59: "ul" +}; + +function _off(style: IAnsiStyle, key: string) { + delete (style as any)[key]; +} + +function _applySgr(style: IAnsiStyle, paramText: string) { + const params = paramText === "" ? ["0"] : paramText.split(";"); + for (let i = 0; i < params.length; i++) { + const sub = params[i].split(":"); + const code = sub[0] === "" ? 0 : +sub[0]; + if (code !== code) { + continue; + } + + if (code === 38 || code === 48 || code === 58) { + let args: number[]; + let mode: number; + if (sub.length > 1) { + mode = _toNum(sub[1]); + // 38:2::r:g:b or 38:2:r:g:b + const rest = sub.slice(2); + args = (mode === 2 && rest.length >= 4 ? rest.slice(1) : rest).map(_toNum); + } else { + mode = _toNum(params[i + 1]); + const count = mode === 5 ? 1 : (mode === 2 ? 3 : 0); + args = []; + for (let j = 0; j < count && i + 2 + j < params.length; j++) { + args.push(_toNum(params[i + 2 + j])); + } + i += 1 + args.length; + } + + const color = _makeColor(mode, args); + if (color) { + (style as any)[code === 38 ? "fg" : (code === 48 ? "bg" : "ul")] = color; + } + } else if (code === 0) { + const link = style.link; + Object.keys(style).forEach((k) => _off(style, k)); + if (link !== undefined) { + style.link = link; + } + } else if (code === 4) { + const u = sub.length > 1 ? _toNum(sub[1]) : 1; + if (u === 0) { + _off(style, "underline"); + } else { + style.underline = u === 2 ? "double" : true; + } + } else if (SGR_ON[code]) { + (style as any)[SGR_ON[code]] = code === 21 ? "double" : true; + } else if (SGR_OFF[code]) { + SGR_OFF[code].split(",").forEach((k) => _off(style, k)); + } else if (code <= 107 && ((code % 60 >= 30 && code % 60 <= 37) || (code % 60 >= 40 && code % 60 <= 47))) { + // 30-37 / 40-47 (basic) and 90-97 / 100-107 (bright, +8) + const bright = code >= 90 ? 8 : 0; + const base = code % 60; + (style as any)[base < 40 ? "fg" : "bg"] = { type: "basic", index: (base % 10) + bright }; + } + } +} + +function _applyOsc(style: IAnsiStyle, payload: string) { + // OSC 8 ; params ; uri + if (payload.substring(0, 2) === "8;") { + const idx = payload.indexOf(";", 2); + if (idx >= 0) { + const uri = payload.substring(idx + 1); + if (uri) { + style.link = uri; + } else { + _off(style, "link"); + } + } + } +} + +function _parse(value: string, style: IAnsiStyle): IAnsiStyledRun[] { + const runs: IAnsiStyledRun[] = []; + if (isNullOrUndefined(value)) { + return runs; + } + + const regexp = new RegExp(TOKEN_REGEXP, "g"); + let lastIndex = 0; + let match: RegExpExecArray | null; + + const addText = (text: string) => { + if (text) { + const last = runs.length ? runs[runs.length - 1] : null; + if (last && _sameStyle(last.style, style)) { + last.text += text; + } else { + runs.push({ text, style: _cloneStyle(style) }); + } + } + }; + + while ((match = regexp.exec(value)) !== null) { + addText(value.substring(lastIndex, match.index)); + lastIndex = match.index + match[0].length; + + if (match[2] !== undefined) { + if (match[2] === "m" && !/[<=>?]/.test(match[1])) { + _applySgr(style, match[1]); + } + } else if (match[3] !== undefined) { + _applyOsc(style, match[3]); + } + } + + addText(value.substring(lastIndex)); + + return runs; +} + +/** + * Parse a string containing ANSI SGR escape sequences into runs of text, each with the style + * resolved from the sequences preceding it. Non-SGR sequences are dropped, OSC 8 hyperlinks + * are reported via the `link` style. + * @param value - The string to parse. + * @param state - Optional style to start with, allowing the style to be carried between calls. + * @returns The styled runs; adjacent runs with the same style are merged and empty runs omitted. + * @since 0.1.5 + * @example + * ```ts + * parseAnsiStyles("\u001b[31mred\u001b[0m plain"); + * //=> [{ text: "red", style: { fg: { type: "basic", index: 1 } } }, { text: " plain", style: {} }] + * ``` + */ +export function parseAnsiStyles(value: string, state?: IAnsiStyle): IAnsiStyledRun[] { + return _parse(value, state ? _cloneStyle(state) : {}); +} + +/** + * Create a stateful parser that carries the style from one chunk to the next. + * @returns An object with a `push` method that returns the runs for the chunk and a `state` method. + * @since 0.1.5 + */ +export function createAnsiStyleParser(): { push(chunk: string): IAnsiStyledRun[]; state(): IAnsiStyle } { + const current: IAnsiStyle = {}; + + return { + push: (chunk: string) => _parse(chunk, current), + state: () => _cloneStyle(current) + }; +} diff --git a/core/src/ansi/sgrCodes.ts b/core/src/ansi/sgrCodes.ts new file mode 100644 index 0000000..25bb44c --- /dev/null +++ b/core/src/ansi/sgrCodes.ts @@ -0,0 +1,51 @@ +/* + * @nevware21/chromacon + * https://github.com/nevware21/chromacon + * + * Copyright (c) 2025 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +const SGR_NAMES: { [code: number]: string } = { + 0: "reset", 1: "bold", 2: "dim", 3: "italic", 4: "underline", 5: "blink", 6: "rapidBlink", + 7: "inverse", 8: "hidden", 9: "strikethrough", 21: "doubleUnderline", 22: "normal", + 23: "notItalic", 24: "notUnderline", 25: "notBlink", 27: "notInverse", 28: "notHidden", + 29: "notStrikethrough", 38: "fgExtended", 39: "fgDefault", 48: "bgExtended", 49: "bgDefault", + 53: "overlined", 55: "notOverlined", 58: "ulExtended", 59: "ulDefault" +}; + +const BASIC = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]; + +/** + * Get the name of an SGR code at runtime. + * @param code - The SGR code. + * @returns The name or undefined if the code is not known. + * @since 0.1.5 + */ +export function getSgrName(code: number): string | undefined { + if (SGR_NAMES.hasOwnProperty(code)) { + return SGR_NAMES[code]; + } + + if (code >= 30 && code <= 37) { + return "fg" + _cap(BASIC[code - 30]); + } + + if (code >= 40 && code <= 47) { + return "bg" + _cap(BASIC[code - 40]); + } + + if (code >= 90 && code <= 97) { + return "fgBright" + _cap(BASIC[code - 90]); + } + + if (code >= 100 && code <= 107) { + return "bgBright" + _cap(BASIC[code - 100]); + } + + return undefined; +} + +function _cap(value: string) { + return value.charAt(0).toUpperCase() + value.substring(1); +} diff --git a/core/src/index.ts b/core/src/index.ts index 7f80710..a9f91d4 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -17,6 +17,10 @@ export { stripAnsi, replaceAnsi, ReplacerFn } from "./ansi/stripAnsi"; export { matchAnsi } from "./ansi/matchAnsi"; export { parseAnsi } from "./ansi/parseAnsi"; export { escapeAnsi } from "./ansi/escapeAnsi"; +export { parseAnsiStyles, createAnsiStyleParser, AnsiColor, IAnsiStyle, IAnsiStyledRun } from "./ansi/parseAnsiStyles"; +export { ansi256ToRgb, ansiColorToCss, DEFAULT_ANSI_PALETTE } from "./ansi/ansiColors"; +export { getSgrName } from "./ansi/sgrCodes"; +export { ansiToHtml, IAnsiToHtmlOptions } from "./ansi/ansiToHtml"; export { setColorDetector, setColorLevel, getColorLevel, diff --git a/core/test/src/ansi/parseAnsiStyles.test.ts b/core/test/src/ansi/parseAnsiStyles.test.ts new file mode 100644 index 0000000..15828b4 --- /dev/null +++ b/core/test/src/ansi/parseAnsiStyles.test.ts @@ -0,0 +1,70 @@ +/* + * @nevware21/chromacon + * https://github.com/nevware21/chromacon + * + * Copyright (c) 2025 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire"; +import { parseAnsiStyles, createAnsiStyleParser } from "../../../src/ansi/parseAnsiStyles"; +import { ansi256ToRgb, ansiColorToCss } from "../../../src/ansi/ansiColors"; +import { getSgrName } from "../../../src/ansi/sgrCodes"; +import { ansiToHtml } from "../../../src/ansi/ansiToHtml"; + +describe("parseAnsiStyles", () => { + const b = (i: number) => ({ type: "basic", index: i }); + + it("plain and resets", () => { + assert.deepEqual(parseAnsiStyles("plain"), [{ text: "plain", style: {} }]); + assert.deepEqual(parseAnsiStyles("\x1b[1;31mFAIL\x1b[22m still red\x1b[0m plain"), [ + { text: "FAIL", style: { bold: true, fg: b(1) } }, + { text: " still red", style: { fg: b(1) } }, + { text: " plain", style: {} } + ]); + }); + + it("extended colors", () => { + assert.deepEqual(parseAnsiStyles("\x1b[38;5;9mx"), [{ text: "x", style: { fg: b(9) } }]); + assert.deepEqual(parseAnsiStyles("\x1b[38;5;208mx\x1b[48;5;244my"), [ + { text: "x", style: { fg: { type: "ansi256", index: 208 } } }, + { text: "y", style: { fg: { type: "ansi256", index: 208 }, bg: { type: "ansi256", index: 244 } } } + ]); + assert.deepEqual(parseAnsiStyles("\x1b[38:2::10:20:30mx"), [{ text: "x", style: { fg: { type: "rgb", r: 10, g: 20, b: 30 } } }]); + assert.deepEqual(parseAnsiStyles("\x1b[38;2;10;20;30mx")[0].style, { fg: { type: "rgb", r: 10, g: 20, b: 30 } }); + assert.deepEqual(parseAnsiStyles("\x1b[32m\x1b[38;5mx"), [{ text: "x", style: { fg: b(2) } }]); + assert.deepEqual(parseAnsiStyles("\x1b[38;2;300;0;0mx"), [{ text: "x", style: {} }]); + }); + + it("other sequences", () => { + assert.deepEqual(parseAnsiStyles("\x1b[2K\x1b[1G\x1b[?25lline"), [{ text: "line", style: {} }]); + assert.deepEqual(parseAnsiStyles("\x9b31mx"), [{ text: "x", style: { fg: b(1) } }]); + assert.deepEqual(parseAnsiStyles("see \x1b]8;;https://x.test\x07docs\x1b]8;;\x07 now"), [ + { text: "see ", style: {} }, + { text: "docs", style: { link: "https://x.test" } }, + { text: " now", style: {} } + ]); + }); + + it("merging and state", () => { + assert.deepEqual(parseAnsiStyles("\x1b[31mone \x1b[31mtwo"), [{ text: "one two", style: { fg: b(1) } }]); + assert.deepEqual(parseAnsiStyles("b", { fg: b(1) } as any), [{ text: "b", style: { fg: b(1) } }]); + const p = createAnsiStyleParser(); + p.push("\x1b[31ma"); + assert.deepEqual(p.push("b"), [{ text: "b", style: { fg: b(1) } }]); + }); + + it("helpers", () => { + assert.deepEqual(ansi256ToRgb(208), { r: 255, g: 135, b: 0 }); + assert.deepEqual(ansi256ToRgb(244), { r: 128, g: 128, b: 128 }); + assert.equal(ansiColorToCss({ type: "rgb", r: 1, g: 2, b: 3 }), "rgb(1, 2, 3)"); + assert.equal(getSgrName(1), "bold"); + assert.equal(getSgrName(31), "fgRed"); + assert.equal(getSgrName(999), undefined); + }); + + it("ansiToHtml", () => { + assert.equal(ansiToHtml("\x1b[1;31m\x1b[0m"), "<a>"); + assert.equal(ansiToHtml("\x1b]8;;https://x.test\x07d\x1b]8;;\x07"), "d"); + }); +});