Skip to content

Commit

Permalink
feat: ✨ adds new methos
Browse files Browse the repository at this point in the history
`.hex()` return hex string\n`.rgb()` return rgb string\n`.rgbObj()` return rgb object
  • Loading branch information
pagyew committed Apr 19, 2024
1 parent 16de61a commit e61f8c3
Show file tree
Hide file tree
Showing 4 changed files with 371 additions and 34 deletions.
106 changes: 106 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
170 changes: 154 additions & 16 deletions src/safolor.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
})
57 changes: 39 additions & 18 deletions src/safolor.ts
Original file line number Diff line number Diff line change
@@ -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
})()
Loading

0 comments on commit e61f8c3

Please sign in to comment.