diff --git a/lib/util.js b/lib/util.js index 9601593eaf404a..34751d25aa5aa6 100644 --- a/lib/util.js +++ b/lib/util.js @@ -114,7 +114,11 @@ const kEscapeEnd = 'm'; const kDimCode = 2; const kBoldCode = 1; +// Close sequence for 24-bit foreground colors (reset to default foreground) +const kHexCloseSeq = kEscape + '39' + kEscapeEnd; + let styleCache; +const hexStyleCache = { __proto__: null }; function getStyleCache() { if (styleCache === undefined) { @@ -137,6 +141,25 @@ function getStyleCache() { return styleCache; } +/** + * Returns the cached ANSI escape sequences for a hex color. + * Computes and caches on first use to avoid repeated Buffer allocations. + * @param {string} hex A valid hex color string (#RGB or #RRGGBB) + * @returns {{openSeq: string, closeSeq: string}} + */ +function getHexStyle(hex) { + const cached = hexStyleCache[hex]; + if (cached !== undefined) return cached; + const { 0: r, 1: g, 2: b } = hexToRgb(hex); + const style = { + __proto__: null, + openSeq: kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd, + closeSeq: kHexCloseSeq, + }; + hexStyleCache[hex] = style; + return style; +} + function replaceCloseCode(str, closeSeq, openSeq, keepClose) { const closeLen = closeSeq.length; let index = str.indexOf(closeSeq); @@ -163,15 +186,6 @@ function replaceCloseCode(str, closeSeq, openSeq, keepClose) { // Matches #RGB or #RRGGBB const hexColorRegExp = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; -/** - * Validates whether a string is a valid hex color code. - * @param {string} hex The hex string to validate (e.g., '#fff' or '#ffffff') - * @returns {boolean} True if valid hex color, false otherwise - */ -function isValidHexColor(hex) { - return typeof hex === 'string' && RegExpPrototypeExec(hexColorRegExp, hex) !== null; -} - /** * Parses a hex color string into RGB components. * Supports both 3-digit (#RGB) and 6-digit (#RRGGBB) formats. @@ -225,6 +239,17 @@ function styleText(format, text, options) { const processed = replaceCloseCode(text, style.closeSeq, style.openSeq, style.keepClose); return style.openSeq + processed + style.closeSeq; } + + if (format[0] === '#') { + let hexStyle = hexStyleCache[format]; + if (hexStyle === undefined && RegExpPrototypeExec(hexColorRegExp, format) !== null) { + hexStyle = getHexStyle(format); + } + if (hexStyle !== undefined) { + const processed = replaceCloseCode(text, hexStyle.closeSeq, hexStyle.openSeq, false); + return hexStyle.openSeq + processed + hexStyle.closeSeq; + } + } } validateString(text, 'text'); @@ -255,24 +280,26 @@ function styleText(format, text, options) { for (const key of formatArray) { if (key === 'none') continue; - if (isValidHexColor(key)) { - if (skipColorize) continue; - const { 0: r, 1: g, 2: b } = hexToRgb(key); - const openSeq = kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd; - const closeSeq = kEscape + '39' + kEscapeEnd; - openCodes += openSeq; - closeCodes = closeSeq + closeCodes; - processedText = replaceCloseCode(processedText, closeSeq, openSeq, false); + if (typeof key === 'string' && key[0] === '#') { + let hexStyle = hexStyleCache[key]; + if (hexStyle === undefined) { + if (RegExpPrototypeExec(hexColorRegExp, key) === null) { + throw new ERR_INVALID_ARG_VALUE('format', key, + 'must be a valid hex color (#RGB or #RRGGBB)'); + } + if (skipColorize) continue; + hexStyle = getHexStyle(key); + } else if (skipColorize) { + continue; + } + openCodes += hexStyle.openSeq; + closeCodes = hexStyle.closeSeq + closeCodes; + processedText = replaceCloseCode(processedText, hexStyle.closeSeq, hexStyle.openSeq, false); continue; } const style = cache[key]; if (style === undefined) { - // Check if it looks like an invalid hex color (starts with #) - if (typeof key === 'string' && key[0] === '#') { - throw new ERR_INVALID_ARG_VALUE('format', key, - 'must be a valid hex color (#RGB or #RRGGBB)'); - } validateOneOf(key, 'format', ObjectGetOwnPropertyNames(inspect.colors)); } openCodes += style.openSeq;