diff --git a/docs/runtime/color.mdx b/docs/runtime/color.mdx index cf718b89cb47..4f1ae09624d2 100644 --- a/docs/runtime/color.mdx +++ b/docs/runtime/color.mdx @@ -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` | @@ -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. diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index c3def63b1275..2a58e5f76e4b 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -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" /** diff --git a/src/css_jsc/color_js.rs b/src/css_jsc/color_js.rs index d36c1f78f3fc..d39572fd154f 100644 --- a/src/css_jsc/color_js.rs +++ b/src/css_jsc/color_js.rs @@ -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)); @@ -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 { use bun_ast::symbol::Map as SymbolMap; use bun_core::ZigStringSlice; @@ -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]); } OutputColorFormat::Ansi16m => { // true color ansi @@ -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 => { @@ -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) )); } } diff --git a/test/js/bun/css/__snapshots__/color.test.ts.snap b/test/js/bun/css/__snapshots__/color.test.ts.snap index a3027e1a91ac..69632b813638 100644 Binary files a/test/js/bun/css/__snapshots__/color.test.ts.snap and b/test/js/bun/css/__snapshots__/color.test.ts.snap differ diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index 362f4a88d145..a1d9b0f10186 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -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]); + }); +});