Unify ANSI rendering with sequence parser

This commit is contained in:
Shantur Rathore
2026-01-02 19:42:26 +00:00
parent 4571a1dcf9
commit eebfcb5628
3 changed files with 112 additions and 258 deletions

View File

@@ -1,89 +1,41 @@
import AnsiToHtml from "ansi-to-html"
import { createAnsiSequenceParser, createColorPalette } from "ansi-sequence-parser"
const ESC_CHAR = "\u001b"
const ANSI_LITERAL_PATTERN = /\\u001b|\\x1b|\\033/
const ANSI_SGR_PATTERN = /\u001b\[[0-9;]*m/
const ANSI_NON_SGR_PATTERN = /\u001b\[[0-9;?]*[A-Za-ln-zA-LN-Z]/g
const ANSI_SGR_CAPTURE_PATTERN = /\u001b\[([0-9;]*)m/g
const ANSI_ESCAPE_PATTERN = /\u001b/
const ansiConverter = new AnsiToHtml({
escapeXML: true,
})
export interface AnsiSgrState {
bold: boolean
dim: boolean
italic: boolean
underline: boolean
inverse: boolean
hidden: boolean
strike: boolean
fg: string | null
bg: string | null
}
export function createAnsiSgrState(): AnsiSgrState {
return {
bold: false,
dim: false,
italic: false,
underline: false,
inverse: false,
hidden: false,
strike: false,
fg: null,
bg: null,
}
}
const colorPalette = createColorPalette()
export function hasAnsi(text: string): boolean {
const normalized = normalizeAnsiText(text)
return ANSI_SGR_PATTERN.test(normalized)
return ANSI_ESCAPE_PATTERN.test(normalized)
}
export function ansiToHtml(text: string): string {
const normalized = normalizeAnsiText(text)
const sanitized = stripNonSgrAnsi(normalized)
return ansiConverter.toHtml(sanitized)
const parser = createAnsiSequenceParser()
const tokens = parser.parse(normalized)
return tokensToHtml(tokens)
}
export function ansiChunkToHtml(chunk: string, state: AnsiSgrState) {
const normalized = normalizeAnsiText(chunk)
const sanitized = stripNonSgrAnsi(normalized)
const prefix = sgrStateToEscapeSequence(state)
const html = ansiConverter.toHtml(prefix + sanitized)
const nextState = computeAnsiSgrState(sanitized, state)
return { html, nextState }
export interface AnsiStreamRenderer {
reset: () => void
render: (chunk: string) => string
}
export function computeAnsiSgrState(text: string, initialState?: AnsiSgrState): AnsiSgrState {
const normalized = normalizeAnsiText(text)
const sanitized = stripNonSgrAnsi(normalized)
const nextState = cloneSgrState(initialState ?? createAnsiSgrState())
export function createAnsiStreamRenderer(): AnsiStreamRenderer {
let parser = createAnsiSequenceParser()
for (const match of sanitized.matchAll(ANSI_SGR_CAPTURE_PATTERN)) {
const params = match[1] ?? ""
const codes = params.length === 0 ? [0] : params.split(";").map((part) => Number(part))
applySgrCodes(nextState, codes)
return {
reset() {
parser = createAnsiSequenceParser()
},
render(chunk: string) {
const normalized = normalizeAnsiText(chunk)
const tokens = parser.parse(normalized)
return tokensToHtml(tokens)
},
}
return nextState
}
export function isAnsiSgrStateEmpty(state: AnsiSgrState): boolean {
return (
!state.bold &&
!state.dim &&
!state.italic &&
!state.underline &&
!state.inverse &&
!state.hidden &&
!state.strike &&
state.fg === null &&
state.bg === null
)
}
function normalizeAnsiText(text: string): string {
@@ -97,177 +49,88 @@ function normalizeAnsiText(text: string): string {
.replace(/\\033/g, ESC_CHAR)
}
function stripNonSgrAnsi(text: string): string {
return text.replace(ANSI_NON_SGR_PATTERN, "")
function tokensToHtml(tokens: { value: string; foreground: unknown; background: unknown; decorations: Set<string> }[]): string {
let html = ""
for (const token of tokens) {
if (!token.value) {
continue
}
const styles = buildTokenStyles(token)
const escaped = escapeHtml(token.value)
if (!styles) {
html += escaped
continue
}
html += `<span style="${styles}">${escaped}</span>`
}
return html
}
function cloneSgrState(state: AnsiSgrState): AnsiSgrState {
return { ...state }
function buildTokenStyles(token: { foreground: any; background: any; decorations: Set<string> }): string | null {
const decorations = token.decorations
let foreground = token.foreground ? colorPalette.value(token.foreground) : null
let background = token.background ? colorPalette.value(token.background) : null
if (decorations.has("reverse")) {
const swapped = foreground
foreground = background
background = swapped
}
const styles: string[] = []
if (foreground) {
styles.push(`color: ${foreground}`)
}
if (background) {
styles.push(`background-color: ${background}`)
}
if (decorations.has("bold")) {
styles.push("font-weight: 600")
}
if (decorations.has("dim")) {
styles.push("opacity: 0.7")
}
if (decorations.has("italic")) {
styles.push("font-style: italic")
}
const lines: string[] = []
if (decorations.has("underline")) {
lines.push("underline")
}
if (decorations.has("strikethrough")) {
lines.push("line-through")
}
if (decorations.has("overline")) {
lines.push("overline")
}
if (lines.length > 0) {
styles.push(`text-decoration-line: ${lines.join(" ")}`)
}
if (decorations.has("hidden")) {
styles.push("color: transparent")
styles.push("background-color: transparent")
}
return styles.length > 0 ? styles.join("; ") : null
}
function sgrStateToEscapeSequence(state: AnsiSgrState): string {
if (isAnsiSgrStateEmpty(state)) {
return ""
}
const codes: number[] = []
if (state.bold) codes.push(1)
if (state.dim) codes.push(2)
if (state.italic) codes.push(3)
if (state.underline) codes.push(4)
if (state.inverse) codes.push(7)
if (state.hidden) codes.push(8)
if (state.strike) codes.push(9)
if (state.fg) {
codes.push(...state.fg.split(";").map((part) => Number(part)))
}
if (state.bg) {
codes.push(...state.bg.split(";").map((part) => Number(part)))
}
if (codes.length === 0) {
return ""
}
return `${ESC_CHAR}[${codes.join(";")}m`
}
function applySgrCodes(state: AnsiSgrState, codes: number[]) {
for (let index = 0; index < codes.length; index++) {
const code = codes[index]
if (!Number.isFinite(code)) continue
if (code === 0) {
state.bold = false
state.dim = false
state.italic = false
state.underline = false
state.inverse = false
state.hidden = false
state.strike = false
state.fg = null
state.bg = null
continue
}
if (code === 1) {
state.bold = true
continue
}
if (code === 2) {
state.dim = true
continue
}
if (code === 22) {
state.bold = false
state.dim = false
continue
}
if (code === 3) {
state.italic = true
continue
}
if (code === 23) {
state.italic = false
continue
}
if (code === 4) {
state.underline = true
continue
}
if (code === 24) {
state.underline = false
continue
}
if (code === 7) {
state.inverse = true
continue
}
if (code === 27) {
state.inverse = false
continue
}
if (code === 8) {
state.hidden = true
continue
}
if (code === 28) {
state.hidden = false
continue
}
if (code === 9) {
state.strike = true
continue
}
if (code === 29) {
state.strike = false
continue
}
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
state.fg = String(code)
continue
}
if (code === 39) {
state.fg = null
continue
}
if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
state.bg = String(code)
continue
}
if (code === 49) {
state.bg = null
continue
}
if (code === 38 || code === 48) {
const isForeground = code === 38
const mode = codes[index + 1]
if (!Number.isFinite(mode)) {
continue
}
if (mode === 5) {
const color = codes[index + 2]
if (Number.isFinite(color)) {
const value = `${code};5;${color}`
if (isForeground) state.fg = value
else state.bg = value
}
index += 2
continue
}
if (mode === 2) {
const r = codes[index + 2]
const g = codes[index + 3]
const b = codes[index + 4]
if ([r, g, b].every((v) => Number.isFinite(v))) {
const value = `${code};2;${r};${g};${b}`
if (isForeground) state.fg = value
else state.bg = value
}
index += 4
continue
}
}
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;")
}