[APP-2160] Validate hexToRgb input and guard against throws
Reject malformed hex strings instead of producing garbled output, drop the alpha channel from 8-digit hex, and wrap the public entrypoints in try/catch so callers always get a usable color string back.
This commit is contained in:
@@ -0,0 +1,94 @@
|
|||||||
|
import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration'
|
||||||
|
|
||||||
|
describe('hexToRgb', () => {
|
||||||
|
it('parses 6-digit hex', () => {
|
||||||
|
expect(hexToRgb('#abcdef')).toEqual({r: 0xab, g: 0xcd, b: 0xef})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses 6-digit hex without leading #', () => {
|
||||||
|
expect(hexToRgb('abcdef')).toEqual({r: 0xab, g: 0xcd, b: 0xef})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses 3-digit shorthand hex', () => {
|
||||||
|
expect(hexToRgb('#abc')).toEqual({r: 0xaa, g: 0xbb, b: 0xcc})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses 8-digit hex by dropping the alpha channel', () => {
|
||||||
|
expect(hexToRgb('#aabbccdd')).toEqual({r: 0xaa, g: 0xbb, b: 0xcc})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles uppercase digits', () => {
|
||||||
|
expect(hexToRgb('#ABCDEF')).toEqual({r: 0xab, g: 0xcd, b: 0xef})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for 4-digit shorthand', () => {
|
||||||
|
expect(hexToRgb('#abcd')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for non-hex characters', () => {
|
||||||
|
expect(hexToRgb('#zzzzzz')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for non-hex words', () => {
|
||||||
|
expect(hexToRgb('blue')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for the empty string', () => {
|
||||||
|
expect(hexToRgb('')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for unexpected lengths', () => {
|
||||||
|
expect(hexToRgb('#ab')).toBeNull()
|
||||||
|
expect(hexToRgb('#abcde')).toBeNull()
|
||||||
|
expect(hexToRgb('#abcdefg')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rgbToHex', () => {
|
||||||
|
it('formats integer channels', () => {
|
||||||
|
expect(rgbToHex(0xab, 0xcd, 0xef)).toBe('#abcdef')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rounds floating-point channels', () => {
|
||||||
|
expect(rgbToHex(170.4, 187.6, 204.5)).toBe('#aabccd')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps below zero to 00', () => {
|
||||||
|
expect(rgbToHex(-10, 0, 0)).toBe('#000000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps above 255 to ff', () => {
|
||||||
|
expect(rgbToHex(300, 255, 255)).toBe('#ffffff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zero-pads short hex output', () => {
|
||||||
|
expect(rgbToHex(0, 0, 0)).toBe('#000000')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lighten / darken', () => {
|
||||||
|
it('lighten increases lightness', () => {
|
||||||
|
expect(lighten('#808080', 10)).toBe('#9a9a9a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('darken decreases lightness', () => {
|
||||||
|
expect(darken('#808080', 10)).toBe('#676767')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lighten clamps at white', () => {
|
||||||
|
expect(lighten('#ffffff', 50)).toBe('#ffffff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('darken clamps at black', () => {
|
||||||
|
expect(darken('#000000', 50)).toBe('#000000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lighten by zero is a no-op', () => {
|
||||||
|
expect(lighten('#abcdef', 0)).toBe('#abcdef')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the input unchanged for invalid hex', () => {
|
||||||
|
expect(lighten('not-a-color', 10)).toBe('not-a-color')
|
||||||
|
expect(darken('#zzz', 10)).toBe('#zzz')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,6 +9,7 @@ export const transparentifyColor = utils.alpha
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Lighten a hex color by `amount` percentage points of HSL lightness (0-100).
|
* Lighten a hex color by `amount` percentage points of HSL lightness (0-100).
|
||||||
|
* If `hex` is not a valid hex color, returns the input unchanged.
|
||||||
*/
|
*/
|
||||||
export function lighten(hex: string, amount: number): string {
|
export function lighten(hex: string, amount: number): string {
|
||||||
return adjustLightness(hex, amount)
|
return adjustLightness(hex, amount)
|
||||||
@@ -16,33 +17,51 @@ export function lighten(hex: string, amount: number): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Darken a hex color by `amount` percentage points of HSL lightness (0-100).
|
* Darken a hex color by `amount` percentage points of HSL lightness (0-100).
|
||||||
|
* If `hex` is not a valid hex color, returns the input unchanged.
|
||||||
*/
|
*/
|
||||||
export function darken(hex: string, amount: number): string {
|
export function darken(hex: string, amount: number): string {
|
||||||
return adjustLightness(hex, -amount)
|
return adjustLightness(hex, -amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustLightness(hex: string, delta: number): string {
|
function adjustLightness(hex: string, delta: number): string {
|
||||||
const {r, g, b} = hexToRgb(hex)
|
try {
|
||||||
const {h, s, l} = rgbToHsl(r, g, b)
|
const rgb = hexToRgb(hex)
|
||||||
const next = clamp(l + delta, 0, 100)
|
if (!rgb) return hex
|
||||||
const out = hslToRgb(h, s, next)
|
const {h, s, l} = rgbToHsl(rgb.r, rgb.g, rgb.b)
|
||||||
return rgbToHex(out.r, out.g, out.b)
|
const next = clamp(l + delta, 0, 100)
|
||||||
|
const out = hslToRgb(h, s, next)
|
||||||
|
return rgbToHex(out.r, out.g, out.b)
|
||||||
|
} catch {
|
||||||
|
return hex
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexToRgb(hex: string): {r: number; g: number; b: number} {
|
const HEX_PATTERN = /^([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
|
||||||
const h = hex.replace('#', '')
|
|
||||||
const expanded =
|
export function hexToRgb(
|
||||||
h.length === 3
|
hex: string,
|
||||||
? h
|
): {r: number; g: number; b: number} | null {
|
||||||
.split('')
|
try {
|
||||||
.map(c => c + c)
|
if (typeof hex !== 'string') return null
|
||||||
.join('')
|
const h = hex.startsWith('#') ? hex.slice(1) : hex
|
||||||
: h
|
if (!HEX_PATTERN.test(h)) return null
|
||||||
const num = parseInt(expanded, 16)
|
// 8-digit hex carries an alpha channel we don't use; drop it
|
||||||
return {
|
const rgb = h.length === 8 ? h.slice(0, 6) : h
|
||||||
r: (num >> 16) & 255,
|
const expanded =
|
||||||
g: (num >> 8) & 255,
|
rgb.length === 3
|
||||||
b: num & 255,
|
? rgb
|
||||||
|
.split('')
|
||||||
|
.map(c => c + c)
|
||||||
|
.join('')
|
||||||
|
: rgb
|
||||||
|
const num = parseInt(expanded, 16)
|
||||||
|
return {
|
||||||
|
r: (num >> 16) & 255,
|
||||||
|
g: (num >> 8) & 255,
|
||||||
|
b: num & 255,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user