figma-local-context-mcp/FIG_DATA_STRUCTURE_ANALYSIS.md

16 KiB
Raw Blame History

.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 扁平数组。
  • 节点树通过 guidparentIndex.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

主要步骤:

  1. 判断输入是否已经是 fig-kiwi 格式。
  2. 如果不是 fig-kiwi,按 zip 解析并取出 canvas.fig
  3. canvas.fig 里切分二进制 parts。
  4. 对 part 做解压:
    • zstd magic 开头:使用 fzstd 解压。
    • PNG magic 开头:保留。
    • 其他内容:使用 UZIP.inflateRaw 解压。
  5. 第一个 part 是 schema第二个 part 是 data。
  6. 使用 kiwi-schema 解码 schema再 decode data。
  7. 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:节点类型,例如 DOCUMENTFRAMETEXTVECTORELLIPSE
  • 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:456123-456node-id=123-456 和完整 Figma 链接。

3.4 Geometry 与 Blob

export type FigGeometry = {
  commandsBlob: number
  windingRule?: string
  styleID?: number
}

commandsBlobFigJson.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.tsparsePathBlob

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 渲染重点支持:

  • SOLID
  • GRADIENT_LINEAR
  • GRADIENT_RADIAL
  • IMAGE

IMAGE paint 会通过 image.hashimageThumbnail.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

查找逻辑:

  1. 先尝试从 nodeIdnodeQuery 中解析标准 node-id。
  2. 如果能解析成 sessionID:localID,按 guid 查找。
  3. 如果不能解析,则按节点名称精确匹配。
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

流程:

  1. loadFigFile 读取文件。
  2. getChildrenByParent 组装父子映射。
  3. 根据 nodeQuery 找目标节点;没有传则找根节点。
  4. serializeNode 输出节点摘要和子树。
  5. 可选输出 tokens。
  6. 可选输出 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

流程:

  1. 找目标节点。
  2. 重建 childrenByParent
  3. 初始化 RenderContext
  4. 调用 renderNodeSubtree 递归生成 SVG body。
  5. 根据节点尺寸、实际渲染 bounds 和 effect bounds 计算导出 viewBox。
  6. 拼接 <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 定义。
  • rasterHintsPNG 阶段需要额外像素补偿的信息。
  • imageAssets:图片 hash 到 data URL 的映射。
  • bounds:普通可见内容范围。
  • effectBounds:阴影、模糊等效果扩展范围。

7.2 renderNodeSubtree

递归渲染顺序:

  1. 跳过 visible === false 的节点。
  2. 合并父级矩阵和当前节点 transform。
  3. 渲染 fill geometry。
  4. 渲染文本 glyph。
  5. 收集 Figma-like PNG raster hint。
  6. 渲染 stroke。
  7. 递归渲染 children。
  8. 给当前节点包一层 <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

流程:

  1. 读取 fillGeometrystrokeGeometry
  2. commandsBlob 找到 figJson.blobs[index]
  3. parsePathBlob 转成 SVG path d
  4. 计算 bounds。
  5. 根据 paint 类型输出对应 SVG。

普通填充:

<path d="..." fill="..." />

奇偶填充规则:

fill-rule="evenodd"

描边优先使用 SVG stroke 属性,而不是直接填充 decoded strokeGeometry。原因是 Figma 的 decoded strokeGeometry 更像内部栅格使用的扩展轮廓,直接填充可能让细描边变粗。

9. 图片资源组装

图片读取入口:src/services/fig-images.ts

流程:

  1. 只对 .fig 生效,.json 返回空 Map。
  2. 判断文件是否是 zip。
  3. 解析 zip。
  4. 遍历 images/ 下的资源。
  5. 通过文件头识别 mime
    • PNG
    • JPEG
    • GIF
    • WEBP
  6. 存成:
Map<hash, dataUrl>

图片渲染入口:renderImageFill

流程:

  1. paint.image.hashpaint.imageThumbnail.hash 得到 hash。
  2. imageAssets 中查找 data URL。
  3. 用当前矢量路径创建 clipPath
  4. 输出一个单位尺寸 <image width="1" height="1">
  5. paint.transform 和节点尺寸计算 image matrix。
  6. 用 wrapper <g clip-path="..."> 裁切图片。

这样可以把 Figma 的图片裁切、填充和矢量路径边界转成 SVG 近似表达。

10. 文本渲染

文本节点不直接输出 <text>,而是使用 glyph path。

入口:renderTextNode

流程:

  1. 判断 node.type === "TEXT"
  2. 读取 derivedTextData.glyphs
  3. 每个 glyph 通过 commandsBlob 解析出路径。
  4. 根据 glyph 的 position、fontSize、rotation 计算矩阵。
  5. 根据 textData.characterStyleIDsstyleOverrideTable 找到 glyph 对应 paint。
  6. 将相同 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 阴影与模糊

入口:createNodeEffectFiltercreateFilter

支持的主要 effect

  • DROP_SHADOW
  • INNER_SHADOW
  • FOREGROUND_BLUR
  • LAYER_BLUR

SVG 导出时会生成 <filter>,并放入 context.defs

PNG 的 figma-like 模式对椭圆内阴影有额外处理:

  • SVG 阶段记录 rasterHints
  • PNG 阶段在 export-node.ts 中直接操作像素。
  • 目的是补偿 resvg 和 Figma 原生 PNG 渲染在内阴影边缘上的差异。

12. PNG 导出逻辑

入口:src/services/export-node.ts

流程:

  1. renderNodeToSvg 先生成 SVG。
  2. 使用 new Resvg(rendered.svg).render() 栅格化。
  3. 如果是普通预览,直接 image.asPng()
  4. 如果是 figma-like,先拿 RGBA pixels再应用 raster hints。
  5. 使用项目内的 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 FigJsonFigNodeFigPaint
节点 id 处理 src/utils/node-id.ts keyForGuidnormalizeNodeId
父子关系组装 src/services/fig-node-svg.ts getChildrenByParent
目标节点查找 src/services/fig-node-svg.ts findTargetNode
SVG 渲染 src/services/fig-node-svg.ts renderNodeToSvgrenderNodeSubtree
路径解析 src/services/fig-node-svg.ts parsePathBlob
图片资源读取 src/services/fig-images.ts loadFigImageAssets
设计上下文 src/services/design-context.ts getDesignContextgetCodeContext
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。