你是否想过,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 文件本质上是一个经过多层压缩的二进制容器,结构大致如下:
[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 定义本身也打包进去了 — 这是自描述格式的典型做法。
解析流程:
1. 读取第一个 chunk → 解压 → 得到 Kiwi 二进制 schema
2. 用 decodeBinarySchema() 解码 schema 定义
3. 用 compileSchema() 动态编译出解码器函数
4. 读取第二个 chunk → 解压 → 用编译好的解码器解析节点数据
这里有个有趣的兼容性问题:不同版本的 Figma 文件,解码出来的根对象的字段名可能不同。标准情况下节点数据在 nodeChanges 字段里,但某些版本可能用其他字段名。我的做法是:如果 nodeChanges 为空,就扫描整个解码对象,找第一个"元素是带 guid 属性的对象"的数组,把它当作节点数据。
压缩算法的"套娃"
更有趣的是,每个 chunk 的压缩方式并不统一。Figma 同时使用了两种压缩算法:
- Deflate (RFC 1951) — 大部分 schema 块用的传统压缩
- Zstandard (zstd) — 数据块使用的高性能压缩,通过魔数
[0x28, 0xB5, 0x2F, 0xFD]识别
为什么要混用?我猜测是历史原因 — 早期版本用 Deflate,后来 Figma 为了提升大文件的压缩/解压性能引入了 zstd,但保留了 Deflate 作为兼容。
我的解压策略是一个三级 fallback 链:
检查 zstd 魔数 → 匹配则用 zstd 解压
→ 不匹配则尝试 deflate
→ deflate 也失败则当作原始字节处理
另外还要特殊处理 PNG 图片数据(通过 0x89 0x50 魔数识别并跳过),避免把嵌入的图片当成节点数据去解析。这些细节不在任何文档里,全靠反复试错和观察才摸索出来。
从扁平数组到节点树
Figma 内部并不是以树形结构存储节点的,而是一个扁平的 nodeChanges 数组。每个节点通过 guid(由 sessionID 和 localID 组成的唯一标识)和 parentIndex.guid 来表达父子关系。
重建树结构的算法:
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 归一化后的坐标,实际渲染时需要按节点的真实尺寸进行缩放。
如果二进制解码失败了怎么办?我设计了一个三级降级策略:
- 图标名匹配 — 尝试用节点名称(如 "search"、"chevron-right")在 Lucide 图标库中查找对应 SVG 路径
- 二进制解码 — 从 fillGeometry/strokeGeometry blob 中解析
- 降级为矩形 — 实在不行就降级为矩形 + 输出警告,保证导入不中断
在实际测试中,这个降级策略的效果出奇地好 — 大量 Figma 设计稿中的图标节点名称恰好就是标准图标库的名字,第一级匹配就能命中。
组件与实例的两遍扫描
Figma 的组件系统使用 SYMBOL(组件定义)和 INSTANCE(组件实例)两种节点类型,通过 GUID(sessionID:localID)关联。
问题在于:INSTANCE 节点可能出现在 SYMBOL 定义之前 — Figma 的扁平数组不保证拓扑排序。所以必须用两遍扫描:
第一遍:扫描所有 SYMBOL 节点,建立 GUID → 内部 ID 映射表
第二遍:处理 INSTANCE 节点时,查表找到对应组件 ID,生成 ref 引用
如果找不到对应 SYMBOL,降级为普通 frame 节点
这个设计也天然支持了跨页面的组件引用 — 多页面导入时,组件映射表是全局共享的,一个页面的 INSTANCE 可以引用另一个页面定义的 SYMBOL。
富文本的逐字符样式解析
Figma 的文本不是简单的"一段文字一个样式"。它是逐字符标记样式的:
{
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),每段可以有独立的字体、字号、字重、颜色、下划线、删除线等属性。上面的例子会被解析为:
[
{ text: "Hello", fontWeight: "400" }, // 默认样式
{ text: " World", fontSize: 24, fontWeight: "700", fill: "#FF0000FF" } // 覆写样式
]
同时还要处理字体粗细的名称解析 — Figma 把字重信息编码在字体名里,比如从 "SFProDisplay-Semibold" 中提取出 fontWeight: 600。我建了一个映射表来处理各种粗细名称:
Thin → 100, Extralight → 200, Light → 300, Regular → 400,
Medium → 500, Semibold → 600, Bold → 700, Extrabold → 800, Black → 900
还有文字变换(Text Transform)要处理:UPPER、LOWER、TITLE 三种大小写变换需要在解析后应用到最终文本内容上。
渐变角度的矩阵逆推
Figma 的线性渐变不存储角度值,而是存储一个 2D 仿射变换矩阵。这个矩阵描述了渐变方向的向量变换:
// 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 十六进制)。
// 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→ JPEG0x89 0x50→ PNG0x47 0x49→ GIF0x52 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 的子元素尺寸有
FIXED、HUG、FILL三种模式,对应 CSS 的具体值比较复杂 - Figma 的对齐方式名称和 CSS 完全不同(
MIN→flex-start、MAX→flex-end、CENTER→center、SPACE_BETWEEN→space-between)
为此我设计了两种导入模式:
保留原始布局模式(Preserve):
- 剥离所有 Auto Layout 属性
- 所有子元素转为绝对定位 + 数值尺寸
- 对有
stackMode的 frame 强制clipContent: true - 100% 还原 Figma 中的视觉效果
- 适合只想看、不想改的场景
转换为可编辑布局模式(OpenPencil):
- 将
stackMode: HORIZONTAL/VERTICAL映射为 layout 属性 - 提取子元素的
stackSizing暗示:FILL→fill_container(撑满父容器)HUG→fit_content(按内容自适应)FIXED→ 固定数值
- 映射
stackPrimaryAlignItems→justifyContent - 映射
stackCounterAlignItems→alignItems - 适合需要继续编辑的场景
用户在导入对话框中可以自由选择,默认是"保留原始布局"。
导入对话框的用户体验
为了让整个导入过程对用户友好,我做了一个完整的向导式对话框,包含五个状态:
- 拖放上传 — 支持拖放
.fig文件或点击浏览,有视觉高亮反馈,还贴心地提示了"如何从 Figma 导出 .fig 文件" - 解析中 — 实时进度条(10% → 50%),显示文件名
- 页面选择 — 如果文件包含多个页面,列出所有页面及其图层数量,支持单页导入或全部导入
- 转换中 — 进度条继续(60% → 95%),后台处理图片和节点
- 完成/错误 — 成功时显示警告列表(如有无法识别的节点类型),失败时提供重试按钮
整个过程用 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…
希望这篇文章能帮到同样在做设计工具或文件格式解析的同学。如果有任何问题或想法,欢迎在评论区交流。