From 6c2e396ed3f3c5734361ddf30f7c73385b222268 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 5 Jun 2026 10:55:45 +0800 Subject: [PATCH] feat: support Figma Make files --- FIG_DATA_STRUCTURE_ANALYSIS.md | 637 +++++++++++++++++++++++++++++++++ README.md | 31 +- src/index.ts | 1 + src/mcp/index.ts | 31 ++ src/services/fig-make.ts | 344 ++++++++++++++++++ src/services/fig-node-svg.ts | 43 +++ src/services/fig-types.ts | 23 ++ src/services/fig2json.ts | 11 +- 8 files changed, 1113 insertions(+), 8 deletions(-) create mode 100644 FIG_DATA_STRUCTURE_ANALYSIS.md create mode 100644 src/services/fig-make.ts diff --git a/FIG_DATA_STRUCTURE_ANALYSIS.md b/FIG_DATA_STRUCTURE_ANALYSIS.md new file mode 100644 index 0000000..5ef1fd8 --- /dev/null +++ b/FIG_DATA_STRUCTURE_ANALYSIS.md @@ -0,0 +1,637 @@ +# .fig 数据结构与组装逻辑分析 + +本文档基于当前项目源码,说明本地 `.fig` 文件如何被解析成可查询的设计上下文,以及如何进一步组装成 SVG/PNG 导出结果。 + +## 1. 总体链路 + +```mermaid +flowchart TD + A["本地 .fig / .fig.json"] --> B["loadFigFile"] + B --> C["FigJson"] + C --> D["nodeChanges 扁平节点数组"] + C --> E["blobs 几何路径数据"] + A --> F["images/ 图片资源"] + 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/`,不是来自 `FigJson.blobs`。 + +## 2. 文件读取与解码 + +入口:`src/services/fig-file.ts` + +```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` 元信息: + +```ts +{ + __figmaToJson: { + schema: string, + delimiter: number + } +} +``` + +## 3. 核心数据结构 + +类型定义入口:`src/services/fig-types.ts` + +### 3.1 FigJson + +```ts +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 + +```ts +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 + +```ts +export type Guid = { + sessionID: number + localID: number +} +``` + +项目内部将它标准化为: + +```ts +`${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 + +```ts +export type FigGeometry = { + commandsBlob: number + windingRule?: string + styleID?: number +} +``` + +`commandsBlob` 是 `FigJson.blobs` 的索引。例如: + +```ts +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 + +```ts +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.hash` 或 `imageThumbnail.hash` 去匹配 `.fig` zip 外层的 `images/`。 + +## 4. 节点树组装逻辑 + +组装入口:`src/services/fig-node-svg.ts` + +```ts +export function getChildrenByParent(figJson: FigJson): Map { + const childrenByParent = new Map() + 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 +} +``` + +组装结果是: + +```ts +Map +``` + +节点自身 id: + +```ts +keyForGuid(node.guid) +``` + +父节点 id: + +```ts +keyForGuid(node.parentIndex.guid) +``` + +子节点排序: + +```ts +function getSortedChildren(context: RenderContext, node: FigNode): FigNode[] { + return [...(context.childrenByParent.get(keyForGuid(node.guid)) ?? [])].sort((a, b) => + comparePosition(a.parentIndex?.position ?? "", b.parentIndex?.position ?? "") + ) +} +``` + +排序使用字符串原始比较: + +```ts +if (left === right) return 0 +return left < right ? -1 : 1 +``` + +原因是 `parentIndex.position` 是 fractional-index 字符串,使用 locale-aware 排序可能会重排标点,从而改变 Figma 原始 z-order。 + +## 5. 目标节点查找 + +入口:`findTargetNode` + +查找逻辑: + +1. 先尝试从 `nodeId` 或 `nodeQuery` 中解析标准 node-id。 +2. 如果能解析成 `sessionID:localID`,按 `guid` 查找。 +3. 如果不能解析,则按节点名称精确匹配。 + +```ts +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。 + +输出结构: + +```ts +{ + 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. 拼接 ``、``、背景和 body。 + +### 7.1 RenderContext + +```ts +type RenderContext = { + figJson: FigJson + childrenByParent: Map + 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 + +递归渲染顺序: + +1. 跳过 `visible === false` 的节点。 +2. 合并父级矩阵和当前节点 transform。 +3. 渲染 fill geometry。 +4. 渲染文本 glyph。 +5. 收集 Figma-like PNG raster hint。 +6. 渲染 stroke。 +7. 递归渲染 children。 +8. 给当前节点包一层 ``,附加 opacity 和 filter。 + +特殊处理: + +- `BOOLEAN_OPERATION` 如果已经有计算后的几何路径,就不再渲染源 children。 +- mask 节点会转成 `clipPath`,后续 sibling 会被 clip。 +- effect 作为节点级 filter 包在 `` 上,而不是只包单个 path。 + +### 7.3 transform 组装 + +Figma 矩阵: + +```ts +{ + m00, m01, m02, + m10, m11, m12 +} +``` + +SVG matrix: + +```ts +[m00, m10, m01, m11, m02, m12] +``` + +转换入口: + +```ts +function toSvgMatrix(matrix?: FigmaMatrix): SvgMatrix +``` + +递归时通过矩阵乘法累积: + +```ts +const matrix = multiply(parentMatrix, localMatrix) +``` + +这样子节点最终会进入根节点坐标空间。 + +## 8. 几何路径渲染 + +几何渲染入口:`renderGeometry` + +流程: + +1. 读取 `fillGeometry` 或 `strokeGeometry`。 +2. 用 `commandsBlob` 找到 `figJson.blobs[index]`。 +3. `parsePathBlob` 转成 SVG path `d`。 +4. 计算 bounds。 +5. 根据 paint 类型输出对应 SVG。 + +普通填充: + +```svg + +``` + +奇偶填充规则: + +```svg +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. 存成: + +```ts +Map +``` + +图片渲染入口:`renderImageFill` + +流程: + +1. 从 `paint.image.hash` 或 `paint.imageThumbnail.hash` 得到 hash。 +2. 在 `imageAssets` 中查找 data URL。 +3. 用当前矢量路径创建 `clipPath`。 +4. 输出一个单位尺寸 ``。 +5. 用 `paint.transform` 和节点尺寸计算 image matrix。 +6. 用 wrapper `` 裁切图片。 + +这样可以把 Figma 的图片裁切、填充和矢量路径边界转成 SVG 近似表达。 + +## 10. 文本渲染 + +文本节点不直接输出 ``,而是使用 glyph path。 + +入口:`renderTextNode` + +流程: + +1. 判断 `node.type === "TEXT"`。 +2. 读取 `derivedTextData.glyphs`。 +3. 每个 glyph 通过 `commandsBlob` 解析出路径。 +4. 根据 glyph 的 position、fontSize、rotation 计算矩阵。 +5. 根据 `textData.characterStyleIDs` 和 `styleOverrideTable` 找到 glyph 对应 paint。 +6. 将相同 paint 的 glyph path 合并成一个 ``。 + +关键转换: + +```ts +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_SHADOW` +- `INNER_SHADOW` +- `FOREGROUND_BLUR` +- `LAYER_BLUR` + +SVG 导出时会生成 ``,并放入 `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` | `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. 简化伪代码 + +```ts +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。 diff --git a/README.md b/README.md index 6319965..f7f01e3 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@ # Figma Local Context MCP -一个本地 `.fig` 文件 MCP Server。它结合了当前项目里的 `.fig` 解码、节点 SVG/PNG 导出能力,以及 `GLips/Figma-Context-MCP` 的 MCP 工具注册方式。 +一个本地 `.fig` / Figma Make `.make` 文件 MCP Server。它结合了当前项目里的 `.fig` 解码、节点 SVG/PNG 导出能力,以及 `GLips/Figma-Context-MCP` 的 MCP 工具注册方式。 -这个项目不依赖 Figma REST API,也不需要 `FIGMA_API_KEY`。MCP 客户端只需要传入本机 `.fig` 或 `.fig.json` 路径即可。 +这个项目不依赖 Figma REST API,也不需要 `FIGMA_API_KEY`。MCP 客户端只需要传入本机 `.fig`、`.make` 或 `.fig.json` 路径即可。 ## 工具 - `get_design_context`:官方 Figma MCP 风格的主入口,返回节点树、样式、tokens 和代码提示。 - `get_code_context`:返回更适合代码生成的布局、样式和导出资源提示。 +- `get_make_context`:读取 Figma Make `.make` 文件,返回包结构、源码文件、代码组件/实例、meta 和 AI 对话摘要。 - `export_assets`:批量导出多个节点为 SVG 或 PNG。 - `list_fig_nodes`:按名称、类型或 node-id 搜索节点。 - `get_design_tokens`:从全文件或指定节点子树中推导颜色、渐变、阴影、描边 token。 -- `inspect_fig_file`:读取本地 `.fig`,返回节点数量、类型统计和节点概览。 +- `inspect_fig_file`:读取本地 `.fig` / `.make`,返回节点数量、类型统计和节点概览。 - `get_fig_node`:按节点名、`1234:5678`、`1234-5678`、`node-id=1234-5678` 或完整 Figma 链接查找节点,并返回简化上下文。 - `export_fig_node`:把指定节点导出为 SVG 或 PNG,PNG 支持倍率,默认走本地 `figma-like` 渲染。它会在本 MCP 生成的 SVG 上对部分滤镜/内阴影做补偿后再用 `@resvg/resvg-js` 栅格化,目标是接近 Figma 在线端 PNG,但不等同于 Figma 原生 PNG 导出。 @@ -39,6 +40,28 @@ } ``` +获取 Figma Make 上下文和源码清单: + +```json +{ + "filePath": "C:\\Users\\you\\Designs\\prototype.make", + "includeSource": false, + "includeAiChat": true, + "maxMessages": 20 +} +``` + +按文件名读取 Make 里的源码内容: + +```json +{ + "filePath": "C:\\Users\\you\\Designs\\prototype.make", + "fileQuery": "App.tsx", + "includeSource": true, + "sourceMaxLength": 50000 +} +``` + 搜索节点: ```json @@ -108,7 +131,7 @@ - `local-figma-like-resvg`:默认 PNG 管线。先生成带 Figma-like 滤镜补偿的本地 SVG,再用 `@resvg/resvg-js` 转 PNG。 - `local-svg-resvg`:普通预览 PNG 管线。先生成本地 SVG,再用 `@resvg/resvg-js` 转 PNG。Figma 在线端 PNG 使用自身渲染管线,不能假定它是在线 SVG 再转 PNG。 -当前本地 SVG/PNG 管线支持 `.fig` 外层 zip 中的 `images/` 图片资源,会把 `IMAGE` 填充按真实图片、paint transform 和矢量路径裁切导出;如果本地文件缺少对应图片资源,会明确报出缺失的 IMAGE hash。线性/径向渐变、虚线描边和部分滤镜也会转换为 SVG 近似表达。 +当前本地 SVG/PNG 管线支持 `.fig` / `.make` 外层 zip 中的 `images/` 图片资源,会把 `IMAGE` 填充按真实图片、paint transform 和矢量路径裁切导出;如果本地文件缺少对应图片资源,会明确报出缺失的 IMAGE hash。Figma Make 的 `CODE_INSTANCE.codeSnapshot` 也会作为预览图片参与导出。线性/径向渐变、虚线描边和部分滤镜也会转换为 SVG 近似表达。 导出结果还会包含 `exportCapabilities`: diff --git a/src/index.ts b/src/index.ts index 1d1bdf9..05d0333 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,3 +10,4 @@ export { getDesignTokens, listFigNodes } from "./services/design-context.js" +export { getMakeContext } from "./services/fig-make.js" diff --git a/src/mcp/index.ts b/src/mcp/index.ts index c125a5b..beeb62b 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -9,6 +9,7 @@ import { listFigNodes } from "../services/design-context.js" import { getFigNodeContext, inspectFigFile } from "../services/fig-file.js" +import { getMakeContext } from "../services/fig-make.js" const serverInfo = { name: "Figma Local Context MCP", @@ -75,6 +76,15 @@ const codeContextParams = z.object({ depth: z.number().int().min(0).max(8).default(2).describe("返回代码提示子树的深度。") }) +const makeContextParams = z.object({ + filePath: z.string().min(1).describe("本地 Figma Make .make、.fig 或 .fig.json 文件路径。"), + includeSource: z.boolean().default(false).describe("是否在结果中包含 CODE_FILE 源码内容。"), + sourceMaxLength: z.number().int().min(0).max(200000).default(20000).describe("每个源码文件最多返回多少字符。"), + fileQuery: z.string().optional().describe("按源码文件名、路径、语言或 node-id 过滤 CODE_FILE。"), + includeAiChat: z.boolean().default(true).describe("是否返回 ai_chat.json 的线程和消息摘要。"), + maxMessages: z.number().int().min(0).max(200).default(20).describe("每个 AI 线程最多返回多少条消息摘要。") +}) + const exportAssetsParams = z.object({ filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"), nodeQueries: z @@ -103,6 +113,7 @@ type ExportNodeParams = z.infer type ListNodesParams = z.infer type DesignContextParams = z.infer type CodeContextParams = z.infer +type MakeContextParams = z.infer type ExportAssetsParams = z.infer type DesignTokensParams = z.infer @@ -166,6 +177,26 @@ export function createServer(): McpServer { ) ) + server.registerTool( + "get_make_context", + { + title: "Get Figma Make context", + description: "读取 Figma Make .make 文件,返回包结构、源码文件、代码组件/实例、meta 和 AI 对话摘要。", + inputSchema: makeContextParams, + annotations: { readOnlyHint: true } + }, + async (params: MakeContextParams) => + jsonResponse( + getMakeContext(params.filePath, { + includeSource: params.includeSource, + sourceMaxLength: params.sourceMaxLength, + fileQuery: params.fileQuery, + includeAiChat: params.includeAiChat, + maxMessages: params.maxMessages + }) + ) + ) + server.registerTool( "export_assets", { diff --git a/src/services/fig-make.ts b/src/services/fig-make.ts new file mode 100644 index 0000000..2ad6d15 --- /dev/null +++ b/src/services/fig-make.ts @@ -0,0 +1,344 @@ +import fs from "node:fs" +import path from "node:path" +import UzipModule from "uzip" +import type { FigJson, FigNode, FigPaint, Guid } from "./fig-types.js" +import { loadFigFile, summarizeNode } from "./fig-file.js" +import { getChildrenByParent } from "./fig-node-svg.js" +import { keyForGuid } from "../utils/node-id.js" + +const UZIP = (UzipModule as any).default ?? UzipModule + +type ZipEntries = Record + +type RawAiChat = { + threads?: RawAiThread[] +} + +type RawAiThread = { + id?: string + threadType?: string + title?: string | null + createdAt?: string + updatedAt?: string + messages?: RawAiMessage[] +} + +type RawAiMessage = { + id?: string + index?: number + role?: string + createdAt?: string + updatedAt?: string + parts?: RawAiPart[] +} + +type RawAiPart = { + partType?: string + contentJson?: string + blobstoreContentKey?: string +} + +export type MakeContextOptions = { + includeSource?: boolean + sourceMaxLength?: number + fileQuery?: string + includeAiChat?: boolean + maxMessages?: number +} + +export function getMakeContext(filePath: string, options: MakeContextOptions = {}) { + const absolutePath = path.resolve(filePath) + const figJson = loadFigFile(absolutePath) + const entries = tryLoadZipEntries(absolutePath) + const meta = readJsonEntry(entries, "meta.json") + const aiChat = readJsonEntry(entries, "ai_chat.json") + const childrenByParent = getChildrenByParent(figJson) + const nodes = figJson.nodeChanges ?? [] + const allCodeFiles = collectCodeFiles(figJson, { includeSource: false }) + const codeFiles = collectCodeFiles(figJson, options) + const codeComponents = collectCodeComponents(figJson, allCodeFiles) + const codeInstances = collectCodeInstances(figJson, codeComponents) + const canvasMagic = getCanvasMagic(entries) + const isMakeFile = + path.extname(absolutePath).toLowerCase() === ".make" || + canvasMagic === "fig-make" || + Boolean(entries?.["ai_chat.json"]) || + nodes.some((node) => node.type?.startsWith("CODE_") || node.type === "RESPONSIVE_SET") + + return { + kind: "figma-make-context", + filePath: absolutePath, + isMakeFile, + canvasMagic, + entries: summarizeEntries(entries), + meta, + document: { + nodeCount: nodes.length, + blobCount: figJson.blobs?.length ?? 0, + topTypes: getTopTypes(figJson), + root: summarizeRootNode(figJson, childrenByParent) + }, + code: { + fileCount: nodes.filter((node) => node.type === "CODE_FILE").length, + componentCount: codeComponents.length, + instanceCount: codeInstances.length, + files: codeFiles, + components: codeComponents, + instances: codeInstances + }, + aiChat: options.includeAiChat === false ? undefined : summarizeAiChat(aiChat, options.maxMessages ?? 20) + } +} + +function collectCodeFiles(figJson: FigJson, options: MakeContextOptions) { + const query = options.fileQuery?.trim().toLowerCase() + const includeSource = options.includeSource ?? false + const sourceMaxLength = Math.max(0, options.sourceMaxLength ?? 20000) + + return (figJson.nodeChanges ?? []) + .filter((node) => node.type === "CODE_FILE") + .map((node) => { + const id = keyForGuid(node.guid) + const filePath = node.codeFilePath ?? node.name + const sourceCode = node.sourceCode ?? "" + return { + id, + name: node.name, + path: filePath, + language: inferLanguage(filePath) ?? inferLanguage(node.name), + sourceLength: sourceCode.length, + importedCodeFileCount: node.importedCodeFiles?.length ?? 0, + ...includeSourcePreview(sourceCode, includeSource, sourceMaxLength) + } + }) + .filter((file) => { + if (!query) return true + return [file.id, file.name, file.path, file.language].some((value) => value?.toLowerCase().includes(query)) + }) +} + +function collectCodeComponents(figJson: FigJson, codeFiles: Array<{ id: string; name?: string; path?: string }>) { + const codeFileById = new Map(codeFiles.map((file) => [file.id, file])) + + return (figJson.nodeChanges ?? []) + .filter((node) => node.type === "CODE_COMPONENT") + .map((node) => { + const exportedFromCodeFileId = keyForReference(node.exportedFromCodeFileId) + const codeFile = codeFileById.get(exportedFromCodeFileId) + return { + id: keyForGuid(node.guid), + name: node.name, + exportedFromCodeFileId, + exportedFromCodeFileName: codeFile?.name, + exportedFromCodeFilePath: codeFile?.path, + codeExportName: node.codeExportName, + propCount: node.componentPropDefs?.length ?? 0 + } + }) +} + +function collectCodeInstances(figJson: FigJson, codeComponents: Array<{ id: string; name?: string }>) { + const componentById = new Map(codeComponents.map((component) => [component.id, component])) + + return (figJson.nodeChanges ?? []) + .filter((node) => node.type === "CODE_INSTANCE") + .map((node) => { + const backingCodeComponentId = keyForReference(node.backingCodeComponentId) + const component = componentById.get(backingCodeComponentId) + return { + id: keyForGuid(node.guid), + name: node.name, + codeFilePath: node.codeFilePath, + backingCodeComponentId, + backingCodeComponentName: component?.name, + snapshotState: node.codeSnapshot?.state, + snapshotLayoutSize: node.codeSnapshot?.layoutSize, + snapshotCanvasSize: node.codeSnapshot?.canvasSize, + snapshotDevicePixelRatio: node.codeSnapshot?.devicePixelRatio, + previewImageHashes: getPaintImageHashes(node.codeSnapshot?.paints) + } + }) +} + +function summarizeAiChat(aiChat: RawAiChat | undefined, maxMessages: number) { + const threads = aiChat?.threads ?? [] + return { + threadCount: threads.length, + messageCount: threads.reduce((count, thread) => count + (thread.messages?.length ?? 0), 0), + threads: threads.map((thread) => ({ + id: thread.id, + threadType: thread.threadType, + title: thread.title, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + messageCount: thread.messages?.length ?? 0, + messages: thread.messages?.slice(0, maxMessages).map(summarizeAiMessage) ?? [] + })) + } +} + +function summarizeAiMessage(message: RawAiMessage) { + const parts = message.parts ?? [] + return { + id: message.id, + index: message.index, + role: message.role, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + partTypes: parts.map((part) => part.partType).filter(Boolean), + preview: getMessagePreview(parts) + } +} + +function getMessagePreview(parts: RawAiPart[]): string | undefined { + for (const part of parts) { + const content = parseJson(part.contentJson) + const text = pickString(content, "text") ?? pickString(content, "title") ?? pickString(content, "resultJson") + if (text) return text.length > 300 ? `${text.slice(0, 300)}...` : text + } + + return undefined +} + +function includeSourcePreview(sourceCode: string, includeSource: boolean, sourceMaxLength: number) { + if (!includeSource) return {} + + const truncated = sourceCode.length > sourceMaxLength + return { + sourceCode: truncated ? sourceCode.slice(0, sourceMaxLength) : sourceCode, + sourceTruncated: truncated + } +} + +function summarizeRootNode(figJson: FigJson, childrenByParent: Map) { + const root = + figJson.nodeChanges?.find((node) => node.type === "DOCUMENT") ?? + figJson.nodeChanges?.find((node) => !node.parentIndex?.guid) ?? + figJson.nodeChanges?.[0] + + return root ? summarizeNode(root, childrenByParent) : undefined +} + +function summarizeEntries(entries: ZipEntries | null) { + if (!entries) return undefined + + const names = Object.keys(entries) + return { + count: names.length, + hasCanvas: Boolean(entries["canvas.fig"]), + hasMeta: Boolean(entries["meta.json"]), + hasAiChat: Boolean(entries["ai_chat.json"]), + hasThumbnail: Boolean(entries["thumbnail.png"]), + imageCount: names.filter((name) => name.startsWith("images/") && !name.endsWith("/")).length, + blobStoreCount: names.filter((name) => name.startsWith("blob_store/") && !name.endsWith("/")).length, + names: names.map((name) => ({ name, size: entries[name]?.length ?? 0 })) + } +} + +function getTopTypes(figJson: FigJson): Record { + const topTypes: Record = {} + for (const node of figJson.nodeChanges ?? []) { + const type = node.type ?? "UNKNOWN" + topTypes[type] = (topTypes[type] ?? 0) + 1 + } + + return Object.fromEntries(Object.entries(topTypes).sort((a, b) => b[1] - a[1])) +} + +function getPaintImageHashes(paints: FigPaint[] | undefined): string[] { + if (!Array.isArray(paints)) return [] + + const hashes = new Set() + for (const paint of paints) { + const imageHash = hashToHex(paint.image?.hash) + const thumbnailHash = hashToHex(paint.imageThumbnail?.hash) + if (imageHash) hashes.add(imageHash) + if (thumbnailHash) hashes.add(thumbnailHash) + } + + return [...hashes] +} + +function keyForReference(reference: { guid: Guid } | undefined): string { + return keyForGuid(reference?.guid) +} + +function inferLanguage(filePath: string | undefined): string | undefined { + if (!filePath) return undefined + + const extension = path.extname(filePath).toLowerCase() + const languages: Record = { + ".css": "css", + ".html": "html", + ".js": "javascript", + ".jsx": "javascriptreact", + ".json": "json", + ".md": "markdown", + ".ts": "typescript", + ".tsx": "typescriptreact" + } + + return languages[extension] ?? (extension.replace(/^\./, "") || undefined) +} + +function readJsonEntry(entries: ZipEntries | null, entryName: string): T | undefined { + const bytes = entries?.[entryName] + if (!bytes) return undefined + + return parseJson(Buffer.from(bytes).toString("utf8")) as T | undefined +} + +function parseJson(value: string | undefined): unknown { + if (!value) return undefined + + try { + return JSON.parse(value) + } catch { + return undefined + } +} + +function pickString(value: unknown, key: string): string | undefined { + if (!value || typeof value !== "object" || !(key in value)) return undefined + + const next = (value as Record)[key] + return typeof next === "string" ? next : undefined +} + +function getCanvasMagic(entries: ZipEntries | null): string | undefined { + const canvas = entries?.["canvas.fig"] + if (!canvas || canvas.length < 8) return undefined + + return Buffer.from(canvas.subarray(0, 8)).toString("utf8") +} + +function tryLoadZipEntries(filePath: string): ZipEntries | null { + const bytes = fs.readFileSync(filePath) + if (!isZipFile(bytes)) return null + + return UZIP.parse(toArrayBuffer(bytes)) as ZipEntries +} + +function isZipFile(bytes: Uint8Array): boolean { + return bytes[0] === 0x50 && bytes[1] === 0x4b +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer +} + +function hashToHex(hash: Uint8Array | number[] | string | Record | undefined): string | null { + if (!hash) return null + if (typeof hash === "string") return hash.toLowerCase() + + const values = + hash instanceof Uint8Array + ? [...hash] + : Array.isArray(hash) + ? hash + : Object.keys(hash) + .sort((left, right) => Number(left) - Number(right)) + .map((key) => hash[key]) + + return values.map((value) => value.toString(16).padStart(2, "0")).join("") +} diff --git a/src/services/fig-node-svg.ts b/src/services/fig-node-svg.ts index b7e2d9b..bc29fb6 100644 --- a/src/services/fig-node-svg.ts +++ b/src/services/fig-node-svg.ts @@ -169,6 +169,7 @@ function renderNodeSubtree( node.type !== "BOOLEAN_OPERATION" || !(node.fillGeometry?.length || node.strokeGeometry?.length) const nodeContent = [ ...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix), + renderCodeSnapshot(context, node, matrix), renderTextNode(context, node, matrix), collectFigmaLikeEllipseInnerShadowHint(context, node, matrix), ...renderStrokeGeometry(context, node, matrix), @@ -610,6 +611,48 @@ function renderGeometry( }) } +function renderCodeSnapshot(context: RenderContext, node: FigNode, matrix: SvgMatrix): string { + const snapshot = node.type === "CODE_INSTANCE" ? node.codeSnapshot : undefined + const paints = snapshot?.paints?.filter((paint) => paint.visible !== false) + const size = snapshot?.layoutSize ?? snapshot?.canvasSize ?? node.size + if (!paints?.length || !size || size.x <= 0 || size.y <= 0) return "" + + const offset = snapshot?.offset ?? { x: 0, y: 0 } + const bounds = { + minX: offset.x, + minY: offset.y, + maxX: offset.x + size.x, + maxY: offset.y + size.y + } + const parsed = { + d: createRectPath(bounds), + bounds + } + + includeBounds(context, transformBounds(matrix, bounds)) + + // Figma Make stores rendered code components as CODE_INSTANCE.codeSnapshot + // image paints instead of normal fillGeometry. Treat the snapshot as a local + // rectangular paint source so Make prototypes export with their preview. + return paints + .map((paint) => { + if (paint.type === "IMAGE") { + return renderImageFill(context, node, parsed, paint, matrix, "") + } + + const fill = paintToSvgFill(context, node, parsed.bounds, paint, matrix) + const opacity = paintOpacityAttribute("fill", paint) + return `` + }) + .join("") +} + +function createRectPath(bounds: Bounds): string { + return `M ${format(bounds.minX)} ${format(bounds.minY)} L ${format(bounds.maxX)} ${format( + bounds.minY + )} L ${format(bounds.maxX)} ${format(bounds.maxY)} L ${format(bounds.minX)} ${format(bounds.maxY)} Z` +} + function renderImageFill( context: RenderContext, node: FigNode, diff --git a/src/services/fig-types.ts b/src/services/fig-types.ts index 3986ddd..5c6cacc 100644 --- a/src/services/fig-types.ts +++ b/src/services/fig-types.ts @@ -39,6 +39,19 @@ export type FigPaint = { originalImageHeight?: number } +export type FigNodeReference = { + guid: Guid +} + +export type FigCodeSnapshot = { + state?: string + paints?: FigPaint[] + offset?: { x: number; y: number } + layoutSize?: { x: number; y: number } + canvasSize?: { x: number; y: number } + devicePixelRatio?: number +} + export type FigTextGlyph = { commandsBlob: number position?: { x: number; y: number } @@ -116,6 +129,16 @@ export type FigNode = { fontSize?: number textData?: FigTextData derivedTextData?: FigDerivedTextData + sourceCode?: string + codeFilePath?: string + importedCodeFiles?: unknown[] + belongsToCodeLibraryId?: FigNodeReference + exportedFromCodeFileId?: FigNodeReference + codeExportName?: string + backingCodeComponentId?: FigNodeReference + codeSnapshot?: FigCodeSnapshot + responsiveSetSettings?: unknown + componentPropDefs?: unknown[] } export type FigJson = { diff --git a/src/services/fig2json.ts b/src/services/fig2json.ts index a69fc6d..2e00c83 100644 --- a/src/services/fig2json.ts +++ b/src/services/fig2json.ts @@ -35,11 +35,11 @@ export function figToJson(fileBuffer: Uint8Array | ArrayBuffer): FigJson { function figToBinaryParts(fileBuffer: Uint8Array | ArrayBuffer): FigmaBinaryParts { let fileByte: Uint8Array = fileBuffer instanceof Uint8Array ? fileBuffer : new Uint8Array(fileBuffer) - if (!isKiwiFile(fileByte)) { + if (!isCanvasFile(fileByte)) { const unzipped = UZIP.parse(toArrayBuffer(fileByte)) const canvas = unzipped["canvas.fig"] if (!canvas) { - throw new Error("未找到 canvas.fig,文件可能不是有效的 .fig") + throw new Error("未找到 canvas.fig,文件可能不是有效的 .fig 或 .make") } fileByte = new Uint8Array(canvas.buffer, canvas.byteOffset, canvas.byteLength) } @@ -83,8 +83,11 @@ function assertFigmaParts(parts: Uint8Array[]): void { } } -function isKiwiFile(bytes: Uint8Array): boolean { - return bytes.length >= 8 && String.fromCharCode(...bytes.slice(0, 8)) === "fig-kiwi" +function isCanvasFile(bytes: Uint8Array): boolean { + if (bytes.length < 8) return false + + const magic = String.fromCharCode(...bytes.slice(0, 8)) + return magic === "fig-kiwi" || magic === "fig-make" } function startsWith(bytes: Uint8Array, magic: number[]): boolean {