export function hexToRgb(hex: string): { r: number; g: number; b: number } { // Check if the hex code is valid const validHex = /^#?([a-fA-F\d]{6}|[a-fA-F\d]{3})$/ if (!validHex.test(hex)) { return { r: 0, g: 0, b: 0 } } // Remove the hash symbol if present const hexWithoutHash = hex.replace(/^#/, '') // Handle 3-digit hex format by converting it to 6-digit let hexValue = hexWithoutHash if (hexWithoutHash.length === 3) { hexValue = hexWithoutHash .split('') .map((char) => char + char) .join('') } // Parse the r, g, b values from the hex string const bigint = parseInt(hexValue, 16) const r = (bigint >> 16) & 255 const g = (bigint >> 8) & 255 const b = bigint & 255 return { r, g, b } } export function rgbToHex(rgb: { r: number; g: number; b: number }): string { return `#${((1 << 24) + (rgb.r << 16) + (rgb.g << 8) + rgb.b).toString(16).slice(1)}` } export const colorToGray = (color: { r: number; g: number; b: number }, saturation = 30) => { const minValue = Math.min(color.r, color.g, color.b) const maxValue = Math.max(color.r, color.g, color.b, minValue + 1) // The magic numbers (0.299, 0.587, 0.114) are the weights of the RGB channels based on human perception return { r: Math.floor(((color.r - minValue) / (maxValue - minValue)) * saturation * (1 - 0.299) + 120 - saturation / 2), g: Math.floor(((color.g - minValue) / (maxValue - minValue)) * saturation * (1 - 0.587) + 120 - saturation / 2), b: Math.floor(((color.b - minValue) / (maxValue - minValue)) * saturation * (1 - 0.114) + 120 - saturation / 2), } } export function reversePalette(colors: Record) { // take the key 50 and switch it with 950, 100 with 900, etc return Object.entries(colors).reduce>((acc, [key, value]) => { const reversedKey = key.replace(/(\d+)/, (num) => { return (1000 - parseInt(num)).toString() }) return { ...acc, [reversedKey]: value } }, {}) } const mainTRC = 2.4 // 2.4 exponent emulates actual monitor perception const sRco = 0.2126729 const sGco = 0.7151522 const sBco = 0.072175 // sRGB coefficients const normBG = 0.56 const normTXT = 0.57 const revTXT = 0.62 const revBG = 0.65 // G-4g constants for use with 2.4 exponent const blkThrs = 0.022 const blkClmp = 1.414 const scaleBoW = 1.14 const scaleWoB = 1.14 const loBoWthresh = 0.035991 const loWoBthresh = 0.035991 const loBoWfactor = 27.7847239587675 const loWoBfactor = 27.7847239587675 const loBoWoffset = 0.027 const loWoBoffset = 0.027 const loClip = 0.001 const deltaYmin = 0.0005 function RGBtoY(color: { r: number; g: number; b: number }) { const { r, g, b } = color // Apply the gamma correction for each channel const simpleExp = (chan: number) => (chan / 255.0) ** mainTRC // Calculate the luminance based on the sRGB coefficients and return return sRco * simpleExp(r) + sGco * simpleExp(g) + sBco * simpleExp(b) } export function apcaContrast(foreground: string | undefined, background: string | undefined) { if (!foreground || !background) { return 0 } let txtY = RGBtoY(hexToRgb(foreground)) let bgY = RGBtoY(hexToRgb(background)) // send linear Y (luminance) for text and background. // IMPORTANT: Do not swap, polarity is important. let SAPC = 0.0 // For raw SAPC values let outputContrast = 0.0 // For weighted final values // TUTORIAL // Use Y for text and BG, and soft clamp black, // return 0 for very close luminances, determine // polarity, and calculate SAPC raw contrast // Then scale for easy to remember levels. // Note that reverse contrast (white text on black) // intentionally returns a negative number // Proper polarity is important! ////////// BLACK SOFT CLAMP /////////////////////////////////////////// // Soft clamps Y for either color if it is near black. txtY = txtY > blkThrs ? txtY : txtY + (blkThrs - txtY) ** blkClmp bgY = bgY > blkThrs ? bgY : bgY + (blkThrs - bgY) ** blkClmp ///// Return 0 Early for extremely low ∆Y if (Math.abs(bgY - txtY) < deltaYmin) { return 0.0 } ////////// APCA/SAPC CONTRAST ///////////////////////////////////////// if (bgY > txtY) { // For normal polarity, black text on white (BoW) // Calculate the SAPC contrast value and scale SAPC = (bgY ** normBG - txtY ** normTXT) * scaleBoW // Low Contrast smooth rollout to prevent polarity reversal // and also a low-clip for very low contrasts outputContrast = SAPC < loClip ? 0.0 : SAPC < loBoWthresh ? SAPC - SAPC * loBoWfactor * loBoWoffset : SAPC - loBoWoffset } else { // For reverse polarity, light text on dark (WoB) // WoB should always return negative value. SAPC = (bgY ** revBG - txtY ** revTXT) * scaleWoB outputContrast = SAPC > -loClip ? 0.0 : SAPC > -loWoBthresh ? SAPC - SAPC * loWoBfactor * loWoBoffset : SAPC + loWoBoffset } // return Lc (lightness contrast) as a signed numeric value // It is permissible to round to the nearest whole number. return Math.abs(outputContrast * 100.0) }