Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions packages/core/color/color-common.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { IColor } from './color-types';
import * as types from '../utils/types';
import * as knownColors from './known-colors';
import { HEX_REGEX, argbFromColorMix, argbFromHslOrHsla, argbFromHsvOrHsva, argbFromRgbOrRgba, hslToRgb, hsvToRgb, isCssColorMixExpression, isHslOrHsla, isHsvOrHsva, isRgbOrRgba, rgbToHsl, rgbToHsv, argbFromString } from './color-utils';
import { HEX_REGEX, argbFromColorMix, argbFromCssColor, argbFromHslOrHsla, argbFromHsvOrHsva, argbFromRgbOrRgba, hslToRgb, hsvToRgb, isCssColorFunction, isCssColorMixExpression, isHslOrHsla, isHsvOrHsva, isModernRgbOrHsl, isRgbOrRgba, rgbToHsl, rgbToHsv, argbFromString } from './color-utils';

export class ColorBase implements IColor {
private _argb: number;
Expand All @@ -15,9 +15,9 @@ export class ColorBase implements IColor {
const arg = args[0];
if (types.isString(arg)) {
const lowered = arg.toLowerCase();
if (isRgbOrRgba(lowered)) {
if (isRgbOrRgba(lowered) && !isModernRgbOrHsl(lowered)) {
this._argb = argbFromRgbOrRgba(lowered);
} else if (isHslOrHsla(lowered)) {
} else if (isHslOrHsla(lowered) && !isModernRgbOrHsl(lowered)) {
this._argb = argbFromHslOrHsla(lowered);
} else if (isHsvOrHsva(lowered)) {
this._argb = argbFromHsvOrHsva(lowered);
Expand All @@ -37,6 +37,12 @@ export class ColorBase implements IColor {
// strange that it would not be. And if it is not a thrown error seems best
// The parameter is a "#RRGGBBAA" formatted string
this._argb = this._argbFromString(arg);
} else if (isCssColorFunction(lowered) || isRgbOrRgba(lowered) || isHslOrHsla(lowered)) {
const argb = argbFromCssColor(lowered);
if (argb === null) {
throw new Error('Invalid color: ' + arg);
}
this._argb = argb >>> 0;
} else {
throw new Error('Invalid color: ' + arg);
}
Expand Down Expand Up @@ -147,7 +153,7 @@ export class ColorBase implements IColor {
return true;
}

return HEX_REGEX.test(value) || isRgbOrRgba(lowered) || isHslOrHsla(lowered);
return HEX_REGEX.test(value) || isRgbOrRgba(lowered) || isHslOrHsla(lowered) || isCssColorFunction(lowered) || isCssColorMixExpression(lowered);
}
public static fromHSL(a, h, s, l) {
return new ColorBase(a, h, s, l, 'hsl');
Expand Down
14 changes: 14 additions & 0 deletions packages/core/color/color-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,24 @@ import { tokenize } from '@csstools/css-tokenizer';

export const HEX_REGEX = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)|(^#[0-9A-F]{8}$)|(^#[0-9A-F]{4}$)/i;

const CSS_COLOR_FUNCTION_REGEX = /^(?:oklch|oklab|lab|lch|hwb|color)\(/;

export function isCssColorMixExpression(value: string) {
return value.includes('color-mix(');
}

export function isCssColorFunction(value: string) {
return CSS_COLOR_FUNCTION_REGEX.test(value);
}

export function isModernRgbOrHsl(value: string) {
return value.includes('/') || value.includes('none') || value.includes('from ') || (value.startsWith('rgb') && value.includes('%'));
}

export function argbFromCssColor(value: string): number | null {
return argbFromColorMix(value);
}

export function argbFromColorMix(value: string): number | null {
const astComponentValue = parseComponentValue(tokenize({ css: value }));
const colorData = color(astComponentValue);
Expand Down
49 changes: 49 additions & 0 deletions packages/core/color/color.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';

import { Color } from '.';

const rgba = (value: string) => new Color(value).toRgbString();

describe('Color', () => {
it('parses CSS Color 4 color spaces', () => {
expect(rgba('oklch(0.6 0.2 250)')).toBe('rgba(0, 131, 224, 1.00)');
expect(rgba('oklch(70% 0.1 30deg / .5)')).toBe('rgba(213, 134, 121, 0.50)');
expect(rgba('OKLCH(0.6 0.2 250)')).toBe('rgba(0, 131, 224, 1.00)');
expect(rgba('oklab(0.628 0.225 0.126)')).toBe('rgba(255, 0, 0, 1.00)');
expect(rgba('lab(50 40 59.5)')).toBe('rgba(191, 87, 0, 1.00)');
expect(rgba('lch(50 72 56)')).toBe('rgba(191, 87, 0, 1.00)');
expect(rgba('hwb(0 0% 0%)')).toBe('rgba(255, 0, 0, 1.00)');
expect(rgba('color(srgb 1 0 0)')).toBe('rgba(255, 0, 0, 1.00)');
});

it('parses relative colors', () => {
expect(rgba('oklch(from #ff0000 calc(l * 0.9) c h)')).toBe('rgba(222, 0, 0, 1.00)');
expect(rgba('rgb(from red r g b / 50%)')).toBe('rgba(255, 0, 0, 0.50)');
});

it('parses the modern rgb() and hsl() syntax', () => {
expect(rgba('rgb(0 0 0 / 15%)')).toBe('rgba(0, 0, 0, 0.15)');
expect(rgba('rgb(100% 0% 0%)')).toBe('rgba(255, 0, 0, 1.00)');
expect(rgba('rgb(255 0 0 / none)')).toBe('rgba(255, 0, 0, 0.00)');
expect(rgba('hsl(0 100% 50% / 50%)')).toBe('rgba(255, 0, 0, 0.50)');
});

it('keeps the legacy syntax on the fast path', () => {
expect(rgba('rgb(255, 8, 128)')).toBe('rgba(255, 8, 128, 1.00)');
expect(rgba('rgba(255, 8, 128, 0.5)')).toBe('rgba(255, 8, 128, 0.50)');
expect(rgba('rgb(255 8 128)')).toBe('rgba(255, 8, 128, 1.00)');
expect(rgba('hsl(330.9, 100%, 51.6%)')).toBe('rgba(255, 8, 128, 1.00)');
});

it('rejects what is not a color', () => {
expect(() => new Color('oklch(nope)')).toThrow('Invalid color');
expect(() => new Color('colorful(1 2 3)')).toThrow('Invalid color');
expect(() => new Color('light-dark(red, blue)')).toThrow('Invalid color');
});

it('reports the new functions as valid', () => {
expect(Color.isValid('oklch(0.6 0.2 250)')).toBe(true);
expect(Color.isValid('color-mix(in srgb, red, blue)')).toBe(true);
expect(Color.isValid('nope(1)')).toBe(false);
});
});
4 changes: 4 additions & 0 deletions packages/core/css/parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ describe('css', () => {
test(parseColor, ' rgba(255, 8, 128, 0.5) ', { start: 0, end: 24, value: new Color(0x80ff0880) });
test(parseColor, ' hsl(330.9, 100%, 51.6%) ', { start: 0, end: 25, value: new Color(0xffff0880) });
test(parseColor, ' hsla(330.9, 100%, 51.6%, 0.5) ', { start: 0, end: 31, value: new Color(0x80ff0880) });
test(parseColor, ' rgb(0 0 0 / 15%) ', { start: 0, end: 18, value: new Color(0x26000000) });
test(parseColor, ' oklch(0.6 0.2 250) ', { start: 0, end: 20, value: new Color(0xff0083e0) });
test(parseColor, ' color-mix(in srgb, rgb(255 0 0), blue) ', { start: 0, end: 40, value: new Color('color-mix(in srgb, rgb(255 0 0), blue)') });
test(parseColor, ' oklch(0.6 0.2 250 ', 0, null);
test(parseColor, '#FF0000 url(lucky.gif)', 8, null);
test(parseColor, 'url(lucky.gif) #FF0000 repeat', 15, { start: 15, end: 23, value: new Color(0xffff0000) });
});
Expand Down
19 changes: 16 additions & 3 deletions packages/core/css/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,29 @@ export function parseHexColor(text: string, start = 0): Parsed<Color> {
return { start, end, value: new Color('#' + result[1]) };
}

const cssColorRegEx = /\s*((?:rgb|rgba|hsl|hsla|hsv|hsva)\([^\(\)]*\))/gy;
const cssColorRegEx = /\s*(rgba?|hsla?|hsva?|oklch|oklab|lab|lch|hwb|color-mix|color)\(/gy;
export function parseCssColor(text: string, start = 0): Parsed<Color> {
cssColorRegEx.lastIndex = start;
const result = cssColorRegEx.exec(text);
if (!result) {
return null;
}
const end = cssColorRegEx.lastIndex;

let end = cssColorRegEx.lastIndex;
for (let depth = 1; depth > 0; end++) {
if (end >= text.length) {
return null;
}
const char = text[end];
if (char === '(') {
depth++;
} else if (char === ')') {
depth--;
}
}

try {
return { start, end, value: new Color(result[1]) };
return { start, end, value: new Color(text.substring(cssColorRegEx.lastIndex - result[1].length - 1, end)) };
} catch {
return null;
}
Expand Down
Loading