diff --git a/doc/api/util.md b/doc/api/util.md index fd93b5272293..eca5023aaf66 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -2548,6 +2548,10 @@ added: - v21.7.0 - v20.12.0 changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/64955 + description: Hexadecimal colors are downgraded to the color depth + supported by the terminal. - version: - v26.1.0 - v24.16.0 @@ -2660,6 +2664,22 @@ console.log(styleText('#ff5733', 'Orange text')); console.log(styleText('#f00', 'Red text')); ``` +Hex colors are emitted with the highest color depth the terminal supports. When +the terminal, or the [`FORCE_COLOR`][] environment variable, reports fewer than +16 million colors, the color is downgraded to the closest color available: + +* `FORCE_COLOR=3`, or a terminal supporting 16 million colors: TrueColor + (24-bit) escape sequences, for example ``. +* `FORCE_COLOR=2`, or a terminal supporting 256 colors: the closest color of the + 256-color palette, for example ``. +* `FORCE_COLOR=1`, or a terminal supporting 16 colors: the closest of the 16 + basic colors, for example ``. + +When `validateStream` is `false`, hex colors are only downgraded if +`FORCE_COLOR` is set, since no stream is inspected to determine the color depth. +A `FORCE_COLOR` value that disables colors (such as `0`) disables colorized +output even when `validateStream` is `false`. + The full list of formats can be found in [modifiers][]. ## Class: `util.TextDecoder` @@ -3907,6 +3927,7 @@ npx codemod@latest @nodejs/util-is [`Array.isArray()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray [`ArrayBuffer.isView()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer/isView [`Error.isError`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/isError +[`FORCE_COLOR`]: cli.md#force_color1-2-3 [`JSON.stringify()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify [`MIMEparams`]: #class-utilmimeparams [`Object.assign()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign diff --git a/lib/internal/util/colors.js b/lib/internal/util/colors.js index 0b37694b513f..156ec0b8fc72 100644 --- a/lib/internal/util/colors.js +++ b/lib/internal/util/colors.js @@ -6,6 +6,12 @@ function lazyInternalTTY() { return internalTTy; } +// Color depths in bits, matching the values returned by `getColorDepth()`. +const COLORS_2 = 1; +const COLORS_16 = 4; +const COLORS_256 = 8; +const COLORS_16m = 24; + module.exports = { blue: '', green: '', @@ -15,13 +21,38 @@ module.exports = { clear: '', reset: '', hasColors: false, - shouldColorize(stream) { + COLORS_2, + COLORS_16, + COLORS_256, + COLORS_16m, + // Number of bits of color the stream supports, as reported by + // `tty.WriteStream.prototype.getColorDepth()`. `FORCE_COLOR` takes precedence + // over the stream, since it describes the terminal the output ends up in. + getColorDepth(stream) { if (process.env.FORCE_COLOR !== undefined) { - return lazyInternalTTY().getColorDepth() > 2; + return lazyInternalTTY().getColorDepth(); + } + + if (!stream?.isTTY) { + return COLORS_2; + } + + return typeof stream.getColorDepth === 'function' ? + stream.getColorDepth() : + COLORS_16; + }, + // Depth to assume when the stream is not validated. `FORCE_COLOR` is then the + // only hint about the terminal capabilities; without it, assume that whoever + // opted out of the stream validation can handle the full color range. + getForcedColorDepth() { + if (process.env.FORCE_COLOR === undefined) { + return COLORS_16m; } - return stream?.isTTY && ( - typeof stream.getColorDepth === 'function' ? - stream.getColorDepth() > 2 : true); + + return lazyInternalTTY().getColorDepth(); + }, + shouldColorize(stream) { + return module.exports.getColorDepth(stream) >= COLORS_16; }, refresh() { if (module.exports.shouldColorize(process.stderr)) { diff --git a/lib/util.js b/lib/util.js index e828229380d9..0c6bdc9de383 100644 --- a/lib/util.js +++ b/lib/util.js @@ -28,6 +28,8 @@ const { Error, ErrorCaptureStackTrace, FunctionPrototypeBind, + MathMax, + MathRound, NumberIsSafeInteger, ObjectDefineProperties, ObjectDefineProperty, @@ -116,7 +118,7 @@ const kEscapeEnd = 'm'; const kDimCode = 2; const kBoldCode = 1; -// Close sequence for 24-bit foreground colors (reset to default foreground) +// Close sequence for hex foreground colors (reset to default foreground) const kHexCloseSeq = kEscape + '39' + kEscapeEnd; let styleCache; @@ -155,19 +157,23 @@ function getStyleCache() { } /** - * Returns the cached ANSI escape sequences for a hex color. - * Computes and caches on first use to avoid repeated Buffer allocations. + * Returns the cached ANSI escape sequences for a hex color, one per supported + * color depth. 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}} + * @returns {{openSeq: string, openSeq256: string, openSeq16: 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); + const ansi256 = rgbToAnsi256(r, g, b); const style = { __proto__: null, openSeq: kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd, + openSeq256: `${kEscape}38;5;${ansi256}${kEscapeEnd}`, + openSeq16: kEscape + ansi256To16(ansi256) + kEscapeEnd, closeSeq: kHexCloseSeq, }; if (cache.size >= kHexStyleCacheMax) @@ -176,6 +182,20 @@ function getHexStyle(hex) { return style; } +/** + * Picks the hex color escape sequence matching the color depth of the terminal, + * downgrading 24-bit colors to the closest 256-color or 16-color equivalent. + * @param {{openSeq: string, openSeq256: string, openSeq16: string}} style + * @param {number} colorDepth Number of bits of color supported + * @returns {string} The ANSI escape sequence + */ +function hexOpenSeq(style, colorDepth) { + const { COLORS_256, COLORS_16m } = lazyUtilColors(); + if (colorDepth >= COLORS_16m) return style.openSeq; + if (colorDepth >= COLORS_256) return style.openSeq256; + return style.openSeq16; +} + function replaceCloseCode(str, closeSeq, openSeq, keepClose) { const closeLen = closeSeq.length; let index = str.indexOf(closeSeq); @@ -235,6 +255,54 @@ function rgbToAnsi24Bit(r, g, b) { return `38;2;${r};${g};${b}`; } +/** + * Converts RGB values to the closest color code of the ANSI 256-color palette. + * @param {number} r Red component (0-255) + * @param {number} g Green component (0-255) + * @param {number} b Blue component (0-255) + * @returns {number} The color code (16-255) + */ +function rgbToAnsi256(r, g, b) { + // Faster equivalent of `r !== g || g !== b`. + if (r ^ g | g ^ b) { + // 6x6x6 color cube (16-231). `c / 255 * 5` is the same as `c / 51`. + return 16 + 36 * MathRound(r / 51) + 6 * MathRound(g / 51) + MathRound(b / 51); + } + + // Grayscale ramp (232-255), with both ends of the color cube as bounds. + if (r < 8) return 16; + if (r > 248) return 231; + return MathRound(((r - 8) * 24) / 247) + 232; +} + +/** + * Converts an ANSI 256-color code to the closest of the 16 basic colors. + * @param {number} code The color code (0-255) + * @returns {number} The foreground color code (30-37 or 90-97) + */ +function ansi256To16(code) { + if (code < 8) return 30 + code; + if (code < 16) return 82 + code; + // Grayscale (232-255) is either black or white, flipping at the middle of the + // ramp. + if (code > 231) return code > 243 ? 37 : 30; + + // Color cube (16-231). + code -= 16; + const remainder = code % 36; + // The channels are integers 0-5; `n | 0` is a faster `MathFloor(n)`. + const r = (code / 36) | 0; + const g = (remainder / 6) | 0; + const b = remainder % 6; + + // A channel is on when it is greater than 2, which is `MathRound(c / 5)`. + // The bits are packed red first, matching the basic color codes. + const color = (r > 2) | ((g > 2) << 1) | ((b > 2) << 2); + + // A fully saturated channel switches to the bright variant (90-97). + return 30 + color + (MathMax(r, g, b) > 4 ? 60 : 0); +} + /** * @param {string | string[]} format * @param {string} text @@ -262,8 +330,12 @@ function styleText(format, text, options) { hexStyle = getHexStyle(format); } if (hexStyle !== undefined) { - const processed = replaceCloseCode(text, hexStyle.closeSeq, hexStyle.openSeq, false); - return hexStyle.openSeq + processed + hexStyle.closeSeq; + const utilColors = lazyUtilColors(); + const colorDepth = utilColors.getForcedColorDepth(); + if (colorDepth < utilColors.COLORS_16) return text; + const openSeq = hexOpenSeq(hexStyle, colorDepth); + const processed = replaceCloseCode(text, hexStyle.closeSeq, openSeq, false); + return openSeq + processed + hexStyle.closeSeq; } } } @@ -274,7 +346,7 @@ function styleText(format, text, options) { } validateBoolean(validateStream, 'options.validateStream'); - let skipColorize; + let colorDepth; if (validateStream) { const stream = options?.stream ?? process.stdout; if ( @@ -284,8 +356,11 @@ function styleText(format, text, options) { ) { throw new ERR_INVALID_ARG_TYPE('stream', ['ReadableStream', 'WritableStream', 'Stream'], stream); } - skipColorize = !lazyUtilColors().shouldColorize(stream); + colorDepth = lazyUtilColors().getColorDepth(stream); + } else { + colorDepth = lazyUtilColors().getForcedColorDepth(); } + const skipColorize = colorDepth < lazyUtilColors().COLORS_16; const formatArray = ArrayIsArray(format) ? format : [format]; const colors = inspect.colors; @@ -303,11 +378,10 @@ function styleText(format, text, options) { 'must be a valid hex color (#RGB or #RRGGBB)'); } if (skipColorize) continue; - const { 0: r, 1: g, 2: b } = hexToRgb(key); - const hexOpenSeq = kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd; - openCodes += hexOpenSeq; + const openSeq = hexOpenSeq(getHexStyle(key), colorDepth); + openCodes += openSeq; closeCodes = kHexCloseSeq + closeCodes; - processedText = replaceCloseCode(processedText, kHexCloseSeq, hexOpenSeq, false); + processedText = replaceCloseCode(processedText, kHexCloseSeq, openSeq, false); continue; } diff --git a/test/parallel/test-util-styletext-hex.js b/test/parallel/test-util-styletext-hex.js index f12c35a780d6..e7ed015058d8 100644 --- a/test/parallel/test-util-styletext-hex.js +++ b/test/parallel/test-util-styletext-hex.js @@ -6,6 +6,15 @@ const { describe, it } = require('node:test'); const util = require('node:util'); const { WriteStream } = require('node:tty'); +// Hex colors are downgraded to the color depth reported by `FORCE_COLOR`, so +// run with an environment that does not set it. Every helper below builds its +// environment from this one, which keeps the expectations independent of the +// environment running the test. +const { FORCE_COLOR, ...envWithoutForceColor } = process.env; +if (FORCE_COLOR !== undefined) { + process.env = envWithoutForceColor; +} + describe('util.styleText hex color support', () => { describe('valid 6-digit hex colors', () => { it('should parse #ffcc00 as RGB(255, 204, 0)', () => { @@ -143,9 +152,19 @@ describe('util.styleText hex color support', () => { }); describe('environment variable behavior', () => { + // #ffcc00 in each of the supported color depths. const styledHex = '\u001b[38;2;255;204;0mtest\u001b[39m'; + const styledHex256 = '\u001b[38;5;220mtest\u001b[39m'; + const styledHex16 = '\u001b[93mtest\u001b[39m'; const noChange = 'test'; + // The output expected from a terminal supporting `depth` bits of color. + function styledForDepth(depth) { + if (depth >= 24) return styledHex; + if (depth >= 8) return styledHex256; + return styledHex16; + } + const fd = common.getTTYfd(); if (fd === -1) { it.skip('Could not create TTY fd', () => {}); @@ -157,7 +176,8 @@ describe('util.styleText hex color support', () => { { isTTY: true, env: {}, - expected: styledHex, + // Depends on the color depth of the terminal running the test. + expected: () => styledForDepth(writeStream.getColorDepth()), description: 'isTTY=true with no env vars', }, { @@ -181,26 +201,56 @@ describe('util.styleText hex color support', () => { { isTTY: true, env: { FORCE_COLOR: '1' }, + expected: styledHex16, + description: 'FORCE_COLOR=1 downgrading to 16 colors', + }, + { + isTTY: true, + env: { FORCE_COLOR: 'true' }, + expected: styledHex16, + description: 'FORCE_COLOR=true downgrading to 16 colors', + }, + { + isTTY: true, + env: { FORCE_COLOR: '' }, + expected: styledHex16, + description: 'an empty FORCE_COLOR downgrading to 16 colors', + }, + { + isTTY: true, + env: { FORCE_COLOR: '2' }, + expected: styledHex256, + description: 'FORCE_COLOR=2 downgrading to 256 colors', + }, + { + isTTY: true, + env: { FORCE_COLOR: '3' }, + expected: styledHex, + description: 'FORCE_COLOR=3 keeping 24-bit colors', + }, + { + isTTY: false, + env: { FORCE_COLOR: '3' }, expected: styledHex, - description: 'FORCE_COLOR=1', + description: 'FORCE_COLOR=3 with isTTY=false', }, { isTTY: true, env: { FORCE_COLOR: '1', NODE_DISABLE_COLORS: '1' }, - expected: styledHex, + expected: styledHex16, description: 'FORCE_COLOR=1 overrides NODE_DISABLE_COLORS', }, { isTTY: false, - env: { FORCE_COLOR: '1', NO_COLOR: '1', NODE_DISABLE_COLORS: '1' }, - expected: styledHex, - description: 'FORCE_COLOR=1 overrides all disable flags', + env: { FORCE_COLOR: '2', NO_COLOR: '1', NODE_DISABLE_COLORS: '1' }, + expected: styledHex256, + description: 'FORCE_COLOR=2 overrides all disable flags', }, { isTTY: true, - env: { FORCE_COLOR: '1', NO_COLOR: '1', NODE_DISABLE_COLORS: '1' }, + env: { FORCE_COLOR: '3', NO_COLOR: '1', NODE_DISABLE_COLORS: '1' }, expected: styledHex, - description: 'FORCE_COLOR=1 wins with all flags', + description: 'FORCE_COLOR=3 wins with all flags', }, { isTTY: true, @@ -217,14 +267,113 @@ describe('util.styleText hex color support', () => { ...originalEnv, ...testCase.env, }; + const expected = typeof testCase.expected === 'function' ? + testCase.expected() : + testCase.expected; const output = util.styleText('#ffcc00', 'test', { stream: writeStream }); - assert.strictEqual(output, testCase.expected); + assert.strictEqual(output, expected); + // Combining the hex color with another format applies the same depth. + const combined = util.styleText(['bold', '#ffcc00'], 'test', { stream: writeStream }); + assert.strictEqual( + combined, + expected === noChange ? noChange : `\u001b[1m${expected}\u001b[22m`, + ); process.env = originalEnv; }); } } }); + describe('color depth downgrade without stream validation', () => { + const originalEnv = { ...process.env }; + + function styled(format, forceColor) { + // `originalEnv` never has `FORCE_COLOR`, so leaving it out is enough to + // test the case where it is unset. + process.env = forceColor === undefined ? + { ...originalEnv } : + { ...originalEnv, FORCE_COLOR: forceColor }; + try { + return util.styleText(format, 'test', { validateStream: false }); + } finally { + process.env = originalEnv; + } + } + + it('should keep 24-bit colors when FORCE_COLOR is not set', () => { + assert.strictEqual(styled('#ffcc00'), '\u001b[38;2;255;204;0mtest\u001b[39m'); + }); + + it('should downgrade to 16 colors with FORCE_COLOR=1', () => { + assert.strictEqual(styled('#ffcc00', '1'), '\u001b[93mtest\u001b[39m'); + }); + + it('should downgrade to 256 colors with FORCE_COLOR=2', () => { + assert.strictEqual(styled('#ffcc00', '2'), '\u001b[38;5;220mtest\u001b[39m'); + }); + + it('should keep 24-bit colors with FORCE_COLOR=3', () => { + assert.strictEqual(styled('#ffcc00', '3'), '\u001b[38;2;255;204;0mtest\u001b[39m'); + }); + + it('should disable colors with FORCE_COLOR=0', () => { + assert.strictEqual(styled('#ffcc00', '0'), 'test'); + // Also via the array path, which goes through the shared loop. + assert.strictEqual(styled(['bold', '#ffcc00'], '0'), 'test'); + }); + + it('should downgrade every color of an array of formats', () => { + assert.strictEqual( + styled(['#ff0000', 'underline', '#00ff00'], '2'), + '\u001b[38;5;196m\u001b[4m\u001b[38;5;46mtest\u001b[39m\u001b[24m\u001b[39m', + ); + }); + }); + + describe('closest color for each depth', () => { + const originalEnv = { ...process.env }; + + function styled(format, forceColor) { + process.env = { ...originalEnv, FORCE_COLOR: forceColor }; + try { + return util.styleText(format, 'x', { validateStream: false }); + } finally { + process.env = originalEnv; + } + } + + it('should map colors to the closest of the 16 basic colors', () => { + assert.strictEqual(styled('#000000', '1'), '\u001b[30mx\u001b[39m'); + assert.strictEqual(styled('#ff0000', '1'), '\u001b[91mx\u001b[39m'); + assert.strictEqual(styled('#00ff00', '1'), '\u001b[92mx\u001b[39m'); + assert.strictEqual(styled('#0000ff', '1'), '\u001b[94mx\u001b[39m'); + assert.strictEqual(styled('#00ffff', '1'), '\u001b[96mx\u001b[39m'); + assert.strictEqual(styled('#ff00ff', '1'), '\u001b[95mx\u001b[39m'); + assert.strictEqual(styled('#ffffff', '1'), '\u001b[97mx\u001b[39m'); + assert.strictEqual(styled('#808080', '1'), '\u001b[37mx\u001b[39m'); + // Only a fully saturated channel switches to the bright variant, so + // mid-tones keep the normal colors. + assert.strictEqual(styled('#aabbcc', '1'), '\u001b[37mx\u001b[39m'); + assert.strictEqual(styled('#f0f0f0', '1'), '\u001b[37mx\u001b[39m'); + assert.strictEqual(styled('#ffcc00', '1'), '\u001b[93mx\u001b[39m'); + // Colors too dark to be told apart end up black. + assert.strictEqual(styled('#123456', '1'), '\u001b[30mx\u001b[39m'); + }); + + it('should map colors to the closest of the 256 color palette', () => { + // Both ends of the 6x6x6 color cube. + assert.strictEqual(styled('#000000', '2'), '\u001b[38;5;16mx\u001b[39m'); + assert.strictEqual(styled('#ffffff', '2'), '\u001b[38;5;231mx\u001b[39m'); + assert.strictEqual(styled('#ff0000', '2'), '\u001b[38;5;196mx\u001b[39m'); + assert.strictEqual(styled('#00ff00', '2'), '\u001b[38;5;46mx\u001b[39m'); + assert.strictEqual(styled('#0000ff', '2'), '\u001b[38;5;21mx\u001b[39m'); + // Grayscale ramp. + assert.strictEqual(styled('#080808', '2'), '\u001b[38;5;232mx\u001b[39m'); + assert.strictEqual(styled('#808080', '2'), '\u001b[38;5;244mx\u001b[39m'); + assert.strictEqual(styled('#f8f8f8', '2'), '\u001b[38;5;255mx\u001b[39m'); + }); + }); + describe('nested hex colors', () => { it('should handle nested hex color styling', () => { const inner = util.styleText('#0000ff', 'inner', { validateStream: false });