Skip to content

Commit f01f79e

Browse files
authored
util: colorize text with hex colors
PR-URL: #61556 Reviewed-By: Michaël Zasso <targos@protonmail.com> Reviewed-By: Jordan Harband <ljharb@gmail.com> Reviewed-By: René <contact.9a5d6388@renegade334.me.uk> Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day> Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com> Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com> Reviewed-By: Claudio Wunder <cwunder@gnome.org> Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
1 parent 41afe9b commit f01f79e

File tree

4 files changed

+344
-2
lines changed

4 files changed

+344
-2
lines changed

benchmark/util/style-text.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const assert = require('node:assert');
77

88
const bench = common.createBenchmark(main, {
99
messageType: ['string', 'number', 'boolean', 'invalid'],
10-
format: ['red', 'italic', 'invalid'],
10+
format: ['red', 'italic', 'invalid', '#ff0000'],
1111
validateStream: [1, 0],
1212
n: [1e3],
1313
});

doc/api/util.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2531,6 +2531,9 @@ added:
25312531
- v21.7.0
25322532
- v20.12.0
25332533
changes:
2534+
- version: REPLACEME
2535+
pr-url: https://github.com/nodejs/node/pull/61556
2536+
description: Add support for hexadecimal colors.
25342537
- version:
25352538
- v24.2.0
25362539
- v22.17.0
@@ -2550,7 +2553,8 @@ changes:
25502553
-->
25512554
25522555
* `format` {string | Array} A text format or an Array
2553-
of text formats defined in `util.inspect.colors`.
2556+
of text formats defined in `util.inspect.colors`, or a hex color in `#RGB`
2557+
or `#RRGGBB` form.
25542558
* `text` {string} The text to to be formatted.
25552559
* `options` {Object}
25562560
* `validateStream` {boolean} When true, `stream` is checked to see if it can handle colors. **Default:** `true`.
@@ -2613,6 +2617,30 @@ console.log(
26132617
26142618
The special format value `none` applies no additional styling to the text.
26152619
2620+
In addition to predefined color names, `util.styleText()` supports hex color
2621+
strings using ANSI TrueColor (24-bit) escape sequences. Hex colors can be
2622+
specified in either 3-digit (`#RGB`) or 6-digit (`#RRGGBB`) format:
2623+
2624+
```mjs
2625+
import { styleText } from 'node:util';
2626+
2627+
// 6-digit hex color
2628+
console.log(styleText('#ff5733', 'Orange text'));
2629+
2630+
// 3-digit hex color (shorthand)
2631+
console.log(styleText('#f00', 'Red text'));
2632+
```
2633+
2634+
```cjs
2635+
const { styleText } = require('node:util');
2636+
2637+
// 6-digit hex color
2638+
console.log(styleText('#ff5733', 'Orange text'));
2639+
2640+
// 3-digit hex color (shorthand)
2641+
console.log(styleText('#f00', 'Red text'));
2642+
```
2643+
26162644
The full list of formats can be found in [modifiers][].
26172645
26182646
## Class: `util.TextDecoder`

lib/util.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ const {
3737
ObjectSetPrototypeOf,
3838
ObjectValues,
3939
ReflectApply,
40+
RegExpPrototypeExec,
41+
StringPrototypeSlice,
4042
StringPrototypeToWellFormed,
4143
} = primordials;
4244

@@ -46,10 +48,12 @@ const {
4648
codes: {
4749
ERR_FALSY_VALUE_REJECTION,
4850
ERR_INVALID_ARG_TYPE,
51+
ERR_INVALID_ARG_VALUE,
4952
ERR_OUT_OF_RANGE,
5053
},
5154
isErrorStackTraceLimitWritable,
5255
} = require('internal/errors');
56+
const { Buffer } = require('buffer');
5357
const {
5458
format,
5559
formatWithOptions,
@@ -156,6 +160,51 @@ function replaceCloseCode(str, closeSeq, openSeq, keepClose) {
156160
return result + str.slice(lastIndex);
157161
}
158162

163+
// Matches #RGB or #RRGGBB
164+
const hexColorRegExp = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
165+
166+
/**
167+
* Validates whether a string is a valid hex color code.
168+
* @param {string} hex The hex string to validate (e.g., '#fff' or '#ffffff')
169+
* @returns {boolean} True if valid hex color, false otherwise
170+
*/
171+
function isValidHexColor(hex) {
172+
return typeof hex === 'string' && RegExpPrototypeExec(hexColorRegExp, hex) !== null;
173+
}
174+
175+
/**
176+
* Parses a hex color string into RGB components.
177+
* Supports both 3-digit (#RGB) and 6-digit (#RRGGBB) formats.
178+
* @param {string} hex A valid hex color string
179+
* @returns {Buffer} The RGB components
180+
*/
181+
function hexToRgb(hex) {
182+
// Normalize to 6 digits
183+
let hexStr;
184+
if (hex.length === 4) {
185+
// Expand #RGB to #RRGGBB
186+
hexStr = hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
187+
} else if (hex.length === 7) {
188+
hexStr = StringPrototypeSlice(hex, 1);
189+
} else {
190+
throw new ERR_OUT_OF_RANGE('hex', '#RGB or #RRGGBB', hex);
191+
}
192+
193+
// TODO(araujogui): use Uint8Array.fromHex
194+
return Buffer.from(hexStr, 'hex');
195+
}
196+
197+
/**
198+
* Generates the ANSI TrueColor (24-bit) escape sequence for a foreground color.
199+
* @param {number} r Red component (0-255)
200+
* @param {number} g Green component (0-255)
201+
* @param {number} b Blue component (0-255)
202+
* @returns {string} The ANSI escape sequence
203+
*/
204+
function rgbToAnsi24Bit(r, g, b) {
205+
return `38;2;${r};${g};${b}`;
206+
}
207+
159208
/**
160209
* @param {string | string[]} format
161210
* @param {string} text
@@ -205,8 +254,25 @@ function styleText(format, text, options) {
205254

206255
for (const key of formatArray) {
207256
if (key === 'none') continue;
257+
258+
if (isValidHexColor(key)) {
259+
if (skipColorize) continue;
260+
const { 0: r, 1: g, 2: b } = hexToRgb(key);
261+
const openSeq = kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd;
262+
const closeSeq = kEscape + '39' + kEscapeEnd;
263+
openCodes += openSeq;
264+
closeCodes = closeSeq + closeCodes;
265+
processedText = replaceCloseCode(processedText, closeSeq, openSeq, false);
266+
continue;
267+
}
268+
208269
const style = cache[key];
209270
if (style === undefined) {
271+
// Check if it looks like an invalid hex color (starts with #)
272+
if (typeof key === 'string' && key[0] === '#') {
273+
throw new ERR_INVALID_ARG_VALUE('format', key,
274+
'must be a valid hex color (#RGB or #RRGGBB)');
275+
}
210276
validateOneOf(key, 'format', ObjectGetOwnPropertyNames(inspect.colors));
211277
}
212278
openCodes += style.openSeq;

0 commit comments

Comments
 (0)