mirror of
https://github.com/KwiTsukasa/figma-local-context-mcp.git
synced 2026-05-27 16:45:46 +08:00
完善本地 Figma PNG 导出拟真能力
This commit is contained in:
parent
e249b5d652
commit
21198de5c2
18
README.md
18
README.md
@ -13,7 +13,7 @@
|
||||
- `get_design_tokens`:从全文件或指定节点子树中推导颜色、渐变、阴影、描边 token。
|
||||
- `inspect_fig_file`:读取本地 `.fig`,返回节点数量、类型统计和节点概览。
|
||||
- `get_fig_node`:按节点名、`1234:5678`、`1234-5678`、`node-id=1234-5678` 或完整 Figma 链接查找节点,并返回简化上下文。
|
||||
- `export_fig_node`:把指定节点导出为 SVG 或 PNG,PNG 支持倍率。
|
||||
- `export_fig_node`:把指定节点导出为 SVG 或 PNG,PNG 支持倍率,默认走本地 `figma-like` 渲染。它会在本 MCP 生成的 SVG 上对部分滤镜/内阴影做补偿后再用 `@resvg/resvg-js` 栅格化,目标是接近 Figma 在线端 PNG,但不等同于 Figma 原生 PNG 导出。
|
||||
|
||||
## 示例参数
|
||||
|
||||
@ -97,10 +97,24 @@
|
||||
"nodeQuery": "1234-5678",
|
||||
"outputPath": "C:\\Users\\you\\Exports\\figma-local-context\\sample-node.png",
|
||||
"format": "png",
|
||||
"scale": 2
|
||||
"scale": 2,
|
||||
"pngRenderer": "figma-like"
|
||||
}
|
||||
```
|
||||
|
||||
导出结果中的 `renderer` 字段会标明导出管线:
|
||||
|
||||
- `local-svg`:直接写出本地解码生成的 SVG。
|
||||
- `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。
|
||||
|
||||
导出结果还会包含 `exportCapabilities`:
|
||||
|
||||
- `localSvg.supported: true`:支持从本地 `.fig` 解码生成 SVG。
|
||||
- `localPng.supported: true`:支持把本地 SVG 栅格化成预览 PNG。
|
||||
- `figmaLikePng.supported: true`:支持本地 Figma-like PNG 近似渲染,会对 Figma 原生 PNG 中更强的内阴影/透明边缘做补偿。
|
||||
- `figmaNativePng.supported: false`:纯本地 `.fig` 解码无法调用 Figma 原生 PNG 渲染器;如果需要与 Figma 在线 PNG 像素级一致,需要接入 Figma 官方运行时/API/桌面端导出能力。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
|
||||
@ -13,7 +13,8 @@ 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."
|
||||
description:
|
||||
"Read local .fig files, inspect node context, export local SVG, and render Figma-like PNGs without Figma API/native renderer."
|
||||
}
|
||||
|
||||
const inspectParams = z.object({
|
||||
@ -37,9 +38,13 @@ const exportNodeParams = z.object({
|
||||
.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。")
|
||||
format: z.enum(["svg", "png"]).default("png").describe("导出格式;png 默认使用本地 figma-like 渲染。"),
|
||||
scale: z.number().positive().max(10).default(2).describe("导出倍率,PNG 预览常用 1/2/3/4。"),
|
||||
background: z.string().optional().describe("可选背景色,例如 #ffffff。"),
|
||||
pngRenderer: z
|
||||
.enum(["figma-like", "local-preview"])
|
||||
.default("figma-like")
|
||||
.describe("PNG 渲染器:figma-like 会增强滤镜/内阴影以接近 Figma PNG;local-preview 是普通 SVG 栅格化。")
|
||||
})
|
||||
|
||||
const listNodesParams = z.object({
|
||||
@ -78,9 +83,13 @@ const exportAssetsParams = z.object({
|
||||
.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。")
|
||||
format: z.enum(["svg", "png"]).default("png").describe("导出格式;png 默认使用本地 figma-like 渲染。"),
|
||||
scale: z.number().positive().max(10).default(2).describe("导出倍率,PNG 预览常用 1/2/3/4。"),
|
||||
background: z.string().optional().describe("可选背景色,例如 #ffffff。"),
|
||||
pngRenderer: z
|
||||
.enum(["figma-like", "local-preview"])
|
||||
.default("figma-like")
|
||||
.describe("PNG 渲染器:figma-like 会增强滤镜/内阴影以接近 Figma PNG;local-preview 是普通 SVG 栅格化。")
|
||||
})
|
||||
|
||||
const designTokensParams = z.object({
|
||||
@ -161,7 +170,7 @@ export function createServer(): McpServer {
|
||||
"export_assets",
|
||||
{
|
||||
title: "Export design assets",
|
||||
description: "批量导出本地 .fig/.fig.json 中的多个节点为 SVG 或 PNG。",
|
||||
description: "批量导出本地 .fig/.fig.json 中的多个节点为 SVG 或本地 figma-like PNG。",
|
||||
inputSchema: exportAssetsParams,
|
||||
annotations: { readOnlyHint: false, openWorldHint: true }
|
||||
},
|
||||
@ -173,7 +182,8 @@ export function createServer(): McpServer {
|
||||
outputDir: params.outputDir,
|
||||
format: params.format,
|
||||
scale: params.scale,
|
||||
background: params.background
|
||||
background: params.background,
|
||||
pngRenderer: params.pngRenderer
|
||||
})
|
||||
)
|
||||
)
|
||||
@ -215,7 +225,7 @@ export function createServer(): McpServer {
|
||||
"export_fig_node",
|
||||
{
|
||||
title: "Export local Figma node",
|
||||
description: "把本地 .fig/.fig.json 中的指定节点导出为 SVG 或 PNG。",
|
||||
description: "把本地 .fig/.fig.json 中的指定节点导出为 SVG 或本地 figma-like PNG。",
|
||||
inputSchema: exportNodeParams,
|
||||
annotations: { readOnlyHint: false, openWorldHint: true }
|
||||
},
|
||||
@ -227,7 +237,8 @@ export function createServer(): McpServer {
|
||||
outputPath: params.outputPath,
|
||||
format: params.format,
|
||||
scale: params.scale,
|
||||
background: params.background
|
||||
background: params.background,
|
||||
pngRenderer: params.pngRenderer
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import path from "node:path"
|
||||
import type { FigColor, FigJson, FigNode, FigPaint } from "./fig-types.js"
|
||||
import { exportFigNode, type ExportNodeResult } from "./export-node.js"
|
||||
import { exportFigNode, type ExportNodeResult, type PngRenderer } 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"
|
||||
@ -33,6 +33,7 @@ export type ExportAssetsOptions = {
|
||||
format: "svg" | "png"
|
||||
scale: number
|
||||
background?: string
|
||||
pngRenderer?: PngRenderer
|
||||
}
|
||||
|
||||
export function listFigNodes(filePath: string, options: NodeListOptions) {
|
||||
@ -102,7 +103,8 @@ export function exportAssets(options: ExportAssetsOptions) {
|
||||
outputPath: path.join(options.outputDir, fileName),
|
||||
format: options.format,
|
||||
scale: options.scale,
|
||||
background: options.background
|
||||
background: options.background,
|
||||
pngRenderer: options.pngRenderer
|
||||
})
|
||||
)
|
||||
}
|
||||
@ -159,7 +161,9 @@ function buildCodeHints(node: FigNode, childrenByParent: Map<string, FigNode[]>,
|
||||
},
|
||||
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.`
|
||||
? `Use export_fig_node or export_assets with nodeQuery "${keyForGuid(
|
||||
node.guid
|
||||
)}". SVG is structural local output; PNG is local SVG rasterization, not Figma native PNG export.`
|
||||
: undefined
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import zlib from "node:zlib"
|
||||
import { Resvg } from "@resvg/resvg-js"
|
||||
import { loadFigFile } from "./fig-file.js"
|
||||
import { renderNodeToSvg } from "./fig-node-svg.js"
|
||||
import { renderNodeToSvg, type FigmaLikeRasterHint } from "./fig-node-svg.js"
|
||||
import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js"
|
||||
|
||||
export type PngRenderer = "figma-like" | "local-preview"
|
||||
|
||||
const MASK_SUPERSAMPLE = 8
|
||||
|
||||
export type ExportNodeOptions = {
|
||||
filePath: string
|
||||
nodeQuery: string
|
||||
@ -12,6 +17,7 @@ export type ExportNodeOptions = {
|
||||
format: "svg" | "png"
|
||||
scale: number
|
||||
background?: string
|
||||
pngRenderer?: PngRenderer
|
||||
}
|
||||
|
||||
export type ExportNodeResult = {
|
||||
@ -21,6 +27,31 @@ export type ExportNodeResult = {
|
||||
scale: number
|
||||
width: number
|
||||
height: number
|
||||
renderer: "local-svg" | "local-svg-resvg" | "local-figma-like-resvg"
|
||||
exportCapabilities: {
|
||||
localSvg: {
|
||||
supported: true
|
||||
renderer: "figma-local-context-mcp"
|
||||
source: "decoded-local-fig"
|
||||
}
|
||||
localPng: {
|
||||
supported: true
|
||||
renderer: "@resvg/resvg-js"
|
||||
source: "mcp-generated-svg"
|
||||
fidelity: "preview"
|
||||
}
|
||||
figmaLikePng: {
|
||||
supported: true
|
||||
renderer: "@resvg/resvg-js"
|
||||
source: "mcp-generated-svg-with-figma-like-filter-compensation"
|
||||
fidelity: "approximate"
|
||||
}
|
||||
figmaNativePng: {
|
||||
supported: false
|
||||
reason: string
|
||||
}
|
||||
}
|
||||
warnings?: string[]
|
||||
node: {
|
||||
id: string
|
||||
name?: string
|
||||
@ -30,20 +61,46 @@ export type ExportNodeResult = {
|
||||
|
||||
export function exportFigNode(options: ExportNodeOptions): ExportNodeResult {
|
||||
const figJson = loadFigFile(options.filePath)
|
||||
const pngRenderer = options.pngRenderer ?? "figma-like"
|
||||
const useFigmaLikePng = options.format === "png" && pngRenderer === "figma-like"
|
||||
const rendered = renderNodeToSvg(figJson, {
|
||||
nodeQuery: options.nodeQuery,
|
||||
scale: options.scale,
|
||||
background: options.background
|
||||
background: options.background,
|
||||
pngFigmaLike: useFigmaLikePng
|
||||
})
|
||||
const outputPath = path.resolve(options.outputPath ?? defaultOutputPath(options))
|
||||
const node = {
|
||||
id: keyForGuid(rendered.node.guid),
|
||||
name: rendered.node.name,
|
||||
type: rendered.node.type
|
||||
}
|
||||
|
||||
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())
|
||||
const image = new Resvg(rendered.svg).render()
|
||||
if (useFigmaLikePng && rendered.rasterHints.length) {
|
||||
const pixels = Buffer.from(image.pixels)
|
||||
unpremultiplyPixels(pixels)
|
||||
applyFigmaLikeRasterHints(pixels, rendered.width, rendered.height, rendered.viewBox, rendered.rasterHints)
|
||||
fs.writeFileSync(outputPath, encodeRgbaPng(rendered.width, rendered.height, pixels))
|
||||
} else {
|
||||
fs.writeFileSync(outputPath, image.asPng())
|
||||
}
|
||||
}
|
||||
|
||||
const warnings =
|
||||
options.format === "png"
|
||||
? [
|
||||
pngRenderer === "figma-like"
|
||||
? "PNG 使用本地 figma-like 渲染:会对 Figma 内阴影/透明边缘做近似补偿,但仍不保证与 Figma 原生 PNG 像素级完全一致。"
|
||||
: "PNG 使用本地 SVG + @resvg/resvg-js 预览渲染,不保证与 Figma 原生 PNG 一致。"
|
||||
]
|
||||
: undefined
|
||||
|
||||
return {
|
||||
filePath: path.resolve(options.filePath),
|
||||
outputPath,
|
||||
@ -51,10 +108,36 @@ export function exportFigNode(options: ExportNodeOptions): ExportNodeResult {
|
||||
scale: options.scale,
|
||||
width: rendered.width,
|
||||
height: rendered.height,
|
||||
node: {
|
||||
id: keyForGuid(rendered.node.guid),
|
||||
name: rendered.node.name,
|
||||
type: rendered.node.type
|
||||
renderer: options.format === "svg" ? "local-svg" : useFigmaLikePng ? "local-figma-like-resvg" : "local-svg-resvg",
|
||||
exportCapabilities: getExportCapabilities(),
|
||||
warnings,
|
||||
node
|
||||
}
|
||||
}
|
||||
|
||||
function getExportCapabilities(): ExportNodeResult["exportCapabilities"] {
|
||||
return {
|
||||
localSvg: {
|
||||
supported: true,
|
||||
renderer: "figma-local-context-mcp",
|
||||
source: "decoded-local-fig"
|
||||
},
|
||||
localPng: {
|
||||
supported: true,
|
||||
renderer: "@resvg/resvg-js",
|
||||
source: "mcp-generated-svg",
|
||||
fidelity: "preview"
|
||||
},
|
||||
figmaLikePng: {
|
||||
supported: true,
|
||||
renderer: "@resvg/resvg-js",
|
||||
source: "mcp-generated-svg-with-figma-like-filter-compensation",
|
||||
fidelity: "approximate"
|
||||
},
|
||||
figmaNativePng: {
|
||||
supported: false,
|
||||
reason:
|
||||
"Figma native PNG export uses Figma's own renderer. This MCP implements a local figma-like approximation instead of calling Figma."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -65,3 +148,231 @@ function defaultOutputPath(options: ExportNodeOptions): string {
|
||||
const scalePart = options.format === "png" ? `@${options.scale}x` : ""
|
||||
return path.join(process.cwd(), `${input.name}-${nodePart}${scalePart}.${options.format}`)
|
||||
}
|
||||
|
||||
function applyFigmaLikeRasterHints(
|
||||
pixels: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
viewBox: { minX: number; minY: number; maxX: number; maxY: number },
|
||||
hints: FigmaLikeRasterHint[]
|
||||
) {
|
||||
const sx = width / (viewBox.maxX - viewBox.minX)
|
||||
const sy = height / (viewBox.maxY - viewBox.minY)
|
||||
|
||||
for (const hint of hints) {
|
||||
if (hint.type !== "ellipse-inner-shadow") continue
|
||||
|
||||
const inverse = invertMatrix(hint.matrix)
|
||||
const color = {
|
||||
r: toByte(hint.color.r),
|
||||
g: toByte(hint.color.g),
|
||||
b: toByte(hint.color.b)
|
||||
}
|
||||
const masks = createEllipseInnerShadowMasks(width, height, viewBox, sx, sy, hint, inverse)
|
||||
const blurScale = (Math.abs(sx) + Math.abs(sy)) / 2
|
||||
const blurredErodedMask = gaussianBlurMask(masks.erodedMask, width, height, Math.max(0.5, hint.blurSigma * blurScale))
|
||||
|
||||
for (let index = 0; index < masks.hardMask.length; index += 1) {
|
||||
if (masks.hardMask[index] === 0) continue
|
||||
|
||||
const shadowCoverage = Math.max(0, masks.hardMask[index] - blurredErodedMask[index])
|
||||
const shadowAlpha = Math.min(1, shadowCoverage * hint.opacity)
|
||||
if (shadowAlpha <= 0.001) continue
|
||||
|
||||
compositePixel(pixels, index * 4, color.r, color.g, color.b, shadowAlpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createEllipseInnerShadowMasks(
|
||||
width: number,
|
||||
height: number,
|
||||
viewBox: { minX: number; minY: number; maxX: number; maxY: number },
|
||||
sx: number,
|
||||
sy: number,
|
||||
hint: FigmaLikeRasterHint,
|
||||
inverse: readonly [number, number, number, number, number, number]
|
||||
) {
|
||||
const hardMask = new Float32Array(width * height)
|
||||
const erodedMask = new Float32Array(width * height)
|
||||
const erodedRx = Math.max(0.1, hint.rx - hint.spread)
|
||||
const erodedRy = Math.max(0.1, hint.ry - hint.spread)
|
||||
const sampleWeight = 1 / (MASK_SUPERSAMPLE * MASK_SUPERSAMPLE)
|
||||
|
||||
// Figma's native PNG export appears to build inner shadow coverage from a
|
||||
// vector mask, then erode and blur that mask. Subpixel coverage is important:
|
||||
// a hard pixel-center mask makes shallow ellipse edges visibly stair-step.
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const index = y * width + x
|
||||
let hardCoverage = 0
|
||||
let erodedCoverage = 0
|
||||
|
||||
for (let sampleY = 0; sampleY < MASK_SUPERSAMPLE; sampleY += 1) {
|
||||
const worldY = viewBox.minY + (y + (sampleY + 0.5) / MASK_SUPERSAMPLE) / sy
|
||||
for (let sampleX = 0; sampleX < MASK_SUPERSAMPLE; sampleX += 1) {
|
||||
const worldX = viewBox.minX + (x + (sampleX + 0.5) / MASK_SUPERSAMPLE) / sx
|
||||
const local = applyMatrix(inverse, worldX, worldY)
|
||||
const normalizedX = (local.x - hint.cx) / hint.rx
|
||||
const normalizedY = (local.y - hint.cy) / hint.ry
|
||||
if (normalizedX * normalizedX + normalizedY * normalizedY > 1) continue
|
||||
|
||||
hardCoverage += sampleWeight
|
||||
|
||||
const erodedX = (local.x - hint.cx) / erodedRx
|
||||
const erodedY = (local.y - hint.cy) / erodedRy
|
||||
if (erodedX * erodedX + erodedY * erodedY <= 1) {
|
||||
erodedCoverage += sampleWeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hardMask[index] = hardCoverage
|
||||
erodedMask[index] = erodedCoverage
|
||||
}
|
||||
}
|
||||
|
||||
return { hardMask, erodedMask }
|
||||
}
|
||||
|
||||
function gaussianBlurMask(mask: Float32Array, width: number, height: number, sigma: number): Float32Array {
|
||||
if (sigma <= 0.01) return mask
|
||||
|
||||
const kernel = createGaussianKernel(sigma)
|
||||
const radius = (kernel.length - 1) / 2
|
||||
const temp = new Float32Array(mask.length)
|
||||
const output = new Float32Array(mask.length)
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
let value = 0
|
||||
for (let offset = -radius; offset <= radius; offset += 1) {
|
||||
const sampleX = Math.max(0, Math.min(width - 1, x + offset))
|
||||
value += mask[y * width + sampleX] * kernel[offset + radius]
|
||||
}
|
||||
temp[y * width + x] = value
|
||||
}
|
||||
}
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
let value = 0
|
||||
for (let offset = -radius; offset <= radius; offset += 1) {
|
||||
const sampleY = Math.max(0, Math.min(height - 1, y + offset))
|
||||
value += temp[sampleY * width + x] * kernel[offset + radius]
|
||||
}
|
||||
output[y * width + x] = value
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function createGaussianKernel(sigma: number): number[] {
|
||||
const radius = Math.max(1, Math.ceil(sigma * 3))
|
||||
const kernel: number[] = []
|
||||
let sum = 0
|
||||
|
||||
for (let offset = -radius; offset <= radius; offset += 1) {
|
||||
const value = Math.exp(-(offset * offset) / (2 * sigma * sigma))
|
||||
kernel.push(value)
|
||||
sum += value
|
||||
}
|
||||
|
||||
return kernel.map((value) => value / sum)
|
||||
}
|
||||
|
||||
function unpremultiplyPixels(pixels: Buffer) {
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
const alpha = pixels[offset + 3]
|
||||
if (alpha === 0 || alpha === 255) continue
|
||||
|
||||
pixels[offset] = Math.min(255, Math.round((pixels[offset] * 255) / alpha))
|
||||
pixels[offset + 1] = Math.min(255, Math.round((pixels[offset + 1] * 255) / alpha))
|
||||
pixels[offset + 2] = Math.min(255, Math.round((pixels[offset + 2] * 255) / alpha))
|
||||
}
|
||||
}
|
||||
|
||||
function compositePixel(pixels: Buffer, offset: number, sourceR: number, sourceG: number, sourceB: number, sourceA: number) {
|
||||
const destA = pixels[offset + 3] / 255
|
||||
const outA = sourceA + destA * (1 - sourceA)
|
||||
if (outA <= 0) return
|
||||
|
||||
pixels[offset] = Math.round((sourceR * sourceA + pixels[offset] * destA * (1 - sourceA)) / outA)
|
||||
pixels[offset + 1] = Math.round((sourceG * sourceA + pixels[offset + 1] * destA * (1 - sourceA)) / outA)
|
||||
pixels[offset + 2] = Math.round((sourceB * sourceA + pixels[offset + 2] * destA * (1 - sourceA)) / outA)
|
||||
pixels[offset + 3] = Math.round(outA * 255)
|
||||
}
|
||||
|
||||
function encodeRgbaPng(width: number, height: number, pixels: Buffer): Buffer {
|
||||
const stride = width * 4
|
||||
const raw = Buffer.alloc((stride + 1) * height)
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const rowOffset = y * (stride + 1)
|
||||
raw[rowOffset] = 0
|
||||
pixels.copy(raw, rowOffset + 1, y * stride, (y + 1) * stride)
|
||||
}
|
||||
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
pngChunk("IHDR", createIhdr(width, height)),
|
||||
pngChunk("IDAT", zlib.deflateSync(raw)),
|
||||
pngChunk("IEND", Buffer.alloc(0))
|
||||
])
|
||||
}
|
||||
|
||||
function createIhdr(width: number, height: number): Buffer {
|
||||
const data = Buffer.alloc(13)
|
||||
data.writeUInt32BE(width, 0)
|
||||
data.writeUInt32BE(height, 4)
|
||||
data[8] = 8
|
||||
data[9] = 6
|
||||
data[10] = 0
|
||||
data[11] = 0
|
||||
data[12] = 0
|
||||
return data
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Buffer): Buffer {
|
||||
const typeBuffer = Buffer.from(type)
|
||||
const length = Buffer.alloc(4)
|
||||
length.writeUInt32BE(data.length, 0)
|
||||
const crc = Buffer.alloc(4)
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0)
|
||||
return Buffer.concat([length, typeBuffer, data, crc])
|
||||
}
|
||||
|
||||
function crc32(buffer: Buffer): number {
|
||||
let crc = 0xffffffff
|
||||
for (const byte of buffer) {
|
||||
crc ^= byte
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
function invertMatrix(matrix: [number, number, number, number, number, number]) {
|
||||
const [a, b, c, d, e, f] = matrix
|
||||
const determinant = a * d - b * c
|
||||
if (Math.abs(determinant) < Number.EPSILON) return [1, 0, 0, 1, 0, 0] as const
|
||||
|
||||
return [
|
||||
d / determinant,
|
||||
-b / determinant,
|
||||
-c / determinant,
|
||||
a / determinant,
|
||||
(c * f - d * e) / determinant,
|
||||
(b * e - a * f) / determinant
|
||||
] as const
|
||||
}
|
||||
|
||||
function applyMatrix(matrix: readonly [number, number, number, number, number, number], 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 toByte(value: number): number {
|
||||
return Math.round(Math.max(0, Math.min(1, value)) * 255)
|
||||
}
|
||||
|
||||
@ -22,8 +22,24 @@ type RenderContext = {
|
||||
figJson: FigJson
|
||||
childrenByParent: Map<string, FigNode[]>
|
||||
defs: string[]
|
||||
rasterHints: FigmaLikeRasterHint[]
|
||||
bounds: Bounds | null
|
||||
effectBounds: Bounds | null
|
||||
idSeed: number
|
||||
pngFigmaLike: boolean
|
||||
}
|
||||
|
||||
export type FigmaLikeRasterHint = {
|
||||
type: "ellipse-inner-shadow"
|
||||
matrix: SvgMatrix
|
||||
cx: number
|
||||
cy: number
|
||||
rx: number
|
||||
ry: number
|
||||
color: FigColor
|
||||
opacity: number
|
||||
spread: number
|
||||
blurSigma: number
|
||||
}
|
||||
|
||||
export type RenderOptions = {
|
||||
@ -32,6 +48,7 @@ export type RenderOptions = {
|
||||
nodeQuery?: string
|
||||
scale?: number
|
||||
background?: string
|
||||
pngFigmaLike?: boolean
|
||||
}
|
||||
|
||||
export type RenderedSvg = {
|
||||
@ -39,6 +56,7 @@ export type RenderedSvg = {
|
||||
width: number
|
||||
height: number
|
||||
viewBox: Bounds
|
||||
rasterHints: FigmaLikeRasterHint[]
|
||||
node: FigNode
|
||||
}
|
||||
|
||||
@ -55,29 +73,48 @@ export function renderNodeToSvg(figJson: FigJson, options: RenderOptions): Rende
|
||||
figJson,
|
||||
childrenByParent,
|
||||
defs: [],
|
||||
rasterHints: [],
|
||||
bounds: null,
|
||||
idSeed: 0
|
||||
effectBounds: null,
|
||||
idSeed: 0,
|
||||
pngFigmaLike: options.pngFigmaLike ?? false
|
||||
}
|
||||
|
||||
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 bounds = getRootExportBounds(target, context.bounds, context.effectBounds)
|
||||
const width = bounds.maxX - bounds.minX
|
||||
const height = bounds.maxY - bounds.minY
|
||||
const scale = options.scale ?? 2
|
||||
const pixelWidth = getFigmaPixelSize(width, scale)
|
||||
const pixelHeight = getFigmaPixelSize(height, scale)
|
||||
// Figma exports selected nodes at ceil(size * scale) without stretching the
|
||||
// artwork; extra fractional pixels become transparent canvas at the edge.
|
||||
const viewBox = {
|
||||
minX: bounds.minX,
|
||||
minY: bounds.minY,
|
||||
maxX: bounds.minX + pixelWidth / scale,
|
||||
maxY: bounds.minY + pixelHeight / scale
|
||||
}
|
||||
const viewBoxWidth = viewBox.maxX - viewBox.minX
|
||||
const viewBoxHeight = viewBox.maxY - viewBox.minY
|
||||
const background = options.background
|
||||
? `<rect x="${format(bounds.minX)}" y="${format(bounds.minY)}" width="${format(width)}" height="${format(height)}" fill="${escapeAttribute(options.background)}"/>`
|
||||
? `<rect x="${format(viewBox.minX)}" y="${format(viewBox.minY)}" width="${format(
|
||||
viewBoxWidth
|
||||
)}" height="${format(viewBoxHeight)}" 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">`,
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${format(pixelWidth)}" height="${format(
|
||||
pixelHeight
|
||||
)}" viewBox="${format(viewBox.minX)} ${format(viewBox.minY)} ${format(viewBoxWidth)} ${format(
|
||||
viewBoxHeight
|
||||
)}" 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 }
|
||||
return { svg, width: pixelWidth, height: pixelHeight, viewBox, rasterHints: context.rasterHints, node: target }
|
||||
}
|
||||
|
||||
export function findTargetNode(figJson: FigJson, options: RenderOptions): FigNode {
|
||||
@ -120,29 +157,200 @@ function renderNodeSubtree(
|
||||
|
||||
const localMatrix = isRoot ? IDENTITY : toSvgMatrix(node.transform)
|
||||
const matrix = multiply(parentMatrix, localMatrix)
|
||||
// Boolean-operation children are construction geometry. Figma renders the
|
||||
// computed boolean path, not the source shapes again.
|
||||
const shouldRenderChildren =
|
||||
node.type !== "BOOLEAN_OPERATION" || !(node.fillGeometry?.length || node.strokeGeometry?.length)
|
||||
const nodeContent = [
|
||||
...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix),
|
||||
collectFigmaLikeEllipseInnerShadowHint(context, node, matrix),
|
||||
...renderStrokeGeometry(context, node, matrix),
|
||||
...getSortedChildren(context, node).map((child) => renderNodeSubtree(context, child, matrix))
|
||||
...(shouldRenderChildren ? 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>`
|
||||
return `<g${opacity}${filter}>${nodeContent}</g>`
|
||||
}
|
||||
|
||||
function renderStrokeGeometry(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] {
|
||||
const outsideEllipseStroke = renderOutsideEllipseStroke(context, node, matrix)
|
||||
if (outsideEllipseStroke) return outsideEllipseStroke
|
||||
|
||||
const pathStroke = renderPathStroke(context, node, matrix)
|
||||
if (pathStroke) return pathStroke
|
||||
|
||||
return renderGeometry(context, node, node.strokeGeometry, node.strokePaints, matrix)
|
||||
}
|
||||
|
||||
function renderPathStroke(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] | null {
|
||||
const strokeWeight = node.strokeWeight ?? 0
|
||||
if (!node.fillGeometry?.length || !node.strokePaints?.length || strokeWeight <= 0) return null
|
||||
const visibleStrokePaints = node.strokePaints.filter((paint) => paint.visible !== false)
|
||||
const hasGradientStroke = visibleStrokePaints.some((paint) => paint.type === "GRADIENT_LINEAR")
|
||||
if (visibleStrokePaints.some((paint) => paint.type !== "SOLID" && paint.type !== "GRADIENT_LINEAR")) return null
|
||||
if (hasGradientStroke && !shouldRenderGradientStrokeAsPath(node)) return null
|
||||
|
||||
return node.fillGeometry.flatMap((geometry) => {
|
||||
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
|
||||
const expandedBounds = expandBounds(parsed.bounds, strokeWeight / 2)
|
||||
includeBounds(context, transformBounds(matrix, expandedBounds))
|
||||
const strokeMatrix = multiply(matrix, getStrokeAlignmentMatrix(parsed.bounds, strokeWeight, node.strokeAlign))
|
||||
const insetMiterPath = createInsetMiterStrokePath(parsed.d, strokeWeight, node.strokeAlign, matrix)
|
||||
|
||||
// Figma's SVG export keeps normal vector strokes as stroke attributes. The
|
||||
// decoded strokeGeometry is an expanded outline for internal raster use; if
|
||||
// we fill it directly, thin isometric edges become much too thick.
|
||||
return visibleStrokePaints
|
||||
.map((paint) => {
|
||||
const stroke = paintToSvgFill(context, node, parsed.bounds, paint, matrix)
|
||||
const opacity = paintOpacityAttribute("stroke", paint)
|
||||
const dashArray = strokeDashArrayAttribute(node)
|
||||
return `<path d="${insetMiterPath ?? transformPathData(parsed.d, strokeMatrix)}" fill="none" stroke="${stroke}" stroke-width="${format(
|
||||
strokeWeight
|
||||
)}"${opacity}${dashArray}/>`
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function shouldRenderGradientStrokeAsPath(node: FigNode): boolean {
|
||||
return Boolean(node.type === "ELLIPSE" && node.size && isFullEllipse(node.arcData))
|
||||
}
|
||||
|
||||
function createInsetMiterStrokePath(
|
||||
d: string,
|
||||
strokeWeight: number,
|
||||
strokeAlign: FigNode["strokeAlign"] | undefined,
|
||||
matrix: SvgMatrix
|
||||
): string | null {
|
||||
if (strokeAlign !== "INSIDE") return null
|
||||
|
||||
const segments = getStraightSegments(d)
|
||||
if (segments.length !== 4) return null
|
||||
|
||||
const area = getSignedArea(segments.map((segment) => segment.from))
|
||||
const offsetLines = segments.map((segment) => offsetLine(segment.from, segment.to, strokeWeight / 2, area >= 0))
|
||||
const intersections = offsetLines.map((line, index) => intersectLines(offsetLines[(index + 3) % 4], line))
|
||||
if (intersections.some((point) => !point)) return null
|
||||
|
||||
const points = [intersections[1]!, intersections[2]!, intersections[3]!, intersections[0]!].map((point) =>
|
||||
applyToPoint(matrix, point.x, point.y)
|
||||
)
|
||||
|
||||
// For small rounded diamond strokes, Figma's SVG export collapses the side
|
||||
// corner arcs into mitered points. Rebuilding that centerline avoids the flat
|
||||
// caps created by a simple scaled copy of the fill path.
|
||||
return `M ${format(points[0].x)} ${format(points[0].y)} L ${format(points[1].x)} ${format(points[1].y)} L ${format(
|
||||
points[2].x
|
||||
)} ${format(points[2].y)} L ${format(points[3].x)} ${format(points[3].y)} Z`
|
||||
}
|
||||
|
||||
function getStraightSegments(d: string): Array<{ from: { x: number; y: number }; to: { x: number; y: number } }> {
|
||||
const tokens = d.match(/[MLCZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? []
|
||||
const segments: Array<{ from: { x: number; y: number }; to: { x: number; y: number } }> = []
|
||||
let index = 0
|
||||
let current: { x: number; y: number } | null = null
|
||||
|
||||
const readPoint = () => ({ x: Number(tokens[index++]), y: Number(tokens[index++]) })
|
||||
|
||||
while (index < tokens.length) {
|
||||
const command = tokens[index++]
|
||||
if (command === "M") {
|
||||
current = readPoint()
|
||||
} else if (command === "L" && current) {
|
||||
const next = readPoint()
|
||||
segments.push({ from: current, to: next })
|
||||
current = next
|
||||
} else if (command === "C") {
|
||||
index += 4
|
||||
current = readPoint()
|
||||
} else if (command === "Z") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const first = segments[0]
|
||||
const last = segments[segments.length - 1]
|
||||
if (first && last && getPointDistance(first.from, last.to) < 0.001) {
|
||||
segments.pop()
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function getPointDistance(first: { x: number; y: number }, second: { x: number; y: number }): number {
|
||||
return Math.hypot(first.x - second.x, first.y - second.y)
|
||||
}
|
||||
|
||||
function getSignedArea(points: Array<{ x: number; y: number }>): number {
|
||||
let area = 0
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const current = points[index]
|
||||
const next = points[(index + 1) % points.length]
|
||||
area += current.x * next.y - next.x * current.y
|
||||
}
|
||||
return area / 2
|
||||
}
|
||||
|
||||
function offsetLine(from: { x: number; y: number }, to: { x: number; y: number }, distance: number, ccw: boolean) {
|
||||
const dx = to.x - from.x
|
||||
const dy = to.y - from.y
|
||||
const length = Math.hypot(dx, dy)
|
||||
if (length < Number.EPSILON) return { from, dx, dy }
|
||||
|
||||
const normal = ccw ? { x: -dy / length, y: dx / length } : { x: dy / length, y: -dx / length }
|
||||
return {
|
||||
from: { x: from.x + normal.x * distance, y: from.y + normal.y * distance },
|
||||
dx,
|
||||
dy
|
||||
}
|
||||
}
|
||||
|
||||
function intersectLines(
|
||||
first: { from: { x: number; y: number }; dx: number; dy: number },
|
||||
second: { from: { x: number; y: number }; dx: number; dy: number }
|
||||
): { x: number; y: number } | null {
|
||||
const determinant = first.dx * second.dy - first.dy * second.dx
|
||||
if (Math.abs(determinant) < Number.EPSILON) return null
|
||||
|
||||
const t =
|
||||
((second.from.x - first.from.x) * second.dy - (second.from.y - first.from.y) * second.dx) / determinant
|
||||
return {
|
||||
x: first.from.x + first.dx * t,
|
||||
y: first.from.y + first.dy * t
|
||||
}
|
||||
}
|
||||
|
||||
function getStrokeAlignmentMatrix(bounds: Bounds, strokeWeight: number, strokeAlign?: FigNode["strokeAlign"]): SvgMatrix {
|
||||
if (strokeAlign !== "INSIDE" && strokeAlign !== "OUTSIDE") return IDENTITY
|
||||
|
||||
const width = bounds.maxX - bounds.minX
|
||||
const height = bounds.maxY - bounds.minY
|
||||
if (width <= 0 || height <= 0) return IDENTITY
|
||||
|
||||
const direction = strokeAlign === "INSIDE" ? -1 : 1
|
||||
const scaleX = Math.max(0.001, (width + direction * strokeWeight) / width)
|
||||
const scaleY = Math.max(0.001, (height + direction * strokeWeight) / height)
|
||||
const centerX = (bounds.minX + bounds.maxX) / 2
|
||||
const centerY = (bounds.minY + bounds.maxY) / 2
|
||||
|
||||
// Figma's SVG export moves inside/outside stroke centerlines instead of
|
||||
// filling the decoded stroke outline. Scaling around the geometry center is
|
||||
// a compact approximation that matches isometric icon bases much better.
|
||||
return [
|
||||
scaleX,
|
||||
0,
|
||||
0,
|
||||
scaleY,
|
||||
centerX - centerX * scaleX,
|
||||
centerY - centerY * scaleY
|
||||
]
|
||||
}
|
||||
|
||||
function renderOutsideEllipseStroke(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] | null {
|
||||
const strokeWeight = node.strokeWeight ?? 0
|
||||
if (
|
||||
@ -167,17 +375,20 @@ function renderOutsideEllipseStroke(context: RenderContext, node: FigNode, matri
|
||||
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 stroke = paintToSvgFill(context, node, bounds, paint, IDENTITY)
|
||||
const opacity = paintOpacityAttribute("stroke", paint)
|
||||
const rx = node.size!.x / 2 + strokeWeight / 2
|
||||
const ry = node.size!.y / 2 + strokeWeight / 2
|
||||
const transform = matrixToAttribute(matrix)
|
||||
|
||||
// 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}/>`
|
||||
)}" ry="${format(ry)}" fill="none" stroke="${stroke}" stroke-width="${format(
|
||||
strokeWeight
|
||||
)}"${opacity}${transform}/>`
|
||||
})
|
||||
}
|
||||
|
||||
@ -203,14 +414,15 @@ function renderGeometry(
|
||||
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
|
||||
const transformedBounds = transformBounds(matrix, parsed.bounds)
|
||||
includeBounds(context, transformedBounds)
|
||||
const fillRule = geometry.windingRule === "ODD" ? ` fill-rule="evenodd"` : ""
|
||||
|
||||
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)}"` : ""
|
||||
const fill = paintToSvgFill(context, node, parsed.bounds, paint, matrix)
|
||||
const opacity = paintOpacityAttribute("fill", paint)
|
||||
|
||||
return `<path d="${parsed.d}" fill="${fill}"${opacity}/>`
|
||||
return `<path d="${transformPathData(parsed.d, matrix)}" fill="${fill}"${fillRule}${opacity}/>`
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -261,14 +473,45 @@ function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath {
|
||||
return { d: d.trim(), bounds }
|
||||
}
|
||||
|
||||
function paintToSvgFill(context: RenderContext, node: FigNode, pathBounds: Bounds, paint: FigPaint): string {
|
||||
function transformPathData(d: string, matrix: SvgMatrix): string {
|
||||
if (matrix === IDENTITY) return d
|
||||
|
||||
const tokens = d.match(/[MLCZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? []
|
||||
const output: string[] = []
|
||||
let index = 0
|
||||
|
||||
const readNumber = () => Number(tokens[index++])
|
||||
const readPoint = () => applyToPoint(matrix, readNumber(), readNumber())
|
||||
const writePoint = (point: { x: number; y: number }) => `${format(point.x)} ${format(point.y)}`
|
||||
|
||||
while (index < tokens.length) {
|
||||
const command = tokens[index++]
|
||||
if (command === "M" || command === "L") {
|
||||
output.push(`${command} ${writePoint(readPoint())}`)
|
||||
} else if (command === "C") {
|
||||
output.push(`C ${writePoint(readPoint())} ${writePoint(readPoint())} ${writePoint(readPoint())}`)
|
||||
} else if (command === "Z") {
|
||||
output.push("Z")
|
||||
}
|
||||
}
|
||||
|
||||
return output.join(" ")
|
||||
}
|
||||
|
||||
function paintToSvgFill(
|
||||
context: RenderContext,
|
||||
node: FigNode,
|
||||
pathBounds: Bounds,
|
||||
paint: FigPaint,
|
||||
matrix: SvgMatrix
|
||||
): 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 gradient = getLinearGradientLine(node, pathBounds, paint, matrix)
|
||||
const stops = paint.stops
|
||||
?.map(
|
||||
(stop) =>
|
||||
@ -289,7 +532,7 @@ function paintToSvgFill(context: RenderContext, node: FigNode, pathBounds: Bound
|
||||
throw new Error(`不支持的填充类型:${paint.type}`)
|
||||
}
|
||||
|
||||
function getLinearGradientLine(node: FigNode, pathBounds: Bounds, paint: FigPaint) {
|
||||
function getLinearGradientLine(node: FigNode, pathBounds: Bounds, paint: FigPaint, matrix: SvgMatrix) {
|
||||
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
|
||||
@ -300,72 +543,235 @@ function getLinearGradientLine(node: FigNode, pathBounds: Bounds, paint: FigPain
|
||||
// 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)
|
||||
const transformedStart = applyToPoint(matrix, originX + start.x * width, originY + start.y * height)
|
||||
const transformedEnd = applyToPoint(matrix, originX + end.x * width, originY + end.y * height)
|
||||
|
||||
return {
|
||||
x1: originX + start.x * width,
|
||||
y1: originY + start.y * height,
|
||||
x2: originX + end.x * width,
|
||||
y2: originY + end.y * height
|
||||
x1: transformedStart.x,
|
||||
y1: transformedStart.y,
|
||||
x2: transformedEnd.x,
|
||||
y2: transformedEnd.y
|
||||
}
|
||||
}
|
||||
|
||||
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 innerShadows = effects?.filter((effect) => effect.visible !== false && effect.type === "INNER_SHADOW") ?? []
|
||||
const layerBlur = effects?.find(
|
||||
(effect) => effect.visible !== false && (effect.type === "FOREGROUND_BLUR" || effect.type === "LAYER_BLUR")
|
||||
)
|
||||
if (!shadow && !innerShadows.length && !layerBlur) 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
|
||||
const shadowRadius = shadow?.radius ?? 0
|
||||
const blurRadius = layerBlur?.radius ?? 0
|
||||
const spread = shadow?.spread ?? 0
|
||||
const offsetX = shadow?.offset?.x ?? 0
|
||||
const offsetY = shadow?.offset?.y ?? 0
|
||||
const filterPadding = Math.max(shadowRadius + Math.abs(spread), blurRadius)
|
||||
const x = bounds.minX + Math.min(0, offsetX) - filterPadding
|
||||
const y = bounds.minY + Math.min(0, offsetY) - filterPadding
|
||||
const width = bounds.maxX - bounds.minX + Math.abs(offsetX) + filterPadding * 2
|
||||
const height = bounds.maxY - bounds.minY + Math.abs(offsetY) + filterPadding * 2
|
||||
const sourceAlpha = shadow && 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 shadowResult = shadow?.showShadowBehindNode === false ? "visibleShadow" : "shadow"
|
||||
const hideShadowBehindSource =
|
||||
shadow.showShadowBehindNode === false
|
||||
shadow?.showShadowBehindNode === false
|
||||
? `<feComposite in="shadow" in2="SourceAlpha" operator="out" result="visibleShadow"/>`
|
||||
: ""
|
||||
const layerBlurMarkup = layerBlur
|
||||
? `<feGaussianBlur in="SourceGraphic" stdDeviation="${format(blurRadius / 2)}" result="layerBlur"/>`
|
||||
: ""
|
||||
const sourceGraphicResult = layerBlur ? "layerBlur" : "SourceGraphic"
|
||||
const shadowMarkup = shadow
|
||||
? `${sourceAlpha}<feGaussianBlur in="${blurInput}" stdDeviation="${format(
|
||||
shadowRadius / 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}`
|
||||
: ""
|
||||
const shadowMergeNode = shadow ? `<feMergeNode in="${shadowResult}"/>` : ""
|
||||
let innerShadowMarkup = ""
|
||||
let sourceWithInnerShadows = sourceGraphicResult
|
||||
innerShadows.forEach((innerShadow, index) => {
|
||||
const result = `innerShadowShape-${index}`
|
||||
innerShadowMarkup += context.pngFigmaLike
|
||||
? createFigmaLikeInnerShadowMarkup(innerShadow, index, sourceWithInnerShadows, result)
|
||||
: createInnerShadowMarkup(innerShadow, index, sourceWithInnerShadows, result)
|
||||
sourceWithInnerShadows = result
|
||||
})
|
||||
|
||||
// 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.
|
||||
// Figma can combine outside-only shadows with layer blur on one node. Keep
|
||||
// both effects in a single filter so the layer order matches Figma export.
|
||||
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>`
|
||||
)}" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">${shadowMarkup}${layerBlurMarkup}${innerShadowMarkup}<feMerge>${shadowMergeNode}<feMergeNode in="${sourceWithInnerShadows}"/></feMerge></filter>`
|
||||
)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
function createFigmaLikeInnerShadowMarkup(effect: FigEffect, index: number, shapeInput: string, result: string): string {
|
||||
const radius = effect.radius ?? 0
|
||||
const spread = effect.spread ?? 0
|
||||
const offsetX = effect.offset?.x ?? 0
|
||||
const offsetY = effect.offset?.y ?? 0
|
||||
const edgeRadius = Math.max(0.5, spread + 0.5 || radius * 0.25)
|
||||
const blurRadius = Math.max(0, radius * 0.2)
|
||||
const hardShape = `innerHardShape-${index}`
|
||||
const edgeResult = `innerEdge-${index}`
|
||||
const offsetResult = `innerEdgeOffset-${index}`
|
||||
const blurResult = `innerEdgeBlur-${index}`
|
||||
const clippedResult = `innerEdgeClip-${index}`
|
||||
const boostedResult = `innerEdgeBoost-${index}`
|
||||
const colorResult = `innerEdgeColor-${index}`
|
||||
const shadowResult = `innerFigmaLikeShadow-${index}`
|
||||
|
||||
// Figma's native PNG path treats low-opacity source fills as a hard shape
|
||||
// when building inner-shadow coverage. resvg follows the SVG filter literally,
|
||||
// which overfills the center; this compensated edge band keeps the center
|
||||
// translucent and strengthens only the inside edge.
|
||||
return `<feComponentTransfer in="SourceAlpha" result="${hardShape}"><feFuncA type="linear" slope="20"/></feComponentTransfer><feMorphology in="${hardShape}" operator="erode" radius="${format(
|
||||
edgeRadius
|
||||
)}" result="${edgeResult}-eroded"/><feComposite in="${hardShape}" in2="${edgeResult}-eroded" operator="out" result="${edgeResult}"/><feOffset in="${edgeResult}" dx="${format(
|
||||
offsetX
|
||||
)}" dy="${format(offsetY)}" result="${offsetResult}"/><feGaussianBlur in="${offsetResult}" stdDeviation="${format(
|
||||
blurRadius
|
||||
)}" result="${blurResult}"/><feComposite in="${blurResult}" in2="${hardShape}" operator="in" result="${clippedResult}"/><feComponentTransfer in="${clippedResult}" result="${boostedResult}"><feFuncA type="linear" slope="1.2"/></feComponentTransfer><feFlood flood-color="${colorToCss(
|
||||
effect.color
|
||||
)}" flood-opacity="${format(effect.color?.a ?? 1)}" result="${colorResult}"/><feComposite in="${colorResult}" in2="${boostedResult}" operator="in" result="${shadowResult}"/><feBlend mode="normal" in="${shadowResult}" in2="${shapeInput}" result="${result}"/>`
|
||||
}
|
||||
|
||||
function createInnerShadowMarkup(effect: FigEffect, index: number, shapeInput: string, result: string): string {
|
||||
const radius = effect.radius ?? 0
|
||||
const spread = effect.spread ?? 0
|
||||
const offsetX = effect.offset?.x ?? 0
|
||||
const offsetY = effect.offset?.y ?? 0
|
||||
const hardAlpha = `innerHardAlpha-${index}`
|
||||
const spreadResult = `innerSpread-${index}`
|
||||
const offsetResult = `innerOffset-${index}`
|
||||
const blurResult = `innerBlur-${index}`
|
||||
const shadowResult = `innerShadow-${index}`
|
||||
const source =
|
||||
spread !== 0
|
||||
? `<feMorphology in="SourceAlpha" operator="${spread > 0 ? "erode" : "dilate"}" radius="${format(
|
||||
Math.abs(spread)
|
||||
)}" result="${spreadResult}"/>`
|
||||
: ""
|
||||
const sourceInput = spread !== 0 ? spreadResult : "SourceAlpha"
|
||||
|
||||
// Figma's own SVG export builds inner shadows with hardAlpha and arithmetic
|
||||
// compositing; using a simpler mask changes the edge strength noticeably.
|
||||
return `<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="${hardAlpha}"/>${source}<feOffset in="${sourceInput}" dx="${format(
|
||||
offsetX
|
||||
)}" dy="${format(offsetY)}" result="${offsetResult}"/><feGaussianBlur in="${offsetResult}" stdDeviation="${format(
|
||||
radius / 2
|
||||
)}" result="${blurResult}"/><feComposite in="${blurResult}" in2="${hardAlpha}" operator="arithmetic" k2="-1" k3="1" result="${shadowResult}"/><feColorMatrix in="${shadowResult}" type="matrix" values="${colorMatrixValues(
|
||||
effect.color
|
||||
)}" result="${shadowResult}-color"/><feBlend mode="normal" in="${shadowResult}-color" in2="${shapeInput}" result="${result}"/>`
|
||||
}
|
||||
|
||||
function createNodeEffectFilter(context: RenderContext, node: FigNode, matrix: SvgMatrix): string | null {
|
||||
const localBounds = getNodeLocalBounds(context, node)
|
||||
if (!localBounds) return null
|
||||
const effects = shouldRenderFigmaLikeEllipseInnerShadowOverlay(context, node)
|
||||
? node.effects?.filter((effect) => effect.type !== "INNER_SHADOW")
|
||||
: node.effects
|
||||
|
||||
// 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)
|
||||
const filterId = createFilter(context, effects, transformBounds(matrix, localBounds))
|
||||
if (!filterId) return null
|
||||
|
||||
includeBounds(context, transformBounds(matrix, expandBoundsForEffects(localBounds, node.effects)))
|
||||
const expandedBounds = transformBounds(matrix, expandBoundsForEffects(localBounds, effects))
|
||||
includeBounds(context, expandedBounds)
|
||||
includeEffectBounds(context, expandedBounds)
|
||||
return filterId
|
||||
}
|
||||
|
||||
function shouldRenderFigmaLikeEllipseInnerShadowOverlay(context: RenderContext, node: FigNode): boolean {
|
||||
return Boolean(
|
||||
context.pngFigmaLike &&
|
||||
node.type === "ELLIPSE" &&
|
||||
node.size &&
|
||||
isFullEllipse(node.arcData) &&
|
||||
node.effects?.some((effect) => effect.visible !== false && effect.type === "INNER_SHADOW")
|
||||
)
|
||||
}
|
||||
|
||||
function collectFigmaLikeEllipseInnerShadowHint(context: RenderContext, node: FigNode, matrix: SvgMatrix): string {
|
||||
if (!shouldRenderFigmaLikeEllipseInnerShadowOverlay(context, node) || !node.size) return ""
|
||||
|
||||
const innerShadow = node.effects?.find((effect) => effect.visible !== false && effect.type === "INNER_SHADOW")
|
||||
if (!innerShadow) return ""
|
||||
|
||||
const rx = node.size.x / 2
|
||||
const ry = node.size.y / 2
|
||||
const cx = rx
|
||||
const cy = ry
|
||||
const minRadius = Math.max(1, Math.min(rx, ry))
|
||||
const radius = innerShadow.radius ?? 0
|
||||
const spread = innerShadow.spread ?? 0
|
||||
const color = innerShadow.color ?? { r: 0, g: 0, b: 0, a: 1 }
|
||||
|
||||
// Figma's native PNG renderer builds inner-shadow coverage from the vector
|
||||
// ellipse itself, not from the low-opacity SVG SourceAlpha filter input.
|
||||
// Record a raster hint so export-node can apply the edge falloff directly to
|
||||
// pixels after SVG rasterization, bypassing backend-specific filter behavior.
|
||||
context.rasterHints.push({
|
||||
type: "ellipse-inner-shadow",
|
||||
matrix,
|
||||
cx,
|
||||
cy,
|
||||
rx,
|
||||
ry,
|
||||
color,
|
||||
opacity: color.a,
|
||||
spread: Math.max(0, Math.min(minRadius - 0.5, spread)),
|
||||
blurSigma: Math.max(0.5, Math.min(minRadius, radius * 0.42))
|
||||
})
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function getRootExportBounds(target: FigNode, renderedBounds: Bounds | null, effectBounds: Bounds | null): Bounds {
|
||||
if (target.size) {
|
||||
const targetBounds = {
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: target.size.x,
|
||||
maxY: target.size.y
|
||||
}
|
||||
|
||||
// Native exports keep the node's nominal box, then extend the bitmap when
|
||||
// visible filters such as foreground blur reach outside that box.
|
||||
return effectBounds ? unionBounds(targetBounds, effectBounds) : targetBounds
|
||||
}
|
||||
|
||||
return renderedBounds ?? { minX: 0, minY: 0, maxX: 0, maxY: 0 }
|
||||
}
|
||||
|
||||
function getFigmaPixelSize(size: number, scale: number): number {
|
||||
const raw = size * scale
|
||||
const rounded = Math.round(raw)
|
||||
|
||||
// Figma files often contain tiny float noise around integer layer sizes. The
|
||||
// native export uses the intended integer in those cases, but still expands
|
||||
// genuinely fractional selections so artwork is not clipped.
|
||||
if (Math.abs(raw - rounded) < 0.01) return Math.max(1, rounded)
|
||||
|
||||
return Math.max(1, Math.ceil(raw))
|
||||
}
|
||||
|
||||
function getNodeLocalBounds(context: RenderContext, node: FigNode): Bounds | null {
|
||||
const geometryBounds = getGeometryLocalBounds(context, node)
|
||||
if (geometryBounds) return geometryBounds
|
||||
@ -394,10 +800,17 @@ function getGeometryLocalBounds(context: RenderContext, node: FigNode): Bounds |
|
||||
|
||||
function getSortedChildren(context: RenderContext, node: FigNode): FigNode[] {
|
||||
return [...(context.childrenByParent.get(keyForGuid(node.guid)) ?? [])].sort((a, b) =>
|
||||
(a.parentIndex?.position ?? "").localeCompare(b.parentIndex?.position ?? "")
|
||||
comparePosition(a.parentIndex?.position ?? "", b.parentIndex?.position ?? "")
|
||||
)
|
||||
}
|
||||
|
||||
function comparePosition(left: string, right: string): number {
|
||||
// parentIndex.position is a fractional-index string; locale-aware sorting
|
||||
// reorders punctuation and changes Figma's z-order.
|
||||
if (left === right) return 0
|
||||
return left < right ? -1 : 1
|
||||
}
|
||||
|
||||
function nextId(context: RenderContext, prefix: string): string {
|
||||
context.idSeed += 1
|
||||
return `${prefix}-${context.idSeed}`
|
||||
@ -462,10 +875,30 @@ function transformBounds(matrix: SvgMatrix, bounds: Bounds): Bounds {
|
||||
return points.reduce((next, point) => includePoint(next, point.x, point.y), createEmptyBounds())
|
||||
}
|
||||
|
||||
function expandBounds(bounds: Bounds, amount: number): Bounds {
|
||||
return {
|
||||
minX: bounds.minX - amount,
|
||||
minY: bounds.minY - amount,
|
||||
maxX: bounds.maxX + amount,
|
||||
maxY: bounds.maxY + amount
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (effect.visible === false) continue
|
||||
|
||||
if (effect.type === "FOREGROUND_BLUR" || effect.type === "LAYER_BLUR") {
|
||||
const radius = effect.radius ?? 0
|
||||
next.minX = Math.min(next.minX, bounds.minX - radius)
|
||||
next.maxX = Math.max(next.maxX, bounds.maxX + radius)
|
||||
next.minY = Math.min(next.minY, bounds.minY - radius)
|
||||
next.maxY = Math.max(next.maxY, bounds.maxY + radius)
|
||||
continue
|
||||
}
|
||||
|
||||
if (effect.type !== "DROP_SHADOW") continue
|
||||
|
||||
const radius = effect.radius ?? 0
|
||||
const spread = effect.spread ?? 0
|
||||
@ -500,6 +933,10 @@ function includeBounds(context: RenderContext, bounds: Bounds) {
|
||||
: { ...bounds }
|
||||
}
|
||||
|
||||
function includeEffectBounds(context: RenderContext, bounds: Bounds) {
|
||||
context.effectBounds = context.effectBounds ? unionBounds(context.effectBounds, bounds) : { ...bounds }
|
||||
}
|
||||
|
||||
function unionBounds(left: Bounds, right: Bounds): Bounds {
|
||||
return {
|
||||
minX: Math.min(left.minX, right.minX),
|
||||
@ -523,6 +960,48 @@ function colorToCss(color?: FigColor): string {
|
||||
return `rgb(${toByte(color.r)} ${toByte(color.g)} ${toByte(color.b)})`
|
||||
}
|
||||
|
||||
function colorMatrixValues(color?: FigColor): string {
|
||||
const next = color ?? { r: 0, g: 0, b: 0, a: 1 }
|
||||
return [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
next.r,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
next.g,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
next.b,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
next.a,
|
||||
0
|
||||
]
|
||||
.map(format)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function paintOpacityAttribute(kind: "fill" | "stroke", paint: FigPaint): string {
|
||||
const opacity = (paint.opacity ?? 1) * (paint.type === "SOLID" ? paint.color?.a ?? 1 : 1)
|
||||
return opacity !== 1 ? ` ${kind}-opacity="${format(opacity)}"` : ""
|
||||
}
|
||||
|
||||
function strokeDashArrayAttribute(node: FigNode): string {
|
||||
const dashPattern = node.dashPattern?.filter((value) => value > 0)
|
||||
if (!dashPattern?.length) return ""
|
||||
|
||||
// Figma preserves dashed strokes as stroke metadata. Emitting dasharray keeps
|
||||
// the semantic dashed stroke instead of rasterizing it from baked contours.
|
||||
return ` stroke-dasharray="${dashPattern.map(format).join(" ")}"`
|
||||
}
|
||||
|
||||
function toByte(value: number): number {
|
||||
return Math.round(Math.max(0, Math.min(1, value)) * 255)
|
||||
}
|
||||
|
||||
@ -61,6 +61,7 @@ export type FigNode = {
|
||||
transform?: FigmaMatrix
|
||||
strokeWeight?: number
|
||||
strokeAlign?: "CENTER" | "INSIDE" | "OUTSIDE"
|
||||
dashPattern?: number[]
|
||||
arcData?: FigArcData
|
||||
fillPaints?: FigPaint[]
|
||||
strokePaints?: FigPaint[]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user