From a4b4e41e2ae06bd41defea7ba411963b353ca61d Mon Sep 17 00:00:00 2001 From: Alexander Kireev Date: Fri, 26 Jun 2026 19:33:32 +0700 Subject: [PATCH 1/2] fix(hcg): return chroma/grayness on [0..1] so rgb2hcg round-trips rgb2hcg scaled chroma and grayness to [0..100] and computed grayness with a mismatched denominator, while hcg2rgb (and the existing tests) treat both channels as [0..1]. As a result rgb -> hcg -> rgb did not round-trip, notably when all three channels were non-zero (#250). Normalize RGB to [0..1], return delta as chroma and min/(1-delta) as grayness (0 when delta is 1, where min is necessarily 0). --- src/io/hcg/rgb2hcg.js | 6 ++--- test/io/rgb2hcg.test.js | 58 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 test/io/rgb2hcg.test.js diff --git a/src/io/hcg/rgb2hcg.js b/src/io/hcg/rgb2hcg.js index 07bc39d0..e74ede12 100644 --- a/src/io/hcg/rgb2hcg.js +++ b/src/io/hcg/rgb2hcg.js @@ -1,12 +1,12 @@ import { unpack, max, min } from '../../utils/index.js'; const rgb2hcg = (...args) => { - const [r, g, b] = unpack(args, 'rgb'); + const [r, g, b] = unpack(args, 'rgb').map((x) => x / 255); const minRgb = min(r, g, b); const maxRgb = max(r, g, b); const delta = maxRgb - minRgb; - const c = (delta * 100) / 255; - const _g = (minRgb / (255 - delta)) * 100; + const c = delta; + const _g = delta < 1 ? minRgb / (1 - delta) : 0; let h; if (delta === 0) { h = Number.NaN; diff --git a/test/io/rgb2hcg.test.js b/test/io/rgb2hcg.test.js new file mode 100644 index 00000000..825d2085 --- /dev/null +++ b/test/io/rgb2hcg.test.js @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import rgb2hcg from '../../src/io/hcg/rgb2hcg.js'; +import hcg2rgb from '../../src/io/hcg/hcg2rgb.js'; + +// chroma value (C) and grayness (G) are defined on [0..1], which is what +// hcg2rgb consumes (see test/io/hcg2rgb.test.js). These cases are the exact +// inverse of that file's cases, so rgb2hcg and hcg2rgb agree. +const tests = { + black: { hcg: [NaN, 0, 0], rgb: [0, 0, 0, 1] }, + white: { hcg: [NaN, 0, 1], rgb: [255, 255, 255, 1] }, + gray: { hcg: [NaN, 0, 0.5], rgb: [127.5, 127.5, 127.5, 1] }, + red: { hcg: [0, 1, 0], rgb: [255, 0, 0, 1] }, + yellow: { hcg: [60, 1, 0], rgb: [255, 255, 0, 1] }, + green: { hcg: [120, 1, 0], rgb: [0, 255, 0, 1] }, + cyan: { hcg: [180, 1, 0], rgb: [0, 255, 255, 1] }, + blue: { hcg: [240, 1, 0], rgb: [0, 0, 255, 1] }, + magenta: { hcg: [300, 1, 0], rgb: [255, 0, 255, 1] } +}; + +describe('Test rgb2hcg color conversions', () => { + Object.keys(tests).forEach((key) => { + const { hcg, rgb } = tests[key]; + + it(`rgb2hcg ${key} converts array`, () => { + expect(rgb2hcg(rgb)).toEqual(hcg); + }); + + it(`rgb2hcg ${key} converts object`, () => { + const [r, g, b] = rgb; + expect(rgb2hcg({ r, g, b })).toEqual(hcg); + }); + + it(`rgb2hcg ${key} converts arguments`, () => { + expect(rgb2hcg(...rgb)).toEqual(hcg); + }); + }); + + // https://github.com/gka/chroma.js/issues/250 — rgb2hcg returned C/G on the + // [0..100] scale (and a broken grayness), so rgb -> hcg -> rgb did not round + // trip, especially when all three channels were non-zero. + it('round-trips arbitrary RGB colors through HCG', () => { + const colors = [ + [128, 64, 32], + [100, 150, 200], + [17, 99, 200], + [250, 5, 5], + [200, 200, 50], + [10, 20, 30] + ]; + colors.forEach((rgb) => { + const [h, c, g] = rgb2hcg(rgb); + const back = hcg2rgb([h, c, g]); + rgb.forEach((channel, i) => { + expect(back[i]).toBeCloseTo(channel, 6); + }); + }); + }); +}); From 4bb76cbc7ef2d8f508f7f02b362e9b7e9e48cc3c Mon Sep 17 00:00:00 2001 From: Alexander Kireev Date: Fri, 26 Jun 2026 20:02:19 +0700 Subject: [PATCH 2/2] docs: add changeset for hcg range fix --- .changeset/honest-numbers-prove.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/honest-numbers-prove.md diff --git a/.changeset/honest-numbers-prove.md b/.changeset/honest-numbers-prove.md new file mode 100644 index 00000000..9570e378 --- /dev/null +++ b/.changeset/honest-numbers-prove.md @@ -0,0 +1,5 @@ +--- +"chroma-js": patch +--- + +fix(hcg): return chroma/grayness on [0..1] so rgb2hcg round-trips (#250)