From cafa9f989c384d2cfb26d3dad9c71b62f9c677a4 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Wed, 17 Jun 2026 14:09:28 +0100 Subject: [PATCH] fix: keep full alpha precision when parsing hex colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parseHex` rounded the alpha byte to 2 decimals while the rest of the library rounds alpha to `ALPHA_PRECISION` (3). A hex alpha byte is 8-bit, so 2 decimals cannot represent it: 155 of the 255 non-opaque bytes do not survive a `#RRGGBBAA` -> `toHex()` round trip, and `0xfe` rounds to exactly 1, which drops the alpha channel altogether — `#000000fe` comes back as `#000000`. Parse the alpha byte at `ALPHA_PRECISION`, as every other color model in the library already does. All 255 bytes round-trip afterwards. This changes serialized alpha for hex-sourced colors from 2 decimals to 3, because 1/255 is smaller than 0.01: a value that round-trips cannot also print as two decimals. Six existing expectations move accordingly (`/ 0.5` -> `/ 0.502`, `/ 0.93` -> `/ 0.929`); the reverse direction (object -> hex) is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- src/colorModels/hex.ts | 5 +++-- tests/colord.test.ts | 15 ++++++++++++--- tests/plugins.test.ts | 12 ++++++------ 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/colorModels/hex.ts b/src/colorModels/hex.ts index 02d1289..1c29334 100644 --- a/src/colorModels/hex.ts +++ b/src/colorModels/hex.ts @@ -1,5 +1,6 @@ import { RgbaColor } from "../types"; import { clamp, round } from "../helpers"; +import { ALPHA_PRECISION } from "../constants"; import { roundRgba } from "./rgb"; const hexMatcher = /^#([0-9a-f]{3,8})$/i; @@ -38,7 +39,7 @@ export const parseHex = (hex: string): RgbaColor | null => { r: digitAt(hex, 1) * 0x11, g: digitAt(hex, 2) * 0x11, b: digitAt(hex, 3) * 0x11, - a: length === 5 ? round((digitAt(hex, 4) * 0x11) / 255, 2) : 1, + a: length === 5 ? round((digitAt(hex, 4) * 0x11) / 255, ALPHA_PRECISION) : 1, }; } @@ -47,7 +48,7 @@ export const parseHex = (hex: string): RgbaColor | null => { r: byteAt(hex, 1), g: byteAt(hex, 3), b: byteAt(hex, 5), - a: length === 9 ? round(byteAt(hex, 7) / 255, 2) : 1, + a: length === 9 ? round(byteAt(hex, 7) / 255, ALPHA_PRECISION) : 1, }; } diff --git a/tests/colord.test.ts b/tests/colord.test.ts index 19d9378..4149c77 100644 --- a/tests/colord.test.ts +++ b/tests/colord.test.ts @@ -2,6 +2,7 @@ import { colord, random, getFormat, Colord, AnyColor } from "../src/"; import { fixtures, lime, saturationLevels } from "./fixtures"; import { clampHue, round, roundHue } from "../src/helpers"; +import { ALPHA_PRECISION } from "../src/constants"; it("Converts between HEX, RGB, HSL and HSV color models properly", () => { for (const fixture of fixtures) { @@ -126,7 +127,7 @@ it("Parses modern HSL functional notations", () => { it("Supports HEX4 and HEX8 color models", () => { expect(colord("#ffffffff").toRgb()).toMatchObject({ r: 255, g: 255, b: 255, a: 1 }); - expect(colord("#80808080").toRgb()).toMatchObject({ r: 128, g: 128, b: 128, a: 0.5 }); + expect(colord("#80808080").toRgb()).toMatchObject({ r: 128, g: 128, b: 128, a: 0.502 }); expect(colord("#AAAF").toRgb()).toMatchObject({ r: 170, g: 170, b: 170, a: 1 }); expect(colord("#5550").toRgb()).toMatchObject({ r: 85, g: 85, b: 85, a: 0 }); expect(colord({ r: 255, g: 255, b: 255, a: 1 }).toHex()).toBe("#ffffff"); @@ -146,7 +147,7 @@ it("Parses and serializes every HEX digit exactly", () => { r: byte, g: byte, b: byte, - a: round(byte / 255, 2), + a: round(byte / 255, ALPHA_PRECISION), }); } @@ -164,7 +165,7 @@ it("Parses and serializes every HEX digit exactly", () => { r: 0, g: 0, b: 0, - a: round(byte / 255, 2), + a: round(byte / 255, ALPHA_PRECISION), }); } @@ -181,6 +182,14 @@ it("Parses and serializes every HEX digit exactly", () => { expect(colord("# 12345").isValid()).toBe(false); }); +it("Round-trips a HEX8 alpha channel without drifting", () => { + // The alpha byte of a HEX8 string is 8-bit; parsing must keep enough + // precision (ALPHA_PRECISION) to re-encode the very same byte. + ["#476d5e55", "#80808080", "#0a141e7f", "#ffffff01", "#000000fe"].forEach((hex) => { + expect(colord(hex).toHex()).toBe(hex); + }); +}); + it("Ignores a case and extra whitespace", () => { expect(colord(" #0a0a0a ").toRgb()).toMatchObject({ r: 10, g: 10, b: 10, a: 1 }); expect(colord("RGB( 10, 10, 10 )").toRgb()).toMatchObject({ r: 10, g: 10, b: 10, a: 1 }); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts index 1a8458b..1f673ca 100644 --- a/tests/plugins.test.ts +++ b/tests/plugins.test.ts @@ -85,7 +85,7 @@ describe("cmyk", () => { // https://en.wikipedia.org/wiki/CMYK_color_model expect(colord("#999966").toCmykString()).toBe("device-cmyk(0% 0% 33% 40%)"); expect(colord("#99ffff").toCmykString()).toBe("device-cmyk(40% 0% 0% 0%)"); - expect(colord("#00336680").toCmykString()).toBe("device-cmyk(100% 50% 0% 60% / 0.5)"); + expect(colord("#00336680").toCmykString()).toBe("device-cmyk(100% 50% 0% 60% / 0.502)"); }); it("Supported by `getFormat`", () => { @@ -196,7 +196,7 @@ describe("hwb", () => { // https://en.wikipedia.org/wiki/HWB_color_model expect(colord("#999966").toHwbString()).toBe("hwb(60 40% 40%)"); expect(colord("#99ffff").toHwbString()).toBe("hwb(180 60% 0%)"); - expect(colord("#00336680").toHwbString()).toBe("hwb(210 0% 60% / 0.5)"); + expect(colord("#00336680").toHwbString()).toBe("hwb(210 0% 60% / 0.502)"); }); it("Supports all valid CSS angle units", () => { @@ -233,7 +233,7 @@ describe("lab", () => { expect(colord("#00ff00").toLab()).toMatchObject({ l: 87.82, a: -79.29, b: 80.99, alpha: 1 }); expect(colord("#ffff00").toLab()).toMatchObject({ l: 97.61, a: -15.75, b: 93.39, alpha: 1 }); expect(colord("#aabbcc").toLab()).toMatchObject({ l: 74.97, a: -3.4, b: -10.7, alpha: 1 }); - expect(colord("#33221180").toLab()).toMatchObject({ l: 15.05, a: 6.68, b: 14.59, alpha: 0.5 }); + expect(colord("#33221180").toLab()).toMatchObject({ l: 15.05, a: 6.68, b: 14.59, alpha: 0.502 }); expect(colord("#d53987").toLab()).toMatchObject({ l: 50.93, a: 64.96, b: -6.38, alpha: 1 }); }); @@ -298,7 +298,7 @@ describe("lch", () => { expect(colord("#c65d06").toLch()).toMatchObject({ l: 52.31, c: 72.21, h: 56.33, a: 1 }); expect(colord("#9d9318").toLch()).toMatchObject({ l: 60.31, c: 59.2, h: 95.46, a: 1 }); expect(colord("#68a639").toLch()).toMatchObject({ l: 62.22, c: 59.15, h: 126.15, a: 1 }); - expect(colord("#62acef80").toLch()).toMatchObject({ l: 67.67, c: 42.18, h: 257.79, a: 0.5 }); + expect(colord("#62acef80").toLch()).toMatchObject({ l: 67.67, c: 42.18, h: 257.79, a: 0.502 }); }); it("Keeps the LCH hue within [0, 360)", () => { @@ -311,9 +311,9 @@ describe("lch", () => { it("Converts a color to CIE LCH string (CSS functional notation)", () => { // https://cielab.xyz/colorconv/ - expect(colord("#00000080").toLchString()).toBe("lch(0% 0 0 / 0.5)"); + expect(colord("#00000080").toLchString()).toBe("lch(0% 0 0 / 0.502)"); expect(colord("#ffffff").toLchString()).toBe("lch(100% 0 0)"); - expect(colord("#c65d06ed").toLchString()).toBe("lch(52.31% 72.21 56.33 / 0.93)"); + expect(colord("#c65d06ed").toLchString()).toBe("lch(52.31% 72.21 56.33 / 0.929)"); expect(colord("#aabbcc").toLchString()).toBe("lch(74.97% 11.22 252.37)"); });