Skip to content

Commit

Permalink
Start implementation of own color engine
Browse files Browse the repository at this point in the history
  • Loading branch information
arnelenero committed Mar 26, 2022
1 parent 4c6d9a0 commit 0bac8a8
Show file tree
Hide file tree
Showing 6 changed files with 364 additions and 0 deletions.
83 changes: 83 additions & 0 deletions src/color/types/_tests_/hslString.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { isHslString, matchHslString } from '../hslString'

describe('isHslString', () => {
const valid = [
'hsl(240, 100%, 50%)', // comma separated
'hsl(240, 100%, 50%, 0.1)', // comma separated with opacity
'hsl(240, 100%, 50%, 10%)', // comma separated with % opacity
'hsl(240,100%,50%,0.1)', // comma separated without spaces
'hsl(180deg, 100%, 50%, 0.1)', // hue with 'deg'
'hsl(3.14rad, 100%, 50%, 0.1)', // hue with 'rad'
'hsl(200grad, 100%, 50%, 0.1)', // hue with 'grad'
'hsl(0.5turn, 100%, 50%, 0.1)', // hue with 'turn'
'hsl(-240, -100%, -50%, -0.1)', // negative values
'hsl(+240, +100%, +50%, +0.1)', // explicit positive sign
'hsl(240.5, 99.99%, 49.999%, 0.9999)', // non-integer values
'hsl(.9, .99%, .999%, .9999)', // fraction w/o leading zero
'hsl(0240, 0100%, 0050%, 01)', // leading zeros
'hsl(240.0, 100.00%, 50.000%, 1.0000)', // trailing decimal zeros
'hsl(2400, 1000%, 1000%, 10)', // out of range values
'hsl(-2400.01deg, -1000.5%, -1000.05%, -100)', // combination of above
'hsl(2.40e+2, 1.00e+2%, 5.00e+1%, 1E-3)', // scientific notation
'hsl(240 100% 50%)', // space separated (CSS Color Level 4)
'hsl(240 100% 50% / 0.1)', // space separated with opacity
'hsla(240, 100%, 50%)', // hsla() alias
'hsla(240, 100%, 50%, 0.1)', // hsla() with opacity
'HSL(240Deg, 100%, 50%)', // case insensitive
]
valid.forEach(str => {
it(`returns true for valid hsl string: ${str}`, () => {
expect(isHslString(str)).toBe(true)
})
})

const invalid = [
'rgb(127, 255, 255)', // different color model
'#88FFFF', // hex string
'blue', // color name
'hsl(240, 1, 0.5, 0.1)', // missing % sign
' hsl(240, 100%, 50%, 0.1) ', // untrimmed spaces
'hsl(240 100% 50% 0.1)', // missing slash in space separated alpha
'rainbow', // invalid color string
]
invalid.forEach(str => {
it(`returns false for invalid hsl string: ${str}`, () => {
expect(isHslString(str)).toBe(false)
})
})
})

describe('matchHslString', () => {
it('returns HSL and alpha (opacity) values in an array', () => {
const str = 'hsl(240, 100%, 50%, 0.1)'
expect(matchHslString(str)).toEqual(['240', '100', '50', '0.1'])
})

it('captures percentage opacity', () => {
const str = 'hsl(240, 100%, 50%, 10%)'
expect(matchHslString(str)).toEqual(['240', '100', '50', '10%'])
})

const hueWithUnit = ['180deg', '3.14rad', '200grad', '0.5turn']
hueWithUnit.forEach(hue => {
it(`captures hue angle with unit: ${hue}`, () => {
const str = `hsl(${hue} 100% 50% / 0.1)`
expect(matchHslString(str)).toEqual([hue, '100', '50', '0.1'])
})
})

it('captures negative values', () => {
const str = 'hsl(-240, -100%, -50%, -0.1)'
expect(matchHslString(str)).toEqual(['-240', '-100', '-50', '-0.1'])
})

it('returns alpha value as undefined if not specified in string', () => {
const str = 'hsl(240, 100%, 50%)'
expect(matchHslString(str)).toEqual(['240', '100', '50', undefined])
})

it('returns null if string is not a valid hsl string', () => {
const str = 'rgb(127, 255, 64)'
expect(matchHslString(str)).toBeNull()
})
})
72 changes: 72 additions & 0 deletions src/color/types/_tests_/rgbString.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { isRgbString, matchRgbString } from '../rgbString'

describe('isRgbString', () => {
const valid = [
'rgb(127, 255, 64)', // comma separated
'rgb(127, 255, 64, 0.1)', // comma separated with opacity
'rgb(127, 255, 64, 10%)', // % opacity
'rgb(50%, 100%, 25%, 0.1)', // % values
'rgb(240,255,64,0.1)', // comma separated without spaces
'rgb(320, 255.5, -64, 10)', // out of range values
'rgb(127 255 64)', // space separated (CSS Color Level 4)
'rgb(127 255 64 / 0.1)', // space separated with opacity
'rgb(127 255 64 / 10%)', // space separated % opacity
'rgb(50% 100% 25% / 0.1)', // space separated % values
'rgb(127 255 64/0.1)', // no spaces around slash
'rgba(127, 255, 64, 0.1)', // rgba() alias
'rgba(127, 255, 64)', // rgba() without opacity
'rgba(127 255 64 / 0.1)', // rgba() space separated
'RGB(127, 255, 64)', // case insensitive
]
valid.forEach(str => {
it(`returns true for valid rgb string: ${str}`, () => {
expect(isRgbString(str)).toBe(true)
})
})

const invalid = [
'hsl(240, 100%, 50%)', // different color model
'#88FFFF', // hex string
'blue', // color name
' rgb(127, 255, 64, 0.1) ', // untrimmed spaces
'rgb(127 255 64 0.1)', // missing slash in space separated alpha
'rainbow', // invalid color string
]
invalid.forEach(str => {
it(`returns false for invalid rgb string: ${str}`, () => {
expect(isRgbString(str)).toBe(false)
})
})
})

describe('matchRgbString', () => {
it('returns RGB and alpha (opacity) values in an array', () => {
const str = 'rgb(127, 255, 64, 0.1)'
expect(matchRgbString(str)).toEqual(['127', '255', '64', '0.1'])
})

it('captures percentage opacity', () => {
const str = 'rgb(127, 255, 64, 100%)'
expect(matchRgbString(str)).toEqual(['127', '255', '64', '100%'])
})

it('captures percentage values', () => {
const str = 'rgb(50%, 100%, 25%, 0.1)'
expect(matchRgbString(str)).toEqual(['50%', '100%', '25%', '0.1'])
})

it('captures negative values', () => {
const str = 'rgb(-127, -255, -64, -0.1)'
expect(matchRgbString(str)).toEqual(['-127', '-255', '-64', '-0.1'])
})

it('returns alpha value as undefined if not specified in string', () => {
const str = 'rgb(127, 255, 64)'
expect(matchRgbString(str)).toEqual(['127', '255', '64', undefined])
})

it('returns null if string is not a valid rgb string', () => {
const str = 'hsl(240 100% 50%)'
expect(matchRgbString(str)).toBeNull()
})
})
94 changes: 94 additions & 0 deletions src/color/types/_tests_/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {
alphaSeparatorMatcher,
cssNumberMatcher,
exact,
separatorMatcher,
} from '../utils'

describe('exact', () => {
it('creates a new regex that is restricted to exact matches only', () => {
const regex = /[0-9]+/
const exactRegex = exact(regex)
expect(regex.test(' 123 ')).toBe(true)
expect(exactRegex.test(' 123 ')).toBe(false)
expect(exactRegex.test('123')).toBe(true)
})

it('retains the original flags (if any)', () => {
const regex = /[a-z]+/i
expect(exact(regex).test('Abc')).toBe(true)
})
})

describe('cssNumberMatcher', () => {
const matcher = exact(cssNumberMatcher)

const valid = [
'255', // integer
'4.5', // non-integer
'0.1', // fraction
'.1', // fraction with no leading zero
'007', // leading zeros
'1.000', // trailing decimal zeros
'-255', // negative
'+255', // explicit positive sign
'1.28e+2', // scientific notation
'1.28E-2', // uppercase scientific notation
'-01.2800e+02', // combination of above
]
valid.forEach(str => {
it(`tests true for exact match with valid CSS number string: ${str}`, () => {
expect(matcher.test(str)).toBe(true)
})
})

const invalid = [
'1,000', // comma
'1.', // missing digit following decimal point
'1.0.0', // excess decimal points
'1_000', // numeric separator
'FF', // hexadecimal
'foo', // totally not a number
]
invalid.forEach(str => {
it(`tests false for exact match with invalid CSS number string: ${str}`, () => {
expect(matcher.test(str)).toBe(false)
})
})
})

describe('separatorMatcher', () => {
const matcher = exact(separatorMatcher)

const valid = [',', ' ,', ', ', ' , ', ' ']
valid.forEach(str => {
it(`tests true for exact match with valid separator: '${str}'`, () => {
expect(matcher.test(str)).toBe(true)
})
})

const invalid = [',,', ', ,', ', , ', '/']
invalid.forEach(str => {
it(`tests false for exact match with invalid separator: '${str}'`, () => {
expect(matcher.test(str)).toBe(false)
})
})
})

describe('alphaSeparatorMatcher', () => {
const matcher = exact(alphaSeparatorMatcher)

const valid = [',', ', ', ' ,', ' , ', '/', '/ ', ' /', ' / ']
valid.forEach(str => {
it(`tests true for exact match with valid separator: '${str}'`, () => {
expect(matcher.test(str)).toBe(true)
})
})

const invalid = [',,', ', ,', ', , ', ',/', ', /', ',/ ', ', / ']
invalid.forEach(str => {
it(`tests false for exact match with invalid separator: '${str}'`, () => {
expect(matcher.test(str)).toBe(false)
})
})
})
49 changes: 49 additions & 0 deletions src/color/types/hslString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
alphaSeparatorMatcher,
cssNumberMatcher,
exact,
separatorMatcher,
} from './utils'

export type HslString = `hsl(${string})` | `hsla(${string})`

const num = cssNumberMatcher.source
const sep = separatorMatcher.source
const asep = alphaSeparatorMatcher.source

/**
* Regular expression for HSL color string
*
* The pattern is less strict than actual CSS, mainly for
* performance reasons. Notably, it does NOT impose
* consistent separator (comma vs. space).
*/
export const hslMatcher = new RegExp(
`hsla?\\(\\s*(${num}(?:deg|rad|grad|turn)?)${sep}(${num})%${sep}(${num})%(?:${asep}(${num}%?))?\\s*\\)`,
'i',
)

/**
* Checks if a given string is a valid HSL color string
*
* @param colorString
* @returns true/false (type predicate for `HslString` in TS)
*/
export function isHslString(colorString: string): colorString is HslString {
return exact(hslMatcher).test(colorString)
}

/**
* Attempts to match the given color string with the HSL
* string pattern, and extracts the color components
*
* Since the standard unit for S and L values is percent,
* the % sign is not included in the captured values.
*
* @param colorString
* @returns an array containing the matched HSL values, or `null`
*/
export function matchHslString(colorString: string): string[] | null {
const match = exact(hslMatcher).exec(colorString)
return match?.slice(1) ?? null
}
47 changes: 47 additions & 0 deletions src/color/types/rgbString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
alphaSeparatorMatcher,
cssNumberMatcher,
exact,
separatorMatcher,
} from './utils'

export type RgbString = `rgb(${string})` | `rgba(${string})`

const num = cssNumberMatcher.source
const sep = separatorMatcher.source
const asep = alphaSeparatorMatcher.source

/**
* Regular expression for RGB color string
*
* The pattern is less strict than actual CSS, mainly for
* performance reasons. Notably, it does NOT impose:
* - consistent separator (comma vs. space)
* - consistent unit of color components (number value vs. percentage)
*/
export const rgbMatcher = new RegExp(
`rgba?\\(\\s*(${num}%?)${sep}(${num}%?)${sep}(${num}%?)(?:${asep}(${num}%?))?\\s*\\)`,
'i',
)

/**
* Checks if a given string is a valid RGB color string
*
* @param colorString
* @returns true/false (type predicate for `RgbString` in TS)
*/
export function isRgbString(colorString: string): colorString is RgbString {
return exact(rgbMatcher).test(colorString)
}

/**
* Attempts to match the given color string with the RGB
* string pattern, and extracts the color components
*
* @param colorString
* @returns an array containing the matched RGB values, or `null`
*/
export function matchRgbString(colorString: string): string[] | null {
const match = exact(rgbMatcher).exec(colorString)
return match?.slice(1) ?? null
}
19 changes: 19 additions & 0 deletions src/color/types/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Creates a modified version of the regular expression
* that is restricted to exact matches only
*
* @param regex
* @returns modified regex including original flags (if any)
*/
export function exact(regex: RegExp): RegExp {
return new RegExp(`^${regex.source}$`, regex.flags)
}

/** Regular expression for valid CSS number */
export const cssNumberMatcher = /[+-]?(?=\.\d|\d)\d*(?:\.\d+)?(?:[eE][+-]?\d+)?/

/** Regular expression for color component separator */
export const separatorMatcher = /(?=[,\s])\s*(?:,\s*)?/

/** Regular expression for alpha separator */
export const alphaSeparatorMatcher = /\s*[,\/]\s*/

0 comments on commit 0bac8a8

Please sign in to comment.