color.mjs

  1// ─── Section 2: Color Utilities ─────────────────────────────────────────────
  2
  3function 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
 54function 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
 61function 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
 68function 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
 74function 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
 92function 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
 97function 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
110function 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
115export {
116  isNeutralColor,
117  parseRgb,
118  relativeLuminance,
119  contrastRatio,
120  parseGradientColors,
121  hasChroma,
122  getHue,
123  colorToHex,
124};