Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions doc/api/util.md
Original file line number Diff line number Diff line change
Expand Up @@ -2638,26 +2638,33 @@ The special format value `none` applies no additional styling to the text.

In addition to predefined color names, `util.styleText()` supports hex color
strings using ANSI TrueColor (24-bit) escape sequences. Hex colors can be
specified in either 3-digit (`#RGB`) or 6-digit (`#RRGGBB`) format:
specified in either 3-digit (`#RGB`) or 6-digit (`#RRGGBB`) format for foreground,
or prefixed with `bg` (e.g., `bg#RGB`, `bg#RRGGBB`) for background:

```mjs
import { styleText } from 'node:util';

// 6-digit hex color
// 6-digit hex color (foreground)
console.log(styleText('#ff5733', 'Orange text'));

// 3-digit hex color (shorthand)
// 3-digit hex color (shorthand) (foreground)
console.log(styleText('#f00', 'Red text'));

// Hex color for background
console.log(styleText('bg#ff5733', 'Text with orange background'));
```

```cjs
const { styleText } = require('node:util');

// 6-digit hex color
// 6-digit hex color (foreground)
console.log(styleText('#ff5733', 'Orange text'));

// 3-digit hex color (shorthand)
// 3-digit hex color (shorthand) (foreground)
console.log(styleText('#f00', 'Red text'));

// Hex color for background
console.log(styleText('bg#ff5733', 'Text with orange background'));
```

The full list of formats can be found in [modifiers][].
Expand Down
68 changes: 52 additions & 16 deletions lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const {
RegExpPrototypeExec,
SafeMap,
StringPrototypeSlice,
StringPrototypeStartsWith,
StringPrototypeToWellFormed,
} = primordials;

Expand Down Expand Up @@ -118,6 +119,8 @@ const kBoldCode = 1;

// Close sequence for 24-bit foreground colors (reset to default foreground)
const kHexCloseSeq = kEscape + '39' + kEscapeEnd;
// Close sequence for 24-bit background colors (reset to default background)
const kBgHexCloseSeq = kEscape + '49' + kEscapeEnd;

let styleCache;

Expand Down Expand Up @@ -155,20 +158,27 @@ function getStyleCache() {
}

/**
* Returns the cached ANSI escape sequences for a hex color.
* Returns the cached ANSI escape sequences for a hex color (foreground or background).
* Computes and caches on first use to avoid repeated Buffer allocations.
* @param {string} hex A valid hex color string (#RGB or #RRGGBB)
* @param {string} hex A valid hex color string (#RGB or #RRGGBB) or background (bg#RGB or bg#RRGGBB)
* @returns {{openSeq: string, closeSeq: string}}
*/
function getHexStyle(hex) {
const cache = getHexStyleCache();
const cached = cache.get(hex);
if (cached !== undefined) return cached;
const { 0: r, 1: g, 2: b } = hexToRgb(hex);

// Check if this is a background hex color prefixed with 'bg#'
const isBg = StringPrototypeStartsWith(hex, 'bg#');
// Strip 'bg' prefix to extract the raw hex color string (#RGB or #RRGGBB)
const cleanHex = isBg ? StringPrototypeSlice(hex, 2) : hex;
const { 0: r, 1: g, 2: b } = hexToRgb(cleanHex);
const style = {
__proto__: null,
openSeq: kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd,
closeSeq: kHexCloseSeq,
// 38 represents foreground TrueColor SGR parameter, while 48 represents background
openSeq: kEscape + (isBg ? bgRgbToAnsi24Bit(r, g, b) : rgbToAnsi24Bit(r, g, b)) + kEscapeEnd,
// 39 resets foreground to default, while 49 resets background to default
closeSeq: isBg ? kBgHexCloseSeq : kHexCloseSeq,
};
if (cache.size >= kHexStyleCacheMax)
cache.delete(cache.keys().next().value);
Expand Down Expand Up @@ -201,6 +211,8 @@ function replaceCloseCode(str, closeSeq, openSeq, keepClose) {

// Matches #RGB or #RRGGBB
const hexColorRegExp = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
// Matches bg#RGB or bg#RRGGBB
const bgHexColorRegExp = /^bg#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;

/**
* Parses a hex color string into RGB components.
Expand Down Expand Up @@ -235,6 +247,17 @@ function rgbToAnsi24Bit(r, g, b) {
return `38;2;${r};${g};${b}`;
}

/**
* Generates the ANSI TrueColor (24-bit) escape sequence for a background color.
* @param {number} r Red component (0-255)
* @param {number} g Green component (0-255)
* @param {number} b Blue component (0-255)
* @returns {string} The ANSI escape sequence
*/
function bgRgbToAnsi24Bit(r, g, b) {
return `48;2;${r};${g};${b}`;
}

/**
* @param {string | string[]} format
* @param {string} text
Expand All @@ -256,10 +279,15 @@ function styleText(format, text, options) {
return style.openSeq + processed + style.closeSeq;
}

if (format[0] === '#') {
const isHex = format[0] === '#';
const isBgHex = !isHex && StringPrototypeStartsWith(format, 'bg#');
if (isHex || isBgHex) {
let hexStyle = getHexStyleCache().get(format);
if (hexStyle === undefined && RegExpPrototypeExec(hexColorRegExp, format) !== null) {
hexStyle = getHexStyle(format);
if (hexStyle === undefined) {
const regExp = isHex ? hexColorRegExp : bgHexColorRegExp;
if (RegExpPrototypeExec(regExp, format) !== null) {
hexStyle = getHexStyle(format);
}
}
if (hexStyle !== undefined) {
const processed = replaceCloseCode(text, hexStyle.closeSeq, hexStyle.openSeq, false);
Expand Down Expand Up @@ -297,17 +325,25 @@ function styleText(format, text, options) {
for (const key of formatArray) {
if (key === 'none') continue;

if (typeof key === 'string' && key[0] === '#') {
if (RegExpPrototypeExec(hexColorRegExp, key) === null) {
throw new ERR_INVALID_ARG_VALUE('format', key,
'must be a valid hex color (#RGB or #RRGGBB)');
if (typeof key === 'string' && (key[0] === '#' || StringPrototypeStartsWith(key, 'bg#'))) {
const isBg = key[0] === 'b';
const regExp = isBg ? bgHexColorRegExp : hexColorRegExp;
if (RegExpPrototypeExec(regExp, key) === null) {
throw new ERR_INVALID_ARG_VALUE(
'format',
key,
'must be a valid hex color (#RGB or #RRGGBB) or ' +
'background hex color (bg#RGB or bg#RRGGBB)',
);
}
if (skipColorize) continue;
const { 0: r, 1: g, 2: b } = hexToRgb(key);
const hexOpenSeq = kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd;
const cleanHex = isBg ? StringPrototypeSlice(key, 2) : key;
const { 0: r, 1: g, 2: b } = hexToRgb(cleanHex);
const hexOpenSeq = kEscape + (isBg ? bgRgbToAnsi24Bit(r, g, b) : rgbToAnsi24Bit(r, g, b)) + kEscapeEnd;
const closeSeq = isBg ? kBgHexCloseSeq : kHexCloseSeq;
openCodes += hexOpenSeq;
closeCodes = kHexCloseSeq + closeCodes;
processedText = replaceCloseCode(processedText, kHexCloseSeq, hexOpenSeq, false);
closeCodes = closeSeq + closeCodes;
processedText = replaceCloseCode(processedText, closeSeq, hexOpenSeq, false);
continue;
}

Expand Down
17 changes: 17 additions & 0 deletions test/parallel/test-util-styletext-hex.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,4 +245,21 @@ describe('util.styleText hex color support', () => {
);
});
});

describe('valid background hex colors', () => {
it('should parse bg#ffcc00 as RGB(255, 204, 0) background', () => {
const styled = util.styleText('bg#ffcc00', 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[48;2;255;204;0mtest\u001b[49m');
});

it('should expand bg#fc0 to bg#ffcc00 -> RGB(255, 204, 0) background', () => {
const styled = util.styleText('bg#fc0', 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[48;2;255;204;0mtest\u001b[49m');
});

it('should combine foreground and background hex colors', () => {
const styled = util.styleText(['#ffffff', 'bg#ff5733'], 'test', { validateStream: false });
assert.strictEqual(styled, '\u001b[38;2;255;255;255m\u001b[48;2;255;87;51mtest\u001b[49m\u001b[39m');
});
});
});
Loading