From 344bb32b6eccf9681969724b8723865ce046876a Mon Sep 17 00:00:00 2001 From: Vlad Shilov Date: Tue, 1 Sep 2026 13:09:04 +0200 Subject: [PATCH 1/2] fix(hex): read the alpha byte exactly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parseHex` stored the alpha rounded to two decimals while every other model rounds to three, so 155 of the 256 HEX8 bytes and 10 of the 16 HEX4 digits came back as a different byte: `#cc88` serialized as `#cccc8887`, `#3366cc01` went fully transparent and `#3366ccfe` went opaque. Two decimals cannot address an 8-bit alpha at all — the step is 1/255 and the rounding error is up to 1/200. The byte is now kept exactly, like every other channel, and the rounding moved to output in a shared `roundAlpha`: three digits, except where two name the very same byte. That keeps `0x80` printing `0.5` rather than `0.502`, so nothing that round-trips today changes — verified over 2886 inputs against 11 outputs, where the only rows that move are the 165 that were already broken. It also matches what a browser prints: Chrome 148 serializes those bytes the same way on all 255. The rule looks at the value, not at the notation it arrived in, so an 8-bit ratio handed over directly shortens as well: `{ a: 128 / 255 }` now reports `0.5` where 2.10.0 reported `0.502`. A literal `0.502` is untouched — the two are different numbers, and only a value that is exactly `k / 255` is affected, which is what dividing an 8-bit source gives you. That is 75 of the 256 ratios, and a deliberate change: it is the same color as `#RRGGBB80`, which 2.10.0 already reported as `0.5`, so the two paths agreed on the color and disagreed on how to print it. Both cases are pinned by tests. `ALPHA_PRECISION` had no consumers left, so its rationale moved into `roundAlpha`, which spells out its factors instead of paying for `Math.pow` on every serialization — parsing a HEX into an RGB string is now ~25% faster. Closes #97 --- .changeset/hex-alpha-byte.md | 5 ++ src/colorModels/cmyk.ts | 5 +- src/colorModels/hex.ts | 4 +- src/colorModels/hsl.ts | 5 +- src/colorModels/hsv.ts | 5 +- src/colorModels/hwb.ts | 5 +- src/colorModels/lab.ts | 5 +- src/colorModels/lch.ts | 5 +- src/colorModels/rgb.ts | 5 +- src/colorModels/xyz.ts | 5 +- src/colord.ts | 5 +- src/constants.ts | 6 -- src/helpers.ts | 16 +++++ tests/colord.test.ts | 130 +++++++++++++++++++++++++++++++---- 14 files changed, 159 insertions(+), 47 deletions(-) create mode 100644 .changeset/hex-alpha-byte.md diff --git a/.changeset/hex-alpha-byte.md b/.changeset/hex-alpha-byte.md new file mode 100644 index 0000000..72b10e9 --- /dev/null +++ b/.changeset/hex-alpha-byte.md @@ -0,0 +1,5 @@ +--- +"colord": patch +--- + +Fix: Read the alpha byte of a HEX color exactly, so `#cc88` no longer comes back as `#cccc8887` ❤️ [@spokodev](https://github.com/spokodev) diff --git a/src/colorModels/cmyk.ts b/src/colorModels/cmyk.ts index 5840c90..89bc675 100644 --- a/src/colorModels/cmyk.ts +++ b/src/colorModels/cmyk.ts @@ -1,6 +1,5 @@ import { RgbaColor, InputObject, CmykaColor } from "../types"; -import { ALPHA_PRECISION } from "../constants"; -import { clamp, isPresent, round } from "../helpers"; +import { clamp, isPresent, round, roundAlpha } from "../helpers"; /** * Clamps the CMYK color object values. @@ -21,7 +20,7 @@ export const roundCmyka = (cmyka: CmykaColor): CmykaColor => ({ m: round(cmyka.m, 2), y: round(cmyka.y, 2), k: round(cmyka.k, 2), - a: round(cmyka.a, ALPHA_PRECISION), + a: roundAlpha(cmyka.a), }); /** diff --git a/src/colorModels/hex.ts b/src/colorModels/hex.ts index 02d1289..1f1026b 100644 --- a/src/colorModels/hex.ts +++ b/src/colorModels/hex.ts @@ -38,7 +38,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 ? (digitAt(hex, 4) * 0x11) / 255 : 1, }; } @@ -47,7 +47,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 ? byteAt(hex, 7) / 255 : 1, }; } diff --git a/src/colorModels/hsl.ts b/src/colorModels/hsl.ts index 7f5e87e..704943e 100644 --- a/src/colorModels/hsl.ts +++ b/src/colorModels/hsl.ts @@ -1,6 +1,5 @@ import { InputObject, RgbaColor, HslaColor, HsvaColor } from "../types"; -import { ALPHA_PRECISION } from "../constants"; -import { clamp, clampHue, round, roundHue, isPresent } from "../helpers"; +import { clamp, clampHue, round, roundAlpha, roundHue, isPresent } from "../helpers"; import { hsvaToRgba, rgbaToHsva } from "./hsv"; export const clampHsla = (hsla: HslaColor): HslaColor => ({ @@ -14,7 +13,7 @@ export const roundHsla = (hsla: HslaColor): HslaColor => ({ h: roundHue(hsla.h), s: round(hsla.s), l: round(hsla.l), - a: round(hsla.a, ALPHA_PRECISION), + a: roundAlpha(hsla.a), }); export const parseHsla = ({ h, s, l, a = 1 }: InputObject): RgbaColor | null => { diff --git a/src/colorModels/hsv.ts b/src/colorModels/hsv.ts index 930e7f4..c8295e9 100644 --- a/src/colorModels/hsv.ts +++ b/src/colorModels/hsv.ts @@ -1,6 +1,5 @@ import { InputObject, RgbaColor, HsvaColor } from "../types"; -import { ALPHA_PRECISION } from "../constants"; -import { clamp, clampHue, isPresent, round, roundHue } from "../helpers"; +import { clamp, clampHue, isPresent, round, roundAlpha, roundHue } from "../helpers"; export const clampHsva = (hsva: HsvaColor): HsvaColor => ({ h: clampHue(hsva.h), @@ -13,7 +12,7 @@ export const roundHsva = (hsva: HsvaColor): HsvaColor => ({ h: roundHue(hsva.h), s: round(hsva.s), v: round(hsva.v), - a: round(hsva.a, ALPHA_PRECISION), + a: roundAlpha(hsva.a), }); export const parseHsva = ({ h, s, v, a = 1 }: InputObject): RgbaColor | null => { diff --git a/src/colorModels/hwb.ts b/src/colorModels/hwb.ts index 165179e..3dd4191 100644 --- a/src/colorModels/hwb.ts +++ b/src/colorModels/hwb.ts @@ -1,6 +1,5 @@ import { RgbaColor, HwbaColor, InputObject } from "../types"; -import { ALPHA_PRECISION } from "../constants"; -import { clamp, clampHue, round, roundHue, isPresent } from "../helpers"; +import { clamp, clampHue, round, roundAlpha, roundHue, isPresent } from "../helpers"; import { hsvaToRgba, rgbaToHsva } from "./hsv"; export const clampHwba = (hwba: HwbaColor): HwbaColor => ({ @@ -14,7 +13,7 @@ export const roundHwba = (hwba: HwbaColor): HwbaColor => ({ h: roundHue(hwba.h), w: round(hwba.w), b: round(hwba.b), - a: round(hwba.a, ALPHA_PRECISION), + a: roundAlpha(hwba.a), }); export const rgbaToHwba = (rgba: RgbaColor): HwbaColor => { diff --git a/src/colorModels/lab.ts b/src/colorModels/lab.ts index 3aad187..cf6f87b 100644 --- a/src/colorModels/lab.ts +++ b/src/colorModels/lab.ts @@ -1,6 +1,5 @@ import { RgbaColor, LabaColor, InputObject } from "../types"; -import { ALPHA_PRECISION } from "../constants"; -import { clamp, isPresent, round } from "../helpers"; +import { clamp, isPresent, round, roundAlpha } from "../helpers"; import { D50, rgbaToXyza, xyzaToRgba } from "./xyz"; // Conversion factors from https://en.wikipedia.org/wiki/CIELAB_color_space @@ -26,7 +25,7 @@ export const roundLaba = (laba: LabaColor): LabaColor => ({ l: round(laba.l, 2), a: round(laba.a, 2), b: round(laba.b, 2), - alpha: round(laba.alpha, ALPHA_PRECISION), + alpha: roundAlpha(laba.alpha), }); export const parseLaba = ({ l, a, b, alpha = 1 }: InputObject): RgbaColor | null => { diff --git a/src/colorModels/lch.ts b/src/colorModels/lch.ts index 4e203bc..bb5ca8e 100644 --- a/src/colorModels/lch.ts +++ b/src/colorModels/lch.ts @@ -1,6 +1,5 @@ import { RgbaColor, InputObject, LchaColor } from "../types"; -import { ALPHA_PRECISION } from "../constants"; -import { clamp, clampHue, isPresent, round, roundHue } from "../helpers"; +import { clamp, clampHue, isPresent, round, roundAlpha, roundHue } from "../helpers"; import { labaToRgba, rgbaToLaba } from "./lab"; /** @@ -19,7 +18,7 @@ export const roundLcha = (laba: LchaColor): LchaColor => ({ l: round(laba.l, 2), c: round(laba.c, 2), h: roundHue(laba.h, 2), - a: round(laba.a, ALPHA_PRECISION), + a: roundAlpha(laba.a), }); export const parseLcha = ({ l, c, h, a = 1 }: InputObject): RgbaColor | null => { diff --git a/src/colorModels/rgb.ts b/src/colorModels/rgb.ts index 77ac191..b13caa2 100644 --- a/src/colorModels/rgb.ts +++ b/src/colorModels/rgb.ts @@ -1,6 +1,5 @@ import { InputObject, RgbaColor } from "../types"; -import { ALPHA_PRECISION } from "../constants"; -import { round, clamp, isPresent } from "../helpers"; +import { round, roundAlpha, clamp, isPresent } from "../helpers"; export const clampRgba = (rgba: RgbaColor): RgbaColor => ({ r: clamp(rgba.r, 0, 255), @@ -13,7 +12,7 @@ export const roundRgba = (rgba: RgbaColor): RgbaColor => ({ r: round(rgba.r), g: round(rgba.g), b: round(rgba.b), - a: round(rgba.a, ALPHA_PRECISION), + a: roundAlpha(rgba.a), }); export const parseRgba = ({ r, g, b, a = 1 }: InputObject): RgbaColor | null => { diff --git a/src/colorModels/xyz.ts b/src/colorModels/xyz.ts index beb526a..e69cdd9 100644 --- a/src/colorModels/xyz.ts +++ b/src/colorModels/xyz.ts @@ -1,6 +1,5 @@ import { InputObject, RgbaColor, XyzColor, XyzaColor } from "../types"; -import { ALPHA_PRECISION } from "../constants"; -import { clamp, isPresent, round } from "../helpers"; +import { clamp, isPresent, round, roundAlpha } from "../helpers"; import { clampRgba, linearizeRgbChannel, unlinearizeRgbChannel } from "./rgb"; // Theoretical light source that approximates "warm daylight" and follows the CIE standard. @@ -25,7 +24,7 @@ export const roundXyza = (xyza: XyzaColor): XyzaColor => ({ x: round(xyza.x, 2), y: round(xyza.y, 2), z: round(xyza.z, 2), - a: round(xyza.a, ALPHA_PRECISION), + a: roundAlpha(xyza.a), }); export const parseXyza = ({ x, y, z, a = 1 }: InputObject): RgbaColor | null => { diff --git a/src/colord.ts b/src/colord.ts index 377c5c9..0ffbb0b 100644 --- a/src/colord.ts +++ b/src/colord.ts @@ -1,6 +1,5 @@ import { Input, AnyColor, RgbaColor, HslaColor, HsvaColor } from "./types"; -import { round, roundHue } from "./helpers"; -import { ALPHA_PRECISION } from "./constants"; +import { round, roundAlpha, roundHue } from "./helpers"; import { parse } from "./parse"; import { rgbaToHex } from "./colorModels/hex"; import { roundRgba } from "./colorModels/rgb"; @@ -162,7 +161,7 @@ export class Colord { public alpha(value: number): Colord; public alpha(value?: number): Colord | number { if (typeof value === "number") return colord(changeAlpha(this.rgba, value)); - return round(this.rgba.a, ALPHA_PRECISION); + return roundAlpha(this.rgba.a); } /** diff --git a/src/constants.ts b/src/constants.ts index fa8f662..6e16362 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,9 +1,3 @@ -/** - * We used to work with 2 digits after the decimal point, but it wasn't accurate enough, - * so the library produced colors that were perceived differently. - */ -export const ALPHA_PRECISION = 3; - /** * Valid CSS units. * https://developer.mozilla.org/en-US/docs/Web/CSS/angle diff --git a/src/helpers.ts b/src/helpers.ts index c046014..986cb48 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -14,6 +14,22 @@ export const floor = (number: number, digits = 0, base = Math.pow(10, digits)): return Math.floor(base * number) / base + 0; }; +/** + * Rounds an alpha value for output. + * + * Two digits are not enough: an alpha read from a HEX is an exact 8-bit ratio, and + * rounding 155 of the 256 of them that way sends the color back as a different byte. + * Three digits hold every byte, but the short form is kept wherever it names the very + * same one, which is how 0x80 stays 0.5 rather than 0.502 — the way browsers print it. + * + * The factors are spelled out instead of calling `round`, which recomputes + * `Math.pow(10, digits)` on every call, and this runs on the way out of every model. + */ +export const roundAlpha = (alpha: number): number => { + const short = Math.round(alpha * 100) / 100; + return Math.round(short * 255) === alpha * 255 ? short : Math.round(alpha * 1000) / 1000; +}; + /** * Clamps a value between an upper and lower bound. * We use ternary operators because it makes the minified code diff --git a/tests/colord.test.ts b/tests/colord.test.ts index 19d9378..a97db17 100644 --- a/tests/colord.test.ts +++ b/tests/colord.test.ts @@ -142,12 +142,14 @@ it("Parses and serializes every HEX digit exactly", () => { expect(colord(hex6).toRgb()).toMatchObject({ r: byte, g: byte, b: byte, a: 1 }); expect(colord(hex6.toUpperCase()).toRgb()).toMatchObject({ r: byte, g: byte, b: byte, a: 1 }); expect(colord({ r: byte, g: byte, b: byte, a: 1 }).toHex()).toBe(hex6); - expect(colord(`${hex6}${digits}`).toRgb()).toMatchObject({ - r: byte, - g: byte, - b: byte, - a: round(byte / 255, 2), - }); + // The alpha byte is read exactly, so it survives too — on its own and through + // an RGB string (255 is left out: an opaque color serializes back to HEX6). + const hex8 = `${hex6}${digits}`; + expect(colord(hex8).toRgb()).toMatchObject({ r: byte, g: byte, b: byte }); + if (byte < 255) { + expect(colord(hex8).toHex()).toBe(hex8); + expect(colord(colord(hex8).toRgbString()).toHex()).toBe(hex8); + } } // Every nibble is duplicated into a full byte by the HEX3/HEX4 shorthand. @@ -160,12 +162,8 @@ it("Parses and serializes every HEX digit exactly", () => { b: byte, a: 1, }); - expect(colord(`#000${digit}`).toRgb()).toMatchObject({ - r: 0, - g: 0, - b: 0, - a: round(byte / 255, 2), - }); + expect(colord(`#000${digit}`).toRgb()).toMatchObject({ r: 0, g: 0, b: 0 }); + if (nibble < 15) expect(colord(`#000${digit}`).toHex()).toBe(`#000000${digit}${digit}`); } // A HEX string with an unsupported digit count is not a color. @@ -422,6 +420,114 @@ it("Checks colors for equality", () => { } }); +it("Keeps every HEX alpha byte intact through the core conversions", () => { + for (let byte = 0; byte < 256; byte++) { + const hex8 = `#3366cc${byte.toString(16).padStart(2, "0")}`; + const color = colord(hex8); + const alpha = color.alpha(); + + // Whatever the alpha is printed as, it has to stand for the byte it came from + expect(round(alpha * 255)).toBe(byte); + + // Every core model reports that same alpha, and reading any of them back + // yields it again — an alpha never drifts by travelling through a notation + const notations: AnyColor[] = [ + color.toHex(), + color.toRgb(), + color.toRgbString(), + color.toHsl(), + color.toHslString(), + color.toHsv(), + ]; + + expect(color.toRgb().a).toBe(alpha); + expect(color.toHsl().a).toBe(alpha); + expect(color.toHsv().a).toBe(alpha); + + for (const notation of notations) { + expect(colord(notation).alpha()).toBe(alpha); + expect(round(colord(notation).alpha() * 255)).toBe(byte); + } + + // RGB carries the channels losslessly too, so the whole color survives it + expect(colord(color.toRgb()).toHex()).toBe(color.toHex()); + expect(colord(color.toRgbString()).toHex()).toBe(color.toHex()); + } +}); + +it("Shortens an alpha value only when the short form means the same byte", () => { + // https://github.com/omgovich/colord/issues/97 + expect(colord("#cc88").toHex()).toBe("#cccc8888"); + + for (let byte = 0; byte < 256; byte++) { + const hex8 = `#000000${byte.toString(16).padStart(2, "0")}`; + const short = round(byte / 255, 2); + // 101 of the 256 bytes are expressible in two digits and have always been + // printed that way; the other 155 need all three to point back at the byte + const expected = round(short * 255) === byte ? short : round(byte / 255, 3); + expect(colord(hex8).alpha()).toBe(expected); + } + + // An alpha that is not an 8-bit ratio is never pulled to a shorter value + for (let permille = 0; permille <= 1000; permille++) { + const value = round(permille / 1000, 3); + expect(colord(`rgba(0, 0, 0, ${value})`).alpha()).toBe(value); + expect(colord({ r: 0, g: 0, b: 0, a: value }).toRgb().a).toBe(value); + } + + // The rule is about the value, not about the notation it arrived in: an 8-bit + // ratio handed over directly is the same alpha as the HEX byte, and prints alike. + // This is a change from 2.10.0, where the two disagreed — `#00000080` said 0.5 + // and `{ a: 128 / 255 }` said 0.502, for one and the same color. + for (let byte = 0; byte < 256; byte++) { + const exact = byte / 255; + const fromHex = colord(`#000000${byte.toString(16).padStart(2, "0")}`).alpha(); + expect(colord({ r: 0, g: 0, b: 0, a: exact }).alpha()).toBe(fromHex); + expect(colord(`rgba(0, 0, 0, ${exact})`).alpha()).toBe(fromHex); + } +}); + +it("Never emits an alpha value longer than 3 digits after the decimal point", () => { + // Every one of these needs rounding on the way out: raw 8-bit ratios, the + // classic float noise, and thirds that never terminate + const uglyAlphas = [ + 1 / 255, + 128 / 255, + 254 / 255, + 1 / 3, + 2 / 3, + 0.1 + 0.2, + 0.123456789, + 0.0039000000000000003, + Math.PI / 4, + ]; + + const inputs: AnyColor[] = []; + for (const alpha of uglyAlphas) { + inputs.push({ r: 51, g: 102, b: 204, a: alpha }); + inputs.push(`rgba(51, 102, 204, ${alpha})`); + inputs.push(`hsla(210, 60%, 50%, ${alpha})`); + inputs.push(`hsl(210 60% 50% / ${alpha})`); + } + for (const input of inputs) { + // Manipulations must not reintroduce the precision the parser dropped + for (const color of [ + colord(input), + colord(input).lighten(1 / 3), + colord(input).alpha(colord(input).alpha() * (2 / 3)), + colord(input).invert(), + ]) { + for (const value of [color.alpha(), color.toRgb().a, color.toHsl().a, color.toHsv().a]) { + expect(round(value, 3)).toBe(value); + } + // Nothing a serializer prints carries a longer fraction, alpha included + for (const string of [color.toRgbString(), color.toHslString(), color.toHex()]) { + expect(string).not.toMatch(/\.\d{4}/); + } + } + } +}); + it("Generates a random color", () => { expect(random()).toBeInstanceOf(Colord); expect(random().toHex()).not.toBe(random().toHex()); From 5a8145fffbbbd17148b54eb564c0fbe996c84623 Mon Sep 17 00:00:00 2001 From: Vlad Shilov Date: Tue, 1 Sep 2026 13:09:04 +0200 Subject: [PATCH 2/2] fix(minify): judge an alpha hex by the same rounding rule as the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The losslessness check carried its own hard-coded two decimals, a copy of the assumption the HEX parser just lost. With the parser fixed it turned down the byte that holds the color exactly, and it had been waving through bytes that did not: `minify("#cc88", { alphaHex: true })` returned `#cccc8887`. Checking with `roundAlpha` instead ties the plugin to the rule everything else prints through. Colors read from a HEX keep their own byte, and the alphas an 8-bit hex can hold go from 99 of 999 to 254 — `rgba(0,0,0,.075)` now minifies to `#00000013`, which reads back as exactly `.075`. `.5` still becomes `#ff000080`, so nothing that shortens today stops shortening. The method could also return `undefined` rather than a string once every notation was either switched off or turned down as lossy; the RGB form is the one that can hold any color, so it is the answer in that case. The HSL variant is never checked for losslessness and can still name a different color — that is an older, separate issue and is left alone here. --- .changeset/minify-alpha-hex.md | 5 ++ src/plugins/minify.ts | 18 +++--- tests/plugins.test.ts | 114 ++++++++++++++++++++++++++++++++- 3 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 .changeset/minify-alpha-hex.md diff --git a/.changeset/minify-alpha-hex.md b/.changeset/minify-alpha-hex.md new file mode 100644 index 0000000..6d48d29 --- /dev/null +++ b/.changeset/minify-alpha-hex.md @@ -0,0 +1,5 @@ +--- +"colord": patch +--- + +Fix: Stop `minify` from turning down an alpha hex that holds the color exactly, and from returning nothing at all when every notation is switched off diff --git a/src/plugins/minify.ts b/src/plugins/minify.ts index 231b322..e75dade 100644 --- a/src/plugins/minify.ts +++ b/src/plugins/minify.ts @@ -1,6 +1,6 @@ import { Colord } from "../colord"; import { Plugin } from "../extend"; -import { round } from "../helpers"; +import { roundAlpha } from "../helpers"; interface MinificationOptions { hex?: boolean; @@ -28,8 +28,10 @@ const minifyPlugin: Plugin = (ColordClass): void => { const alpha = instance.alpha(); const [, r1, r2, g1, g2, b1, b2, a1, a2] = hex.split(""); - // Make sure conversion is lossless - if (alpha > 0 && alpha < 1 && round(parseInt(a1 + a2, 16) / 255, 2) !== alpha) return null; + // Make sure conversion is lossless: the alpha byte has to read back as the very + // alpha this color has. It is the same rounding rule the whole library prints + // through, so 0x88 keeps its own byte instead of drifting into 0x87. + if (alpha > 0 && alpha < 1 && roundAlpha(parseInt(a1 + a2, 16) / 255) !== alpha) return null; // Check if the string can be shorten if (r1 === r2 && g1 === g2 && b1 === b2) { @@ -92,10 +94,10 @@ const minifyPlugin: Plugin = (ColordClass): void => { if (hex) variants.push(hex); } - // rgb() functional notation with no spaces - if (settings.rgb) { - variants.push(a === 1 ? `rgb(${r},${g},${b})` : `rgba(${r},${g},${b},${a})`); - } + // rgb() functional notation with no spaces. It is the one form that can hold + // any color, so it doubles as the answer when every other one is turned down + const rgbVariant = a === 1 ? `rgb(${r},${g},${b})` : `rgba(${r},${g},${b},${a})`; + if (settings.rgb) variants.push(rgbVariant); // hsl() functional notation with no spaces if (settings.hsl) { @@ -111,7 +113,7 @@ const minifyPlugin: Plugin = (ColordClass): void => { if (name) variants.push(name); } - return findShortestString(variants); + return variants.length ? findShortestString(variants) : rgbVariant; }; }; diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts index 1a8458b..878d719 100644 --- a/tests/plugins.test.ts +++ b/tests/plugins.test.ts @@ -1,4 +1,5 @@ -import { colord, getFormat, extend, Colord } from "../src/"; +import { colord, getFormat, extend, Colord, AnyColor } from "../src/"; +import { round } from "../src/helpers"; import a11yPlugin from "../src/plugins/a11y"; import cmykPlugin from "../src/plugins/cmyk"; import harmoniesPlugin, { HarmonyType } from "../src/plugins/harmonies"; @@ -343,16 +344,46 @@ describe("minify", () => { expect(colord("rgba(200,200,200,0.55)").minify({ hsl: false })).toBe("rgba(200,200,200,.55)"); }); + it("Never returns a color other than the one it was given", () => { + // The HSL variant rounds its channels and can name a different color; that is a + // separate, older issue, so this locks the notations that are meant to be exact + for (let byte = 1; byte < 255; byte += 7) { + const digits = byte.toString(16).padStart(2, "0"); + for (const rgb of ["#123456", "#3366cc", "#7f8081", "#000000"]) { + for (const options of [{ alphaHex: true, hsl: false }, { hsl: false }]) { + const color = colord(`${rgb}${digits}`); + expect(colord(color.minify(options)).toRgbString()).toBe(color.toRgbString()); + } + } + } + }); + it("Supports alpha hexes", () => { expect(colord("hsla(0, 100%, 50%, .5)").minify()).toBe("rgba(255,0,0,.5)"); expect(colord("hsla(0, 100%, 50%, .5)").minify({ alphaHex: true })).toBe("#ff000080"); expect(colord("rgba(0, 0, 255, 0.4)").minify({ alphaHex: true })).toBe("#00f6"); + // A color read from a HEX keeps its own byte instead of drifting to a neighbour + expect(colord("#cc88").minify({ alphaHex: true })).toBe("#cc88"); }); it("Performs lossless minification (handles alpha hex issues)", () => { expect(colord("rgba(0,0,0,.4)").minify({ alphaHex: true })).toBe("#0006"); - expect(colord("rgba(0,0,0,.075)").minify({ alphaHex: true })).toBe("rgba(0,0,0,.075)"); + // 0x13 reads back as exactly `.075`, so the shorter form is the same color... + expect(colord("rgba(0,0,0,.075)").minify({ alphaHex: true })).toBe("#00000013"); + // ...while no byte reads back as `.515`: 0x83 would come back as `.514` expect(colord("hsla(0,0%,50%,.515)").minify({ alphaHex: true })).toBe("hsla(0,0%,50%,.515)"); + expect(colord("#0000000d").minify({ alphaHex: true })).toBe("#0000000d"); + // With every other notation turned down or switched off, RGB is the answer: + // it is the only one that can hold any color at all + expect(colord("hsla(0,0%,50%,.515)").minify({ rgb: false, hsl: false })).toBe( + "rgba(128,128,128,.515)" + ); + // No alpha is ever traded for a byte that reads back as a different one + for (let permille = 1; permille < 1000; permille++) { + const alpha = permille / 1000; + const minified = colord(`rgba(0,0,0,${alpha})`).minify({ alphaHex: true }); + expect(colord(minified).alpha()).toBe(alpha); + } }); it("Supports names", () => { @@ -522,3 +553,82 @@ describe("xyz", () => { expect(getFormat({ x: 50, y: 50, z: 50 })).toBe("xyz"); }); }); + +describe("alpha channel", () => { + extend([cmykPlugin, hwbPlugin, labPlugin, lchPlugin, minifyPlugin, xyzPlugin]); + + it("Survives every plugin conversion, for every HEX alpha byte", () => { + for (let byte = 0; byte < 256; byte++) { + const hex8 = `#3366cc${byte.toString(16).padStart(2, "0")}`; + const color = colord(hex8); + const alpha = color.alpha(); + + // The models below round their channels, so only the alpha is expected to + // come back untouched — but it has to, and it has to still mean its byte + const notations: AnyColor[] = [ + color.toHwb(), + color.toHwbString(), + color.toLab(), + color.toLch(), + color.toLchString(), + color.toXyz(), + color.toCmyk(), + color.toCmykString(), + ]; + + expect(color.toHwb().a).toBe(alpha); + expect(color.toLab().alpha).toBe(alpha); + expect(color.toLch().a).toBe(alpha); + expect(color.toXyz().a).toBe(alpha); + expect(color.toCmyk().a).toBe(alpha); + + for (const notation of notations) { + expect(colord(notation).alpha()).toBe(alpha); + expect(Math.round(colord(notation).alpha() * 255)).toBe(byte); + } + } + }); + + it("Never emits an alpha value longer than 3 digits, in any plugin", () => { + extend([harmoniesPlugin, mixPlugin]); + + const uglyAlphas = [1 / 255, 128 / 255, 254 / 255, 1 / 3, 0.1 + 0.2, 0.123456789]; + const inputs: AnyColor[] = []; + for (const alpha of uglyAlphas) inputs.push({ r: 51, g: 102, b: 204, a: alpha }); + for (let byte = 0; byte < 256; byte += 7) { + inputs.push(`#3366cc${byte.toString(16).padStart(2, "0")}`); + } + + for (const input of inputs) { + const source = colord(input); + // Whatever a plugin hands back is a color too, and has to obey the same rule + for (const color of [ + source, + source.mix("#fff", 1 / 3), + source.tints(3)[1], + ...source.harmonies("triadic"), + ]) { + for (const value of [ + color.alpha(), + color.toHwb().a, + color.toLab().alpha, + color.toLch().a, + color.toXyz().a, + color.toCmyk().a, + ]) { + expect(round(value, 3)).toBe(value); + } + + for (const string of [ + color.toHwbString(), + color.toLchString(), + color.toCmykString(), + color.minify({ alphaHex: true }), + ]) { + expect(string).not.toMatch(/\.\d{4}/); + } + } + } + }); + +});