| 1 | // ─── Section 2: Color Utilities ───────────────────────────────────────────── |
| 2 | |
| 3 | function isNeutralColor(color) { |
| 4 | if (!color || color === 'transparent') return true; |
| 5 | |
| 6 | // rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range. |
| 7 | const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); |
| 8 | if (rgb) { |
| 9 | return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30; |
| 10 | } |
| 11 | |
| 12 | // oklch()/lch() — chroma is the second numeric component. |
| 13 | // oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray. |
| 14 | // lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats |
| 15 | // literally (it does NOT convert them to rgb). |
| 16 | const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i); |
| 17 | if (oklch) return parseFloat(oklch[1]) < 0.02; |
| 18 | const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i); |
| 19 | if (lch) return parseFloat(lch[1]) < 3; |
| 20 | |
| 21 | // oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²). |
| 22 | // oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3. |
| 23 | const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i); |
| 24 | if (oklab) { |
| 25 | const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]); |
| 26 | return Math.hypot(a, b) < 0.02; |
| 27 | } |
| 28 | const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i); |
| 29 | if (lab) { |
| 30 | const a = parseFloat(lab[1]), b = parseFloat(lab[2]); |
| 31 | return Math.hypot(a, b) < 3; |
| 32 | } |
| 33 | |
| 34 | // hsl/hsla — saturation is the second numeric component (percent). |
| 35 | // Modern jsdom usually converts hsl() to rgb, but handle it directly for |
| 36 | // safety across versions and for any engine that preserves the format. |
| 37 | const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i); |
| 38 | if (hsl) return parseFloat(hsl[1]) < 10; |
| 39 | |
| 40 | // hwb(hue whiteness% blackness%) — a pixel is fully gray when |
| 41 | // whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100. |
| 42 | const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i); |
| 43 | if (hwb) { |
| 44 | const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]); |
| 45 | return (1 - Math.min(100, w + b) / 100) < 0.1; |
| 46 | } |
| 47 | |
| 48 | // Unknown / unrecognized format — err on the side of DETECTING rather |
| 49 | // than silently skipping. This is the opposite of the previous default, |
| 50 | // which was the root cause of the oklch bug. |
| 51 | return false; |
| 52 | } |
| 53 | |
| 54 | function parseRgb(color) { |
| 55 | if (!color || color === 'transparent') return null; |
| 56 | const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); |
| 57 | if (!m) return null; |
| 58 | return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 }; |
| 59 | } |
| 60 | |
| 61 | function relativeLuminance({ r, g, b }) { |
| 62 | const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c => |
| 63 | c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 |
| 64 | ); |
| 65 | return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; |
| 66 | } |
| 67 | |
| 68 | function contrastRatio(c1, c2) { |
| 69 | const l1 = relativeLuminance(c1); |
| 70 | const l2 = relativeLuminance(c2); |
| 71 | return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); |
| 72 | } |
| 73 | |
| 74 | function parseGradientColors(bgImage) { |
| 75 | if (!bgImage || !bgImage.includes('gradient')) return []; |
| 76 | const colors = []; |
| 77 | for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) { |
| 78 | const c = parseRgb(m[0]); |
| 79 | if (c) colors.push(c); |
| 80 | } |
| 81 | for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) { |
| 82 | const h = m[1]; |
| 83 | if (h.length === 6) { |
| 84 | colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 }); |
| 85 | } else { |
| 86 | colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 }); |
| 87 | } |
| 88 | } |
| 89 | return colors; |
| 90 | } |
| 91 | |
| 92 | function hasChroma(c, threshold = 30) { |
| 93 | if (!c) return false; |
| 94 | return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold; |
| 95 | } |
| 96 | |
| 97 | function getHue(c) { |
| 98 | if (!c) return 0; |
| 99 | const r = c.r / 255, g = c.g / 255, b = c.b / 255; |
| 100 | const max = Math.max(r, g, b), min = Math.min(r, g, b); |
| 101 | if (max === min) return 0; |
| 102 | const d = max - min; |
| 103 | let h; |
| 104 | if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; |
| 105 | else if (max === g) h = ((b - r) / d + 2) / 6; |
| 106 | else h = ((r - g) / d + 4) / 6; |
| 107 | return Math.round(h * 360); |
| 108 | } |
| 109 | |
| 110 | function colorToHex(c) { |
| 111 | if (!c) return '?'; |
| 112 | return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); |
| 113 | } |
| 114 | |
| 115 | export { |
| 116 | isNeutralColor, |
| 117 | parseRgb, |
| 118 | relativeLuminance, |
| 119 | contrastRatio, |
| 120 | parseGradientColors, |
| 121 | hasChroma, |
| 122 | getHue, |
| 123 | colorToHex, |
| 124 | }; |
| 125 |