From 37ddc2aaf9ba9ed421ca574a65b36f1c27df953d Mon Sep 17 00:00:00 2001 From: carlosnaico77 Date: Thu, 23 Jul 2026 17:52:48 -0400 Subject: [PATCH 1/3] util: support background colors with hex codes Signed-off-by: carlosnaico77 --- doc/api/util.md | 17 ++++-- lib/util.js | 68 ++++++++++++++++++------ test/parallel/test-util-styletext-hex.js | 17 ++++++ 3 files changed, 81 insertions(+), 21 deletions(-) diff --git a/doc/api/util.md b/doc/api/util.md index fd93b52722934f..0afb36de6acff8 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -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][]. diff --git a/lib/util.js b/lib/util.js index e828229380d9e8..11ccf7e791857f 100644 --- a/lib/util.js +++ b/lib/util.js @@ -40,6 +40,7 @@ const { RegExpPrototypeExec, SafeMap, StringPrototypeSlice, + StringPrototypeStartsWith, StringPrototypeToWellFormed, } = primordials; @@ -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; @@ -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); @@ -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. @@ -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 @@ -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); @@ -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; } diff --git a/test/parallel/test-util-styletext-hex.js b/test/parallel/test-util-styletext-hex.js index f12c35a780d6df..0fe60f08acdcc8 100644 --- a/test/parallel/test-util-styletext-hex.js +++ b/test/parallel/test-util-styletext-hex.js @@ -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'); + }); + }); }); From 547d2b5fd894639f8be9fbc774eb52efbe3ac176 Mon Sep 17 00:00:00 2001 From: Carlos Lozano <159742143+carlosnaico77@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:20:13 -0400 Subject: [PATCH 2/3] Update lib/util.js Co-authored-by: Aviv Keller --- lib/util.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/util.js b/lib/util.js index 11ccf7e791857f..f2fd988dfe1138 100644 --- a/lib/util.js +++ b/lib/util.js @@ -209,10 +209,8 @@ function replaceCloseCode(str, closeSeq, openSeq, keepClose) { return result + str.slice(lastIndex); } -// 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})$/; +// Matches (fg|bg)?#RGB, (fg|bg)?#RRGGBB +const hexColorRegExp = /^(bg|fg)?#([0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?)$/; /** * Parses a hex color string into RGB components. From 7b2f20161aa150c409e01b3ef0624f3ea761ebb4 Mon Sep 17 00:00:00 2001 From: carlosnaico77 Date: Fri, 24 Jul 2026 16:46:32 -0400 Subject: [PATCH 3/3] util: add support for fg# hex color prefix and update docs/tests Signed-off-by: carlosnaico77 --- doc/api/util.md | 11 +++++++++-- lib/util.js | 25 +++++++++++++----------- test/parallel/test-util-styletext-hex.js | 17 ++++++++++++++++ 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/doc/api/util.md b/doc/api/util.md index 0afb36de6acff8..91b7f5da3158d1 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -2638,8 +2638,9 @@ 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 for foreground, -or prefixed with `bg` (e.g., `bg#RGB`, `bg#RRGGBB`) for background: +specified in either 3-digit (`#RGB`) or 6-digit (`#RRGGBB`) format for foreground +(optionally prefixed with `fg` for clarity, e.g., `fg#RGB`, `fg#RRGGBB`), or +prefixed with `bg` (e.g., `bg#RGB`, `bg#RRGGBB`) for background: ```mjs import { styleText } from 'node:util'; @@ -2647,6 +2648,9 @@ import { styleText } from 'node:util'; // 6-digit hex color (foreground) console.log(styleText('#ff5733', 'Orange text')); +// Hex color prefixed with 'fg' (foreground) +console.log(styleText('fg#ff5733', 'Orange text')); + // 3-digit hex color (shorthand) (foreground) console.log(styleText('#f00', 'Red text')); @@ -2660,6 +2664,9 @@ const { styleText } = require('node:util'); // 6-digit hex color (foreground) console.log(styleText('#ff5733', 'Orange text')); +// Hex color prefixed with 'fg' (foreground) +console.log(styleText('fg#ff5733', 'Orange text')); + // 3-digit hex color (shorthand) (foreground) console.log(styleText('#f00', 'Red text')); diff --git a/lib/util.js b/lib/util.js index f2fd988dfe1138..d3741e787e5679 100644 --- a/lib/util.js +++ b/lib/util.js @@ -170,8 +170,9 @@ function getHexStyle(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 isFg = !isBg && StringPrototypeStartsWith(hex, 'fg#'); + // Strip the 'bg' or 'fg' prefix to extract the raw hex color string (#RGB or #RRGGBB) + const cleanHex = isBg || isFg ? StringPrototypeSlice(hex, 2) : hex; const { 0: r, 1: g, 2: b } = hexToRgb(cleanHex); const style = { __proto__: null, @@ -279,11 +280,11 @@ function styleText(format, text, options) { const isHex = format[0] === '#'; const isBgHex = !isHex && StringPrototypeStartsWith(format, 'bg#'); - if (isHex || isBgHex) { + const isFgHex = !isHex && !isBgHex && StringPrototypeStartsWith(format, 'fg#'); + if (isHex || isBgHex || isFgHex) { let hexStyle = getHexStyleCache().get(format); if (hexStyle === undefined) { - const regExp = isHex ? hexColorRegExp : bgHexColorRegExp; - if (RegExpPrototypeExec(regExp, format) !== null) { + if (RegExpPrototypeExec(hexColorRegExp, format) !== null) { hexStyle = getHexStyle(format); } } @@ -323,19 +324,21 @@ function styleText(format, text, options) { for (const key of formatArray) { if (key === 'none') continue; - if (typeof key === 'string' && (key[0] === '#' || StringPrototypeStartsWith(key, 'bg#'))) { - const isBg = key[0] === 'b'; - const regExp = isBg ? bgHexColorRegExp : hexColorRegExp; - if (RegExpPrototypeExec(regExp, key) === null) { + if (typeof key === 'string' && + (key[0] === '#' || StringPrototypeStartsWith(key, 'bg#') || StringPrototypeStartsWith(key, 'fg#'))) { + if (RegExpPrototypeExec(hexColorRegExp, key) === null) { throw new ERR_INVALID_ARG_VALUE( 'format', key, - 'must be a valid hex color (#RGB or #RRGGBB) or ' + + 'must be a valid hex color (#RGB or #RRGGBB), ' + + 'foreground hex color (fg#RGB or fg#RRGGBB), or ' + 'background hex color (bg#RGB or bg#RRGGBB)', ); } if (skipColorize) continue; - const cleanHex = isBg ? StringPrototypeSlice(key, 2) : key; + const isBg = StringPrototypeStartsWith(key, 'bg#'); + const isFg = !isBg && StringPrototypeStartsWith(key, 'fg#'); + const cleanHex = isBg || isFg ? 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; diff --git a/test/parallel/test-util-styletext-hex.js b/test/parallel/test-util-styletext-hex.js index 0fe60f08acdcc8..087af9c51d24c6 100644 --- a/test/parallel/test-util-styletext-hex.js +++ b/test/parallel/test-util-styletext-hex.js @@ -262,4 +262,21 @@ describe('util.styleText hex color support', () => { assert.strictEqual(styled, '\u001b[38;2;255;255;255m\u001b[48;2;255;87;51mtest\u001b[49m\u001b[39m'); }); }); + + describe('valid foreground hex colors with fg# prefix', () => { + it('should parse fg#ffcc00 as RGB(255, 204, 0) foreground', () => { + const styled = util.styleText('fg#ffcc00', 'test', { validateStream: false }); + assert.strictEqual(styled, '\u001b[38;2;255;204;0mtest\u001b[39m'); + }); + + it('should expand fg#fc0 to fg#ffcc00 -> RGB(255, 204, 0) foreground', () => { + const styled = util.styleText('fg#fc0', 'test', { validateStream: false }); + assert.strictEqual(styled, '\u001b[38;2;255;204;0mtest\u001b[39m'); + }); + + it('should combine foreground (fg#) and background (bg#) hex colors', () => { + const styled = util.styleText(['fg#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'); + }); + }); });