Figma插件魔术:用SVG和Canvas API混合颜色
2024 年 5 月 8 日

在开发过程中,有时候会遇到一个问题,显而易见的解决方案(甚至是明显的解决方案B或C)都无法奏效。这就发生在我制作Polychrom时,这是我们新的UI文本可读性插件。在我们的情况下,我从这个障碍中学到了东西,利用了解决问题的技巧来完全改变我对问题的看法,工程了一个“啊哈”时刻,然后迅速前进找到了解决方案。

Polychrom (opens new window) 是我们的免费Figma插件,可以更精确地控制UI文本的可读性。它通过使用APCA方法 (opens new window)自动检测所选项目与其背景层之间的对比度水平。为了确保用户获得完美且可读的对比值,它允许用户:根据背景轻松调整文本颜色;根据文本调整背景颜色。 这篇文章讨论了处理颜色填充混合的最终情况,并介绍了在插件内实现颜色填充混合的方法。我们是如何做到的?请继续阅读。

最初,Polychrom仅处理简单的用户场景(例如,当选择了一个只有一个不透明填充的文本对象,并且它位于另一个只有一个不透明填充的对象的背景之上)。这是我们的开发策略的一部分——我们首先实现一些简单的东西来定义主要的里程碑,然后逐渐增强插件的功能。

因此,一旦我们发布了第一个版本,我们希望让用户能够看到“复合”对象的对比度,即具有不同透明级别和不同混合模式的多个填充的对象,这些对象还与具有多个填充的其他“复合”对象相互叠加等。您可以看到一个例子。 简单与复杂案例

在左侧是简单的情况,在右侧是复杂的情况,其中包含重叠的透明形状。

你可以在视频中看到这种复杂行为的示例:

请注意,文本也会改变颜色,因为这个元素是部分透明的。

因此,我们需要能够混合用户所选择的对象的填充,并从中计算计算出的颜色。此外,我们还需要-下面的部分听起来有些不切实际-考虑到位于所选对象下方的对象,其中的填充,并为它们所有计算计算出的颜色。(然后我们将使用这两个值来获得我们最终的对比度值。)

整个解决方案可以归结为两个步骤:找到构成所选元素背景的所有相交元素,然后计算这些元素的计算颜色。 修正所有元素及其字段的计算颜色。

在解决问题时,尝试实现明显且直接的解决方案是一个好习惯。毕竟,当那些努力可以更好地用在其他地方时,就没有必要做额外的工作。

但是,正如我们将看到的那样,当这种方法没有带来成果时,通常需要改变视角。

然而,让我们像我一样开始。

第一次失败尝试:使用 Culori 直接解决任务 #

一开始接近这个任务时,我想:“颜色混合?这将会很容易。我们只需要采用某个库,完成它,然后就可以了。”因此,我的第一次尝试是直接的迭代混合方法,使用 Culori 库。

Culori 提供了 blend 函数 (opens new window),该函数接受形式为数组的颜色代码和混合模式,然后返回最终计算的颜色:

imp import { blend } from 'culori';

blend(
  ['rgba(255, 0, 0, 0.5)', 'rgba(0, 255, 0, 0.5)', 'rgba(0, 0, 255, 0.5)'],
  'screen'
);

// ⇒ { mode: 'rgb', alpha: 0.875, r: 0.57…, g: 0.57…, b:0.57… }

但是… 它并没有完全按我们的需求工作。换句话说,我们无法完全让 Figma 世界和 Culori 世界“混合”在一起。

为什么?首先,Figma 中的每个填充和每个元素都有自己的混合模式。这些模式影响填充和图层之间的互动方式。相反,Culori 的 `blend` 只接受一个混合模式作为参数。

一个下拉菜单显示了填充的所有混合模式选项

这意味着我们不能输入一堆填充,而是必须像这样逐步循环计算计算出的颜色:

* 合并前两个 从列表中选择颜色
*   记录结果
*   选择下一个颜色
*   将其与前一个结果混合
*   重复这个过程

在代码中,看起来像这样:

import { blend } from 'culori';

export const blendFills = (fills: SolidPaint[]): SolidPaint => { const [firstFill, ...fillsToBlend] = fills;

const result = fillsToBlend.reduce((acc, fill) => { return blend([acc, fill], fill.blendMode, 'rgb'); }, firstFill);

return result; };


但这还不够。实际上,我们不能只是简单地将选定元素的第一对背景填充“粘合”在一起,因为列表中的第一个填充,具有透明值,还需要使用自己的混合模式“粘合”到其所在的对象上。

这意味着我们应该考虑所有下面的元素 用户选择的对象,一直到页面本身,并以某种方式处理它们:

* 获取所有图层
* 获取所有填充
* 找到最深的不透明填充
* 从它开始混合色调,直到选择的那个(因为不透明填充之下的填充和图层并不需要,因为它们实际上是不可见的)

在执行此过程时,我们还必须过滤禁用的填充。 😫

## 失败的尝试一(续):使用 Culori 的算法

在您继续阅读之前,我将进行一个披露:有大量的代码示例表明我如何使用这种方法进行调试,但最终,我决定这不是这个故事的重点。

因此,我留下了最终的函数供查阅,只是要注意,它们也没有起作用:

export const blendLayersColors = (layers: SceneNode[]): string | undefined => { // 跳过隐藏的元素和没有填充的元素 const visibleLayers = layers.filter((layer) => !isLayerI ```markdown const firstNonTransparentLayerIndex = visibleLayers.findIndex( (layer) => !ifLayerHasTransparency(layer) );

// 无需考虑不透明元素下面的填充,混合将从不透明图层开始从底部向上 const layersToBlend = visibleLayers.slice( 0, firstNonTransparentLayerIndex + 1 );

// 通过正确排列顺序,从图层列表生成填充列表 const allFills = layersToBlend.map((layer) => layer.fills.reverse()).flat();

const visibleFills = allFills.filter( (fill) => fill.visible === true && fill.opacity !== 0 );

// 与元素一样 - 不透明填充下面的填充是不需要的 const firstNonTransparentFillIndex = visibleFills.findIndex( (fill) => fill.opacity === 1 );

const fillsToBlend = visibleFills .slice(0, firstNonTransparentFillIndex + 1) .reverse();

const [firstFill, ...restFills] = fillsToBlend;

// 在循环中开始混合与中间结果


顺便说一句,如果您看到这段代码并认为它可能有效,那么您是对的!作为简单情况的解决方案,这段代码确实可以正常工作。但是想象一下,用户可能会以多少种方式排列Figma元素和填充。总会有一些情况使得这段代码无法正常工作,需要不断修复和调整。

对于我们的任务来说,这种方法很不可靠。为什么呢?因为这是一种天真(但必要)的方法!最重要的是,这种方法仅适用于平面填充列表:它不考虑元素是否按照树形结构排列。

但是您知道Figma中的布局是如何组织的吧?一切都是嵌套的! ## 到了改变视角的时候

那么,现在怎么办呢?在我们跳入一些复杂的解决方案之前,让我们先停下来反思一下。这里有一个问题:是否有更简单的方法?也就是说,我们是否可以避免试图一点一点地重新发明混合机制呢?

这就是我们可以再次回顾本文开头提到的水平创新的教训的地方。从这种心态出发,一个想法突然出现:如果我们尝试以一种更“声明式”的方式混合颜色会怎样呢?

我的意思是,如果我们在代码中简单描述用户在Figma填充中看到的特定情况,然后获得结果颜色,那不是很酷吗?

例如,如果我们有一种吸管机制——比如这里有一堆图层和填充,在它们之间有透明度,很多透明度。 颜色和混合模式,并**仅以与Figma中呈现的方式相同的方式复制它们**,然后给我结果颜色。

实际上,浏览器中有这样一种机制:[`Canvas`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas)。

更具体地说,[`OffscreenCanvas`](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) 对我们来说是个合适的灵感,因为我们不需要在界面中显示我们的画布,我们只需要它在虚拟中进行计算。

> 这个想法很简单:从用户创建的Figma元素树中获取,然后将其直接传输到浏览器画布中。

但是如何获取计算出的颜色呢?[`getImageData`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getImageData) 方法返回一个像素数组,并将每个像素的颜色作为一个包含RGBA颜色的四个数字的数组返回:`r`、`g`、`b`、`a`。

## Canvas API的一个小问题 在这种情况下,Canvas 也并非完美:这里没有分组功能。

由于我们在谈论Canvas API,我们需要明白它不是基于矢量的,而是基于光栅的。这意味着我们需要在其上绘制形状,而不能仅仅将一个元素树从Figma传输到画布上。这是一个问题,因为我们需要将Figma元素绘制为组,而Canvas 无法做到。

> 让我强调一下我们任务中的关键要素:Figma 文档中元素的树状结构和祖先 - 后代关系非常重要,因为祖先的透明度和混合模式会影响其所有后代。

使用Canvas API,我们无法使用类似祖先 - 后代范例的概念,我们只能在其中绘制形状,这意味着直接在画布上绘制并不适合我们。

我们如何简单地绘制一个结构,其中有一个父级框架,其他父级框架嵌套在其中,其他父级框架内有形状嵌套? 在其中,并且这种链中的每个元素都有自己的透明度和混合模式?

我们不能。

但是有一个解决方案:我们可以使用`SVG`格式。

SVG也理解[祖先-后代](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g)、[透明度](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/opacity)和[混合模式](https://developer.mozilla.org/en-US/docs/Web/CSS/mix-blend-mode)的概念。

最重要的是,我们可以在Canvas上使用SVG进行绘制!🎉 因此,我们可以从Figma获取元素树,将其按嵌套视角转换为SVG,保存树状结构,然后在画布上绘制它。

从那里,我们可以使用[`getImageData`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getImageData)来获取生成图像的颜色。 Canvas API, 我们需要定义形状的视觉组合方式,以便获取数据。

在这种情况下,不需要实现用户将要使用的确切元素,我们只需要绘制一些代表元素关系及其填充的像素。

因此,我们将绘制一个由矩形组成的组合,其中所有元素和填充属于用户选择的背景将填满整个宽度,而用户选择的确切部分将位于右侧。

例如,当用户选择卡片的标题以检查标题与背景的对比时,在虚拟画布中,我们将绘制类似以下矩形组合:

## 魔法展现

现在是时候揭示我们的底牌了。首先,让我们确定我们将使用的数据格式: 树状数据结构,树的每个节点都包含多个属性。

```typescript
export interface PolychromNode {
  blendMode: BlendMode;
  children: PolychromNode[];
  fills: FigmaPaint[];
  opacity?: number;
  visible?: boolean;
}

我们将使用这些数据以后递归地绘制填充和矩形。

现在,让我们为虚拟画布上的背景和前景对象定义一些框的坐标。我们还将定义“吸色器”的坐标,从这些点我们将获得有关颜色的信息:

const BACKGROUND_BOX = {
  eyeDropperX: 10,
  eyeDropperY: 10,
  height: 10,
  width: 20,
};

const FOREGROUND_BOX = {
  eyeDropperX: 0,
  eyeDropperY: 0,
  height: 10,
  width: 10,
};

让我们不要忘记考虑颜色空间,因为Figma最近添加了P3支持 (opens new window)。 我们可以使用Canvas API来管理设计文件中的颜色配置。我们可以创建一个用于将Figma数据与Canvas API进行匹配的字典:

export type FigmaColorSpace = 'DISPLAY_P3' | 'LEGACY' | 'SRGB';

export const CanvasColorSpace: Record<FigmaColorSpace, 'display-p3' | 'srgb'> =
  {
    DISPLAY_P3: 'display-p3',
    LEGACY: 'srgb',
    SRGB: 'srgb',
  };

接下来,我们创建canvascontext对象:

const canvas = new OffscreenCanvas(
  BACKGROUND_BOX.width,
  BACKGROUND_BOX.height
);

const ctx = canvas.getContext('2d', {
  colorSpace: CanvasColorSpace[figmaColorSpace]
});

接着我们开始绘制;我们创建一个svg元素,在其上绘制构图,然后传递给Canvas进行渲染:

const drawNodesOnContext = async (
  ctx: OffscreenCanvasRenderingContext2D,
  userSelection: PolychromNode,
  colorSpace: FigmaColorSpace
): Promise<void> => {
  co nst drawNode = (node: PolychromNode, parentGroup: SVGGElement): void => {
    const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
    parentGroup.appendChild(group);
  
    group.setAttribute('transform', `translate(${node.x}, ${node.y})`);
  
    node.children.forEach(child => {
      drawNode(child, group);
    });
  };

  drawNode(pair, svg);
};

在SVG格式中呈现可视元素的drawNodesOnSvg函数是一个核心部分;它通过迭代PolychromNode节点并根据其属性应用特定属性和样式来运作:

export const drawNodesOnSvg = (
  svg: SVGSVGElement,
  pair: PolychromNode,
  foregroundBox: CanvasRect,
  backgroundBox: CanvasRect,
  colorSpace: FigmaColorSpace
): void => {
  const drawNode = (node: PolychromNode, parentGroup: SVGGElement): void => {};

  drawNode(pair, svg);
};

这里是创建SVG组的分解:对于每个节点,我们创建一个组(<g>元素),允许我们对SVG元素进行有组织的结构化:

const drawNode = (node: PolychromNode, parentGroup: SVGGElement): void => {
    const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
    parentGroup.appendChild(group);
  
    group.setAttribute('transform', `translate(${node.x}, ${node.y})`);
  
    node.children.forEach(child => {
      drawNode(child, group);
    });
  };

  drawNode(pair, svg);
};
``` nst svgGroup = document.createElementNS(
  'http://www.w3.org/2000/svg',
  'g'
);

现在,让我们来看一下如何设置不透明度和混合模式:这个函数根据节点的不透明度设置每个组的不透明度,并且将从 Figma 格式转换为 SVG 样式的混合模式应用于每个组,确保视觉一致性:

如果节点的不透明度不是 1,则执行以下操作:
```javascript
svgGroup.setAttribute('opacity', `${node.opacity?.toFixed(2) ?? 1}`);

映射的混合模式为 mapFigmaBlendToCanvas(node.blendMode)

svgGroup.setAttribute(
  'style',
  `mix-blend-mode: ${mappedBlendMode}; isolation: isolate;`
);

通过在 SVG 组上设置 isolation: isolate;,我们本质上为混合模式创建了一个边界。

这意味着应用于该组内元素的混合效果只会相互作用,而不会与组外的元素相互作用。在颜色混合方面:“在这个组中发生的事情,都发生在这个组内”。 mapFigmaBlendToCanvas 函数将 Figma 的混合模式转换为 CSS 混合模式,确保在 Canvas 上呈现 Figma 设计时视觉一致性。

export const mapFigmaBlendToCanvas = (
  figmaBlend?: BlendMode
): CSSProperties['mixBlendMode'] => {
    const mapping: Record<BlendMode, CSSProperties['mixBlendMode']> = {
        COLOR: 'color',
        COLOR_BURN: 'color-burn',
        COLOR_DODGE: 'color-dodge',
        // ... 其他模式
    }
}

下一个函数仅过滤出每个节点中可见的填充。然后,它为每个填充创建一个 SVG 矩形(<rect>),根据节点是否被选中,确定它应该放在前景框还是背景框中:

const visibleFills = node.fills.filter(isVisibleSolidFill);

visibleFills.forEach((fill) => {
  const svgRect = drawFillAsRect(
    fill,
    node.isSelected === true ? foregroundBox : backgroundBox,
    colorSpace
  );

  if (isEmpty(svgRect)) return;

  svgGroup.appendChild(s
``` 每个代表填充的SVG矩形都附加到为节点创建的组中,然后将每个组添加到其父组中,保持层次结构:

parentGroup.appendChild(svgGroup);


该函数递归地将此过程应用于初始节点的所有子节点,确保整个节点层次结构准确呈现:

node.children.forEach((childNode) => { drawNode(childNode, svgGroup); });


这样,我们最终得到了节点的详细准确的表示(包括它们的视觉样式和层次关系)以SVG格式呈现。


## 渲染单个填充

`drawFillAsRect`函数创建一个SVG矩形(`<rect>`)来直观表示Figma的填充。它根据提供的尺寸(通过`rectBox`)设置矩形的大小,应用适当的混合模式(从Figma翻译到CSS标准),并分配填充样式(考虑颜色空间的考虑): ```
export const drawFillAsRect = (
  fill: FigmaPaint,
  rectBox: CanvasRect,
  colorSpace: FigmaColorSpace
): null | SVGGElement => {
  const svgRect = document.createElementNS(
    'http://www.w3.org/2000/svg',
    'rect'
  );

  svgRect.setAttribute('width', String(rectBox.width));
  svgRect.setAttribute('height', String(rectBox.height));

  if (notEmpty(fill.blendMode)) {
    const mappedBlendMode = mapFigmaBlendToCanvas(fill.blendMode);

    if (notEmpty(mappedBlendMode)) {
      svgRect.setAttribute('style', `mix-blend-mode: ${mappedBlendMode};`);
    }
  }

  const fillStyle = determineFillStyle(fill, colorSpace);

  if (isEmpty(fillStyle)) return null;

  svgRect.setAttribute('fill', fillStyle);

  if (fill.opacity !== 1) {
    svgRect.setAttribute('opacity', `${fill.opacity?.toFixed(2) ?? 1}`);
  }

  return svgRect;
};
``` 这个过程包含几个关键步骤。

首先是**SVG序列化**。在这里,函数通过使用XMLSerializer接口将SVG元素转换为字符串开始:

const xml = new XMLSerializer().serializeToString(svg);


接下来是**Base64编码**。序列化的SVG字符串被编码为Base64,这代表二进制数据以ASCII字符串格式表示。这一步对于将SVG转换为可用作有效图像源的格式至关重要:

const svg64 = btoa(xml); const b64Start = 'data:image/svg+xml;base64,';


现在我们继续\_\_创建一个图像元素\_\_;一个HTML `<img>`元素会被动态创建,这个元素将作为一个容器来存放编码后的SVG,使其被视为图像:

const img = document.createElement('img');


是时候**设置图像源**了。我们必须通过将Base64前缀与编码后的SVG字符串组合来构建一个数据URL。然后将这个URL设置为`src`属性。 使用DataURL创建图像元素。浏览器将此DataURL解释为图像的源,有效地加载SVG作为图像。

img.src = b64Start + svg64;


最后一步:**在Canvas上绘制**。一旦图像加载完成,事件(`img.onload`)会触发绘图动作。加载的图像(现在包含SVG图形)使用`drawImage`方法绘制到Canvas上。我们将SVG呈现到Canvas上,其图形内容将显示为:

img.onload = () => { ctx.drawImage(img, 0, 0); }


## 从Canvas获取数据

`getColorData`函数从`Uint8ClampedArray`中提取RGBA值,并将其转换为标准化格式,检查颜色值的存在:

export interface ColorData { alpha: number; b: number; g: number; r: number; }

export const getColorData = (fill: Uint8ClampedArray): ColorData | null => { const [r, g, b, alpha] = fill;

if (isEmpty(r) || isEmpty(g) || isEmpty(b)) return null;

};

`getFillFromCtx` 函数从 Canvas 上特定像素的颜色数据。它使用 Canvas API 上下文的 `getImageData` 方法来访问给定坐标 (x, y) 处的颜色信息,考虑指定的颜色空间,并返回一个包含该像素的 RGBA 值的 `Uint8ClampedArray`。

export const getFillFromCtx = ( ctx: OffscreenCanvasRenderingContext2D, x: number, y: number, colorSpace: FigmaColorSpace ): Uint8ClampedArray => { return ctx.getImageData(x, y, 1, 1, { colorSpace: isSupportsOKLCH ? CanvasColorSpace[colorSpace] : 'srgb', }).data; };


然后这个函数接收 Canvas 上下文,以及 `BACKGROUND_BOX` 的背景和 `FOREGROUND_BOX` 的前景的坐标。

const bgColorData = getColorData( getFillFromCtx( ctx, BACKGROUND_BOX.eyeDropperX, BACKGROUND_BOX.eyeDropperY, figmaColorSpa const bgColorData = getColorData( getFillFromCtx( ctx, BACKGROUND_BOX.eyeDropperX, BACKGROUND_BOX.eyeDropperY, figmaColorSpace ) );

const fgColorData = getColorData( getFillFromCtx( ctx, FOREGROUND_BOX.eyeDropperX, FOREGROUND_BOX.eyeDropperY, figmaColorSpace ) );

By using this data, we can easily calculate the final required contrast ratio as follows:

const apcaScore = calculateApcaScore( fgColorData, bgColorData, figmaColorSpace );

And that's it!

演出结束 #

通过使用这种问题解决方法,我能够利用预先存在的解决方案,如Canvas API和SVG,为Polychrom插件重新创建所需的Figma功能。当然,我的第一个方法并没有按照预期工作,但这都是过程的一部分。最终,我能够花更多时间编写实际的Polychrom业务逻辑,而不是重复造轮子!

在Evil Martians,我们将处于增长阶段的初创公司转变为独角兽,构建开发人员工具,并创建开源产品。如果您准备启动超空间引擎,请给我们发消息!