diff --git a/.changeset/fair-panthers-learn.md b/.changeset/fair-panthers-learn.md new file mode 100644 index 0000000..f825b0d --- /dev/null +++ b/.changeset/fair-panthers-learn.md @@ -0,0 +1,5 @@ +--- +"@sardine/colour": minor +--- + +feat: Add isHexColour diff --git a/.changeset/nervous-ears-burn.md b/.changeset/nervous-ears-burn.md new file mode 100644 index 0000000..74a35ad --- /dev/null +++ b/.changeset/nervous-ears-burn.md @@ -0,0 +1,5 @@ +--- +"@sardine/colour": minor +--- + +feat: Increase coverage of different CSS RGB formats diff --git a/.changeset/rude-parrots-press.md b/.changeset/rude-parrots-press.md new file mode 100644 index 0000000..4fda232 --- /dev/null +++ b/.changeset/rude-parrots-press.md @@ -0,0 +1,5 @@ +--- +"@sardine/colour": minor +--- + +feat: Add isCSSRGBDarkColour diff --git a/.changeset/slimy-drinks-invent.md b/.changeset/slimy-drinks-invent.md new file mode 100644 index 0000000..dda7f3e --- /dev/null +++ b/.changeset/slimy-drinks-invent.md @@ -0,0 +1,5 @@ +--- +"@sardine/colour": minor +--- + +Add isCSSRGBColour diff --git a/.changeset/spotty-pears-confess.md b/.changeset/spotty-pears-confess.md new file mode 100644 index 0000000..41a51e6 --- /dev/null +++ b/.changeset/spotty-pears-confess.md @@ -0,0 +1,5 @@ +--- +"@sardine/colour": minor +--- + +feat: Add isCSSNamedDarkColour diff --git a/src/assertions.ts b/src/assertions.ts new file mode 100644 index 0000000..fddcdd0 --- /dev/null +++ b/src/assertions.ts @@ -0,0 +1,16 @@ +import { + cssRGBARegex, + hexRegex, + hexAlphaRegex, + shortAlphaHexRegex, + shortHexRegex, +} from "./util/regexers.js"; + +export const isCSSRGBColour = (colour: string): boolean => + !!colour.match(cssRGBARegex); + +export const isHexColour = (colour: string) => + !!colour.match(hexRegex) || + !!colour.match(hexAlphaRegex) || + !!colour.match(shortAlphaHexRegex) || + !!colour.match(shortHexRegex); diff --git a/src/converters.ts b/src/converters.ts index 13450ac..799d8de 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -1,7 +1,7 @@ import type { LabColour, NamedCSSColour, RGBColour, XYZColour } from "./types"; -import { constrainLab, linearRGB } from "./util/index.js"; +import { clamp, constrainLab, linearRGB } from "./util/index.js"; import { namedCSSColours } from "./util/namedCSSColours.js"; -import { cssRGBA, hexAlphaRegex, hexRegex, shortAlphaHexRegex, shortHexRegex } from "./util/regexers.js"; +import { cssRGBARegex, hexAlphaRegex, hexRegex, shortAlphaHexRegex, shortHexRegex } from "./util/regexers.js"; /** * Converts sRGB colour space to XYZ. @@ -71,8 +71,11 @@ export function convertRGBtoLab(colour: RGBColour): LabColour { * @returns - An hexadecimal string */ export function convertRGBtoHex({R, G, B, A}: RGBColour): string { - const hex = (n: number) => n.toString(16).padStart(2, '0'); - return '#'+hex(R)+hex(G)+hex(B)+(A ? hex(Math.round(A * 255)) : ''); + const hex = (n: number) =>{ + const value = clamp(n, 0, 255); + return value.toString(16).padStart(2, '0') + }; + return `#${hex(R)}${hex(G)}${hex(B)}${(A ? hex(Math.round(A * 255)) : '')}`; } /** @@ -81,23 +84,51 @@ export function convertRGBtoHex({R, G, B, A}: RGBColour): string { * * - `rgb(0,0,0)` * - `rgba(0,0,0,0.4)` + * - `rgb(0 0 0)` + * - `rgba(0 0 0 / 0.4)` * * @returns {string} - An hexadecimal string */ export function convertCSSRGBtoHex(colour:string): string { - const match = colour.match(cssRGBA); + const rgb: RGBColour = convertCSSRGBtoRGB(colour); + return convertRGBtoHex(rgb); +} + +/** + * Converts CSS RGB colour format into RGB colour object. + * @param {string} colour - A CSS RGB colour in the format: + * + * - `rgb(0,0,0)` + * - `rgba(0,0,0,0.4)` + * - `rgb(0 0 0)` + * - `rgba(0 0 0 / 0.4)` + * + * @returns {RGBColour} - RGB colour object. + */ +export function convertCSSRGBtoRGB(colour: string): RGBColour { + const match = colour.match(cssRGBARegex); if (!match) { throw new Error(`convertCSSRGBtoHex expects a valid CSS RGB string but got ${colour}`)} const rgbNumber = (n: string) : number => parseInt(n, 10); const alphaNumber = (n: string) : number => parseFloat(n) ?? undefined; - const rgb: RGBColour = { + return { R:rgbNumber(match[1] as string), G:rgbNumber(match[2] as string), B:rgbNumber(match[3] as string), A:alphaNumber(match[4] as string) }; - return convertRGBtoHex(rgb); } +/** + * Converts an hexadecimal colour into RGB colour object. + * @param {string} hex - An hexadecimal colour in the format: + * + * - `#000` + * - `#102030` + * - `#ffff` + * - `#102030ff` + * + * @returns {RGBColour} - RGB colour object. + */ export function convertHextoRGB(hex: string): RGBColour { if (typeof hex !== "string") { throw new Error(`convertHextoRGB expects a string but got a ${typeof hex}`); diff --git a/src/getSRGBLuminanceFromHex.ts b/src/getSRGBLuminanceFromHex.ts index f797815..7b55379 100644 --- a/src/getSRGBLuminanceFromHex.ts +++ b/src/getSRGBLuminanceFromHex.ts @@ -1,5 +1,5 @@ import { convertHextoRGB } from "./converters.js"; -import { linearRGB } from "./util/index.js"; +import { getSRGBLuminanceFromRGB } from "./getSRGBLuminanceFromRGB.js"; import type { WCAG } from "./types"; /** @@ -13,15 +13,8 @@ import type { WCAG } from "./types"; * https://www.w3.org/WAI/GL/wiki/Relative_luminance * @param colour an hexadecimal colour */ -export const getSRGBLuminanceFromHex = ( - colour: string, - standard?: WCAG -) => { - const isWCAG21 = standard === "WCAG2.1"; +export function getSRGBLuminanceFromHex(colour: string, + standard?: WCAG) { const rgbColor = convertHextoRGB(colour); - const r = linearRGB(rgbColor.R, isWCAG21); - const g = linearRGB(rgbColor.G, isWCAG21); - const b = linearRGB(rgbColor.B, isWCAG21); - - return 0.2126 * r + 0.7152 * g + 0.0722 * b; -}; + return getSRGBLuminanceFromRGB(rgbColor, standard); +} diff --git a/src/getSRGBLuminanceFromRGB.ts b/src/getSRGBLuminanceFromRGB.ts new file mode 100644 index 0000000..a065867 --- /dev/null +++ b/src/getSRGBLuminanceFromRGB.ts @@ -0,0 +1,25 @@ +import { linearRGB } from "./util/index.js"; +import type { RGBColour, WCAG } from "./types"; + +/** + * Returns the relative luminance of a colour in the sRGB space. + * + * The calculations are compatible with WCAG 3.0 as it aligns with the sRGB spec + * and difference to WCAG 2.1 is minimal in a 8 bit channel. + * + * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef + * + * https://www.w3.org/WAI/GL/wiki/Relative_luminance + * @param colour an hexadecimal colour + */ +export function getSRGBLuminanceFromRGB( + { R, G, B }: RGBColour, + standard?: WCAG, +) { + const isWCAG21 = standard === "WCAG2.1"; + const r = linearRGB(R, isWCAG21); + const g = linearRGB(G, isWCAG21); + const b = linearRGB(B, isWCAG21); + + return (0.2126 * r) + (0.7152 * g) + (0.0722 * b); +} diff --git a/src/index.ts b/src/index.ts index 5147a07..da37b53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,5 +10,9 @@ export { } from "./converters.js"; export { RGBdistance } from "./RGBdistance.js"; export { getSRGBLuminanceFromHex } from "./getSRGBLuminanceFromHex.js"; +export { getSRGBLuminanceFromRGB } from "./getSRGBLuminanceFromRGB.js"; export { pickHexColourContrast } from "./pickHexColourContrast.js"; +export { isCSSRGBColour } from "./assertions.js"; +export { isCSSNamedDarkColour } from "./isCSSNameDarkColour.js"; +export { isCSSRGBDarkColour } from "./isCSSRGBDarkColour.js"; export { isHexDarkColour } from "./isHexDarkColour.js"; diff --git a/src/isCSSNameDarkColour.ts b/src/isCSSNameDarkColour.ts new file mode 100644 index 0000000..ebdd4b8 --- /dev/null +++ b/src/isCSSNameDarkColour.ts @@ -0,0 +1,17 @@ +import { convertNamedCSSColourtoHex } from "./converters.js"; +import { isHexDarkColour } from "./isHexDarkColour.js"; +import type { NamedCSSColour, WCAG } from "./types"; + +/** + * Evaluates if a named CSS colour is dark by measuring the contrast ratio against black and white + * @param {string} name - A named CSS colour, ie: `hotpink` + * @param {"WCAG2.1" | "WCAG3.0"} standard - Evaluate agains "WCAG2.1" or "WCAG3.0" + * @returns {boolean | undefined} Returns `true`, `false` or `undefined` if name is not a valid CSS named colour + */ +export function isCSSNamedDarkColour(name: NamedCSSColour, standard: WCAG): boolean | undefined { + const hex = convertNamedCSSColourtoHex(name); + if (hex) { + return isHexDarkColour(hex, standard); + } + return undefined +} diff --git a/src/isCSSRGBDarkColour.ts b/src/isCSSRGBDarkColour.ts new file mode 100644 index 0000000..54c1be1 --- /dev/null +++ b/src/isCSSRGBDarkColour.ts @@ -0,0 +1,14 @@ +import { convertCSSRGBtoRGB } from "./converters.js"; +import { getSRGBLuminanceFromRGB } from "./getSRGBLuminanceFromRGB.js"; +import type { WCAG } from "./types"; + +export function isCSSRGBDarkColour(colour: string, standard: WCAG): boolean { + const rgb = convertCSSRGBtoRGB(colour); + const colourLuminance = getSRGBLuminanceFromRGB(rgb, standard); + // We know white luminance is 1 so we can pre-calculate the whiteLuminance to 1.05 (1 + 0.05) + const whiteContrast = 1.05 / colourLuminance; + // We know black luminance is 0 so we can pre-calculate the blackLuminance to 0.05 (0 + 0.05) + const blackContrast = colourLuminance / 0.05; + + return whiteContrast > blackContrast; +} diff --git a/src/isHexDarkColour.ts b/src/isHexDarkColour.ts index 869e7c5..df265c0 100644 --- a/src/isHexDarkColour.ts +++ b/src/isHexDarkColour.ts @@ -7,7 +7,7 @@ import { WCAG } from "./types"; * @param {"WCAG2.1" | "WCAG3.0"} standard - Evaluate agains "WCAG2.1" or "WCAG3.0" * @returns {boolean} Returns either `true` or `false` */ -export const isHexDarkColour = (colour: string, standard: WCAG): boolean => { +export function isHexDarkColour(colour: string, standard: WCAG): boolean { const colourLuminance = getSRGBLuminanceFromHex(colour, standard) + 0.05; // We know white luminance is 1 so we can pre-calculate the whiteLuminance to 1.05 (1 + 0.05) @@ -16,4 +16,4 @@ export const isHexDarkColour = (colour: string, standard: WCAG): boolean => { const blackContrast = colourLuminance / 0.05; return whiteContrast > blackContrast; -}; +} diff --git a/src/tests/assertions.test.ts b/src/tests/assertions.test.ts new file mode 100644 index 0000000..e26548a --- /dev/null +++ b/src/tests/assertions.test.ts @@ -0,0 +1,36 @@ +import test from "ava"; +import { isCSSRGBColour, isHexColour } from "../assertions.js"; + +test("assert true if string is in the CSS RGB format", ({ is }) => { + is(isCSSRGBColour("rgb(12, 23, 111)"), true); +}); + +test("assert false if string is not in the CSS RGB format", ({ is }) => { + is(isCSSRGBColour("zzz( 23, 111, 87)"), false); +}); + +test.skip("assert false if string doesn't have 3 colour values", ({ is }) => { + is(isCSSRGBColour("rgb( 23, 111)"), false); +}); + +test("assert true if string is a short hexadecimal colour", ({ is }) => { + is(isHexColour("#333"), true); +}); + +test("assert true if string is a six digital hexadecimal colour", ({ is }) => { + is(isHexColour("#333000"), true); +}); + +test("assert true if string is a short hexadecimal colour with alpha", ({ + is, +}) => { + is(isHexColour("#333f"), true); +}); + +test("assert true if string is an hexadecimal colour with alpha", ({ is }) => { + is(isHexColour("#333000ff"), true); +}); + +test("assert false if string is not a valid hexadecimal colour", ({ is }) => { + is(isHexColour("#333HH0ff"), false); +}); diff --git a/src/tests/converters.test.ts b/src/tests/converters.test.ts index c6058ba..9d868bb 100644 --- a/src/tests/converters.test.ts +++ b/src/tests/converters.test.ts @@ -198,6 +198,46 @@ test("converts CSS RGBA format to hexadecimal colour with alpha channel", ({ is(convertCSSRGBtoHex(RGBA), expectedHex); }); +test("converts CSS RGBA format to hexadecimal colour with short alpha channel", ({ + is +}) => { + const expectedHex = "#ffffff80"; + const RGBA = 'rgba(255,255,255,.5)'; + is(convertCSSRGBtoHex(RGBA), expectedHex); +}); + +test("converts CSS RGB format to hexadecimal colour but clamps max channel value at 255", ({ + is +}) => { + const expectedHex = "#ffffff"; + const RGBA = 'rgba(300,500,900)'; + is(convertCSSRGBtoHex(RGBA), expectedHex); +}); + +test("converts CSS RGB format to hexadecimal colour but clamps min channel value at 0", ({ + is +}) => { + const expectedHex = "#000000"; + const RGB = 'rgb(-300,-500,-900)'; + is(convertCSSRGBtoHex(RGB), expectedHex); +}); + +test("converts CSS RGB with space separated values to hexadecimal colour", ({ + is +}) => { + const expectedHex = "#ffffff"; + const RGB = 'rgb(255 255 255)'; + is(convertCSSRGBtoHex(RGB), expectedHex); +}); + +test("converts CSS RGBA with space separated values and forward slash for the alpha channel to hexadecimal colour", ({ + is +}) => { + const expectedHex = "#ffffff80"; + const RGBA = 'rgba(255 255 255 / 0.5)'; + is(convertCSSRGBtoHex(RGBA), expectedHex); +}); + test("throws an error if not passing a valid CSS RGB format", ({ is, throws, diff --git a/src/tests/isCSSNameDarkColour.test.ts b/src/tests/isCSSNameDarkColour.test.ts new file mode 100644 index 0000000..eadcef6 --- /dev/null +++ b/src/tests/isCSSNameDarkColour.test.ts @@ -0,0 +1,15 @@ +import test from "ava"; +import { isCSSNamedDarkColour } from "../isCSSNameDarkColour.js"; + +test("verify if `darkblue` is a dark colour", ({ is }) => { + is(isCSSNamedDarkColour("darkblue", "WCAG2.1"), true); +}); + +test("verify if `lightgoldenrodyellow` is a dark colour", ({ is }) => { + is(isCSSNamedDarkColour("lightgoldenrodyellow", "WCAG2.1"), false); +}); + +test("return undefined if named colour does not exist", ({ is }) => { + /* @ts-ignore-line */ + is(isCSSNamedDarkColour("rose", "WCAG2.1"), undefined); +}); diff --git a/src/tests/isCSSRGBDarkColour.test.ts b/src/tests/isCSSRGBDarkColour.test.ts new file mode 100644 index 0000000..0ac3e39 --- /dev/null +++ b/src/tests/isCSSRGBDarkColour.test.ts @@ -0,0 +1,10 @@ +import test from "ava"; +import { isCSSRGBDarkColour } from "../isCSSRGBDarkColour.js"; + +test("verify if `rgb(20, 20, 20)` is a dark colour", ({ is }) => { + is(isCSSRGBDarkColour("rgb(20, 20, 20)", "WCAG2.1"), true); +}); + +test("verify if `rgb(200, 200, 200)` is not a dark colour", ({ is }) => { + is(isCSSRGBDarkColour("rgb(200, 200, 200)", "WCAG2.1"), false); +}); diff --git a/src/util/index.ts b/src/util/index.ts index 85e7cfd..5ebcb59 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -109,3 +109,14 @@ export function constrainLab(n: number): number { return t; } + +/** + * Clamps a number between two values + * @param {number} value - The value to be clamped + * @param {number} min - The minimum value + * @param {number} max - The maximum value + * @returns {number} - A clamped value + */ +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/src/util/regexers.ts b/src/util/regexers.ts index 12c20e1..be00fe6 100644 --- a/src/util/regexers.ts +++ b/src/util/regexers.ts @@ -7,4 +7,4 @@ export const shortHexRegex = /^#[a-fA-F0-9]{3}$/; /** Four digit Hexadecimal colour, ie: #FFF4 */ export const shortAlphaHexRegex = /^#[a-fA-F0-9]{4}$/; /** CSS RGB with optional alpha channel, ie: rgb(23, 213, 11) or rgba(12,34,12,0.2) */ -export const cssRGBA = /^rgba*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,*\s*(\d+.\d)*\)$/i; +export const cssRGBARegex = /^rgba*\(\s*([-+]?\d+)\s*(?:,)?\s*([-+]?\d+)\s*(?:,)?\s*([-+]?\d+)\s*(?:,*|\/*)\s*([-+]?\d*[.]?\d+[%]?)*\)$/i;