UNPKG

@steve02081504/virtual-console

Version:

A virtual console for capturing and manipulating terminal output.

432 lines (397 loc) 13.7 kB
/** * 将 `console` 风格 `%c` 的 CSS 子集映射为 ANSI SGR + 24 位真彩色。 * - 颜色:`color` / `background-color` / `background`(纯色)、`#rgb`、`rgb()`/`hsl()`、命名色。 * - 字形:`font-weight`→粗体(1)、`lighter`→半亮(2);`font-style: italic|oblique`→斜体(3)。 * - 装饰:`text-decoration` / `text-decoration-line`→下划线(4)、删除线(9)。 * - 半亮:`opacity`∈(0,1)、带半透明通道的颜色、`font-weight:lighter`→SGR 2(dim)。 */ import colorName from 'color-name' /** * 表示 RGB 颜色通道。 * @typedef {{ r: number; g: number; b: number }} Rgb * @property {number} r - 红色通道 * @property {number} g - 绿色通道 * @property {number} b - 蓝色通道 */ /** * 表示 CSS 声明的 ANSI SGR 与颜色标志。 * @typedef {object} CssAnsiDeclFlags * @property {Rgb | undefined} [fg] * @property {Rgb | undefined} [bg] * @property {boolean} [dim] * @property {boolean} [bold] * @property {boolean} [italic] * @property {boolean} [underline] * @property {boolean} [lineThrough] */ /** * @param {[number, number, number]} triple - `color-name` 的 RGB 三元组。 * @returns {Rgb} 与三元组通道对应的 `{ r, g, b }` 对象。 */ function namedTripleToRgb(triple) { return { r: triple[0], g: triple[1], b: triple[2] } } /** * @param {number} n - 0xRRGGBB * @returns {Rgb} 拆分的 R/G/B 通道。 */ function hexIntToRgb(n) { return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 } } /** * @param {number} x - 通道值 * @returns {number} 钳制到 0–255 的整数。 */ function clampByte(x) { return Math.max(0, Math.min(255, Math.round(x))) } /** * @param {string} raw - 单段数值或百分比 * @param {number} i - 通道索引 0–2 * @returns {number} 单通道字节或 NaN。 */ function parseRgbComponent(raw, i) { const s = raw.trim() if (s.endsWith('%')) { const p = parseFloat(s.slice(0, -1)) if (Number.isNaN(p)) return NaN return clampByte((p / 100) * 255) } const n = parseFloat(s) if (Number.isNaN(n)) return NaN // 规范:0–255 或 0–1 浮点 if (n <= 1 && s.includes('.') && !s.includes('e') && !s.includes('E')) return clampByte(n * 255) return clampByte(n) } /** * @param {string} inner - 括号内串(逗号或空白分隔) * @returns {Rgb | null} 解析成功返回 RGB,否则 null。 */ function parseRgbFunctionInner(inner) { const parts = inner.includes(',') ? inner.split(',').map(p => p.trim()).filter(Boolean) : inner.trim().split(/\s+/).filter(Boolean) if (parts.length < 3) return null const r = parseRgbComponent(parts[0], 0) const g = parseRgbComponent(parts[1], 1) const b = parseRgbComponent(parts[2], 2) if ([r, g, b].some(Number.isNaN)) return null return { r, g, b } } /** * @param {string} value - 颜色串 * @returns {Rgb | null} 可映射的前景色 RGB;无法解析时为 null。 */ export function parseCssColorToRgb(value) { const v = String(value || '').trim() if (!v || v.toLowerCase() === 'transparent' || v.toLowerCase() === 'currentcolor') return null const lower = v.toLowerCase() const named = /** @type {Record<string, [number, number, number]>} */ colorName[lower] if (named) return namedTripleToRgb(named) const hexMatch = /^#([\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.exec(v) if (hexMatch) { let h = hexMatch[1] if (h.length === 3 || h.length === 4) h = h.slice(0, 3).split('').map(c => c + c).join('') else if (h.length === 8) h = h.slice(0, 6) const n = parseInt(h, 16) if (Number.isNaN(n)) return null return hexIntToRgb(n) } const rgbMatch = /^rgba?\(\s*([\S\s]+?)\s*\)$/i.exec(v) if (rgbMatch) return parseRgbFunctionInner(rgbMatch[1].replace(/\s*\/\s*[\d.]+\s*$/, '')) // hsl(h,s,l) 与 hsla(h,s,l,α):前三项为色相/饱和度/亮度(颜色);第四项 α 是透明度,不参与 RGB 换算。 // 斜杠语法里的 α 已在 inner 上去掉;逗号形式 hsla 则需丢弃第四段,避免把 α 误当作 L。 const hslMatch = /^hsla?\(\s*([\S\s]+?)\s*\)$/i.exec(v) if (hslMatch) { const inner = hslMatch[1].replace(/\s*\/\s*[\d.]+\s*$/, '') let parts = inner.includes(',') ? inner.split(',').map(p => p.trim()).filter(Boolean) : inner.trim().split(/\s+/).filter(Boolean) if (parts.length > 3) parts = parts.slice(0, 3) if (parts.length < 3) return null let h = parseFloat(parts[0]) const sStr = parts[1] const lStr = parts[2] if (Number.isNaN(h)) return null h = ((h % 360) + 360) % 360 let s = parseFloat(sStr) let l = parseFloat(lStr) if (sStr.endsWith('%')) s = parseFloat(sStr.slice(0, -1)) / 100 else if (s > 1) s /= 100 if (lStr.endsWith('%')) l = parseFloat(lStr.slice(0, -1)) / 100 else if (l > 1) l /= 100 if (Number.isNaN(s) || Number.isNaN(l)) return null s = Math.max(0, Math.min(1, s)) l = Math.max(0, Math.min(1, l)) return hslToRgb(h, s, l) } return null } /** * HSL → RGB。入参 h 为色相角度(度),s/l 已归一化到 [0,1]。 * @param {number} h - 色相 0–360(函数内会 /360) * @param {number} s - 饱和度 0–1 * @param {number} l - 亮度 0–1(注意:不是 hsla 里的 α) * @returns {Rgb} HSL 换算得到的 sRGB 通道。 */ function hslToRgb(h, s, l) { h /= 360 /** * HSL 分段线性插值辅助。 * @param {number} p - 暗端。 * @param {number} q - 亮端。 * @param {number} t - [0,1] 上的色相相位参数。 * @returns {number} 单通道 0–1 浮点。 */ const hue2rgb = (p, q, t) => { let tt = t if (tt < 0) tt += 1 if (tt > 1) tt -= 1 if (tt < 1 / 6) return p + (q - p) * 6 * tt if (tt < 1 / 2) return q if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6 return p } let r; let g; let b if (s === 0) r = g = b = l else { const q = l < 0.5 ? l * (1 + s) : l + s - l * s const p = 2 * l - q r = hue2rgb(p, q, h + 1 / 3) g = hue2rgb(p, q, h) b = hue2rgb(p, q, h - 1 / 3) } return { r: clampByte(r * 255), g: clampByte(g * 255), b: clampByte(b * 255) } } /** * @param {string} val - `font-weight` 声明值 * @returns {boolean} 视为粗体映射时为 true。 */ function parseFontWeightBold(val) { const v = String(val || '').trim().toLowerCase() if (v === 'bold' || v === 'bolder') return true if (v === 'normal' || v === 'lighter') return false const n = parseInt(v, 10) if (!Number.isNaN(n)) return n >= 600 return false } /** * @param {string} val - `font-style` 声明值 * @returns {boolean} italic/oblique 时为 true。 */ function parseFontStyleItalic(val) { const v = String(val || '').trim().toLowerCase() return v === 'italic' || v === 'oblique' } /** * @param {string} val - `text-decoration` / `text-decoration-line` * @returns {{ underline: boolean; lineThrough: boolean }} 下划线与删除线开关。 */ function parseTextDecorationKeywords(val) { const t = String(val || '').toLowerCase().trim() if (!t || t === 'none') return { underline: false, lineThrough: false } return { underline: /\bunderline\b/.test(t), lineThrough: /\bline-through\b/.test(t), } } /** * @param {string} raw - alpha 分量 * @returns {number | null} 归一化到 [0,1];不可解析时为 null。 */ function parseAlphaComponent(raw) { const t = String(raw || '').trim() if (t.endsWith('%')) { const p = parseFloat(t.slice(0, -1)) / 100 return Number.isNaN(p) ? null : Math.max(0, Math.min(1, p)) } const n = parseFloat(t) if (Number.isNaN(n)) return null return Math.max(0, Math.min(1, n)) } /** * `opacity`:严格介于 0 与 1 之间时视为半亮提示。 * @param {string} val - 声明值 * @returns {boolean} 应触发半亮(dim)提示时为 true。 */ function parseOpacityInducesDim(val) { const t = String(val || '').trim() if (t.endsWith('%')) { const p = parseFloat(t.slice(0, -1)) / 100 if (Number.isNaN(p)) return false return p > 0 && p < 1 } const n = parseFloat(t) if (Number.isNaN(n)) return false return n > 0 && n < 1 } /** * 颜色字符串 → RGB;若含半透明(alpha∈(0,1))则 `dimHint`。 * `hsla` 的第四段是 α 而非 HSL 的「第四个颜色分量」;与 `hsl(…/α)` 一样只影响是否 dim。 * @param {string} val - `color` / `background-color` 等一侧的值 * @returns {{ rgb: Rgb | null; dimHint: boolean }} RGB 与是否因半透明建议 dim。 */ function cssColorValueToRgbAndDimHint(val) { const v = String(val || '').trim() let dimHint = false const hex8Match = /^#([\da-f]{8})$/i.exec(v) if (hex8Match) { const full = parseInt(hex8Match[1], 16) const a = (full & 0xff) / 255 if (a > 0 && a < 1) dimHint = true const rgbInt = (full >>> 8) & 0xffffff return { rgb: hexIntToRgb(rgbInt), dimHint } } const hex4Match = /^#([\da-f]{4})$/i.exec(v) if (hex4Match) { const expanded = hex4Match[1].split('').map(c => c + c).join('') const rgbInt = parseInt(expanded.slice(0, 6), 16) const aByte = parseInt(expanded.slice(6, 8), 16) const a = aByte / 255 if (a > 0 && a < 1) dimHint = true return { rgb: hexIntToRgb(rgbInt), dimHint } } if (/^rgba\(/i.test(v)) { const body = /^rgba\(\s*([\S\s]+?)\s*\)$/i.exec(v) if (body) { const inner = body[1] const preSlash = inner.replace(/\s*\/\s*[\d\s%.]+$/, '') const parts = preSlash.includes(',') ? preSlash.split(',').map(p => p.trim()).filter(Boolean) : preSlash.split(/\s+/).filter(Boolean) if (parts.length >= 4) { const a = parseAlphaComponent(parts[3]) if (a !== null && a > 0 && a < 1) dimHint = true } else { const slash = inner.match(/\/\s*([\d\s%.]+)\s*$/) if (slash) { const a = parseAlphaComponent(slash[1].trim()) if (a !== null && a > 0 && a < 1) dimHint = true } } } return { rgb: parseCssColorToRgb(v), dimHint } } // hsla:第四段为 α;半透明时提示 dim(RGB 仍由 parseCssColorToRgb 仅用 H,S,L 算出)。 if (/^hsla\(/i.test(v)) { const body = /^hsla\(\s*([\S\s]+?)\s*\)$/i.exec(v) if (body) { const inner = body[1].replace(/\s*\/\s*[\d\s%.]+$/, '') const parts = inner.includes(',') ? inner.split(',').map(p => p.trim()).filter(Boolean) : inner.trim().split(/\s+/).filter(Boolean) if (parts.length >= 4) { const a = parseAlphaComponent(parts[3]) if (a !== null && a > 0 && a < 1) dimHint = true } } return { rgb: parseCssColorToRgb(v), dimHint } } if (/^rgb\(/i.test(v) && !/^rgba\(/i.test(v)) { const m = v.match(/\/\s*([\d\s%.]+)\s*\)\s*$/i) if (m) { const a = parseAlphaComponent(m[1].trim()) if (a !== null && a > 0 && a < 1) dimHint = true } return { rgb: parseCssColorToRgb(v), dimHint } } if (/^hsl\(/i.test(v) && !/^hsla\(/i.test(v)) { const m = v.match(/\/\s*([\d\s%.]+)\s*\)\s*$/i) if (m) { const a = parseAlphaComponent(m[1].trim()) if (a !== null && a > 0 && a < 1) dimHint = true } return { rgb: parseCssColorToRgb(v), dimHint } } return { rgb: parseCssColorToRgb(v), dimHint: false } } /** * @param {string} css - `font-size:…; color: red` 等 * @returns {CssAnsiDeclFlags} 解析得到的着色与字形标志。 */ export function parseCssDecls(css) { const s = String(css || '') /** @type {Rgb | undefined} */ let fg /** @type {Rgb | undefined} */ let bg let dim = false let bold = false let italic = false let underline = false let lineThrough = false for (const chunk of s.split(';')) { const colon = chunk.indexOf(':') if (colon === -1) continue const prop = chunk.slice(0, colon).trim().toLowerCase() const val = chunk.slice(colon + 1).trim() if (!val) continue if (prop === 'color') { const { rgb, dimHint } = cssColorValueToRgbAndDimHint(val) if (rgb) fg = rgb if (dimHint) dim = true } else if (prop === 'background-color') { const { rgb, dimHint } = cssColorValueToRgbAndDimHint(val) if (rgb) bg = rgb if (dimHint) dim = true } else if (prop === 'background') { if (/\b(?:url|gradient|linear-gradient|radial-gradient|repeating-linear|repeating-radial)\(/i.test(val)) continue const { rgb, dimHint } = cssColorValueToRgbAndDimHint(val) if (rgb) bg = rgb if (dimHint) dim = true } else if (prop === 'opacity') { if (parseOpacityInducesDim(val)) dim = true } else if (prop === 'font-weight') { const vv = String(val || '').trim().toLowerCase() if (vv === 'lighter') dim = true bold = parseFontWeightBold(val) } else if (prop === 'font-style') italic = parseFontStyleItalic(val) else if (prop === 'text-decoration' || prop === 'text-decoration-line') { const d = parseTextDecorationKeywords(val) underline = d.underline lineThrough = d.lineThrough } } return { fg, bg, dim, bold, italic, underline, lineThrough } } /** * @param {CssAnsiDeclFlags} flags - {@link parseCssDecls} 产物。 * @returns {string} 如 `\x1b[2;1;3;4;38;2;255;0;0m`,无可映射项时 `''`。 */ export function flagsToSgrPrefix(flags) { const codes = [] if (flags.dim) codes.push('2') if (flags.bold) codes.push('1') if (flags.italic) codes.push('3') if (flags.underline) codes.push('4') if (flags.lineThrough) codes.push('9') if (flags.fg) codes.push(`38;2;${flags.fg.r};${flags.fg.g};${flags.fg.b}`) if (flags.bg) codes.push(`48;2;${flags.bg.r};${flags.bg.g};${flags.bg.b}`) if (!codes.length) return '' return `\x1b[${codes.join(';')}m` } /** * @param {string} css - `font-size:…; color: red` 等 * @returns {string} 如 `\x1b[2;1;3;4;38;2;255;0;0m`,无可映射项时 `''`。 */ export function cssStyleStringToAnsiPrefix(css) { const flags = parseCssDecls(css) if (!String(css || '').trim()) return '' return flagsToSgrPrefix(flags) }