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。
|
- `get_design_tokens`:从全文件或指定节点子树中推导颜色、渐变、阴影、描边 token。
|
||||||
- `inspect_fig_file`:读取本地 `.fig`,返回节点数量、类型统计和节点概览。
|
- `inspect_fig_file`:读取本地 `.fig`,返回节点数量、类型统计和节点概览。
|
||||||
- `get_fig_node`:按节点名、`1234:5678`、`1234-5678`、`node-id=1234-5678` 或完整 Figma 链接查找节点,并返回简化上下文。
|
- `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",
|
"nodeQuery": "1234-5678",
|
||||||
"outputPath": "C:\\Users\\you\\Exports\\figma-local-context\\sample-node.png",
|
"outputPath": "C:\\Users\\you\\Exports\\figma-local-context\\sample-node.png",
|
||||||
"format": "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
|
```bash
|
||||||
|
|||||||
@ -13,7 +13,8 @@ import { getFigNodeContext, inspectFigFile } from "../services/fig-file.js"
|
|||||||
const serverInfo = {
|
const serverInfo = {
|
||||||
name: "Figma Local Context MCP",
|
name: "Figma Local Context MCP",
|
||||||
version: process.env.NPM_PACKAGE_VERSION ?? "0.1.0",
|
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({
|
const inspectParams = z.object({
|
||||||
@ -37,9 +38,13 @@ const exportNodeParams = z.object({
|
|||||||
.min(1)
|
.min(1)
|
||||||
.describe("节点名称、2625:12945、2625-12945、node-id=2625-12945 或完整 Figma 链接。"),
|
.describe("节点名称、2625:12945、2625-12945、node-id=2625-12945 或完整 Figma 链接。"),
|
||||||
outputPath: z.string().min(1).optional().describe("输出文件路径;不传时写到当前工作目录。"),
|
outputPath: z.string().min(1).optional().describe("输出文件路径;不传时写到当前工作目录。"),
|
||||||
format: z.enum(["svg", "png"]).default("png").describe("导出格式。"),
|
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。"),
|
scale: z.number().positive().max(10).default(2).describe("导出倍率,PNG 预览常用 1/2/3/4。"),
|
||||||
background: z.string().optional().describe("可选背景色,例如 #ffffff。")
|
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({
|
const listNodesParams = z.object({
|
||||||
@ -78,9 +83,13 @@ const exportAssetsParams = z.object({
|
|||||||
.max(100)
|
.max(100)
|
||||||
.describe("要导出的节点名或 node-id 列表。"),
|
.describe("要导出的节点名或 node-id 列表。"),
|
||||||
outputDir: z.string().min(1).describe("资源输出目录。"),
|
outputDir: z.string().min(1).describe("资源输出目录。"),
|
||||||
format: z.enum(["svg", "png"]).default("png").describe("导出格式。"),
|
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。"),
|
scale: z.number().positive().max(10).default(2).describe("导出倍率,PNG 预览常用 1/2/3/4。"),
|
||||||
background: z.string().optional().describe("可选背景色,例如 #ffffff。")
|
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({
|
const designTokensParams = z.object({
|
||||||
@ -161,7 +170,7 @@ export function createServer(): McpServer {
|
|||||||
"export_assets",
|
"export_assets",
|
||||||
{
|
{
|
||||||
title: "Export design assets",
|
title: "Export design assets",
|
||||||
description: "批量导出本地 .fig/.fig.json 中的多个节点为 SVG 或 PNG。",
|
description: "批量导出本地 .fig/.fig.json 中的多个节点为 SVG 或本地 figma-like PNG。",
|
||||||
inputSchema: exportAssetsParams,
|
inputSchema: exportAssetsParams,
|
||||||
annotations: { readOnlyHint: false, openWorldHint: true }
|
annotations: { readOnlyHint: false, openWorldHint: true }
|
||||||
},
|
},
|
||||||
@ -173,7 +182,8 @@ export function createServer(): McpServer {
|
|||||||
outputDir: params.outputDir,
|
outputDir: params.outputDir,
|
||||||
format: params.format,
|
format: params.format,
|
||||||
scale: params.scale,
|
scale: params.scale,
|
||||||
background: params.background
|
background: params.background,
|
||||||
|
pngRenderer: params.pngRenderer
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -215,7 +225,7 @@ export function createServer(): McpServer {
|
|||||||
"export_fig_node",
|
"export_fig_node",
|
||||||
{
|
{
|
||||||
title: "Export local Figma node",
|
title: "Export local Figma node",
|
||||||
description: "把本地 .fig/.fig.json 中的指定节点导出为 SVG 或 PNG。",
|
description: "把本地 .fig/.fig.json 中的指定节点导出为 SVG 或本地 figma-like PNG。",
|
||||||
inputSchema: exportNodeParams,
|
inputSchema: exportNodeParams,
|
||||||
annotations: { readOnlyHint: false, openWorldHint: true }
|
annotations: { readOnlyHint: false, openWorldHint: true }
|
||||||
},
|
},
|
||||||
@ -227,7 +237,8 @@ export function createServer(): McpServer {
|
|||||||
outputPath: params.outputPath,
|
outputPath: params.outputPath,
|
||||||
format: params.format,
|
format: params.format,
|
||||||
scale: params.scale,
|
scale: params.scale,
|
||||||
background: params.background
|
background: params.background,
|
||||||
|
pngRenderer: params.pngRenderer
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import type { FigColor, FigJson, FigNode, FigPaint } from "./fig-types.js"
|
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 { getChildrenByParent, findTargetNode } from "./fig-node-svg.js"
|
||||||
import { getRootNode, loadFigFile, serializeNode, summarizeNode } from "./fig-file.js"
|
import { getRootNode, loadFigFile, serializeNode, summarizeNode } from "./fig-file.js"
|
||||||
import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js"
|
import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js"
|
||||||
@ -33,6 +33,7 @@ export type ExportAssetsOptions = {
|
|||||||
format: "svg" | "png"
|
format: "svg" | "png"
|
||||||
scale: number
|
scale: number
|
||||||
background?: string
|
background?: string
|
||||||
|
pngRenderer?: PngRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listFigNodes(filePath: string, options: NodeListOptions) {
|
export function listFigNodes(filePath: string, options: NodeListOptions) {
|
||||||
@ -102,7 +103,8 @@ export function exportAssets(options: ExportAssetsOptions) {
|
|||||||
outputPath: path.join(options.outputDir, fileName),
|
outputPath: path.join(options.outputDir, fileName),
|
||||||
format: options.format,
|
format: options.format,
|
||||||
scale: options.scale,
|
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:
|
exportHint:
|
||||||
node.type === "VECTOR" || node.type === "ELLIPSE" || node.type === "FRAME"
|
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
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
import fs from "node:fs"
|
import fs from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import zlib from "node:zlib"
|
||||||
import { Resvg } from "@resvg/resvg-js"
|
import { Resvg } from "@resvg/resvg-js"
|
||||||
import { loadFigFile } from "./fig-file.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"
|
import { keyForGuid, sanitizeFilePart } from "../utils/node-id.js"
|
||||||
|
|
||||||
|
export type PngRenderer = "figma-like" | "local-preview"
|
||||||
|
|
||||||
|
const MASK_SUPERSAMPLE = 8
|
||||||
|
|
||||||
export type ExportNodeOptions = {
|
export type ExportNodeOptions = {
|
||||||
filePath: string
|
filePath: string
|
||||||
nodeQuery: string
|
nodeQuery: string
|
||||||
@ -12,6 +17,7 @@ export type ExportNodeOptions = {
|
|||||||
format: "svg" | "png"
|
format: "svg" | "png"
|
||||||
scale: number
|
scale: number
|
||||||
background?: string
|
background?: string
|
||||||
|
pngRenderer?: PngRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExportNodeResult = {
|
export type ExportNodeResult = {
|
||||||
@ -21,6 +27,31 @@ export type ExportNodeResult = {
|
|||||||
scale: number
|
scale: number
|
||||||
width: number
|
width: number
|
||||||
height: 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: {
|
node: {
|
||||||
id: string
|
id: string
|
||||||
name?: string
|
name?: string
|
||||||
@ -30,19 +61,45 @@ export type ExportNodeResult = {
|
|||||||
|
|
||||||
export function exportFigNode(options: ExportNodeOptions): ExportNodeResult {
|
export function exportFigNode(options: ExportNodeOptions): ExportNodeResult {
|
||||||
const figJson = loadFigFile(options.filePath)
|
const figJson = loadFigFile(options.filePath)
|
||||||
|
const pngRenderer = options.pngRenderer ?? "figma-like"
|
||||||
|
const useFigmaLikePng = options.format === "png" && pngRenderer === "figma-like"
|
||||||
const rendered = renderNodeToSvg(figJson, {
|
const rendered = renderNodeToSvg(figJson, {
|
||||||
nodeQuery: options.nodeQuery,
|
nodeQuery: options.nodeQuery,
|
||||||
scale: options.scale,
|
scale: options.scale,
|
||||||
background: options.background
|
background: options.background,
|
||||||
|
pngFigmaLike: useFigmaLikePng
|
||||||
})
|
})
|
||||||
const outputPath = path.resolve(options.outputPath ?? defaultOutputPath(options))
|
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 })
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||||
|
|
||||||
if (options.format === "svg") {
|
if (options.format === "svg") {
|
||||||
fs.writeFileSync(outputPath, rendered.svg)
|
fs.writeFileSync(outputPath, rendered.svg)
|
||||||
} else {
|
} 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 {
|
return {
|
||||||
filePath: path.resolve(options.filePath),
|
filePath: path.resolve(options.filePath),
|
||||||
@ -51,10 +108,36 @@ export function exportFigNode(options: ExportNodeOptions): ExportNodeResult {
|
|||||||
scale: options.scale,
|
scale: options.scale,
|
||||||
width: rendered.width,
|
width: rendered.width,
|
||||||
height: rendered.height,
|
height: rendered.height,
|
||||||
node: {
|
renderer: options.format === "svg" ? "local-svg" : useFigmaLikePng ? "local-figma-like-resvg" : "local-svg-resvg",
|
||||||
id: keyForGuid(rendered.node.guid),
|
exportCapabilities: getExportCapabilities(),
|
||||||
name: rendered.node.name,
|
warnings,
|
||||||
type: rendered.node.type
|
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` : ""
|
const scalePart = options.format === "png" ? `@${options.scale}x` : ""
|
||||||
return path.join(process.cwd(), `${input.name}-${nodePart}${scalePart}.${options.format}`)
|
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
|
figJson: FigJson
|
||||||
childrenByParent: Map<string, FigNode[]>
|
childrenByParent: Map<string, FigNode[]>
|
||||||
defs: string[]
|
defs: string[]
|
||||||
|
rasterHints: FigmaLikeRasterHint[]
|
||||||
bounds: Bounds | null
|
bounds: Bounds | null
|
||||||
|
effectBounds: Bounds | null
|
||||||
idSeed: number
|
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 = {
|
export type RenderOptions = {
|
||||||
@ -32,6 +48,7 @@ export type RenderOptions = {
|
|||||||
nodeQuery?: string
|
nodeQuery?: string
|
||||||
scale?: number
|
scale?: number
|
||||||
background?: string
|
background?: string
|
||||||
|
pngFigmaLike?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RenderedSvg = {
|
export type RenderedSvg = {
|
||||||
@ -39,6 +56,7 @@ export type RenderedSvg = {
|
|||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
viewBox: Bounds
|
viewBox: Bounds
|
||||||
|
rasterHints: FigmaLikeRasterHint[]
|
||||||
node: FigNode
|
node: FigNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,29 +73,48 @@ export function renderNodeToSvg(figJson: FigJson, options: RenderOptions): Rende
|
|||||||
figJson,
|
figJson,
|
||||||
childrenByParent,
|
childrenByParent,
|
||||||
defs: [],
|
defs: [],
|
||||||
|
rasterHints: [],
|
||||||
bounds: null,
|
bounds: null,
|
||||||
idSeed: 0
|
effectBounds: null,
|
||||||
|
idSeed: 0,
|
||||||
|
pngFigmaLike: options.pngFigmaLike ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = renderNodeSubtree(context, target, IDENTITY, true)
|
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 width = bounds.maxX - bounds.minX
|
||||||
const height = bounds.maxY - bounds.minY
|
const height = bounds.maxY - bounds.minY
|
||||||
const scale = options.scale ?? 2
|
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
|
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 = [
|
const svg = [
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${format(width * scale)}" height="${format(
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${format(pixelWidth)}" height="${format(
|
||||||
height * scale
|
pixelHeight
|
||||||
)}" viewBox="${format(bounds.minX)} ${format(bounds.minY)} ${format(width)} ${format(height)}" shape-rendering="geometricPrecision">`,
|
)}" viewBox="${format(viewBox.minX)} ${format(viewBox.minY)} ${format(viewBoxWidth)} ${format(
|
||||||
|
viewBoxHeight
|
||||||
|
)}" shape-rendering="geometricPrecision">`,
|
||||||
context.defs.length ? `<defs>${context.defs.join("")}</defs>` : "",
|
context.defs.length ? `<defs>${context.defs.join("")}</defs>` : "",
|
||||||
background,
|
background,
|
||||||
body,
|
body,
|
||||||
"</svg>"
|
"</svg>"
|
||||||
].join("")
|
].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 {
|
export function findTargetNode(figJson: FigJson, options: RenderOptions): FigNode {
|
||||||
@ -120,29 +157,200 @@ function renderNodeSubtree(
|
|||||||
|
|
||||||
const localMatrix = isRoot ? IDENTITY : toSvgMatrix(node.transform)
|
const localMatrix = isRoot ? IDENTITY : toSvgMatrix(node.transform)
|
||||||
const matrix = multiply(parentMatrix, localMatrix)
|
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 = [
|
const nodeContent = [
|
||||||
...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix),
|
...renderGeometry(context, node, node.fillGeometry, node.fillPaints, matrix),
|
||||||
|
collectFigmaLikeEllipseInnerShadowHint(context, node, matrix),
|
||||||
...renderStrokeGeometry(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("")
|
].join("")
|
||||||
|
|
||||||
if (!nodeContent) return ""
|
if (!nodeContent) return ""
|
||||||
|
|
||||||
const transform = matrixToAttribute(localMatrix)
|
|
||||||
const opacity = node.opacity != null && node.opacity !== 1 ? ` opacity="${format(node.opacity)}"` : ""
|
const opacity = node.opacity != null && node.opacity !== 1 ? ` opacity="${format(node.opacity)}"` : ""
|
||||||
const filterId = createNodeEffectFilter(context, node, matrix)
|
const filterId = createNodeEffectFilter(context, node, matrix)
|
||||||
const filter = filterId ? ` filter="url(#${filterId})"` : ""
|
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[] {
|
function renderStrokeGeometry(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] {
|
||||||
const outsideEllipseStroke = renderOutsideEllipseStroke(context, node, matrix)
|
const outsideEllipseStroke = renderOutsideEllipseStroke(context, node, matrix)
|
||||||
if (outsideEllipseStroke) return outsideEllipseStroke
|
if (outsideEllipseStroke) return outsideEllipseStroke
|
||||||
|
|
||||||
|
const pathStroke = renderPathStroke(context, node, matrix)
|
||||||
|
if (pathStroke) return pathStroke
|
||||||
|
|
||||||
return renderGeometry(context, node, node.strokeGeometry, node.strokePaints, matrix)
|
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 {
|
function renderOutsideEllipseStroke(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] | null {
|
||||||
const strokeWeight = node.strokeWeight ?? 0
|
const strokeWeight = node.strokeWeight ?? 0
|
||||||
if (
|
if (
|
||||||
@ -167,17 +375,20 @@ function renderOutsideEllipseStroke(context: RenderContext, node: FigNode, matri
|
|||||||
return node.strokePaints
|
return node.strokePaints
|
||||||
.filter((paint) => paint.visible !== false)
|
.filter((paint) => paint.visible !== false)
|
||||||
.map((paint) => {
|
.map((paint) => {
|
||||||
const stroke = paintToSvgFill(context, node, bounds, paint)
|
const stroke = paintToSvgFill(context, node, bounds, paint, IDENTITY)
|
||||||
const opacity = paint.opacity != null && paint.opacity !== 1 ? ` stroke-opacity="${format(paint.opacity)}"` : ""
|
const opacity = paintOpacityAttribute("stroke", paint)
|
||||||
const rx = node.size!.x / 2 + strokeWeight / 2
|
const rx = node.size!.x / 2 + strokeWeight / 2
|
||||||
const ry = node.size!.y / 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
|
// Figma's strokeGeometry for OUTSIDE ellipses is an expanded filled
|
||||||
// outline. Using it directly paints inward and erases the ring gap, so
|
// outline. Using it directly paints inward and erases the ring gap, so
|
||||||
// complete ellipses are emitted as actual outside strokes.
|
// complete ellipses are emitted as actual outside strokes.
|
||||||
return `<ellipse cx="${format(node.size!.x / 2)}" cy="${format(node.size!.y / 2)}" rx="${format(
|
return `<ellipse cx="${format(node.size!.x / 2)}" cy="${format(node.size!.y / 2)}" rx="${format(
|
||||||
rx
|
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 parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
|
||||||
const transformedBounds = transformBounds(matrix, parsed.bounds)
|
const transformedBounds = transformBounds(matrix, parsed.bounds)
|
||||||
includeBounds(context, transformedBounds)
|
includeBounds(context, transformedBounds)
|
||||||
|
const fillRule = geometry.windingRule === "ODD" ? ` fill-rule="evenodd"` : ""
|
||||||
|
|
||||||
return paints
|
return paints
|
||||||
.filter((paint) => paint.visible !== false)
|
.filter((paint) => paint.visible !== false)
|
||||||
.map((paint) => {
|
.map((paint) => {
|
||||||
const fill = paintToSvgFill(context, node, parsed.bounds, paint)
|
const fill = paintToSvgFill(context, node, parsed.bounds, paint, matrix)
|
||||||
const opacity = paint.opacity != null && paint.opacity !== 1 ? ` fill-opacity="${format(paint.opacity)}"` : ""
|
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 }
|
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") {
|
if (paint.type === "SOLID") {
|
||||||
return colorToCss(paint.color)
|
return colorToCss(paint.color)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paint.type === "GRADIENT_LINEAR") {
|
if (paint.type === "GRADIENT_LINEAR") {
|
||||||
const id = nextId(context, "gradient")
|
const id = nextId(context, "gradient")
|
||||||
const gradient = getLinearGradientLine(node, pathBounds, paint)
|
const gradient = getLinearGradientLine(node, pathBounds, paint, matrix)
|
||||||
const stops = paint.stops
|
const stops = paint.stops
|
||||||
?.map(
|
?.map(
|
||||||
(stop) =>
|
(stop) =>
|
||||||
@ -289,7 +532,7 @@ function paintToSvgFill(context: RenderContext, node: FigNode, pathBounds: Bound
|
|||||||
throw new Error(`不支持的填充类型:${paint.type}`)
|
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 width = node.size?.x || pathBounds.maxX - pathBounds.minX
|
||||||
const height = node.size?.y || pathBounds.maxY - pathBounds.minY
|
const height = node.size?.y || pathBounds.maxY - pathBounds.minY
|
||||||
const originX = pathBounds.minX < 0 ? pathBounds.minX : 0
|
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.
|
// unit gradient space, so SVG needs the inverse projected onto the node box.
|
||||||
const start = applyToPoint(inverse, 0, 0)
|
const start = applyToPoint(inverse, 0, 0)
|
||||||
const end = applyToPoint(inverse, 1, 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 {
|
return {
|
||||||
x1: originX + start.x * width,
|
x1: transformedStart.x,
|
||||||
y1: originY + start.y * height,
|
y1: transformedStart.y,
|
||||||
x2: originX + end.x * width,
|
x2: transformedEnd.x,
|
||||||
y2: originY + end.y * height
|
y2: transformedEnd.y
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFilter(context: RenderContext, effects: FigEffect[] | undefined, bounds: Bounds): string | null {
|
function createFilter(context: RenderContext, effects: FigEffect[] | undefined, bounds: Bounds): string | null {
|
||||||
const shadow = effects?.find((effect) => effect.visible !== false && effect.type === "DROP_SHADOW")
|
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 id = nextId(context, "shadow")
|
||||||
const radius = shadow.radius ?? 0
|
const shadowRadius = shadow?.radius ?? 0
|
||||||
const spread = shadow.spread ?? 0
|
const blurRadius = layerBlur?.radius ?? 0
|
||||||
const offsetX = shadow.offset?.x ?? 0
|
const spread = shadow?.spread ?? 0
|
||||||
const offsetY = shadow.offset?.y ?? 0
|
const offsetX = shadow?.offset?.x ?? 0
|
||||||
const x = bounds.minX + Math.min(0, offsetX) - radius - spread
|
const offsetY = shadow?.offset?.y ?? 0
|
||||||
const y = bounds.minY + Math.min(0, offsetY) - radius - spread
|
const filterPadding = Math.max(shadowRadius + Math.abs(spread), blurRadius)
|
||||||
const width = bounds.maxX - bounds.minX + Math.abs(offsetX) + radius * 2 + spread * 2
|
const x = bounds.minX + Math.min(0, offsetX) - filterPadding
|
||||||
const height = bounds.maxY - bounds.minY + Math.abs(offsetY) + radius * 2 + spread * 2
|
const y = bounds.minY + Math.min(0, offsetY) - filterPadding
|
||||||
const sourceAlpha = spread
|
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(
|
? `<feMorphology in="SourceAlpha" operator="${spread > 0 ? "dilate" : "erode"}" radius="${format(
|
||||||
Math.abs(spread)
|
Math.abs(spread)
|
||||||
)}" result="spreadAlpha"/>`
|
)}" result="spreadAlpha"/>`
|
||||||
: ""
|
: ""
|
||||||
const blurInput = spread ? "spreadAlpha" : "SourceAlpha"
|
const blurInput = spread ? "spreadAlpha" : "SourceAlpha"
|
||||||
const shadowResult = shadow.showShadowBehindNode === false ? "visibleShadow" : "shadow"
|
const shadowResult = shadow?.showShadowBehindNode === false ? "visibleShadow" : "shadow"
|
||||||
const hideShadowBehindSource =
|
const hideShadowBehindSource =
|
||||||
shadow.showShadowBehindNode === false
|
shadow?.showShadowBehindNode === false
|
||||||
? `<feComposite in="shadow" in2="SourceAlpha" operator="out" result="visibleShadow"/>`
|
? `<feComposite in="shadow" in2="SourceAlpha" operator="out" result="visibleShadow"/>`
|
||||||
: ""
|
: ""
|
||||||
|
const layerBlurMarkup = layerBlur
|
||||||
// Figma can store showShadowBehindNode=false. SVG's feDropShadow always
|
? `<feGaussianBlur in="SourceGraphic" stdDeviation="${format(blurRadius / 2)}" result="layerBlur"/>`
|
||||||
// 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.
|
const sourceGraphicResult = layerBlur ? "layerBlur" : "SourceGraphic"
|
||||||
context.defs.push(
|
const shadowMarkup = shadow
|
||||||
`<filter id="${id}" x="${format(x)}" y="${format(y)}" width="${format(width)}" height="${format(
|
? `${sourceAlpha}<feGaussianBlur in="${blurInput}" stdDeviation="${format(
|
||||||
height
|
shadowRadius / 2
|
||||||
)}" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">${sourceAlpha}<feGaussianBlur in="${blurInput}" stdDeviation="${format(
|
|
||||||
radius / 2
|
|
||||||
)}" result="blurredShadow"/><feOffset in="blurredShadow" dx="${format(offsetX)}" dy="${format(
|
)}" result="blurredShadow"/><feOffset in="blurredShadow" dx="${format(offsetX)}" dy="${format(
|
||||||
offsetY
|
offsetY
|
||||||
)}" result="offsetShadow"/><feFlood flood-color="${colorToCss(shadow.color)}" flood-opacity="${format(
|
)}" result="offsetShadow"/><feFlood flood-color="${colorToCss(shadow.color)}" flood-opacity="${format(
|
||||||
shadow.color?.a ?? 1
|
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>`
|
)}" 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 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">${shadowMarkup}${layerBlurMarkup}${innerShadowMarkup}<feMerge>${shadowMergeNode}<feMergeNode in="${sourceWithInnerShadows}"/></feMerge></filter>`
|
||||||
)
|
)
|
||||||
|
|
||||||
return id
|
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 {
|
function createNodeEffectFilter(context: RenderContext, node: FigNode, matrix: SvgMatrix): string | null {
|
||||||
const localBounds = getNodeLocalBounds(context, node)
|
const localBounds = getNodeLocalBounds(context, node)
|
||||||
if (!localBounds) return null
|
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
|
// 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
|
// even when their own fill paints are empty, so the filter must wrap the
|
||||||
// rendered layer content instead of only individual geometry paths.
|
// 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
|
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
|
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 {
|
function getNodeLocalBounds(context: RenderContext, node: FigNode): Bounds | null {
|
||||||
const geometryBounds = getGeometryLocalBounds(context, node)
|
const geometryBounds = getGeometryLocalBounds(context, node)
|
||||||
if (geometryBounds) return geometryBounds
|
if (geometryBounds) return geometryBounds
|
||||||
@ -394,10 +800,17 @@ function getGeometryLocalBounds(context: RenderContext, node: FigNode): Bounds |
|
|||||||
|
|
||||||
function getSortedChildren(context: RenderContext, node: FigNode): FigNode[] {
|
function getSortedChildren(context: RenderContext, node: FigNode): FigNode[] {
|
||||||
return [...(context.childrenByParent.get(keyForGuid(node.guid)) ?? [])].sort((a, b) =>
|
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 {
|
function nextId(context: RenderContext, prefix: string): string {
|
||||||
context.idSeed += 1
|
context.idSeed += 1
|
||||||
return `${prefix}-${context.idSeed}`
|
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())
|
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 {
|
function expandBoundsForEffects(bounds: Bounds, effects: FigEffect[] | undefined): Bounds {
|
||||||
const next = { ...bounds }
|
const next = { ...bounds }
|
||||||
for (const effect of effects ?? []) {
|
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 radius = effect.radius ?? 0
|
||||||
const spread = effect.spread ?? 0
|
const spread = effect.spread ?? 0
|
||||||
@ -500,6 +933,10 @@ function includeBounds(context: RenderContext, bounds: 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 {
|
function unionBounds(left: Bounds, right: Bounds): Bounds {
|
||||||
return {
|
return {
|
||||||
minX: Math.min(left.minX, right.minX),
|
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)})`
|
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 {
|
function toByte(value: number): number {
|
||||||
return Math.round(Math.max(0, Math.min(1, value)) * 255)
|
return Math.round(Math.max(0, Math.min(1, value)) * 255)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,6 +61,7 @@ export type FigNode = {
|
|||||||
transform?: FigmaMatrix
|
transform?: FigmaMatrix
|
||||||
strokeWeight?: number
|
strokeWeight?: number
|
||||||
strokeAlign?: "CENTER" | "INSIDE" | "OUTSIDE"
|
strokeAlign?: "CENTER" | "INSIDE" | "OUTSIDE"
|
||||||
|
dashPattern?: number[]
|
||||||
arcData?: FigArcData
|
arcData?: FigArcData
|
||||||
fillPaints?: FigPaint[]
|
fillPaints?: FigPaint[]
|
||||||
strokePaints?: FigPaint[]
|
strokePaints?: FigPaint[]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user