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
21 changes: 21 additions & 0 deletions doc/api/util.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
avivkeller marked this conversation as resolved.
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`
Expand Down Expand Up @@ -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
Expand Down
41 changes: 36 additions & 5 deletions lib/internal/util/colors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -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)) {
Expand Down
98 changes: 86 additions & 12 deletions lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const {
Error,
ErrorCaptureStackTrace,
FunctionPrototypeBind,
MathMax,
MathRound,
NumberIsSafeInteger,
ObjectDefineProperties,
ObjectDefineProperty,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
}
Expand All @@ -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 (
Expand All @@ -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;
Expand All @@ -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;
}

Expand Down
Loading