Skip to content

Commit 4877d8d

Browse files
committed
wip
1 parent 4aaf5fd commit 4877d8d

File tree

6 files changed

+639
-71
lines changed

6 files changed

+639
-71
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* A Map that can generate default values for keys that don't exist.
3+
* Generated default values are added to the map to avoid recomputation.
4+
*/
5+
export class DefaultMap<T = string, V = any> extends Map<T, V> {
6+
constructor(private factory: (key: T, self: DefaultMap<T, V>) => V) {
7+
super()
8+
}
9+
10+
get(key: T): V {
11+
let value = super.get(key)
12+
13+
if (value === undefined) {
14+
value = this.factory(key, this)
15+
this.set(key, value)
16+
}
17+
18+
return value
19+
}
20+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import * as culori from 'culori'
2+
import { expect, test } from 'vitest'
3+
import { colorFromString, colorMix, colorMixFromString } from './color'
4+
5+
test('colorFromString', () => {
6+
expect(colorFromString('red')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0 })
7+
expect(colorFromString('rgb(255 0 0)')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0 })
8+
expect(colorFromString('hsl(0 100% 50%)')).toEqual({ mode: 'hsl', h: 0, s: 1, l: 0.5 })
9+
expect(colorFromString('#f00')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0 })
10+
expect(colorFromString('#f003')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0, alpha: 0.2 })
11+
expect(colorFromString('#ff0000')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0 })
12+
expect(colorFromString('#ff000033')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0, alpha: 0.2 })
13+
14+
expect(colorFromString('color(srgb 1 0 0 )')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0 })
15+
expect(colorFromString('color(srgb-linear 1 0 0 )')).toEqual({ mode: 'lrgb', r: 1, g: 0, b: 0 })
16+
expect(colorFromString('color(display-p3 1 0 0 )')).toEqual({ mode: 'p3', r: 1, g: 0, b: 0 })
17+
expect(colorFromString('color(a98-rgb 1 0 0 )')).toEqual({ mode: 'a98', r: 1, g: 0, b: 0 })
18+
expect(colorFromString('color(prophoto-rgb 1 0 0 )')).toEqual({
19+
mode: 'prophoto',
20+
r: 1,
21+
g: 0,
22+
b: 0,
23+
})
24+
expect(colorFromString('color(rec2020 1 0 0 )')).toEqual({ mode: 'rec2020', r: 1, g: 0, b: 0 })
25+
26+
expect(colorFromString('color(xyz 1 0 0 )')).toEqual({ mode: 'xyz65', x: 1, y: 0, z: 0 })
27+
expect(colorFromString('color(xyz-d65 1 0 0 )')).toEqual({ mode: 'xyz65', x: 1, y: 0, z: 0 })
28+
expect(colorFromString('color(xyz-d50 1 0 0 )')).toEqual({ mode: 'xyz50', x: 1, y: 0, z: 0 })
29+
30+
expect(colorFromString('#ff000033cccc')).toEqual(null)
31+
32+
// none keywords work too
33+
expect(colorFromString('rgb(255 none 0)')).toEqual({ mode: 'rgb', r: 1, b: 0 })
34+
})
35+
36+
test('colorMixFromString', () => {
37+
expect(colorMixFromString('color-mix(in srgb, #f00 50%, transparent)')).toEqual({
38+
mode: 'rgb',
39+
r: 1,
40+
g: 0,
41+
b: 0,
42+
alpha: 0.5,
43+
})
44+
})
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import * as culori from 'culori'
2+
import {
3+
ComponentValue,
4+
isFunctionNode,
5+
isTokenNode,
6+
isWhitespaceNode,
7+
parseComponentValue,
8+
} from '@csstools/css-parser-algorithms'
9+
import {
10+
isTokenComma,
11+
isTokenHash,
12+
isTokenIdent,
13+
isTokenNumber,
14+
isTokenNumeric,
15+
isTokenPercentage,
16+
stringify,
17+
tokenize,
18+
} from '@csstools/css-tokenizer'
19+
20+
const COLOR_FN = /^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color)$/i
21+
22+
export type KeywordColor = 'currentColor'
23+
export type ParsedColor = culori.Color | KeywordColor | null
24+
25+
export function colorFromString(value: string): ParsedColor {
26+
let tokens = tokenize({ css: value })
27+
let cv = parseComponentValue(tokens)
28+
let color = colorFromComponentValue(cv)
29+
30+
return color
31+
}
32+
33+
export function colorFromComponentValue(cv: ComponentValue): ParsedColor {
34+
if (isTokenNode(cv)) {
35+
if (isTokenIdent(cv.value)) {
36+
let str = cv.value[4].value.toLowerCase()
37+
38+
if (str === 'currentcolor') return 'currentColor'
39+
40+
if (str === 'transparent') {
41+
// We omit rgb channels instead of using transparent black because we
42+
// use `culori.interpolate` to mix colors and it handles `transparent`
43+
// differently from the spec (all channels are mixed, not just alpha)
44+
return culori.parse('rgb(none none none / 0.5)')
45+
}
46+
47+
if (str in culori.colorsNamed) {
48+
return culori.parseNamed(str as keyof typeof culori.colorsNamed) ?? null
49+
}
50+
}
51+
52+
//
53+
else if (isTokenHash(cv.value)) {
54+
let hex = cv.value[4].value.toLowerCase()
55+
56+
return culori.parseHex(hex) ?? null
57+
}
58+
59+
return null
60+
}
61+
62+
//
63+
else if (isFunctionNode(cv)) {
64+
let fn = cv.getName()
65+
66+
if (COLOR_FN.test(fn)) {
67+
return culori.parse(stringify(...cv.tokens())) ?? null
68+
}
69+
}
70+
71+
return null
72+
}
73+
74+
export function equivalentColorFromString(value: string): string {
75+
let color = colorFromString(value)
76+
let equivalent = computeEquivalentColor(color)
77+
78+
return equivalent ?? value
79+
}
80+
81+
function computeEquivalentColor(color: ParsedColor): string | null {
82+
if (!color) return null
83+
if (typeof color === 'string') return null
84+
if (!culori.inGamut('rgb')(color)) return null
85+
86+
if (color.alpha === undefined || color.alpha === 1) {
87+
return culori.formatHex(color)
88+
}
89+
90+
return culori.formatHex8(color)
91+
}
92+
93+
export function colorMixFromString(value: string): ParsedColor {
94+
let tokens = tokenize({ css: value })
95+
let cv = parseComponentValue(tokens)
96+
let color = colorMixFromComponentValue(cv)
97+
98+
return color
99+
}
100+
101+
export function colorMixFromComponentValue(cv: ComponentValue): ParsedColor {
102+
if (!isFunctionNode(cv)) return null
103+
if (cv.getName() !== 'color-mix') return null
104+
105+
let state: 'in' | 'colorspace' | 'colors' = 'in'
106+
let colorspace: string = ''
107+
let colors: Array<culori.Color | number> = []
108+
109+
for (let i = 0; i < cv.value.length; ++i) {
110+
let value = cv.value[i]
111+
112+
if (isWhitespaceNode(value)) continue
113+
114+
if (state === 'in') {
115+
if (isTokenNode(value)) {
116+
if (isTokenIdent(value.value)) {
117+
if (value.value[4].value === 'in') {
118+
state = 'colorspace'
119+
}
120+
}
121+
}
122+
} else if (state === 'colorspace') {
123+
if (isTokenNode(value)) {
124+
if (isTokenIdent(value.value)) {
125+
if (colorspace !== '') return null
126+
127+
colorspace = value.value[4].value
128+
} else if (isTokenComma(value.value)) {
129+
state = 'colors'
130+
}
131+
}
132+
} else if (state === 'colors') {
133+
if (isTokenNode(value)) {
134+
if (isTokenPercentage(value.value)) {
135+
colors.push(value.value[4].value / 100)
136+
continue
137+
} else if (isTokenNumber(value.value)) {
138+
colors.push(value.value[4].value)
139+
continue
140+
} else if (isTokenComma(value.value)) {
141+
continue
142+
}
143+
}
144+
145+
let color = colorFromComponentValue(value)
146+
if (!color) return null
147+
if (typeof color === 'string') return null
148+
colors.push(color)
149+
}
150+
}
151+
152+
let t = culori.interpolate(colors, colorspace as any)
153+
154+
return t(0.5) ?? null
155+
}

0 commit comments

Comments
 (0)