From e61f8c349525d0487bc7de78c3fa1e6ec2f2671f Mon Sep 17 00:00:00 2001 From: Vladislav Tsepilov Date: Fri, 19 Apr 2024 14:04:33 +0600 Subject: [PATCH] feat: :sparkles: adds new methos `.hex()` return hex string\n`.rgb()` return rgb string\n`.rgbObj()` return rgb object --- src/helpers.ts | 106 +++++++++++++++++++++++++++ src/safolor.test.ts | 170 +++++++++++++++++++++++++++++++++++++++----- src/safolor.ts | 57 ++++++++++----- src/types.d.ts | 72 +++++++++++++++++++ 4 files changed, 371 insertions(+), 34 deletions(-) create mode 100644 src/helpers.ts create mode 100644 src/types.d.ts diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..c100ebe --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,106 @@ +import type { RGBObject } from './types' + +const validHEXSymbols = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'] + +function hexToInt(symbol: string): number { + return Number.parseInt(symbol, 16) +} + +export function intToHex(number: number): string { + const str = number.toString(16) + return str.padEnd(2, str) +} + +function normalizePct(number: number): number { + return Math.max(0, Math.min(100, number)) +} + +function normalizeRGBValue(number: number): number { + return Math.max(0, Math.min(255, number)) +} + +function pctToInt(pct: string): number { + return normalizePct(Number.parseFloat(pct)) / 100 +} + +function rgbValueToInt(value: string): number { + return normalizeRGBValue(Number.parseFloat(value)) +} + +function parseHEX(color: string): RGBObject { + if (color.toLowerCase().split('').slice(1).some(l => !validHEXSymbols.includes(l))) + throw new SyntaxError('For HEX format expected only HEX symbols') + + const hex = color.slice(1) + let r + let g + let b + let a + + switch (hex.length) { + case 3: + case 4: + r = hexToInt(hex[0] + hex[0]) + g = hexToInt(hex[1] + hex[1]) + b = hexToInt(hex[2] + hex[2]) + hex[3] && (a = hexToInt(hex[3] + hex[3])) + break + case 6: + case 8: + r = hexToInt(hex[0] + hex[1]) + g = hexToInt(hex[2] + hex[3]) + b = hexToInt(hex[4] + hex[5]) + hex[6] && (a = hexToInt(hex[6] + hex[7])) + break + default: + throw new SyntaxError('For HEX format expected 3, 4, 6, or 8 symbols') + } + + return { r, g, b, a } +} + +function parseRGB(color: string): RGBObject { + const rgbLegacyPct = color.match(/^rgba?\(\s*(\d*\.?\d+%)\s*,\s*(\d*\.?\d+%)\s*,\s*(\d*\.?\d+%)\s*(,\s*(\d*\.?\d+%?)\s*)?\)$/) + const rgbLegacyInt = color.match(/^rgba?\(\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*(,\s*(\d*\.?\d+%?)\s*)?\)$/) + const rgbModernPct = color.match(/^rgba?\(\s*(\d*\.?\d+%|none)\s+(\d*\.?\d+%|none)\s+(\d*\.?\d+%|none)\s*(?:\/\s*(\d*\.?\d+%?|none)\s*)?\)$/) + const rgbModernInt = color.match(/^rgba?\(\s*(\d*\.?\d+|none)\s+(\d*\.?\d+|none)\s+(\d*\.?\d+|none)\s*(?:\/\s*(\d*\.?\d+%?|none)\s*)?\)$/) + const rgb = rgbLegacyPct || rgbLegacyInt || rgbModernPct || rgbModernInt + + if (rgb === null) + throw new SyntaxError('Invalid RGB format, expected legacy or modern syntax with 3 or 4 values') + + const [_, R, G, B, A = '1'] = rgb + const r = R === 'none' ? 0 : R.endsWith('%') ? pctToInt(R) * 255 : rgbValueToInt(R) + const g = G === 'none' ? 0 : G.endsWith('%') ? pctToInt(G) * 255 : rgbValueToInt(G) + const b = B === 'none' ? 0 : B.endsWith('%') ? pctToInt(B) * 255 : rgbValueToInt(B) + const a = A === 'none' ? 0 : A.endsWith('%') ? pctToInt(A) : Math.max(0, Math.min(1, Number.parseFloat(A))) + + return { r, g, b, a } +} + +export function parse(color: any): RGBObject { + if (typeof color !== 'string') + throw new TypeError('Expected a string') + + if (!(color.startsWith('#') || color.startsWith('rgb'))) + throw new SyntaxError('Expected a HEX or RGB format') + + let rgbObject: RGBObject + = { r: 0, g: 0, b: 0, a: 1 } + + if (color.startsWith('#')) + rgbObject = parseHEX(color) + else if (color.startsWith('rgb')) + rgbObject = parseRGB(color) + + return rgbObject +} + +export function safe(color: RGBObject): RGBObject { + const { r, g, b } = color + const nR = Math.round(r / 51) * 51 + const nG = Math.round(g / 51) * 51 + const nB = Math.round(b / 51) * 51 + + return { r: nR, g: nG, b: nB } +} diff --git a/src/safolor.test.ts b/src/safolor.test.ts index 3e030cc..d7b0c04 100644 --- a/src/safolor.test.ts +++ b/src/safolor.test.ts @@ -1,23 +1,161 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { safolor } from './safolor' describe('safolor', () => { - it.each([ - ['#000000', '#000000'], - ['#123456', '#003366'], - ['#abcdef', '#99ccff'], - ['#01234567', '#003333'], - ['#fff', '#ffffff'], - ['#1af', '#0099ff'], - ['#cdef', '#ccccff'], - ])('%s => %s', (o_color, s_color) => { - expect(safolor(o_color as any)).toBe(s_color) + describe('errors', () => { + const typeErrorMessage = 'Expected a string' + const formatErrorMessage = 'Expected a HEX or RGB format' + const hexLengthErrorMessage = 'For HEX format expected 3, 4, 6, or 8 symbols' + const hexSymbolsErrorMessage = 'For HEX format expected only HEX symbols' + const rgbErrorMessage = 'Invalid RGB format, expected legacy or modern syntax with 3 or 4 values' + + it.each([ + [true, new TypeError(typeErrorMessage)], + ['some color', new SyntaxError(formatErrorMessage)], + ['#', new SyntaxError(hexLengthErrorMessage)], + ['#1', new SyntaxError(hexLengthErrorMessage)], + ['#12', new SyntaxError(hexLengthErrorMessage)], + ['#12345', new SyntaxError(hexLengthErrorMessage)], + ['#1234567', new SyntaxError(hexLengthErrorMessage)], + ['#123456789', new SyntaxError(hexLengthErrorMessage)], + ['#qwerty', new SyntaxError(hexSymbolsErrorMessage)], + ['rgb(123, 50%, 255)', new SyntaxError(rgbErrorMessage)], + ['rgba(123 50 255 1)', new SyntaxError(rgbErrorMessage)], + ['rgb(123 50 255, 1)', new SyntaxError(rgbErrorMessage)], + ['rgba(123 50% 255 / 1)', new SyntaxError(rgbErrorMessage)], + ['rgb(123, 50, 255 / 1)', new SyntaxError(rgbErrorMessage)], + ['rgba(123, 50, 255 1)', new SyntaxError(rgbErrorMessage)], + ['rgb(123, 50 255, 1)', new SyntaxError(rgbErrorMessage)], + ])('%s => %s', (o_color, s_color) => { + expect(() => safolor(o_color as any)).toThrowError(s_color) + }) + }) + + describe('() direct call', () => { + it('() calls .hex() method', () => { + const color = '#123456' + const spy = vi.spyOn(safolor, 'hex').mockImplementationOnce(() => color) + + safolor(color) + expect(spy).toHaveBeenCalledWith(color) + + spy.mockRestore() + }) + }) + + describe('.hex() method', () => { + describe('from hex', () => { + it.each([ + ['#000000', '#000000'], + ['#123456', '#003366'], + ['#abcdef', '#99ccff'], + ['#01234567', '#003333'], + ['#fff', '#ffffff'], + ['#1af', '#0099ff'], + ['#cdef', '#ccccff'], + ])('%s => %s', (o_color, s_color) => { + expect(safolor.hex(o_color as any)).toBe(s_color) + }) + }) + + describe('from rgb', () => { + it.each([ + ['rgb(0, 0, 0)', '#000000'], + ['rgba(18, 52, 86, 1)', '#003366'], + ['rgb(100, 136, 170, 50%)', '#669999'], + ['rgba(200%, 100%, 100%)', '#ffffff'], + ['rgb(100%, 36%, 70%, 0.404)', '#ff66cc'], + ['rgba(18%, 52%, 86%, 50%)', '#3399cc'], + ['rgb(171 205 239)', '#99ccff'], + ['rgba(255 255 255 / 2)', '#ffffff'], + ['rgb(17 170 255 / 50%)', '#0099ff'], + ['rgba(40.4% 42.1% 83.8%)', '#6666cc'], + ['rgb(57.1% 20.5% 73.9% / .5)', '#9933cc'], + ['rgba(37.1% 60.5% 23.9% / 60%)', '#669933'], + ])('%s => %s', (o_color, s_color) => { + expect(safolor.hex(o_color as any)).toBe(s_color) + }) + }) }) - it.each([ - [true, new TypeError('Expected a string')], - ['rgb(255 255 255)', new SyntaxError('Expected a HEX format')], - ])('%s => %s', (o_color, s_color) => { - expect(() => safolor(o_color as any)).toThrowError(s_color) + describe('.rgb() method', () => { + it('.rgb() calls .rgbObj() method', () => { + const color = '#123456' + const returned = { r: 18, g: 52, b: 86, a: 1 } + const spy = vi.spyOn(safolor, 'rgbObj').mockImplementationOnce(() => returned) + + safolor.rgb(color) + expect(spy).toHaveBeenCalledWith(color) + + spy.mockRestore() + }) + + describe('from hex', () => { + it.each([ + ['#000000', 'rgb(0, 0, 0)'], + ['#123456', 'rgb(0, 51, 102)'], + ['#abcdef', 'rgb(153, 204, 255)'], + ['#01234567', 'rgb(0, 51, 51)'], + ['#fff', 'rgb(255, 255, 255)'], + ['#1af', 'rgb(0, 153, 255)'], + ['#cdef', 'rgb(204, 204, 255)'], + ])('%s => %s', (o_color, s_color) => { + expect(safolor.rgb(o_color as any)).toBe(s_color) + }) + }) + + describe('from rgb', () => { + it.each([ + ['rgb(0, 0, 0)', 'rgb(0, 0, 0)'], + ['rgba(18, 52, 86, 1)', 'rgb(0, 51, 102)'], + ['rgb(100, 136, 170, 50%)', 'rgb(102, 153, 153)'], + ['rgba(200%, 100%, 100%)', 'rgb(255, 255, 255)'], + ['rgb(100%, 36%, 70%, 0.404)', 'rgb(255, 102, 204)'], + ['rgba(18%, 52%, 86%, 50%)', 'rgb(51, 153, 204)'], + ['rgb(171 205 239)', 'rgb(153, 204, 255)'], + ['rgba(255 255 255 / 2)', 'rgb(255, 255, 255)'], + ['rgb(17 170 255 / 50%)', 'rgb(0, 153, 255)'], + ['rgba(40.4% 42.1% 83.8%)', 'rgb(102, 102, 204)'], + ['rgb(57.1% 20.5% 73.9% / .5)', 'rgb(153, 51, 204)'], + ['rgba(37.1% 60.5% 23.9% / 60%)', 'rgb(102, 153, 51)'], + ])('%s => %s', (o_color, s_color) => { + expect(safolor.rgb(o_color as any)).toBe(s_color) + }) + }) + }) + + describe('.rgbObj() method', () => { + describe('from hex', () => { + it.each([ + ['#000000', { r: 0, g: 0, b: 0 }], + ['#123456', { r: 0, g: 51, b: 102 }], + ['#abcdef', { r: 153, g: 204, b: 255 }], + ['#01234567', { r: 0, g: 51, b: 51 }], + ['#fff', { r: 255, g: 255, b: 255 }], + ['#1af', { r: 0, g: 153, b: 255 }], + ['#cdef', { r: 204, g: 204, b: 255 }], + ])('%s => %s', (o_color, s_color) => { + expect(safolor.rgbObj(o_color as any)).toStrictEqual(s_color) + }) + }) + + describe('from rgb', () => { + it.each([ + ['rgb(0, 0, 0)', { r: 0, g: 0, b: 0 }], + ['rgba(18, 52, 86, 1)', { r: 0, g: 51, b: 102 }], + ['rgb(100, 136, 170, 50%)', { r: 102, g: 153, b: 153 }], + ['rgba(200%, 100%, 100%)', { r: 255, g: 255, b: 255 }], + ['rgb(100%, 36%, 70%, 0.404)', { r: 255, g: 102, b: 204 }], + ['rgba(18%, 52%, 86%, 50%)', { r: 51, g: 153, b: 204 }], + ['rgb(171 205 239)', { r: 153, g: 204, b: 255 }], + ['rgba(255 255 255 / 2)', { r: 255, g: 255, b: 255 }], + ['rgb(17 170 255 / 50%)', { r: 0, g: 153, b: 255 }], + ['rgba(40.4% 42.1% 83.8%)', { r: 102, g: 102, b: 204 }], + ['rgb(57.1% 20.5% 73.9% / .5)', { r: 153, g: 51, b: 204 }], + ['rgba(37.1% 60.5% 23.9% / 60%)', { r: 102, g: 153, b: 51 }], + ])('%s => %s', (o_color, s_color) => { + expect(safolor.rgbObj(o_color as any)).toStrictEqual(s_color) + }) + }) }) }) diff --git a/src/safolor.ts b/src/safolor.ts index 155ca26..e949d83 100644 --- a/src/safolor.ts +++ b/src/safolor.ts @@ -1,25 +1,46 @@ -export type HEX = `#${string}` +import { intToHex, parse, safe } from './helpers' +import type { HEX, RGB, RGBA, RGBObject, Safolor } from './types' -export function safolor(color: HEX): HEX { - if (typeof color !== 'string') - throw new TypeError('Expected a string') +export const safolor: Safolor = (() => { + let color: RGBObject + let safeColor: RGBObject - const hex = color.match( - /^#(?:([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})?|([\da-f]{1})([\da-f]{1})([\da-f]{1})([\da-f]{1})?)$/i, - ) + function exec(original: string) { + color = parse(original) + safeColor = safe(color) + } - if (hex === null) - throw new SyntaxError('Expected a HEX format') + function hex(color: HEX): HEX + function hex(color: RGB | RGBA): HEX + function hex(color: string): HEX { + exec(color) + const { r, g, b } = safeColor + return `#${intToHex(r)}${intToHex(g)}${intToHex(b)}` + } - const [_, rr, gg, bb, __, r, g, b, ___] = hex + function rgb(color: HEX): RGB + function rgb(color: RGB | RGBA): RGB + function rgb(color: string): RGB { + const { r, g, b } = call.rgbObj(color as any) + return `rgb(${r}, ${g}, ${b})` + } - const R = rr || r.padEnd(2, r) - const G = gg || g.padEnd(2, g) - const B = bb || b.padEnd(2, b) + function rgbObj(color: HEX): RGBObject + function rgbObj(color: RGB | RGBA): RGBObject + function rgbObj(color: string): RGBObject { + exec(color) + return safeColor + } - const nR = (Math.round(Number.parseInt(R, 16) / 51) * 51).toString(16) - const nG = (Math.round(Number.parseInt(G, 16) / 51) * 51).toString(16) - const nB = (Math.round(Number.parseInt(B, 16) / 51) * 51).toString(16) + function call(color: HEX): HEX + function call(color: RGB | RGBA): HEX + function call(color: string): HEX { + return call.hex(color as any) + } - return `#${nR.padEnd(2, nR)}${nG.padEnd(2, nG)}${nB.padEnd(2, nB)}` -} + call.hex = hex + call.rgb = rgb + call.rgbObj = rgbObj + + return call +})() diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..7b47d6e --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,72 @@ +export type Num = number + +export type Pct = `${number}%` + +export type Alpha = + Num | + Pct + +export type Color = + ColorBase + +export type ColorBase = + HEX | + ColorFunction + +export type ColorFunction = + RGB | + RGBA + +export type HEX = `#${string}` + +export type RGB = + LegacyRGB | + ModernRGB + +export type RGBA = + LegacyRGBA | + ModernRGBA + +export type LegacyRGB = + `rgb(${Num}, ${Num}, ${Num})` | + `rgb(${Num}, ${Num}, ${Num}, ${Alpha})` | + `rgb(${Pct}, ${Pct}, ${Pct})` | + `rgb(${Pct}, ${Pct}, ${Pct}, ${Alpha})` + +export type ModernRGB = + `rgb(${Num | Pct} ${Num | Pct} ${Num | Pct})` | + `rgb(${Num | Pct} ${Num | Pct} ${Num | Pct} / ${Alpha})` + +export type LegacyRGBA = + `rgba(${Num}, ${Num}, ${Num})` | + `rgba(${Num}, ${Num}, ${Num}, ${Alpha})` | + `rgba(${Pct}, ${Pct}, ${Pct})` | + `rgba(${Pct}, ${Pct}, ${Pct}, ${Alpha})` + +export type ModernRGBA = + `rgba(${Num | Pct} ${Num | Pct} ${Num | Pct})` | + `rgba(${Num | Pct} ${Num | Pct} ${Num | Pct} / ${Alpha})` + +export interface RGBObject { + r: Num + g: Num + b: Num + a?: Num +} + +export interface Safolor { + (color: HEX): HEX + (color: RGB | RGBA): HEX + hex: { + (color: HEX): HEX + (color: RGB | RGBA): HEX + } + rgb: { + (color: HEX): RGB + (color: RGB | RGBA): RGB + } + rgbObj: { + (color: HEX): RGBObject + (color: RGB | RGBA): RGBObject + } +}