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/.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/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/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/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()); 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}/); + } + } + } + }); + +});