16 KiB
.fig 数据结构与组装逻辑分析
本文档基于当前项目源码,说明本地 .fig 文件如何被解析成可查询的设计上下文,以及如何进一步组装成 SVG/PNG 导出结果。
1. 总体链路
flowchart TD
A["本地 .fig / .fig.json"] --> B["loadFigFile"]
B --> C["FigJson"]
C --> D["nodeChanges 扁平节点数组"]
C --> E["blobs 几何路径数据"]
A --> F["images/<hash> 图片资源"]
D --> G["getChildrenByParent 重建父子关系"]
G --> H["设计上下文: serializeNode / tokens / codeHints"]
G --> I["renderNodeToSvg 递归渲染"]
E --> I
F --> I
I --> J["SVG"]
J --> K["resvg 栅格化 PNG"]
核心结论:
.fig解码后的节点不是树形结构,而是nodeChanges扁平数组。- 节点树通过
guid和parentIndex.guid在运行时重建。 - 子节点层级顺序通过
parentIndex.position排序。 - 矢量路径不直接保存在节点字段里,而是通过
commandsBlob引用blobs[index]。 - 图片资源来自
.fig外层 zip 的images/<hash>,不是来自FigJson.blobs。
2. 文件读取与解码
入口:src/services/fig-file.ts
export function loadFigFile(filePath: string): FigJson {
const absolutePath = path.resolve(filePath)
if (absolutePath.toLowerCase().endsWith(".json")) {
return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as FigJson
}
return figToJson(fs.readFileSync(absolutePath))
}
读取策略:
.fig.json:直接作为已经解码过的 JSON 读取。.fig:调用figToJson解析 Figma 本地文件。
.fig 解码入口:src/services/fig2json.ts
主要步骤:
- 判断输入是否已经是
fig-kiwi格式。 - 如果不是
fig-kiwi,按 zip 解析并取出canvas.fig。 - 从
canvas.fig里切分二进制 parts。 - 对 part 做解压:
- zstd magic 开头:使用
fzstd解压。 - PNG magic 开头:保留。
- 其他内容:使用
UZIP.inflateRaw解压。
- zstd magic 开头:使用
- 第一个 part 是 schema,第二个 part 是 data。
- 使用
kiwi-schema解码 schema,再 decode data。 - 将
blobs转成 base64,得到项目内部使用的FigJson。
输出的 FigJson 还会附带 __figmaToJson 元信息:
{
__figmaToJson: {
schema: string,
delimiter: number
}
}
3. 核心数据结构
类型定义入口:src/services/fig-types.ts
3.1 FigJson
export type FigJson = {
type?: string
sessionID?: number
ackID?: number
nodeChanges?: FigNode[]
blobs?: string[]
__figmaToJson?: {
schema: string
delimiter: number
}
}
字段含义:
nodeChanges:所有节点的扁平列表。blobs:几何路径 blob,已被转为 base64 字符串。sessionID/ackID:文件级元信息。__figmaToJson:当前项目解码时保存的 schema 和 delimiter 信息。
3.2 FigNode
export type FigNode = {
guid: Guid
parentIndex?: { guid: Guid; position?: string }
type?: string
name?: string
visible?: boolean
opacity?: number
size?: { x: number; y: number }
transform?: FigmaMatrix
fillPaints?: FigPaint[]
strokePaints?: FigPaint[]
fillGeometry?: FigGeometry[]
strokeGeometry?: FigGeometry[]
effects?: FigEffect[]
textData?: FigTextData
derivedTextData?: FigDerivedTextData
}
重要字段:
guid:节点唯一标识。parentIndex.guid:父节点标识。parentIndex.position:同级节点排序信息,也是 z-order 的关键。type:节点类型,例如DOCUMENT、FRAME、TEXT、VECTOR、ELLIPSE。size:节点本地尺寸。transform:节点相对父级的 2D 变换矩阵。fillPaints/strokePaints:填充和描边 paint。fillGeometry/strokeGeometry:几何数据引用。effects:阴影、模糊等效果。textData/derivedTextData:文本内容、样式覆盖、glyph 路径等。
3.3 Guid 与 node-id
export type Guid = {
sessionID: number
localID: number
}
项目内部将它标准化为:
`${sessionID}:${localID}`
相关工具:src/utils/node-id.ts
keyForGuid(guid):把{ sessionID, localID }转成sessionID:localID。normalizeNodeId(value):支持123:456、123-456、node-id=123-456和完整 Figma 链接。
3.4 Geometry 与 Blob
export type FigGeometry = {
commandsBlob: number
windingRule?: string
styleID?: number
}
commandsBlob 是 FigJson.blobs 的索引。例如:
const blob = figJson.blobs?.[geometry.commandsBlob]
blob 内容是 base64 编码的紧凑路径命令。渲染时会被解析为 SVG path。
当前支持的路径命令:
| opcode | SVG 命令 | 含义 |
|---|---|---|
1 |
M |
moveTo |
2 |
L |
lineTo |
3 |
Q |
quadraticCurveTo |
4 |
C |
cubicCurveTo |
0 |
Z |
closePath |
解析入口:src/services/fig-node-svg.ts 的 parsePathBlob。
3.5 Paint
export type FigPaint = {
type: "SOLID" | "GRADIENT_LINEAR" | string
color?: FigColor
opacity?: number
visible?: boolean
stops?: Array<{ color: FigColor; position: number }>
transform?: FigmaMatrix
image?: { hash?: Uint8Array | number[] | string; name?: string }
imageThumbnail?: { hash?: Uint8Array | number[] | string; name?: string }
}
当前 SVG 渲染重点支持:
SOLIDGRADIENT_LINEARGRADIENT_RADIALIMAGE
IMAGE paint 会通过 image.hash 或 imageThumbnail.hash 去匹配 .fig zip 外层的 images/<hash>。
4. 节点树组装逻辑
组装入口:src/services/fig-node-svg.ts
export function getChildrenByParent(figJson: FigJson): Map<string, FigNode[]> {
const childrenByParent = new Map<string, FigNode[]>()
for (const node of figJson.nodeChanges ?? []) {
if (!node.parentIndex?.guid) continue
const parentKey = keyForGuid(node.parentIndex.guid)
const children = childrenByParent.get(parentKey) ?? []
children.push(node)
childrenByParent.set(parentKey, children)
}
return childrenByParent
}
组装结果是:
Map<parentNodeId, childNodes[]>
节点自身 id:
keyForGuid(node.guid)
父节点 id:
keyForGuid(node.parentIndex.guid)
子节点排序:
function getSortedChildren(context: RenderContext, node: FigNode): FigNode[] {
return [...(context.childrenByParent.get(keyForGuid(node.guid)) ?? [])].sort((a, b) =>
comparePosition(a.parentIndex?.position ?? "", b.parentIndex?.position ?? "")
)
}
排序使用字符串原始比较:
if (left === right) return 0
return left < right ? -1 : 1
原因是 parentIndex.position 是 fractional-index 字符串,使用 locale-aware 排序可能会重排标点,从而改变 Figma 原始 z-order。
5. 目标节点查找
入口:findTargetNode
查找逻辑:
- 先尝试从
nodeId或nodeQuery中解析标准 node-id。 - 如果能解析成
sessionID:localID,按guid查找。 - 如果不能解析,则按节点名称精确匹配。
const target = normalizedNodeId
? nodes.find((node) => keyForGuid(node.guid) === normalizedNodeId)
: nodes.find((node) => node.name === nodeName)
因此当前项目的节点查询不是模糊搜索。模糊搜索只存在于 listFigNodes 的查询列表逻辑里。
6. 设计上下文组装
入口:src/services/design-context.ts
6.1 getDesignContext
流程:
loadFigFile读取文件。getChildrenByParent组装父子映射。- 根据
nodeQuery找目标节点;没有传则找根节点。 serializeNode输出节点摘要和子树。- 可选输出 tokens。
- 可选输出 codeHints。
输出结构:
{
kind: "figma-local-design-context",
filePath,
query,
node,
tokens,
codeHints
}
6.2 serializeNode
serializeNode 会输出:
- 基础摘要:id、name、type、size、visible、opacity。
- 父节点 id。
- 子节点数量。
- paint 和 effect 是否存在。
- transform、stroke、arcData。
- 简化 fills / strokes。
- geometry 数量。
- 指定深度内的 children。
这部分适合给模型或上层工具提供可读的设计上下文。
6.3 tokens 推导
collectDesignTokens 并不读取 Figma 变量或全局样式注册表,而是从当前子树中推导:
- 颜色
- 线性渐变
- 投影
- 描边宽度
源码里也明确说明:本地 .fig 解码目前还没有解析 Figma 的变量和样式 registry。
7. SVG 组装逻辑
入口:renderNodeToSvg
流程:
- 找目标节点。
- 重建
childrenByParent。 - 初始化
RenderContext。 - 调用
renderNodeSubtree递归生成 SVG body。 - 根据节点尺寸、实际渲染 bounds 和 effect bounds 计算导出 viewBox。
- 拼接
<svg>、<defs>、背景和 body。
7.1 RenderContext
type RenderContext = {
figJson: FigJson
childrenByParent: Map<string, FigNode[]>
defs: string[]
rasterHints: FigmaLikeRasterHint[]
imageAssets: FigImageAssets
bounds: Bounds | null
effectBounds: Bounds | null
idSeed: number
pngFigmaLike: boolean
}
关键字段:
defs:渐变、clipPath、filter 等 SVG 定义。rasterHints:PNG 阶段需要额外像素补偿的信息。imageAssets:图片 hash 到 data URL 的映射。bounds:普通可见内容范围。effectBounds:阴影、模糊等效果扩展范围。
7.2 renderNodeSubtree
递归渲染顺序:
- 跳过
visible === false的节点。 - 合并父级矩阵和当前节点 transform。
- 渲染 fill geometry。
- 渲染文本 glyph。
- 收集 Figma-like PNG raster hint。
- 渲染 stroke。
- 递归渲染 children。
- 给当前节点包一层
<g>,附加 opacity 和 filter。
特殊处理:
BOOLEAN_OPERATION如果已经有计算后的几何路径,就不再渲染源 children。- mask 节点会转成
clipPath,后续 sibling 会被 clip。 - effect 作为节点级 filter 包在
<g>上,而不是只包单个 path。
7.3 transform 组装
Figma 矩阵:
{
m00, m01, m02,
m10, m11, m12
}
SVG matrix:
[m00, m10, m01, m11, m02, m12]
转换入口:
function toSvgMatrix(matrix?: FigmaMatrix): SvgMatrix
递归时通过矩阵乘法累积:
const matrix = multiply(parentMatrix, localMatrix)
这样子节点最终会进入根节点坐标空间。
8. 几何路径渲染
几何渲染入口:renderGeometry
流程:
- 读取
fillGeometry或strokeGeometry。 - 用
commandsBlob找到figJson.blobs[index]。 parsePathBlob转成 SVG pathd。- 计算 bounds。
- 根据 paint 类型输出对应 SVG。
普通填充:
<path d="..." fill="..." />
奇偶填充规则:
fill-rule="evenodd"
描边优先使用 SVG stroke 属性,而不是直接填充 decoded strokeGeometry。原因是 Figma 的 decoded strokeGeometry 更像内部栅格使用的扩展轮廓,直接填充可能让细描边变粗。
9. 图片资源组装
图片读取入口:src/services/fig-images.ts
流程:
- 只对
.fig生效,.json返回空 Map。 - 判断文件是否是 zip。
- 解析 zip。
- 遍历
images/下的资源。 - 通过文件头识别 mime:
- PNG
- JPEG
- GIF
- WEBP
- 存成:
Map<hash, dataUrl>
图片渲染入口:renderImageFill
流程:
- 从
paint.image.hash或paint.imageThumbnail.hash得到 hash。 - 在
imageAssets中查找 data URL。 - 用当前矢量路径创建
clipPath。 - 输出一个单位尺寸
<image width="1" height="1">。 - 用
paint.transform和节点尺寸计算 image matrix。 - 用 wrapper
<g clip-path="...">裁切图片。
这样可以把 Figma 的图片裁切、填充和矢量路径边界转成 SVG 近似表达。
10. 文本渲染
文本节点不直接输出 <text>,而是使用 glyph path。
入口:renderTextNode
流程:
- 判断
node.type === "TEXT"。 - 读取
derivedTextData.glyphs。 - 每个 glyph 通过
commandsBlob解析出路径。 - 根据 glyph 的 position、fontSize、rotation 计算矩阵。
- 根据
textData.characterStyleIDs和styleOverrideTable找到 glyph 对应 paint。 - 将相同 paint 的 glyph path 合并成一个
<path>。
关键转换:
const scale: SvgMatrix = [fontSize, 0, 0, -fontSize, 0, 0]
原因是 glyph blob 使用字体轮廓坐标,Y 轴向上;SVG 本地空间 Y 轴向下,所以要做垂直翻转。
11. 渐变与效果
11.1 线性渐变
入口:paintToSvgFill 中的 GRADIENT_LINEAR 分支。
逻辑:
- Figma 的 paint transform 是从对象空间回到单位渐变空间。
- SVG 需要的是 user space 中的
x1/y1/x2/y2。 - 所以代码会先对 transform 求逆,再投影到节点 box。
11.2 径向渐变
入口:GRADIENT_RADIAL 分支。
逻辑:
- 使用
radialGradient。 - 通过
gradientTransform把单位圆投影到节点空间。
11.3 阴影与模糊
入口:createNodeEffectFilter 和 createFilter
支持的主要 effect:
DROP_SHADOWINNER_SHADOWFOREGROUND_BLURLAYER_BLUR
SVG 导出时会生成 <filter>,并放入 context.defs。
PNG 的 figma-like 模式对椭圆内阴影有额外处理:
- SVG 阶段记录
rasterHints。 - PNG 阶段在
export-node.ts中直接操作像素。 - 目的是补偿
resvg和 Figma 原生 PNG 渲染在内阴影边缘上的差异。
12. PNG 导出逻辑
入口:src/services/export-node.ts
流程:
renderNodeToSvg先生成 SVG。- 使用
new Resvg(rendered.svg).render()栅格化。 - 如果是普通预览,直接
image.asPng()。 - 如果是
figma-like,先拿 RGBA pixels,再应用 raster hints。 - 使用项目内的 PNG encoder 写回 PNG。
当前导出能力说明:
- SVG:本地结构化输出。
- PNG:本地 SVG 经过
@resvg/resvg-js栅格化。 - Figma-like PNG:额外补偿部分滤镜和内阴影。
- Figma native PNG:当前不支持,因为没有调用 Figma 官方渲染器。
13. 当前实现边界
当前项目已经支持:
- 本地
.fig和.fig.json读取。 - 节点列表、节点上下文、设计上下文。
- 子树序列化。
- 颜色、渐变、阴影、描边宽度 token 推导。
- SVG 导出。
- PNG 预览导出。
- 图片填充。
- 文本 glyph path 渲染。
- mask 到 clipPath 的转换。
- 线性和径向渐变。
- 虚线描边。
- 部分 filter 和 Figma-like PNG 补偿。
主要限制:
- 不解析 Figma 官方变量和样式 registry。
- 不保证 PNG 与 Figma native export 像素级一致。
getDesignContext里的节点查找是精确名称或精确 node-id。.fig.json不包含 zip 外层图片资源时,IMAGE填充无法恢复真实图片。- 复杂 blend mode、组件实例覆盖、变量绑定、auto layout 语义等还没有完整建模。
14. 源码入口索引
| 功能 | 文件 | 关键函数 |
|---|---|---|
| 文件读取 | src/services/fig-file.ts |
loadFigFile |
.fig 解码 |
src/services/fig2json.ts |
figToJson |
| 类型定义 | src/services/fig-types.ts |
FigJson、FigNode、FigPaint |
| 节点 id 处理 | src/utils/node-id.ts |
keyForGuid、normalizeNodeId |
| 父子关系组装 | src/services/fig-node-svg.ts |
getChildrenByParent |
| 目标节点查找 | src/services/fig-node-svg.ts |
findTargetNode |
| SVG 渲染 | src/services/fig-node-svg.ts |
renderNodeToSvg、renderNodeSubtree |
| 路径解析 | src/services/fig-node-svg.ts |
parsePathBlob |
| 图片资源读取 | src/services/fig-images.ts |
loadFigImageAssets |
| 设计上下文 | src/services/design-context.ts |
getDesignContext、getCodeContext |
| token 推导 | src/services/design-context.ts |
collectDesignTokens |
| 导出文件 | src/services/export-node.ts |
exportFigNode |
| MCP 工具注册 | src/mcp/index.ts |
createServer |
15. 简化伪代码
const figJson = loadFigFile(filePath)
const imageAssets = loadFigImageAssets(filePath)
const childrenByParent = getChildrenByParent(figJson)
const target = findTargetNode(figJson, { nodeQuery })
const svg = renderNodeToSvg(figJson, {
nodeQuery,
imageAssets,
scale,
pngFigmaLike
})
if (format === "png") {
const image = new Resvg(svg).render()
writePng(image)
} else {
writeSvg(svg)
}
这就是当前项目对 .fig 的核心处理方式:先把 Figma 本地文件解成扁平数据,再用 guid 关系重建节点树,最后把节点、几何 blob、paint、图片资源和 effect 递归组合成可导出的 SVG/PNG。