Initial Commit
This commit is contained in:
65
packages/media/image/colours/README.md
Normal file
65
packages/media/image/colours/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# @suaveplan/colours - Universal colour manipulation: parsing, conversion, analysis, mixing, blending, and palette generation
|
||||
|
||||
Universal colour manipulation library: parsing, conversion, analysis, mixing, blending, and palette generation. Parses hex (3/4/6/8 digit), CSS `rgb()`, `hsl()`, `lab()`, `lch()`, `oklab()`, `oklch()`, named colours, and plain objects. Converts between RGB, HSL, HSV, LAB, LCH, OKLAB, OKLCH, XYZ, and CMYK. Supports manipulation (lighten, darken, saturate, desaturate, rotate, alpha, fade, opaquer, negate, grayscale, mix), analysis (luminance, perceived brightness, contrast ratio, distance), and factories (random colours, temperature-based black-body curves, blend modes, gradient steps, harmonious palettes).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [colour](./src/colour/colour.md) — `colour.ts` implements the `Colour` class — an immutable, chainable colour value object. It wraps an internal `RGBA` st…
|
||||
- [colour-formatters](./src/colour/colour-formatters.md) — `colour-formatters.ts` provides deterministic string-formatting functions for every colour representation supported by…
|
||||
- [colour](./src/colour/colour.md) — `colour.ts` implements the `Colour` class — an immutable, chainable colour value object. It wraps an internal `RGBA` st…
|
||||
- [colour-factories](./src/colour-factories/colour-factories.md) — `colour-factories.ts` provides pure, deterministic helpers for generating colours and colour collections. It is complet…
|
||||
- [colour-factories](./src/colour-factories/colour-factories.md) — `colour-factories.ts` provides pure, deterministic helpers for generating colours and colour collections. It is complet…
|
||||
- [conversions](./src/conversions/conversions.md) — `conversions.ts` provides deterministic, side-effect-free mathematical transforms between colour spaces. Every function…
|
||||
- [conversions](./src/conversions/conversions.md) — `conversions.ts` provides deterministic, side-effect-free mathematical transforms between colour spaces. Every function…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` declares the canonical error-code registry for `@suaveplan/colours`. Every failure mode that the colou…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` declares the canonical error-code registry for `@suaveplan/colours`. Every failure mode that the colou…
|
||||
- [errors](./src/errors/errors.md) — `errors.ts` provides the typed error hierarchy for `@suaveplan/colours`. `ColourError` extends `SuaveplanError` so that…
|
||||
- [errors](./src/errors/errors.md) — `errors.ts` provides the typed error hierarchy for `@suaveplan/colours`. `ColourError` extends `SuaveplanError` so that…
|
||||
- [parser](./src/parser/parser.md) — `parser.ts` converts arbitrary colour input into a canonical `RGBA` object. It supports hex strings (3/4/6/8 digit), mo…
|
||||
- [parser](./src/parser/parser.md) — `parser.ts` converts arbitrary colour input into a canonical `RGBA` object. It supports hex strings (3/4/6/8 digit), mo…
|
||||
- [colour-types](./src/types/colour-types/colour-types.md) — `colour-types.ts` is the universal type system and Zod schema registry for `@suaveplan/colours`. It defines TypeSc…
|
||||
- [colour-types](./src/types/colour-types/colour-types.md) — `colour-types.ts` is the universal type system and Zod schema registry for `@suaveplan/colours`. It defines TypeSc…
|
||||
- [utils](./src/utils/utils.md) — `utils.ts` provides small, pure mathematical utilities used throughout `@suaveplan/colours`. Every function is determin…
|
||||
- [utils](./src/utils/utils.md) — `utils.ts` provides small, pure mathematical utilities used throughout `@suaveplan/colours`. Every function is determin…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/colours
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { Colour } from "@suaveplan/colours";
|
||||
|
||||
const c = new Colour("#ff9900");
|
||||
console.log(c.lighten(0.2).toHex()); // "#ffb34d"
|
||||
console.log(c.isDark()); // false
|
||||
console.log(c.contrast("#ffffff")); // WCAG contrast ratio
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/colour/colour.md`](./src/colour/colour.md) — `Colour` class and instance methods
|
||||
- [`src/colour/colour-formatters.md`](./src/colour/colour-formatters.md) — Output formatters (hex, rgb, hsl, etc.)
|
||||
- [`src/colour-factories/colour-factories.md`](./src/colour-factories/colour-factories.md) — Random colours, temperature, blend modes, palettes
|
||||
- [`src/conversions/conversions.md`](./src/conversions/conversions.md) — Colour-space conversion functions
|
||||
- [`src/parser/parser.md`](./src/parser/parser.md) — `parseColour` input parser
|
||||
- [`src/types/colour-types/colour-types.md`](./src/types/colour-types/colour-types.md) — TypeScript interfaces and Zod schemas
|
||||
- [`src/utils/utils.md`](./src/utils/utils.md) — Clamping and rounding utilities
|
||||
- [`src/errors/errors.md`](./src/errors/errors.md) — `ColourError` hierarchy and typed factories
|
||||
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) — `COLOUR_CODES` registry
|
||||
|
||||
## Subpath Exports
|
||||
|
||||
- `@suaveplan/colours/colour` — `Colour` class
|
||||
- `@suaveplan/colours/parser` — `parseColour`
|
||||
- `@suaveplan/colours/conversions` — colour-space conversion functions
|
||||
- `@suaveplan/colours/types` — TypeScript interfaces and Zod schemas
|
||||
- `@suaveplan/colours/utils` — clamping and rounding utilities
|
||||
- `@suaveplan/colours/errors` — `ColourError` and factory functions
|
||||
- `@suaveplan/colours/error-codes` — `COLOUR_CODES` registry
|
||||
|
||||
105
packages/media/image/colours/package.json
Normal file
105
packages/media/image/colours/package.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "@suaveplan/colours",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal colour manipulation: parsing, conversion, analysis, mixing, blending, and palette generation",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./errors/errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/errors.d.ts",
|
||||
"default": "./dist/errors/errors.js"
|
||||
}
|
||||
},
|
||||
"./types/colour-types": {
|
||||
"import": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/colour-types.js"
|
||||
}
|
||||
},
|
||||
"./parser/parser": {
|
||||
"import": {
|
||||
"types": "./dist/parser/parser.d.ts",
|
||||
"default": "./dist/parser/parser.js"
|
||||
}
|
||||
},
|
||||
"./conversions/conversions": {
|
||||
"import": {
|
||||
"types": "./dist/conversions/conversions.d.ts",
|
||||
"default": "./dist/conversions/conversions.js"
|
||||
}
|
||||
},
|
||||
"./colour/colour": {
|
||||
"import": {
|
||||
"types": "./dist/colour/colour.d.ts",
|
||||
"default": "./dist/colour/colour.js"
|
||||
}
|
||||
},
|
||||
"./colour-factories/colour-factories": {
|
||||
"import": {
|
||||
"types": "./dist/colour-factories/colour-factories.d.ts",
|
||||
"default": "./dist/colour-factories/colour-factories.js"
|
||||
}
|
||||
},
|
||||
"./utils/utils": {
|
||||
"import": {
|
||||
"types": "./dist/utils/utils.d.ts",
|
||||
"default": "./dist/utils/utils.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# colour-factories
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`colour-factories.ts` provides pure, deterministic helpers for generating colours and colour collections. It is completely stateless and has no side effects. All functions return either a single `Colour` instance or an array of `Colour` instances. These helpers extend the core `Colour` class with common creative and programmatic colour tasks.
|
||||
|
||||
## Features
|
||||
|
||||
- Random colour generation with optional alpha
|
||||
- Temperature-based black-body approximation (Kelvin to RGB)
|
||||
- Colour blending with standard Photoshop-style blend modes
|
||||
- Gradient step generation between two colours
|
||||
- Palette generation (analogous, monochromatic, triadic, tetradic, complementary, split-complementary)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { randomColour, steps } from "@suaveplan/colours/colour-factories";
|
||||
|
||||
const r = randomColour();
|
||||
const gradient = steps("#ff0000", "#0000ff", 5);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { palette, fromTemperature } from "@suaveplan/colours/colour-factories";
|
||||
|
||||
const warm = fromTemperature(3200);
|
||||
const brandPalette = palette("#3b82f6", "analogous", 5);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `randomColour(alpha?: number): Colour`
|
||||
|
||||
Returns a pseudo-random colour. If `alpha` is provided it is clamped to `[0,1]`.
|
||||
|
||||
### `fromTemperature(kelvin: number): Colour`
|
||||
|
||||
Approximates a black-body colour temperature in Kelvin. Valid range is roughly `1000K` to `40000K`. Values outside this range are clamped.
|
||||
|
||||
### `blend(bottom: ColourInput, top: ColourInput, mode: BlendMode): Colour`
|
||||
|
||||
Applies a standard blend mode to two colours and returns the result. Supported modes include `normal`, `multiply`, `screen`, `overlay`, `darken`, `lighten`, `color-dodge`, `color-burn`, `difference`, `exclusion`.
|
||||
|
||||
### `steps(start: ColourInput, end: ColourInput, count: number): Colour[]`
|
||||
|
||||
Generates `count` evenly spaced colours between `start` and `end` in OKLAB space for perceptual uniformity.
|
||||
|
||||
### `palette(base: ColourInput, type: PaletteType, count?: number): Colour[]`
|
||||
|
||||
Generates a harmonious palette from a base colour. `count` defaults to `5` for most types. Types:
|
||||
|
||||
- `analogous` — colours adjacent on the colour wheel
|
||||
- `monochromatic` — same hue, varying lightness
|
||||
- `triadic` — evenly spaced three-colour harmony
|
||||
- `tetradic` — four-colour rectangular harmony
|
||||
- `complementary` — opposite hue
|
||||
- `split-complementary` — opposite hue split by a small angle
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All factory functions create new `Colour` instances via the constructor; they never mutate existing instances. The `steps` function uses OKLAB interpolation because it preserves perceptual uniformity better than RGB or HSL. Temperature approximation uses the standard black-body polynomial from Tanner Helland.
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Colour } from "../colour/colour.js";
|
||||
import { ColourError } from "../errors/errors.js";
|
||||
import {
|
||||
blendColours,
|
||||
colourFromTemperature,
|
||||
colourSteps,
|
||||
generatePalette,
|
||||
randomColour,
|
||||
} from "./colour-factories.js";
|
||||
|
||||
describe("colour-factories", () => {
|
||||
it("randomColour produces valid colour", { timeout: 5000 }, () => {
|
||||
const c = randomColour();
|
||||
const { r, g, b } = c.toRgba();
|
||||
expect(r).toBeGreaterThanOrEqual(0);
|
||||
expect(r).toBeLessThanOrEqual(255);
|
||||
expect(g).toBeGreaterThanOrEqual(0);
|
||||
expect(g).toBeLessThanOrEqual(255);
|
||||
expect(b).toBeGreaterThanOrEqual(0);
|
||||
expect(b).toBeLessThanOrEqual(255);
|
||||
});
|
||||
|
||||
it("colourFromTemperature at 2700K is warm", { timeout: 5000 }, () => {
|
||||
const c = colourFromTemperature(2700);
|
||||
const { r, b } = c.toRgba();
|
||||
expect(r).toBeGreaterThan(b);
|
||||
});
|
||||
|
||||
it("colourFromTemperature at 6500K is near white", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = colourFromTemperature(6500);
|
||||
const { r, g, b } = c.toRgba();
|
||||
expect(r).toBeGreaterThan(240);
|
||||
expect(g).toBeGreaterThan(240);
|
||||
expect(b).toBeGreaterThan(240);
|
||||
});
|
||||
|
||||
it("colourFromTemperature rejects out of range", { timeout: 5000 }, () => {
|
||||
expect(() => colourFromTemperature(100)).toThrow(ColourError);
|
||||
expect(() => colourFromTemperature(50000)).toThrow(ColourError);
|
||||
});
|
||||
|
||||
it("blendColours requires at least 2 colours", { timeout: 5000 }, () => {
|
||||
expect(() => blendColours("multiply", "#ff0000")).toThrow(ColourError);
|
||||
});
|
||||
|
||||
it("blendColours multiplies", { timeout: 5000 }, () => {
|
||||
const result = blendColours("multiply", "#ffffff", "#808080");
|
||||
const { r } = result.toRgba();
|
||||
expect(r).toBe(128);
|
||||
});
|
||||
|
||||
it("steps produces correct count", { timeout: 5000 }, () => {
|
||||
const steps = colourSteps("#ff0000", "#0000ff", 5);
|
||||
expect(steps).toHaveLength(5);
|
||||
expect(steps[0].toHex()).toBe("#ff0000");
|
||||
expect(steps[4].toHex()).toBe("#0000ff");
|
||||
});
|
||||
|
||||
it("steps returns single for count < 2", { timeout: 5000 }, () => {
|
||||
const steps = colourSteps("#ff0000", "#0000ff", 1);
|
||||
expect(steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("steps accepts Colour objects", { timeout: 5000 }, () => {
|
||||
const steps = colourSteps(
|
||||
new Colour("#ff0000"),
|
||||
new Colour("#0000ff"),
|
||||
3
|
||||
);
|
||||
expect(steps).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("steps with count < 2 and Colour object", { timeout: 5000 }, () => {
|
||||
const steps = colourSteps(new Colour("#ff0000"), "#0000ff", 1);
|
||||
expect(steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("generatePalette complementary", { timeout: 5000 }, () => {
|
||||
const palette = generatePalette("#ff0000", { type: "complementary" });
|
||||
expect(palette).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("generatePalette triadic", { timeout: 5000 }, () => {
|
||||
const palette = generatePalette("#ff0000", { type: "triadic" });
|
||||
expect(palette).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("generatePalette monochromatic", { timeout: 5000 }, () => {
|
||||
const palette = generatePalette("#ff0000", {
|
||||
type: "monochromatic",
|
||||
count: 5,
|
||||
});
|
||||
expect(palette).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("generatePalette analogous", { timeout: 5000 }, () => {
|
||||
const palette = generatePalette("#ff0000", { type: "analogous" });
|
||||
expect(palette).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("generatePalette tetradic", { timeout: 5000 }, () => {
|
||||
const palette = generatePalette("#ff0000", { type: "tetradic" });
|
||||
expect(palette).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("generatePalette split-complementary", { timeout: 5000 }, () => {
|
||||
const palette = generatePalette("#ff0000", {
|
||||
type: "split-complementary",
|
||||
});
|
||||
expect(palette).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("blendColours exclusion", { timeout: 5000 }, () => {
|
||||
const result = blendColours("exclusion", "#ffffff", "#808080");
|
||||
expect(result.toRgba().r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("blendColours screen", { timeout: 5000 }, () => {
|
||||
const result = blendColours("screen", "#000000", "#808080");
|
||||
expect(result.toRgba().r).toBe(128);
|
||||
});
|
||||
|
||||
it("blendColours overlay with a < 128", { timeout: 5000 }, () => {
|
||||
const result = blendColours("overlay", "#404040", "#ffffff");
|
||||
expect(result.toRgba().r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("blendColours overlay with a >= 128", { timeout: 5000 }, () => {
|
||||
const result = blendColours("overlay", "#808080", "#ffffff");
|
||||
expect(result.toRgba().r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("blendColours difference", { timeout: 5000 }, () => {
|
||||
const result = blendColours("difference", "#ff0000", "#0000ff");
|
||||
expect(result.toRgba().r).toBe(255);
|
||||
});
|
||||
|
||||
it("blendColours addition", { timeout: 5000 }, () => {
|
||||
const result = blendColours("addition", "#ff0000", "#00ff00");
|
||||
expect(result.toRgba().r).toBe(255);
|
||||
});
|
||||
|
||||
it("colourFromTemperature at very low temp has zero blue", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = colourFromTemperature(1000);
|
||||
expect(c.toRgba().b).toBe(0);
|
||||
});
|
||||
|
||||
it("colourFromTemperature at high temp uses alternate curves", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = colourFromTemperature(20000);
|
||||
expect(c.toRgba().g).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Colour } from "../colour/colour.js";
|
||||
import { colourInvalid } from "../errors/errors.js";
|
||||
import type {
|
||||
BlendMode,
|
||||
MixMode,
|
||||
PaletteType,
|
||||
} from "../types/colour-types/colour-types.js";
|
||||
import { clamp, roundChannel } from "../utils/utils.js";
|
||||
|
||||
export function randomColour(): Colour {
|
||||
return new Colour({
|
||||
r: Math.floor(Math.random() * 256),
|
||||
g: Math.floor(Math.random() * 256),
|
||||
b: Math.floor(Math.random() * 256),
|
||||
a: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function colourFromTemperature(kelvin: number): Colour {
|
||||
if (kelvin < 1000 || kelvin > 40000) {
|
||||
throw colourInvalid(
|
||||
`Temperature must be between 1000K and 40000K: ${kelvin}`
|
||||
);
|
||||
}
|
||||
|
||||
const temp = kelvin / 100;
|
||||
let r: number;
|
||||
let g: number;
|
||||
let b: number;
|
||||
|
||||
if (temp <= 66) {
|
||||
r = 255;
|
||||
} else {
|
||||
r = 329.698727446 * (temp - 60) ** -0.1332047592;
|
||||
r = clamp(r, 0, 255);
|
||||
}
|
||||
|
||||
if (temp <= 66) {
|
||||
g = 99.4708025861 * Math.log(temp) - 161.1195681661;
|
||||
g = clamp(g, 0, 255);
|
||||
} else {
|
||||
g = 288.1221695283 * (temp - 60) ** -0.0755148492;
|
||||
g = clamp(g, 0, 255);
|
||||
}
|
||||
|
||||
if (temp >= 66) {
|
||||
b = 255;
|
||||
} else if (temp <= 19) {
|
||||
b = 0;
|
||||
} else {
|
||||
b = 138.5177312231 * Math.log(temp - 10) - 305.0447927307;
|
||||
b = clamp(b, 0, 255);
|
||||
}
|
||||
|
||||
return new Colour({
|
||||
r: Math.round(r),
|
||||
g: Math.round(g),
|
||||
b: Math.round(b),
|
||||
a: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function blendColours(
|
||||
mode: BlendMode,
|
||||
...colours: (Colour | string)[]
|
||||
): Colour {
|
||||
if (colours.length < 2) {
|
||||
throw colourInvalid("Blend requires at least 2 colours");
|
||||
}
|
||||
|
||||
const base = new Colour(colours[0]).toRgba();
|
||||
|
||||
const blendFns: Record<string, (a: number, b: number) => number> = {
|
||||
multiply: (a, b) => (a * b) / 255,
|
||||
screen: (a, b) => 255 - ((255 - a) * (255 - b)) / 255,
|
||||
overlay: (a, b) =>
|
||||
a < 128
|
||||
? (2 * a * b) / 255
|
||||
: 255 - (2 * (255 - a) * (255 - b)) / 255,
|
||||
difference: (a, b) => Math.abs(a - b),
|
||||
addition: (a, b) => Math.min(255, a + b),
|
||||
exclusion: (a, b) => a + b - (2 * a * b) / 255,
|
||||
};
|
||||
|
||||
const fn = blendFns[mode];
|
||||
let result = { ...base };
|
||||
|
||||
for (let i = 1; i < colours.length; i++) {
|
||||
const next = new Colour(colours[i]!).toRgba();
|
||||
const a = next.a;
|
||||
result = {
|
||||
r: roundChannel(result.r * (1 - a) + fn!(result.r, next.r) * a),
|
||||
g: roundChannel(result.g * (1 - a) + fn!(result.g, next.g) * a),
|
||||
b: roundChannel(result.b * (1 - a) + fn!(result.b, next.b) * a),
|
||||
a: result.a + next.a * (1 - result.a),
|
||||
};
|
||||
}
|
||||
|
||||
return new Colour(result);
|
||||
}
|
||||
|
||||
export function colourSteps(
|
||||
from: Colour | string,
|
||||
to: Colour | string,
|
||||
count: number,
|
||||
mode: MixMode = "oklab"
|
||||
): Colour[] {
|
||||
if (count < 2) return [from instanceof Colour ? from : new Colour(from)];
|
||||
const result: Colour[] = [];
|
||||
const start = new Colour(from);
|
||||
for (let i = 0; i < count; i++) {
|
||||
result.push(
|
||||
start.mix(
|
||||
to instanceof Colour ? to : new Colour(to),
|
||||
100 - (i / (count - 1)) * 100,
|
||||
mode
|
||||
)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function generatePalette(
|
||||
base: Colour | string,
|
||||
options: {
|
||||
type: PaletteType;
|
||||
count?: number;
|
||||
}
|
||||
): Colour[] {
|
||||
const c = new Colour(base);
|
||||
const { type, count = 5 } = options;
|
||||
|
||||
switch (type) {
|
||||
case "monochromatic":
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
c.lighten((i / (count - 1) - 0.5) * 100).alpha(1)
|
||||
);
|
||||
case "analogous":
|
||||
return [c.rotate(-30), c, c.rotate(30)];
|
||||
case "complementary":
|
||||
return [c, c.rotate(180)];
|
||||
case "triadic":
|
||||
return [c, c.rotate(120), c.rotate(240)];
|
||||
case "tetradic":
|
||||
return [c, c.rotate(90), c.rotate(180), c.rotate(270)];
|
||||
case "split-complementary":
|
||||
return [c, c.rotate(150), c.rotate(210)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
blendColours,
|
||||
colourFromTemperature,
|
||||
colourSteps,
|
||||
generatePalette,
|
||||
randomColour,
|
||||
} from "./colour-factories.js";
|
||||
58
packages/media/image/colours/src/colour/colour-formatters.md
Normal file
58
packages/media/image/colours/src/colour/colour-formatters.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# colour-formatters
|
||||
|
||||
## Purpose
|
||||
|
||||
`colour-formatters.ts` provides deterministic string-formatting functions for every colour representation supported by `@suaveplan/colours`. Each formatter takes a canonical `RGBA` object and returns a human-readable or machine-parseable string. The module is pure, stateless, and side-effect-free.
|
||||
|
||||
## Features
|
||||
|
||||
- Hex formatters: 6-digit, 3-digit shorthand, and 8-digit with alpha
|
||||
- CSS functional notation: `rgb()`, `rgba()`, `hsl()`, `hsla()`, `hsv()`
|
||||
- CIE and OK colour spaces: `lab()`, `lch()`, `oklab()`, `oklch()`
|
||||
- Device colour: `cmyk()`
|
||||
- Named colour lookup with fallback to hex
|
||||
- Unified `formatColour` dispatcher
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { formatHex, formatRgb } from "@suaveplan/colours/colour";
|
||||
|
||||
const rgba = { r: 255, g: 128, b: 0, a: 1 };
|
||||
console.log(formatHex(rgba)); // "#ff8000"
|
||||
console.log(formatRgb(rgba)); // "rgb(255 128 0)"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { formatColour } from "@suaveplan/colours/colour";
|
||||
|
||||
function exportColour(rgba: RGBA, targetFormat: string) {
|
||||
return formatColour(rgba, targetFormat as any);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `formatHex(rgba: RGBA): string`
|
||||
- `formatHex3(rgba: RGBA): string | null`
|
||||
- `formatHex8(rgba: RGBA): string`
|
||||
- `formatRgb(rgba: RGBA): string`
|
||||
- `formatRgba(rgba: RGBA): string`
|
||||
- `formatHsl(rgba: RGBA): string`
|
||||
- `formatHsla(rgba: RGBA): string`
|
||||
- `formatHsv(rgba: RGBA): string`
|
||||
- `formatLab(rgba: RGBA): string`
|
||||
- `formatLch(rgba: RGBA): string`
|
||||
- `formatOklab(rgba: RGBA): string`
|
||||
- `formatOklch(rgba: RGBA): string`
|
||||
- `formatCmyk(rgba: RGBA): string`
|
||||
- `formatNamed(rgba: RGBA): string`
|
||||
- `formatColour(rgba: RGBA, format?: string): string`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All formatters use fixed-precision rounding appropriate to their colour space. Hex formatters pad single-digit components with leading zeros. The `formatHex3` formatter returns `null` when the colour cannot be exactly represented in three-digit shorthand. `formatNamed` iterates the `NAMED_COLOURS` map and falls back to `formatHex` for unknown colours.
|
||||
|
||||
Formatters are grouped together so that the `Colour` class can delegate all string output to a single module. This keeps the class focused on manipulation logic while the formatters handle presentation details.
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
formatCmyk,
|
||||
formatColour,
|
||||
formatHex,
|
||||
formatHex3,
|
||||
formatHex8,
|
||||
formatHsl,
|
||||
formatHsla,
|
||||
formatHsv,
|
||||
formatLab,
|
||||
formatLch,
|
||||
formatNamed,
|
||||
formatOklab,
|
||||
formatOklch,
|
||||
formatRgb,
|
||||
formatRgba,
|
||||
} from "./colour-formatters.js";
|
||||
|
||||
describe("colour-formatters", () => {
|
||||
const red: import("../types/colour-types/colour-types.js").RGBA = {
|
||||
r: 255,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 1,
|
||||
};
|
||||
const gray: import("../types/colour-types/colour-types.js").RGBA = {
|
||||
r: 128,
|
||||
g: 128,
|
||||
b: 128,
|
||||
a: 1,
|
||||
};
|
||||
const halfAlpha: import("../types/colour-types/colour-types.js").RGBA = {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 0.5,
|
||||
};
|
||||
|
||||
it("formatHex returns 6-digit hex", { timeout: 5000 }, () => {
|
||||
expect(formatHex(red)).toBe("#ff0000");
|
||||
expect(formatHex(gray)).toBe("#808080");
|
||||
});
|
||||
|
||||
it("formatHex3 returns shorthand when possible", { timeout: 5000 }, () => {
|
||||
expect(formatHex3({ r: 255, g: 0, b: 0, a: 1 })).toBe("#f00");
|
||||
expect(formatHex3(gray)).toBeNull();
|
||||
});
|
||||
|
||||
it("formatHex8 includes alpha channel", { timeout: 5000 }, () => {
|
||||
expect(formatHex8(red)).toBe("#ff0000ff");
|
||||
expect(formatHex8(halfAlpha)).toBe("#00000080");
|
||||
});
|
||||
|
||||
it("formatRgb returns modern rgb()", { timeout: 5000 }, () => {
|
||||
expect(formatRgb(red)).toBe("rgb(255 0 0)");
|
||||
});
|
||||
|
||||
it("formatRgba includes alpha", { timeout: 5000 }, () => {
|
||||
expect(formatRgba(halfAlpha)).toBe("rgba(0 0 0 / 0.5)");
|
||||
});
|
||||
|
||||
it("formatHsl returns hsl() string", { timeout: 5000 }, () => {
|
||||
expect(formatHsl(red)).toMatch(/^hsl\(/);
|
||||
});
|
||||
|
||||
it("formatHsla includes alpha", { timeout: 5000 }, () => {
|
||||
expect(formatHsla(halfAlpha)).toMatch(/^hsla\(/);
|
||||
});
|
||||
|
||||
it("formatHsv returns hsv() string", { timeout: 5000 }, () => {
|
||||
expect(formatHsv(red)).toMatch(/^hsv\(/);
|
||||
});
|
||||
|
||||
it("formatLab returns lab() string", { timeout: 5000 }, () => {
|
||||
expect(formatLab(red)).toMatch(/^lab\(/);
|
||||
});
|
||||
|
||||
it("formatLch returns lch() string", { timeout: 5000 }, () => {
|
||||
expect(formatLch(red)).toMatch(/^lch\(/);
|
||||
});
|
||||
|
||||
it("formatOklab returns oklab() string", { timeout: 5000 }, () => {
|
||||
expect(formatOklab(red)).toMatch(/^oklab\(/);
|
||||
});
|
||||
|
||||
it("formatOklch returns oklch() string", { timeout: 5000 }, () => {
|
||||
expect(formatOklch(red)).toMatch(/^oklch\(/);
|
||||
});
|
||||
|
||||
it("formatCmyk returns cmyk() string", { timeout: 5000 }, () => {
|
||||
expect(formatCmyk(red)).toMatch(/^cmyk\(/);
|
||||
});
|
||||
|
||||
it("formatNamed returns name for known colours", { timeout: 5000 }, () => {
|
||||
expect(formatNamed({ r: 255, g: 0, b: 0, a: 1 })).toBe("red");
|
||||
});
|
||||
|
||||
it("formatNamed falls back to hex for unknown colours", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(formatNamed({ r: 1, g: 2, b: 3, a: 1 })).toBe("#010203");
|
||||
});
|
||||
|
||||
it("formatColour dispatches by format", { timeout: 5000 }, () => {
|
||||
expect(formatColour(red, "hex")).toBe("#ff0000");
|
||||
expect(formatColour(red, "hex3")).toBe("#f00");
|
||||
expect(formatColour(red, "hex8")).toBe("#ff0000ff");
|
||||
expect(formatColour(red, "rgb")).toBe("rgb(255 0 0)");
|
||||
expect(formatColour(red, "rgba")).toBe("rgba(255 0 0 / 1)");
|
||||
expect(formatColour(red, "hsl")).toMatch(/^hsl\(/);
|
||||
expect(formatColour(red, "hsla")).toMatch(/^hsla\(/);
|
||||
expect(formatColour(red, "hsv")).toMatch(/^hsv\(/);
|
||||
expect(formatColour(red, "lab")).toMatch(/^lab\(/);
|
||||
expect(formatColour(red, "lch")).toMatch(/^lch\(/);
|
||||
expect(formatColour(red, "oklab")).toMatch(/^oklab\(/);
|
||||
expect(formatColour(red, "oklch")).toMatch(/^oklch\(/);
|
||||
expect(formatColour(red, "cmyk")).toMatch(/^cmyk\(/);
|
||||
expect(formatColour(red, "named")).toBe("red");
|
||||
expect(formatColour(red)).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("formatColour hex3 falls back to hex when not shrinkable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(formatColour(gray, "hex3")).toBe("#808080");
|
||||
});
|
||||
});
|
||||
136
packages/media/image/colours/src/colour/colour-formatters.ts
Normal file
136
packages/media/image/colours/src/colour/colour-formatters.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import * as Conversions from "../conversions/conversions.js";
|
||||
import {
|
||||
NAMED_COLOURS,
|
||||
type RGBA,
|
||||
} from "../types/colour-types/colour-types.js";
|
||||
|
||||
export function formatHex({ r, g, b }: RGBA): string {
|
||||
const toHex = (n: number) => n.toString(16).padStart(2, "0");
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
export function formatHex3({ r, g, b }: RGBA): string | null {
|
||||
const toHex = (n: number) => Math.round(n / 17).toString(16);
|
||||
const [rh, gh, bh] = [r, g, b].map((n) => Math.round(n / 17) * 17);
|
||||
if (rh === r && gh === g && bh === b) {
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatHex8({ r, g, b, a }: RGBA): string {
|
||||
const toHex = (n: number) => n.toString(16).padStart(2, "0");
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(Math.round(a * 255))}`;
|
||||
}
|
||||
|
||||
export function formatRgb({ r, g, b }: RGBA): string {
|
||||
return `rgb(${r} ${g} ${b})`;
|
||||
}
|
||||
|
||||
export function formatRgba({ r, g, b, a }: RGBA): string {
|
||||
return `rgba(${r} ${g} ${b} / ${a})`;
|
||||
}
|
||||
|
||||
export function formatHsl(rgba: RGBA): string {
|
||||
const { h, s, l } = Conversions.rgbToHsl(rgba);
|
||||
return `hsl(${Math.round(h)} ${Math.round(s)}% ${Math.round(l)}%)`;
|
||||
}
|
||||
|
||||
export function formatHsla(rgba: RGBA): string {
|
||||
const { h, s, l, a } = Conversions.rgbToHsl(rgba);
|
||||
return `hsla(${Math.round(h)} ${Math.round(s)}% ${Math.round(l)}% / ${a})`;
|
||||
}
|
||||
|
||||
export function formatHsv(rgba: RGBA): string {
|
||||
const { h, s, v } = Conversions.rgbToHsv(rgba);
|
||||
return `hsv(${Math.round(h)} ${Math.round(s)}% ${Math.round(v)}%)`;
|
||||
}
|
||||
|
||||
export function formatLab(rgba: RGBA): string {
|
||||
const { l, a, b } = Conversions.rgbToLab(rgba);
|
||||
return `lab(${l.toFixed(2)} ${a.toFixed(2)} ${b.toFixed(2)})`;
|
||||
}
|
||||
|
||||
export function formatLch(rgba: RGBA): string {
|
||||
const { l, c, h } = Conversions.labToLch(Conversions.rgbToLab(rgba));
|
||||
return `lch(${l.toFixed(2)} ${c.toFixed(2)} ${h.toFixed(2)})`;
|
||||
}
|
||||
|
||||
export function formatOklab(rgba: RGBA): string {
|
||||
const { L, a, b } = Conversions.rgbToOklab(rgba);
|
||||
return `oklab(${L.toFixed(3)} ${a.toFixed(3)} ${b.toFixed(3)})`;
|
||||
}
|
||||
|
||||
export function formatOklch(rgba: RGBA): string {
|
||||
const { L, C, h } = Conversions.oklabToOklch(Conversions.rgbToOklab(rgba));
|
||||
return `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${h.toFixed(2)})`;
|
||||
}
|
||||
|
||||
export function formatCmyk(rgba: RGBA): string {
|
||||
const { c, m, y, k } = Conversions.rgbToCmyk(rgba);
|
||||
return `cmyk(${c}% ${m}% ${y}% ${k}%)`;
|
||||
}
|
||||
|
||||
export function formatNamed(rgba: RGBA): string {
|
||||
for (const [name, nrgba] of NAMED_COLOURS) {
|
||||
if (
|
||||
nrgba.r === rgba.r &&
|
||||
nrgba.g === rgba.g &&
|
||||
nrgba.b === rgba.b &&
|
||||
nrgba.a === rgba.a
|
||||
) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return formatHex(rgba);
|
||||
}
|
||||
|
||||
export function formatColour(
|
||||
rgba: RGBA,
|
||||
format?:
|
||||
| "hex"
|
||||
| "hex3"
|
||||
| "hex8"
|
||||
| "rgb"
|
||||
| "rgba"
|
||||
| "hsl"
|
||||
| "hsla"
|
||||
| "hsv"
|
||||
| "lab"
|
||||
| "lch"
|
||||
| "oklab"
|
||||
| "oklch"
|
||||
| "cmyk"
|
||||
| "named"
|
||||
): string {
|
||||
switch (format) {
|
||||
case "hex3":
|
||||
return formatHex3(rgba) ?? formatHex(rgba);
|
||||
case "hex8":
|
||||
return formatHex8(rgba);
|
||||
case "rgb":
|
||||
return formatRgb(rgba);
|
||||
case "rgba":
|
||||
return formatRgba(rgba);
|
||||
case "hsl":
|
||||
return formatHsl(rgba);
|
||||
case "hsla":
|
||||
return formatHsla(rgba);
|
||||
case "hsv":
|
||||
return formatHsv(rgba);
|
||||
case "lab":
|
||||
return formatLab(rgba);
|
||||
case "lch":
|
||||
return formatLch(rgba);
|
||||
case "oklab":
|
||||
return formatOklab(rgba);
|
||||
case "oklch":
|
||||
return formatOklch(rgba);
|
||||
case "cmyk":
|
||||
return formatCmyk(rgba);
|
||||
case "named":
|
||||
return formatNamed(rgba);
|
||||
default:
|
||||
return formatHex(rgba);
|
||||
}
|
||||
}
|
||||
88
packages/media/image/colours/src/colour/colour.md
Normal file
88
packages/media/image/colours/src/colour/colour.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# colour
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`colour.ts` implements the `Colour` class — an immutable, chainable colour value object. It wraps an internal `RGBA` state and exposes methods for manipulation, analysis, conversion, and output formatting. The class is the primary public API surface for the package and is used by the `colour-factories` helpers.
|
||||
|
||||
## Features
|
||||
|
||||
- Immutable chainable manipulation (lighten, darken, saturate, desaturate, rotate, alpha, fade, opaquer)
|
||||
- Colour-space conversion methods (hex, RGB, HSL, HSV, LAB, LCH, OKLAB, OKLCH, CMYK)
|
||||
- Analysis methods (luminance, perceived brightness, contrast ratio, isDark, isLight, best contrast, distance)
|
||||
- Utility methods (negate, grayscale, mix, clone, equals, toString)
|
||||
- Pure functional implementation with no mutation of internal state
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Colour } from "@suaveplan/colours/colour";
|
||||
|
||||
const c = new Colour("#ff9900");
|
||||
console.log(c.lighten(0.2).toHex()); // "#ffb34d"
|
||||
console.log(c.isDark()); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Colour } from "@suaveplan/colours/colour";
|
||||
|
||||
const brand = new Colour("#3b82f6");
|
||||
const text = brand.bestContrast("#ffffff", "#1f2937");
|
||||
console.log(text.toHex()); // "#ffffff"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
- `new Colour(input: ColourInput)` — parses any supported colour input into an immutable `Colour` instance.
|
||||
|
||||
### Manipulation
|
||||
|
||||
- `lighten(amount: number): Colour`
|
||||
- `darken(amount: number): Colour`
|
||||
- `saturate(amount: number): Colour`
|
||||
- `desaturate(amount: number): Colour`
|
||||
- `rotate(degrees: number): Colour`
|
||||
- `alpha(a: number): Colour`
|
||||
- `fade(amount: number): Colour`
|
||||
- `opaquer(amount: number): Colour`
|
||||
- `negate(): Colour`
|
||||
- `grayscale(): Colour`
|
||||
- `mix(other: ColourInput, amount?: number): Colour`
|
||||
|
||||
### Analysis
|
||||
|
||||
- `luminance(): number`
|
||||
- `perceivedBrightness(): number`
|
||||
- `contrast(other: ColourInput): number`
|
||||
- `isDark(): boolean`
|
||||
- `isLight(): boolean`
|
||||
- `bestContrast(...candidates: (Colour | string)[]): Colour`
|
||||
- `distance(other: ColourInput, mode?: DistanceMode): number`
|
||||
- `equals(other: ColourInput): boolean`
|
||||
|
||||
### Output
|
||||
|
||||
- `toHex(): string`
|
||||
- `toHex3(): string`
|
||||
- `toHex8(): string`
|
||||
- `toRgb(): RGB`
|
||||
- `toRgba(): RGBA`
|
||||
- `toHsl(): HSL`
|
||||
- `toHsv(): HSV`
|
||||
- `toLab(): LAB`
|
||||
- `toLch(): LCH`
|
||||
- `toOklab(): OKLAB`
|
||||
- `toOklch(): OKLCH`
|
||||
- `toCmyk(): CMYK`
|
||||
- `toString(): string`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal `_rgba` state is never mutated. Every chainable method returns a new `Colour` instance. Conversion methods delegate to the `conversions` module. Analysis methods use the standard WCAG 2.1 luminance formula and sRGB gamma. The `bestContrast` helper uses the APCA-like perceived brightness for candidate ranking.
|
||||
323
packages/media/image/colours/src/colour/colour.test.ts
Normal file
323
packages/media/image/colours/src/colour/colour.test.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Colour } from "./colour.js";
|
||||
|
||||
describe("Colour", () => {
|
||||
it("constructs from hex string", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ff9900");
|
||||
expect(c.toRgba()).toEqual({ r: 255, g: 153, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("constructs from RGBA object", { timeout: 5000 }, () => {
|
||||
const c = new Colour({ r: 10, g: 20, b: 30, a: 0.5 });
|
||||
expect(c.toRgba()).toEqual({ r: 10, g: 20, b: 30, a: 0.5 });
|
||||
});
|
||||
|
||||
it("constructs from RGB object defaulting alpha to 1", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new Colour({ r: 10, g: 20, b: 30 } as any);
|
||||
expect(c.toRgba().a).toBe(1);
|
||||
});
|
||||
|
||||
it("defaults to black", { timeout: 5000 }, () => {
|
||||
const c = new Colour();
|
||||
expect(c.toHex()).toBe("#000000");
|
||||
});
|
||||
|
||||
it("lighten and darken are inverse", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#808080");
|
||||
const back = c.lighten(20).darken(20);
|
||||
expect(back.equals(c, 5)).toBe(true);
|
||||
});
|
||||
|
||||
it("saturate and desaturate are inverse", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#808080");
|
||||
const back = c.saturate(20).desaturate(20);
|
||||
expect(back.equals(c, 5)).toBe(true);
|
||||
});
|
||||
|
||||
it("rotate wraps hue", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ff0000");
|
||||
const rotated = c.rotate(120);
|
||||
expect(rotated.toHex()).not.toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("alpha sets opacity", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ff0000").alpha(0.5);
|
||||
expect(c.toRgba().a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("fade reduces alpha", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ff0000").alpha(1).fade(50);
|
||||
expect(c.toRgba().a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("opaquer increases alpha", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ff0000").alpha(0.5).opaquer(50);
|
||||
expect(c.toRgba().a).toBe(1);
|
||||
});
|
||||
|
||||
it("negate inverts channels", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ffffff").negate();
|
||||
expect(c.toHex()).toBe("#000000");
|
||||
});
|
||||
|
||||
it("grayscale removes saturation", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ff0000").grayscale();
|
||||
const { r, g, b } = c.toRgba();
|
||||
expect(r).toBe(g);
|
||||
expect(g).toBe(b);
|
||||
});
|
||||
|
||||
it("mix in oklab space", { timeout: 5000 }, () => {
|
||||
const mixed = new Colour("#ff0000").mix("#0000ff", 50, "oklab");
|
||||
expect(mixed.toRgba().r).toBeGreaterThan(0);
|
||||
expect(mixed.toRgba().b).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mix with defaults", { timeout: 5000 }, () => {
|
||||
const mixed = new Colour("#ff0000").mix("#0000ff");
|
||||
expect(mixed.toRgba().r).toBeGreaterThan(0);
|
||||
expect(mixed.toRgba().b).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mix with Colour object", { timeout: 5000 }, () => {
|
||||
const mixed = new Colour("#ff0000").mix(new Colour("#0000ff"));
|
||||
expect(mixed.toRgba().r).toBeGreaterThan(0);
|
||||
expect(mixed.toRgba().b).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mix in rgb space", { timeout: 5000 }, () => {
|
||||
const mixed = new Colour("#ff0000").mix("#0000ff", 50, "rgb");
|
||||
expect(mixed.toRgba().r).toBeGreaterThan(0);
|
||||
expect(mixed.toRgba().b).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mix in rgb space with zero alpha", { timeout: 5000 }, () => {
|
||||
const mixed = new Colour({ r: 255, g: 0, b: 0, a: 0 }).mix(
|
||||
{ r: 0, g: 0, b: 255, a: 0 },
|
||||
50,
|
||||
"rgb"
|
||||
);
|
||||
expect(mixed.toRgba().a).toBe(0);
|
||||
});
|
||||
|
||||
it("contrast ratio between black and white", { timeout: 5000 }, () => {
|
||||
const black = new Colour("#000000");
|
||||
const white = new Colour("#ffffff");
|
||||
expect(black.contrast(white)).toBeCloseTo(21, 0);
|
||||
});
|
||||
|
||||
it("isDark and isLight", { timeout: 5000 }, () => {
|
||||
expect(new Colour("#000000").isDark()).toBe(true);
|
||||
expect(new Colour("#000000").isLight()).toBe(false);
|
||||
expect(new Colour("#ffffff").isLight()).toBe(true);
|
||||
});
|
||||
|
||||
it("bestContrast selects highest ratio", { timeout: 5000 }, () => {
|
||||
const red = new Colour("#ff0000");
|
||||
const best = red.bestContrast("#000000", "#ffffff");
|
||||
expect(best.toHex()).toBe("#000000");
|
||||
});
|
||||
|
||||
it("bestContrast updates best when later option wins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const red = new Colour("#ff0000");
|
||||
// white has lower contrast with red than black, so second option wins
|
||||
const best = red.bestContrast("#ffffff", "#000000");
|
||||
expect(best.toHex()).toBe("#000000");
|
||||
});
|
||||
|
||||
it("bestContrast returns Colour object directly", { timeout: 5000 }, () => {
|
||||
const red = new Colour("#ff0000");
|
||||
const best = red.bestContrast(
|
||||
new Colour("#ffffff"),
|
||||
new Colour("#000000")
|
||||
);
|
||||
expect(best.toHex()).toBe("#000000");
|
||||
});
|
||||
|
||||
it("distance in oklab", { timeout: 5000 }, () => {
|
||||
const red = new Colour("#ff0000");
|
||||
const blue = new Colour("#0000ff");
|
||||
expect(red.distance(blue, "oklab")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("distance with string input", { timeout: 5000 }, () => {
|
||||
const red = new Colour("#ff0000");
|
||||
expect(red.distance("#0000ff", "oklab")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("distance in euclidean", { timeout: 5000 }, () => {
|
||||
const black = new Colour("#000000");
|
||||
const white = new Colour("#ffffff");
|
||||
expect(black.distance(white, "euclidean")).toBeCloseTo(
|
||||
Math.sqrt(3 * 255 * 255),
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it("equals within epsilon", { timeout: 5000 }, () => {
|
||||
const c1 = new Colour("#ff0000");
|
||||
const c2 = new Colour("#ff0100");
|
||||
expect(c1.equals(c2, 2)).toBe(true);
|
||||
expect(c1.equals(c2, 0.1)).toBe(false);
|
||||
});
|
||||
|
||||
it("equals with Colour object", { timeout: 5000 }, () => {
|
||||
const c1 = new Colour("#ff0000");
|
||||
expect(c1.equals(c1)).toBe(true);
|
||||
});
|
||||
|
||||
it("equals with string input", { timeout: 5000 }, () => {
|
||||
const c1 = new Colour("#ff0000");
|
||||
expect(c1.equals("#ff0000")).toBe(true);
|
||||
});
|
||||
|
||||
it("clone produces equal colour", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ff0000");
|
||||
expect(c.clone().equals(c)).toBe(true);
|
||||
});
|
||||
|
||||
it("hex round-trip", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ff9900");
|
||||
expect(c.toHex()).toBe("#ff9900");
|
||||
});
|
||||
|
||||
it("toHex8 includes alpha", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ff9900").alpha(1);
|
||||
expect(c.toHex8()).toBe("#ff9900ff");
|
||||
});
|
||||
|
||||
it("toRgb formats correctly", { timeout: 5000 }, () => {
|
||||
expect(new Colour("#ff9900").toRgb()).toBe("rgb(255 153 0)");
|
||||
});
|
||||
|
||||
it("toHsl formats correctly", { timeout: 5000 }, () => {
|
||||
const hsl = new Colour("#ff0000").toHsl();
|
||||
expect(hsl).toContain("hsl(");
|
||||
});
|
||||
|
||||
it("toString with format", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ff0000");
|
||||
expect(c.toString("hex")).toBe("#ff0000");
|
||||
expect(c.toString("rgb")).toBe("rgb(255 0 0)");
|
||||
expect(c.toString("named")).toBe("red");
|
||||
});
|
||||
|
||||
it("toJSON returns RGBA", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ff0000");
|
||||
expect(c.toJSON()).toEqual({ r: 255, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("valueOf returns hex", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ff0000");
|
||||
expect(c.valueOf()).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("Symbol.toPrimitive returns hex or number", { timeout: 5000 }, () => {
|
||||
const c = new Colour("#ff0000");
|
||||
expect((c as any)[Symbol.toPrimitive]("default")).toBe("#ff0000");
|
||||
expect((c as any)[Symbol.toPrimitive]("number")).toBe(0xff0000);
|
||||
});
|
||||
|
||||
it("luminance of black is 0", { timeout: 5000 }, () => {
|
||||
expect(new Colour("#000000").luminance()).toBe(0);
|
||||
});
|
||||
|
||||
it("luminance of white is 1", { timeout: 5000 }, () => {
|
||||
expect(new Colour("#ffffff").luminance()).toBe(1);
|
||||
});
|
||||
|
||||
it("perceivedBrightness", { timeout: 5000 }, () => {
|
||||
expect(new Colour("#000000").perceivedBrightness()).toBe(0);
|
||||
expect(new Colour("#ffffff").perceivedBrightness()).toBe(1);
|
||||
});
|
||||
|
||||
it("distance in cie76", { timeout: 5000 }, () => {
|
||||
const red = new Colour("#ff0000");
|
||||
const blue = new Colour("#0000ff");
|
||||
expect(red.distance(blue, "cie76")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("distance in cie94", { timeout: 5000 }, () => {
|
||||
const red = new Colour("#ff0000");
|
||||
const blue = new Colour("#0000ff");
|
||||
expect(red.distance(blue, "cie94")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("distance accepts Colour object directly", { timeout: 5000 }, () => {
|
||||
const red = new Colour("#ff0000");
|
||||
const blue = new Colour("#0000ff");
|
||||
expect(red.distance(blue, "oklab")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("distance with default mode", { timeout: 5000 }, () => {
|
||||
const red = new Colour("#ff0000");
|
||||
const blue = new Colour("#0000ff");
|
||||
expect(red.distance(blue)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("bestContrast throws without options", { timeout: 5000 }, () => {
|
||||
expect(() => new Colour("#ff0000").bestContrast()).toThrow();
|
||||
});
|
||||
|
||||
it("toHex3 returns shorthand", { timeout: 5000 }, () => {
|
||||
expect(new Colour("#ff0000").toHex3()).toBe("#f00");
|
||||
});
|
||||
|
||||
it("toHsv returns hsv string", { timeout: 5000 }, () => {
|
||||
expect(new Colour("#ff0000").toHsv()).toMatch(/^hsv\(/);
|
||||
});
|
||||
|
||||
it("toLab returns lab string", { timeout: 5000 }, () => {
|
||||
expect(new Colour("#ff0000").toLab()).toMatch(/^lab\(/);
|
||||
});
|
||||
|
||||
it("toLch returns lch string", { timeout: 5000 }, () => {
|
||||
expect(new Colour("#ff0000").toLch()).toMatch(/^lch\(/);
|
||||
});
|
||||
|
||||
it("toOklab returns oklab string", { timeout: 5000 }, () => {
|
||||
expect(new Colour("#ff0000").toOklab()).toMatch(/^oklab\(/);
|
||||
});
|
||||
|
||||
it("toOklch returns oklch string", { timeout: 5000 }, () => {
|
||||
expect(new Colour("#ff0000").toOklch()).toMatch(/^oklch\(/);
|
||||
});
|
||||
|
||||
it("toCmyk returns cmyk string", { timeout: 5000 }, () => {
|
||||
expect(new Colour("#ff0000").toCmyk()).toMatch(/^cmyk\(/);
|
||||
});
|
||||
|
||||
it("mix in hsl space", { timeout: 5000 }, () => {
|
||||
const mixed = new Colour("#ff0000").mix("#0000ff", 50, "hsl");
|
||||
// HSL interpolation of red (h=0) and blue (h=240) at 50% gives green (h=120)
|
||||
expect(mixed.toRgba().g).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mix in lab space", { timeout: 5000 }, () => {
|
||||
const mixed = new Colour("#ff0000").mix("#0000ff", 50, "lab");
|
||||
expect(mixed.toRgba().r).toBeGreaterThan(0);
|
||||
expect(mixed.toRgba().b).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mix in lch space", { timeout: 5000 }, () => {
|
||||
const mixed = new Colour("#ff0000").mix("#0000ff", 50, "lch");
|
||||
expect(mixed.toRgba().r).toBeGreaterThan(0);
|
||||
expect(mixed.toRgba().b).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mix in oklch space", { timeout: 5000 }, () => {
|
||||
const mixed = new Colour("#ff0000").mix("#0000ff", 50, "oklch");
|
||||
expect(mixed.toRgba().r).toBeGreaterThan(0);
|
||||
expect(mixed.toRgba().b).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("constructs from another Colour", { timeout: 5000 }, () => {
|
||||
const original = new Colour("#ff0000");
|
||||
const copy = new Colour(original);
|
||||
expect(copy.equals(original)).toBe(true);
|
||||
});
|
||||
});
|
||||
400
packages/media/image/colours/src/colour/colour.ts
Normal file
400
packages/media/image/colours/src/colour/colour.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import * as Conversions from "../conversions/conversions.js";
|
||||
import { colourInvalid } from "../errors/errors.js";
|
||||
import { parseColour } from "../parser/parser.js";
|
||||
import type {
|
||||
DistanceMode,
|
||||
MixMode,
|
||||
RGBA,
|
||||
} from "../types/colour-types/colour-types.js";
|
||||
import { clamp, roundAlpha, roundChannel } from "../utils/utils.js";
|
||||
import * as Formatters from "./colour-formatters.js";
|
||||
|
||||
export class Colour {
|
||||
private readonly _rgba: RGBA;
|
||||
|
||||
constructor(input: Colour | string | RGBA = "#000000") {
|
||||
if (input instanceof Colour) {
|
||||
this._rgba = input._rgba;
|
||||
} else if (typeof input === "string") {
|
||||
this._rgba = parseColour(input);
|
||||
} else {
|
||||
this._rgba = {
|
||||
r: input.r,
|
||||
g: input.g,
|
||||
b: input.b,
|
||||
a: input.a ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private get rgba(): RGBA {
|
||||
return { ...this._rgba };
|
||||
}
|
||||
|
||||
lighten(amount: number): Colour {
|
||||
const hsl = Conversions.rgbToHsl(this.rgba);
|
||||
return new Colour(
|
||||
Conversions.hslToRgb({ ...hsl, l: clamp(hsl.l + amount, 0, 100) })
|
||||
);
|
||||
}
|
||||
|
||||
darken(amount: number): Colour {
|
||||
return this.lighten(-amount);
|
||||
}
|
||||
|
||||
saturate(amount: number): Colour {
|
||||
const hsl = Conversions.rgbToHsl(this.rgba);
|
||||
return new Colour(
|
||||
Conversions.hslToRgb({ ...hsl, s: clamp(hsl.s + amount, 0, 100) })
|
||||
);
|
||||
}
|
||||
|
||||
desaturate(amount: number): Colour {
|
||||
return this.saturate(-amount);
|
||||
}
|
||||
|
||||
rotate(degrees: number): Colour {
|
||||
const hsl = Conversions.rgbToHsl(this.rgba);
|
||||
return new Colour(
|
||||
Conversions.hslToRgb({ ...hsl, h: (hsl.h + degrees) % 360 })
|
||||
);
|
||||
}
|
||||
|
||||
alpha(value: number): Colour {
|
||||
return new Colour({ ...this.rgba, a: roundAlpha(value) });
|
||||
}
|
||||
|
||||
fade(amount: number): Colour {
|
||||
return this.alpha(this._rgba.a - amount / 100);
|
||||
}
|
||||
|
||||
opaquer(amount: number): Colour {
|
||||
return this.fade(-amount);
|
||||
}
|
||||
|
||||
negate(): Colour {
|
||||
const { r, g, b, a } = this.rgba;
|
||||
return new Colour({ r: 255 - r, g: 255 - g, b: 255 - b, a });
|
||||
}
|
||||
|
||||
grayscale(): Colour {
|
||||
const { r, g, b, a } = this.rgba;
|
||||
const y = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
|
||||
return new Colour({ r: y, g: y, b: y, a });
|
||||
}
|
||||
|
||||
mix(other: Colour | string, weight = 50, mode: MixMode = "oklab"): Colour {
|
||||
const w = clamp(weight, 0, 100) / 100;
|
||||
const w1 = w;
|
||||
const w2 = 1 - w;
|
||||
const c2 = other instanceof Colour ? other : new Colour(other);
|
||||
|
||||
if (mode === "rgb") {
|
||||
const a1 = this._rgba.a * w1;
|
||||
const a2 = c2._rgba.a * w2;
|
||||
const a = a1 + a2;
|
||||
const invA = a === 0 ? 0 : 1 / a;
|
||||
return new Colour({
|
||||
r: roundChannel((this._rgba.r * a1 + c2._rgba.r * a2) * invA),
|
||||
g: roundChannel((this._rgba.g * a1 + c2._rgba.g * a2) * invA),
|
||||
b: roundChannel((this._rgba.b * a1 + c2._rgba.b * a2) * invA),
|
||||
a,
|
||||
});
|
||||
}
|
||||
|
||||
const toSpace = (c: Colour) => {
|
||||
switch (mode) {
|
||||
case "hsl":
|
||||
return Conversions.rgbToHsl(c._rgba);
|
||||
case "lab":
|
||||
return Conversions.rgbToLab(c._rgba);
|
||||
case "oklab":
|
||||
return Conversions.rgbToOklab(c._rgba);
|
||||
case "lch":
|
||||
return Conversions.labToLch(Conversions.rgbToLab(c._rgba));
|
||||
case "oklch":
|
||||
return Conversions.oklabToOklch(
|
||||
Conversions.rgbToOklab(c._rgba)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const s1 = toSpace(this);
|
||||
const s2 = toSpace(c2);
|
||||
|
||||
const interpolate = (a: number, b: number, key: string): number => {
|
||||
if ((mode === "lch" || mode === "oklch") && key === "h") {
|
||||
const diff = ((b - a + 540) % 360) - 180;
|
||||
return a + diff * w2;
|
||||
}
|
||||
return a * w1 + b * w2;
|
||||
};
|
||||
|
||||
const keys: string[] =
|
||||
mode === "hsl"
|
||||
? ["h", "s", "l"]
|
||||
: mode === "lab"
|
||||
? ["l", "a", "b"]
|
||||
: mode === "oklab"
|
||||
? ["L", "a", "b"]
|
||||
: mode === "lch"
|
||||
? ["l", "c", "h"]
|
||||
: ["L", "C", "h"];
|
||||
const mixed = Object.fromEntries(
|
||||
keys.map((k) => [
|
||||
k,
|
||||
interpolate(
|
||||
(s1 as unknown as Record<string, number>)[k]!,
|
||||
(s2 as unknown as Record<string, number>)[k]!,
|
||||
k
|
||||
),
|
||||
])
|
||||
) as Record<string, number>;
|
||||
|
||||
const alpha = this._rgba.a * w1 + c2._rgba.a * w2;
|
||||
|
||||
const fromSpace = (space: Record<string, number>): RGBA => {
|
||||
switch (mode) {
|
||||
case "hsl":
|
||||
return Conversions.hslToRgb({
|
||||
h: space["h"]!,
|
||||
s: space["s"]!,
|
||||
l: space["l"]!,
|
||||
a: alpha,
|
||||
});
|
||||
case "lab":
|
||||
return {
|
||||
...Conversions.labToRgb({
|
||||
l: space["l"]!,
|
||||
a: space["a"]!,
|
||||
b: space["b"]!,
|
||||
}),
|
||||
a: alpha,
|
||||
};
|
||||
case "oklab":
|
||||
return {
|
||||
...Conversions.oklabToRgb({
|
||||
L: space["L"]!,
|
||||
a: space["a"]!,
|
||||
b: space["b"]!,
|
||||
}),
|
||||
a: alpha,
|
||||
};
|
||||
case "lch":
|
||||
return {
|
||||
...Conversions.lchToRgb({
|
||||
l: space["l"]!,
|
||||
c: space["c"]!,
|
||||
h: space["h"]!,
|
||||
}),
|
||||
a: alpha,
|
||||
};
|
||||
case "oklch":
|
||||
return {
|
||||
...Conversions.oklchToRgb({
|
||||
L: space["L"]!,
|
||||
C: space["C"]!,
|
||||
h: space["h"]!,
|
||||
}),
|
||||
a: alpha,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return new Colour(fromSpace(mixed));
|
||||
}
|
||||
|
||||
luminance(): number {
|
||||
const { r, g, b } = this._rgba;
|
||||
const [R, G, B] = [r, g, b].map((v) => {
|
||||
v /= 255;
|
||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
}) as [number, number, number];
|
||||
return R * 0.2126 + G * 0.7152 + B * 0.0722;
|
||||
}
|
||||
|
||||
perceivedBrightness(): number {
|
||||
const { r, g, b } = this._rgba;
|
||||
return (r * 0.299 + g * 0.587 + b * 0.114) / 255;
|
||||
}
|
||||
|
||||
contrast(other: Colour | string): number {
|
||||
const c2 = other instanceof Colour ? other : new Colour(other);
|
||||
const lum1 = this.luminance();
|
||||
const lum2 = c2.luminance();
|
||||
const brightest = Math.max(lum1, lum2);
|
||||
const darkest = Math.min(lum1, lum2);
|
||||
return (brightest + 0.05) / (darkest + 0.05);
|
||||
}
|
||||
|
||||
isDark(): boolean {
|
||||
return this.luminance() < 0.179;
|
||||
}
|
||||
|
||||
isLight(): boolean {
|
||||
return !this.isDark();
|
||||
}
|
||||
|
||||
bestContrast(...options: (Colour | string)[]): Colour {
|
||||
if (options.length === 0) {
|
||||
throw colourInvalid("bestContrast requires at least one option");
|
||||
}
|
||||
|
||||
let best = options[0]!;
|
||||
let bestRatio = this.contrast(best);
|
||||
|
||||
for (let i = 1; i < options.length; i++) {
|
||||
const ratio = this.contrast(options[i]!);
|
||||
if (ratio > bestRatio) {
|
||||
bestRatio = ratio;
|
||||
best = options[i]!;
|
||||
}
|
||||
}
|
||||
|
||||
return best instanceof Colour ? best : new Colour(best);
|
||||
}
|
||||
|
||||
distance(other: Colour | string, mode: DistanceMode = "oklab"): number {
|
||||
const c2 = other instanceof Colour ? other : new Colour(other);
|
||||
|
||||
switch (mode) {
|
||||
case "euclidean": {
|
||||
const { r: r1, g: g1, b: b1 } = this._rgba;
|
||||
const { r: r2, g: g2, b: b2 } = c2._rgba;
|
||||
return Math.sqrt(
|
||||
(r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2
|
||||
);
|
||||
}
|
||||
case "cie76": {
|
||||
const lab1 = Conversions.rgbToLab(this._rgba);
|
||||
const lab2 = Conversions.rgbToLab(c2._rgba);
|
||||
return Math.sqrt(
|
||||
(lab1.l - lab2.l) ** 2 +
|
||||
(lab1.a - lab2.a) ** 2 +
|
||||
(lab1.b - lab2.b) ** 2
|
||||
);
|
||||
}
|
||||
case "oklab": {
|
||||
const ok1 = Conversions.rgbToOklab(this._rgba);
|
||||
const ok2 = Conversions.rgbToOklab(c2._rgba);
|
||||
return Math.sqrt(
|
||||
(ok1.L - ok2.L) ** 2 +
|
||||
(ok1.a - ok2.a) ** 2 +
|
||||
(ok1.b - ok2.b) ** 2
|
||||
);
|
||||
}
|
||||
case "cie94": {
|
||||
const lab1 = Conversions.rgbToLab(this._rgba);
|
||||
const lab2 = Conversions.rgbToLab(c2._rgba);
|
||||
const dL = lab1.l - lab2.l;
|
||||
const da = lab1.a - lab2.a;
|
||||
const db = lab1.b - lab2.b;
|
||||
const c1 = Math.sqrt(lab1.a ** 2 + lab1.b ** 2);
|
||||
const c2v = Math.sqrt(lab2.a ** 2 + lab2.b ** 2);
|
||||
const dC = c1 - c2v;
|
||||
const dH = Math.sqrt(Math.max(0, da ** 2 + db ** 2 - dC ** 2));
|
||||
const sC = 1 + 0.045 * c1;
|
||||
const sH = 1 + 0.015 * c1;
|
||||
return Math.sqrt(dL ** 2 + (dC / sC) ** 2 + (dH / sH) ** 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
equals(other: Colour | string, epsilon = 0.01): boolean {
|
||||
const c2 = other instanceof Colour ? other : new Colour(other);
|
||||
const { r: r1, g: g1, b: b1, a: a1 } = this._rgba;
|
||||
const { r: r2, g: g2, b: b2, a: a2 } = c2._rgba;
|
||||
return (
|
||||
Math.abs(r1 - r2) <= epsilon &&
|
||||
Math.abs(g1 - g2) <= epsilon &&
|
||||
Math.abs(b1 - b2) <= epsilon &&
|
||||
Math.abs(a1 - a2) <= epsilon
|
||||
);
|
||||
}
|
||||
|
||||
clone(): Colour {
|
||||
return new Colour(this._rgba);
|
||||
}
|
||||
|
||||
toHex(): string {
|
||||
return Formatters.formatHex(this._rgba);
|
||||
}
|
||||
|
||||
toHex8(): string {
|
||||
return Formatters.formatHex8(this._rgba);
|
||||
}
|
||||
|
||||
toRgb(): string {
|
||||
return Formatters.formatRgb(this._rgba);
|
||||
}
|
||||
|
||||
toRgba(): RGBA {
|
||||
return this.rgba;
|
||||
}
|
||||
|
||||
toHsl(): string {
|
||||
return Formatters.formatHsl(this._rgba);
|
||||
}
|
||||
|
||||
toHex3(): string | null {
|
||||
return Formatters.formatHex3(this._rgba);
|
||||
}
|
||||
|
||||
toHsv(): string {
|
||||
return Formatters.formatHsv(this._rgba);
|
||||
}
|
||||
|
||||
toLab(): string {
|
||||
return Formatters.formatLab(this._rgba);
|
||||
}
|
||||
|
||||
toLch(): string {
|
||||
return Formatters.formatLch(this._rgba);
|
||||
}
|
||||
|
||||
toOklab(): string {
|
||||
return Formatters.formatOklab(this._rgba);
|
||||
}
|
||||
|
||||
toOklch(): string {
|
||||
return Formatters.formatOklch(this._rgba);
|
||||
}
|
||||
|
||||
toCmyk(): string {
|
||||
return Formatters.formatCmyk(this._rgba);
|
||||
}
|
||||
|
||||
toString(
|
||||
format?:
|
||||
| "hex"
|
||||
| "hex3"
|
||||
| "hex8"
|
||||
| "rgb"
|
||||
| "rgba"
|
||||
| "hsl"
|
||||
| "hsla"
|
||||
| "hsv"
|
||||
| "lab"
|
||||
| "lch"
|
||||
| "oklab"
|
||||
| "oklch"
|
||||
| "cmyk"
|
||||
| "named"
|
||||
): string {
|
||||
return Formatters.formatColour(this._rgba, format);
|
||||
}
|
||||
|
||||
toJSON(): RGBA {
|
||||
return this.rgba;
|
||||
}
|
||||
|
||||
valueOf(): string {
|
||||
return this.toHex();
|
||||
}
|
||||
|
||||
[Symbol.toPrimitive](hint: string): string | number {
|
||||
if (hint === "number")
|
||||
return Number.parseInt(this.toHex().slice(1), 16);
|
||||
return this.toHex();
|
||||
}
|
||||
}
|
||||
1
packages/media/image/colours/src/colour/index.ts
Normal file
1
packages/media/image/colours/src/colour/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Colour } from "./colour.js";
|
||||
76
packages/media/image/colours/src/conversions/conversions.md
Normal file
76
packages/media/image/colours/src/conversions/conversions.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# conversions
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`conversions.ts` provides deterministic, side-effect-free mathematical transforms between colour spaces. Every function takes a tuple of numbers and returns a tuple of numbers. No object mutation, no I/O, no dependency on any other package logic except basic math. These functions form the numerical foundation for the `Colour` class.
|
||||
|
||||
## Features
|
||||
|
||||
- RGB to HSL/HSLA and back
|
||||
- RGB to HSV and back
|
||||
- RGB to XYZ and back
|
||||
- XYZ to LAB/LCH and back
|
||||
- RGB to OKLAB/OKLCH and back
|
||||
- RGB to CMYK
|
||||
- Alpha passthrough on every conversion
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rgbToHsl, hslToRgb } from "@suaveplan/colours/conversions";
|
||||
|
||||
const hsl = rgbToHsl({ r: 255, g: 128, b: 0 });
|
||||
const back = hslToRgb(hsl);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { colourSteps } from "@suaveplan/colours/colour-factories";
|
||||
|
||||
const gradient = colourSteps("#ff0000", "#0000ff", 5);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### RGB ↔ HSL
|
||||
|
||||
- `rgbToHsl(rgb: RGB): HSL`
|
||||
- `hslToRgb(hsl: HSL): RGBA`
|
||||
|
||||
### RGB ↔ HSV
|
||||
|
||||
- `rgbToHsv(rgb: RGB): HSV`
|
||||
- `hsvToRgb(hsv: HSV, alpha?: number): RGBA`
|
||||
|
||||
### RGB ↔ XYZ
|
||||
|
||||
- `rgbToXyz(rgb: RGB): XYZ`
|
||||
- `xyzToRgb(xyz: XYZ, alpha?: number): RGBA`
|
||||
|
||||
### XYZ ↔ LAB/LCH
|
||||
|
||||
- `xyzToLab(xyz: XYZ): LAB`
|
||||
- `labToXyz(lab: LAB): XYZ`
|
||||
- `labToLch(lab: LAB): LCH`
|
||||
- `lchToLab(lch: LCH): LAB`
|
||||
|
||||
### RGB ↔ OKLAB/OKLCH
|
||||
|
||||
- `rgbToOklab(rgb: RGB): OKLAB`
|
||||
- `oklabToRgb(oklab: OKLAB, alpha?: number): RGBA`
|
||||
- `oklabToOklch(oklab: OKLAB): OKLCH`
|
||||
- `oklchToRgb(oklch: OKLCH): RGBA`
|
||||
|
||||
### RGB ↔ CMYK
|
||||
|
||||
- `rgbToCmyk(rgb: RGB): CMYK`
|
||||
- `cmykToRgb(cmyk: CMYK): RGBA`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All transforms use double-precision arithmetic and standard colour-science matrices. RGB values are normalised to `[0,1]` before matrix multiplication and clamped back to `[0,255]` afterwards. OKLAB uses the Bradley coefficients. CMYK uses the simple 100% minus RGB ratio formula. Alpha is never modified by colour-space conversion.
|
||||
173
packages/media/image/colours/src/conversions/conversions.test.ts
Normal file
173
packages/media/image/colours/src/conversions/conversions.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as Conversions from "./conversions.js";
|
||||
|
||||
describe("conversions", () => {
|
||||
it("rgb to hsl round-trip", { timeout: 5000 }, () => {
|
||||
const rgb = { r: 200, g: 100, b: 50 };
|
||||
const hsl = Conversions.rgbToHsl({ ...rgb, a: 1 });
|
||||
const back = Conversions.hslToRgb(hsl);
|
||||
expect(back.r).toBeCloseTo(rgb.r, -1);
|
||||
expect(back.g).toBeCloseTo(rgb.g, -1);
|
||||
expect(back.b).toBeCloseTo(rgb.b, -1);
|
||||
});
|
||||
|
||||
it("rgbToHsl without alpha defaults to 1", { timeout: 5000 }, () => {
|
||||
const hsl = Conversions.rgbToHsl({ r: 255, g: 0, b: 0 } as any);
|
||||
expect(hsl.a).toBe(1);
|
||||
});
|
||||
|
||||
it("rgb to hsv round-trip", { timeout: 5000 }, () => {
|
||||
const rgb = { r: 200, g: 100, b: 50 };
|
||||
const hsv = Conversions.rgbToHsv(rgb);
|
||||
const back = Conversions.hsvToRgb({ ...hsv, a: 1 });
|
||||
expect(back.r).toBeCloseTo(rgb.r, -1);
|
||||
expect(back.g).toBeCloseTo(rgb.g, -1);
|
||||
expect(back.b).toBeCloseTo(rgb.b, -1);
|
||||
});
|
||||
|
||||
it("hsvToRgb without alpha defaults to 1", { timeout: 5000 }, () => {
|
||||
const rgb = Conversions.hsvToRgb({ h: 0, s: 100, v: 100 } as any);
|
||||
expect(rgb.a).toBe(1);
|
||||
});
|
||||
|
||||
it("rgb to xyz round-trip", { timeout: 5000 }, () => {
|
||||
const rgb = { r: 128, g: 64, b: 32 };
|
||||
const xyz = Conversions.rgbToXyz(rgb);
|
||||
const back = Conversions.xyzToRgb(xyz);
|
||||
expect(back.r).toBeCloseTo(rgb.r, -1);
|
||||
expect(back.g).toBeCloseTo(rgb.g, -1);
|
||||
expect(back.b).toBeCloseTo(rgb.b, -1);
|
||||
});
|
||||
|
||||
it("xyz to lab round-trip", { timeout: 5000 }, () => {
|
||||
const xyz = { x: 50, y: 30, z: 20 };
|
||||
const lab = Conversions.xyzToLab(xyz);
|
||||
const back = Conversions.labToXyz(lab);
|
||||
expect(back.x).toBeCloseTo(xyz.x, 0);
|
||||
expect(back.y).toBeCloseTo(xyz.y, 0);
|
||||
expect(back.z).toBeCloseTo(xyz.z, 0);
|
||||
});
|
||||
|
||||
it("lab to lch preserves chroma and hue", { timeout: 5000 }, () => {
|
||||
const lab = { l: 50, a: 20, b: -30 };
|
||||
const lch = Conversions.labToLch(lab);
|
||||
expect(lch.c).toBeGreaterThan(0);
|
||||
expect(lch.h).toBeGreaterThanOrEqual(0);
|
||||
expect(lch.h).toBeLessThan(360);
|
||||
});
|
||||
|
||||
it("lab to lch round-trip", { timeout: 5000 }, () => {
|
||||
const lab = { l: 50, a: 20, b: -30 };
|
||||
const lch = Conversions.labToLch(lab);
|
||||
const back = Conversions.lchToLab(lch);
|
||||
expect(back.l).toBeCloseTo(lab.l, 0);
|
||||
expect(back.a).toBeCloseTo(lab.a, 0);
|
||||
expect(back.b).toBeCloseTo(lab.b, 0);
|
||||
});
|
||||
|
||||
it("rgb to oklab round-trip", { timeout: 5000 }, () => {
|
||||
const rgb = { r: 128, g: 64, b: 32 };
|
||||
const oklab = Conversions.rgbToOklab(rgb);
|
||||
const back = Conversions.oklabToRgb(oklab);
|
||||
expect(back.r).toBeCloseTo(rgb.r, -1);
|
||||
expect(back.g).toBeCloseTo(rgb.g, -1);
|
||||
expect(back.b).toBeCloseTo(rgb.b, -1);
|
||||
});
|
||||
|
||||
it("oklab to oklch round-trip", { timeout: 5000 }, () => {
|
||||
const oklab = { L: 0.5, a: 0.1, b: -0.2 };
|
||||
const oklch = Conversions.oklabToOklch(oklab);
|
||||
const back = Conversions.oklchToRgb(oklch);
|
||||
expect(back.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rgb to cmyk round-trip", { timeout: 5000 }, () => {
|
||||
const rgb = { r: 128, g: 64, b: 32 };
|
||||
const cmyk = Conversions.rgbToCmyk(rgb);
|
||||
const back = Conversions.cmykToRgb(cmyk);
|
||||
expect(back.r).toBeCloseTo(rgb.r, -1);
|
||||
expect(back.g).toBeCloseTo(rgb.g, -1);
|
||||
expect(back.b).toBeCloseTo(rgb.b, -1);
|
||||
});
|
||||
|
||||
it("lab to rgb", { timeout: 5000 }, () => {
|
||||
const rgb = Conversions.labToRgb({ l: 50, a: 0, b: 0 });
|
||||
expect(rgb.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("lch to rgb", { timeout: 5000 }, () => {
|
||||
const rgb = Conversions.lchToRgb({ l: 50, c: 10, h: 180 });
|
||||
expect(rgb.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hslToRgb with zero saturation returns gray", { timeout: 5000 }, () => {
|
||||
const rgb = Conversions.hslToRgb({ h: 120, s: 0, l: 50, a: 1 });
|
||||
expect(rgb.r).toBe(128);
|
||||
expect(rgb.g).toBe(128);
|
||||
expect(rgb.b).toBe(128);
|
||||
});
|
||||
|
||||
it("hslToRgb without alpha defaults to 1", { timeout: 5000 }, () => {
|
||||
const rgb = Conversions.hslToRgb({ h: 0, s: 100, l: 50 } as any);
|
||||
expect(rgb.a).toBe(1);
|
||||
});
|
||||
|
||||
it("rgbToHsv with gray returns zero saturation", { timeout: 5000 }, () => {
|
||||
const hsv = Conversions.rgbToHsv({ r: 128, g: 128, b: 128 });
|
||||
expect(hsv.s).toBe(0);
|
||||
expect(hsv.v).toBeCloseTo(50, 0);
|
||||
});
|
||||
|
||||
it("rgbToLab converts red", { timeout: 5000 }, () => {
|
||||
const lab = Conversions.rgbToLab({ r: 255, g: 0, b: 0 });
|
||||
expect(lab.l).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rgbToHsl when green is max", { timeout: 5000 }, () => {
|
||||
const hsl = Conversions.rgbToHsl({ r: 0, g: 255, b: 0, a: 1 });
|
||||
expect(hsl.h).toBeCloseTo(120, 0);
|
||||
});
|
||||
|
||||
it("rgbToHsl when blue is max", { timeout: 5000 }, () => {
|
||||
const hsl = Conversions.rgbToHsl({ r: 0, g: 0, b: 255, a: 1 });
|
||||
expect(hsl.h).toBeCloseTo(240, 0);
|
||||
});
|
||||
|
||||
it("rgbToHsv when green is max", { timeout: 5000 }, () => {
|
||||
const hsv = Conversions.rgbToHsv({ r: 0, g: 255, b: 0 });
|
||||
expect(hsv.h).toBeCloseTo(120, 0);
|
||||
});
|
||||
|
||||
it("rgbToHsv when blue is max", { timeout: 5000 }, () => {
|
||||
const hsv = Conversions.rgbToHsv({ r: 0, g: 0, b: 255 });
|
||||
expect(hsv.h).toBeCloseTo(240, 0);
|
||||
});
|
||||
|
||||
it("rgbToHsv of black has zero saturation", { timeout: 5000 }, () => {
|
||||
const hsv = Conversions.rgbToHsv({ r: 0, g: 0, b: 0 });
|
||||
expect(hsv.s).toBe(0);
|
||||
expect(hsv.v).toBe(0);
|
||||
});
|
||||
|
||||
it("rgbToHsv when red max and green less than blue", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hsv = Conversions.rgbToHsv({ r: 255, g: 0, b: 128 });
|
||||
expect(hsv.h).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it("rgbToHsl when red max and green less than blue", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hsl = Conversions.rgbToHsl({ r: 255, g: 0, b: 128, a: 1 });
|
||||
expect(hsl.h).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it("rgbToCmyk of pure black", { timeout: 5000 }, () => {
|
||||
const cmyk = Conversions.rgbToCmyk({ r: 0, g: 0, b: 0 });
|
||||
expect(cmyk.c).toBe(0);
|
||||
expect(cmyk.m).toBe(0);
|
||||
expect(cmyk.y).toBe(0);
|
||||
expect(cmyk.k).toBe(100);
|
||||
});
|
||||
});
|
||||
346
packages/media/image/colours/src/conversions/conversions.ts
Normal file
346
packages/media/image/colours/src/conversions/conversions.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import type {
|
||||
HSLA,
|
||||
HSV,
|
||||
LAB,
|
||||
LCH,
|
||||
OKLAB,
|
||||
OKLCH,
|
||||
RGB,
|
||||
RGBA,
|
||||
} from "../types/colour-types/colour-types.js";
|
||||
import { clamp, roundChannel } from "../utils/utils.js";
|
||||
|
||||
export function rgbToHsl({ r, g, b, a = 1 }: RGBA): HSLA {
|
||||
const R = r / 255;
|
||||
const G = g / 255;
|
||||
const B = b / 255;
|
||||
const max = Math.max(R, G, B);
|
||||
const min = Math.min(R, G, B);
|
||||
const d = max - min;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (d !== 0) {
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case R:
|
||||
h = (G - B) / d + (G < B ? 6 : 0);
|
||||
break;
|
||||
case G:
|
||||
h = (B - R) / d + 2;
|
||||
break;
|
||||
case B:
|
||||
h = (R - G) / d + 4;
|
||||
break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return { h: h * 360, s: s * 100, l: l * 100, a };
|
||||
}
|
||||
|
||||
export function hslToRgb({ h, s, l, a = 1 }: HSLA): RGBA {
|
||||
const H = (((h % 360) + 360) % 360) / 360;
|
||||
const S = clamp(s, 0, 100) / 100;
|
||||
const L = clamp(l, 0, 100) / 100;
|
||||
|
||||
if (S === 0) {
|
||||
const v = Math.round(L * 255);
|
||||
return { r: v, g: v, b: v, a };
|
||||
}
|
||||
|
||||
const q = L < 0.5 ? L * (1 + S) : L + S - L * S;
|
||||
const p = 2 * L - q;
|
||||
|
||||
const hue2rgb = (t: number) => {
|
||||
t = ((t % 1) + 1) % 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
return {
|
||||
r: roundChannel(hue2rgb(H + 1 / 3) * 255),
|
||||
g: roundChannel(hue2rgb(H) * 255),
|
||||
b: roundChannel(hue2rgb(H - 1 / 3) * 255),
|
||||
a,
|
||||
};
|
||||
}
|
||||
|
||||
export function rgbToHsv({ r, g, b }: RGB): HSV {
|
||||
const R = r / 255;
|
||||
const G = g / 255;
|
||||
const B = b / 255;
|
||||
const max = Math.max(R, G, B);
|
||||
const min = Math.min(R, G, B);
|
||||
const d = max - min;
|
||||
let h = 0;
|
||||
const s = max === 0 ? 0 : d / max;
|
||||
const v = max;
|
||||
|
||||
if (d !== 0) {
|
||||
switch (max) {
|
||||
case R:
|
||||
h = ((G - B) / d + (G < B ? 6 : 0)) / 6;
|
||||
break;
|
||||
case G:
|
||||
h = ((B - R) / d + 2) / 6;
|
||||
break;
|
||||
case B:
|
||||
h = ((R - G) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { h: h * 360, s: s * 100, v: v * 100 };
|
||||
}
|
||||
|
||||
export function hsvToRgb({ h, s, v, a = 1 }: HSV & { a?: number }): RGBA {
|
||||
const H = (((h % 360) + 360) % 360) / 360;
|
||||
const S = clamp(s, 0, 100) / 100;
|
||||
const V = clamp(v, 0, 100) / 100;
|
||||
|
||||
const i = Math.floor(H * 6);
|
||||
const f = H * 6 - i;
|
||||
const p = V * (1 - S);
|
||||
const q = V * (1 - f * S);
|
||||
const t = V * (1 - (1 - f) * S);
|
||||
|
||||
const [R, G, B] = (
|
||||
[
|
||||
[V, t, p],
|
||||
[q, V, p],
|
||||
[p, V, t],
|
||||
[p, q, V],
|
||||
[t, p, V],
|
||||
[V, p, q],
|
||||
] as [number, number, number][]
|
||||
)[i % 6]!;
|
||||
|
||||
return {
|
||||
r: roundChannel(R * 255),
|
||||
g: roundChannel(G * 255),
|
||||
b: roundChannel(B * 255),
|
||||
a,
|
||||
};
|
||||
}
|
||||
|
||||
export function rgbToXyz({ r, g, b }: RGB): {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
} {
|
||||
const toLinear = (c: number) => {
|
||||
c /= 255;
|
||||
return c > 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92;
|
||||
};
|
||||
|
||||
const R = toLinear(r);
|
||||
const G = toLinear(g);
|
||||
const B = toLinear(b);
|
||||
return {
|
||||
x: R * 0.4124564 + G * 0.3575761 + B * 0.1804375,
|
||||
y: R * 0.2126729 + G * 0.7151522 + B * 0.072175,
|
||||
z: R * 0.0193339 + G * 0.119192 + B * 0.9503041,
|
||||
};
|
||||
}
|
||||
|
||||
export function xyzToRgb({
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}): RGB {
|
||||
const R = x * 3.2404542 + y * -1.5371385 + z * -0.4985314;
|
||||
const G = x * -0.969266 + y * 1.8760108 + z * 0.041556;
|
||||
const B = x * 0.0556434 + y * -0.2040259 + z * 1.0572252;
|
||||
|
||||
const fromLinear = (c: number) => {
|
||||
c = c > 0.0031308 ? 1.055 * c ** (1 / 2.4) - 0.055 : 12.92 * c;
|
||||
return roundChannel(c * 255);
|
||||
};
|
||||
|
||||
return { r: fromLinear(R), g: fromLinear(G), b: fromLinear(B) };
|
||||
}
|
||||
|
||||
export function xyzToLab({
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}): LAB {
|
||||
const transform = (t: number) =>
|
||||
t > 0.008856 ? t ** (1 / 3) : 7.787 * t + 16 / 116;
|
||||
const refX = 95.047;
|
||||
const refY = 100.0;
|
||||
const refZ = 108.883;
|
||||
return {
|
||||
l: 116 * transform(y / refY) - 16,
|
||||
a: 500 * (transform(x / refX) - transform(y / refY)),
|
||||
b: 200 * (transform(y / refY) - transform(z / refZ)),
|
||||
};
|
||||
}
|
||||
|
||||
export function labToXyz({ l, a, b }: LAB): {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
} {
|
||||
const fy = (l + 16) / 116;
|
||||
const fx = a / 500 + fy;
|
||||
const fz = fy - b / 200;
|
||||
|
||||
const transform = (t: number) => {
|
||||
const t3 = t ** 3;
|
||||
return t3 > 0.008856 ? t3 : (t - 16 / 116) / 7.787;
|
||||
};
|
||||
|
||||
const refX = 95.047;
|
||||
const refY = 100.0;
|
||||
const refZ = 108.883;
|
||||
return {
|
||||
x: transform(fx) * refX,
|
||||
y: transform(fy) * refY,
|
||||
z: transform(fz) * refZ,
|
||||
};
|
||||
}
|
||||
|
||||
export function labToLch({ l, a, b }: LAB): LCH {
|
||||
const c = Math.sqrt(a * a + b * b);
|
||||
let h = Math.atan2(b, a) * (180 / Math.PI);
|
||||
if (h < 0) h += 360;
|
||||
return { l, c, h };
|
||||
}
|
||||
|
||||
export function lchToLab({ l, c, h }: LCH): LAB {
|
||||
const rad = h * (Math.PI / 180);
|
||||
return { l, a: c * Math.cos(rad), b: c * Math.sin(rad) };
|
||||
}
|
||||
|
||||
export function rgbToOklab(rgb: RGB): OKLAB {
|
||||
const xyz = rgbToXyz(rgb);
|
||||
const lms = {
|
||||
l: xyz.x * 0.8189330101 + xyz.y * 0.3618667424 + xyz.z * -0.1288597137,
|
||||
m: xyz.x * 0.0329845436 + xyz.y * 0.9293118715 + xyz.z * 0.0361456387,
|
||||
s: xyz.x * 0.0482003018 + xyz.y * 0.2643662691 + xyz.z * 0.633851707,
|
||||
};
|
||||
const lms_ = {
|
||||
l: Math.cbrt(lms.l),
|
||||
m: Math.cbrt(lms.m),
|
||||
s: Math.cbrt(lms.s),
|
||||
};
|
||||
return {
|
||||
L:
|
||||
lms_.l * 0.2104542553 +
|
||||
lms_.m * 0.793617785 +
|
||||
lms_.s * -0.0040720468,
|
||||
a:
|
||||
lms_.l * 1.9779984951 +
|
||||
lms_.m * -2.428592205 +
|
||||
lms_.s * 0.4505937099,
|
||||
b:
|
||||
lms_.l * 0.0259040371 +
|
||||
lms_.m * 0.7827717662 +
|
||||
lms_.s * -0.808675766,
|
||||
};
|
||||
}
|
||||
|
||||
export function oklabToRgb({ L, a, b, alpha }: OKLAB): RGBA {
|
||||
const lms_ = {
|
||||
l: L * 0.9999999985 + a * 0.3963377774 + b * 0.2158037573,
|
||||
m: L * 1.0000000089 + a * -0.1055613458 + b * -0.0638541728,
|
||||
s: L * 1.0000000547 + a * -0.0894841775 + b * -1.291485548,
|
||||
};
|
||||
const lms = {
|
||||
l: lms_.l * lms_.l * lms_.l,
|
||||
m: lms_.m * lms_.m * lms_.m,
|
||||
s: lms_.s * lms_.s * lms_.s,
|
||||
};
|
||||
const xyz = {
|
||||
x: lms.l * 1.2270138511 + lms.m * -0.5577992887 + lms.s * 0.281256149,
|
||||
y: lms.l * -0.0405801784 + lms.m * 1.1122568696 + lms.s * -0.0716766787,
|
||||
z: lms.l * -0.0763812845 + lms.m * -0.4214819784 + lms.s * 1.5861632204,
|
||||
};
|
||||
return { ...xyzToRgb(xyz), a: alpha ?? 1 };
|
||||
}
|
||||
|
||||
export function oklabToOklch({ L, a, b }: OKLAB): OKLCH {
|
||||
const C = Math.sqrt(a * a + b * b);
|
||||
let h = Math.atan2(b, a) * (180 / Math.PI);
|
||||
if (h < 0) h += 360;
|
||||
return { L, C, h };
|
||||
}
|
||||
|
||||
export function oklchToRgb({ L, C, h, alpha }: OKLCH): RGBA {
|
||||
const rad = h * (Math.PI / 180);
|
||||
const rgb = oklabToRgb({
|
||||
L,
|
||||
a: C * Math.cos(rad),
|
||||
b: C * Math.sin(rad),
|
||||
});
|
||||
return { ...rgb, a: alpha ?? 1 };
|
||||
}
|
||||
|
||||
export function rgbToCmyk({ r, g, b }: RGB): {
|
||||
c: number;
|
||||
m: number;
|
||||
y: number;
|
||||
k: number;
|
||||
} {
|
||||
const c = 1 - r / 255;
|
||||
const m = 1 - g / 255;
|
||||
const y = 1 - b / 255;
|
||||
const k = Math.min(c, m, y);
|
||||
|
||||
return k === 1
|
||||
? { c: 0, m: 0, y: 0, k: 100 }
|
||||
: {
|
||||
c: Math.round(((c - k) / (1 - k)) * 100),
|
||||
m: Math.round(((m - k) / (1 - k)) * 100),
|
||||
y: Math.round(((y - k) / (1 - k)) * 100),
|
||||
k: Math.round(k * 100),
|
||||
};
|
||||
}
|
||||
|
||||
export function cmykToRgb({
|
||||
c,
|
||||
m,
|
||||
y,
|
||||
k,
|
||||
}: {
|
||||
c: number;
|
||||
m: number;
|
||||
y: number;
|
||||
k: number;
|
||||
}): RGBA {
|
||||
const C = c / 100;
|
||||
const M = m / 100;
|
||||
const Y = y / 100;
|
||||
const K = k / 100;
|
||||
return {
|
||||
r: roundChannel(255 * (1 - C) * (1 - K)),
|
||||
g: roundChannel(255 * (1 - M) * (1 - K)),
|
||||
b: roundChannel(255 * (1 - Y) * (1 - K)),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function rgbToLab(rgb: RGB): LAB {
|
||||
return xyzToLab(rgbToXyz(rgb));
|
||||
}
|
||||
|
||||
export function labToRgb(lab: LAB): RGBA {
|
||||
return { ...xyzToRgb(labToXyz(lab)), a: lab.alpha ?? 1 };
|
||||
}
|
||||
|
||||
export function lchToRgb(lch: LCH): RGBA {
|
||||
return { ...xyzToRgb(labToXyz(lchToLab(lch))), a: lch.alpha ?? 1 };
|
||||
}
|
||||
21
packages/media/image/colours/src/conversions/index.ts
Normal file
21
packages/media/image/colours/src/conversions/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
cmykToRgb,
|
||||
hslToRgb,
|
||||
hsvToRgb,
|
||||
labToLch,
|
||||
labToRgb,
|
||||
labToXyz,
|
||||
lchToLab,
|
||||
lchToRgb,
|
||||
oklabToOklch,
|
||||
oklabToRgb,
|
||||
oklchToRgb,
|
||||
rgbToCmyk,
|
||||
rgbToHsl,
|
||||
rgbToHsv,
|
||||
rgbToLab,
|
||||
rgbToOklab,
|
||||
rgbToXyz,
|
||||
xyzToLab,
|
||||
xyzToRgb,
|
||||
} from "./conversions.js";
|
||||
61
packages/media/image/colours/src/error-codes/error-codes.md
Normal file
61
packages/media/image/colours/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`error-codes.ts` declares the canonical error-code registry for `@suaveplan/colours`. Every failure mode that the colour package can surface — from invalid hex strings to unsupported CSS colour formats — is assigned a stable, namespaced string via `defineCodes` from `@suaveplan/error`. This gives downstream log analysers, retry policies, and problem+json renderers a deterministic contract they can match against without parsing human-readable messages.
|
||||
|
||||
## Features
|
||||
|
||||
- Three stable codes under the `COLOUR.*` namespace
|
||||
- Type-safe `ColourCode` union derived from the registry object
|
||||
- Zero runtime side effects — the registry is a plain constant
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { COLOUR_CODES } from "@suaveplan/colours/error-codes";
|
||||
|
||||
function handleColourError(err: { code: string }) {
|
||||
if (err.code === COLOUR_CODES.INVALID_INPUT) {
|
||||
return { status: 400, message: "Invalid colour" };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { COLOUR_CODES, type ColourCode } from "@suaveplan/colours/error-codes";
|
||||
|
||||
const retryableCodes: ColourCode[] = [
|
||||
COLOUR_CODES.CONVERSION_FAILED,
|
||||
];
|
||||
|
||||
function shouldRetry(code: ColourCode): boolean {
|
||||
return retryableCodes.includes(code);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `COLOUR_CODES`
|
||||
|
||||
A frozen object mapping human-readable keys to namespaced string values:
|
||||
|
||||
- `INVALID_INPUT` → `"COLOUR.INVALID_INPUT"`
|
||||
- `UNSUPPORTED_FORMAT` → `"COLOUR.UNSUPPORTED_FORMAT"`
|
||||
- `CONVERSION_FAILED` → `"COLOUR.CONVERSION_FAILED"`
|
||||
|
||||
### `ColourCode`
|
||||
|
||||
A TypeScript union of all three code string literals. Useful for function parameters and discriminated unions.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is produced by `defineCodes("COLOUR", [...])`. If the namespace or code list ever changes, every test that asserts against `COLOUR_CODES.*` will fail, which is the desired behaviour — it prevents silent drift in the error contract.
|
||||
|
||||
Error codes are intentionally coarse-grained. They group related failure modes under a single code so that retry policies and alerting rules do not need to track an ever-expanding list of micro-codes. When more detail is required, the factory functions attach a context object with the raw input or format string.
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { COLOUR_CODES } from "./error-codes.js";
|
||||
|
||||
describe("COLOUR_CODES", () => {
|
||||
it("code values use COLOUR.* namespace", { timeout: 5000 }, () => {
|
||||
expect(COLOUR_CODES.INVALID_INPUT).toBe("COLOUR.INVALID_INPUT");
|
||||
expect(COLOUR_CODES.UNSUPPORTED_FORMAT).toBe(
|
||||
"COLOUR.UNSUPPORTED_FORMAT"
|
||||
);
|
||||
expect(COLOUR_CODES.CONVERSION_FAILED).toBe("COLOUR.CONVERSION_FAILED");
|
||||
});
|
||||
|
||||
it("contains expected codes", { timeout: 5000 }, () => {
|
||||
const codes = Object.values(COLOUR_CODES);
|
||||
expect(codes).toContain("COLOUR.INVALID_INPUT");
|
||||
expect(codes).toContain("COLOUR.UNSUPPORTED_FORMAT");
|
||||
expect(codes).toContain("COLOUR.CONVERSION_FAILED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineCodes } from "@suaveplan/error";
|
||||
|
||||
export const COLOUR_CODES = defineCodes("COLOUR", [
|
||||
"INVALID_INPUT",
|
||||
"UNSUPPORTED_FORMAT",
|
||||
"CONVERSION_FAILED",
|
||||
] as const);
|
||||
|
||||
export type ColourCode = (typeof COLOUR_CODES)[keyof typeof COLOUR_CODES];
|
||||
2
packages/media/image/colours/src/error-codes/index.ts
Normal file
2
packages/media/image/colours/src/error-codes/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { ColourCode } from "./error-codes.js";
|
||||
export { COLOUR_CODES } from "./error-codes.js";
|
||||
65
packages/media/image/colours/src/errors/errors.md
Normal file
65
packages/media/image/colours/src/errors/errors.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`errors.ts` provides the typed error hierarchy for `@suaveplan/colours`. `ColourError` extends `SuaveplanError` so that callers receive structured failures with a stable code, a message key, an optional context object, and an optional cause chain. Typed factory functions make it easy to construct correctly-coded errors without hand-typing string literals.
|
||||
|
||||
## Features
|
||||
|
||||
- `ColourError` class with `code`, `message`, `context`, and `cause`
|
||||
- Factory functions for every failure mode
|
||||
- Context carries `input` and `format` for debugging
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { colourInvalid } from "@suaveplan/colours/errors";
|
||||
|
||||
function parseUserColour(input: string) {
|
||||
if (input.length === 0) {
|
||||
throw colourInvalid(input);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ColourError, COLOUR_CODES } from "@suaveplan/colours/errors";
|
||||
|
||||
function enrichError(err: unknown): ColourError {
|
||||
if (err instanceof ColourError) return err;
|
||||
return new ColourError(
|
||||
COLOUR_CODES.CONVERSION_FAILED,
|
||||
"Unexpected failure",
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ColourError`
|
||||
|
||||
Extends `SuaveplanError<ColourCode>`.
|
||||
|
||||
- `constructor(code, messageKey, options?)` — `options` may include `cause` and `context`.
|
||||
- `context?: ColourErrorContext` — carries `input` and `format`.
|
||||
|
||||
### Factory Functions
|
||||
|
||||
- `colourInvalid(input?)`
|
||||
- `colourUnsupportedFormat(format)`
|
||||
- `colourConversionFailed(message)`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All factories are thin wrappers — they do not duplicate logic. The `ColourErrorContext` interface extends `Record<string, unknown>` so callers can safely add custom fields without losing type safety.
|
||||
|
||||
Every factory accepts an optional input or format value and embeds it in the error context. This makes debugging straightforward because the thrown error carries the exact value that caused the failure. Callers should never construct `ColourError` manually; always use the exported factories to guarantee the correct code and context shape.
|
||||
|
||||
The error hierarchy follows the suaveplan standard of extending `SuaveplanError` so that all packages in the project throw errors with a consistent shape. This allows top-level error handlers to treat colour errors the same way they treat network, validation, or auth errors.
|
||||
32
packages/media/image/colours/src/errors/errors.test.ts
Normal file
32
packages/media/image/colours/src/errors/errors.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { COLOUR_CODES } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
ColourError,
|
||||
colourConversionFailed,
|
||||
colourInvalid,
|
||||
colourUnsupportedFormat,
|
||||
} from "./errors.js";
|
||||
|
||||
describe("ColourError", () => {
|
||||
it("creates error with code and message", { timeout: 5000 }, () => {
|
||||
const err = new ColourError(COLOUR_CODES.INVALID_INPUT, "bad input");
|
||||
expect(err.code).toBe("COLOUR.INVALID_INPUT");
|
||||
expect(err.message).toContain("bad input");
|
||||
expect(err.name).toBe("ColourError");
|
||||
});
|
||||
|
||||
it("factory functions produce correctly-coded errors", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const invalid = colourInvalid("foo");
|
||||
expect(invalid.code).toBe("COLOUR.INVALID_INPUT");
|
||||
expect(invalid.context?.input).toBe("foo");
|
||||
|
||||
const unsupported = colourUnsupportedFormat("xyz");
|
||||
expect(unsupported.code).toBe("COLOUR.UNSUPPORTED_FORMAT");
|
||||
expect(unsupported.context?.format).toBe("xyz");
|
||||
|
||||
const conversion = colourConversionFailed("failed");
|
||||
expect(conversion.code).toBe("COLOUR.CONVERSION_FAILED");
|
||||
});
|
||||
});
|
||||
36
packages/media/image/colours/src/errors/errors.ts
Normal file
36
packages/media/image/colours/src/errors/errors.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { COLOUR_CODES, type ColourCode } from "../error-codes/error-codes.js";
|
||||
|
||||
export interface ColourErrorContext {
|
||||
readonly input?: unknown;
|
||||
readonly format?: string;
|
||||
}
|
||||
|
||||
export class ColourError extends SuaveplanError<ColourCode> {
|
||||
constructor(
|
||||
code: ColourCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions & { context?: ColourErrorContext }
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "ColourError";
|
||||
}
|
||||
}
|
||||
|
||||
export function colourInvalid(input?: unknown): ColourError {
|
||||
return new ColourError(COLOUR_CODES.INVALID_INPUT, "Invalid colour input", {
|
||||
context: { input },
|
||||
});
|
||||
}
|
||||
|
||||
export function colourUnsupportedFormat(format: string): ColourError {
|
||||
return new ColourError(
|
||||
COLOUR_CODES.UNSUPPORTED_FORMAT,
|
||||
"Unsupported colour format",
|
||||
{ context: { format } }
|
||||
);
|
||||
}
|
||||
|
||||
export function colourConversionFailed(message: string): ColourError {
|
||||
return new ColourError(COLOUR_CODES.CONVERSION_FAILED, message);
|
||||
}
|
||||
7
packages/media/image/colours/src/errors/index.ts
Normal file
7
packages/media/image/colours/src/errors/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type { ColourErrorContext } from "./errors.js";
|
||||
export {
|
||||
ColourError,
|
||||
colourConversionFailed,
|
||||
colourInvalid,
|
||||
colourUnsupportedFormat,
|
||||
} from "./errors.js";
|
||||
75
packages/media/image/colours/src/index.ts
Normal file
75
packages/media/image/colours/src/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export { Colour } from "./colour/index.js";
|
||||
export {
|
||||
blendColours,
|
||||
colourFromTemperature,
|
||||
colourSteps,
|
||||
generatePalette,
|
||||
randomColour,
|
||||
} from "./colour-factories/index.js";
|
||||
export {
|
||||
cmykToRgb,
|
||||
hslToRgb,
|
||||
hsvToRgb,
|
||||
labToLch,
|
||||
labToRgb,
|
||||
labToXyz,
|
||||
lchToLab,
|
||||
lchToRgb,
|
||||
oklabToOklch,
|
||||
oklabToRgb,
|
||||
oklchToRgb,
|
||||
rgbToCmyk,
|
||||
rgbToHsl,
|
||||
rgbToHsv,
|
||||
rgbToLab,
|
||||
rgbToOklab,
|
||||
rgbToXyz,
|
||||
xyzToLab,
|
||||
xyzToRgb,
|
||||
} from "./conversions/index.js";
|
||||
export { COLOUR_CODES, type ColourCode } from "./error-codes/index.js";
|
||||
export type { ColourErrorContext } from "./errors/index.js";
|
||||
export {
|
||||
ColourError,
|
||||
colourConversionFailed,
|
||||
colourInvalid,
|
||||
colourUnsupportedFormat,
|
||||
} from "./errors/index.js";
|
||||
export { parseColour } from "./parser/index.js";
|
||||
export type {
|
||||
BlendMode,
|
||||
CMYK,
|
||||
ColourInput,
|
||||
DistanceMode,
|
||||
HSL,
|
||||
HSLA,
|
||||
HSV,
|
||||
LAB,
|
||||
LCH,
|
||||
MixMode,
|
||||
OKLAB,
|
||||
OKLCH,
|
||||
PaletteType,
|
||||
RGB,
|
||||
RGBA,
|
||||
} from "./types/index.js";
|
||||
export {
|
||||
CmykSchema,
|
||||
HslaSchema,
|
||||
HslSchema,
|
||||
HsvSchema,
|
||||
LabSchema,
|
||||
LchSchema,
|
||||
NAMED_COLOURS,
|
||||
OklabSchema,
|
||||
OklchSchema,
|
||||
RgbaSchema,
|
||||
RgbSchema,
|
||||
} from "./types/index.js";
|
||||
export {
|
||||
clamp,
|
||||
isFiniteNumber,
|
||||
roundAlpha,
|
||||
roundChannel,
|
||||
roundPercent,
|
||||
} from "./utils/index.js";
|
||||
4
packages/media/image/colours/src/parser/index.ts
Normal file
4
packages/media/image/colours/src/parser/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
parseColour,
|
||||
tryParseColour,
|
||||
} from "./parser.js";
|
||||
51
packages/media/image/colours/src/parser/parser.md
Normal file
51
packages/media/image/colours/src/parser/parser.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# parser
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`parser.ts` converts arbitrary colour input into a canonical `RGBA` object. It supports hex strings (3/4/6/8 digit), modern and legacy CSS `rgb()` and `hsl()` syntax, CSS Color Module Level 4/5 `lab()`, `lch()`, `oklab()`, and `oklch()` syntax, named CSS colours, and plain JavaScript objects in every supported colour space. The parser is pure and side-effect-free.
|
||||
|
||||
## Features
|
||||
|
||||
- Hex parsing with optional `#` prefix
|
||||
- Modern space-separated and legacy comma-separated CSS syntax
|
||||
- Named colour lookup via `NAMED_COLOURS`
|
||||
- Object dispatch for RGB, HSL, HSV, LAB, OKLAB, and CMYK objects
|
||||
- Strict validation with typed `ColourError` throws
|
||||
- Safe parsing via `tryParseColour` with configurable fallback
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseColour } from "@suaveplan/colours/parser";
|
||||
|
||||
const rgba = parseColour("#ff9900");
|
||||
console.log(rgba); // { r: 255, g: 153, b: 0, a: 1 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { tryParseColour } from "@suaveplan/colours/parser";
|
||||
|
||||
const safe = tryParseColour(userInput, { r: 255, g: 255, b: 255, a: 1 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseColour(input: ColourInput): RGBA`
|
||||
|
||||
Returns a canonical `RGBA` object. Throws `ColourError` with code `COLOUR.INVALID_INPUT` or `COLOUR.UNSUPPORTED_FORMAT` on failure.
|
||||
|
||||
### `tryParseColour(input: ColourInput, fallback?: RGBA): RGBA`
|
||||
|
||||
Returns the parsed `RGBA` object, or `fallback` if parsing fails. The default fallback is black with full opacity (`{ r: 0, g: 0, b: 0, a: 1 }`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Regex patterns are compiled once at module load and reused for every parse call. Object parsing uses property detection (e.g. `'r' in obj`) rather than `instanceof` to avoid cross-realm issues. Alpha values in hex8 are normalised with `roundAlpha` to three decimal places.
|
||||
|
||||
The parser is designed to be strict rather than permissive. It does not attempt to guess intent when input is ambiguous; instead it throws a typed `ColourError` immediately. This fail-fast behaviour makes debugging easier for callers and prevents subtle colour drift caused by silently accepted malformed input. The `tryParseColour` variant provides a convenient escape hatch for callers who need graceful degradation rather than exceptions.
|
||||
330
packages/media/image/colours/src/parser/parser.test.ts
Normal file
330
packages/media/image/colours/src/parser/parser.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ColourError } from "../errors/errors.js";
|
||||
import { parseColour, tryParseColour } from "./parser.js";
|
||||
|
||||
describe("parseColour", () => {
|
||||
it("parses 6-digit hex", { timeout: 5000 }, () => {
|
||||
const c = parseColour("#ff9900");
|
||||
expect(c).toEqual({ r: 255, g: 153, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("parses 3-digit hex", { timeout: 5000 }, () => {
|
||||
const c = parseColour("#f90");
|
||||
expect(c).toEqual({ r: 255, g: 153, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("parses 8-digit hex with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour("#ff990080");
|
||||
expect(c.r).toBe(255);
|
||||
expect(c.a).toBeGreaterThan(0);
|
||||
expect(c.a).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("parses hex without hash", { timeout: 5000 }, () => {
|
||||
const c = parseColour("ff9900");
|
||||
expect(c).toEqual({ r: 255, g: 153, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("parses modern rgb", { timeout: 5000 }, () => {
|
||||
const c = parseColour("rgb(255 128 0)");
|
||||
expect(c).toEqual({ r: 255, g: 128, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("parses modern rgb with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour("rgb(255 128 0 / 0.5)");
|
||||
expect(c).toEqual({ r: 255, g: 128, b: 0, a: 0.5 });
|
||||
});
|
||||
|
||||
it("parses rgb with percent channels", { timeout: 5000 }, () => {
|
||||
const c = parseColour("rgb(100% 50% 0%)");
|
||||
expect(c.r).toBe(255);
|
||||
expect(c.g).toBe(127);
|
||||
expect(c.b).toBe(0);
|
||||
});
|
||||
|
||||
it("parses legacy rgb", { timeout: 5000 }, () => {
|
||||
const c = parseColour("rgb(255, 128, 0)");
|
||||
expect(c).toEqual({ r: 255, g: 128, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("parses legacy rgba", { timeout: 5000 }, () => {
|
||||
const c = parseColour("rgba(255, 128, 0, 0.5)");
|
||||
expect(c).toEqual({ r: 255, g: 128, b: 0, a: 0.5 });
|
||||
});
|
||||
|
||||
it("parses modern hsl", { timeout: 5000 }, () => {
|
||||
const c = parseColour("hsl(30 100% 50%)");
|
||||
expect(c.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses hsl with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour("hsl(30 100% 50% / 0.5)");
|
||||
expect(c.a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("parses lab with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour("lab(50 10 -20 / 0.5)");
|
||||
expect(c.a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("parses lch with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour("lch(50 10 180 / 0.5)");
|
||||
expect(c.a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("parses oklab with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour("oklab(0.5 0.1 -0.2 / 0.5)");
|
||||
expect(c.a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("parses oklch with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour("oklch(0.8 0.2 20 / 0.5)");
|
||||
expect(c.a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("parses rgb alpha as percent", { timeout: 5000 }, () => {
|
||||
const c = parseColour("rgb(255 128 0 / 50%)");
|
||||
expect(c.a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("parses legacy hsl", { timeout: 5000 }, () => {
|
||||
const c = parseColour("hsl(30, 100%, 50%)");
|
||||
expect(c.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses named colours", { timeout: 5000 }, () => {
|
||||
expect(parseColour("red")).toEqual({ r: 255, g: 0, b: 0, a: 1 });
|
||||
expect(parseColour("transparent")).toEqual({ r: 0, g: 0, b: 0, a: 0 });
|
||||
expect(parseColour("white")).toEqual({ r: 255, g: 255, b: 255, a: 1 });
|
||||
});
|
||||
|
||||
it("parses RGB object", { timeout: 5000 }, () => {
|
||||
expect(parseColour({ r: 10, g: 20, b: 30 })).toEqual({
|
||||
r: 10,
|
||||
g: 20,
|
||||
b: 30,
|
||||
a: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses RGBA object", { timeout: 5000 }, () => {
|
||||
expect(parseColour({ r: 10, g: 20, b: 30, a: 0.5 })).toEqual({
|
||||
r: 10,
|
||||
g: 20,
|
||||
b: 30,
|
||||
a: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses HSL object", { timeout: 5000 }, () => {
|
||||
const c = parseColour({ h: 0, s: 100, l: 50 });
|
||||
expect(c.r).toBe(255);
|
||||
expect(c.g).toBe(0);
|
||||
expect(c.b).toBe(0);
|
||||
});
|
||||
|
||||
it("parses HSL object with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour({ h: 0, s: 100, l: 50, a: 0.5 });
|
||||
expect(c.a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("parses HSV object", { timeout: 5000 }, () => {
|
||||
const c = parseColour({ h: 0, s: 100, v: 100 });
|
||||
expect(c.r).toBe(255);
|
||||
expect(c.g).toBe(0);
|
||||
expect(c.b).toBe(0);
|
||||
});
|
||||
|
||||
it("parses HSV object with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour({ h: 0, s: 100, v: 100, a: 0.5 });
|
||||
expect(c.a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("parses LAB object", { timeout: 5000 }, () => {
|
||||
const c = parseColour({ l: 50, a: 0, b: 0 });
|
||||
expect(c.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses LAB object with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour({ l: 50, a: 0, b: 0, alpha: 0.5 });
|
||||
expect(c.a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("parses OKLAB object", { timeout: 5000 }, () => {
|
||||
const c = parseColour({ L: 0.5, a: 0, b: 0 });
|
||||
expect(c.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses OKLAB object with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour({ L: 0.5, a: 0, b: 0, alpha: 0.5 });
|
||||
expect(c.a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("parses CMYK object", { timeout: 5000 }, () => {
|
||||
const c = parseColour({ c: 0, m: 100, y: 100, k: 0 });
|
||||
expect(c.r).toBe(255);
|
||||
expect(c.g).toBe(0);
|
||||
expect(c.b).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects invalid string", { timeout: 5000 }, () => {
|
||||
expect(() => parseColour("not-a-colour")).toThrow(ColourError);
|
||||
});
|
||||
|
||||
it("rejects null", { timeout: 5000 }, () => {
|
||||
expect(() => parseColour(null as unknown as string)).toThrow(
|
||||
ColourError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects undefined", { timeout: 5000 }, () => {
|
||||
expect(() => parseColour(undefined as unknown as string)).toThrow(
|
||||
ColourError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects NaN channel values", { timeout: 5000 }, () => {
|
||||
expect(() => parseColour({ r: Number.NaN, g: 0, b: 0 })).toThrow(
|
||||
ColourError
|
||||
);
|
||||
});
|
||||
|
||||
it("parses 4-digit hex with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour("#f908");
|
||||
expect(c.r).toBe(255);
|
||||
expect(c.a).toBeGreaterThan(0);
|
||||
expect(c.a).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("parses lab() css string", { timeout: 5000 }, () => {
|
||||
const c = parseColour("lab(50 10 -20)");
|
||||
expect(c.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses lch() css string", { timeout: 5000 }, () => {
|
||||
const c = parseColour("lch(50 10 180)");
|
||||
expect(c.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses oklab() css string", { timeout: 5000 }, () => {
|
||||
const c = parseColour("oklab(0.5 0.1 -0.2)");
|
||||
expect(c.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses oklab with percent channels", { timeout: 5000 }, () => {
|
||||
const c = parseColour("oklab(50% 10% -20%)");
|
||||
expect(c.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses oklch() css string", { timeout: 5000 }, () => {
|
||||
const c = parseColour("oklch(0.8 0.2 20)");
|
||||
expect(c.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses oklch with percent channels", { timeout: 5000 }, () => {
|
||||
const c = parseColour("oklch(80% 20% 20)");
|
||||
expect(c.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses LCH object", { timeout: 5000 }, () => {
|
||||
const c = parseColour({ l: 50, c: 10, h: 180 });
|
||||
expect(c.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses LCH object with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour({ l: 50, c: 10, h: 180, alpha: 0.5 });
|
||||
expect(c.a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("parses OKLCH object", { timeout: 5000 }, () => {
|
||||
const c = parseColour({ L: 0.8, C: 0.2, h: 20 });
|
||||
expect(c.r).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses OKLCH object with alpha", { timeout: 5000 }, () => {
|
||||
const c = parseColour({ L: 0.8, C: 0.2, h: 20, alpha: 0.5 });
|
||||
expect(c.a).toBe(0.5);
|
||||
});
|
||||
|
||||
it("rejects invalid channel in object", { timeout: 5000 }, () => {
|
||||
expect(() => parseColour({ r: "bad", g: 0, b: 0 })).toThrow(
|
||||
ColourError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid hue in object", { timeout: 5000 }, () => {
|
||||
expect(() => parseColour({ h: "bad", s: 0, l: 0 })).toThrow(
|
||||
ColourError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid alpha in object", { timeout: 5000 }, () => {
|
||||
expect(() => parseColour({ r: 0, g: 0, b: 0, a: "bad" })).toThrow(
|
||||
ColourError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-object non-string input", { timeout: 5000 }, () => {
|
||||
expect(() => parseColour(123 as unknown as string)).toThrow(
|
||||
ColourError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid hex length", { timeout: 5000 }, () => {
|
||||
expect(() => parseColour("#12345")).toThrow(ColourError);
|
||||
});
|
||||
|
||||
it("rejects invalid signed channel in lab object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => parseColour({ l: 50, a: "bad", b: 0 })).toThrow(
|
||||
ColourError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid percent in hsl object", { timeout: 5000 }, () => {
|
||||
expect(() => parseColour({ h: 0, s: "bad", l: 0 })).toThrow(
|
||||
ColourError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid number in oklab object", { timeout: 5000 }, () => {
|
||||
expect(() => parseColour({ L: "bad", a: 0, b: 0 })).toThrow(
|
||||
ColourError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown object format", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseColour({ x: 1 } as unknown as Record<string, unknown>)
|
||||
).toThrow(ColourError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryParseColour", () => {
|
||||
it("returns parsed colour on valid input", { timeout: 5000 }, () => {
|
||||
const result = tryParseColour("#ff9900");
|
||||
expect(result).toEqual({ r: 255, g: 153, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("returns default fallback on invalid string", { timeout: 5000 }, () => {
|
||||
const result = tryParseColour("not-a-colour");
|
||||
expect(result).toEqual({ r: 0, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("returns default fallback on null", { timeout: 5000 }, () => {
|
||||
const result = tryParseColour(null as unknown as string);
|
||||
expect(result).toEqual({ r: 0, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("returns default fallback on undefined", { timeout: 5000 }, () => {
|
||||
const result = tryParseColour(undefined as unknown as string);
|
||||
expect(result).toEqual({ r: 0, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("returns custom fallback when provided", { timeout: 5000 }, () => {
|
||||
const fallback = { r: 255, g: 255, b: 255, a: 1 };
|
||||
const result = tryParseColour("not-a-colour", fallback);
|
||||
expect(result).toBe(fallback);
|
||||
});
|
||||
});
|
||||
328
packages/media/image/colours/src/parser/parser.ts
Normal file
328
packages/media/image/colours/src/parser/parser.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import * as Conversions from "../conversions/conversions.js";
|
||||
import { colourInvalid, colourUnsupportedFormat } from "../errors/errors.js";
|
||||
import {
|
||||
type ColourInput,
|
||||
NAMED_COLOURS,
|
||||
type RGBA,
|
||||
} from "../types/colour-types/colour-types.js";
|
||||
import {
|
||||
clamp,
|
||||
isFiniteNumber,
|
||||
roundAlpha,
|
||||
roundChannel,
|
||||
} from "../utils/utils.js";
|
||||
|
||||
const HEX_RE = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
const RGB_RE =
|
||||
/^rgba?\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+%?)(?:\s*\/\s*([\d.]+%?))?\s*\)$/;
|
||||
const RGB_LEGACY_RE =
|
||||
/^rgba?\(\s*([\d.]+%?)\s*,\s*([\d.]+%?)\s*,\s*([\d.]+%?)(?:\s*,\s*([\d.]+%?))?\s*\)$/;
|
||||
const HSL_RE =
|
||||
/^hsla?\(\s*([\d.]+)\s+([\d.]+%?)\s+([\d.]+%?)(?:\s*\/\s*([\d.]+%?))?\s*\)$/;
|
||||
const HSL_LEGACY_RE =
|
||||
/^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+%?)\s*,\s*([\d.]+%?)(?:\s*,\s*([\d.]+%?))?\s*\)$/;
|
||||
const LAB_RE =
|
||||
/^lab\(\s*([\d.]+)\s+([-?\d.]+)\s+([-?\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/;
|
||||
const LCH_RE =
|
||||
/^lch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/;
|
||||
const OKLAB_RE =
|
||||
/^oklab\(\s*([\d.]+%?)\s+([-?\d.]+%?)\s+([-?\d.]+%?)(?:\s*\/\s*([\d.]+%?))?\s*\)$/;
|
||||
const OKLCH_RE =
|
||||
/^oklch\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/;
|
||||
|
||||
export function parseColour(input: ColourInput): RGBA {
|
||||
if (input === null || input === undefined) {
|
||||
throw colourInvalid(input);
|
||||
}
|
||||
|
||||
if (typeof input === "string") {
|
||||
return parseString(input.trim());
|
||||
}
|
||||
|
||||
if (typeof input !== "object") {
|
||||
throw colourInvalid(input);
|
||||
}
|
||||
|
||||
return parseObject(input as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function tryParseColour(
|
||||
input: ColourInput,
|
||||
fallback: RGBA = { r: 0, g: 0, b: 0, a: 1 }
|
||||
): RGBA {
|
||||
try {
|
||||
return parseColour(input);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function parseString(str: string): RGBA {
|
||||
const lower = str.toLowerCase();
|
||||
|
||||
const named = NAMED_COLOURS.get(lower);
|
||||
if (named) return { ...named };
|
||||
|
||||
if (lower.startsWith("#") || HEX_RE.test(lower)) {
|
||||
return parseHex(lower.startsWith("#") ? lower : `#${lower}`);
|
||||
}
|
||||
|
||||
let match = RGB_RE.exec(str);
|
||||
if (match) return parseRgbMatch(match);
|
||||
|
||||
match = RGB_LEGACY_RE.exec(str);
|
||||
if (match) return parseRgbMatch(match);
|
||||
|
||||
match = HSL_RE.exec(str);
|
||||
if (match) return parseHslMatch(match);
|
||||
|
||||
match = HSL_LEGACY_RE.exec(str);
|
||||
if (match) return parseHslMatch(match);
|
||||
|
||||
match = LAB_RE.exec(str);
|
||||
if (match) return parseLabMatch(match);
|
||||
|
||||
match = LCH_RE.exec(str);
|
||||
if (match) return parseLchMatch(match);
|
||||
|
||||
match = OKLAB_RE.exec(str);
|
||||
if (match) return parseOklabMatch(match);
|
||||
|
||||
match = OKLCH_RE.exec(str);
|
||||
if (match) return parseOklchMatch(match);
|
||||
|
||||
throw colourUnsupportedFormat(str);
|
||||
}
|
||||
|
||||
function parseObject(obj: Record<string, unknown>): RGBA {
|
||||
if ("r" in obj && "g" in obj && "b" in obj) {
|
||||
return {
|
||||
r: parseChannel(obj["r"], "r"),
|
||||
g: parseChannel(obj["g"], "g"),
|
||||
b: parseChannel(obj["b"], "b"),
|
||||
a: "a" in obj ? parseAlpha(obj["a"]) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
if ("h" in obj && "s" in obj && "l" in obj) {
|
||||
return Conversions.hslToRgb({
|
||||
h: parseHue(obj["h"]),
|
||||
s: parsePercent(obj["s"], "s"),
|
||||
l: parsePercent(obj["l"], "l"),
|
||||
a: "a" in obj ? parseAlpha(obj["a"]) : 1,
|
||||
});
|
||||
}
|
||||
|
||||
if ("h" in obj && "s" in obj && "v" in obj) {
|
||||
return Conversions.hsvToRgb({
|
||||
h: parseHue(obj["h"]),
|
||||
s: parsePercent(obj["s"], "s"),
|
||||
v: parsePercent(obj["v"], "v"),
|
||||
a: "a" in obj ? parseAlpha(obj["a"]) : 1,
|
||||
});
|
||||
}
|
||||
|
||||
if ("L" in obj && "a" in obj && "b" in obj && !("v" in obj)) {
|
||||
return Conversions.oklabToRgb({
|
||||
L: parseNumber(obj["L"], "L", 0, 1),
|
||||
a: parseSignedChannel(obj["a"], "a"),
|
||||
b: parseSignedChannel(obj["b"], "b"),
|
||||
alpha: "alpha" in obj ? parseAlpha(obj["alpha"]) : 1,
|
||||
});
|
||||
}
|
||||
|
||||
if ("l" in obj && "c" in obj && "h" in obj && !("m" in obj)) {
|
||||
return Conversions.lchToRgb({
|
||||
l: parseNumber(obj["l"], "l", 0, 100),
|
||||
c: parseNumber(obj["c"], "c", 0, Number.POSITIVE_INFINITY),
|
||||
h: parseHue(obj["h"]),
|
||||
alpha: "alpha" in obj ? parseAlpha(obj["alpha"]) : 1,
|
||||
});
|
||||
}
|
||||
|
||||
if ("L" in obj && "C" in obj && "h" in obj) {
|
||||
return Conversions.oklchToRgb({
|
||||
L: parseNumber(obj["L"], "L", 0, 1),
|
||||
C: parseNumber(obj["C"], "C", 0, Number.POSITIVE_INFINITY),
|
||||
h: parseHue(obj["h"]),
|
||||
alpha: "alpha" in obj ? parseAlpha(obj["alpha"]) : 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
"l" in obj &&
|
||||
"a" in obj &&
|
||||
"b" in obj &&
|
||||
!("v" in obj) &&
|
||||
!("L" in obj) &&
|
||||
!("c" in obj)
|
||||
) {
|
||||
return Conversions.labToRgb({
|
||||
l: parseNumber(obj["l"], "l", 0, 100),
|
||||
a: parseSignedChannel(obj["a"], "a"),
|
||||
b: parseSignedChannel(obj["b"], "b"),
|
||||
alpha: "alpha" in obj ? parseAlpha(obj["alpha"]) : 1,
|
||||
});
|
||||
}
|
||||
|
||||
if ("c" in obj && "m" in obj && "y" in obj && "k" in obj) {
|
||||
return Conversions.cmykToRgb({
|
||||
c: parsePercent(obj["c"], "c"),
|
||||
m: parsePercent(obj["m"], "m"),
|
||||
y: parsePercent(obj["y"], "y"),
|
||||
k: parsePercent(obj["k"], "k"),
|
||||
});
|
||||
}
|
||||
|
||||
throw colourInvalid(obj);
|
||||
}
|
||||
|
||||
function parseHex(hex: string): RGBA {
|
||||
const digits = hex.slice(1);
|
||||
|
||||
if (digits.length === 3) {
|
||||
const [r, g, b] = [...digits].map((c) =>
|
||||
Number.parseInt(c + c, 16)
|
||||
) as [number, number, number];
|
||||
return { r, g, b, a: 1 };
|
||||
}
|
||||
|
||||
if (digits.length === 4) {
|
||||
const [r, g, b, a] = [...digits].map((c) =>
|
||||
Number.parseInt(c + c, 16)
|
||||
) as [number, number, number, number];
|
||||
return { r, g, b, a: roundAlpha(a / 255) };
|
||||
}
|
||||
|
||||
if (digits.length === 6) {
|
||||
return {
|
||||
r: Number.parseInt(digits.slice(0, 2), 16),
|
||||
g: Number.parseInt(digits.slice(2, 4), 16),
|
||||
b: Number.parseInt(digits.slice(4, 6), 16),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (digits.length === 8) {
|
||||
return {
|
||||
r: Number.parseInt(digits.slice(0, 2), 16),
|
||||
g: Number.parseInt(digits.slice(2, 4), 16),
|
||||
b: Number.parseInt(digits.slice(4, 6), 16),
|
||||
a: roundAlpha(Number.parseInt(digits.slice(6, 8), 16) / 255),
|
||||
};
|
||||
}
|
||||
|
||||
throw colourInvalid(hex);
|
||||
}
|
||||
|
||||
function parseRgbMatch(m: RegExpExecArray): RGBA {
|
||||
const parse = (val: string) =>
|
||||
val.endsWith("%")
|
||||
? Number.parseFloat(val) * 2.55
|
||||
: Number.parseFloat(val);
|
||||
return {
|
||||
r: roundChannel(parse(m[1]!)),
|
||||
g: roundChannel(parse(m[2]!)),
|
||||
b: roundChannel(parse(m[3]!)),
|
||||
a: m[4] ? parseAlphaValue(m[4]) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
function parseHslMatch(m: RegExpExecArray): RGBA {
|
||||
return Conversions.hslToRgb({
|
||||
h: Number.parseFloat(m[1]!),
|
||||
s: Number.parseFloat(m[2]!),
|
||||
l: Number.parseFloat(m[3]!),
|
||||
a: m[4] ? parseAlphaValue(m[4]) : 1,
|
||||
});
|
||||
}
|
||||
|
||||
function parseLabMatch(m: RegExpExecArray): RGBA {
|
||||
return Conversions.labToRgb({
|
||||
l: Number.parseFloat(m[1]!),
|
||||
a: Number.parseFloat(m[2]!),
|
||||
b: Number.parseFloat(m[3]!),
|
||||
alpha: m[4] ? parseAlphaValue(m[4]) : 1,
|
||||
});
|
||||
}
|
||||
|
||||
function parseLchMatch(m: RegExpExecArray): RGBA {
|
||||
return Conversions.lchToRgb({
|
||||
l: Number.parseFloat(m[1]!),
|
||||
c: Number.parseFloat(m[2]!),
|
||||
h: Number.parseFloat(m[3]!),
|
||||
alpha: m[4] ? parseAlphaValue(m[4]) : 1,
|
||||
});
|
||||
}
|
||||
|
||||
function parseOklabMatch(m: RegExpExecArray): RGBA {
|
||||
const parse = (val: string) =>
|
||||
val.endsWith("%")
|
||||
? Number.parseFloat(val) / 100
|
||||
: Number.parseFloat(val);
|
||||
return Conversions.oklabToRgb({
|
||||
L: parse(m[1]!),
|
||||
a: parse(m[2]!),
|
||||
b: parse(m[3]!),
|
||||
alpha: m[4] ? parseAlphaValue(m[4]) : 1,
|
||||
});
|
||||
}
|
||||
|
||||
function parseOklchMatch(m: RegExpExecArray): RGBA {
|
||||
const parse = (val: string) =>
|
||||
val.endsWith("%")
|
||||
? Number.parseFloat(val) / 100
|
||||
: Number.parseFloat(val);
|
||||
return Conversions.oklchToRgb({
|
||||
L: parse(m[1]!),
|
||||
C: parse(m[2]!),
|
||||
h: Number.parseFloat(m[3]!),
|
||||
alpha: m[4] ? parseAlphaValue(m[4]) : 1,
|
||||
});
|
||||
}
|
||||
|
||||
function parseAlphaValue(val: string): number {
|
||||
if (val.endsWith("%")) return roundAlpha(Number.parseFloat(val) / 100);
|
||||
return roundAlpha(Number.parseFloat(val));
|
||||
}
|
||||
|
||||
function parseChannel(val: unknown, name: string): number {
|
||||
if (!isFiniteNumber(val))
|
||||
throw colourInvalid(`Channel ${name} must be a number: ${val}`);
|
||||
return roundChannel(val);
|
||||
}
|
||||
|
||||
function parseSignedChannel(val: unknown, name: string): number {
|
||||
if (!isFiniteNumber(val))
|
||||
throw colourInvalid(`Channel ${name} must be a number: ${val}`);
|
||||
return val;
|
||||
}
|
||||
|
||||
function parsePercent(val: unknown, name: string): number {
|
||||
if (!isFiniteNumber(val))
|
||||
throw colourInvalid(`${name} must be a number: ${val}`);
|
||||
return clamp(val, 0, 100);
|
||||
}
|
||||
|
||||
function parseHue(val: unknown): number {
|
||||
if (!isFiniteNumber(val))
|
||||
throw colourInvalid(`Hue must be a number: ${val}`);
|
||||
return val % 360;
|
||||
}
|
||||
|
||||
function parseNumber(
|
||||
val: unknown,
|
||||
name: string,
|
||||
min: number,
|
||||
max: number
|
||||
): number {
|
||||
if (!isFiniteNumber(val))
|
||||
throw colourInvalid(`${name} must be a number: ${val}`);
|
||||
return clamp(val, min, max);
|
||||
}
|
||||
|
||||
function parseAlpha(val: unknown): number {
|
||||
if (!isFiniteNumber(val))
|
||||
throw colourInvalid(`Alpha must be a number: ${val}`);
|
||||
return roundAlpha(val);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# colour-types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`colour-types.ts` is the universal type system and Zod schema registry for `@suaveplan/colours`. It defines TypeScript interfaces for every colour space supported by the library, validates object inputs via Zod, and provides a lookup table of named CSS colours. Everything in this module is deterministic and side-effect free.
|
||||
|
||||
## Features
|
||||
|
||||
- TypeScript interfaces for RGB, RGBA, HSL, HSLA, HSV, LAB, LCH, OKLAB, OKLCH, CMYK
|
||||
- Zod schemas for runtime validation of each colour-space object
|
||||
- Named colour map with 19 CSS named colours plus transparent
|
||||
- Union types for `ColourInput`, `MixMode`, `DistanceMode`, `BlendMode`, and `PaletteType`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RgbSchema, NAMED_COLOURS } from "@suaveplan/colours/types";
|
||||
|
||||
const rgb = RgbSchema.parse({ r: 255, g: 128, b: 0 });
|
||||
const red = NAMED_COLOURS.get("red");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { LabSchema, type LAB } from "@suaveplan/colours/types";
|
||||
|
||||
function processLab(input: unknown): LAB {
|
||||
return LabSchema.parse(input);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Zod Schemas
|
||||
|
||||
- `RgbSchema`, `RgbaSchema`
|
||||
- `HslSchema`, `HslaSchema`
|
||||
- `HsvSchema`
|
||||
- `LabSchema`, `LchSchema`
|
||||
- `OklabSchema`, `OklchSchema`
|
||||
- `CmykSchema`
|
||||
|
||||
### Types
|
||||
|
||||
- `RGB`, `RGBA`, `HSL`, `HSLA`, `HSV`, `LAB`, `LCH`, `OKLAB`, `OKLCH`, `CMYK`
|
||||
- `ColourInput`, `MixMode`, `DistanceMode`, `BlendMode`, `PaletteType`
|
||||
|
||||
### Constants
|
||||
|
||||
- `NAMED_COLOURS` — `ReadonlyMap<string, RGBA>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Zod schemas use `.int()` and `.min().max()` bounds to match CSS channel constraints. `NAMED_COLOURS` is a native `Map` wrapped in `ReadonlyMap` to prevent accidental mutation while preserving `O(1)` lookups.
|
||||
|
||||
Type definitions are kept as simple readonly interfaces so they can be used in both runtime Zod validation and compile-time TypeScript checks without duplication. The union types for modes and palettes are derived from const arrays, which means adding a new mode updates both the type and any Zod schema that references it automatically.
|
||||
@@ -0,0 +1,184 @@
|
||||
import z from "zod";
|
||||
|
||||
export const RgbSchema = z.object({
|
||||
r: z.number().int().min(0).max(255),
|
||||
g: z.number().int().min(0).max(255),
|
||||
b: z.number().int().min(0).max(255),
|
||||
});
|
||||
|
||||
export const RgbaSchema = z.object({
|
||||
r: z.number().int().min(0).max(255),
|
||||
g: z.number().int().min(0).max(255),
|
||||
b: z.number().int().min(0).max(255),
|
||||
a: z.number().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
export const HslSchema = z.object({
|
||||
h: z.number(),
|
||||
s: z.number().min(0).max(100),
|
||||
l: z.number().min(0).max(100),
|
||||
});
|
||||
|
||||
export const HslaSchema = z.object({
|
||||
h: z.number(),
|
||||
s: z.number().min(0).max(100),
|
||||
l: z.number().min(0).max(100),
|
||||
a: z.number().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
export const HsvSchema = z.object({
|
||||
h: z.number(),
|
||||
s: z.number().min(0).max(100),
|
||||
v: z.number().min(0).max(100),
|
||||
});
|
||||
|
||||
export const LabSchema = z.object({
|
||||
l: z.number().min(0).max(100),
|
||||
a: z.number(),
|
||||
b: z.number(),
|
||||
alpha: z.number().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
export const LchSchema = z.object({
|
||||
l: z.number().min(0).max(100),
|
||||
c: z.number().min(0),
|
||||
h: z.number(),
|
||||
alpha: z.number().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
export const OklabSchema = z.object({
|
||||
L: z.number().min(0).max(1),
|
||||
a: z.number(),
|
||||
b: z.number(),
|
||||
alpha: z.number().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
export const OklchSchema = z.object({
|
||||
L: z.number().min(0).max(1),
|
||||
C: z.number().min(0),
|
||||
h: z.number(),
|
||||
alpha: z.number().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
export const CmykSchema = z.object({
|
||||
c: z.number().min(0).max(100),
|
||||
m: z.number().min(0).max(100),
|
||||
y: z.number().min(0).max(100),
|
||||
k: z.number().min(0).max(100),
|
||||
});
|
||||
|
||||
export interface RGB {
|
||||
readonly r: number;
|
||||
readonly g: number;
|
||||
readonly b: number;
|
||||
}
|
||||
|
||||
export interface RGBA extends RGB {
|
||||
readonly a: number;
|
||||
}
|
||||
|
||||
export interface HSL {
|
||||
readonly h: number;
|
||||
readonly s: number;
|
||||
readonly l: number;
|
||||
}
|
||||
|
||||
export interface HSLA extends HSL {
|
||||
readonly a: number;
|
||||
}
|
||||
|
||||
export interface HSV {
|
||||
readonly h: number;
|
||||
readonly s: number;
|
||||
readonly v: number;
|
||||
}
|
||||
|
||||
export interface LAB {
|
||||
readonly l: number;
|
||||
readonly a: number;
|
||||
readonly b: number;
|
||||
readonly alpha?: number;
|
||||
}
|
||||
|
||||
export interface LCH {
|
||||
readonly l: number;
|
||||
readonly c: number;
|
||||
readonly h: number;
|
||||
readonly alpha?: number;
|
||||
}
|
||||
|
||||
export interface OKLAB {
|
||||
readonly L: number;
|
||||
readonly a: number;
|
||||
readonly b: number;
|
||||
readonly alpha?: number;
|
||||
}
|
||||
|
||||
export interface OKLCH {
|
||||
readonly L: number;
|
||||
readonly C: number;
|
||||
readonly h: number;
|
||||
readonly alpha?: number;
|
||||
}
|
||||
|
||||
export interface CMYK {
|
||||
readonly c: number;
|
||||
readonly m: number;
|
||||
readonly y: number;
|
||||
readonly k: number;
|
||||
}
|
||||
|
||||
export type ColourInput =
|
||||
| string
|
||||
| RGB
|
||||
| RGBA
|
||||
| HSL
|
||||
| HSLA
|
||||
| HSV
|
||||
| LAB
|
||||
| LCH
|
||||
| OKLAB
|
||||
| OKLCH
|
||||
| CMYK;
|
||||
|
||||
export type MixMode = "rgb" | "hsl" | "lab" | "oklab" | "lch" | "oklch";
|
||||
|
||||
export type DistanceMode = "euclidean" | "cie76" | "cie94" | "oklab";
|
||||
|
||||
export type BlendMode =
|
||||
| "multiply"
|
||||
| "screen"
|
||||
| "overlay"
|
||||
| "difference"
|
||||
| "addition"
|
||||
| "exclusion";
|
||||
|
||||
export type PaletteType =
|
||||
| "monochromatic"
|
||||
| "analogous"
|
||||
| "complementary"
|
||||
| "triadic"
|
||||
| "tetradic"
|
||||
| "split-complementary";
|
||||
|
||||
export const NAMED_COLOURS: ReadonlyMap<string, RGBA> = new Map([
|
||||
["black", { r: 0, g: 0, b: 0, a: 1 }],
|
||||
["white", { r: 255, g: 255, b: 255, a: 1 }],
|
||||
["red", { r: 255, g: 0, b: 0, a: 1 }],
|
||||
["green", { r: 0, g: 128, b: 0, a: 1 }],
|
||||
["lime", { r: 0, g: 255, b: 0, a: 1 }],
|
||||
["blue", { r: 0, g: 0, b: 255, a: 1 }],
|
||||
["yellow", { r: 255, g: 255, b: 0, a: 1 }],
|
||||
["cyan", { r: 0, g: 255, b: 255, a: 1 }],
|
||||
["magenta", { r: 255, g: 0, b: 255, a: 1 }],
|
||||
["silver", { r: 192, g: 192, b: 192, a: 1 }],
|
||||
["gray", { r: 128, g: 128, b: 128, a: 1 }],
|
||||
["grey", { r: 128, g: 128, b: 128, a: 1 }],
|
||||
["maroon", { r: 128, g: 0, b: 0, a: 1 }],
|
||||
["olive", { r: 128, g: 128, b: 0, a: 1 }],
|
||||
["purple", { r: 128, g: 0, b: 128, a: 1 }],
|
||||
["teal", { r: 0, g: 128, b: 128, a: 1 }],
|
||||
["navy", { r: 0, g: 0, b: 128, a: 1 }],
|
||||
["orange", { r: 255, g: 165, b: 0, a: 1 }],
|
||||
["transparent", { r: 0, g: 0, b: 0, a: 0 }],
|
||||
]);
|
||||
30
packages/media/image/colours/src/types/colour-types/index.ts
Normal file
30
packages/media/image/colours/src/types/colour-types/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type {
|
||||
BlendMode,
|
||||
CMYK,
|
||||
ColourInput,
|
||||
DistanceMode,
|
||||
HSL,
|
||||
HSLA,
|
||||
HSV,
|
||||
LAB,
|
||||
LCH,
|
||||
MixMode,
|
||||
OKLAB,
|
||||
OKLCH,
|
||||
PaletteType,
|
||||
RGB,
|
||||
RGBA,
|
||||
} from "./colour-types.js";
|
||||
export {
|
||||
CmykSchema,
|
||||
HslaSchema,
|
||||
HslSchema,
|
||||
HsvSchema,
|
||||
LabSchema,
|
||||
LchSchema,
|
||||
NAMED_COLOURS,
|
||||
OklabSchema,
|
||||
OklchSchema,
|
||||
RgbaSchema,
|
||||
RgbSchema,
|
||||
} from "./colour-types.js";
|
||||
30
packages/media/image/colours/src/types/index.ts
Normal file
30
packages/media/image/colours/src/types/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type {
|
||||
BlendMode,
|
||||
CMYK,
|
||||
ColourInput,
|
||||
DistanceMode,
|
||||
HSL,
|
||||
HSLA,
|
||||
HSV,
|
||||
LAB,
|
||||
LCH,
|
||||
MixMode,
|
||||
OKLAB,
|
||||
OKLCH,
|
||||
PaletteType,
|
||||
RGB,
|
||||
RGBA,
|
||||
} from "./colour-types/index.js";
|
||||
export {
|
||||
CmykSchema,
|
||||
HslaSchema,
|
||||
HslSchema,
|
||||
HsvSchema,
|
||||
LabSchema,
|
||||
LchSchema,
|
||||
NAMED_COLOURS,
|
||||
OklabSchema,
|
||||
OklchSchema,
|
||||
RgbaSchema,
|
||||
RgbSchema,
|
||||
} from "./colour-types/index.js";
|
||||
7
packages/media/image/colours/src/utils/index.ts
Normal file
7
packages/media/image/colours/src/utils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
clamp,
|
||||
isFiniteNumber,
|
||||
roundAlpha,
|
||||
roundChannel,
|
||||
roundPercent,
|
||||
} from "./utils.js";
|
||||
56
packages/media/image/colours/src/utils/utils.md
Normal file
56
packages/media/image/colours/src/utils/utils.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# utils
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`utils.ts` provides small, pure mathematical utilities used throughout `@suaveplan/colours`. Every function is deterministic and side-effect-free, making the module safe to import in any JavaScript runtime. These helpers enforce the numeric invariants that keep colour channels, percentages, and alpha values within their valid ranges.
|
||||
|
||||
## Features
|
||||
|
||||
- `clamp` — restricts a number to a closed interval
|
||||
- `isFiniteNumber` — type guard rejecting `NaN` and `Infinity`
|
||||
- `roundChannel` — clamps and rounds to `[0, 255]`
|
||||
- `roundPercent` — clamps and rounds to `[0, 100]`
|
||||
- `roundAlpha` — clamps and rounds to three decimal places in `[0, 1]`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { clamp, roundChannel } from "@suaveplan/colours/utils";
|
||||
|
||||
const value = clamp(300, 0, 255);
|
||||
console.log(value); // 255
|
||||
|
||||
const channel = roundChannel(128.6);
|
||||
console.log(channel); // 129
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { isFiniteNumber, roundAlpha } from "@suaveplan/colours/utils";
|
||||
|
||||
function safeAlpha(input: unknown): number {
|
||||
if (!isFiniteNumber(input)) return 1;
|
||||
return roundAlpha(input);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `clamp(val, min, max): number`
|
||||
- `isFiniteNumber(v): v is number`
|
||||
- `roundChannel(v): number`
|
||||
- `roundPercent(v): number`
|
||||
- `roundAlpha(v): number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`roundAlpha` uses `Math.round(v * 1000) / 1000` to cap precision at three decimal places. This matches the CSS `opacity` resolution and prevents floating-point drift in serialised output.
|
||||
|
||||
All utilities are implemented as standalone functions with no shared state. They can be imported individually for tree-shaking or used together as a consistent numeric toolkit. Because every helper is pure, they are safe to call inside hot loops such as pixel shaders or animation frames without risk of side effects.
|
||||
|
||||
The clamp function uses simple comparison operators rather than Math.min and Math.max for slightly better performance in tight loops. The isFiniteNumber guard explicitly rejects NaN and Infinity, which is stricter than a plain typeof check and prevents invalid values from propagating through conversion chains.
|
||||
41
packages/media/image/colours/src/utils/utils.test.ts
Normal file
41
packages/media/image/colours/src/utils/utils.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
clamp,
|
||||
isFiniteNumber,
|
||||
roundAlpha,
|
||||
roundChannel,
|
||||
roundPercent,
|
||||
} from "./utils.js";
|
||||
|
||||
describe("utils", () => {
|
||||
it("clamp restricts to range", { timeout: 5000 }, () => {
|
||||
expect(clamp(5, 0, 10)).toBe(5);
|
||||
expect(clamp(-5, 0, 10)).toBe(0);
|
||||
expect(clamp(15, 0, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it("isFiniteNumber rejects non-finite", { timeout: 5000 }, () => {
|
||||
expect(isFiniteNumber(5)).toBe(true);
|
||||
expect(isFiniteNumber(Number.NaN)).toBe(false);
|
||||
expect(isFiniteNumber(Number.POSITIVE_INFINITY)).toBe(false);
|
||||
expect(isFiniteNumber("5")).toBe(false);
|
||||
});
|
||||
|
||||
it("roundChannel clamps to 0-255", { timeout: 5000 }, () => {
|
||||
expect(roundChannel(128.6)).toBe(129);
|
||||
expect(roundChannel(-10)).toBe(0);
|
||||
expect(roundChannel(300)).toBe(255);
|
||||
});
|
||||
|
||||
it("roundPercent clamps to 0-100", { timeout: 5000 }, () => {
|
||||
expect(roundPercent(50.4)).toBe(50);
|
||||
expect(roundPercent(-5)).toBe(0);
|
||||
expect(roundPercent(105)).toBe(100);
|
||||
});
|
||||
|
||||
it("roundAlpha clamps to 0-1", { timeout: 5000 }, () => {
|
||||
expect(roundAlpha(0.5)).toBe(0.5);
|
||||
expect(roundAlpha(-0.1)).toBe(0);
|
||||
expect(roundAlpha(1.1)).toBe(1);
|
||||
});
|
||||
});
|
||||
10
packages/media/image/colours/src/utils/utils.ts
Normal file
10
packages/media/image/colours/src/utils/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const isFiniteNumber = (v: unknown): v is number =>
|
||||
typeof v === "number" && Number.isFinite(v);
|
||||
|
||||
export const clamp = (val: number, min: number, max: number): number =>
|
||||
Math.max(min, Math.min(max, val));
|
||||
|
||||
export const roundChannel = (v: number): number => Math.round(clamp(v, 0, 255));
|
||||
export const roundPercent = (v: number): number => Math.round(clamp(v, 0, 100));
|
||||
export const roundAlpha = (v: number): number =>
|
||||
clamp(Math.round(v * 1000) / 1000, 0, 1);
|
||||
39
packages/media/image/colours/tests/dep-graph.test.ts
Normal file
39
packages/media/image/colours/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("@suaveplan/* runtime deps are the expected set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual(["@suaveplan/error", "@suaveplan/types"]);
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual([
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
});
|
||||
});
|
||||
1
packages/media/image/colours/tests/setup/polyfills.ts
Normal file
1
packages/media/image/colours/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@suaveplan/testing/polyfills";
|
||||
20
packages/media/image/colours/tests/tree-shake.test.ts
Normal file
20
packages/media/image/colours/tests/tree-shake.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("exports has at least 5 entries", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports?: Record<string, unknown> };
|
||||
expect(pkg.exports).toBeDefined();
|
||||
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
15
packages/media/image/colours/tsconfig.lib.json
Normal file
15
packages/media/image/colours/tsconfig.lib.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
32
packages/media/image/colours/vite.config.ts
Normal file
32
packages/media/image/colours/vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: {
|
||||
index: "./src/index.ts",
|
||||
"error-codes/error-codes": "./src/error-codes/error-codes.ts",
|
||||
"errors/errors": "./src/errors/errors.ts",
|
||||
"types/colour-types": "./src/types/colour-types/index.ts",
|
||||
"parser/parser": "./src/parser/parser.ts",
|
||||
"conversions/conversions": "./src/conversions/conversions.ts",
|
||||
"colour/colour": "./src/colour/colour.ts",
|
||||
"colour-factories/colour-factories": "./src/colour-factories/colour-factories.ts",
|
||||
"utils/utils": "./src/utils/utils.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
external: [/^@suaveplan\//, "zod", "tslib"],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
rollupTypes: false,
|
||||
include: ["src/**/*.ts"],
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
}),
|
||||
],
|
||||
});
|
||||
27
packages/media/image/colours/vitest.config.ts
Normal file
27
packages/media/image/colours/vitest.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user