Skip to content

Commit

Permalink
feat: Add more utility functions (#32)
Browse files Browse the repository at this point in the history
* feat: Add isCSSRGBColour

* feat: Increase coverage of CSS RGB formats

* feat: Add isHexColour #24

* feat: Add isCSSNamedColourDarkColour #25

* feat: Add isCSSRGBDarkColour #22
  • Loading branch information
Marabyte authored Aug 29, 2022
1 parent 732f0ee commit b35132a
Show file tree
Hide file tree
Showing 19 changed files with 259 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/fair-panthers-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sardine/colour": minor
---

feat: Add isHexColour
5 changes: 5 additions & 0 deletions .changeset/nervous-ears-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sardine/colour": minor
---

feat: Increase coverage of different CSS RGB formats
5 changes: 5 additions & 0 deletions .changeset/rude-parrots-press.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sardine/colour": minor
---

feat: Add isCSSRGBDarkColour
5 changes: 5 additions & 0 deletions .changeset/slimy-drinks-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sardine/colour": minor
---

Add isCSSRGBColour
5 changes: 5 additions & 0 deletions .changeset/spotty-pears-confess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sardine/colour": minor
---

feat: Add isCSSNamedDarkColour
16 changes: 16 additions & 0 deletions src/assertions.ts
Original file line number Diff line number Diff line change
@@ -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);
45 changes: 38 additions & 7 deletions src/converters.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)) : '')}`;
}

/**
Expand All @@ -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}`);
Expand Down
17 changes: 5 additions & 12 deletions src/getSRGBLuminanceFromHex.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -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);
}
25 changes: 25 additions & 0 deletions src/getSRGBLuminanceFromRGB.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
17 changes: 17 additions & 0 deletions src/isCSSNameDarkColour.ts
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 14 additions & 0 deletions src/isCSSRGBDarkColour.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 2 additions & 2 deletions src/isHexDarkColour.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -16,4 +16,4 @@ export const isHexDarkColour = (colour: string, standard: WCAG): boolean => {
const blackContrast = colourLuminance / 0.05;

return whiteContrast > blackContrast;
};
}
36 changes: 36 additions & 0 deletions src/tests/assertions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
40 changes: 40 additions & 0 deletions src/tests/converters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions src/tests/isCSSNameDarkColour.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
10 changes: 10 additions & 0 deletions src/tests/isCSSRGBDarkColour.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
11 changes: 11 additions & 0 deletions src/util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
2 changes: 1 addition & 1 deletion src/util/regexers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

0 comments on commit b35132a

Please sign in to comment.