colorizr
Version:
Manipulate colors like a boss
142 lines (116 loc) • 4.3 kB
text/typescript
import hex2rgb from '~/converters/hex2rgb';
import { extractAlphaFromHex, normalizeAlpha } from '~/modules/alpha';
import { COLOR_KEYS, MESSAGES } from '~/modules/constants';
import { invariant } from '~/modules/invariant';
import { hasValidMatches, isHex, isString } from '~/modules/validators';
import { ColorModelKey, PlainObject } from '~/types';
// Regex components for parsing CSS color strings
const MODEL = '(rgb|hsl|oklab|oklch)a?';
const SEP = '(?:\\s*[,/]\\s*|\\s+)';
// Accepts: numbers, percentages, 'none', and angle units (deg, grad, rad, turn)
const VALUE = '(none|[\\d%.-]+(?:deg|grad|rad|turn)?)';
const colorRegex = new RegExp(
`${MODEL}\\s*\\(\\s*${VALUE}${SEP}${VALUE}${SEP}${VALUE}(?:${SEP}${VALUE})?\\s*\\)`,
'i',
);
export type ExtractColorPartsReturn = {
alpha?: number;
model: ColorModelKey;
} & PlainObject<number>;
/**
* Convert angle value with units to degrees
*/
function parseAngle(value: string): number {
const number_ = parseFloat(value);
let result: number;
if (value.endsWith('grad')) {
result = number_ * 0.9; // 400grad = 360deg
} else if (value.endsWith('rad')) {
result = number_ * (180 / Math.PI); // radians to degrees
} else if (value.endsWith('turn')) {
result = number_ * 360; // 1turn = 360deg
} else {
// 'deg' suffix or unitless - return as-is
result = number_;
}
// Round to 5 decimal places for consistency
return Math.round(result * 100000) / 100000;
}
/**
* Extract the color parts from a CSS color string.
* Hex colors are supported via conversion.
*
* @param input - The CSS color string.
* @returns An object with the color model and component values.
*/
export default function extractColorParts(input: string): ExtractColorPartsReturn {
invariant(isString(input), MESSAGES.inputString);
if (isHex(input)) {
const keys = COLOR_KEYS.rgb;
const { r, g, b } = hex2rgb(input);
const alpha = extractAlphaFromHex(input);
return {
model: 'rgb' as ColorModelKey,
[keys[0]]: r,
[keys[1]]: g,
[keys[2]]: b,
alpha: alpha < 1 ? alpha : undefined,
} as ExtractColorPartsReturn;
}
const matches = colorRegex.exec(input);
invariant(hasValidMatches(matches), MESSAGES.invalidCSS);
let rawAlpha = 1;
if (matches[5]) {
rawAlpha = matches[5] === 'none' ? 0 : parseFloat(matches[5]);
}
const model = matches[1] as ColorModelKey;
const keys = COLOR_KEYS[model];
const alpha = normalizeAlpha(rawAlpha);
// Parse values and convert percentages/angles for color models
const parseValue = (value: string, index: number): number => {
// Handle 'none' keyword - treated as 0 for rendering (CSS Color Level 4)
if (value === 'none') {
return 0;
}
// Handle hue values with angle units (HSL index 0, OkLCH index 2)
const isHue = (model === 'hsl' && index === 0) || (model === 'oklch' && index === 2);
if (isHue) {
return parseAngle(value);
}
const parsedValue = parseFloat(value);
const isPercent = value.includes('%');
if (!isPercent) {
return parsedValue;
}
// Convert percentages based on color model and position
if (model === 'oklch') {
// oklch: L (0-100), C (0-0.4), H (0-360)
if (index === 1) {
// Chroma: 100% = 0.4
return (parsedValue * 0.4) / 100;
}
// oklab: L (0-100), a (-0.4 to 0.4), b (-0.4 to 0.4)
} else if (model === 'oklab' && (index === 1 || index === 2)) {
// a/b: ±100% = ±0.4
return (parsedValue * 0.4) / 100;
}
return parsedValue;
};
const values = [parseValue(matches[2], 0), parseValue(matches[3], 1), parseValue(matches[4], 2)];
// Validate ranges for oklab/oklch after percentage conversion
if (model === 'oklab') {
// a and b must be in range -0.4 to 0.4
invariant(values[1] >= -0.4 && values[1] <= 0.4, MESSAGES.invalidRange);
invariant(values[2] >= -0.4 && values[2] <= 0.4, MESSAGES.invalidRange);
} else if (model === 'oklch') {
// chroma must be in range 0 to 0.4
invariant(values[1] >= 0 && values[1] <= 0.4, MESSAGES.invalidRange);
}
return {
model,
[keys[0]]: values[0],
[keys[1]]: values[1],
[keys[2]]: values[2],
alpha: alpha < 1 ? alpha : undefined,
} as ExtractColorPartsReturn;
}