mirror of
https://github.com/KwiTsukasa/figma-local-context-mcp.git
synced 2026-05-27 16:45:46 +08:00
feat: support local fig image assets in svg export
This commit is contained in:
parent
21198de5c2
commit
1e3d3ee6a1
@ -108,6 +108,8 @@
|
|||||||
- `local-figma-like-resvg`:默认 PNG 管线。先生成带 Figma-like 滤镜补偿的本地 SVG,再用 `@resvg/resvg-js` 转 PNG。
|
- `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。
|
- `local-svg-resvg`:普通预览 PNG 管线。先生成本地 SVG,再用 `@resvg/resvg-js` 转 PNG。Figma 在线端 PNG 使用自身渲染管线,不能假定它是在线 SVG 再转 PNG。
|
||||||
|
|
||||||
|
当前本地 SVG/PNG 管线支持 `.fig` 外层 zip 中的 `images/<hash>` 图片资源,会把 `IMAGE` 填充按真实图片、paint transform 和矢量路径裁切导出;如果本地文件缺少对应图片资源,会明确报出缺失的 IMAGE hash。线性/径向渐变、虚线描边和部分滤镜也会转换为 SVG 近似表达。
|
||||||
|
|
||||||
导出结果还会包含 `exportCapabilities`:
|
导出结果还会包含 `exportCapabilities`:
|
||||||
|
|
||||||
- `localSvg.supported: true`:支持从本地 `.fig` 解码生成 SVG。
|
- `localSvg.supported: true`:支持从本地 `.fig` 解码生成 SVG。
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import path from "node:path"
|
|||||||
import zlib from "node:zlib"
|
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 { loadFigImageAssets } from "./fig-images.js"
|
||||||
import { renderNodeToSvg, type FigmaLikeRasterHint } 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"
|
||||||
|
|
||||||
@ -61,13 +62,15 @@ 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 imageAssets = loadFigImageAssets(options.filePath)
|
||||||
const pngRenderer = options.pngRenderer ?? "figma-like"
|
const pngRenderer = options.pngRenderer ?? "figma-like"
|
||||||
const useFigmaLikePng = options.format === "png" && 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
|
pngFigmaLike: useFigmaLikePng,
|
||||||
|
imageAssets
|
||||||
})
|
})
|
||||||
const outputPath = path.resolve(options.outputPath ?? defaultOutputPath(options))
|
const outputPath = path.resolve(options.outputPath ?? defaultOutputPath(options))
|
||||||
const node = {
|
const node = {
|
||||||
|
|||||||
58
src/services/fig-images.ts
Normal file
58
src/services/fig-images.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import fs from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import UzipModule from "uzip"
|
||||||
|
|
||||||
|
const UZIP = (UzipModule as any).default ?? UzipModule
|
||||||
|
|
||||||
|
export type FigImageAssets = Map<string, string>
|
||||||
|
|
||||||
|
export function loadFigImageAssets(filePath: string): FigImageAssets {
|
||||||
|
const absolutePath = path.resolve(filePath)
|
||||||
|
if (absolutePath.toLowerCase().endsWith(".json")) return new Map()
|
||||||
|
|
||||||
|
const fileBytes = fs.readFileSync(absolutePath)
|
||||||
|
if (!isZipFile(fileBytes)) return new Map()
|
||||||
|
|
||||||
|
const unzipped = UZIP.parse(toArrayBuffer(fileBytes))
|
||||||
|
const assets: FigImageAssets = new Map()
|
||||||
|
|
||||||
|
for (const [entryName, entryBytes] of Object.entries(unzipped) as Array<[string, Uint8Array]>) {
|
||||||
|
if (!entryName.startsWith("images/") || entryName.endsWith("/")) continue
|
||||||
|
|
||||||
|
const hash = path.posix.basename(entryName).toLowerCase()
|
||||||
|
const mimeType = detectImageMimeType(entryBytes)
|
||||||
|
if (!mimeType) continue
|
||||||
|
|
||||||
|
assets.set(hash, `data:${mimeType};base64,${Buffer.from(entryBytes).toString("base64")}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return assets
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectImageMimeType(bytes: Uint8Array): string | null {
|
||||||
|
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png"
|
||||||
|
if (bytes[0] === 0xff && bytes[1] === 0xd8) return "image/jpeg"
|
||||||
|
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return "image/gif"
|
||||||
|
if (
|
||||||
|
bytes[0] === 0x52 &&
|
||||||
|
bytes[1] === 0x49 &&
|
||||||
|
bytes[2] === 0x46 &&
|
||||||
|
bytes[3] === 0x46 &&
|
||||||
|
bytes[8] === 0x57 &&
|
||||||
|
bytes[9] === 0x45 &&
|
||||||
|
bytes[10] === 0x42 &&
|
||||||
|
bytes[11] === 0x50
|
||||||
|
) {
|
||||||
|
return "image/webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZipFile(bytes: Uint8Array): boolean {
|
||||||
|
return bytes[0] === 0x50 && bytes[1] === 0x4b
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||||
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||||
|
}
|
||||||
@ -7,8 +7,10 @@ import type {
|
|||||||
FigJson,
|
FigJson,
|
||||||
FigNode,
|
FigNode,
|
||||||
FigPaint,
|
FigPaint,
|
||||||
|
FigTextGlyph,
|
||||||
FigmaMatrix
|
FigmaMatrix
|
||||||
} from "./fig-types.js"
|
} from "./fig-types.js"
|
||||||
|
import type { FigImageAssets } from "./fig-images.js"
|
||||||
import { keyForGuid, normalizeNodeId } from "../utils/node-id.js"
|
import { keyForGuid, normalizeNodeId } from "../utils/node-id.js"
|
||||||
|
|
||||||
type SvgMatrix = [number, number, number, number, number, number]
|
type SvgMatrix = [number, number, number, number, number, number]
|
||||||
@ -23,6 +25,7 @@ type RenderContext = {
|
|||||||
childrenByParent: Map<string, FigNode[]>
|
childrenByParent: Map<string, FigNode[]>
|
||||||
defs: string[]
|
defs: string[]
|
||||||
rasterHints: FigmaLikeRasterHint[]
|
rasterHints: FigmaLikeRasterHint[]
|
||||||
|
imageAssets: FigImageAssets
|
||||||
bounds: Bounds | null
|
bounds: Bounds | null
|
||||||
effectBounds: Bounds | null
|
effectBounds: Bounds | null
|
||||||
idSeed: number
|
idSeed: number
|
||||||
@ -49,6 +52,7 @@ export type RenderOptions = {
|
|||||||
scale?: number
|
scale?: number
|
||||||
background?: string
|
background?: string
|
||||||
pngFigmaLike?: boolean
|
pngFigmaLike?: boolean
|
||||||
|
imageAssets?: FigImageAssets
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RenderedSvg = {
|
export type RenderedSvg = {
|
||||||
@ -63,6 +67,7 @@ export type RenderedSvg = {
|
|||||||
const IDENTITY: SvgMatrix = [1, 0, 0, 1, 0, 0]
|
const IDENTITY: SvgMatrix = [1, 0, 0, 1, 0, 0]
|
||||||
const COMMAND_MOVE = 1
|
const COMMAND_MOVE = 1
|
||||||
const COMMAND_LINE = 2
|
const COMMAND_LINE = 2
|
||||||
|
const COMMAND_QUADRATIC = 3
|
||||||
const COMMAND_CUBIC = 4
|
const COMMAND_CUBIC = 4
|
||||||
const COMMAND_CLOSE = 0
|
const COMMAND_CLOSE = 0
|
||||||
|
|
||||||
@ -74,6 +79,7 @@ export function renderNodeToSvg(figJson: FigJson, options: RenderOptions): Rende
|
|||||||
childrenByParent,
|
childrenByParent,
|
||||||
defs: [],
|
defs: [],
|
||||||
rasterHints: [],
|
rasterHints: [],
|
||||||
|
imageAssets: options.imageAssets ?? new Map(),
|
||||||
bounds: null,
|
bounds: null,
|
||||||
effectBounds: null,
|
effectBounds: null,
|
||||||
idSeed: 0,
|
idSeed: 0,
|
||||||
@ -163,9 +169,10 @@ function renderNodeSubtree(
|
|||||||
node.type !== "BOOLEAN_OPERATION" || !(node.fillGeometry?.length || node.strokeGeometry?.length)
|
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),
|
||||||
|
renderTextNode(context, node, matrix),
|
||||||
collectFigmaLikeEllipseInnerShadowHint(context, node, matrix),
|
collectFigmaLikeEllipseInnerShadowHint(context, node, matrix),
|
||||||
...renderStrokeGeometry(context, node, matrix),
|
...renderStrokeGeometry(context, node, matrix),
|
||||||
...(shouldRenderChildren ? getSortedChildren(context, node).map((child) => renderNodeSubtree(context, child, matrix)) : [])
|
...(shouldRenderChildren ? renderChildNodes(context, node, matrix) : [])
|
||||||
].join("")
|
].join("")
|
||||||
|
|
||||||
if (!nodeContent) return ""
|
if (!nodeContent) return ""
|
||||||
@ -177,12 +184,170 @@ function renderNodeSubtree(
|
|||||||
return `<g${opacity}${filter}>${nodeContent}</g>`
|
return `<g${opacity}${filter}>${nodeContent}</g>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderChildNodes(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] {
|
||||||
|
const output: string[] = []
|
||||||
|
let activeClipId: string | null = null
|
||||||
|
|
||||||
|
for (const child of getSortedChildren(context, node)) {
|
||||||
|
if (child.visible === false) continue
|
||||||
|
|
||||||
|
if (child.mask) {
|
||||||
|
activeClipId = createMaskClipPath(context, child, matrix)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const rendered = renderNodeSubtree(context, child, matrix)
|
||||||
|
if (!rendered) continue
|
||||||
|
|
||||||
|
output.push(activeClipId ? `<g clip-path="url(#${activeClipId})">${rendered}</g>` : rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMaskClipPath(context: RenderContext, node: FigNode, parentMatrix: SvgMatrix): string | null {
|
||||||
|
const content = renderClipPathNode(context, node, parentMatrix)
|
||||||
|
if (!content) return null
|
||||||
|
|
||||||
|
const id = nextId(context, "clip")
|
||||||
|
context.defs.push(`<clipPath id="${id}" clipPathUnits="userSpaceOnUse">${content}</clipPath>`)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClipPathNode(context: RenderContext, node: FigNode, parentMatrix: SvgMatrix): string {
|
||||||
|
if (node.visible === false) return ""
|
||||||
|
|
||||||
|
const matrix = multiply(parentMatrix, toSvgMatrix(node.transform))
|
||||||
|
return [
|
||||||
|
...renderGeometryClipPaths(context, node.fillGeometry, matrix),
|
||||||
|
...renderGeometryClipPaths(context, node.strokeGeometry, matrix),
|
||||||
|
...renderTextClipPaths(context, node, matrix),
|
||||||
|
...getSortedChildren(context, node).map((child) => renderClipPathNode(context, child, matrix))
|
||||||
|
].join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGeometryClipPaths(
|
||||||
|
context: RenderContext,
|
||||||
|
geometries: FigGeometry[] | undefined,
|
||||||
|
matrix: SvgMatrix
|
||||||
|
): string[] {
|
||||||
|
if (!geometries?.length) return []
|
||||||
|
|
||||||
|
return geometries.flatMap((geometry) => {
|
||||||
|
const parsed = tryParsePathBlob(context.figJson, geometry.commandsBlob)
|
||||||
|
if (!parsed || !isFiniteBounds(parsed.bounds) || !parsed.d) return []
|
||||||
|
|
||||||
|
const clipRule = geometry.windingRule === "ODD" ? ` clip-rule="evenodd"` : ""
|
||||||
|
return `<path d="${transformPathData(parsed.d, matrix)}"${clipRule}/>`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTextClipPaths(context: RenderContext, node: FigNode, matrix: SvgMatrix): string[] {
|
||||||
|
if (node.type !== "TEXT") return []
|
||||||
|
|
||||||
|
return (
|
||||||
|
node.derivedTextData?.glyphs?.flatMap((glyph) => {
|
||||||
|
const parsed = parsePathBlob(context.figJson, glyph.commandsBlob)
|
||||||
|
if (!isFiniteBounds(parsed.bounds) || !parsed.d) return []
|
||||||
|
|
||||||
|
return `<path d="${transformPathData(parsed.d, getGlyphMatrix(glyph, matrix))}"/>`
|
||||||
|
}) ?? []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTextNode(context: RenderContext, node: FigNode, matrix: SvgMatrix): string {
|
||||||
|
if (node.type !== "TEXT") return ""
|
||||||
|
|
||||||
|
const glyphs = node.derivedTextData?.glyphs
|
||||||
|
const textBounds = getTextLocalBounds(node)
|
||||||
|
if (!glyphs?.length || !textBounds) return ""
|
||||||
|
|
||||||
|
const groups = new Map<string, { paints: FigPaint[]; paths: string[] }>()
|
||||||
|
includeBounds(context, transformBounds(matrix, textBounds))
|
||||||
|
|
||||||
|
for (const glyph of glyphs) {
|
||||||
|
const parsed = parsePathBlob(context.figJson, glyph.commandsBlob)
|
||||||
|
if (!isFiniteBounds(parsed.bounds) || !parsed.d) continue
|
||||||
|
|
||||||
|
const paints = getTextGlyphPaints(node, glyph)
|
||||||
|
if (!paints.length) continue
|
||||||
|
|
||||||
|
const key = JSON.stringify(paints)
|
||||||
|
const group = groups.get(key) ?? { paints, paths: [] }
|
||||||
|
group.paths.push(transformPathData(parsed.d, getGlyphMatrix(glyph, matrix)))
|
||||||
|
groups.set(key, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groups.values()]
|
||||||
|
.flatMap((group) => renderTextPaintGroup(context, node, textBounds, group.paints, matrix, group.paths))
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTextPaintGroup(
|
||||||
|
context: RenderContext,
|
||||||
|
node: FigNode,
|
||||||
|
textBounds: Bounds,
|
||||||
|
paints: FigPaint[],
|
||||||
|
matrix: SvgMatrix,
|
||||||
|
paths: string[]
|
||||||
|
): string[] {
|
||||||
|
if (!paths.length) return []
|
||||||
|
|
||||||
|
const d = paths.join(" ")
|
||||||
|
return paints
|
||||||
|
.filter((paint) => paint.visible !== false)
|
||||||
|
.map((paint) => {
|
||||||
|
if (paint.type === "IMAGE") return ""
|
||||||
|
|
||||||
|
const fill = paintToSvgFill(context, node, textBounds, paint, matrix)
|
||||||
|
const opacity = paintOpacityAttribute("fill", paint)
|
||||||
|
return `<path d="${d}" fill="${fill}"${opacity}/>`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextGlyphPaints(node: FigNode, glyph: FigTextGlyph): FigPaint[] {
|
||||||
|
const characterIndex = glyph.firstCharacter ?? 0
|
||||||
|
const styleId = node.textData?.characterStyleIDs?.[characterIndex]
|
||||||
|
const override = node.textData?.styleOverrideTable?.find((style) => style.styleID === styleId)
|
||||||
|
|
||||||
|
return override?.fillPaints ?? node.fillPaints ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextLocalBounds(node: FigNode): Bounds | null {
|
||||||
|
const size = node.derivedTextData?.layoutSize ?? node.size
|
||||||
|
if (!size || size.x <= 0 || size.y <= 0) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: 0,
|
||||||
|
minY: 0,
|
||||||
|
maxX: size.x,
|
||||||
|
maxY: size.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGlyphMatrix(glyph: FigTextGlyph, textMatrix: SvgMatrix): SvgMatrix {
|
||||||
|
const position = glyph.position ?? { x: 0, y: 0 }
|
||||||
|
const fontSize = glyph.fontSize ?? 1
|
||||||
|
const rotation = glyph.rotation ?? 0
|
||||||
|
|
||||||
|
// Glyph blobs are normalized font outlines with positive Y going upward from
|
||||||
|
// the baseline. SVG local space is Y-down, so text rendering needs a vertical
|
||||||
|
// flip around Figma's stored glyph baseline.
|
||||||
|
const scale: SvgMatrix = [fontSize, 0, 0, -fontSize, 0, 0]
|
||||||
|
const glyphLocal =
|
||||||
|
rotation === 0
|
||||||
|
? ([fontSize, 0, 0, -fontSize, position.x, position.y] as SvgMatrix)
|
||||||
|
: multiply([Math.cos(rotation), Math.sin(rotation), -Math.sin(rotation), Math.cos(rotation), position.x, position.y], scale)
|
||||||
|
|
||||||
|
return multiply(textMatrix, glyphLocal)
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
const pathStroke = renderPathStroke(context, node, matrix)
|
||||||
if (pathStroke) return pathStroke
|
if (pathStroke?.length) return pathStroke
|
||||||
|
|
||||||
return renderGeometry(context, node, node.strokeGeometry, node.strokePaints, matrix)
|
return renderGeometry(context, node, node.strokeGeometry, node.strokePaints, matrix)
|
||||||
}
|
}
|
||||||
@ -196,7 +361,9 @@ function renderPathStroke(context: RenderContext, node: FigNode, matrix: SvgMatr
|
|||||||
if (hasGradientStroke && !shouldRenderGradientStrokeAsPath(node)) return null
|
if (hasGradientStroke && !shouldRenderGradientStrokeAsPath(node)) return null
|
||||||
|
|
||||||
return node.fillGeometry.flatMap((geometry) => {
|
return node.fillGeometry.flatMap((geometry) => {
|
||||||
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
|
const parsed = tryParsePathBlob(context.figJson, geometry.commandsBlob)
|
||||||
|
if (!parsed || !isFiniteBounds(parsed.bounds) || !parsed.d) return []
|
||||||
|
|
||||||
const expandedBounds = expandBounds(parsed.bounds, strokeWeight / 2)
|
const expandedBounds = expandBounds(parsed.bounds, strokeWeight / 2)
|
||||||
includeBounds(context, transformBounds(matrix, expandedBounds))
|
includeBounds(context, transformBounds(matrix, expandedBounds))
|
||||||
const strokeMatrix = multiply(matrix, getStrokeAlignmentMatrix(parsed.bounds, strokeWeight, node.strokeAlign))
|
const strokeMatrix = multiply(matrix, getStrokeAlignmentMatrix(parsed.bounds, strokeWeight, node.strokeAlign))
|
||||||
@ -217,6 +384,13 @@ function renderPathStroke(context: RenderContext, node: FigNode, matrix: SvgMatr
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tryParsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath | null {
|
||||||
|
const blob = figJson.blobs?.[blobIndex]
|
||||||
|
if (!blob) return null
|
||||||
|
|
||||||
|
return parsePathBlob(figJson, blobIndex)
|
||||||
|
}
|
||||||
|
|
||||||
function shouldRenderGradientStrokeAsPath(node: FigNode): boolean {
|
function shouldRenderGradientStrokeAsPath(node: FigNode): boolean {
|
||||||
return Boolean(node.type === "ELLIPSE" && node.size && isFullEllipse(node.arcData))
|
return Boolean(node.type === "ELLIPSE" && node.size && isFullEllipse(node.arcData))
|
||||||
}
|
}
|
||||||
@ -265,6 +439,9 @@ function getStraightSegments(d: string): Array<{ from: { x: number; y: number };
|
|||||||
const next = readPoint()
|
const next = readPoint()
|
||||||
segments.push({ from: current, to: next })
|
segments.push({ from: current, to: next })
|
||||||
current = next
|
current = next
|
||||||
|
} else if (command === "Q") {
|
||||||
|
index += 2
|
||||||
|
current = readPoint()
|
||||||
} else if (command === "C") {
|
} else if (command === "C") {
|
||||||
index += 4
|
index += 4
|
||||||
current = readPoint()
|
current = readPoint()
|
||||||
@ -412,6 +589,8 @@ function renderGeometry(
|
|||||||
|
|
||||||
return geometries.flatMap((geometry) => {
|
return geometries.flatMap((geometry) => {
|
||||||
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
|
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
|
||||||
|
if (!isFiniteBounds(parsed.bounds) || !parsed.d) return []
|
||||||
|
|
||||||
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"` : ""
|
const fillRule = geometry.windingRule === "ODD" ? ` fill-rule="evenodd"` : ""
|
||||||
@ -419,6 +598,10 @@ function renderGeometry(
|
|||||||
return paints
|
return paints
|
||||||
.filter((paint) => paint.visible !== false)
|
.filter((paint) => paint.visible !== false)
|
||||||
.map((paint) => {
|
.map((paint) => {
|
||||||
|
if (paint.type === "IMAGE") {
|
||||||
|
return renderImageFill(context, node, parsed, paint, matrix, fillRule)
|
||||||
|
}
|
||||||
|
|
||||||
const fill = paintToSvgFill(context, node, parsed.bounds, paint, matrix)
|
const fill = paintToSvgFill(context, node, parsed.bounds, paint, matrix)
|
||||||
const opacity = paintOpacityAttribute("fill", paint)
|
const opacity = paintOpacityAttribute("fill", paint)
|
||||||
|
|
||||||
@ -427,6 +610,31 @@ function renderGeometry(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderImageFill(
|
||||||
|
context: RenderContext,
|
||||||
|
node: FigNode,
|
||||||
|
parsed: ParsedPath,
|
||||||
|
paint: FigPaint,
|
||||||
|
matrix: SvgMatrix,
|
||||||
|
fillRule: string
|
||||||
|
): string {
|
||||||
|
const href = getImageDataUrl(context, paint)
|
||||||
|
const clipId = nextId(context, "clip")
|
||||||
|
const clipRule = fillRule ? fillRule.replace(" fill-rule=", " clip-rule=") : ""
|
||||||
|
const transformedPath = transformPathData(parsed.d, matrix)
|
||||||
|
const imageMatrix = getImagePaintMatrix(node, parsed.bounds, paint, matrix)
|
||||||
|
const opacity = imageOpacityAttribute(paint)
|
||||||
|
|
||||||
|
context.defs.push(`<clipPath id="${clipId}"><path d="${transformedPath}"${clipRule}/></clipPath>`)
|
||||||
|
|
||||||
|
// Figma stores image crop/fill as a normalized transform on the paint. Keep
|
||||||
|
// the clip on a wrapper <g>; resvg can drop the bitmap when clip-path is put
|
||||||
|
// directly on a transformed unit-sized <image>.
|
||||||
|
return `<g clip-path="url(#${clipId})"><image href="${href}" width="1" height="1" preserveAspectRatio="none" transform="matrix(${imageMatrix
|
||||||
|
.map(format)
|
||||||
|
.join(" ")})"${opacity}/></g>`
|
||||||
|
}
|
||||||
|
|
||||||
function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath {
|
function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath {
|
||||||
const blob = figJson.blobs?.[blobIndex]
|
const blob = figJson.blobs?.[blobIndex]
|
||||||
if (!blob) {
|
if (!blob) {
|
||||||
@ -437,6 +645,7 @@ function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath {
|
|||||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||||
let offset = 0
|
let offset = 0
|
||||||
let d = ""
|
let d = ""
|
||||||
|
let hasOpenSubpath = false
|
||||||
const bounds = createEmptyBounds()
|
const bounds = createEmptyBounds()
|
||||||
|
|
||||||
const readFloat = () => {
|
const readFloat = () => {
|
||||||
@ -459,12 +668,18 @@ function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath {
|
|||||||
|
|
||||||
if (command === COMMAND_MOVE) {
|
if (command === COMMAND_MOVE) {
|
||||||
d += `M ${readPoint()} `
|
d += `M ${readPoint()} `
|
||||||
|
hasOpenSubpath = true
|
||||||
} else if (command === COMMAND_LINE) {
|
} else if (command === COMMAND_LINE) {
|
||||||
d += `L ${readPoint()} `
|
d += `L ${readPoint()} `
|
||||||
|
} else if (command === COMMAND_QUADRATIC) {
|
||||||
|
d += `Q ${readPoint()} ${readPoint()} `
|
||||||
} else if (command === COMMAND_CUBIC) {
|
} else if (command === COMMAND_CUBIC) {
|
||||||
d += `C ${readPoint()} ${readPoint()} ${readPoint()} `
|
d += `C ${readPoint()} ${readPoint()} ${readPoint()} `
|
||||||
} else if (command === COMMAND_CLOSE) {
|
} else if (command === COMMAND_CLOSE) {
|
||||||
d += "Z "
|
if (hasOpenSubpath) {
|
||||||
|
d += "Z "
|
||||||
|
hasOpenSubpath = false
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`几何数据 blob ${blobIndex} 中存在不支持的向量命令:${command}`)
|
throw new Error(`几何数据 blob ${blobIndex} 中存在不支持的向量命令:${command}`)
|
||||||
}
|
}
|
||||||
@ -476,7 +691,7 @@ function parsePathBlob(figJson: FigJson, blobIndex: number): ParsedPath {
|
|||||||
function transformPathData(d: string, matrix: SvgMatrix): string {
|
function transformPathData(d: string, matrix: SvgMatrix): string {
|
||||||
if (matrix === IDENTITY) return d
|
if (matrix === IDENTITY) return d
|
||||||
|
|
||||||
const tokens = d.match(/[MLCZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? []
|
const tokens = d.match(/[MLQCZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? []
|
||||||
const output: string[] = []
|
const output: string[] = []
|
||||||
let index = 0
|
let index = 0
|
||||||
|
|
||||||
@ -488,6 +703,8 @@ function transformPathData(d: string, matrix: SvgMatrix): string {
|
|||||||
const command = tokens[index++]
|
const command = tokens[index++]
|
||||||
if (command === "M" || command === "L") {
|
if (command === "M" || command === "L") {
|
||||||
output.push(`${command} ${writePoint(readPoint())}`)
|
output.push(`${command} ${writePoint(readPoint())}`)
|
||||||
|
} else if (command === "Q") {
|
||||||
|
output.push(`Q ${writePoint(readPoint())} ${writePoint(readPoint())}`)
|
||||||
} else if (command === "C") {
|
} else if (command === "C") {
|
||||||
output.push(`C ${writePoint(readPoint())} ${writePoint(readPoint())} ${writePoint(readPoint())}`)
|
output.push(`C ${writePoint(readPoint())} ${writePoint(readPoint())} ${writePoint(readPoint())}`)
|
||||||
} else if (command === "Z") {
|
} else if (command === "Z") {
|
||||||
@ -529,6 +746,26 @@ function paintToSvgFill(
|
|||||||
return `url(#${id})`
|
return `url(#${id})`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (paint.type === "GRADIENT_RADIAL") {
|
||||||
|
const id = nextId(context, "gradient")
|
||||||
|
const gradientMatrix = getRadialGradientMatrix(node, pathBounds, paint, matrix)
|
||||||
|
const stops = paint.stops
|
||||||
|
?.map(
|
||||||
|
(stop) =>
|
||||||
|
`<stop offset="${format(stop.position * 100)}%" stop-color="${colorToCss(stop.color)}" stop-opacity="${format(
|
||||||
|
stop.color.a
|
||||||
|
)}"/>`
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
|
||||||
|
context.defs.push(
|
||||||
|
`<radialGradient id="${id}" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="matrix(${gradientMatrix
|
||||||
|
.map(format)
|
||||||
|
.join(" ")})">${stops ?? ""}</radialGradient>`
|
||||||
|
)
|
||||||
|
return `url(#${id})`
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`不支持的填充类型:${paint.type}`)
|
throw new Error(`不支持的填充类型:${paint.type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -554,6 +791,73 @@ function getLinearGradientLine(node: FigNode, pathBounds: Bounds, paint: FigPain
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRadialGradientMatrix(node: FigNode, pathBounds: Bounds, paint: FigPaint, matrix: SvgMatrix): 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
|
||||||
|
const originY = pathBounds.minY < 0 ? pathBounds.minY : 0
|
||||||
|
const inverse = invert(toSvgMatrix(paint.transform))
|
||||||
|
const center = applyToPoint(inverse, 0, 0)
|
||||||
|
const xEdge = applyToPoint(inverse, 1, 0)
|
||||||
|
const yEdge = applyToPoint(inverse, 0, 1)
|
||||||
|
const localMatrix: SvgMatrix = [
|
||||||
|
(xEdge.x - center.x) * width,
|
||||||
|
(xEdge.y - center.y) * height,
|
||||||
|
(yEdge.x - center.x) * width,
|
||||||
|
(yEdge.y - center.y) * height,
|
||||||
|
originX + center.x * width,
|
||||||
|
originY + center.y * height
|
||||||
|
]
|
||||||
|
|
||||||
|
// Radial gradients use the same normalized paint transform as linear
|
||||||
|
// gradients, but SVG needs that unit circle projected into user space.
|
||||||
|
return multiply(matrix, localMatrix)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageDataUrl(context: RenderContext, paint: FigPaint): string {
|
||||||
|
const candidates = [hashToHex(paint.image?.hash), hashToHex(paint.imageThumbnail?.hash)].filter(Boolean) as string[]
|
||||||
|
for (const hash of candidates) {
|
||||||
|
const asset = context.imageAssets.get(hash)
|
||||||
|
if (asset) return asset
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`不支持的 IMAGE 填充:找不到本地图片资源 ${candidates.join(" 或 ") || "unknown"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImagePaintMatrix(node: FigNode, pathBounds: Bounds, paint: FigPaint, matrix: SvgMatrix): 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
|
||||||
|
const originY = pathBounds.minY < 0 ? pathBounds.minY : 0
|
||||||
|
const transform = paint.transform ?? { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||||||
|
const imageMatrix: SvgMatrix = [
|
||||||
|
transform.m00 * width,
|
||||||
|
transform.m10 * height,
|
||||||
|
transform.m01 * width,
|
||||||
|
transform.m11 * height,
|
||||||
|
originX + transform.m02 * width,
|
||||||
|
originY + transform.m12 * height
|
||||||
|
]
|
||||||
|
|
||||||
|
return multiply(matrix, imageMatrix)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashToHex(hash: Uint8Array | number[] | string | Record<string, number> | undefined): string | null {
|
||||||
|
if (!hash) return null
|
||||||
|
if (typeof hash === "string") return hash.toLowerCase()
|
||||||
|
|
||||||
|
const values =
|
||||||
|
hash instanceof Uint8Array
|
||||||
|
? [...hash]
|
||||||
|
: Array.isArray(hash)
|
||||||
|
? hash
|
||||||
|
: Object.keys(hash)
|
||||||
|
.sort((left, right) => Number(left) - Number(right))
|
||||||
|
.map((key) => (hash as Record<string, number>)[key])
|
||||||
|
|
||||||
|
return values.map((value) => value.toString(16).padStart(2, "0")).join("")
|
||||||
|
}
|
||||||
|
|
||||||
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")
|
||||||
const innerShadows = effects?.filter((effect) => effect.visible !== false && effect.type === "INNER_SHADOW") ?? []
|
const innerShadows = effects?.filter((effect) => effect.visible !== false && effect.type === "INNER_SHADOW") ?? []
|
||||||
@ -754,12 +1058,18 @@ function getRootExportBounds(target: FigNode, renderedBounds: Bounds | null, eff
|
|||||||
|
|
||||||
// Native exports keep the node's nominal box, then extend the bitmap when
|
// Native exports keep the node's nominal box, then extend the bitmap when
|
||||||
// visible filters such as foreground blur reach outside that box.
|
// visible filters such as foreground blur reach outside that box.
|
||||||
|
if (shouldUseNominalRootBounds(target)) return targetBounds
|
||||||
|
|
||||||
return effectBounds ? unionBounds(targetBounds, effectBounds) : targetBounds
|
return effectBounds ? unionBounds(targetBounds, effectBounds) : targetBounds
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderedBounds ?? { minX: 0, minY: 0, maxX: 0, maxY: 0 }
|
return renderedBounds ?? { minX: 0, minY: 0, maxX: 0, maxY: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseNominalRootBounds(target: FigNode): boolean {
|
||||||
|
return Boolean(target.exportSettings?.some((setting) => setting.useAbsoluteBounds === false))
|
||||||
|
}
|
||||||
|
|
||||||
function getFigmaPixelSize(size: number, scale: number): number {
|
function getFigmaPixelSize(size: number, scale: number): number {
|
||||||
const raw = size * scale
|
const raw = size * scale
|
||||||
const rounded = Math.round(raw)
|
const rounded = Math.round(raw)
|
||||||
@ -791,7 +1101,12 @@ function getGeometryLocalBounds(context: RenderContext, node: FigNode): Bounds |
|
|||||||
let bounds: Bounds | null = null
|
let bounds: Bounds | null = null
|
||||||
|
|
||||||
for (const geometry of geometries) {
|
for (const geometry of geometries) {
|
||||||
const parsed = parsePathBlob(context.figJson, geometry.commandsBlob)
|
// Some imported component assets keep stale fillGeometry blob references
|
||||||
|
// for invisible fills. Bounds should come from any remaining valid geometry
|
||||||
|
// instead of failing before visible strokeGeometry can render.
|
||||||
|
const parsed = tryParsePathBlob(context.figJson, geometry.commandsBlob)
|
||||||
|
if (!parsed || !isFiniteBounds(parsed.bounds)) continue
|
||||||
|
|
||||||
bounds = bounds ? unionBounds(bounds, parsed.bounds) : { ...parsed.bounds }
|
bounds = bounds ? unionBounds(bounds, parsed.bounds) : { ...parsed.bounds }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -875,6 +1190,17 @@ 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 isFiniteBounds(bounds: Bounds): boolean {
|
||||||
|
return (
|
||||||
|
Number.isFinite(bounds.minX) &&
|
||||||
|
Number.isFinite(bounds.minY) &&
|
||||||
|
Number.isFinite(bounds.maxX) &&
|
||||||
|
Number.isFinite(bounds.maxY) &&
|
||||||
|
bounds.maxX >= bounds.minX &&
|
||||||
|
bounds.maxY >= bounds.minY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function expandBounds(bounds: Bounds, amount: number): Bounds {
|
function expandBounds(bounds: Bounds, amount: number): Bounds {
|
||||||
return {
|
return {
|
||||||
minX: bounds.minX - amount,
|
minX: bounds.minX - amount,
|
||||||
@ -993,6 +1319,11 @@ function paintOpacityAttribute(kind: "fill" | "stroke", paint: FigPaint): string
|
|||||||
return opacity !== 1 ? ` ${kind}-opacity="${format(opacity)}"` : ""
|
return opacity !== 1 ? ` ${kind}-opacity="${format(opacity)}"` : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function imageOpacityAttribute(paint: FigPaint): string {
|
||||||
|
const opacity = paint.opacity ?? 1
|
||||||
|
return opacity !== 1 ? ` opacity="${format(opacity)}"` : ""
|
||||||
|
}
|
||||||
|
|
||||||
function strokeDashArrayAttribute(node: FigNode): string {
|
function strokeDashArrayAttribute(node: FigNode): string {
|
||||||
const dashPattern = node.dashPattern?.filter((value) => value > 0)
|
const dashPattern = node.dashPattern?.filter((value) => value > 0)
|
||||||
if (!dashPattern?.length) return ""
|
if (!dashPattern?.length) return ""
|
||||||
|
|||||||
@ -32,6 +32,47 @@ export type FigPaint = {
|
|||||||
visible?: boolean
|
visible?: boolean
|
||||||
stops?: Array<{ color: FigColor; position: number }>
|
stops?: Array<{ color: FigColor; position: number }>
|
||||||
transform?: FigmaMatrix
|
transform?: FigmaMatrix
|
||||||
|
image?: { hash?: Uint8Array | number[] | string; name?: string }
|
||||||
|
imageThumbnail?: { hash?: Uint8Array | number[] | string; name?: string }
|
||||||
|
imageScaleMode?: "FILL" | "FIT" | "STRETCH" | "TILE" | string
|
||||||
|
originalImageWidth?: number
|
||||||
|
originalImageHeight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FigTextGlyph = {
|
||||||
|
commandsBlob: number
|
||||||
|
position?: { x: number; y: number }
|
||||||
|
fontSize?: number
|
||||||
|
firstCharacter?: number
|
||||||
|
advance?: number
|
||||||
|
rotation?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FigTextStyleOverride = {
|
||||||
|
styleID?: number
|
||||||
|
fillPaints?: FigPaint[]
|
||||||
|
fontSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FigTextData = {
|
||||||
|
characters?: string
|
||||||
|
characterStyleIDs?: number[]
|
||||||
|
styleOverrideTable?: FigTextStyleOverride[]
|
||||||
|
lines?: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FigDerivedTextData = {
|
||||||
|
glyphs?: FigTextGlyph[]
|
||||||
|
layoutSize?: { x: number; y: number }
|
||||||
|
baselines?: Array<{
|
||||||
|
position?: { x: number; y: number }
|
||||||
|
width?: number
|
||||||
|
lineY?: number
|
||||||
|
lineHeight?: number
|
||||||
|
lineAscent?: number
|
||||||
|
firstCharacter?: number
|
||||||
|
endCharacter?: number
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FigGeometry = {
|
export type FigGeometry = {
|
||||||
@ -62,12 +103,19 @@ export type FigNode = {
|
|||||||
strokeWeight?: number
|
strokeWeight?: number
|
||||||
strokeAlign?: "CENTER" | "INSIDE" | "OUTSIDE"
|
strokeAlign?: "CENTER" | "INSIDE" | "OUTSIDE"
|
||||||
dashPattern?: number[]
|
dashPattern?: number[]
|
||||||
|
frameMaskDisabled?: boolean
|
||||||
|
mask?: boolean
|
||||||
|
maskType?: "OUTLINE" | "ALPHA" | string
|
||||||
|
exportSettings?: Array<{ useAbsoluteBounds?: boolean; contentsOnly?: boolean; [key: string]: unknown }>
|
||||||
arcData?: FigArcData
|
arcData?: FigArcData
|
||||||
fillPaints?: FigPaint[]
|
fillPaints?: FigPaint[]
|
||||||
strokePaints?: FigPaint[]
|
strokePaints?: FigPaint[]
|
||||||
fillGeometry?: FigGeometry[]
|
fillGeometry?: FigGeometry[]
|
||||||
strokeGeometry?: FigGeometry[]
|
strokeGeometry?: FigGeometry[]
|
||||||
effects?: FigEffect[]
|
effects?: FigEffect[]
|
||||||
|
fontSize?: number
|
||||||
|
textData?: FigTextData
|
||||||
|
derivedTextData?: FigDerivedTextData
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FigJson = {
|
export type FigJson = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user