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

15 阅读17分钟

你是否想过,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 同时使用了两种压缩算法:

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

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

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

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

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

从扁平数组到节点树

Figma 内部并不是以树形结构存储节点的,而是一个扁平的 nodeChanges 数组。每个节点通过 guid(由 sessionIDlocalID 组成的唯一标识)和 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_RECTANGLErectangle四角独立圆角
ELLIPSE(仅图片填充时)image圆形头像场景
LINEline从 size.x 提取宽度
VECTOR / STAR / POLYGONpath二进制路径解码 + 图标名匹配
TEXTtext逐字符样式解析
FRAME / SECTIONframe可选保留 Auto Layout
SYMBOLframe (reusable=true)标记为可复用组件
INSTANCErefGUID 关联到 SYMBOL
GROUPgroup保留几何信息
BOOLEAN_OPERATIONpath展开为路径

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

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

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

命令字节含义参数
0x00closePath (Z)
0x01moveTo (M)2 × float32 LE
0x02lineTo (L)2 × float32 LE
0x03quadTo (Q)4 × float32 LE
0x04cubicTo (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 的扁平数组不保证拓扑排序。所以必须用两遍扫描

第一遍:扫描所有 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)要处理:UPPERLOWERTITLE 三种大小写变换需要在解析后应用到最终文本内容上。

渐变角度的矩阵逆推

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 → 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…


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