mirror of
https://github.com/KwiTsukasa/figma-local-context-mcp.git
synced 2026-05-27 16:45:46 +08:00
Initial commit
This commit is contained in:
commit
7c510c737c
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 sunlei
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
155
README.md
Normal file
155
README.md
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
# Figma Local Context MCP
|
||||||
|
|
||||||
|
一个本地 `.fig` 文件 MCP Server。它结合了当前项目里的 `.fig` 解码、节点 SVG/PNG 导出能力,以及 `GLips/Figma-Context-MCP` 的 MCP 工具注册方式。
|
||||||
|
|
||||||
|
这个项目不依赖 Figma REST API,也不需要 `FIGMA_API_KEY`。MCP 客户端只需要传入本机 `.fig` 或 `.fig.json` 路径即可。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
- `get_design_context`:官方 Figma MCP 风格的主入口,返回节点树、样式、tokens 和代码提示。
|
||||||
|
- `get_code_context`:返回更适合代码生成的布局、样式和导出资源提示。
|
||||||
|
- `export_assets`:批量导出多个节点为 SVG 或 PNG。
|
||||||
|
- `list_fig_nodes`:按名称、类型或 node-id 搜索节点。
|
||||||
|
- `get_design_tokens`:从全文件或指定节点子树中推导颜色、渐变、阴影、描边 token。
|
||||||
|
- `inspect_fig_file`:读取本地 `.fig`,返回节点数量、类型统计和节点概览。
|
||||||
|
- `get_fig_node`:按节点名、`2625:12945`、`2625-12945`、`node-id=2625-12945` 或完整 Figma 链接查找节点,并返回简化上下文。
|
||||||
|
- `export_fig_node`:把指定节点导出为 SVG 或 PNG,PNG 支持倍率。
|
||||||
|
|
||||||
|
## 示例参数
|
||||||
|
|
||||||
|
获取官方风格设计上下文:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
|
||||||
|
"nodeQuery": "node-id=2625-12945",
|
||||||
|
"depth": 2,
|
||||||
|
"includeTokens": true,
|
||||||
|
"includeCodeHints": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
获取代码生成上下文:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
|
||||||
|
"nodeQuery": "2625-12945",
|
||||||
|
"depth": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
搜索节点:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
|
||||||
|
"query": "Group 1321315187",
|
||||||
|
"limit": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
批量导出资源:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
|
||||||
|
"nodeQueries": ["2625-12945", "Group 1321315187"],
|
||||||
|
"outputDir": "D:\\MyFiles\\Download\\fig-export-debug\\assets",
|
||||||
|
"format": "png",
|
||||||
|
"scale": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
获取设计 token:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
|
||||||
|
"nodeQuery": "2625-12945"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
底层文件概览:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
|
||||||
|
"maxNodes": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
底层单节点查询:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
|
||||||
|
"nodeQuery": "node-id=2625-12945",
|
||||||
|
"depth": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
底层单节点导出:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filePath": "D:\\MyFiles\\Download\\兴泰安全生产预计平台-朱梓鑫.fig",
|
||||||
|
"nodeQuery": "2625-12945",
|
||||||
|
"outputPath": "D:\\MyFiles\\Download\\fig-export-debug\\mcp-export-node.png",
|
||||||
|
"format": "png",
|
||||||
|
"scale": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
本地调试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
MCP 客户端配置示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"Figma Local Context": {
|
||||||
|
"command": "cmd",
|
||||||
|
"args": ["/c", "pnpm", "--dir", "D:\\MyFiles\\Codes\\Node\\figma-local-context-mcp", "dev"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
构建后也可以使用:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"Figma Local Context": {
|
||||||
|
"command": "cmd",
|
||||||
|
"args": ["/c", "node", "D:\\MyFiles\\Codes\\Node\\figma-local-context-mcp\\dist\\bin.js", "--stdio"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 引用与致谢
|
||||||
|
|
||||||
|
本项目在实现过程中参考并复用了以下开源项目的思路或能力:
|
||||||
|
|
||||||
|
- [GLips/Figma-Context-MCP](https://github.com/GLips/Figma-Context-MCP):参考它的 MCP Server 组织方式、工具注册结构和官方 Figma MCP 风格的上下文输出思路。该仓库根目录提供 MIT License,`package.json` 也声明为 MIT。
|
||||||
|
- [yagudaev/figma-to-json](https://github.com/yagudaev/figma-to-json):参考它对 `.fig` 文件读写和解码的实现方向,并在本项目中继续扩展本地节点检索、SVG/PNG 导出和 MCP 上下文能力。该仓库根 `package.json` 声明 `license: MIT`;当前主分支没有单独的根 LICENSE 文件。
|
||||||
|
|
||||||
|
## 许可证兼容性
|
||||||
|
|
||||||
|
上面两个引用仓库均声明为 MIT 许可证。MIT 许可证允许使用、复制、修改、合并、发布、分发、再许可和销售软件副本,因此支持本项目的私有使用、商业使用、二次开发和发布。需要遵守的核心条件是:分发包含这些项目的代码或其重要部分时,应保留对应的版权声明和许可声明。
|
||||||
|
|
||||||
|
本项目同样使用 MIT License,见 [LICENSE](./LICENSE)。
|
||||||
59
package.json
Normal file
59
package.json
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "figma-local-context-mcp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Local MCP server for reading .fig files, inspecting nodes, and exporting SVG/PNG assets without the Figma API.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"bin": {
|
||||||
|
"figma-local-context-mcp": "dist/bin.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md",
|
||||||
|
"server.json"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup --dts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"start": "node dist/bin.js --stdio",
|
||||||
|
"dev": "tsx src/bin.ts --stdio",
|
||||||
|
"inspect": "pnpx @modelcontextprotocol/inspector"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.20.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
"keywords": [
|
||||||
|
"figma",
|
||||||
|
"mcp",
|
||||||
|
"fig",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/KwiTsukasa/figma-local-context-mcp.git"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
|
"cleye": "^2.2.1",
|
||||||
|
"fzstd": "^0.1.1",
|
||||||
|
"kiwi-schema": "^0.5.0",
|
||||||
|
"uzip": "^0.20201231.0",
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.3.3",
|
||||||
|
"@types/uzip": "^0.20201231.0",
|
||||||
|
"tsup": "^8.5.1",
|
||||||
|
"tsx": "^4.22.3",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@resvg/resvg-js",
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2143
pnpm-lock.yaml
Normal file
2143
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
19
server.json
Normal file
19
server.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
|
||||||
|
"name": "figma-local-context-mcp",
|
||||||
|
"description": "Read local .fig files, inspect node data, and export selected nodes to SVG/PNG.",
|
||||||
|
"status": "active",
|
||||||
|
"repository": {
|
||||||
|
"url": "file://.",
|
||||||
|
"source": "local"
|
||||||
|
},
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"registry_type": "npm",
|
||||||
|
"identifier": "figma-local-context-mcp",
|
||||||
|
"transport": {
|
||||||
|
"type": "stdio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
28
src/bin.ts
Normal file
28
src/bin.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { cli } from "cleye"
|
||||||
|
import { startStdioServer } from "./server.js"
|
||||||
|
|
||||||
|
const argv = cli({
|
||||||
|
name: "figma-local-context-mcp",
|
||||||
|
version: process.env.NPM_PACKAGE_VERSION ?? "0.1.0",
|
||||||
|
flags: {
|
||||||
|
stdio: {
|
||||||
|
type: Boolean,
|
||||||
|
description: "Run in stdio transport mode for MCP clients"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
if (!argv.flags.stdio) {
|
||||||
|
process.stderr.write("当前版本只支持 stdio,请传入 --stdio。\n")
|
||||||
|
}
|
||||||
|
await startStdioServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
process.stderr.write(`启动失败:${message}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
12
src/index.ts
Normal file
12
src/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export { createServer } from "./mcp/index.js"
|
||||||
|
export { startStdioServer } from "./server.js"
|
||||||
|
export { figToJson } from "./services/fig2json.js"
|
||||||
|
export { inspectFigFile, getFigNodeContext, loadFigFile } from "./services/fig-file.js"
|
||||||
|
export { exportFigNode } from "./services/export-node.js"
|
||||||
|
export {
|
||||||
|
exportAssets,
|
||||||
|
getCodeContext,
|
||||||
|
getDesignContext,
|
||||||
|
getDesignTokens,
|
||||||
|
listFigNodes
|
||||||
|
} from "./services/design-context.js"
|
||||||
247
src/mcp/index.ts
Normal file
247
src/mcp/index.ts
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { exportFigNode } from "../services/export-node.js"
|
||||||
|
import {
|
||||||
|
exportAssets,
|
||||||
|
getCodeContext,
|
||||||
|
getDesignContext,
|
||||||
|
getDesignTokens,
|
||||||
|
listFigNodes
|
||||||
|
} from "../services/design-context.js"
|
||||||
|
import { getFigNodeContext, inspectFigFile } from "../services/fig-file.js"
|
||||||
|
|
||||||
|
const serverInfo = {
|
||||||
|
name: "Figma Local Context MCP",
|
||||||
|
version: process.env.NPM_PACKAGE_VERSION ?? "0.1.0",
|
||||||
|
description: "Read local .fig files, inspect node context, and export selected nodes without Figma API."
|
||||||
|
}
|
||||||
|
|
||||||
|
const inspectParams = z.object({
|
||||||
|
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
|
||||||
|
maxNodes: z.number().int().positive().max(2000).default(200).describe("最多返回多少个节点摘要。")
|
||||||
|
})
|
||||||
|
|
||||||
|
const getNodeParams = z.object({
|
||||||
|
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
|
||||||
|
nodeQuery: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("节点名称、2625:12945、2625-12945、node-id=2625-12945 或完整 Figma 链接。"),
|
||||||
|
depth: z.number().int().min(0).max(10).default(2).describe("返回节点子树的深度。")
|
||||||
|
})
|
||||||
|
|
||||||
|
const exportNodeParams = z.object({
|
||||||
|
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
|
||||||
|
nodeQuery: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("节点名称、2625:12945、2625-12945、node-id=2625-12945 或完整 Figma 链接。"),
|
||||||
|
outputPath: z.string().min(1).optional().describe("输出文件路径;不传时写到当前工作目录。"),
|
||||||
|
format: z.enum(["svg", "png"]).default("png").describe("导出格式。"),
|
||||||
|
scale: z.number().positive().max(10).default(2).describe("导出倍率,PNG 常用 1/2/3/4。"),
|
||||||
|
background: z.string().optional().describe("可选背景色,例如 #ffffff。")
|
||||||
|
})
|
||||||
|
|
||||||
|
const listNodesParams = z.object({
|
||||||
|
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
|
||||||
|
query: z.string().optional().describe("按节点名、类型或 node-id 片段搜索。"),
|
||||||
|
type: z.string().optional().describe("按 Figma 节点类型过滤,例如 FRAME、TEXT、VECTOR。"),
|
||||||
|
limit: z.number().int().positive().max(5000).default(200).describe("最多返回多少个节点。"),
|
||||||
|
includeHidden: z.boolean().default(false).describe("是否包含 visible=false 的节点。")
|
||||||
|
})
|
||||||
|
|
||||||
|
const designContextParams = z.object({
|
||||||
|
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
|
||||||
|
nodeQuery: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("节点名称、2625:12945、2625-12945、node-id=2625-12945 或完整 Figma 链接;不传则返回文档根节点上下文。"),
|
||||||
|
depth: z.number().int().min(0).max(8).default(2).describe("返回节点子树的深度。"),
|
||||||
|
includeTokens: z.boolean().default(true).describe("是否附带从当前子树推导出的设计 token。"),
|
||||||
|
includeCodeHints: z.boolean().default(true).describe("是否附带代码实现提示。")
|
||||||
|
})
|
||||||
|
|
||||||
|
const codeContextParams = z.object({
|
||||||
|
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
|
||||||
|
nodeQuery: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("节点名称、2625:12945、2625-12945、node-id=2625-12945 或完整 Figma 链接。"),
|
||||||
|
depth: z.number().int().min(0).max(8).default(2).describe("返回代码提示子树的深度。")
|
||||||
|
})
|
||||||
|
|
||||||
|
const exportAssetsParams = z.object({
|
||||||
|
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
|
||||||
|
nodeQueries: z
|
||||||
|
.array(z.string().min(1))
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.describe("要导出的节点名或 node-id 列表。"),
|
||||||
|
outputDir: z.string().min(1).describe("资源输出目录。"),
|
||||||
|
format: z.enum(["svg", "png"]).default("png").describe("导出格式。"),
|
||||||
|
scale: z.number().positive().max(10).default(2).describe("导出倍率,PNG 常用 1/2/3/4。"),
|
||||||
|
background: z.string().optional().describe("可选背景色,例如 #ffffff。")
|
||||||
|
})
|
||||||
|
|
||||||
|
const designTokensParams = z.object({
|
||||||
|
filePath: z.string().min(1).describe("本地 .fig 或 .fig.json 文件路径。"),
|
||||||
|
nodeQuery: z.string().optional().describe("可选节点范围;不传则从文档根节点提取。")
|
||||||
|
})
|
||||||
|
|
||||||
|
type InspectParams = z.infer<typeof inspectParams>
|
||||||
|
type GetNodeParams = z.infer<typeof getNodeParams>
|
||||||
|
type ExportNodeParams = z.infer<typeof exportNodeParams>
|
||||||
|
type ListNodesParams = z.infer<typeof listNodesParams>
|
||||||
|
type DesignContextParams = z.infer<typeof designContextParams>
|
||||||
|
type CodeContextParams = z.infer<typeof codeContextParams>
|
||||||
|
type ExportAssetsParams = z.infer<typeof exportAssetsParams>
|
||||||
|
type DesignTokensParams = z.infer<typeof designTokensParams>
|
||||||
|
|
||||||
|
export function createServer(): McpServer {
|
||||||
|
const server = new McpServer(serverInfo)
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"list_fig_nodes",
|
||||||
|
{
|
||||||
|
title: "List local Figma nodes",
|
||||||
|
description: "按名称、类型或 node-id 搜索本地 .fig/.fig.json 里的节点。",
|
||||||
|
inputSchema: listNodesParams,
|
||||||
|
annotations: { readOnlyHint: true }
|
||||||
|
},
|
||||||
|
async (params: ListNodesParams) =>
|
||||||
|
jsonResponse(
|
||||||
|
listFigNodes(params.filePath, {
|
||||||
|
query: params.query,
|
||||||
|
type: params.type,
|
||||||
|
limit: params.limit,
|
||||||
|
includeHidden: params.includeHidden
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_design_context",
|
||||||
|
{
|
||||||
|
title: "Get design context",
|
||||||
|
description: "官方 Figma MCP 风格的设计上下文工具:返回节点树、样式、tokens 和代码提示。",
|
||||||
|
inputSchema: designContextParams,
|
||||||
|
annotations: { readOnlyHint: true }
|
||||||
|
},
|
||||||
|
async (params: DesignContextParams) =>
|
||||||
|
jsonResponse(
|
||||||
|
getDesignContext({
|
||||||
|
filePath: params.filePath,
|
||||||
|
nodeQuery: params.nodeQuery,
|
||||||
|
depth: params.depth,
|
||||||
|
includeTokens: params.includeTokens,
|
||||||
|
includeCodeHints: params.includeCodeHints
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_code_context",
|
||||||
|
{
|
||||||
|
title: "Get code context",
|
||||||
|
description: "返回适合代码生成的布局、样式、导出资源提示和子节点实现线索。",
|
||||||
|
inputSchema: codeContextParams,
|
||||||
|
annotations: { readOnlyHint: true }
|
||||||
|
},
|
||||||
|
async (params: CodeContextParams) =>
|
||||||
|
jsonResponse(
|
||||||
|
getCodeContext({
|
||||||
|
filePath: params.filePath,
|
||||||
|
nodeQuery: params.nodeQuery,
|
||||||
|
depth: params.depth
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"export_assets",
|
||||||
|
{
|
||||||
|
title: "Export design assets",
|
||||||
|
description: "批量导出本地 .fig/.fig.json 中的多个节点为 SVG 或 PNG。",
|
||||||
|
inputSchema: exportAssetsParams,
|
||||||
|
annotations: { readOnlyHint: false, openWorldHint: true }
|
||||||
|
},
|
||||||
|
async (params: ExportAssetsParams) =>
|
||||||
|
jsonResponse(
|
||||||
|
exportAssets({
|
||||||
|
filePath: params.filePath,
|
||||||
|
nodeQueries: params.nodeQueries,
|
||||||
|
outputDir: params.outputDir,
|
||||||
|
format: params.format,
|
||||||
|
scale: params.scale,
|
||||||
|
background: params.background
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_design_tokens",
|
||||||
|
{
|
||||||
|
title: "Get design tokens",
|
||||||
|
description: "从本地 .fig/.fig.json 的全文件或指定节点子树中推导颜色、渐变、阴影和描边 token。",
|
||||||
|
inputSchema: designTokensParams,
|
||||||
|
annotations: { readOnlyHint: true }
|
||||||
|
},
|
||||||
|
async (params: DesignTokensParams) => jsonResponse(getDesignTokens(params.filePath, params.nodeQuery))
|
||||||
|
)
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"inspect_fig_file",
|
||||||
|
{
|
||||||
|
title: "Inspect local .fig file",
|
||||||
|
description: "读取本地 .fig/.fig.json,返回节点数量、类型统计和节点摘要。",
|
||||||
|
inputSchema: inspectParams,
|
||||||
|
annotations: { readOnlyHint: true }
|
||||||
|
},
|
||||||
|
async (params: InspectParams) => jsonResponse(inspectFigFile(params.filePath, params.maxNodes))
|
||||||
|
)
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_fig_node",
|
||||||
|
{
|
||||||
|
title: "Get local Figma node",
|
||||||
|
description: "按节点名或 node-id 从本地 .fig/.fig.json 中查找节点,并返回简化上下文。",
|
||||||
|
inputSchema: getNodeParams,
|
||||||
|
annotations: { readOnlyHint: true }
|
||||||
|
},
|
||||||
|
async (params: GetNodeParams) => jsonResponse(getFigNodeContext(params.filePath, params.nodeQuery, params.depth))
|
||||||
|
)
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"export_fig_node",
|
||||||
|
{
|
||||||
|
title: "Export local Figma node",
|
||||||
|
description: "把本地 .fig/.fig.json 中的指定节点导出为 SVG 或 PNG。",
|
||||||
|
inputSchema: exportNodeParams,
|
||||||
|
annotations: { readOnlyHint: false, openWorldHint: true }
|
||||||
|
},
|
||||||
|
async (params: ExportNodeParams) =>
|
||||||
|
jsonResponse(
|
||||||
|
exportFigNode({
|
||||||
|
filePath: params.filePath,
|
||||||
|
nodeQuery: params.nodeQuery,
|
||||||
|
outputPath: params.outputPath,
|
||||||
|
format: params.format,
|
||||||
|
scale: params.scale,
|
||||||
|
background: params.background
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(value: unknown) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: JSON.stringify(value, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/server.ts
Normal file
17
src/server.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||||
|
import { createServer } from "./mcp/index.js"
|
||||||
|
|
||||||
|
export async function startStdioServer(): Promise<void> {
|
||||||
|
const server = createServer()
|
||||||
|
const transport = new StdioServerTransport()
|
||||||
|
await server.connect(transport)
|
||||||
|
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
await server.close()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
process.on("SIGTERM", async () => {
|
||||||
|
await server.close()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
276
src/services/design-context.ts
Normal file
276
src/services/design-context.ts
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
import path from "node:path"
|
||||||
|
import type { FigColor, FigJson, FigNode, FigPaint } from "./fig-types.js"
|
||||||
|
import { exportFigNode, type ExportNodeResult } from "./export-node.js"
|
||||||
|
import { getChildrenByParent, findTargetNode } from "./fig-node-svg.js"
|
||||||
|
import { getRootNode, loadFigFile, serializeNode, summarizeNode } from "./fig-file.js"
|
||||||
|
import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js"
|
||||||
|
|
||||||
|
export type NodeListOptions = {
|
||||||
|
query?: string
|
||||||
|
type?: string
|
||||||
|
limit: number
|
||||||
|
includeHidden: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DesignContextOptions = {
|
||||||
|
filePath: string
|
||||||
|
nodeQuery?: string
|
||||||
|
depth: number
|
||||||
|
includeTokens: boolean
|
||||||
|
includeCodeHints: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodeContextOptions = {
|
||||||
|
filePath: string
|
||||||
|
nodeQuery: string
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExportAssetsOptions = {
|
||||||
|
filePath: string
|
||||||
|
nodeQueries: string[]
|
||||||
|
outputDir: string
|
||||||
|
format: "svg" | "png"
|
||||||
|
scale: number
|
||||||
|
background?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFigNodes(filePath: string, options: NodeListOptions) {
|
||||||
|
const figJson = loadFigFile(filePath)
|
||||||
|
const childrenByParent = getChildrenByParent(figJson)
|
||||||
|
const normalizedQuery = options.query?.trim().toLowerCase()
|
||||||
|
const normalizedType = options.type?.trim().toUpperCase()
|
||||||
|
const nodes = (figJson.nodeChanges ?? [])
|
||||||
|
.filter((node) => options.includeHidden || node.visible !== false)
|
||||||
|
.filter((node) => !normalizedType || node.type?.toUpperCase() === normalizedType)
|
||||||
|
.filter((node) => {
|
||||||
|
if (!normalizedQuery) return true
|
||||||
|
const haystack = `${node.name ?? ""} ${node.type ?? ""} ${keyForGuid(node.guid)}`.toLowerCase()
|
||||||
|
return haystack.includes(normalizedQuery)
|
||||||
|
})
|
||||||
|
.slice(0, options.limit)
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: path.resolve(filePath),
|
||||||
|
query: options.query,
|
||||||
|
type: options.type,
|
||||||
|
count: nodes.length,
|
||||||
|
nodes: nodes.map((node) => summarizeNode(node, childrenByParent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDesignContext(options: DesignContextOptions) {
|
||||||
|
const figJson = loadFigFile(options.filePath)
|
||||||
|
const childrenByParent = getChildrenByParent(figJson)
|
||||||
|
const target = options.nodeQuery ? findTargetNode(figJson, { nodeQuery: options.nodeQuery }) : getRootNode(figJson)
|
||||||
|
if (!target) {
|
||||||
|
throw new Error("文件中没有可用节点")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "figma-local-design-context",
|
||||||
|
filePath: path.resolve(options.filePath),
|
||||||
|
query: options.nodeQuery,
|
||||||
|
node: serializeNode(target, childrenByParent, options.depth),
|
||||||
|
tokens: options.includeTokens ? collectDesignTokens(figJson, target, childrenByParent) : undefined,
|
||||||
|
codeHints: options.includeCodeHints ? buildCodeHints(target, childrenByParent, options.depth) : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCodeContext(options: CodeContextOptions) {
|
||||||
|
const figJson = loadFigFile(options.filePath)
|
||||||
|
const childrenByParent = getChildrenByParent(figJson)
|
||||||
|
const target = findTargetNode(figJson, { nodeQuery: options.nodeQuery })
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "figma-local-code-context",
|
||||||
|
filePath: path.resolve(options.filePath),
|
||||||
|
query: options.nodeQuery,
|
||||||
|
node: summarizeNode(target, childrenByParent),
|
||||||
|
implementationHints: buildCodeHints(target, childrenByParent, options.depth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportAssets(options: ExportAssetsOptions) {
|
||||||
|
const results: ExportNodeResult[] = []
|
||||||
|
for (const nodeQuery of options.nodeQueries) {
|
||||||
|
const fileName = `${sanitizeFilePart(nodeQuery)}${options.format === "png" ? `@${options.scale}x` : ""}.${options.format}`
|
||||||
|
results.push(
|
||||||
|
exportFigNode({
|
||||||
|
filePath: options.filePath,
|
||||||
|
nodeQuery,
|
||||||
|
outputPath: path.join(options.outputDir, fileName),
|
||||||
|
format: options.format,
|
||||||
|
scale: options.scale,
|
||||||
|
background: options.background
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: path.resolve(options.filePath),
|
||||||
|
outputDir: path.resolve(options.outputDir),
|
||||||
|
count: results.length,
|
||||||
|
assets: results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDesignTokens(filePath: string, nodeQuery?: string) {
|
||||||
|
const figJson = loadFigFile(filePath)
|
||||||
|
const childrenByParent = getChildrenByParent(figJson)
|
||||||
|
const target = nodeQuery ? findTargetNode(figJson, { nodeQuery }) : getRootNode(figJson)
|
||||||
|
if (!target) {
|
||||||
|
throw new Error("文件中没有可用节点")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: path.resolve(filePath),
|
||||||
|
query: nodeQuery,
|
||||||
|
tokens: collectDesignTokens(figJson, target, childrenByParent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCodeHints(node: FigNode, childrenByParent: Map<string, FigNode[]>, depth: number): unknown {
|
||||||
|
const children = childrenByParent.get(keyForGuid(node.guid)) ?? []
|
||||||
|
const hints = {
|
||||||
|
id: keyForGuid(node.guid),
|
||||||
|
name: node.name,
|
||||||
|
type: node.type,
|
||||||
|
box: {
|
||||||
|
width: node.size?.x,
|
||||||
|
height: node.size?.y,
|
||||||
|
x: node.transform?.m02,
|
||||||
|
y: node.transform?.m12
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
opacity: node.opacity,
|
||||||
|
fills: simplifyPaintsForCode(node.fillPaints),
|
||||||
|
strokes: simplifyPaintsForCode(node.strokePaints),
|
||||||
|
strokeWidth: node.strokeWeight,
|
||||||
|
shadows: node.effects
|
||||||
|
?.filter((effect) => effect.visible !== false && effect.type === "DROP_SHADOW")
|
||||||
|
.map((effect) => ({
|
||||||
|
x: effect.offset?.x ?? 0,
|
||||||
|
y: effect.offset?.y ?? 0,
|
||||||
|
blur: effect.radius ?? 0,
|
||||||
|
spread: effect.spread ?? 0,
|
||||||
|
color: colorToCss(effect.color)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
exportHint:
|
||||||
|
node.type === "VECTOR" || node.type === "ELLIPSE" || node.type === "FRAME"
|
||||||
|
? `Use export_fig_node or export_assets with nodeQuery "${keyForGuid(node.guid)}" for exact SVG/PNG.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth <= 0) return hints
|
||||||
|
|
||||||
|
return {
|
||||||
|
...hints,
|
||||||
|
children: children
|
||||||
|
.sort((a, b) => (a.parentIndex?.position ?? "").localeCompare(b.parentIndex?.position ?? ""))
|
||||||
|
.map((child) => buildCodeHints(child, childrenByParent, depth - 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDesignTokens(figJson: FigJson, root: FigNode, childrenByParent: Map<string, FigNode[]>) {
|
||||||
|
const colors = new Map<string, { value: string; count: number; examples: string[] }>()
|
||||||
|
const gradients = new Map<string, { value: string[]; count: number; examples: string[] }>()
|
||||||
|
const shadows = new Map<string, { value: unknown; count: number; examples: string[] }>()
|
||||||
|
const strokeWidths = new Map<string, { value: number; count: number; examples: string[] }>()
|
||||||
|
|
||||||
|
// Tokens are inferred from the selected subtree instead of global styles,
|
||||||
|
// because local .fig decoding does not yet resolve Figma's variable/style registry.
|
||||||
|
for (const node of walkSubtree(root, childrenByParent)) {
|
||||||
|
const example = `${node.name ?? "未命名"} (${keyForGuid(node.guid)})`
|
||||||
|
for (const paint of [...(node.fillPaints ?? []), ...(node.strokePaints ?? [])]) {
|
||||||
|
if (paint.visible === false) continue
|
||||||
|
if (paint.type === "SOLID") {
|
||||||
|
addToken(colors, colorToCss(paint.color), colorToCss(paint.color), example)
|
||||||
|
} else if (paint.type === "GRADIENT_LINEAR") {
|
||||||
|
const stops = paint.stops?.map((stop) => `${formatPercent(stop.position)} ${colorToCss(stop.color)}`) ?? []
|
||||||
|
addToken(gradients, stops.join(" | "), stops, example)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const effect of node.effects ?? []) {
|
||||||
|
if (effect.visible === false || effect.type !== "DROP_SHADOW") continue
|
||||||
|
const value = {
|
||||||
|
x: effect.offset?.x ?? 0,
|
||||||
|
y: effect.offset?.y ?? 0,
|
||||||
|
blur: effect.radius ?? 0,
|
||||||
|
spread: effect.spread ?? 0,
|
||||||
|
color: colorToCss(effect.color)
|
||||||
|
}
|
||||||
|
addToken(shadows, JSON.stringify(value), value, example)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.strokeWeight != null) {
|
||||||
|
addToken(strokeWidths, String(node.strokeWeight), node.strokeWeight, example)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeCount: figJson.nodeChanges?.length ?? 0,
|
||||||
|
colors: [...colors.values()],
|
||||||
|
gradients: [...gradients.values()],
|
||||||
|
shadows: [...shadows.values()],
|
||||||
|
strokeWidths: [...strokeWidths.values()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function* walkSubtree(root: FigNode, childrenByParent: Map<string, FigNode[]>): Generator<FigNode> {
|
||||||
|
yield root
|
||||||
|
for (const child of childrenByParent.get(keyForGuid(root.guid)) ?? []) {
|
||||||
|
yield* walkSubtree(child, childrenByParent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToken<T>(
|
||||||
|
map: Map<string, { value: T; count: number; examples: string[] }>,
|
||||||
|
key: string,
|
||||||
|
value: T,
|
||||||
|
example: string
|
||||||
|
) {
|
||||||
|
const current = map.get(key)
|
||||||
|
if (current) {
|
||||||
|
current.count += 1
|
||||||
|
if (current.examples.length < 5) current.examples.push(example)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
map.set(key, { value, count: 1, examples: [example] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function simplifyPaintsForCode(paints: FigPaint[] | undefined) {
|
||||||
|
if (!paints?.length) return undefined
|
||||||
|
|
||||||
|
return paints
|
||||||
|
.filter((paint) => paint.visible !== false)
|
||||||
|
.map((paint) => {
|
||||||
|
if (paint.type === "SOLID") return colorToCss(paint.color)
|
||||||
|
if (paint.type === "GRADIENT_LINEAR") {
|
||||||
|
const stops = paint.stops?.map((stop) => `${colorToCss(stop.color)} ${formatPercent(stop.position)}`) ?? []
|
||||||
|
return `linear-gradient(${stops.join(", ")})`
|
||||||
|
}
|
||||||
|
return paint.type
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorToCss(color?: FigColor): string {
|
||||||
|
if (!color) return "rgb(0 0 0 / 1)"
|
||||||
|
|
||||||
|
return `rgb(${toByte(color.r)} ${toByte(color.g)} ${toByte(color.b)} / ${trimNumber(color.a)})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toByte(value: number): number {
|
||||||
|
return Math.round(Math.max(0, Math.min(1, value)) * 255)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number): string {
|
||||||
|
return `${trimNumber(value * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimNumber(value: number): string {
|
||||||
|
return Number.isInteger(value) ? String(value) : Number(value.toFixed(4)).toString()
|
||||||
|
}
|
||||||
67
src/services/export-node.ts
Normal file
67
src/services/export-node.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import fs from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import { Resvg } from "@resvg/resvg-js"
|
||||||
|
import { loadFigFile } from "./fig-file.js"
|
||||||
|
import { renderNodeToSvg } from "./fig-node-svg.js"
|
||||||
|
import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js"
|
||||||
|
|
||||||
|
export type ExportNodeOptions = {
|
||||||
|
filePath: string
|
||||||
|
nodeQuery: string
|
||||||
|
outputPath?: string
|
||||||
|
format: "svg" | "png"
|
||||||
|
scale: number
|
||||||
|
background?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExportNodeResult = {
|
||||||
|
filePath: string
|
||||||
|
outputPath: string
|
||||||
|
format: "svg" | "png"
|
||||||
|
scale: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
node: {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportFigNode(options: ExportNodeOptions): ExportNodeResult {
|
||||||
|
const figJson = loadFigFile(options.filePath)
|
||||||
|
const rendered = renderNodeToSvg(figJson, {
|
||||||
|
nodeQuery: options.nodeQuery,
|
||||||
|
scale: options.scale,
|
||||||
|
background: options.background
|
||||||
|
})
|
||||||
|
const outputPath = path.resolve(options.outputPath ?? defaultOutputPath(options))
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||||
|
if (options.format === "svg") {
|
||||||
|
fs.writeFileSync(outputPath, rendered.svg)
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(outputPath, new Resvg(rendered.svg).render().asPng())
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: path.resolve(options.filePath),
|
||||||
|
outputPath,
|
||||||
|
format: options.format,
|
||||||
|
scale: options.scale,
|
||||||
|
width: rendered.width,
|
||||||
|
height: rendered.height,
|
||||||
|
node: {
|
||||||
|
id: keyForGuid(rendered.node.guid),
|
||||||
|
name: rendered.node.name,
|
||||||
|
type: rendered.node.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultOutputPath(options: ExportNodeOptions): string {
|
||||||
|
const input = path.parse(options.filePath)
|
||||||
|
const nodePart = sanitizeFilePart(options.nodeQuery)
|
||||||
|
const scalePart = options.format === "png" ? `@${options.scale}x` : ""
|
||||||
|
return path.join(process.cwd(), `${input.name}-${nodePart}${scalePart}.${options.format}`)
|
||||||
|
}
|
||||||
138
src/services/fig-file.ts
Normal file
138
src/services/fig-file.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import fs from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import type { FigJson, FigNode } from "./fig-types.js"
|
||||||
|
import { figToJson } from "./fig2json.js"
|
||||||
|
import { findTargetNode, getChildrenByParent } from "./fig-node-svg.js"
|
||||||
|
import { keyForGuid, normalizeNodeId } from "../utils/node-id.js"
|
||||||
|
|
||||||
|
export type NodeSummary = {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
type?: string
|
||||||
|
parentId?: string
|
||||||
|
visible?: boolean
|
||||||
|
opacity?: number
|
||||||
|
size?: { x: number; y: number }
|
||||||
|
childCount: number
|
||||||
|
hasFill: boolean
|
||||||
|
hasStroke: boolean
|
||||||
|
hasEffects: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileSummary = {
|
||||||
|
filePath: string
|
||||||
|
nodeCount: number
|
||||||
|
blobCount: number
|
||||||
|
topTypes: Record<string, number>
|
||||||
|
nodes: NodeSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadFigFile(filePath: string): FigJson {
|
||||||
|
const absolutePath = path.resolve(filePath)
|
||||||
|
if (!fs.existsSync(absolutePath)) {
|
||||||
|
throw new Error(`文件不存在:${absolutePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (absolutePath.toLowerCase().endsWith(".json")) {
|
||||||
|
return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as FigJson
|
||||||
|
}
|
||||||
|
|
||||||
|
return figToJson(fs.readFileSync(absolutePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectFigFile(filePath: string, maxNodes: number): FileSummary {
|
||||||
|
const figJson = loadFigFile(filePath)
|
||||||
|
const childrenByParent = getChildrenByParent(figJson)
|
||||||
|
const nodes = figJson.nodeChanges ?? []
|
||||||
|
const topTypes: Record<string, number> = {}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const type = node.type ?? "UNKNOWN"
|
||||||
|
topTypes[type] = (topTypes[type] ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: path.resolve(filePath),
|
||||||
|
nodeCount: nodes.length,
|
||||||
|
blobCount: figJson.blobs?.length ?? 0,
|
||||||
|
topTypes: Object.fromEntries(Object.entries(topTypes).sort((a, b) => b[1] - a[1])),
|
||||||
|
nodes: nodes.slice(0, maxNodes).map((node) => summarizeNode(node, childrenByParent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFigNodeContext(filePath: string, nodeQuery: string, depth: number): unknown {
|
||||||
|
const figJson = loadFigFile(filePath)
|
||||||
|
const target = findTargetNode(figJson, { nodeQuery })
|
||||||
|
const childrenByParent = getChildrenByParent(figJson)
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: path.resolve(filePath),
|
||||||
|
query: nodeQuery,
|
||||||
|
normalizedNodeId: normalizeNodeId(nodeQuery),
|
||||||
|
node: serializeNode(target, childrenByParent, depth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRootNode(figJson: FigJson): FigNode | undefined {
|
||||||
|
const nodes = figJson.nodeChanges ?? []
|
||||||
|
return (
|
||||||
|
nodes.find((node) => node.type === "DOCUMENT") ??
|
||||||
|
nodes.find((node) => !node.parentIndex?.guid) ??
|
||||||
|
nodes[0]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeNode(node: FigNode, childrenByParent: Map<string, FigNode[]>): NodeSummary {
|
||||||
|
return {
|
||||||
|
id: keyForGuid(node.guid),
|
||||||
|
name: node.name,
|
||||||
|
type: node.type,
|
||||||
|
parentId: keyForGuid(node.parentIndex?.guid) || undefined,
|
||||||
|
visible: node.visible,
|
||||||
|
opacity: node.opacity,
|
||||||
|
size: node.size,
|
||||||
|
childCount: childrenByParent.get(keyForGuid(node.guid))?.length ?? 0,
|
||||||
|
hasFill: Boolean(node.fillPaints?.length),
|
||||||
|
hasStroke: Boolean(node.strokePaints?.length),
|
||||||
|
hasEffects: Boolean(node.effects?.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeNode(node: FigNode, childrenByParent: Map<string, FigNode[]>, depth: number): unknown {
|
||||||
|
const children = childrenByParent.get(keyForGuid(node.guid)) ?? []
|
||||||
|
const summary = {
|
||||||
|
...summarizeNode(node, childrenByParent),
|
||||||
|
transform: node.transform,
|
||||||
|
strokeWeight: node.strokeWeight,
|
||||||
|
strokeAlign: node.strokeAlign,
|
||||||
|
arcData: node.arcData,
|
||||||
|
fills: simplifyPaints(node.fillPaints),
|
||||||
|
strokes: simplifyPaints(node.strokePaints),
|
||||||
|
effects: node.effects,
|
||||||
|
geometry: {
|
||||||
|
fillCount: node.fillGeometry?.length ?? 0,
|
||||||
|
strokeCount: node.strokeGeometry?.length ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth <= 0) return summary
|
||||||
|
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
children: children
|
||||||
|
.sort((a, b) => (a.parentIndex?.position ?? "").localeCompare(b.parentIndex?.position ?? ""))
|
||||||
|
.map((child) => serializeNode(child, childrenByParent, depth - 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simplifyPaints(paints: FigNode["fillPaints"]): unknown[] | undefined {
|
||||||
|
if (!paints?.length) return undefined
|
||||||
|
|
||||||
|
return paints.map((paint) => ({
|
||||||
|
type: paint.type,
|
||||||
|
visible: paint.visible,
|
||||||
|
opacity: paint.opacity,
|
||||||
|
color: paint.color,
|
||||||
|
stops: paint.stops
|
||||||
|
}))
|
||||||
|
}
|
||||||
541
src/services/fig-node-svg.ts
Normal file
541
src/services/fig-node-svg.ts
Normal file
@ -0,0 +1,541 @@
|
|||||||
|
import type {
|
||||||
|
Bounds,
|
||||||
|
FigArcData,
|
||||||
|
FigColor,
|
||||||
|
FigEffect,
|
||||||
|
FigGeometry,
|
||||||
|
FigJson,
|
||||||
|
FigNode,
|
||||||
|
FigPaint,
|
||||||
|
FigmaMatrix
|
||||||
|
} from "./fig-types.js"
|
||||||
|
import { keyForGuid, normalizeNodeId } from "../utils/node-id.js"
|
||||||
|
|
||||||
|
type SvgMatrix = [number, number, number, number, number, number]
|
||||||
|
|
||||||
|
type ParsedPath = {
|
||||||
|
d: string
|
||||||
|
bounds: Bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenderContext = {
|
||||||
|
figJson: FigJson
|
||||||
|
childrenByParent: Map<string, FigNode[]>
|
||||||
|
defs: string[]
|
||||||
|
bounds: Bounds | null
|
||||||
|
idSeed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenderOptions = {
|
||||||
|
nodeName?: string
|
||||||
|
nodeId?: string
|
||||||
|
nodeQuery?: string
|
||||||
|
scale?: number
|
||||||
|
background?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenderedSvg = {
|
||||||
|
svg: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
viewBox: Bounds
|
||||||
|
node: FigNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDENTITY: SvgMatrix = [1, 0, 0, 1, 0, 0]
|
||||||
|
const COMMAND_MOVE = 1
|
||||||
|
const COMMAND_LINE = 2
|
||||||
|
const COMMAND_CUBIC = 4
|
||||||
|
const COMMAND_CLOSE = 0
|
||||||
|
|
||||||
|
export function renderNodeToSvg(figJson: FigJson, options: RenderOptions): RenderedSvg {
|
||||||
|
const target = findTargetNode(figJson, options)
|
||||||
|
const childrenByParent = getChildrenByParent(figJson)
|
||||||
|
const context: RenderContext = {
|
||||||
|
figJson,
|
||||||
|
childrenByParent,
|
||||||
|
defs: [],
|
||||||
|
bounds: null,
|
||||||
|
idSeed: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = renderNodeSubtree(context, target, IDENTITY, true)
|
||||||
|
const bounds = context.bounds ?? { minX: 0, minY: 0, maxX: target.size?.x ?? 0, maxY: target.size?.y ?? 0 }
|
||||||
|
const width = bounds.maxX - bounds.minX
|
||||||
|
const height = bounds.maxY - bounds.minY
|
||||||
|
const scale = options.scale ?? 2
|
||||||
|
const background = options.background
|
||||||
|
? `<rect x="${format(bounds.minX)}" y="${format(bounds.minY)}" width="${format(width)}" height="${format(height)}" fill="${escapeAttribute(options.background)}"/>`
|
||||||
|
: ""
|
||||||
|
const svg = [
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${format(width * scale)}" height="${format(
|
||||||
|
height * scale
|
||||||
|
)}" viewBox="${format(bounds.minX)} ${format(bounds.minY)} ${format(width)} ${format(height)}" shape-rendering="geometricPrecision">`,
|
||||||
|
context.defs.length ? `<defs>${context.defs.join("")}</defs>` : "",
|
||||||
|
background,
|
||||||
|
body,
|
||||||
|
"</svg>"
|
||||||
|
].join("")
|
||||||
|
|
||||||
|
return { svg, width: width * scale, height: height * scale, viewBox: bounds, node: target }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findTargetNode(figJson: FigJson, options: RenderOptions): FigNode {
|
||||||
|
const nodes = figJson.nodeChanges ?? []
|
||||||
|
const rawQuery = options.nodeId ?? options.nodeQuery
|
||||||
|
const normalizedNodeId = normalizeNodeId(rawQuery)
|
||||||
|
const nodeName = options.nodeName ?? (normalizedNodeId ? undefined : options.nodeQuery)
|
||||||
|
const target = normalizedNodeId
|
||||||
|
? nodes.find((node) => keyForGuid(node.guid) === normalizedNodeId)
|
||||||
|
: nodes.find((node) => node.name === nodeName)
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new Error(`找不到节点:${options.nodeId ?? options.nodeName ?? options.nodeQuery}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodeSubtree(
|
||||||
|
context: RenderContext,
|
||||||
|
node: FigNode,
|
||||||
|
parentMatrix: SvgMatrix,
|
||||||
|
isRoot = false
|
||||||
|
): string {
|
||||||
|
if (node.visible === false) return ""
|
||||||
|
|
||||||
|
const localMatrix = isRoot ? IDENTITY : toSvgMatrix(node.transform)
|
||||||
|
const matrix = multiply(parentMatrix, localMatrix)
|
||||||
|
const nodeContent = [
|
||||||
|
...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix),
|
||||||
|
...renderStrokeGeometry(context, node, matrix),
|
||||||
|
...getSortedChildren(context, node).map((child) => renderNodeSubtree(context, child, matrix))
|
||||||
|
].join("")
|
||||||
|
|
||||||
|
if (!nodeContent) return ""
|
||||||
|
|
||||||
|
const transform = matrixToAttribute(localMatrix)
|
||||||
|
const opacity = node.opacity != null && node.opacity !== 1 ? ` opacity="${format(node.opacity)}"` : ""
|
||||||
|
const filterId = createNodeEffectFilter(context, node, matrix)
|
||||||
|
const filter = filterId ? ` filter="url(#${filterId})"` : ""
|
||||||
|
|
||||||
|
return `<g${transform}${opacity}${filter}>${nodeContent}</g>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStrokeGeometry(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] {
|
||||||
|
const outsideEllipseStroke = renderOutsideEllipseStroke(context, node, matrix)
|
||||||
|
if (outsideEllipseStroke) return outsideEllipseStroke
|
||||||
|
|
||||||
|
return renderGeometry(context, node, node.strokeGeometry, node.strokePaints, matrix)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOutsideEllipseStroke(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] | null {
|
||||||
|
const strokeWeight = node.strokeWeight ?? 0
|
||||||
|
if (
|
||||||
|
node.type !== "ELLIPSE" ||
|
||||||
|
node.strokeAlign !== "OUTSIDE" ||
|
||||||
|
!node.size ||
|
||||||
|
!node.strokePaints?.length ||
|
||||||
|
strokeWeight <= 0 ||
|
||||||
|
!isFullEllipse(node.arcData)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = {
|
||||||
|
minX: -strokeWeight,
|
||||||
|
minY: -strokeWeight,
|
||||||
|
maxX: node.size.x + strokeWeight,
|
||||||
|
maxY: node.size.y + strokeWeight
|
||||||
|
}
|
||||||
|
includeBounds(context, transformBounds(matrix, bounds))
|
||||||
|
|
||||||
|
return node.strokePaints
|
||||||
|
.filter((paint) => paint.visible !== false)
|
||||||
|
.map((paint) => {
|
||||||
|
const stroke = paintToSvgFill(context, node, bounds, paint)
|
||||||
|
const opacity = paint.opacity != null && paint.opacity !== 1 ? ` stroke-opacity="${format(paint.opacity)}"` : ""
|
||||||
|
const rx = node.size!.x / 2 + strokeWeight / 2
|
||||||
|
const ry = node.size!.y / 2 + strokeWeight / 2
|
||||||
|
|
||||||
|
// Figma's strokeGeometry for OUTSIDE ellipses is an expanded filled
|
||||||
|
// outline. Using it directly paints inward and erases the ring gap, so
|
||||||
|
// complete ellipses are emitted as actual outside strokes.
|
||||||
|
return `<ellipse cx="${format(node.size!.x / 2)}" cy="${format(node.size!.y / 2)}" rx="${format(
|
||||||
|
rx
|
||||||
|
)}" ry="${format(ry)}" fill="none" stroke="${stroke}" stroke-width="${format(strokeWeight)}"${opacity}/>`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFullEllipse(arcData?: FigArcData): boolean {
|
||||||
|
if (!arcData) return true
|
||||||
|
|
||||||
|
const start = arcData.startingAngle ?? 0
|
||||||
|
const end = arcData.endingAngle ?? Math.PI * 2
|
||||||
|
const delta = Math.abs(end - start)
|
||||||
|
return (arcData.innerRadius ?? 0) === 0 && Math.abs(delta - Math.PI * 2) < 0.001
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGeometry(
|
||||||
|
context: RenderContext,
|
||||||
|
node: FigNode,
|
||||||
|
geometries: FigGeometry[] | undefined,
|
||||||
|
paints: FigPaint[] | undefined,
|
||||||
|
matrix: SvgMatrix
|
||||||
|
): string[] {
|
||||||
|
if (!geometries?.length || !paints?.length) return []
|
||||||
|
|
||||||
|
return geometries.flatMap((geometry) => {
|
||||||
|
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
|
||||||
|
const transformedBounds = transformBounds(matrix, parsed.bounds)
|
||||||
|
includeBounds(context, transformedBounds)
|
||||||
|
|
||||||
|
return paints
|
||||||
|
.filter((paint) => paint.visible !== false)
|
||||||
|
.map((paint) => {
|
||||||
|
const fill = paintToSvgFill(context, node, parsed.bounds, paint)
|
||||||
|
const opacity = paint.opacity != null && paint.opacity !== 1 ? ` fill-opacity="${format(paint.opacity)}"` : ""
|
||||||
|
|
||||||
|
return `<path d="${parsed.d}" fill="${fill}"${opacity}/>`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath {
|
||||||
|
const blob = figJson.blobs?.[blobIndex]
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error(`缺少几何数据 blob:${blobIndex}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = base64ToBytes(blob)
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||||
|
let offset = 0
|
||||||
|
let d = ""
|
||||||
|
const bounds = createEmptyBounds()
|
||||||
|
|
||||||
|
const readFloat = () => {
|
||||||
|
const value = view.getFloat32(offset, true)
|
||||||
|
offset += 4
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const readPoint = () => {
|
||||||
|
const x = readFloat()
|
||||||
|
const y = readFloat()
|
||||||
|
includePoint(bounds, x, y)
|
||||||
|
return `${format(x)} ${format(y)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Figma stores flattened vector commands as compact opcodes followed by
|
||||||
|
// little-endian float coordinates: 1=M, 2=L, 4=C, 0=Z.
|
||||||
|
while (offset < bytes.length) {
|
||||||
|
const command = bytes[offset]
|
||||||
|
offset += 1
|
||||||
|
|
||||||
|
if (command === COMMAND_MOVE) {
|
||||||
|
d += `M ${readPoint()} `
|
||||||
|
} else if (command === COMMAND_LINE) {
|
||||||
|
d += `L ${readPoint()} `
|
||||||
|
} else if (command === COMMAND_CUBIC) {
|
||||||
|
d += `C ${readPoint()} ${readPoint()} ${readPoint()} `
|
||||||
|
} else if (command === COMMAND_CLOSE) {
|
||||||
|
d += "Z "
|
||||||
|
} else {
|
||||||
|
throw new Error(`几何数据 blob ${blobIndex} 中存在不支持的向量命令:${command}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { d: d.trim(), bounds }
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintToSvgFill(context: RenderContext, node: FigNode, pathBounds: Bounds, paint: FigPaint): string {
|
||||||
|
if (paint.type === "SOLID") {
|
||||||
|
return colorToCss(paint.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paint.type === "GRADIENT_LINEAR") {
|
||||||
|
const id = nextId(context, "gradient")
|
||||||
|
const gradient = getLinearGradientLine(node, pathBounds, paint)
|
||||||
|
const stops = paint.stops
|
||||||
|
?.map(
|
||||||
|
(stop) =>
|
||||||
|
`<stop offset="${format(stop.position * 100)}%" stop-color="${colorToCss(stop.color)}" stop-opacity="${format(
|
||||||
|
stop.color.a
|
||||||
|
)}"/>`
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
|
||||||
|
context.defs.push(
|
||||||
|
`<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${format(gradient.x1)}" y1="${format(
|
||||||
|
gradient.y1
|
||||||
|
)}" x2="${format(gradient.x2)}" y2="${format(gradient.y2)}">${stops ?? ""}</linearGradient>`
|
||||||
|
)
|
||||||
|
return `url(#${id})`
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`不支持的填充类型:${paint.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinearGradientLine(node: FigNode, pathBounds: Bounds, paint: FigPaint) {
|
||||||
|
const width = node.size?.x || pathBounds.maxX - pathBounds.minX
|
||||||
|
const height = node.size?.y || pathBounds.maxY - pathBounds.minY
|
||||||
|
const originX = pathBounds.minX < 0 ? pathBounds.minX : 0
|
||||||
|
const originY = pathBounds.minY < 0 ? pathBounds.minY : 0
|
||||||
|
const inverse = invert(toSvgMatrix(paint.transform))
|
||||||
|
|
||||||
|
// Figma's stored gradient transform maps local object space back into the
|
||||||
|
// unit gradient space, so SVG needs the inverse projected onto the node box.
|
||||||
|
const start = applyToPoint(inverse, 0, 0)
|
||||||
|
const end = applyToPoint(inverse, 1, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
x1: originX + start.x * width,
|
||||||
|
y1: originY + start.y * height,
|
||||||
|
x2: originX + end.x * width,
|
||||||
|
y2: originY + end.y * height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFilter(context: RenderContext, effects: FigEffect[] | undefined, bounds: Bounds): string | null {
|
||||||
|
const shadow = effects?.find((effect) => effect.visible !== false && effect.type === "DROP_SHADOW")
|
||||||
|
if (!shadow) return null
|
||||||
|
|
||||||
|
const id = nextId(context, "shadow")
|
||||||
|
const radius = shadow.radius ?? 0
|
||||||
|
const spread = shadow.spread ?? 0
|
||||||
|
const offsetX = shadow.offset?.x ?? 0
|
||||||
|
const offsetY = shadow.offset?.y ?? 0
|
||||||
|
const x = bounds.minX + Math.min(0, offsetX) - radius - spread
|
||||||
|
const y = bounds.minY + Math.min(0, offsetY) - radius - spread
|
||||||
|
const width = bounds.maxX - bounds.minX + Math.abs(offsetX) + radius * 2 + spread * 2
|
||||||
|
const height = bounds.maxY - bounds.minY + Math.abs(offsetY) + radius * 2 + spread * 2
|
||||||
|
const sourceAlpha = spread
|
||||||
|
? `<feMorphology in="SourceAlpha" operator="${spread > 0 ? "dilate" : "erode"}" radius="${format(
|
||||||
|
Math.abs(spread)
|
||||||
|
)}" result="spreadAlpha"/>`
|
||||||
|
: ""
|
||||||
|
const blurInput = spread ? "spreadAlpha" : "SourceAlpha"
|
||||||
|
const shadowResult = shadow.showShadowBehindNode === false ? "visibleShadow" : "shadow"
|
||||||
|
const hideShadowBehindSource =
|
||||||
|
shadow.showShadowBehindNode === false
|
||||||
|
? `<feComposite in="shadow" in2="SourceAlpha" operator="out" result="visibleShadow"/>`
|
||||||
|
: ""
|
||||||
|
|
||||||
|
// Figma can store showShadowBehindNode=false. SVG's feDropShadow always
|
||||||
|
// leaves the blurred shadow under the source, so we build the filter steps
|
||||||
|
// manually and subtract SourceAlpha when Figma says the shadow is outside-only.
|
||||||
|
context.defs.push(
|
||||||
|
`<filter id="${id}" x="${format(x)}" y="${format(y)}" width="${format(width)}" height="${format(
|
||||||
|
height
|
||||||
|
)}" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">${sourceAlpha}<feGaussianBlur in="${blurInput}" stdDeviation="${format(
|
||||||
|
radius / 2
|
||||||
|
)}" result="blurredShadow"/><feOffset in="blurredShadow" dx="${format(offsetX)}" dy="${format(
|
||||||
|
offsetY
|
||||||
|
)}" result="offsetShadow"/><feFlood flood-color="${colorToCss(shadow.color)}" flood-opacity="${format(
|
||||||
|
shadow.color?.a ?? 1
|
||||||
|
)}" result="shadowColor"/><feComposite in="shadowColor" in2="offsetShadow" operator="in" result="shadow"/>${hideShadowBehindSource}<feMerge><feMergeNode in="${shadowResult}"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`
|
||||||
|
)
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNodeEffectFilter(context: RenderContext, node: FigNode, matrix: SvgMatrix): string | null {
|
||||||
|
const localBounds = getNodeLocalBounds(context, node)
|
||||||
|
if (!localBounds) return null
|
||||||
|
|
||||||
|
// Effects belong to the Figma layer itself. Frames/groups can carry shadows
|
||||||
|
// even when their own fill paints are empty, so the filter must wrap the
|
||||||
|
// rendered layer content instead of only individual geometry paths.
|
||||||
|
const filterId = createFilter(context, node.effects, localBounds)
|
||||||
|
if (!filterId) return null
|
||||||
|
|
||||||
|
includeBounds(context, transformBounds(matrix, expandBoundsForEffects(localBounds, node.effects)))
|
||||||
|
return filterId
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeLocalBounds(context: RenderContext, node: FigNode): Bounds | null {
|
||||||
|
const geometryBounds = getGeometryLocalBounds(context, node)
|
||||||
|
if (geometryBounds) return geometryBounds
|
||||||
|
|
||||||
|
if (!node.size) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: 0,
|
||||||
|
minY: 0,
|
||||||
|
maxX: node.size.x,
|
||||||
|
maxY: node.size.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGeometryLocalBounds(context: RenderContext, node: FigNode): Bounds | null {
|
||||||
|
const geometries = [...(node.fillGeometry ?? []), ...(node.strokeGeometry ?? [])]
|
||||||
|
let bounds: Bounds | null = null
|
||||||
|
|
||||||
|
for (const geometry of geometries) {
|
||||||
|
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
|
||||||
|
bounds = bounds ? unionBounds(bounds, parsed.bounds) : { ...parsed.bounds }
|
||||||
|
}
|
||||||
|
|
||||||
|
return bounds && Number.isFinite(bounds.minX) ? bounds : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortedChildren(context: RenderContext, node: FigNode): FigNode[] {
|
||||||
|
return [...(context.childrenByParent.get(keyForGuid(node.guid)) ?? [])].sort((a, b) =>
|
||||||
|
(a.parentIndex?.position ?? "").localeCompare(b.parentIndex?.position ?? "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextId(context: RenderContext, prefix: string): string {
|
||||||
|
context.idSeed += 1
|
||||||
|
return `${prefix}-${context.idSeed}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSvgMatrix(matrix?: FigmaMatrix): SvgMatrix {
|
||||||
|
if (!matrix) return IDENTITY
|
||||||
|
return [matrix.m00, matrix.m10, matrix.m01, matrix.m11, matrix.m02, matrix.m12]
|
||||||
|
}
|
||||||
|
|
||||||
|
function matrixToAttribute(matrix: SvgMatrix): string {
|
||||||
|
if (matrix.every((value, index) => value === IDENTITY[index])) return ""
|
||||||
|
return ` transform="matrix(${matrix.map(format).join(" ")})"`
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiply(left: SvgMatrix, right: SvgMatrix): SvgMatrix {
|
||||||
|
const [a1, b1, c1, d1, e1, f1] = left
|
||||||
|
const [a2, b2, c2, d2, e2, f2] = right
|
||||||
|
|
||||||
|
return [
|
||||||
|
a1 * a2 + c1 * b2,
|
||||||
|
b1 * a2 + d1 * b2,
|
||||||
|
a1 * c2 + c1 * d2,
|
||||||
|
b1 * c2 + d1 * d2,
|
||||||
|
a1 * e2 + c1 * f2 + e1,
|
||||||
|
b1 * e2 + d1 * f2 + f1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function invert(matrix: SvgMatrix): SvgMatrix {
|
||||||
|
const [a, b, c, d, e, f] = matrix
|
||||||
|
const determinant = a * d - b * c
|
||||||
|
if (Math.abs(determinant) < Number.EPSILON) return IDENTITY
|
||||||
|
|
||||||
|
return [
|
||||||
|
d / determinant,
|
||||||
|
-b / determinant,
|
||||||
|
-c / determinant,
|
||||||
|
a / determinant,
|
||||||
|
(c * f - d * e) / determinant,
|
||||||
|
(b * e - a * f) / determinant
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToPoint(matrix: SvgMatrix, x: number, y: number) {
|
||||||
|
const [a, b, c, d, e, f] = matrix
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: a * x + c * y + e,
|
||||||
|
y: b * x + d * y + f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformBounds(matrix: SvgMatrix, bounds: Bounds): Bounds {
|
||||||
|
const points = [
|
||||||
|
applyToPoint(matrix, bounds.minX, bounds.minY),
|
||||||
|
applyToPoint(matrix, bounds.maxX, bounds.minY),
|
||||||
|
applyToPoint(matrix, bounds.maxX, bounds.maxY),
|
||||||
|
applyToPoint(matrix, bounds.minX, bounds.maxY)
|
||||||
|
]
|
||||||
|
|
||||||
|
return points.reduce((next, point) => includePoint(next, point.x, point.y), createEmptyBounds())
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandBoundsForEffects(bounds: Bounds, effects: FigEffect[] | undefined): Bounds {
|
||||||
|
const next = { ...bounds }
|
||||||
|
for (const effect of effects ?? []) {
|
||||||
|
if (effect.visible === false || effect.type !== "DROP_SHADOW") continue
|
||||||
|
|
||||||
|
const radius = effect.radius ?? 0
|
||||||
|
const spread = effect.spread ?? 0
|
||||||
|
const offsetX = effect.offset?.x ?? 0
|
||||||
|
const offsetY = effect.offset?.y ?? 0
|
||||||
|
next.minX = Math.min(next.minX, bounds.minX + offsetX - radius - spread)
|
||||||
|
next.maxX = Math.max(next.maxX, bounds.maxX + offsetX + radius + spread)
|
||||||
|
next.minY = Math.min(next.minY, bounds.minY + offsetY - radius - spread)
|
||||||
|
next.maxY = Math.max(next.maxY, bounds.maxY + offsetY + radius + spread)
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyBounds(): Bounds {
|
||||||
|
return {
|
||||||
|
minX: Number.POSITIVE_INFINITY,
|
||||||
|
minY: Number.POSITIVE_INFINITY,
|
||||||
|
maxX: Number.NEGATIVE_INFINITY,
|
||||||
|
maxY: Number.NEGATIVE_INFINITY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function includeBounds(context: RenderContext, bounds: Bounds) {
|
||||||
|
context.bounds = context.bounds
|
||||||
|
? {
|
||||||
|
minX: Math.min(context.bounds.minX, bounds.minX),
|
||||||
|
minY: Math.min(context.bounds.minY, bounds.minY),
|
||||||
|
maxX: Math.max(context.bounds.maxX, bounds.maxX),
|
||||||
|
maxY: Math.max(context.bounds.maxY, bounds.maxY)
|
||||||
|
}
|
||||||
|
: { ...bounds }
|
||||||
|
}
|
||||||
|
|
||||||
|
function unionBounds(left: Bounds, right: Bounds): Bounds {
|
||||||
|
return {
|
||||||
|
minX: Math.min(left.minX, right.minX),
|
||||||
|
minY: Math.min(left.minY, right.minY),
|
||||||
|
maxX: Math.max(left.maxX, right.maxX),
|
||||||
|
maxY: Math.max(left.maxY, right.maxY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function includePoint(bounds: Bounds, x: number, y: number): Bounds {
|
||||||
|
bounds.minX = Math.min(bounds.minX, x)
|
||||||
|
bounds.minY = Math.min(bounds.minY, y)
|
||||||
|
bounds.maxX = Math.max(bounds.maxX, x)
|
||||||
|
bounds.maxY = Math.max(bounds.maxY, y)
|
||||||
|
return bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorToCss(color?: FigColor): string {
|
||||||
|
if (!color) return "#000000"
|
||||||
|
|
||||||
|
return `rgb(${toByte(color.r)} ${toByte(color.g)} ${toByte(color.b)})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toByte(value: number): number {
|
||||||
|
return Math.round(Math.max(0, Math.min(1, value)) * 255)
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(value: string): Uint8Array {
|
||||||
|
const buffer = Buffer.from(value, "base64")
|
||||||
|
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttribute(value: string): string {
|
||||||
|
return value.replace(/"/g, """)
|
||||||
|
}
|
||||||
|
|
||||||
|
function format(value: number): string {
|
||||||
|
return Number.isInteger(value) ? String(value) : Number(value.toFixed(4)).toString()
|
||||||
|
}
|
||||||
89
src/services/fig-types.ts
Normal file
89
src/services/fig-types.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
export type Guid = {
|
||||||
|
sessionID: number
|
||||||
|
localID: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FigmaMatrix = {
|
||||||
|
m00: number
|
||||||
|
m01: number
|
||||||
|
m02: number
|
||||||
|
m10: number
|
||||||
|
m11: number
|
||||||
|
m12: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FigArcData = {
|
||||||
|
startingAngle?: number
|
||||||
|
endingAngle?: number
|
||||||
|
innerRadius?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FigColor = {
|
||||||
|
r: number
|
||||||
|
g: number
|
||||||
|
b: number
|
||||||
|
a: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FigPaint = {
|
||||||
|
type: "SOLID" | "GRADIENT_LINEAR" | string
|
||||||
|
color?: FigColor
|
||||||
|
opacity?: number
|
||||||
|
visible?: boolean
|
||||||
|
stops?: Array<{ color: FigColor; position: number }>
|
||||||
|
transform?: FigmaMatrix
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FigGeometry = {
|
||||||
|
commandsBlob: number
|
||||||
|
windingRule?: string
|
||||||
|
styleID?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FigEffect = {
|
||||||
|
type: string
|
||||||
|
visible?: boolean
|
||||||
|
offset?: { x: number; y: number }
|
||||||
|
radius?: number
|
||||||
|
spread?: number
|
||||||
|
showShadowBehindNode?: boolean
|
||||||
|
color?: FigColor
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
strokeWeight?: number
|
||||||
|
strokeAlign?: "CENTER" | "INSIDE" | "OUTSIDE"
|
||||||
|
arcData?: FigArcData
|
||||||
|
fillPaints?: FigPaint[]
|
||||||
|
strokePaints?: FigPaint[]
|
||||||
|
fillGeometry?: FigGeometry[]
|
||||||
|
strokeGeometry?: FigGeometry[]
|
||||||
|
effects?: FigEffect[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FigJson = {
|
||||||
|
type?: string
|
||||||
|
sessionID?: number
|
||||||
|
ackID?: number
|
||||||
|
nodeChanges?: FigNode[]
|
||||||
|
blobs?: string[]
|
||||||
|
__figmaToJson?: {
|
||||||
|
schema: string
|
||||||
|
delimiter: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Bounds = {
|
||||||
|
minX: number
|
||||||
|
minY: number
|
||||||
|
maxX: number
|
||||||
|
maxY: number
|
||||||
|
}
|
||||||
104
src/services/fig2json.ts
Normal file
104
src/services/fig2json.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { ByteBuffer, compileSchema, decodeBinarySchema } from "kiwi-schema"
|
||||||
|
import { decompress as decompressZstd } from "fzstd"
|
||||||
|
import UzipModule from "uzip"
|
||||||
|
import type { FigJson } from "./fig-types.js"
|
||||||
|
|
||||||
|
const UZIP = (UzipModule as any).default ?? UzipModule
|
||||||
|
const FIGMA_TO_JSON_METADATA_KEY = "__figmaToJson"
|
||||||
|
const ZSTD_MAGIC = [0x28, 0xb5, 0x2f, 0xfd]
|
||||||
|
const PNG_MAGIC = [137, 80, 78, 71]
|
||||||
|
|
||||||
|
type FigmaBinaryParts = {
|
||||||
|
delimiter: number
|
||||||
|
parts: Uint8Array<ArrayBufferLike>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function figToJson(fileBuffer: Uint8Array | ArrayBuffer): FigJson {
|
||||||
|
const { delimiter, parts } = figToBinaryParts(fileBuffer)
|
||||||
|
assertFigmaParts(parts)
|
||||||
|
const [schemaByte, dataByte] = parts
|
||||||
|
|
||||||
|
const schema = decodeBinarySchema(new ByteBuffer(schemaByte))
|
||||||
|
const dataBB = new ByteBuffer(dataByte)
|
||||||
|
const schemaHelper = compileSchema(schema)
|
||||||
|
const json = schemaHelper.decodeMessage(dataBB) as FigJson
|
||||||
|
|
||||||
|
return {
|
||||||
|
...convertBlobsToBase64(json),
|
||||||
|
[FIGMA_TO_JSON_METADATA_KEY]: {
|
||||||
|
schema: bytesToBase64(schemaByte),
|
||||||
|
delimiter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function figToBinaryParts(fileBuffer: Uint8Array | ArrayBuffer): FigmaBinaryParts {
|
||||||
|
let fileByte: Uint8Array<ArrayBufferLike> = fileBuffer instanceof Uint8Array ? fileBuffer : new Uint8Array(fileBuffer)
|
||||||
|
|
||||||
|
if (!isKiwiFile(fileByte)) {
|
||||||
|
const unzipped = UZIP.parse(toArrayBuffer(fileByte))
|
||||||
|
const canvas = unzipped["canvas.fig"]
|
||||||
|
if (!canvas) {
|
||||||
|
throw new Error("未找到 canvas.fig,文件可能不是有效的 .fig")
|
||||||
|
}
|
||||||
|
fileByte = new Uint8Array(canvas.buffer, canvas.byteOffset, canvas.byteLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = 8
|
||||||
|
const delimiter = readUint32(fileByte, start)
|
||||||
|
start += 4
|
||||||
|
|
||||||
|
const parts: Uint8Array<ArrayBufferLike>[] = []
|
||||||
|
while (start < fileByte.length) {
|
||||||
|
const size = readUint32(fileByte, start)
|
||||||
|
start += 4
|
||||||
|
|
||||||
|
let part: Uint8Array<ArrayBufferLike> = fileByte.slice(start, start + size)
|
||||||
|
if (startsWith(part, ZSTD_MAGIC)) {
|
||||||
|
part = decompressZstd(part)
|
||||||
|
} else if (!startsWith(part, PNG_MAGIC)) {
|
||||||
|
part = UZIP.inflateRaw(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(part)
|
||||||
|
start += size
|
||||||
|
}
|
||||||
|
|
||||||
|
return { delimiter, parts }
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertBlobsToBase64(json: FigJson): FigJson {
|
||||||
|
const blobs = (json as any).blobs
|
||||||
|
if (!Array.isArray(blobs)) return json
|
||||||
|
|
||||||
|
return {
|
||||||
|
...json,
|
||||||
|
blobs: blobs.map((blob: any) => bytesToBase64(blob.bytes ?? blob))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFigmaParts(parts: Uint8Array[]): void {
|
||||||
|
if (parts.length < 2) {
|
||||||
|
throw new Error("fig 文件缺少 schema 或 data 数据段")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKiwiFile(bytes: Uint8Array): boolean {
|
||||||
|
return bytes.length >= 8 && String.fromCharCode(...bytes.slice(0, 8)) === "fig-kiwi"
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWith(bytes: Uint8Array, magic: number[]): boolean {
|
||||||
|
return magic.every((value, index) => bytes[index] === value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUint32(bytes: Uint8Array, offset: number): number {
|
||||||
|
return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64")
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArrayBuffer(bytes: Uint8Array<ArrayBufferLike>): ArrayBuffer {
|
||||||
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||||
|
}
|
||||||
30
src/utils/node-id.ts
Normal file
30
src/utils/node-id.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { Guid } from "../services/fig-types.js"
|
||||||
|
|
||||||
|
export function keyForGuid(guid?: Guid): string {
|
||||||
|
return guid ? `${guid.sessionID}:${guid.localID}` : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeNodeId(value?: string): string | null {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const text = safeDecodeURIComponent(value.trim())
|
||||||
|
const nodeIdParam = text.match(/(?:^|[?&#\s])node-id=([^&#\s]+)/i)?.[1]
|
||||||
|
const candidate = safeDecodeURIComponent(nodeIdParam ?? text).trim()
|
||||||
|
const match = candidate.match(/^(\d+)[:\-](\d+)$/)
|
||||||
|
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
return `${match[1]}:${match[2]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeFilePart(value: string): string {
|
||||||
|
return value.replace(/[^a-z0-9\u4e00-\u9fa5_-]+/gi, "-").replace(/^-|-$/g, "") || "node"
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDecodeURIComponent(value: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value)
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
10
tsup.config.ts
Normal file
10
tsup.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "tsup"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/bin.ts", "src/index.ts"],
|
||||||
|
format: ["esm"],
|
||||||
|
target: "node20",
|
||||||
|
dts: true,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user