Skip to content
Merged
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
10 changes: 5 additions & 5 deletions docs/runtime/color.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ description: Format colors as CSS, ANSI, numbers, hex strings, and more
| ------------ | -------------------------------- |
| `"css"` | `"red"` |
| `"ansi"` | `"\x1b[38;2;255;0;0m"` |
| `"ansi-16"` | `"\x1b[38;5;\tm"` |
| `"ansi-16"` | `"\x1b[91m"` |
| `"ansi-256"` | `"\x1b[38;5;196m"` |
| `"ansi-16m"` | `"\x1b[38;2;255;0;0m"` |
| `"number"` | `0x1a2b3c` |
Expand Down Expand Up @@ -124,10 +124,10 @@ To convert from RGBA to one of the 256 ANSI colors, we ported the algorithm that
The `"ansi-16"` format approximates the input color to the nearest of the 16 ANSI colors supported by most terminals.

```ts
Bun.color("red", "ansi-16"); // "\u001b[38;5;\tm"
Bun.color(0xff0000, "ansi-16"); // "\u001b[38;5;\tm"
Bun.color("#f00", "ansi-16"); // "\u001b[38;5;\tm"
Bun.color("#ff0000", "ansi-16"); // "\u001b[38;5;\tm"
Bun.color("red", "ansi-16"); // "\u001b[91m"
Bun.color(0xff0000, "ansi-16"); // "\u001b[91m"
Bun.color("#f00", "ansi-16"); // "\u001b[91m"
Bun.color("#ff0000", "ansi-16"); // "\u001b[91m"
```

Bun converts the input to a 24-bit RGB color space, then to `ansi-256`, then to the nearest of the 16 ANSI colors.
Expand Down
4 changes: 2 additions & 2 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4610,11 +4610,11 @@ declare module "bun" {
*/
| "HEX"
/**
* @example hsl(35.764706, 1, 0.5)
* @example hsl(35.764706, 100%, 50%)
*/
| "hsl"
/**
* @example lab(0.72732764, 33.938198, -25.311619)
* @example lab(72.732764% 33.938198 -25.311619)
*/
| "lab"
/**
Expand Down
60 changes: 38 additions & 22 deletions src/css_jsc/color_js.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,10 @@ pub mod ansi256 {
let grey_idx = if grey_avg > 238 {
23
} else {
(grey_avg.wrapping_sub(3)) / 10
// tmux does this in signed int, where (2 - 3) / 10 truncates to 0.
// Wrapping on u32 would send the palette index into the hundreds of
// millions for any average below 3.
grey_avg.saturating_sub(3) / 10
};
let grey = 8u32.wrapping_add(10u32.wrapping_mul(grey_idx));

Expand Down Expand Up @@ -186,6 +189,13 @@ pub mod ansi256 {
}
}

/// A missing color component (CSS Color 4's `none`, or the hue of an achromatic
/// color) is stored as NaN, and behaves as zero outside of interpolation. Printing
/// it as `NaN` would produce a string no CSS parser accepts.
fn zero_if_none(component: f32) -> f32 {
if component.is_nan() { 0.0 } else { component }
}

pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult<JSValue> {
use bun_ast::symbol::Map as SymbolMap;
use bun_core::ZigStringSlice;
Expand Down Expand Up @@ -484,27 +494,24 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult
));
}
OutputColorFormat::Ansi16 => {
let ansi_16_color = ansi256::get16(
let index = ansi256::get16(
rgba.red as u32,
rgba.green as u32,
rgba.blue as u32,
);
// 16-color ansi, foreground text color
break 'color BunString::clone_latin1(&[
// 0x1b is the escape character
// 38 is the foreground color code
// 5 is the 16-color mode
// {d} is the color index
0x1b,
b'[',
b'3',
b'8',
b';',
b'5',
b';',
ansi_16_color,
b'm',
]);
// 16-color SGR: 30..=37 for the first eight, 90..=97
// for their bright variants. The 38;5;{index} form
// only a 256-color terminal reads is ansi-256's job.
let sgr = if index < 8 { 30 + index } else { 82 + index };
let mut buf = [0u8; 8];
buf[0..2].copy_from_slice(b"\x1b[");
let extra_len = {
let mut cursor = &mut buf[2..];
let before = cursor.len();
write!(cursor, "{}m", sgr).expect("unreachable");
before - cursor.len()
};
break 'color BunString::clone_latin1(&buf[0..2 + extra_len]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
OutputColorFormat::Ansi16m => {
// true color ansi
Expand Down Expand Up @@ -555,9 +562,14 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult
_ => break 'formatted,
};

// Saturation and lightness are stored as 0..1 but hsl()
// takes percentages. A missing component (an achromatic
// hue, or `none`) is a zero value in a concrete color.
break 'color BunString::create_format(format_args!(
"hsl({}, {}, {})",
hsl.h, hsl.s, hsl.l
"hsl({}, {}%, {}%)",
zero_if_none(hsl.h),
zero_if_none(hsl.s) * 100.0,
zero_if_none(hsl.l) * 100.0
));
}
OutputColorFormat::Lab => {
Expand All @@ -571,9 +583,13 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult
_ => break 'formatted,
};

// lab() is space-separated and takes lightness as a
// percentage, matching what the CSS printer emits.
break 'color BunString::create_format(format_args!(
"lab({}, {}, {})",
lab.l, lab.a, lab.b
"lab({}% {} {})",
zero_if_none(lab.l) * 100.0,
zero_if_none(lab.a),
zero_if_none(lab.b)
));
}
}
Expand Down
Binary file modified test/js/bun/css/__snapshots__/color.test.ts.snap
Binary file not shown.
185 changes: 185 additions & 0 deletions test/js/bun/css/color.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,188 @@ test.skipIf(isDebug)("fuzz ansi256", () => {
}
});
});

// These assert the documented contract rather than snapshotting whatever the
// implementation currently emits. https://bun.com/docs/runtime/color
describe("ansi output is a well-formed SGR sequence", () => {
const sgr = /^\u001b\[[\d;]+m$/;

test.each(["ansi-16", "ansi-256", "ansi-16m"])("%s", format => {
for (const input of ["black", "red", "lime", "blue", "white", "magenta", "cyan", "yellow", "#336699"]) {
const escape = color(input, format as any);
expect(typeof escape).toBe("string");
expect(escape).toMatch(sgr);
}
});

// 30..=37 for the first eight colors, 90..=97 for their bright variants.
// https://github.com/oven-sh/bun/issues/22161
test("ansi-16 uses the 16-color SGR parameters", () => {
expect(color("black", "ansi-16")).toBe("\u001b[30m");
expect(color("green", "ansi-16")).toBe("\u001b[32m");
expect(color("gray", "ansi-16")).toBe("\u001b[37m");
expect(color("red", "ansi-16")).toBe("\u001b[91m");
expect(color("lime", "ansi-16")).toBe("\u001b[92m");
expect(color("blue", "ansi-16")).toBe("\u001b[94m");
expect(color("magenta", "ansi-16")).toBe("\u001b[95m");
expect(color("white", "ansi-16")).toBe("\u001b[97m");
});

test("ansi-16 never emits a 256-color escape", () => {
for (let r = 0; r < 256; r += r < 8 ? 1 : 51) {
for (let g = 0; g < 256; g += g < 8 ? 1 : 51) {
for (let b = 0; b < 256; b += b < 8 ? 1 : 51) {
expect(color({ r, g, b }, "ansi-16")).toMatch(/^\u001b\[(3[0-7]|9[0-7])m$/);
}
}
}
});

test("ansi-256 and ansi-16m keep their documented shapes", () => {
expect(color("red", "ansi-256")).toBe("\u001b[38;5;196m");
expect(color("red", "ansi-16m")).toBe("\u001b[38;2;255;0;0m");
});

// The palette only has 256 entries, so a valid-looking `38;5;429496961m` is
// still a broken escape. The grey ramp is where the index arithmetic underflows.
test("ansi-256 never emits an index outside the palette", () => {
withoutAggressiveGC(() => {
for (let value = 0; value < 256; value++) {
for (const rgb of [
{ r: value, g: value, b: value },
{ r: 0, g: 0, b: value },
{ r: value, g: 0, b: 0 },
]) {
const index = Number(color(rgb, "ansi-256")!.match(/38;5;(\d+)m/)![1]);
if (index > 255) throw new Error(`color(${JSON.stringify(rgb)}, "ansi-256") = index ${index}`);
}
}
});
});

// https://github.com/tmux/tmux/blob/master/colour.c
test("near-black colors land on black, not on a wrapped grey index", () => {
expect(color("#020202", "ansi-256")).toBe("\u001b[38;5;16m");
expect(color("#020202", "ansi-16")).toBe("\u001b[30m");
expect(color("#000004", "ansi-256")).toBe("\u001b[38;5;16m");
});

// A terminal skips the whole escape, so the printed width is just the text.
test.each(["ansi-16", "ansi-256", "ansi-16m"])("%s occupies no columns", format => {
expect(Bun.stringWidth(color("red", format as any) + "hello")).toBe(5);
});

test("every 24-bit color produces a well-formed ansi-16 sequence", () => {
withoutAggressiveGC(() => {
for (let r = 0; r < 256; r += r < 8 ? 1 : 17) {
for (let g = 0; g < 256; g += g < 8 ? 1 : 17) {
for (let b = 0; b < 256; b += b < 8 ? 1 : 17) {
const escape = color({ r, g, b }, "ansi-16");
if (!sgr.test(escape!)) throw new Error(`color(${r},${g},${b}, "ansi-16") = ${JSON.stringify(escape)}`);
}
}
}
});
});
});

describe("css string output parses back to the same color", () => {
const inputs = ["red", "#336699", "rgb(1, 2, 3)", "#000000", "#ffffff"];

test.each(["css", "hex", "HEX", "rgb", "rgba"])("%s round-trips", format => {
for (const input of inputs) {
expect(color(color(input, format as any) as string, "hex")).toBe(color(input, "hex"));
}
});

test("hsl round-trips", () => {
for (const input of [...inputs, "#808080", "lime", "rebeccapurple"]) {
expect(color(color(input, "hsl") as string, "hex")).toBe(color(input, "hex"));
}
});

test("hsl round-trips across the color cube", () => {
withoutAggressiveGC(() => {
for (let r = 0; r < 256; r += 37) {
for (let g = 0; g < 256; g += 53) {
for (let b = 0; b < 256; b += 61) {
const back = color(color({ r, g, b }, "hsl") as string, "hex");
if (back !== color({ r, g, b }, "hex")) {
throw new Error(`hsl(${r},${g},${b}) round-tripped to ${back}`);
}
}
}
}
});
});

// An achromatic color has no hue, and `hsl(NaN, ...)` is not parseable.
test("hsl of a grey has a zero hue", () => {
expect(color("#808080", "hsl")).toMatch(/^hsl\(0, 0%, 50\.19\d*%\)$/);
expect(color("#000000", "hsl")).toBe("hsl(0, 0%, 0%)");
});

test("lab output is CSS that Bun can parse back", () => {
for (const input of [...inputs, "#808080", "lime", "rebeccapurple"]) {
expect(color(color(input, "lab") as string, "hex")).not.toBeNull();
}
});

// https://github.com/oven-sh/bun/issues/33331
test.failing("lab round-trips", () => {
expect(color(color("#0000ff", "lab") as string, "hex")).toBe("#0000ff");
});

// The forward direction is exact, so the inverse is the broken one. It goes
// through cbrt, so the last f32 digit varies by platform; compare numerically.
test.each([
["#ff0000", [54.29, 80.8, 69.89]],
["#00ff00", [87.82, -79.27, 80.99]],
["#0000ff", [29.57, 68.29, -112.03]],
])("lab of %s matches the CIELAB D50 reference", (input, reference) => {
const components = (color(input as string, "lab") as string).match(/-?[\d.]+/g)!.map(Number);
expect(components).toHaveLength(3);
for (let i = 0; i < 3; i++) {
expect(components[i]).toBeCloseTo((reference as number[])[i], 1);
}
});

// A `none` component is a zero value outside of interpolation, and `NaN` is not
// a token any CSS parser accepts.
test("a none component does not leak NaN into the output", () => {
expect(color("hsl(120 none 50%)", "hsl")).toBe("hsl(120, 0%, 50%)");
expect(color("lab(none 40 30)", "lab")).toBe("lab(0% 40 30)");
expect(color("lab(50% none 30)", "lab")).toBe("lab(50% 0 30)");
expect(color(color("hsl(120 none 50%)", "hsl") as string, "hex")).not.toBeNull();
});
});

describe("input forms", () => {
test.each([
["a named color", "red"],
["3-digit hex", "#f00"],
["6-digit hex", "#ff0000"],
["8-digit hex", "#ff0000ff"],
["rgb()", "rgb(255, 0, 0)"],
["rgba()", "rgba(255, 0, 0, 1)"],
["hsl() with percentages", "hsl(0, 100%, 50%)"],
["a number", 0xff0000],
["an object", { r: 255, g: 0, b: 0 }],
["an array", [255, 0, 0]],
])("%s resolves to red", (_name, input) => {
expect(color(input as any, "hex")).toBe("#ff0000");
});

test("an unparseable color is null", () => {
expect(color("notacolor", "hex")).toBeNull();
expect(color("", "hex")).toBeNull();
expect(color("#gg0000", "hex")).toBeNull();
});

test("alpha survives the object and array forms", () => {
expect(color("#f00", "{rgba}")).toEqual({ r: 255, g: 0, b: 0, a: 1 });
expect(color("#f00", "[rgba]")).toEqual([255, 0, 0, 255]);
expect(color("#f00", "{rgb}")).toEqual({ r: 255, g: 0, b: 0 });
expect(color("#f00", "[rgb]")).toEqual([255, 0, 0]);
});
});
Loading