颜色也有距离?咋计算?一键找出上万个文件中的相近颜色并替换

一、背景

前段时间在公司项目中推进全局换肤之后,发现有个后遗症。项目中存在大量硬编码的颜色值,导致部分场景无法达到动态换肤的效果,需要把这些颜色值全部找出来并替换成变量。再加上公司正在风风火火的实行UI规范大一统,对这个问题有迫切的解决需求。然而公司中现存项目数量众多,在上万个文件中找出所有硬编码的颜色值,无异于大海捞针。且在各业务线紧张的迭代中,还要花费大量的人力去查找替换这些色值明显是不太现实的

二、目标

通过工具化的方式,一键将项目中硬编码的颜色值全部替换成相应的变量(less 变量或者 css3 变量),前提条件是公司必须有一套标准的配色表

三、实现方案

1、配色表转换

首先,需要将 UI 设计师提供的配色表转成对应的变量(本篇文章以 less 为例,其他预处理器同理)。例如设计师提供的配色表,如下

色号 色值
$ \color{#e6f7ff}{1号蓝} $ #e6f7ff
$ \color{#bae7ff}{2号蓝} $ #bae7ff
$ \color{#91d5ff}{3号蓝} $ #91d5ff
$ \color{#69c0ff}{4号蓝} $ #69c0ff
$ \color{#40a9ff}{5号蓝} $ #40a9ff
$ \color{#1890ff}{6号蓝} $ #1890ff
$ \color{#096dd9}{7号蓝} $ #096dd9
$ \color{#0050b3}{8号蓝} $ #0050b3
$ \color{#003a8c}{9号蓝} $ #003a8c
$ \color{#002766}{10号蓝} $ #002766

转换成 less 变量之后,形式如下,姑且把这个文件命名为:color-table.less

@blue-1: #e6f7ff; 
@blue-2: #bae7ff; 
@blue-3: #91d5ff; 
@blue-4: #69c0ff; 
@blue-5: #40a9ff; 
@blue-6: #1890ff; 
@blue-7: #096dd9; 
@blue-8: #0050b3; 
@blue-9: #003a8c; 
@blue-10: #002766; 

2、找颜色

前面准备工作做好之后,接下来就是怎么把颜色找出来了。嗯?那么多文件怎么找?很容易想到,可以通过 fs.readdirfs.readFile 配合正则表达式进行查找匹配,进而可以得出以下表达式(可精确匹配 rgbahex 格式的颜色值,暂不考虑英文字面量颜色与hsl格式,无他,项目中基本不用)

/(#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3}))|([rR][gG][Bb][Aa]?[\(]([\s]*(2[0-4][0-9]|25[0-5]|[01]?[0-9][0-9]?),){2}[\s]*(2[0-4][0-9]|25[0-5]|[01]?[0-9][0-9]?),?[\s]*(0\.\d{1,2}|1|0)?[\)]{1})/g

这么长一串看着晕吗?我也是这种感觉!那咱们把它可视化一下,最后长这样:

reg.png

666,瞬间清晰多了

正则表达式有了之后,需要把它利用起来才能找到颜色值,这一块逻辑的实现代码如下:

// 匹配 rgba + hex 格式颜色值正则
const HEX_AND_RGB_COLOR_REG =
  /(#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3}))|([rR][gG][Bb][Aa]?[\(]([\s]*(2[0-4][0-9]|25[0-5]|[01]?[0-9][0-9]?),){2}[\s]*(2[0-4][0-9]|25[0-5]|[01]?[0-9][0-9]?),?[\s]*(0?\.\d{1,2}|1|0)?[\)]{1})/g;

// 读取文件
const readFile = (filePath) => {
  fs.readFile(filePath, function (err, data) {
    if (err) {
      return err;
    }
    let str = data.toString();
    
    // 匹配出来的颜色值
    const matchColor = str.match(HEX_AND_RGB_COLOR_REG);
    console.log(matchColor);
  });
};

// 读取文件夹
const readDir = (filePath) => {
  //遍历目标文件夹
  fs.readdir(filePath + "", function (err, files) {
    if (err) {
      console.log(err);
      return err;
    }
    if (files.length !== 0) {
      files.forEach((item) => {
        const fileP = path.resolve(filePath, item);
        //判断文件的状态,用于区分文件名/文件夹
        fs.stat(fileP, function (err, status) {
          if (err) {
            return err;
          }
          const isFile = status.isFile(); //是文件
          const isDir = status.isDirectory(); //是文件夹

          if (isFile) {
            readFile(fileP);
          }

          if (isDir) {
            readDir(fileP);
          }
        });
      });
    }
  });
}

3、计算颜色距离

配色表有了,项目中的颜色也找出来了。接下来到了最关键的一步:把颜色之间的相似度计算出来。也就是说需要知道两个颜色之间的“距离”才能知道从项目中找出来的这个颜色值需要被替换成配色表中的哪个变量

在这之前需要把配色表文件 color-table.less 处理一下,使之成为 key-vlaue 的对象形式,方便后续的匹配操作

// 读取配色表中的内容
const colorTableStr = fs.readFileSync("./color-table.less", "utf-8");
// 去除行末的分号
const colorTableStrNext = colorTableStr.replace(/;/g, "");
// 将每一行数据装进数组,形如:['@blue-1: #e6f7ff', '@blue-2: #bae7ff']
const colorTableArr = colorTableStrNext.split("\n").filter((item) => !!item);

// 配色表对象,形如:{'#e6f7ff': '@blue-1', '#bae7ff': '@blue-2'}
const colorVarObj = {};
colorTableArr.forEach((item) => {
  item = item.toString();
  if (item) {
    const [key, value] = item.split(":");
    // 去除空格
    const val = value.replace(/\s/g, "");
    colorVarObj[val] = key;
  }
});

经过上述处理之后的配色表对象 colorVarObj 格式为:{'#e6f7ff': '@blue-1', '#bae7ff': '@blue-2'}

既然是计算,那肯定得有一套计算逻辑,首先想到的计算方法是:

  • 将颜色全部转换为 RGB 格式;
  • 结合公式 $ \Delta RGB = \sqrt{(R_2 – R_1)^2 + (G_2 – G_1)^2 + (B_2 – B_1)^2} $ 进行计算

这种计算方法对于大部分颜色来讲是没问题的,但是对于某些颜色就行不通了,假设有下面这一组颜色

$ \color{#969696}{color1: rgb(150,150,150)} $

$ \color{#787878}{color2: rgb(120,120,120)} $

$ \color{#AA8282}{color3: rgb(170,130,130)} $

从计算结果来看,color3color1 的色差 34.64,明显小于 color2color1 的色差 51.96。但是从视觉效果上来看,color2color3 更接近 color1,所以这个方法行不通

经过查阅相关资料,找到了一个相对比较完美的计算方案:

  • 将颜色全部转成 LAB 格式
  • 结合 CIEDE2000 公式 $ \Delta E_{00}^* = \sqrt{ (\frac{\Delta L\prime}{K_LS_L})2 + (\frac{\Delta C\prime}{K_CS_C})2 + (\frac{\Delta H\prime}{K_HS_H})2 + R_T\frac{\Delta C^\prime}{K_CS_C}\frac{\Delta H^\prime}{K_HS_H} } $ 来计算,这个方案用 js 代码实现之后,跟 Photoshop 的色差计算结果相差无几,基本上可以说是一模一样。

上述公式中各字母代表的含义及计算方法:

接下来,咱们来稍微实现一下这个公式。。。可惜啊,已经有大佬实现了这个计算公式,秉着不重复造轮子观念,就直接用这个库吧(其实就是能力不行,实现不了😄)。传送门:DeltaE

不过呢,需要注意的是,这个公式接收的颜色格式为 LAB,所以需要对颜色进行转换,找了一圈类似的 js 库,经测试发现转换得并不精准。那咱们自己实现一下?上代码:

// rgb转为lab
const rgb2lab = function ({ r, g, b }) {
  r /= 255.0; // rgb range: 0 ~ 1
  g /= 255.0;
  b /= 255.0;
  // gamma 2.2
  if (r > 0.04045) {
    r = Math.pow((r + 0.055) / 1.055, 2.4);
  } else {
    r = r / 12.92;
  }
  if (g > 0.04045) {
    g = Math.pow((g + 0.055) / 1.055, 2.4);
  } else {
    g = g / 12.92;
  }
  if (b > 0.04045) {
    b = Math.pow((b + 0.055) / 1.055, 2.4);
  } else {
    b = b / 12.92;
  }
  // sRGB
  let X = r * 0.436052025 + g * 0.385081593 + b * 0.143087414;
  let Y = r * 0.222491598 + g * 0.71688606 + b * 0.060621486;
  let Z = r * 0.013929122 + g * 0.097097002 + b * 0.71418547;
  // XYZ range: 0~100
  X = X * 100.0;
  Y = Y * 100.0;
  Z = Z * 100.0;
  // Reference White Point
  const ref_X = 96.4221;
  const ref_Y = 100.0;
  const ref_Z = 82.5211;
  X = X / ref_X;
  Y = Y / ref_Y;
  Z = Z / ref_Z;
  // Lab
  if (X > 0.008856) {
    X = Math.pow(X, 1 / 3.0);
  } else {
    X = 7.787 * X + 16 / 116.0;
  }
  if (Y > 0.008856) {
    Y = Math.pow(Y, 1 / 3.0);
  } else {
    Y = 7.787 * Y + 16 / 116.0;
  }
  if (Z > 0.008856) {
    Z = Math.pow(Z, 1 / 3.0);
  } else {
    Z = 7.787 * Z + 16 / 116.0;
  }

  const lab_L = 116.0 * Y - 16.0;
  const lab_A = 500.0 * (X - Y);
  const lab_B = 200.0 * (Y - Z);

  return [lab_L, lab_A, lab_B];
};

看到这里是不是有点疑惑,上面那一堆数字怎么来的,你咋知道是这些?附上参考资料:Conversion from RGB to lab

结合 DeltaE 公式,实现逻辑如下:

// 计算颜色距离
const calDistance = (current, source) => {
  const [cl, ca, cb] = rgb2lab(tinyColor(current).toRgb());
  const [sl, sa, sb] = rgb2lab(tinyColor(source).toRgb());

  const distance = DeltaE.getDeltaE00(
    { L: cl, A: ca, B: cb },
    { L: sl, A: sa, B: sb }
  );
  return distance;
};

还是使用前面那一组颜色进行测试这个新公式的计算效果。color3color1 的色差 16.11,是大于 color2color1 的色差 10.82。计算结果与视觉效果完全一致。

4、替换颜色

一切准备就绪,替换颜色就很简单了。但是,要注意以下三点:

  • less 文件:直接将相应的颜色值替换为 color-table.less 中的 less 变量
  • tsx 文件:由于 tsx 文件不支持 less 变量,所以 tsx 文件中硬编码的颜色值,将被替换为 color-table.less 中 less 变量对应的色值或 css3 变量
  • 透明度不为 1 的 rgba 颜色值:由于 LAB 色彩空间没有透明度信息,所以经过转换之后,原色值的透明度会丢失,导致被转换为错误的变量。此时,需要结合 less 的 fade 方法还原丢失的透明度fade(${less变量}, ${透明度 * 100}%)

代码实现如下:

// 根据颜色距离,取出最接近的颜色
const nearColor = (color) => {
  const colors = Object.keys(colorVarObj);
  const distance = colors.map((item) => {
    return {
      distance: calDistance(color, item),
      color: item,
    };
  });
  let resColor = color;
  let minDis = Number.MAX_SAFE_INTEGER;
  distance.forEach((item) => {
    if (item.distance < minDis) {
      minDis = item.distance;
      resColor = item.color;
    }
  });
  // 替换为less变量
  return [colorVarObj[resColor], resColor];
};

// 替换颜色
const replaceNearColor = (str, type) => {
  let [colorVar, colorRes] = nearColor(str);

  let realColor = colorTableObj[colorVar] ? colorTableObj[colorVar] : str;

  // 透明度不为1的颜色特殊处理
  const alp = tinyColor(str).getAlpha();
  if (alp !== 1) {
    const color = replaceCssVarFn(realColor);
    colorVar = `fade(${color}, ${alp * 100}%)`;
  }

  // 处理非less文件中的颜色值
  if (type !== 'less') {
    if (alp !== 1) {
      realColor = tinyColor(realColor).setAlpha(alp).toRgbString();
    }
    return realColor;
  }
  return colorVar;
};

5、效果体验

在线体验地址://hxkj.vip/demo/color

github 地址://github.com/TangSY/near-color-change

priview.png

四、结语

作者:HashTang

别忘了点赞、关注,支持一下哦~

欢迎提问交流!

参考资料

//zschuessler.github.io/DeltaE/learn/

//zh.wikipedia.org/wiki/颜色差异