color-map 2.2.0 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +12 -0
- package/.codex-plugin/plugin.json +12 -0
- package/.cursor-plugin/plugin.json +12 -0
- package/CHANGELOG.md +100 -0
- package/README.md +71 -8
- package/bin/cli-CELmx3Y9.js +6414 -0
- package/bin/color-map.js +9 -0
- package/bin/index.js +1 -0
- package/cjs/color/index.d.ts +5 -0
- package/cjs/color/index.js +52 -0
- package/cjs/color-wNrkrhC7.js +374 -0
- package/cjs/color-wNrkrhC7.js.map +1 -0
- package/cjs/contrast-XjF5Rwui.js +293 -0
- package/cjs/contrast-XjF5Rwui.js.map +1 -0
- package/cjs/gamut-CG1J2aat.d.ts +158 -0
- package/cjs/gamut-CG1J2aat.d.ts.map +1 -0
- package/cjs/index-BcEqCPHz.d.ts +137 -0
- package/cjs/index-BcEqCPHz.d.ts.map +1 -0
- package/cjs/index-CGKCqRqD.d.ts +111 -0
- package/cjs/index-CGKCqRqD.d.ts.map +1 -0
- package/cjs/index-Cv3iUUYE.d.ts +171 -0
- package/cjs/index-Cv3iUUYE.d.ts.map +1 -0
- package/cjs/index-Dw43Hj4v.d.ts +41 -0
- package/cjs/index-Dw43Hj4v.d.ts.map +1 -0
- package/cjs/index-Dy8UARqO.d.ts +72 -0
- package/cjs/index-Dy8UARqO.d.ts.map +1 -0
- package/cjs/index.d.ts +26 -36
- package/cjs/index.d.ts.map +1 -1
- package/cjs/index.js +83 -62
- package/cjs/index.js.map +1 -1
- package/cjs/legacy/index.d.ts +2 -0
- package/cjs/legacy/index.js +75 -0
- package/cjs/legacy/index.js.map +1 -0
- package/cjs/mix-Cs4owSbW.js +158 -0
- package/cjs/mix-Cs4owSbW.js.map +1 -0
- package/cjs/mix-DGlKoUw4.d.ts +31 -0
- package/cjs/mix-DGlKoUw4.d.ts.map +1 -0
- package/cjs/palette/index.d.ts +3 -0
- package/cjs/palette/index.js +8 -0
- package/cjs/palette-DyFV_KoJ.js +332 -0
- package/cjs/palette-DyFV_KoJ.js.map +1 -0
- package/cjs/parse-8mt2OMKf.js +1508 -0
- package/cjs/parse-8mt2OMKf.js.map +1 -0
- package/cjs/scale/index.d.ts +2 -0
- package/cjs/scale/index.js +11 -0
- package/cjs/scale-Cq9pXrc1.js +318 -0
- package/cjs/scale-Cq9pXrc1.js.map +1 -0
- package/cjs/semantic-CrEIo8tS.d.ts +52 -0
- package/cjs/semantic-CrEIo8tS.d.ts.map +1 -0
- package/cjs/serialize-C69DAmoE.d.ts +25 -0
- package/cjs/serialize-C69DAmoE.d.ts.map +1 -0
- package/cjs/serialize-Ci2X0cPN.js +103 -0
- package/cjs/serialize-Ci2X0cPN.js.map +1 -0
- package/cjs/tokens/index.d.ts +2 -0
- package/cjs/tokens/index.js +6 -0
- package/cjs/tokens-CzXxaMgM.js +168 -0
- package/cjs/tokens-CzXxaMgM.js.map +1 -0
- package/cjs/tone-BAhu-QzQ.js +118 -0
- package/cjs/tone-BAhu-QzQ.js.map +1 -0
- package/dist/color-map.es5.js.map +1 -1
- package/esm/color/a98-rgb.d.ts +7 -0
- package/esm/color/a98-rgb.d.ts.map +1 -0
- package/esm/color/a98-rgb.js +13 -0
- package/esm/color/a98-rgb.js.map +1 -0
- package/esm/color/argb.d.ts +6 -0
- package/esm/color/argb.d.ts.map +1 -0
- package/esm/color/argb.js +25 -0
- package/esm/color/argb.js.map +1 -0
- package/esm/color/calc.js +82 -0
- package/esm/color/calc.js.map +1 -0
- package/esm/color/compare.d.ts +20 -0
- package/esm/color/compare.d.ts.map +1 -0
- package/esm/color/compare.js +46 -0
- package/esm/color/compare.js.map +1 -0
- package/esm/color/contrast.d.ts +107 -0
- package/esm/color/contrast.d.ts.map +1 -0
- package/esm/color/contrast.js +242 -0
- package/esm/color/contrast.js.map +1 -0
- package/esm/color/convert.d.ts +9 -0
- package/esm/color/convert.d.ts.map +1 -0
- package/esm/color/convert.js +45 -0
- package/esm/color/convert.js.map +1 -0
- package/esm/color/display-p3-linear.d.ts +7 -0
- package/esm/color/display-p3-linear.d.ts.map +1 -0
- package/esm/color/display-p3-linear.js +12 -0
- package/esm/color/display-p3-linear.js.map +1 -0
- package/esm/color/display-p3.d.ts +7 -0
- package/esm/color/display-p3.d.ts.map +1 -0
- package/esm/color/display-p3.js +12 -0
- package/esm/color/display-p3.js.map +1 -0
- package/esm/color/expr.d.ts +43 -0
- package/esm/color/expr.d.ts.map +1 -0
- package/esm/color/expr.js +160 -0
- package/esm/color/expr.js.map +1 -0
- package/esm/color/gamut.d.ts +31 -0
- package/esm/color/gamut.d.ts.map +1 -0
- package/esm/color/gamut.js +210 -0
- package/esm/color/gamut.js.map +1 -0
- package/esm/color/hsl.d.ts +7 -0
- package/esm/color/hsl.d.ts.map +1 -0
- package/esm/color/hsl.js +67 -0
- package/esm/color/hsl.js.map +1 -0
- package/esm/color/hwb.d.ts +7 -0
- package/esm/color/hwb.d.ts.map +1 -0
- package/esm/color/hwb.js +40 -0
- package/esm/color/hwb.js.map +1 -0
- package/esm/color/index.d.ts +28 -0
- package/esm/color/index.js +27 -0
- package/esm/color/lab.d.ts +7 -0
- package/esm/color/lab.d.ts.map +1 -0
- package/esm/color/lab.js +33 -0
- package/esm/color/lab.js.map +1 -0
- package/esm/color/lch.d.ts +7 -0
- package/esm/color/lch.d.ts.map +1 -0
- package/esm/color/lch.js +11 -0
- package/esm/color/lch.js.map +1 -0
- package/esm/color/math.js +32 -0
- package/esm/color/math.js.map +1 -0
- package/esm/color/matrices.js +180 -0
- package/esm/color/matrices.js.map +1 -0
- package/esm/color/mix.d.ts +30 -0
- package/esm/color/mix.d.ts.map +1 -0
- package/esm/color/mix.js +152 -0
- package/esm/color/mix.js.map +1 -0
- package/esm/color/named.js +11 -0
- package/esm/color/named.js.map +1 -0
- package/esm/color/oklab.d.ts +7 -0
- package/esm/color/oklab.d.ts.map +1 -0
- package/esm/color/oklab.js +21 -0
- package/esm/color/oklab.js.map +1 -0
- package/esm/color/oklch.d.ts +7 -0
- package/esm/color/oklch.d.ts.map +1 -0
- package/esm/color/oklch.js +11 -0
- package/esm/color/oklch.js.map +1 -0
- package/esm/color/parse.d.ts +9 -0
- package/esm/color/parse.d.ts.map +1 -0
- package/esm/color/parse.js +452 -0
- package/esm/color/parse.js.map +1 -0
- package/esm/color/polar.js +27 -0
- package/esm/color/polar.js.map +1 -0
- package/esm/color/prophoto-rgb.d.ts +7 -0
- package/esm/color/prophoto-rgb.d.ts.map +1 -0
- package/esm/color/prophoto-rgb.js +18 -0
- package/esm/color/prophoto-rgb.js.map +1 -0
- package/esm/color/rec2020.d.ts +7 -0
- package/esm/color/rec2020.d.ts.map +1 -0
- package/esm/color/rec2020.js +13 -0
- package/esm/color/rec2020.js.map +1 -0
- package/esm/color/resolve.d.ts +18 -0
- package/esm/color/resolve.d.ts.map +1 -0
- package/esm/color/resolve.js +98 -0
- package/esm/color/resolve.js.map +1 -0
- package/esm/color/rgb.js +20 -0
- package/esm/color/rgb.js.map +1 -0
- package/esm/color/serialize.d.ts +24 -0
- package/esm/color/serialize.d.ts.map +1 -0
- package/esm/color/serialize.js +88 -0
- package/esm/color/serialize.js.map +1 -0
- package/esm/color/spaces.d.ts +6 -0
- package/esm/color/spaces.d.ts.map +1 -0
- package/esm/color/spaces.js +42 -0
- package/esm/color/spaces.js.map +1 -0
- package/esm/color/srgb-linear.d.ts +7 -0
- package/esm/color/srgb-linear.d.ts.map +1 -0
- package/esm/color/srgb-linear.js +12 -0
- package/esm/color/srgb-linear.js.map +1 -0
- package/esm/color/srgb.d.ts +7 -0
- package/esm/color/srgb.d.ts.map +1 -0
- package/esm/color/srgb.js +12 -0
- package/esm/color/srgb.js.map +1 -0
- package/esm/color/transfer.js +11 -0
- package/esm/color/transfer.js.map +1 -0
- package/esm/color/types.d.ts +23 -0
- package/esm/color/types.d.ts.map +1 -0
- package/esm/color/unknown-name.js +16 -0
- package/esm/color/unknown-name.js.map +1 -0
- package/esm/color/xyz-d50.d.ts +7 -0
- package/esm/color/xyz-d50.d.ts.map +1 -0
- package/esm/color/xyz-d50.js +15 -0
- package/esm/color/xyz-d50.js.map +1 -0
- package/esm/color/xyz-d65.d.ts +4 -0
- package/esm/color/xyz-d65.d.ts.map +1 -0
- package/esm/color/xyz-d65.js +10 -0
- package/esm/color/xyz-d65.js.map +1 -0
- package/esm/index.d.ts +63 -6
- package/esm/index.d.ts.map +1 -0
- package/esm/index.js +54 -5
- package/esm/index.js.map +1 -0
- package/esm/legacy/createColors.d.ts +9 -0
- package/esm/legacy/createColors.d.ts.map +1 -0
- package/esm/{createColors.js → legacy/createColors.js} +7 -2
- package/esm/legacy/createColors.js.map +1 -0
- package/esm/legacy/createColorsFromMap.d.ts +9 -0
- package/esm/legacy/createColorsFromMap.d.ts.map +1 -0
- package/esm/{createColorsFromMap.js → legacy/createColorsFromMap.js} +4 -2
- package/esm/legacy/createColorsFromMap.js.map +1 -0
- package/esm/legacy/index.d.ts +6 -0
- package/esm/legacy/index.js +5 -0
- package/esm/legacy/interfaces.d.ts +19 -0
- package/esm/legacy/interfaces.d.ts.map +1 -0
- package/esm/{rgbHex.d.ts → legacy/rgbHex.d.ts} +1 -4
- package/esm/legacy/rgbHex.d.ts.map +1 -0
- package/esm/{rgbHex.js → legacy/rgbHex.js} +1 -2
- package/esm/legacy/rgbHex.js.map +1 -0
- package/esm/legacy/rgbaString.d.ts +4 -0
- package/esm/{rgbaString.d.ts.map → legacy/rgbaString.d.ts.map} +1 -1
- package/esm/legacy/rgbaString.js +8 -0
- package/esm/legacy/rgbaString.js.map +1 -0
- package/esm/palette/dark.d.ts +23 -0
- package/esm/palette/dark.d.ts.map +1 -0
- package/esm/palette/dark.js +54 -0
- package/esm/palette/dark.js.map +1 -0
- package/esm/palette/harmony.d.ts +56 -0
- package/esm/palette/harmony.d.ts.map +1 -0
- package/esm/palette/harmony.js +115 -0
- package/esm/palette/harmony.js.map +1 -0
- package/esm/palette/index.d.ts +5 -0
- package/esm/palette/index.js +5 -0
- package/esm/palette/semantic.d.ts +53 -0
- package/esm/palette/semantic.d.ts.map +1 -0
- package/esm/palette/semantic.js +96 -0
- package/esm/palette/semantic.js.map +1 -0
- package/esm/palette/validate.d.ts +39 -0
- package/esm/palette/validate.d.ts.map +1 -0
- package/esm/palette/validate.js +56 -0
- package/esm/palette/validate.js.map +1 -0
- package/esm/scale/brand.d.ts +21 -0
- package/esm/scale/brand.d.ts.map +1 -0
- package/esm/scale/brand.js +63 -0
- package/esm/scale/brand.js.map +1 -0
- package/esm/scale/contrast-scale.d.ts +33 -0
- package/esm/scale/contrast-scale.d.ts.map +1 -0
- package/esm/scale/contrast-scale.js +26 -0
- package/esm/scale/contrast-scale.js.map +1 -0
- package/esm/scale/gradient.d.ts +38 -0
- package/esm/scale/gradient.d.ts.map +1 -0
- package/esm/scale/gradient.js +73 -0
- package/esm/scale/gradient.js.map +1 -0
- package/esm/scale/index.d.ts +6 -0
- package/esm/scale/index.js +6 -0
- package/esm/scale/scale.d.ts +31 -0
- package/esm/scale/scale.d.ts.map +1 -0
- package/esm/scale/scale.js +152 -0
- package/esm/scale/scale.js.map +1 -0
- package/esm/scale/tone.d.ts +25 -0
- package/esm/scale/tone.d.ts.map +1 -0
- package/esm/scale/tone.js +96 -0
- package/esm/scale/tone.js.map +1 -0
- package/esm/tokens/css.d.ts +18 -0
- package/esm/tokens/css.d.ts.map +1 -0
- package/esm/tokens/css.js +23 -0
- package/esm/tokens/css.js.map +1 -0
- package/esm/tokens/dtcg.d.ts +33 -0
- package/esm/tokens/dtcg.d.ts.map +1 -0
- package/esm/tokens/dtcg.js +42 -0
- package/esm/tokens/dtcg.js.map +1 -0
- package/esm/tokens/index.d.ts +5 -0
- package/esm/tokens/index.js +4 -0
- package/esm/tokens/tailwind.d.ts +19 -0
- package/esm/tokens/tailwind.d.ts.map +1 -0
- package/esm/tokens/tailwind.js +19 -0
- package/esm/tokens/tailwind.js.map +1 -0
- package/esm/tokens/tokens.d.ts +11 -0
- package/esm/tokens/tokens.d.ts.map +1 -0
- package/esm/tokens/tokens.js +77 -0
- package/esm/tokens/tokens.js.map +1 -0
- package/llms.txt +48 -0
- package/package.json +277 -64
- package/plugin.json +18 -0
- package/schemas/contrast.schema.json +24 -0
- package/schemas/convert.schema.json +60 -0
- package/schemas/export.schema.json +106 -0
- package/schemas/find-contrast.schema.json +51 -0
- package/schemas/harmony.schema.json +58 -0
- package/schemas/mix.schema.json +72 -0
- package/schemas/palette.schema.json +56 -0
- package/schemas/scale.schema.json +86 -0
- package/skills/color-map/SKILL.md +38 -0
- package/src/cli/bin.ts +11 -0
- package/src/cli/cli.ts +40 -0
- package/src/cli/commands/contrast.ts +36 -0
- package/src/cli/commands/convert.ts +37 -0
- package/src/cli/commands/export.ts +102 -0
- package/src/cli/commands/find-contrast.ts +37 -0
- package/src/cli/commands/harmony.ts +30 -0
- package/src/cli/commands/mix.ts +29 -0
- package/src/cli/commands/palette.ts +66 -0
- package/src/cli/commands/scale.ts +75 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/schema-command.ts +31 -0
- package/src/cli/schemas.ts +86 -0
- package/src/cli/shared.ts +72 -0
- package/src/cli/testing.ts +27 -0
- package/src/color/a98-rgb.ts +11 -0
- package/src/color/argb.ts +21 -0
- package/src/color/calc.ts +84 -0
- package/src/color/compare.ts +53 -0
- package/src/color/contrast.ts +277 -0
- package/src/color/convert.ts +43 -0
- package/src/color/display-p3-linear.ts +9 -0
- package/src/color/display-p3.ts +9 -0
- package/src/color/expr.ts +158 -0
- package/src/color/gamut.ts +215 -0
- package/src/color/hsl.ts +57 -0
- package/src/color/hwb.ts +26 -0
- package/src/color/index.ts +43 -0
- package/src/color/lab.ts +29 -0
- package/src/color/lch.ts +8 -0
- package/src/color/math.ts +30 -0
- package/src/color/matrices.ts +91 -0
- package/src/color/mix.ts +158 -0
- package/src/color/named.ts +13 -0
- package/src/color/oklab.ts +19 -0
- package/src/color/oklch.ts +8 -0
- package/src/color/parse.ts +355 -0
- package/src/color/polar.ts +17 -0
- package/src/color/prophoto-rgb.ts +18 -0
- package/src/color/rec2020.ts +11 -0
- package/src/color/resolve.ts +87 -0
- package/src/color/rgb.ts +20 -0
- package/src/color/serialize.ts +96 -0
- package/src/color/spaces.ts +42 -0
- package/src/color/srgb-linear.ts +9 -0
- package/src/color/srgb.ts +9 -0
- package/src/color/test-utils.ts +10 -0
- package/src/color/transfer.ts +14 -0
- package/src/color/types.ts +41 -0
- package/src/color/unknown-name.ts +15 -0
- package/src/color/xyz-d50.ts +13 -0
- package/src/color/xyz-d65.ts +8 -0
- package/src/index.ts +37 -0
- package/{ts → src/legacy}/createColors.ts +6 -0
- package/{ts → src/legacy}/createColorsFromMap.ts +3 -0
- package/{ts → src/legacy}/interfaces.ts +4 -0
- package/{ts → src/legacy}/rgbaString.ts +1 -0
- package/src/palette/dark.ts +55 -0
- package/src/palette/harmony.ts +107 -0
- package/src/palette/index.ts +18 -0
- package/src/palette/semantic.ts +120 -0
- package/src/palette/validate.ts +82 -0
- package/src/scale/brand.ts +50 -0
- package/src/scale/contrast-scale.ts +48 -0
- package/src/scale/gradient.ts +91 -0
- package/src/scale/index.ts +5 -0
- package/src/scale/scale.ts +127 -0
- package/src/scale/tone.ts +69 -0
- package/src/tokens/css.ts +32 -0
- package/src/tokens/dtcg.ts +62 -0
- package/src/tokens/index.ts +11 -0
- package/src/tokens/tailwind.ts +24 -0
- package/src/tokens/tokens.ts +82 -0
- package/esm/createColors.d.ts +0 -6
- package/esm/createColors.d.ts.map +0 -1
- package/esm/createColors.js.map +0 -1
- package/esm/createColorsFromMap.d.ts +0 -9
- package/esm/createColorsFromMap.d.ts.map +0 -1
- package/esm/createColorsFromMap.js.map +0 -1
- package/esm/interfaces.d.ts +0 -18
- package/esm/interfaces.d.ts.map +0 -1
- package/esm/rgbHex.d.ts.map +0 -1
- package/esm/rgbHex.js.map +0 -1
- package/esm/rgbaString.d.ts +0 -6
- package/esm/rgbaString.js +0 -8
- package/esm/rgbaString.js.map +0 -1
- /package/{ts → src/legacy}/index.ts +0 -0
- /package/{ts → src/legacy}/rgbHex.ts +0 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { multiply } from './math.js'
|
|
2
|
+
import { lmsToOklab, lmsToXyz, oklabToLms, xyzToLms } from './matrices.js'
|
|
3
|
+
import type { Space } from './types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The CSS `oklab` space. Components are lightness in 0 to 1, then the a and b axes, which stay within about -0.4 to 0.4 for real colors.
|
|
7
|
+
* Pass it to `createConverter` and to the narrow helpers such as `createMix` and `createToGamut`.
|
|
8
|
+
*/
|
|
9
|
+
export const oklab: Space = {
|
|
10
|
+
id: 'oklab',
|
|
11
|
+
toXyz: (c) => {
|
|
12
|
+
const v = multiply(oklabToLms, c[0], c[1], c[2])
|
|
13
|
+
return multiply(lmsToXyz, v[0] ** 3, v[1] ** 3, v[2] ** 3)
|
|
14
|
+
},
|
|
15
|
+
fromXyz: (xyz) => {
|
|
16
|
+
const v = multiply(xyzToLms, xyz[0], xyz[1], xyz[2])
|
|
17
|
+
return multiply(lmsToOklab, Math.cbrt(v[0]), Math.cbrt(v[1]), Math.cbrt(v[2]))
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { oklab } from './oklab.js'
|
|
2
|
+
import { polarSpace } from './polar.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The CSS `oklch` space, the polar form of `oklab`. Components are lightness in 0 to 1, chroma (about 0 to 0.4) and hue in degrees.
|
|
6
|
+
* Pass it to `createConverter` and to the narrow helpers such as `createMix` and `createToGamut`.
|
|
7
|
+
*/
|
|
8
|
+
export const oklch = polarSpace('oklch', oklab)
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { namedColor } from './named.js'
|
|
2
|
+
import type { Color, ColorSpaceId, Component } from './types.js'
|
|
3
|
+
|
|
4
|
+
export const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/
|
|
5
|
+
// Tables keyed by text a caller wrote have no prototype (`__proto__: null`), so `constructor` or `toString` is an unknown name, not Object.prototype's.
|
|
6
|
+
export const UNITS: Record<string, number> = {
|
|
7
|
+
__proto__: null as never,
|
|
8
|
+
'': 1,
|
|
9
|
+
deg: 1,
|
|
10
|
+
grad: 0.9,
|
|
11
|
+
rad: 180 / Math.PI,
|
|
12
|
+
turn: 360
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** How one channel reads: what 100% means, and the allowed range. */
|
|
16
|
+
export interface Channel {
|
|
17
|
+
/** The value of 100%. Also the factor applied to a plain number, usually 1. */
|
|
18
|
+
pct: number
|
|
19
|
+
/** A plain number is divided by this (255 for sRGB channels). */
|
|
20
|
+
div?: number
|
|
21
|
+
min?: number
|
|
22
|
+
max?: number
|
|
23
|
+
hue?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function fail(input: string, what: string, token?: string): never {
|
|
27
|
+
throw new Error(`color-map: cannot parse "${input}": ${what}${token === undefined ? '' : ` "${token}"`}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function channel(token: string, spec: Channel, input: string): Component {
|
|
31
|
+
if (token === 'none') return null
|
|
32
|
+
const m = NUMBER.exec(token)
|
|
33
|
+
if (!m) return fail(input, 'invalid value', token)
|
|
34
|
+
const n = Number(m[0])
|
|
35
|
+
const rest = token.slice(m[0].length)
|
|
36
|
+
let value: number
|
|
37
|
+
if (spec.hue) {
|
|
38
|
+
const unit = UNITS[rest]
|
|
39
|
+
if (unit === undefined) return fail(input, 'invalid angle', token)
|
|
40
|
+
return n * unit
|
|
41
|
+
}
|
|
42
|
+
if (rest === '%') value = (n * spec.pct) / 100
|
|
43
|
+
else if (rest === '') value = n / (spec.div ?? 1)
|
|
44
|
+
else return fail(input, 'invalid value', token)
|
|
45
|
+
return Math.min(spec.max ?? Number.POSITIVE_INFINITY, Math.max(spec.min ?? Number.NEGATIVE_INFINITY, value))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const SRGB: Channel = { pct: 1, div: 255 }
|
|
49
|
+
const PCT100: Channel = { pct: 100, min: 0 }
|
|
50
|
+
export const UNIT: Channel = { pct: 1 }
|
|
51
|
+
export const ALPHA: Channel = { pct: 1, min: 0, max: 1 }
|
|
52
|
+
const HUE: Channel = { pct: 0, hue: true }
|
|
53
|
+
|
|
54
|
+
export const FUNCTIONS: Record<string, { space: ColorSpaceId; channels: [Channel, Channel, Channel] }> = {
|
|
55
|
+
__proto__: null as never,
|
|
56
|
+
rgb: { space: 'srgb', channels: [SRGB, SRGB, SRGB] },
|
|
57
|
+
hsl: { space: 'hsl', channels: [HUE, PCT100, PCT100] },
|
|
58
|
+
hwb: { space: 'hwb', channels: [HUE, PCT100, PCT100] },
|
|
59
|
+
lab: { space: 'lab', channels: [{ pct: 100, min: 0, max: 100 }, { pct: 125 }, { pct: 125 }] },
|
|
60
|
+
lch: { space: 'lch', channels: [{ pct: 100, min: 0, max: 100 }, { pct: 150, min: 0 }, HUE] },
|
|
61
|
+
oklab: { space: 'oklab', channels: [{ pct: 1, min: 0, max: 1 }, { pct: 0.4 }, { pct: 0.4 }] },
|
|
62
|
+
oklch: { space: 'oklch', channels: [{ pct: 1, min: 0, max: 1 }, { pct: 0.4, min: 0 }, HUE] }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The spaces `color()` names, by the name it uses: their own, and `xyz` for `xyz-d65`. */
|
|
66
|
+
export const COLOR_SPACES: Record<string, ColorSpaceId> = { __proto__: null as never, xyz: 'xyz-d65' }
|
|
67
|
+
for (const id of [
|
|
68
|
+
'srgb',
|
|
69
|
+
'srgb-linear',
|
|
70
|
+
'display-p3',
|
|
71
|
+
'display-p3-linear',
|
|
72
|
+
'a98-rgb',
|
|
73
|
+
'prophoto-rgb',
|
|
74
|
+
'rec2020',
|
|
75
|
+
'xyz-d50',
|
|
76
|
+
'xyz-d65'
|
|
77
|
+
] as const)
|
|
78
|
+
COLOR_SPACES[id] = id
|
|
79
|
+
|
|
80
|
+
/** `currentcolor` and the CSS Color 4 system colors (canvas, canvastext, linktext, buttonface, ...). */
|
|
81
|
+
export const VALUES_NEEDING_RESOLVE =
|
|
82
|
+
/^(?:currentcolor|(?:canvas|field|highlight|selecteditem|mark|accentcolor)(?:text)?|(?:link|visited|active|button|gray)text|button(?:face|border))$/
|
|
83
|
+
|
|
84
|
+
function hex(input: string): Color {
|
|
85
|
+
const digits = input.slice(1)
|
|
86
|
+
if (!/^(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/.test(digits)) return fail(input, 'invalid hex color')
|
|
87
|
+
const full = digits.length < 5 ? [...digits].map((d) => d + d).join('') : digits
|
|
88
|
+
const byte = (i: number): number => Number.parseInt(full.slice(i * 2, i * 2 + 2), 16) / 255
|
|
89
|
+
const color: Color = { colorSpace: 'srgb', components: [byte(0), byte(1), byte(2)] }
|
|
90
|
+
if (full.length === 8) color.alpha = byte(3)
|
|
91
|
+
return color
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ALIASES: Record<string, string> = { __proto__: null as never, rgba: 'rgb', hsla: 'hsl' }
|
|
95
|
+
|
|
96
|
+
function functional(rawName: string, body: string, input: string): Color {
|
|
97
|
+
const name = ALIASES[rawName] ?? rawName
|
|
98
|
+
const [main, alphaToken, ...extra] = body.split('/').map((s) => s.trim())
|
|
99
|
+
if (extra.length > 0) return fail(input, 'more than one "/"')
|
|
100
|
+
const legacy = main.includes(',')
|
|
101
|
+
const tokens = (legacy ? main.split(',') : main.split(/\s+/)).map((s) => s.trim()).filter(Boolean)
|
|
102
|
+
let alpha = alphaToken
|
|
103
|
+
if (legacy) {
|
|
104
|
+
if (name !== 'rgb' && name !== 'hsl') return fail(input, 'the comma syntax is only for rgb() and hsl()')
|
|
105
|
+
if (alpha !== undefined) return fail(input, 'cannot mix "," and "/"')
|
|
106
|
+
if (tokens.length === 4) alpha = tokens.pop() as string
|
|
107
|
+
if (tokens.includes('none')) return fail(input, '"none" is not allowed in the comma syntax')
|
|
108
|
+
}
|
|
109
|
+
let space: ColorSpaceId
|
|
110
|
+
let channels: [Channel, Channel, Channel]
|
|
111
|
+
if (name === 'color') {
|
|
112
|
+
const id = COLOR_SPACES[tokens.shift() ?? '']
|
|
113
|
+
if (!id) return fail(input, 'unknown color space', main.split(/\s+/)[0])
|
|
114
|
+
space = id
|
|
115
|
+
channels = [UNIT, UNIT, UNIT]
|
|
116
|
+
} else {
|
|
117
|
+
const fn = FUNCTIONS[name]
|
|
118
|
+
if (!fn) return fail(input, 'unknown function', rawName)
|
|
119
|
+
;({ space, channels } = fn)
|
|
120
|
+
}
|
|
121
|
+
if (tokens.length !== 3) return fail(input, `expected 3 components, got ${tokens.length}`)
|
|
122
|
+
if (legacy) {
|
|
123
|
+
const percents = tokens.map((t) => t.endsWith('%'))
|
|
124
|
+
const ok = space === 'hsl' ? !percents[0] && percents[1] && percents[2] : percents.every((p) => p === percents[0])
|
|
125
|
+
if (!ok) return fail(input, 'mixed numbers and percentages', tokens.join(', '))
|
|
126
|
+
}
|
|
127
|
+
const color: Color = {
|
|
128
|
+
colorSpace: space,
|
|
129
|
+
components: tokens.map((t, i) => channel(t, channels[i], input))
|
|
130
|
+
}
|
|
131
|
+
if (alpha !== undefined) color.alpha = channel(alpha, ALPHA, input)
|
|
132
|
+
return color
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parses a CSS Color 4 color: hex, named colors, `transparent`, `rgb()`,
|
|
137
|
+
* `hsl()`, `hwb()`, `lab()`, `lch()`, `oklab()`, `oklch()` and `color()`.
|
|
138
|
+
* `none` becomes `null`. Values that need a context (`currentcolor`, system
|
|
139
|
+
* colors) throw, because nothing here can resolve them.
|
|
140
|
+
*/
|
|
141
|
+
export function parse(input: string): Color {
|
|
142
|
+
const text = input.trim().toLowerCase()
|
|
143
|
+
return scan(text) ?? parseGeneric(input, text)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The regex-based parser: every accepted form, and every error message. `parse`
|
|
148
|
+
* tries {@link scan} first, which takes only the plain valid forms and leaves
|
|
149
|
+
* the rest, including every error, to this.
|
|
150
|
+
*/
|
|
151
|
+
export function parseGeneric(input: string, text: string): Color {
|
|
152
|
+
if (text.startsWith('#')) return hex(text)
|
|
153
|
+
const fn = /^([a-z]+)\((.*)\)$/s.exec(text)
|
|
154
|
+
if (fn) return functional(fn[1], fn[2], input)
|
|
155
|
+
if (text === 'transparent') return { colorSpace: 'srgb', components: [0, 0, 0], alpha: 0 }
|
|
156
|
+
const named = namedColor(text)
|
|
157
|
+
if (named !== undefined) {
|
|
158
|
+
return { colorSpace: 'srgb', components: [(named >> 16) / 255, ((named >> 8) & 255) / 255, (named & 255) / 255] }
|
|
159
|
+
}
|
|
160
|
+
if (VALUES_NEEDING_RESOLVE.test(text)) return fail(input, 'needs a context to resolve', text)
|
|
161
|
+
return fail(input, 'not a color')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A color as given, or parsed with {@link parse} when it is a string. */
|
|
165
|
+
export const toColor = (c: Color | string): Color => (typeof c === 'string' ? parse(c) : c)
|
|
166
|
+
|
|
167
|
+
// --- The scanner: a char-code fast path for hex and the functional forms ---
|
|
168
|
+
|
|
169
|
+
const isSpace = (c: number): boolean => c === 32 || c === 9 || c === 10 || c === 13 || c === 12
|
|
170
|
+
const isDigit = (c: number): boolean => c >= 48 && c <= 57
|
|
171
|
+
const hexValue = (c: number): number => (c >= 48 && c <= 57 ? c - 48 : c >= 97 && c <= 102 ? c - 87 : -1)
|
|
172
|
+
|
|
173
|
+
// Scanner state, shared across calls: `parse` is synchronous and does not re-enter.
|
|
174
|
+
let at = 0
|
|
175
|
+
let percent = false
|
|
176
|
+
|
|
177
|
+
function scanHex(text: string): Color | undefined {
|
|
178
|
+
const len = text.length
|
|
179
|
+
if (len !== 4 && len !== 5 && len !== 7 && len !== 9) return undefined
|
|
180
|
+
const short = len < 6
|
|
181
|
+
const count = short ? len - 1 : (len - 1) / 2
|
|
182
|
+
const bytes: number[] = []
|
|
183
|
+
for (let i = 0; i < count; i++) {
|
|
184
|
+
let value: number
|
|
185
|
+
if (short) {
|
|
186
|
+
const d = hexValue(text.charCodeAt(1 + i))
|
|
187
|
+
value = d * 17
|
|
188
|
+
if (d < 0) return undefined
|
|
189
|
+
} else {
|
|
190
|
+
const hi = hexValue(text.charCodeAt(1 + i * 2))
|
|
191
|
+
const lo = hexValue(text.charCodeAt(2 + i * 2))
|
|
192
|
+
if (hi < 0 || lo < 0) return undefined
|
|
193
|
+
value = hi * 16 + lo
|
|
194
|
+
}
|
|
195
|
+
bytes.push(value / 255)
|
|
196
|
+
}
|
|
197
|
+
const color: Color = { colorSpace: 'srgb', components: [bytes[0], bytes[1], bytes[2]] }
|
|
198
|
+
if (count === 4) color.alpha = bytes[3]
|
|
199
|
+
return color
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Skips whitespace from `at` up to `end`. */
|
|
203
|
+
function skip(text: string, end: number): void {
|
|
204
|
+
while (at < end && isSpace(text.charCodeAt(at))) at++
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The end of the token that starts at `at`: it runs to whitespace, a comma, a slash or `end`. */
|
|
208
|
+
function tokenEnd(text: string, end: number): number {
|
|
209
|
+
let j = at
|
|
210
|
+
while (j < end) {
|
|
211
|
+
const c = text.charCodeAt(j)
|
|
212
|
+
if (isSpace(c) || c === 44 || c === 47) break
|
|
213
|
+
j++
|
|
214
|
+
}
|
|
215
|
+
return j
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Reads the token at `at` as {@link channel} would, or returns `undefined` when
|
|
220
|
+
* it is anything but a plain valid one. Leaves `at` after the token.
|
|
221
|
+
*/
|
|
222
|
+
function scanChannel(text: string, end: number, spec: Channel): Component | undefined {
|
|
223
|
+
const s = at
|
|
224
|
+
const e = tokenEnd(text, end)
|
|
225
|
+
at = e
|
|
226
|
+
if (e === s) return undefined
|
|
227
|
+
if (e - s === 4 && text.startsWith('none', s)) return null
|
|
228
|
+
let j = s
|
|
229
|
+
let c = text.charCodeAt(j)
|
|
230
|
+
if (c === 43 || c === 45) c = text.charCodeAt(++j)
|
|
231
|
+
let digits = 0
|
|
232
|
+
while (isDigit(c)) {
|
|
233
|
+
c = text.charCodeAt(++j)
|
|
234
|
+
digits++
|
|
235
|
+
}
|
|
236
|
+
if (c === 46) {
|
|
237
|
+
c = text.charCodeAt(++j)
|
|
238
|
+
while (isDigit(c)) {
|
|
239
|
+
c = text.charCodeAt(++j)
|
|
240
|
+
digits++
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (digits === 0) return undefined
|
|
244
|
+
if (c === 101) {
|
|
245
|
+
let k = j + 1
|
|
246
|
+
let x = text.charCodeAt(k)
|
|
247
|
+
if (x === 43 || x === 45) x = text.charCodeAt(++k)
|
|
248
|
+
if (isDigit(x)) {
|
|
249
|
+
while (isDigit(text.charCodeAt(k))) k++
|
|
250
|
+
j = k
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const n = Number(text.slice(s, j))
|
|
254
|
+
if (spec.hue) {
|
|
255
|
+
const unit = j === e ? 1 : UNITS[text.slice(j, e)]
|
|
256
|
+
return unit === undefined ? undefined : n * unit
|
|
257
|
+
}
|
|
258
|
+
let value: number
|
|
259
|
+
if (j === e) value = n / (spec.div ?? 1)
|
|
260
|
+
else if (e - j === 1 && text.charCodeAt(j) === 37) {
|
|
261
|
+
percent = true
|
|
262
|
+
value = (n * spec.pct) / 100
|
|
263
|
+
} else return undefined
|
|
264
|
+
return Math.min(spec.max ?? Number.POSITIVE_INFINITY, Math.max(spec.min ?? Number.NEGATIVE_INFINITY, value))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Parses the plain valid forms (hex, and the comma or space forms of the
|
|
269
|
+
* functions) by char code, with no regex, split or per-token array. Anything
|
|
270
|
+
* else returns `undefined`, so that {@link parseGeneric} handles it and says
|
|
271
|
+
* why it is wrong.
|
|
272
|
+
*/
|
|
273
|
+
function scan(text: string): Color | undefined {
|
|
274
|
+
if (text.charCodeAt(0) === 35) return scanHex(text)
|
|
275
|
+
const end = text.length - 1
|
|
276
|
+
if (text.charCodeAt(end) !== 41) return undefined
|
|
277
|
+
let open = 0
|
|
278
|
+
while (open < end) {
|
|
279
|
+
const c = text.charCodeAt(open)
|
|
280
|
+
if (c < 97 || c > 122) break
|
|
281
|
+
open++
|
|
282
|
+
}
|
|
283
|
+
if (open === 0 || text.charCodeAt(open) !== 40) return undefined
|
|
284
|
+
const rawName = text.slice(0, open)
|
|
285
|
+
const name = ALIASES[rawName] ?? rawName
|
|
286
|
+
let space: ColorSpaceId
|
|
287
|
+
let channels: [Channel, Channel, Channel]
|
|
288
|
+
at = open + 1
|
|
289
|
+
skip(text, end)
|
|
290
|
+
if (name === 'color') {
|
|
291
|
+
const s = at
|
|
292
|
+
while (at < end && !isSpace(text.charCodeAt(at))) at++
|
|
293
|
+
const id = COLOR_SPACES[text.slice(s, at)]
|
|
294
|
+
if (!id) return undefined
|
|
295
|
+
space = id
|
|
296
|
+
channels = [UNIT, UNIT, UNIT]
|
|
297
|
+
skip(text, end)
|
|
298
|
+
} else {
|
|
299
|
+
const fn = FUNCTIONS[name]
|
|
300
|
+
if (!fn) return undefined
|
|
301
|
+
;({ space, channels } = fn)
|
|
302
|
+
}
|
|
303
|
+
percent = false
|
|
304
|
+
const c0 = scanChannel(text, end, channels[0])
|
|
305
|
+
if (c0 === undefined) return undefined
|
|
306
|
+
const p0 = percent
|
|
307
|
+
skip(text, end)
|
|
308
|
+
const comma = text.charCodeAt(at) === 44
|
|
309
|
+
if (comma && name !== 'rgb' && name !== 'hsl') return undefined
|
|
310
|
+
let alpha: Component | undefined
|
|
311
|
+
let c1: Component | undefined
|
|
312
|
+
let c2: Component | undefined
|
|
313
|
+
let p1 = false
|
|
314
|
+
let p2 = false
|
|
315
|
+
if (comma) {
|
|
316
|
+
at++
|
|
317
|
+
skip(text, end)
|
|
318
|
+
percent = false
|
|
319
|
+
c1 = scanChannel(text, end, channels[1])
|
|
320
|
+
p1 = percent
|
|
321
|
+
skip(text, end)
|
|
322
|
+
if (text.charCodeAt(at++) !== 44) return undefined
|
|
323
|
+
skip(text, end)
|
|
324
|
+
percent = false
|
|
325
|
+
c2 = scanChannel(text, end, channels[2])
|
|
326
|
+
p2 = percent
|
|
327
|
+
skip(text, end)
|
|
328
|
+
if (c0 === null || c1 === undefined || c1 === null || c2 === undefined || c2 === null) return undefined
|
|
329
|
+
const ok = space === 'hsl' ? !p0 && p1 && p2 : p0 === p1 && p1 === p2
|
|
330
|
+
if (!ok) return undefined
|
|
331
|
+
if (at < end) {
|
|
332
|
+
if (text.charCodeAt(at++) !== 44) return undefined
|
|
333
|
+
skip(text, end)
|
|
334
|
+
alpha = scanChannel(text, end, ALPHA)
|
|
335
|
+
if (alpha === undefined || alpha === null) return undefined
|
|
336
|
+
skip(text, end)
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
c1 = scanChannel(text, end, channels[1])
|
|
340
|
+
skip(text, end)
|
|
341
|
+
c2 = scanChannel(text, end, channels[2])
|
|
342
|
+
skip(text, end)
|
|
343
|
+
if (at < end) {
|
|
344
|
+
if (text.charCodeAt(at++) !== 47) return undefined
|
|
345
|
+
skip(text, end)
|
|
346
|
+
alpha = scanChannel(text, end, ALPHA)
|
|
347
|
+
if (alpha === undefined) return undefined
|
|
348
|
+
skip(text, end)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (at !== end || c1 === undefined || c2 === undefined) return undefined
|
|
352
|
+
const color: Color = { colorSpace: space, components: [c0, c1, c2] }
|
|
353
|
+
if (alpha !== undefined) color.alpha = alpha
|
|
354
|
+
return color
|
|
355
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { normalizeHue } from './math.js'
|
|
2
|
+
import type { Space } from './types.js'
|
|
3
|
+
|
|
4
|
+
/** Wraps a rectangular Lab-like space as its polar (LCH-like) twin. */
|
|
5
|
+
export function polarSpace(id: 'lch' | 'oklch', base: Space): Space {
|
|
6
|
+
return {
|
|
7
|
+
id,
|
|
8
|
+
toXyz: ([l, c, h]) => {
|
|
9
|
+
const r = (h * Math.PI) / 180
|
|
10
|
+
return base.toXyz([l, c * Math.cos(r), c * Math.sin(r)])
|
|
11
|
+
},
|
|
12
|
+
fromXyz: (xyz) => {
|
|
13
|
+
const [l, a, b] = base.fromXyz(xyz)
|
|
14
|
+
return [l, Math.hypot(a, b), normalizeHue((Math.atan2(b, a) * 180) / Math.PI)]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { prophotoToXyzD50, xyzD50ToProphoto } from './matrices.js'
|
|
2
|
+
import { rgbSpace } from './rgb.js'
|
|
3
|
+
import { prophotoToGamma, prophotoToLinear } from './transfer.js'
|
|
4
|
+
import type { Space } from './types.js'
|
|
5
|
+
import { xyzD50 } from './xyz-d50.js'
|
|
6
|
+
|
|
7
|
+
// ProPhoto's matrices lead to XYZ-D50, so it goes through that space on the way to the hub.
|
|
8
|
+
const d50 = rgbSpace('prophoto-rgb', prophotoToLinear, prophotoToGamma, prophotoToXyzD50, xyzD50ToProphoto)
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The CSS `prophoto-rgb` space. Components are red, green and blue in 0 to 1. It is defined against a D50 white and converted through `xyzD50`.
|
|
12
|
+
* Pass it to `createConverter` and to the narrow helpers such as `createMix` and `createToGamut`.
|
|
13
|
+
*/
|
|
14
|
+
export const prophotoRgb: Space = {
|
|
15
|
+
id: 'prophoto-rgb',
|
|
16
|
+
toXyz: (c) => xyzD50.toXyz(d50.toXyz(c)),
|
|
17
|
+
fromXyz: (xyz) => d50.fromXyz(xyzD50.fromXyz(xyz))
|
|
18
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { rec2020ToXyz, xyzToRec2020 } from './matrices.js'
|
|
2
|
+
import { rgbSpace } from './rgb.js'
|
|
3
|
+
import { power } from './transfer.js'
|
|
4
|
+
|
|
5
|
+
const [toLinear, toGamma] = power(2.4)
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The CSS `rec2020` space. Components are red, green and blue in 0 to 1, with a 2.4 power transfer curve.
|
|
9
|
+
* Pass it to `createConverter` and to the narrow helpers such as `createMix` and `createToGamut`.
|
|
10
|
+
*/
|
|
11
|
+
export const rec2020 = rgbSpace('rec2020', toLinear, toGamma, rec2020ToXyz, xyzToRec2020)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { evaluate } from './calc.js'
|
|
2
|
+
import { wcag2 } from './contrast.js'
|
|
3
|
+
import { type ColorExpr, parseExpr } from './expr.js'
|
|
4
|
+
import { mix } from './mix.js'
|
|
5
|
+
import { ALPHA, channel, FUNCTIONS, fail, UNIT } from './parse.js'
|
|
6
|
+
import { componentsIn } from './spaces.js'
|
|
7
|
+
import type { Color, Component } from './types.js'
|
|
8
|
+
|
|
9
|
+
/** What `resolve` needs from its surroundings to settle `currentcolor`, `light-dark()` and system colors. */
|
|
10
|
+
export interface ResolveContext {
|
|
11
|
+
/** What `currentcolor` means. */
|
|
12
|
+
currentColor?: string | ColorExpr
|
|
13
|
+
/** Which side `light-dark()` takes. Default `light`. */
|
|
14
|
+
colorScheme?: 'light' | 'dark'
|
|
15
|
+
/** What each system color (`canvas`, `buttontext`, ...) means, by lowercase name. */
|
|
16
|
+
systemColors?: Record<string, string | ColorExpr>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The names a relative color's channel keywords have, by space, one letter each. The RGB spaces use `rgb`. */
|
|
20
|
+
const KEYWORDS: Record<string, string> = {
|
|
21
|
+
__proto__: null as never,
|
|
22
|
+
hsl: 'hsl',
|
|
23
|
+
hwb: 'hwb',
|
|
24
|
+
lab: 'lab',
|
|
25
|
+
lch: 'lch',
|
|
26
|
+
oklab: 'lab',
|
|
27
|
+
oklch: 'lch',
|
|
28
|
+
'xyz-d50': 'xyz',
|
|
29
|
+
'xyz-d65': 'xyz'
|
|
30
|
+
}
|
|
31
|
+
const SIMPLE = /^(?:none|[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?[a-z%]*)$/
|
|
32
|
+
|
|
33
|
+
const WHITE: Color = { colorSpace: 'srgb', components: [1, 1, 1] }
|
|
34
|
+
const BLACK: Color = { colorSpace: 'srgb', components: [0, 0, 0] }
|
|
35
|
+
|
|
36
|
+
/** WCAG 2 choice between white and black: whichever contrasts more with the color. */
|
|
37
|
+
function contrastColor(color: Color): Color {
|
|
38
|
+
return wcag2(WHITE, color) >= wcag2(BLACK, color) ? WHITE : BLACK
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveRelative(node: Extract<ColorExpr, { type: 'relative' }>, context: ResolveContext): Color {
|
|
42
|
+
const origin = resolve(node.from, context)
|
|
43
|
+
const c = componentsIn(origin, node.space)
|
|
44
|
+
const scale = node.fn === 'rgb' ? 255 : 1
|
|
45
|
+
const names = KEYWORDS[node.space] ?? 'rgb'
|
|
46
|
+
const vars: Record<string, number> = { alpha: origin.alpha === undefined ? 1 : (origin.alpha ?? 0) }
|
|
47
|
+
for (let i = 0; i < 3; i++) vars[names[i]] = (c[i] ?? 0) * scale
|
|
48
|
+
const specs = node.fn === 'color' ? [UNIT, UNIT, UNIT] : FUNCTIONS[node.fn].channels
|
|
49
|
+
const value = (token: string, spec: typeof ALPHA): Component =>
|
|
50
|
+
channel(SIMPLE.test(token) ? token : String(evaluate(token, vars)), spec, token)
|
|
51
|
+
const result: Color = { colorSpace: node.space, components: node.channels.map((t, i) => value(t, specs[i])) }
|
|
52
|
+
const alpha = node.alpha === undefined ? origin.alpha : value(node.alpha, ALPHA)
|
|
53
|
+
if (alpha !== undefined) result.alpha = alpha
|
|
54
|
+
return result
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveMix(node: Extract<ColorExpr, { type: 'color-mix' }>, context: ResolveContext): Color {
|
|
58
|
+
const [first, second] = node.items
|
|
59
|
+
const p1 = first.percent ?? (second.percent === undefined ? 50 : 100 - second.percent)
|
|
60
|
+
const p2 = second.percent ?? 100 - p1
|
|
61
|
+
const sum = p1 + p2
|
|
62
|
+
if (sum <= 0) return fail('color-mix()', 'the percentages must add up to more than 0')
|
|
63
|
+
const options = node.hue === undefined ? { in: node.space } : { in: node.space, hue: node.hue }
|
|
64
|
+
const result = mix(resolve(first.color, context), resolve(second.color, context), p2 / sum, options)
|
|
65
|
+
// Percentages adding up to less than 100% make the result translucent.
|
|
66
|
+
if (sum < 100 && result.alpha !== null) result.alpha = (result.alpha ?? 1) * (sum / 100)
|
|
67
|
+
return result
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Turns a color, a CSS string or a {@link ColorExpr} into a concrete {@link Color}.
|
|
72
|
+
* `currentcolor`, system colors and `light-dark()` read `context`; nothing is
|
|
73
|
+
* guessed, so a missing entry throws and names the value.
|
|
74
|
+
*/
|
|
75
|
+
export function resolve(input: string | ColorExpr, context: ResolveContext = {}): Color {
|
|
76
|
+
const expr = typeof input === 'string' ? parseExpr(input) : input
|
|
77
|
+
if ('colorSpace' in expr) return expr
|
|
78
|
+
if (expr.type === 'light-dark') return resolve(context.colorScheme === 'dark' ? expr.dark : expr.light, context)
|
|
79
|
+
if (expr.type === 'color-mix') return resolveMix(expr, context)
|
|
80
|
+
if (expr.type === 'relative') return resolveRelative(expr, context)
|
|
81
|
+
if (expr.type === 'contrast-color') return contrastColor(resolve(expr.color, context))
|
|
82
|
+
const name = expr.type === 'currentcolor' ? 'currentcolor' : expr.name
|
|
83
|
+
const value = expr.type === 'currentcolor' ? context.currentColor : context.systemColors?.[name]
|
|
84
|
+
if (value === undefined)
|
|
85
|
+
return fail(name, `needs ${expr.type === 'currentcolor' ? 'currentColor' : `systemColors.${name}`} to resolve`)
|
|
86
|
+
return resolve(value, { ...context, currentColor: undefined })
|
|
87
|
+
}
|
package/src/color/rgb.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Matrix, multiply } from './math.js'
|
|
2
|
+
import type { ColorSpaceId, Space } from './types.js'
|
|
3
|
+
|
|
4
|
+
/** An RGB space: a transfer function, then a matrix to XYZ-D65. */
|
|
5
|
+
export function rgbSpace(
|
|
6
|
+
id: ColorSpaceId,
|
|
7
|
+
toLinear: (n: number) => number,
|
|
8
|
+
toGamma: (n: number) => number,
|
|
9
|
+
toXyz: Matrix,
|
|
10
|
+
fromXyz: Matrix
|
|
11
|
+
): Space {
|
|
12
|
+
return {
|
|
13
|
+
id,
|
|
14
|
+
toXyz: (c) => multiply(toXyz, toLinear(c[0]), toLinear(c[1]), toLinear(c[2])),
|
|
15
|
+
fromXyz: (xyz) => {
|
|
16
|
+
const v = multiply(fromXyz, xyz[0], xyz[1], xyz[2])
|
|
17
|
+
return [toGamma(v[0]), toGamma(v[1]), toGamma(v[2])]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type ComponentsIn, componentsOver } from './convert.js'
|
|
2
|
+
import { componentsIn } from './spaces.js'
|
|
3
|
+
import { srgb } from './srgb.js'
|
|
4
|
+
import type { Color, ColorSpaceId, Component, Space } from './types.js'
|
|
5
|
+
|
|
6
|
+
/** Options for `toCss`. */
|
|
7
|
+
export interface ToCssOptions {
|
|
8
|
+
/** Decimal places kept in every number. Default 5. */
|
|
9
|
+
precision?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const POWERS = Array.from({ length: 23 }, (_, i) => Number(`1e${i}`))
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `Number(n.toFixed(precision))`, without building the string. `toFixed` rounds
|
|
16
|
+
* the exact value of `n` half up (away from zero), and the number it then reads
|
|
17
|
+
* is `k / 10^precision` for that integer `k`, which one division gives exactly.
|
|
18
|
+
* `k` comes from `t = |n| * 10^precision`. The product is rounded, but a tie
|
|
19
|
+
* (`t` ending in .5) is a double, and rounding is monotonic, so any `t` that
|
|
20
|
+
* is not exactly a tie lies on the same side of it as the exact product: only
|
|
21
|
+
* `t` ending in .5 is left to `toFixed`. So are NaN, the infinities, products
|
|
22
|
+
* of 1e9 and up, and precisions that `POWERS` lacks.
|
|
23
|
+
*/
|
|
24
|
+
export function roundTo(n: number, precision: number): number {
|
|
25
|
+
const scale = POWERS[precision]
|
|
26
|
+
const t = Math.abs(n) * scale
|
|
27
|
+
const whole = Math.floor(t)
|
|
28
|
+
const rest = t - whole
|
|
29
|
+
if (t < 1e9 && rest !== 0.5) {
|
|
30
|
+
const r = (rest > 0.5 ? whole + 1 : whole) / scale
|
|
31
|
+
return n < 0 ? -r : r
|
|
32
|
+
}
|
|
33
|
+
return Number(n.toFixed(precision))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Prints a number with at most `precision` decimals, without `-0` or trailing zeros. */
|
|
37
|
+
function num(n: number, precision: number): string {
|
|
38
|
+
return String(roundTo(n, precision) + 0)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const part = (c: Component, precision: number, scale = 1, suffix = ''): string =>
|
|
42
|
+
c === null ? 'none' : num(c * scale, precision) + suffix
|
|
43
|
+
|
|
44
|
+
const RECTANGULAR = new Set<ColorSpaceId>(['hsl', 'hwb', 'lab', 'lch', 'oklab', 'oklch'])
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Serializes a color as CSS. `srgb` and `hsl` print in the comma syntax
|
|
48
|
+
* (`rgb(255, 0, 0)`, `rgba(...)`) unless a component is `none`; the other
|
|
49
|
+
* spaces print in the space syntax (`oklch(0.7 0.1 200 / 0.5)`,
|
|
50
|
+
* `color(display-p3 1 0 0)`). Out-of-gamut values are kept, not clipped.
|
|
51
|
+
*/
|
|
52
|
+
export function toCss(color: Color, options: ToCssOptions = {}): string {
|
|
53
|
+
const { precision = 5 } = options
|
|
54
|
+
const id = color.colorSpace
|
|
55
|
+
const [c0, c1, c2] = color.components
|
|
56
|
+
const p = (c: Component, scale?: number, suffix?: string): string => part(c, precision, scale, suffix)
|
|
57
|
+
const hasAlpha = color.alpha !== undefined && color.alpha !== 1
|
|
58
|
+
const a = hasAlpha ? p(color.alpha as Component) : undefined
|
|
59
|
+
const percent = id === 'hsl' || id === 'hwb' ? '%' : ''
|
|
60
|
+
const scale = id === 'srgb' ? 255 : 1
|
|
61
|
+
const args = [p(c0, scale), p(c1, scale, percent), p(c2, scale, percent)]
|
|
62
|
+
if ((id === 'srgb' || id === 'hsl') && color.alpha !== null && !color.components.includes(null)) {
|
|
63
|
+
const name = id === 'srgb' ? 'rgb' : 'hsl'
|
|
64
|
+
return a === undefined ? `${name}(${args.join(', ')})` : `${name}a(${args.join(', ')}, ${a})`
|
|
65
|
+
}
|
|
66
|
+
const head = id === 'srgb' ? 'rgb(' : RECTANGULAR.has(id) ? `${id}(` : `color(${id} `
|
|
67
|
+
return `${head}${args.join(' ')}${a === undefined ? '' : ` / ${a}`})`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const hexByte = (n: number): string =>
|
|
71
|
+
Math.min(255, Math.max(0, Math.round(n * 255)))
|
|
72
|
+
.toString(16)
|
|
73
|
+
.padStart(2, '0')
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The hex form of a color, `#rrggbb` or `#rrggbbaa` when it is not opaque.
|
|
77
|
+
* Colors outside sRGB are converted and clipped, so map them with `toGamut` first.
|
|
78
|
+
*/
|
|
79
|
+
export function toHex(color: Color): string {
|
|
80
|
+
return hexWith(componentsIn, color)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hexWith(componentsIn: ComponentsIn, color: Color): string {
|
|
84
|
+
const [r, g, b] = componentsIn(color, 'srgb') as number[]
|
|
85
|
+
const alpha = color.alpha ?? 1
|
|
86
|
+
return `#${hexByte(r)}${hexByte(g)}${hexByte(b)}${alpha === 1 ? '' : hexByte(alpha)}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Builds {@link toHex} over the spaces given, plus `srgb`, so a bundle contains
|
|
91
|
+
* only those. A color in a space not given throws, naming that space.
|
|
92
|
+
*/
|
|
93
|
+
export function createToHex(...spaces: Space[]): (color: Color) => string {
|
|
94
|
+
const over = componentsOver([srgb, ...spaces])
|
|
95
|
+
return (color) => hexWith(over, color)
|
|
96
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { a98Rgb } from './a98-rgb.js'
|
|
2
|
+
import { componentsOver, createConverter } from './convert.js'
|
|
3
|
+
import { displayP3 } from './display-p3.js'
|
|
4
|
+
import { displayP3Linear } from './display-p3-linear.js'
|
|
5
|
+
import { hsl } from './hsl.js'
|
|
6
|
+
import { hwb } from './hwb.js'
|
|
7
|
+
import { lab } from './lab.js'
|
|
8
|
+
import { lch } from './lch.js'
|
|
9
|
+
import { oklab } from './oklab.js'
|
|
10
|
+
import { oklch } from './oklch.js'
|
|
11
|
+
import { prophotoRgb } from './prophoto-rgb.js'
|
|
12
|
+
import { rec2020 } from './rec2020.js'
|
|
13
|
+
import { srgb } from './srgb.js'
|
|
14
|
+
import { srgbLinear } from './srgb-linear.js'
|
|
15
|
+
import type { ColorSpaceId, Space } from './types.js'
|
|
16
|
+
import { xyzD50 } from './xyz-d50.js'
|
|
17
|
+
import { xyzD65 } from './xyz-d65.js'
|
|
18
|
+
|
|
19
|
+
/** Every space. Importing this pulls them all in; import single spaces to avoid that. */
|
|
20
|
+
export const spaces: Record<ColorSpaceId, Space> = {
|
|
21
|
+
srgb,
|
|
22
|
+
'srgb-linear': srgbLinear,
|
|
23
|
+
hsl,
|
|
24
|
+
hwb,
|
|
25
|
+
lab,
|
|
26
|
+
lch,
|
|
27
|
+
oklab,
|
|
28
|
+
oklch,
|
|
29
|
+
'display-p3': displayP3,
|
|
30
|
+
'display-p3-linear': displayP3Linear,
|
|
31
|
+
'a98-rgb': a98Rgb,
|
|
32
|
+
'prophoto-rgb': prophotoRgb,
|
|
33
|
+
rec2020,
|
|
34
|
+
'xyz-d50': xyzD50,
|
|
35
|
+
'xyz-d65': xyzD65
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A color's components in any of the 15 spaces, as `convert` gives them, without the color object. */
|
|
39
|
+
export const componentsIn = componentsOver(Object.values(spaces))
|
|
40
|
+
|
|
41
|
+
/** Converts a color to any of the 15 spaces. */
|
|
42
|
+
export const convert = createConverter(...Object.values(spaces))
|