我用纯前端逆向了 Figma 的二进制文件格式,实现了 .fig 文件的完整解析和导入

你是否想过,Figma 的 .fig 文件里到底藏了什么?它是怎么把你精心设计的渐变、圆角、自动布局、组件实例压缩进一个二进制文件的?这篇文章带你从零逆向 Figma 的私有文件格式,在纯前端环境下实现完整的 .fig 文件解析与导入。

起因

我在做一个开源设计工具 OpenPencil(一个 Design-as-Code 理念的矢量设计工具),核心目标之一就是:让设计师能把已有的 Figma 设计直接导入,无缝继续工作

市面上大多数"Figma 导入"方案要么依赖 Figma REST API(需要登录授权、只能导出 JSON),要么只支持 SVG/PNG 等扁平格式(丢失结构信息)。而我想做的是:用户拖一个 .fig 文件进来,所有图层、样式、组件关系、自动布局、富文本都完整还原

这意味着我需要在纯前端环境下,不依赖 Figma API,直接解析 .fig 二进制文件。

没有官方文档。没有公开 spec。只能逆向。

.fig 文件到底是什么?

拿到一个 .fig 文件后,第一件事当然是用十六进制编辑器打开看看。文件头赫然写着 fig-kiwi 八个字节。

.fig 文件本质上是一个经过多层压缩的二进制容器,结构大致如下:

css 复制代码
[8字节 "fig-kiwi" 魔数头]
[4字节 分隔符]
[4字节 chunk大小] [压缩数据块1 → Kiwi Schema 定义]
[4字节 chunk大小] [压缩数据块2 → 编码后的节点数据]
...

是的,Figma 用的是 Kiwi 编码格式 --- 一种由 Figma 联合创始人 Evan Wallace 创建的二进制 schema 编码。它比 Protobuf 更紧凑,特别适合设计工具这种需要频繁序列化大量节点树的场景。

而且 .fig 文件还有两种外层包装:

  • 裸二进制 :直接以 fig-kiwi 开头,较早版本的 Figma 使用
  • ZIP 压缩包 :新版 Figma 的格式,内含 canvas.fig 主文件 + images/ 目录(图片以 hash 为文件名存储)

所以解析的第一步就是判断格式版本:检查文件头是不是 fig-kiwi 魔数,如果不是就当 ZIP 解压,再从里面提取出 canvas.fig 和所有图片文件。

Kiwi Schema 的动态编译

Figma 并不使用固定的 schema,不同版本的 Figma 可能产出略有差异的 schema 结构。所以 .fig 文件把 schema 定义本身也打包进去了 --- 这是自描述格式的典型做法。

解析流程:

markdown 复制代码
1. 读取第一个 chunk → 解压 → 得到 Kiwi 二进制 schema
2. 用 decodeBinarySchema() 解码 schema 定义
3. 用 compileSchema() 动态编译出解码器函数
4. 读取第二个 chunk → 解压 → 用编译好的解码器解析节点数据

这里有个有趣的兼容性问题:不同版本的 Figma 文件,解码出来的根对象的字段名可能不同。标准情况下节点数据在 nodeChanges 字段里,但某些版本可能用其他字段名。我的做法是:如果 nodeChanges 为空,就扫描整个解码对象,找第一个"元素是带 guid 属性的对象"的数组,把它当作节点数据。

压缩算法的"套娃"

更有趣的是,每个 chunk 的压缩方式并不统一。Figma 同时使用了两种压缩算法:

  1. Deflate (RFC 1951) --- 大部分 schema 块用的传统压缩
  2. Zstandard (zstd) --- 数据块使用的高性能压缩,通过魔数 [0x28, 0xB5, 0x2F, 0xFD] 识别

为什么要混用?我猜测是历史原因 --- 早期版本用 Deflate,后来 Figma 为了提升大文件的压缩/解压性能引入了 zstd,但保留了 Deflate 作为兼容。

我的解压策略是一个三级 fallback 链:

markdown 复制代码
检查 zstd 魔数 → 匹配则用 zstd 解压
                → 不匹配则尝试 deflate
                → deflate 也失败则当作原始字节处理

另外还要特殊处理 PNG 图片数据(通过 0x89 0x50 魔数识别并跳过),避免把嵌入的图片当成节点数据去解析。这些细节不在任何文档里,全靠反复试错和观察才摸索出来。

从扁平数组到节点树

Figma 内部并不是以树形结构存储节点的,而是一个扁平的 nodeChanges 数组 。每个节点通过 guid(由 sessionIDlocalID 组成的唯一标识)和 parentIndex.guid 来表达父子关系。

重建树结构的算法:

markdown 复制代码
1. 遍历所有 nodeChanges,以 "sessionID:localID" 为 key 建立索引 Map
2. 过滤掉 phase === 'REMOVED' 的已删除节点
3. 对每个节点,通过 parentIndex.guid 找到父节点,挂载为子节点
4. 根据 parentIndex.position(分数索引字符串)排序同级子节点

图层顺序的"反直觉"

一个容易被忽略但至关重要的细节:Figma 的图层是从前到后存储的,而大多数渲染引擎(包括我用的 Fabric.js)需要从后到前

什么意思呢?在 Figma 里,图层面板最上面的元素是"最前面"的(覆盖其他元素),但在 nodeChanges 数组里,它的 position 值反而是最大的。而 Canvas 渲染时,先绘制的在下面,后绘制的在上面。

Figma 内部用一种分数索引字符串 (fractional index)来表示同级元素的排列顺序 --- 这是一种常见的协同编辑排序策略,允许在任意两个元素之间插入新元素而不需要重新编号。解析时必须按 parentIndex.position 降序排列子节点,才能保证导入后的图层顺序和 Figma 里看到的一致。

这个坑我踩了好几次才定位到 --- 导入后看起来"差不多对"但总有些元素的遮挡关系不对,debug 了很久才意识到是排序方向的问题。

节点类型的映射

Figma 有 20 多种节点类型,需要映射到 OpenPencil 的节点体系。核心映射表:

Figma 类型 OpenPencil 类型 特殊处理
RECTANGLE / ROUNDED_RECTANGLE rectangle 四角独立圆角
ELLIPSE(仅图片填充时) image 圆形头像场景
LINE line 从 size.x 提取宽度
VECTOR / STAR / POLYGON path 二进制路径解码 + 图标名匹配
TEXT text 逐字符样式解析
FRAME / SECTION frame 可选保留 Auto Layout
SYMBOL frame (reusable=true) 标记为可复用组件
INSTANCE ref GUID 关联到 SYMBOL
GROUP group 保留几何信息
BOOLEAN_OPERATION path 展开为路径

值得一提的是椭圆节点的特殊处理:如果一个 ELLIPSE 节点只有图片类型的填充,就会被识别为圆形头像,直接转换为 image 类型节点。这是一个基于实际设计稿常见 pattern 做的启发式优化。

最硬核的部分:矢量路径的二进制解码

Figma 把矢量路径存储为一种紧凑的二进制指令序列,而不是我们熟悉的 SVG path 字符串。每条指令的格式:

命令字节 含义 参数
0x00 closePath (Z)
0x01 moveTo (M) 2 × float32 LE
0x02 lineTo (L) 2 × float32 LE
0x03 quadTo (Q) 4 × float32 LE
0x04 cubicTo (C) 6 × float32 LE

解码过程就是逐字节读取命令类型,再按对应参数数量读出 float32 小端序浮点数,最终拼接成标准 SVG path 字符串:

复制代码
M10.00 20.00 L30.00 40.00 C50.00 60.00 70.00 80.00 90.00 100.00 Z

这里有个坑:Figma 的路径数据是基于 normalizedSize 归一化后的坐标,实际渲染时需要按节点的真实尺寸进行缩放。

如果二进制解码失败了怎么办?我设计了一个三级降级策略:

  1. 图标名匹配 --- 尝试用节点名称(如 "search"、"chevron-right")在 Lucide 图标库中查找对应 SVG 路径
  2. 二进制解码 --- 从 fillGeometry/strokeGeometry blob 中解析
  3. 降级为矩形 --- 实在不行就降级为矩形 + 输出警告,保证导入不中断

在实际测试中,这个降级策略的效果出奇地好 --- 大量 Figma 设计稿中的图标节点名称恰好就是标准图标库的名字,第一级匹配就能命中。

组件与实例的两遍扫描

Figma 的组件系统使用 SYMBOL(组件定义)和 INSTANCE(组件实例)两种节点类型,通过 GUID(sessionID:localID)关联。

问题在于:INSTANCE 节点可能出现在 SYMBOL 定义之前 --- Figma 的扁平数组不保证拓扑排序。所以必须用两遍扫描

csharp 复制代码
第一遍:扫描所有 SYMBOL 节点,建立 GUID → 内部 ID 映射表
第二遍:处理 INSTANCE 节点时,查表找到对应组件 ID,生成 ref 引用
        如果找不到对应 SYMBOL,降级为普通 frame 节点

这个设计也天然支持了跨页面的组件引用 --- 多页面导入时,组件映射表是全局共享的,一个页面的 INSTANCE 可以引用另一个页面定义的 SYMBOL。

富文本的逐字符样式解析

Figma 的文本不是简单的"一段文字一个样式"。它是逐字符标记样式的:

javascript 复制代码
{
  characters: "Hello World",
  characterStyleIDs: [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1],
  styleOverrideTable: {
    1: { fontSize: 24, fontWeight: 'bold', fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }] }
  }
}

解析时需要把连续相同样式 ID 的字符合并成一个文本段(segment),每段可以有独立的字体、字号、字重、颜色、下划线、删除线等属性。上面的例子会被解析为:

javascript 复制代码
[
  { text: "Hello", fontWeight: "400" },  // 默认样式
  { text: " World", fontSize: 24, fontWeight: "700", fill: "#FF0000FF" }  // 覆写样式
]

同时还要处理字体粗细的名称解析 --- Figma 把字重信息编码在字体名里,比如从 "SFProDisplay-Semibold" 中提取出 fontWeight: 600。我建了一个映射表来处理各种粗细名称:

scss 复制代码
Thin → 100, Extralight → 200, Light → 300, Regular → 400,
Medium → 500, Semibold → 600, Bold → 700, Extrabold → 800, Black → 900

还有文字变换(Text Transform)要处理:UPPERLOWERTITLE 三种大小写变换需要在解析后应用到最终文本内容上。

渐变角度的矩阵逆推

Figma 的线性渐变不存储角度值,而是存储一个 2D 仿射变换矩阵。这个矩阵描述了渐变方向的向量变换:

typescript 复制代码
// Figma 存储的是 transform 矩阵
// m00 m01 m02
// m10 m11 m12
const angle = Math.atan2(transform.m10, transform.m00) * (180 / Math.PI)

这个角度计算本身不难,但容易在象限判断上出错(atan2 的返回值范围是 -π 到 π),需要注意角度的规范化。

对于径向渐变、角度渐变、菱形渐变(Figma 支持四种渐变类型),由于 OpenPencil 目前只支持线性和径向两种,我统一映射为径向渐变,保留渐变停止点的颜色和位置信息。视觉效果可能有微小差异,但对大部分设计稿来说是可以接受的。

颜色空间转换

一个看似简单但容易出错的细节:Figma 使用 0.0-1.0 的浮点数表示颜色,而前端标准是 0-255 的整数(或 #RRGGBB 十六进制)

typescript 复制代码
// Figma: { r: 0.2, g: 0.4, b: 0.8, a: 0.5 }
// 需要转换为: #3366CCFF → 再根据 opacity 调整 alpha
const hex = '#' +
  Math.round(r * 255).toString(16).padStart(2, '0') +
  Math.round(g * 255).toString(16).padStart(2, '0') +
  Math.round(b * 255).toString(16).padStart(2, '0') +
  Math.round(a * 255).toString(16).padStart(2, '0')

需要注意浮点精度问题 --- Math.round(0.498 * 255) 是 127 还是 128?在色值密集的渐变中,这种精度差异累积起来会导致色带(banding),所以需要确保四舍五入策略的一致性。

描边的复杂性

你可能觉得描边就是"线条颜色 + 粗细",但 Figma 的描边系统比想象中复杂得多:

  • 每边独立粗细 --- borderStrokeWeightsIndependent 为 true 时,上右下左四边可以有不同的描边宽度
  • 渐变描边 --- 描边也支持渐变色,和填充一样可以是 SOLID / LINEAR / RADIAL
  • 描边对齐 --- CENTER / INSIDE / OUTSIDE 三种对齐方式,影响描边相对于形状边缘的位置
  • 线帽和连接 --- NONE / ROUND / SQUARE 三种线帽,MITER / BEVEL / ROUND 三种连接方式
  • 虚线模式 --- dashPattern 数组定义线段-间隔的循环模式

所有这些属性都需要正确映射,否则导入后的视觉效果就会"差一点点"。

图片的延迟解析

嵌入图片的处理分两个阶段,这是一个有意为之的架构决策:

第一阶段(转换时):只记录引用标记,不处理二进制数据

  • __hash:a3f2c1... --- 引用 ZIP 中的 images/a3f2c1... 文件
  • __blob:3 --- 引用解析出的 blob 数组第 3 个元素

第二阶段(后处理):统一将二进制数据转换为 data URL

  • 通过魔数识别 MIME 类型:
    • 0xFF 0xD8 → JPEG
    • 0x89 0x50 → PNG
    • 0x47 0x49 → GIF
    • 0x52 0x49 → WebP
  • Base64 编码后生成 data:image/png;base64,...

为什么要延迟处理?因为图片数据可能很大(一个设计稿里有几十张高清图很常见),在主转换流程中处理 Base64 编码会阻塞 UI。分离成两个阶段后,主流程只做轻量的节点结构转换,图片编码可以在 requestAnimationFrame 空闲时批量处理。

自动布局的双模式导入

Figma 的 Auto Layout 和 CSS Flexbox 看似相似,但细节差异很大。举几个例子:

  • Figma 的 stackSpacing 可以是负数(元素重叠),CSS 的 gap 不行
  • Figma 的 stackPadding 支持四边独立设置(stackPaddingTop/Right/Bottom/Left),但也有统一值
  • Figma 的子元素尺寸有 FIXEDHUGFILL 三种模式,对应 CSS 的具体值比较复杂
  • Figma 的对齐方式名称和 CSS 完全不同(MINflex-startMAXflex-endCENTERcenterSPACE_BETWEENspace-between

为此我设计了两种导入模式:

保留原始布局模式(Preserve)

  • 剥离所有 Auto Layout 属性
  • 所有子元素转为绝对定位 + 数值尺寸
  • 对有 stackMode 的 frame 强制 clipContent: true
  • 100% 还原 Figma 中的视觉效果
  • 适合只想看、不想改的场景

转换为可编辑布局模式(OpenPencil)

  • stackMode: HORIZONTAL/VERTICAL 映射为 layout 属性
  • 提取子元素的 stackSizing 暗示:
    • FILLfill_container(撑满父容器)
    • HUGfit_content(按内容自适应)
    • FIXED → 固定数值
  • 映射 stackPrimaryAlignItemsjustifyContent
  • 映射 stackCounterAlignItemsalignItems
  • 适合需要继续编辑的场景

用户在导入对话框中可以自由选择,默认是"保留原始布局"。

导入对话框的用户体验

为了让整个导入过程对用户友好,我做了一个完整的向导式对话框,包含五个状态:

  1. 拖放上传 --- 支持拖放 .fig 文件或点击浏览,有视觉高亮反馈,还贴心地提示了"如何从 Figma 导出 .fig 文件"
  2. 解析中 --- 实时进度条(10% → 50%),显示文件名
  3. 页面选择 --- 如果文件包含多个页面,列出所有页面及其图层数量,支持单页导入或全部导入
  4. 转换中 --- 进度条继续(60% → 95%),后台处理图片和节点
  5. 完成/错误 --- 成功时显示警告列表(如有无法识别的节点类型),失败时提供重试按钮

整个过程用 requestAnimationFrame 避免 UI 阻塞,导入完成后自动 zoomToFitContent 让用户立刻看到导入结果。

完整的功能覆盖

最终实现的导入能力覆盖了 Figma 的核心功能:

功能 支持情况
纯色 / 线性渐变 / 径向渐变填充
图片填充(hash / blob)
描边(渐变、每边独立粗细、虚线、线帽、连接)
投影 / 内阴影 / 前景模糊 / 背景模糊
2D 仿射变换(位移、旋转、缩放)
圆角(统一 / 四角独立)
Auto Layout(含间距、四边独立内边距、对齐)
富文本(逐字符样式、大小写变换、多行对齐)
矢量路径(二进制解码 + 图标名匹配 + 降级)
组件定义与实例引用(跨页面)
多页面文档(单页 / 全部导入)
嵌套 Group / Frame / Section
混合模式 / 透明度 / 可见性 / 锁定
布局模式切换(保留原始 / 可编辑布局)

一些踩坑记录

分享几个印象深刻的坑:

1. 内存爆炸

第一版解析器在处理大文件时直接 OOM。原因是我一次性把所有 blob 数据都转成 Base64 字符串。一个 50MB 的 .fig 文件,里面可能有几十个高清图片 blob,Base64 后体积膨胀 33%,轻松突破浏览器的内存限制。改成延迟解析后,内存占用降了一个数量级。

2. 坐标系不一致

Figma 的节点位置存储在 transform 矩阵的 m02(x)和 m12(y)中,而不是独立的 x/y 字段。但同时 size 字段又是独立的 width/height。混用矩阵和独立值让坐标计算容易出错,特别是涉及旋转的时候。

3. "Internal Only" 页面

Figma 文件中有些 CANVAS 节点被标记为"Internal Only Canvas",这些是 Figma 内部使用的,不应该出现在导入结果里。发现这个是因为某些文件导入后多出了奇怪的空白页面。

4. zstd 的浏览器兼容

浏览器原生不支持 zstd 解压,需要引入 wasm 版本的 zstd 解压库。这增加了约 30KB 的 bundle 大小,但考虑到新版 Figma 文件普遍使用 zstd,这是必要的取舍。

关于 OpenPencil

OpenPencil 是一个完全开源(MIT 协议)的矢量设计工具。

核心理念是 Design-as-Code --- 设计稿本身就是可版本管理、可编程操作的结构化数据(JSON 格式的 .pen 文件),而不是锁在某个云端的黑盒。你可以用 Git 管理设计版本,用脚本批量操作设计稿,甚至让 AI 直接读写设计文件。

除了 Figma 文件导入,v0.1.0 还包括这些核心能力:

AI 驱动的设计生成

用自然语言描述你想要的界面,AI 直接生成完整的、可编辑的设计稿。

底层是一个编排器(Orchestrator)架构 :先用一次快速的 AI 调用把复杂设计分解为多个空间子任务(比如"导航栏"、"英雄区"、"功能卡片"、"页脚"),然后顺序执行每个子任务,每个子任务以 JSONL 流式输出节点数据,节点一生成就实时插入到画布上,带有渐入动画效果。

生成完成后还有一个可选的视觉验证步骤 --- 对画布截图,用视觉模型检查布局问题(对齐偏差、元素溢出、宽度不一致等),自动修复可以安全调整的属性。

支持选中已有元素后用自然语言描述修改需求,AI 会基于选中元素的上下文做局部修改,而不是重新生成整个设计。

更多特性

  • 桌面应用 --- 基于 Electron,支持 macOS、Windows、Linux 三端原生应用,带自动更新
  • 多页面支持 --- 完整的多页面管理,标签栏导航,支持右键菜单操作
  • 组件系统 --- 可复用组件、UIKit 导入/导出、组件浏览器面板
  • SVG 导入 --- 解析 SVG 文件为可编辑的矢量节点
  • 导出功能 --- 支持 PNG / SVG 导出,可选 1x / 2x / 3x 缩放

写在最后

写这个 Figma 解析器的过程让我对"文件格式逆向"有了全新的认识。一个看似简单的 .fig 文件背后,是 Kiwi 编码、双压缩算法套娃、二进制路径指令、分数索引排序、逐字符样式标记、仿射矩阵变换、延迟图片解析等一系列精巧设计的叠加。

Figma 团队在文件格式上做的工程取舍非常值得学习 --- 用 Kiwi 而不是 Protobuf 是为了更好的压缩率和解析速度,混用 Deflate 和 zstd 是为了兼容性和性能的平衡,分数索引是为了协同编辑时的无冲突排序。每个决定背后都有工程思考。

如果你也对设计工具、文件格式解析、或者 Design-as-Code 感兴趣,欢迎来 Star、试用、提 Issue,也非常欢迎贡献代码。

GitHub 地址:github.com/ZSeven-W/op...


希望这篇文章能帮到同样在做设计工具或文件格式解析的同学。如果有任何问题或想法,欢迎在评论区交流。

相关推荐
mldong10 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排11 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
linqiw11 小时前
Embabel 上手:Java Agent 是怎么干活的
agent
沧沧凉凉12 小时前
同一个 Blender 建模,Claude 两个模型都翻车,GPT-6 一次过
人工智能·游戏·ai编程
passerby606112 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角13 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!13 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰13 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
大模型真好玩14 小时前
DeepSeek Harness 入门很简单(三)——DeepSeek Harness接入工具、MCP、Skill
人工智能·agent·deepseek