Reduce cyclomatic complexity below 20 in all core functions

Refactor 24 complex functions across 14 files by extracting
dispatch branches and property-group handlers into focused helpers.

kiwi/kiwi-convert.ts (4 functions, was 22-81):
- nodeChangeToProps → 6 property-group converters
- convertOverrideToProps → 5 override applicators
- importStyleRuns → 3 style-run helpers
- mapNodeType → NODE_TYPE_MAP lookup table

kiwi/instance-overrides.ts (3 functions, was 28-44):
- applyDerivedSymbolData → resolveDsd* helpers
- applyComponentProperties → assignment/override applicators
- propagateOverridesTransitively → clone/sync helpers

kiwi/fig-import.ts (2 functions, was 34-40):
- importNodeChanges → buildChangeMaps, importPages, etc.
- importVariables → importCollections, resolveVariableType, etc.

render/export-jsx.ts (2 functions, was 70-85):
- collectProps → property-category extractors
- collectTailwindClasses → Tailwind class-group extractors

render/renderer.ts: propsToOverrides (78) → style-category helpers

renderer/scene.ts: renderNode (46), renderShapeUncached (30) →
  phase extractors (transforms, content, children, strokes)

renderer/renderer.ts: buildParagraph (34) → buildTruncateOpts,
  addStyledRuns

svg-export.ts: split into svg-export.ts + svg-export-defs.ts +
  svg-export-paths.ts; renderNode/renderTextNode/nodeShapeElements
  all reduced via helper extraction

kiwi-serialize.ts: sceneNodeToKiwi (44) → 6 property serializers
clipboard.ts: importClipboardNodes (36) → 5 focused helpers
scene-graph.ts: hitTestChildren (31) → containsPoint,
  hitTestOpaqueContainer, hitTestTransparentContainer
tools/analyze.ts: serializeNodeProps (23) → 4 prop serializers
This commit is contained in:
Danila Poyarkov 2026-03-09 13:40:57 +03:00
parent f3eac5ac8b
commit 59981709ba
17 changed files with 2116 additions and 1752 deletions

View file

@ -88,6 +88,7 @@
"typescript/no-non-null-assertion": "off"
}
},
{
"files": ["**/kiwi/kiwi-schema/**"],
"rules": {
@ -116,12 +117,6 @@
"rules": {
"open-pencil/no-raw-console-format": "error"
}
},
{
"files": ["packages/core/src/**"],
"rules": {
"complexity": "warn"
}
}
],
"ignorePatterns": ["node_modules", "dist", "desktop", "*.config.*"]

View file

@ -77,6 +77,13 @@ const NON_VISUAL_TYPES = new Set([
'SLIDE'
])
function isChildOfVisualNode(nc: KiwiNodeChange, parentTypes: Map<string, string>): boolean {
const parentId = nc.parentIndex?.guid
? `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}`
: null
return !!parentId && parentTypes.has(parentId) && !NON_VISUAL_TYPES.has(parentTypes.get(parentId) ?? '')
}
export function figmaNodesBounds(
nodeChanges: KiwiNodeChange[]
): { x: number; y: number; w: number; h: number } | null {
@ -93,11 +100,7 @@ export function figmaNodesBounds(
for (const nc of nodeChanges) {
if (!nc.type || NON_VISUAL_TYPES.has(nc.type)) continue
const parentId = nc.parentIndex?.guid
? `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}`
: null
if (parentId && parentTypes.has(parentId) && !NON_VISUAL_TYPES.has(parentTypes.get(parentId)!))
continue
if (isChildOfVisualNode(nc, parentTypes)) continue
const x = nc.transform?.m02 ?? 0
const y = nc.transform?.m12 ?? 0
@ -113,14 +116,12 @@ export function figmaNodesBounds(
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
}
export function importClipboardNodes(
nodeChanges: KiwiNodeChange[],
graph: SceneGraph,
targetParentId: string,
offsetX = 0,
offsetY = 0,
blobs: Uint8Array[] = []
): string[] {
interface ClipboardImportMaps {
guidMap: Map<string, KiwiNodeChange>
parentMap: Map<string, string>
}
function buildClipboardMaps(nodeChanges: KiwiNodeChange[]): ClipboardImportMaps {
const guidMap = new Map<string, KiwiNodeChange>()
const parentMap = new Map<string, string>()
for (const nc of nodeChanges) {
@ -130,7 +131,13 @@ export function importClipboardNodes(
parentMap.set(id, `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}`)
}
}
return { guidMap, parentMap }
}
function findInternalNodeIds(
guidMap: Map<string, KiwiNodeChange>,
parentMap: Map<string, string>
): { internalCanvasIds: Set<string>; internalFigmaIds: Set<string> } {
const internalCanvasIds = new Set<string>()
for (const [id, nc] of guidMap) {
if (nc.type === 'CANVAS' && nc.internalOnly) {
@ -147,6 +154,14 @@ export function importClipboardNodes(
}
for (const canvasId of internalCanvasIds) markInternal(canvasId)
return { internalCanvasIds, internalFigmaIds }
}
function classifyTopLevelNodes(
guidMap: Map<string, KiwiNodeChange>,
parentMap: Map<string, string>,
internalCanvasIds: Set<string>
): { topLevel: string[]; internalTopLevel: string[] } {
const topLevel: string[] = []
const internalTopLevel: string[] = []
for (const [id, nc] of guidMap) {
@ -164,6 +179,39 @@ export function importClipboardNodes(
}
}
}
return { topLevel, internalTopLevel }
}
function remapComponentIds(created: Map<string, string>, graph: SceneGraph): void {
for (const [, ourId] of created) {
const node = graph.getNode(ourId)
if (node?.type !== 'INSTANCE' || !node.componentId) continue
const ourComponentId = created.get(node.componentId)
if (ourComponentId) graph.updateNode(ourId, { componentId: ourComponentId })
}
}
function detachOrphanedInstances(created: Map<string, string>, graph: SceneGraph): void {
for (const [, ourId] of created) {
const node = graph.getNode(ourId)
if (node?.type !== 'INSTANCE') continue
if (node.childIds.length === 0 && (!node.componentId || !graph.getNode(node.componentId))) {
graph.updateNode(ourId, { type: 'FRAME', componentId: '' })
}
}
}
export function importClipboardNodes(
nodeChanges: KiwiNodeChange[],
graph: SceneGraph,
targetParentId: string,
offsetX = 0,
offsetY = 0,
blobs: Uint8Array[] = []
): string[] {
const { guidMap, parentMap } = buildClipboardMaps(nodeChanges)
const { internalCanvasIds, internalFigmaIds } = findInternalNodeIds(guidMap, parentMap)
const { topLevel, internalTopLevel } = classifyTopLevelNodes(guidMap, parentMap, internalCanvasIds)
const created = new Map<string, string>()
const createdIds: string[] = []
@ -205,13 +253,7 @@ export function importClipboardNodes(
createNode(id, targetParentId)
}
// Remap componentId from original Figma GUIDs to our node IDs
for (const [, ourId] of created) {
const node = graph.getNode(ourId)
if (node?.type !== 'INSTANCE' || !node.componentId) continue
const ourComponentId = created.get(node.componentId)
if (ourComponentId) graph.updateNode(ourId, { componentId: ourComponentId })
}
remapComponentIds(created, graph)
populateAndApplyOverrides(
graph,
@ -225,16 +267,7 @@ export function importClipboardNodes(
if (ourId) graph.deleteNode(ourId)
}
// Detach orphaned instances whose components weren't in the paste data.
// Without the component, children can't be populated — convert to FRAME
// so the node at least renders its own fills/strokes/layout.
for (const [, ourId] of created) {
const node = graph.getNode(ourId)
if (node?.type !== 'INSTANCE') continue
if (node.childIds.length === 0 && (!node.componentId || !graph.getNode(node.componentId))) {
graph.updateNode(ourId, { type: 'FRAME', componentId: '' })
}
}
detachOrphanedInstances(created, graph)
return createdIds
}

View file

@ -2,7 +2,7 @@ export const FIG_KIWI_VERSION = 106
import { deflateSync, inflateSync } from 'fflate'
import { weightToStyle, getLoadedFontData, styleToWeight } from './fonts'
import { weightToStyle, getLoadedFontData } from './fonts'
import { encodeVectorNetworkBlob } from './vector'
import { stringToGuid, VARIABLE_BINDING_FIELDS } from './kiwi/kiwi-convert'
@ -22,7 +22,8 @@ async function computeFontDigest(data: ArrayBuffer): Promise<Uint8Array> {
async function getFontDigest(family: string, style: string): Promise<Uint8Array | null> {
const key = `${family}|${style}`
if (fontDigestCache.has(key)) return fontDigestCache.get(key)!
const cached = fontDigestCache.get(key)
if (cached) return cached
const data = getLoadedFontData(family, style)
if (!data) return null
const digest = await computeFontDigest(data)
@ -202,7 +203,7 @@ function exportTextData(node: SceneNode): NodeChange['textData'] {
return { characters: node.text, lines: textLines(node.text) }
}
const charIds = new Array<number>(node.text.length).fill(0)
const charIds = Array.from<number>({ length: node.text.length }).fill(0)
const styleMap = new Map<string, { id: number; style: CharacterStyleOverride }>()
let nextId = 1
@ -247,6 +248,140 @@ function exportTextData(node: SceneNode): NodeChange['textData'] {
}
}
function fillToKiwiPaint(f: SceneNode['fills'][number]): Paint {
const paint: Paint = {
type: f.type,
color: f.color,
opacity: f.opacity,
visible: f.visible,
blendMode: f.blendMode ?? 'NORMAL'
}
if (f.gradientStops) {
paint.stops = f.gradientStops.map((s) => ({ color: s.color, position: s.position }))
}
if (f.gradientTransform) paint.transform = f.gradientTransform
if (f.imageHash) paint.image = { hash: f.imageHash }
if (f.imageScaleMode) paint.imageScaleMode = f.imageScaleMode
if (f.imageTransform) paint.transform = f.imageTransform
return paint
}
function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
if (node.cornerRadius > 0 || node.independentCorners) {
nc.cornerRadius = node.cornerRadius
nc.rectangleCornerRadiiIndependent = node.independentCorners
nc.rectangleTopLeftCornerRadius = node.independentCorners
? node.topLeftRadius
: node.cornerRadius
nc.rectangleTopRightCornerRadius = node.independentCorners
? node.topRightRadius
: node.cornerRadius
nc.rectangleBottomLeftCornerRadius = node.independentCorners
? node.bottomLeftRadius
: node.cornerRadius
nc.rectangleBottomRightCornerRadius = node.independentCorners
? node.bottomRightRadius
: node.cornerRadius
}
if (node.cornerSmoothing > 0) {
nc.cornerSmoothing = node.cornerSmoothing
}
}
function serializeTextProps(
node: SceneNode,
nc: KiwiNodeChange,
fontDigestMap?: Map<string, Uint8Array>
): void {
nc.fontSize = node.fontSize
nc.fontName = {
family: node.fontFamily,
style: weightToStyle(node.fontWeight, node.italic),
postscript: ''
}
nc.textData = exportTextData(node)
nc.textAutoResize = 'WIDTH_AND_HEIGHT'
nc.textAlignHorizontal = node.textAlignHorizontal
nc.textUserLayoutVersion = 3
if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap)
if (node.lineHeight != null) nc.lineHeight = { value: node.lineHeight, units: 'PIXELS' }
if (node.letterSpacing !== 0) nc.letterSpacing = { value: node.letterSpacing, units: 'PIXELS' }
if (node.textDecoration !== 'NONE') {
nc.textDecoration = node.textDecoration === 'UNDERLINE' ? 'UNDERLINE' : 'STRIKETHROUGH'
}
}
function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') {
nc.stackMode = node.layoutMode
nc.stackSpacing = node.itemSpacing
nc.stackVerticalPadding = node.paddingTop
nc.stackHorizontalPadding = node.paddingLeft
nc.stackPaddingBottom = node.paddingBottom
nc.stackPaddingRight = node.paddingRight
nc.stackPrimarySizing = node.primaryAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
nc.stackCounterSizing = node.counterAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
nc.stackPrimaryAlignItems = node.primaryAxisAlign
nc.stackCounterAlignItems = node.counterAxisAlign
if (node.layoutWrap === 'WRAP') nc.stackWrap = 'WRAP'
if (node.counterAxisSpacing > 0) nc.stackCounterSpacing = node.counterAxisSpacing
}
if (node.layoutPositioning === 'ABSOLUTE') nc.stackPositioning = 'ABSOLUTE'
if (node.layoutGrow > 0) nc.stackChildPrimaryGrow = node.layoutGrow
}
function serializeGeometry(node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]): void {
if (node.vectorNetwork && node.type === 'VECTOR') {
const blobIdx = blobs.length
blobs.push(encodeVectorNetworkBlob(node.vectorNetwork))
nc.vectorData = {
vectorNetworkBlob: blobIdx,
normalizedSize: { x: node.width, y: node.height }
}
}
if (node.fillGeometry.length > 0) {
nc.fillGeometry = node.fillGeometry.map((g) => {
const blobIdx = blobs.length
blobs.push(g.commandsBlob)
return { windingRule: g.windingRule, commandsBlob: blobIdx }
})
}
if (node.strokeGeometry.length > 0) {
nc.strokeGeometry = node.strokeGeometry.map((g) => {
const blobIdx = blobs.length
blobs.push(g.commandsBlob)
return { windingRule: g.windingRule, commandsBlob: blobIdx }
})
}
}
function serializeVariableBindings(
node: SceneNode,
nc: KiwiNodeChange,
graph: SceneGraph
): void {
if (Object.keys(node.boundVariables).length === 0) return
const entries: VariableConsumptionEntry[] = []
const typeMap: Record<string, string> = { COLOR: 'COLOR', BOOLEAN: 'BOOLEAN', STRING: 'STRING' }
for (const [field, varId] of Object.entries(node.boundVariables)) {
const kiwiField = VARIABLE_BINDING_FIELDS[field]
if (!kiwiField) continue
const variable = graph.variables.get(varId)
if (!variable) continue
const varGuid = stringToGuid(varId)
const resolvedType = typeMap[variable.type] ?? 'FLOAT'
entries.push({
variableData: {
value: { alias: { guid: varGuid } },
dataType: 'ALIAS',
resolvedDataType: resolvedType
},
variableField: kiwiField
})
}
if (entries.length > 0) nc.variableConsumptionMap = { entries }
}
export function sceneNodeToKiwi(
node: SceneNode,
parentGuid: GUID,
@ -264,24 +399,7 @@ export function sceneNodeToKiwi(
const cos = Math.cos((node.rotation * Math.PI) / 180)
const sin = Math.sin((node.rotation * Math.PI) / 180)
const fillPaints = node.fills.map((f) => {
const paint: Paint = {
type: f.type,
color: f.color,
opacity: f.opacity,
visible: f.visible,
blendMode: f.blendMode ?? 'NORMAL'
}
if (f.gradientStops) {
paint.stops = f.gradientStops.map((s) => ({ color: s.color, position: s.position }))
}
if (f.gradientTransform) paint.transform = f.gradientTransform
if (f.imageHash) paint.image = { hash: f.imageHash }
if (f.imageScaleMode) paint.imageScaleMode = f.imageScaleMode
if (f.imageTransform) paint.transform = f.imageTransform
return paint
})
const fillPaints = node.fills.map(fillToKiwiPaint)
const strokePaints = node.strokes.map((s) => ({
type: 'SOLID' as const,
color: s.color,
@ -315,26 +433,7 @@ export function sceneNodeToKiwi(
if (fillPaints.length > 0) nc.fillPaints = fillPaints
if (strokePaints.length > 0) nc.strokePaints = strokePaints
if (node.cornerRadius > 0 || node.independentCorners) {
nc.cornerRadius = node.cornerRadius
nc.rectangleCornerRadiiIndependent = node.independentCorners
nc.rectangleTopLeftCornerRadius = node.independentCorners
? node.topLeftRadius
: node.cornerRadius
nc.rectangleTopRightCornerRadius = node.independentCorners
? node.topRightRadius
: node.cornerRadius
nc.rectangleBottomLeftCornerRadius = node.independentCorners
? node.bottomLeftRadius
: node.cornerRadius
nc.rectangleBottomRightCornerRadius = node.independentCorners
? node.bottomRightRadius
: node.cornerRadius
}
if (node.cornerSmoothing > 0) {
nc.cornerSmoothing = node.cornerSmoothing
}
serializeCornerRadii(node, nc)
if (node.effects.length > 0) {
nc.effects = node.effects.map((e) => ({
@ -347,93 +446,16 @@ export function sceneNodeToKiwi(
}))
}
if (node.type === 'TEXT') {
nc.fontSize = node.fontSize
nc.fontName = {
family: node.fontFamily,
style: weightToStyle(node.fontWeight, node.italic),
postscript: ''
}
nc.textData = exportTextData(node)
nc.textAutoResize = 'WIDTH_AND_HEIGHT'
nc.textAlignHorizontal = node.textAlignHorizontal
nc.textUserLayoutVersion = 3
if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap)
if (node.lineHeight != null) nc.lineHeight = { value: node.lineHeight, units: 'PIXELS' }
if (node.letterSpacing !== 0) nc.letterSpacing = { value: node.letterSpacing, units: 'PIXELS' }
if (node.textDecoration !== 'NONE') {
nc.textDecoration = node.textDecoration === 'UNDERLINE' ? 'UNDERLINE' : 'STRIKETHROUGH'
}
}
if (node.type === 'TEXT') serializeTextProps(node, nc, fontDigestMap)
if (node.type === 'FRAME' || node.type === 'GROUP') {
nc.frameMaskDisabled = node.type === 'GROUP'
if (node.clipsContent) nc.clipsContent = true
}
if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') {
nc.stackMode = node.layoutMode
nc.stackSpacing = node.itemSpacing
nc.stackVerticalPadding = node.paddingTop
nc.stackHorizontalPadding = node.paddingLeft
nc.stackPaddingBottom = node.paddingBottom
nc.stackPaddingRight = node.paddingRight
nc.stackPrimarySizing = node.primaryAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
nc.stackCounterSizing = node.counterAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
nc.stackPrimaryAlignItems = node.primaryAxisAlign
nc.stackCounterAlignItems = node.counterAxisAlign
if (node.layoutWrap === 'WRAP') nc.stackWrap = 'WRAP'
if (node.counterAxisSpacing > 0) nc.stackCounterSpacing = node.counterAxisSpacing
}
if (node.layoutPositioning === 'ABSOLUTE') nc.stackPositioning = 'ABSOLUTE'
if (node.layoutGrow > 0) nc.stackChildPrimaryGrow = node.layoutGrow
if (node.vectorNetwork && node.type === 'VECTOR') {
const blobIdx = blobs.length
blobs.push(encodeVectorNetworkBlob(node.vectorNetwork))
nc.vectorData = {
vectorNetworkBlob: blobIdx,
normalizedSize: { x: node.width, y: node.height }
}
}
if (node.fillGeometry.length > 0) {
nc.fillGeometry = node.fillGeometry.map((g) => {
const blobIdx = blobs.length
blobs.push(g.commandsBlob)
return { windingRule: g.windingRule, commandsBlob: blobIdx }
})
}
if (node.strokeGeometry.length > 0) {
nc.strokeGeometry = node.strokeGeometry.map((g) => {
const blobIdx = blobs.length
blobs.push(g.commandsBlob)
return { windingRule: g.windingRule, commandsBlob: blobIdx }
})
}
if (Object.keys(node.boundVariables).length > 0) {
const entries: VariableConsumptionEntry[] = []
for (const [field, varId] of Object.entries(node.boundVariables)) {
const kiwiField = VARIABLE_BINDING_FIELDS[field]
if (!kiwiField) continue
const variable = graph.variables.get(varId)
if (!variable) continue
const varGuid = stringToGuid(varId)
const typeMap: Record<string, string> = { COLOR: 'COLOR', BOOLEAN: 'BOOLEAN', STRING: 'STRING' }
const resolvedType = typeMap[variable.type] ?? 'FLOAT'
entries.push({
variableData: {
value: { alias: { guid: varGuid } },
dataType: 'ALIAS',
resolvedDataType: resolvedType
},
variableField: kiwiField
})
}
if (entries.length > 0) nc.variableConsumptionMap = { entries }
}
serializeLayoutProps(node, nc)
serializeGeometry(node, nc, blobs)
serializeVariableBindings(node, nc, graph)
const result: KiwiNodeChange[] = [nc]
const children = graph.getChildren(node.id)

View file

@ -10,26 +10,15 @@ import {
import { populateAndApplyOverrides } from './instance-overrides'
import type { InstanceNodeChange } from './instance-overrides'
import type { NodeChange } from './codec'
import type { NodeChange, VariableDataValuesEntry } from './codec'
export function importNodeChanges(
nodeChanges: NodeChange[],
blobs: Uint8Array[] = [],
images?: Map<string, Uint8Array>
): SceneGraph {
const graph = new SceneGraph()
if (images) {
for (const [hash, data] of images) {
graph.images.set(hash, data)
}
}
// Remove the default page created by constructor — we'll create pages from the file
for (const page of graph.getPages(true)) {
graph.deleteNode(page.id)
}
interface ChangeMaps {
changeMap: Map<string, NodeChange>
parentMap: Map<string, string>
childrenMap: Map<string, string[]>
}
function buildChangeMaps(nodeChanges: NodeChange[]): ChangeMaps {
const changeMap = new Map<string, NodeChange>()
const parentMap = new Map<string, string>()
const childrenMap = new Map<string, string[]>()
@ -56,12 +45,211 @@ export function importNodeChanges(
if (parentNc) sortChildren(children, parentNc, changeMap)
}
function getChildren(ncId: string): string[] {
return childrenMap.get(ncId) ?? []
return { changeMap, parentMap, childrenMap }
}
function resolveVariableType(resolvedType: string | undefined): VariableType {
if (resolvedType === 'COLOR') return 'COLOR'
if (resolvedType === 'BOOLEAN') return 'BOOLEAN'
if (resolvedType === 'STRING') return 'STRING'
return 'FLOAT'
}
function resolveVariableValue(entry: VariableDataValuesEntry): VariableValue | undefined {
const vd = entry.variableData
if (!vd.value) return undefined
const dt = vd.dataType ?? vd.resolvedDataType
if (dt === 'COLOR' && vd.value.colorValue) {
const c = vd.value.colorValue
return { r: c.r, g: c.g, b: c.b, a: c.a }
}
if (dt === 'BOOLEAN') return vd.value.boolValue ?? false
if (dt === 'STRING') return vd.value.textValue ?? ''
if (dt === 'ALIAS' && vd.value.alias?.guid) {
return { aliasId: guidToString(vd.value.alias.guid) }
}
return vd.value.floatValue ?? 0
}
function resolveDefaultValue(type: VariableType): VariableValue {
if (type === 'BOOLEAN') return false
if (type === 'STRING') return ''
if (type === 'COLOR') return { r: 0, g: 0, b: 0, a: 1 }
return 0
}
function importCollections(
changeMap: Map<string, NodeChange>,
graph: SceneGraph
): void {
for (const [id, nc] of changeMap) {
if (nc.type !== 'VARIABLE_SET') continue
const modes = (nc.variableSetModes ?? []).map((m) => {
const modeId = guidToString(m.id)
return { modeId, name: m.name }
})
if (modes.length === 0) modes.push({ modeId: 'default', name: 'Default' })
graph.addCollection({
id,
name: nc.name ?? 'Variables',
modes,
defaultModeId: modes[0].modeId,
variableIds: []
})
}
}
function importVariableEntries(
changeMap: Map<string, NodeChange>,
parentMap: Map<string, string>,
graph: SceneGraph
): void {
for (const [id, nc] of changeMap) {
if (nc.type !== 'VARIABLE') continue
const collectionId = nc.variableSetID?.guid ? guidToString(nc.variableSetID.guid) : (parentMap.get(id) ?? '')
if (!graph.variableCollections.has(collectionId)) {
const parentNc = changeMap.get(collectionId)
graph.addCollection({
id: collectionId,
name: parentNc?.name ?? 'Variables',
modes: [{ modeId: 'default', name: 'Default' }],
defaultModeId: 'default',
variableIds: []
})
}
const type = resolveVariableType(nc.variableResolvedType)
const valuesByMode: Record<string, VariableValue> = {}
if (nc.variableDataValues?.entries) {
for (const entry of nc.variableDataValues.entries) {
const val = resolveVariableValue(entry)
if (val !== undefined) {
valuesByMode[guidToString(entry.modeID)] = val
}
}
}
if (Object.keys(valuesByMode).length === 0) {
const col = graph.variableCollections.get(collectionId)
const defaultMode = col?.defaultModeId ?? 'default'
valuesByMode[defaultMode] = resolveDefaultValue(type)
}
graph.addVariable({
id,
name: nc.name ?? 'Variable',
type,
collectionId,
valuesByMode,
description: '',
hiddenFromPublishing: false
})
}
}
function importPages(
graph: SceneGraph,
changeMap: Map<string, NodeChange>,
parentMap: Map<string, string>,
childrenMap: Map<string, string[]>,
created: Set<string>,
createSceneNode: (ncId: string, graphParentId: string) => void
): void {
const getChildren = (ncId: string): string[] => childrenMap.get(ncId) ?? []
let docId: string | null = null
for (const [id, nc] of changeMap) {
if (nc.type === 'DOCUMENT' || id === '0:0') {
docId = id
break
}
}
if (docId) {
for (const canvasId of getChildren(docId)) {
const canvasNc = changeMap.get(canvasId)
if (!canvasNc) continue
if (canvasNc.type === 'CANVAS') {
const page = graph.addPage(canvasNc.name ?? 'Page')
if (canvasNc.internalOnly) page.internalOnly = true
created.add(canvasId)
for (const childId of getChildren(canvasId)) {
createSceneNode(childId, page.id)
}
} else {
createSceneNode(canvasId, graph.getPages()[0]?.id ?? graph.rootId)
}
}
} else {
const roots: string[] = []
for (const [id] of changeMap) {
const pid = parentMap.get(id)
if (!pid || !changeMap.has(pid)) roots.push(id)
}
const page = graph.getPages()[0] ?? graph.addPage('Page 1')
for (const rootId of roots) {
createSceneNode(rootId, page.id)
}
}
}
function importVariableBindings(
changeMap: Map<string, NodeChange>,
guidToNodeId: Map<string, string>,
graph: SceneGraph
): void {
for (const [ncId, nc] of changeMap) {
if (!nc.variableConsumptionMap?.entries?.length) continue
const nodeId = guidToNodeId.get(ncId)
if (!nodeId) continue
for (const entry of nc.variableConsumptionMap.entries) {
const varGuid = entry.variableData?.value?.alias?.guid
if (!varGuid) continue
const field = VARIABLE_BINDING_FIELDS_INVERSE[entry.variableField ?? '']
if (field) graph.bindVariable(nodeId, field, guidToString(varGuid))
}
}
}
function remapComponentIds(
graph: SceneGraph,
guidToNodeId: Map<string, string>
): void {
for (const node of graph.getAllNodes()) {
if (node.type !== 'INSTANCE' || !node.componentId) continue
const remapped = guidToNodeId.get(node.componentId)
if (remapped) node.componentId = remapped
}
}
export function importNodeChanges(
nodeChanges: NodeChange[],
blobs: Uint8Array[] = [],
images?: Map<string, Uint8Array>
): SceneGraph {
const graph = new SceneGraph()
if (images) {
for (const [hash, data] of images) {
graph.images.set(hash, data)
}
}
for (const page of graph.getPages(true)) {
graph.deleteNode(page.id)
}
const { changeMap, parentMap, childrenMap } = buildChangeMaps(nodeChanges)
const created = new Set<string>()
const guidToNodeId = new Map<string, string>()
const getChildren = (ncId: string): string[] => childrenMap.get(ncId) ?? []
function createSceneNode(ncId: string, graphParentId: string) {
if (created.has(ncId)) return
@ -81,153 +269,12 @@ export function importNodeChanges(
}
}
function importVariables() {
for (const [id, nc] of changeMap) {
if (nc.type !== 'VARIABLE_SET') continue
importPages(graph, changeMap, parentMap, childrenMap, created, createSceneNode)
const modes = (nc.variableSetModes ?? []).map((m) => {
const modeId = guidToString(m.id)
return { modeId, name: m.name }
})
if (modes.length === 0) modes.push({ modeId: 'default', name: 'Default' })
graph.addCollection({
id,
name: nc.name ?? 'Variables',
modes,
defaultModeId: modes[0].modeId,
variableIds: []
})
}
for (const [id, nc] of changeMap) {
if (nc.type !== 'VARIABLE') continue
const collectionId = nc.variableSetID?.guid ? guidToString(nc.variableSetID.guid) : (parentMap.get(id) ?? '')
if (!graph.variableCollections.has(collectionId)) {
const parentNc = changeMap.get(collectionId)
graph.addCollection({
id: collectionId,
name: parentNc?.name ?? 'Variables',
modes: [{ modeId: 'default', name: 'Default' }],
defaultModeId: 'default',
variableIds: []
})
}
let type: VariableType = 'FLOAT'
const resolvedType = nc.variableResolvedType
if (resolvedType === 'COLOR') type = 'COLOR'
else if (resolvedType === 'BOOLEAN') type = 'BOOLEAN'
else if (resolvedType === 'STRING') type = 'STRING'
const valuesByMode: Record<string, VariableValue> = {}
if (nc.variableDataValues?.entries) {
for (const entry of nc.variableDataValues.entries) {
const modeId = guidToString(entry.modeID)
const vd = entry.variableData
if (!vd.value) continue
const dt = vd.dataType ?? vd.resolvedDataType
if (dt === 'COLOR' && vd.value.colorValue) {
const c = vd.value.colorValue
valuesByMode[modeId] = { r: c.r, g: c.g, b: c.b, a: c.a }
} else if (dt === 'BOOLEAN') {
valuesByMode[modeId] = vd.value.boolValue ?? false
} else if (dt === 'STRING') {
valuesByMode[modeId] = vd.value.textValue ?? ''
} else if (dt === 'ALIAS' && vd.value.alias?.guid) {
valuesByMode[modeId] = { aliasId: guidToString(vd.value.alias.guid) }
} else {
valuesByMode[modeId] = vd.value.floatValue ?? 0
}
}
}
if (Object.keys(valuesByMode).length === 0) {
const col = graph.variableCollections.get(collectionId)
const defaultMode = col?.defaultModeId ?? 'default'
const defaultValue = type === 'BOOLEAN' ? false : (type === 'STRING' ? '' : null)
valuesByMode[defaultMode] = defaultValue ?? (type === 'COLOR' ? { r: 0, g: 0, b: 0, a: 1 } : 0)
}
graph.addVariable({
id,
name: nc.name ?? 'Variable',
type,
collectionId,
valuesByMode,
description: '',
hiddenFromPublishing: false
})
}
}
function importVariableBindings() {
for (const [ncId, nc] of changeMap) {
if (!nc.variableConsumptionMap?.entries?.length) continue
const nodeId = guidToNodeId.get(ncId)
if (!nodeId) continue
for (const entry of nc.variableConsumptionMap.entries) {
const varGuid = entry.variableData?.value?.alias?.guid
if (!varGuid) continue
const field = VARIABLE_BINDING_FIELDS_INVERSE[entry.variableField ?? '']
if (field) graph.bindVariable(nodeId, field, guidToString(varGuid))
}
}
}
// Find the document node (type=DOCUMENT or guid 0:0)
let docId: string | null = null
for (const [id, nc] of changeMap) {
if (nc.type === 'DOCUMENT' || id === '0:0') {
docId = id
break
}
}
if (docId) {
// Import pages (CANVAS nodes) and their children
for (const canvasId of getChildren(docId)) {
const canvasNc = changeMap.get(canvasId)
if (!canvasNc) continue
if (canvasNc.type === 'CANVAS') {
const page = graph.addPage(canvasNc.name ?? 'Page')
if (canvasNc.internalOnly) page.internalOnly = true
created.add(canvasId)
for (const childId of getChildren(canvasId)) {
createSceneNode(childId, page.id)
}
} else {
createSceneNode(canvasId, graph.getPages()[0]?.id ?? graph.rootId)
}
}
} else {
// No document structure — treat all roots as children of the first page
const roots: string[] = []
for (const [id] of changeMap) {
const pid = parentMap.get(id)
if (!pid || !changeMap.has(pid)) roots.push(id)
}
const page = graph.getPages()[0] ?? graph.addPage('Page 1')
for (const rootId of roots) {
createSceneNode(rootId, page.id)
}
}
importVariables()
importVariableBindings()
// Remap componentId from original Figma GUIDs to imported node IDs
for (const node of graph.getAllNodes()) {
if (node.type !== 'INSTANCE' || !node.componentId) continue
const remapped = guidToNodeId.get(node.componentId)
if (remapped) node.componentId = remapped
}
importCollections(changeMap, graph)
importVariableEntries(changeMap, parentMap, graph)
importVariableBindings(changeMap, guidToNodeId, graph)
remapComponentIds(graph, guidToNodeId)
populateAndApplyOverrides(
graph,
@ -236,7 +283,6 @@ export function importNodeChanges(
blobs
)
// Ensure at least one page exists
if (graph.getPages(true).length === 0) {
graph.addPage('Page 1')
}

View file

@ -256,61 +256,39 @@ export function populateAndApplyOverrides(
componentIdRoot.clear()
}
function applyComponentProperties() {
const propRefsMap = new Map<string, ComponentPropRef[]>()
for (const [figmaId, nc] of changeMap) {
if (nc.componentPropRefs?.length) {
propRefsMap.set(figmaId, nc.componentPropRefs)
}
function assignmentsToValueMap(assignments: ComponentPropAssignment[]): Map<string, ComponentPropAssignment['value']> {
const valueByDef = new Map<string, ComponentPropAssignment['value']>()
for (const a of assignments) {
valueByDef.set(guidToString(a.defID), a.value)
}
if (propRefsMap.size === 0) return
return valueByDef
}
// Collect all assignment sources: figmaGuid → assignments[]
// Sources: top-level on instance nodes, and inside symbolOverrides
const assignmentSources = new Map<string, ComponentPropAssignment[]>()
for (const [figmaId, nc] of changeMap) {
if (nc.componentPropAssignments?.length) {
assignmentSources.set(figmaId, nc.componentPropAssignments)
}
}
// Apply assignments from the instance's own kiwi data first. The graph
// node for a kiwi INSTANCE has componentPropAssignments that control
// which children are visible, swapped, etc.
function applyInstanceDirectAssignments(
assignmentSources: Map<string, ComponentPropAssignment[]>,
propRefsMap: Map<string, ComponentPropRef[]>
) {
for (const node of graph.getAllNodes()) {
if (node.type !== 'INSTANCE') continue
const ownFigmaId = nodeIdToGuid.get(node.id)
if (ownFigmaId) {
const ownAssignments = assignmentSources.get(ownFigmaId)
if (ownAssignments) {
const valueByDef = new Map<string, ComponentPropAssignment['value']>()
for (const a of ownAssignments) {
valueByDef.set(guidToString(a.defID), a.value)
}
applyPropAssignments(node.id, valueByDef, propRefsMap)
applyPropAssignments(node.id, assignmentsToValueMap(ownAssignments), propRefsMap)
}
}
// Also apply assignments from cloned instance sources. After
// population, cloned instances have componentId pointing to
// the original kiwi node. If that node had assignments, apply
// them to the clone (defaults for nested instances).
if (!node.componentId) continue
const sourceFigmaId = nodeIdToGuid.get(node.componentId)
if (!sourceFigmaId) continue
const assignments = assignmentSources.get(sourceFigmaId)
if (!assignments) continue
const valueByDef = new Map<string, ComponentPropAssignment['value']>()
for (const a of assignments) {
valueByDef.set(guidToString(a.defID), a.value)
}
applyPropAssignments(node.id, valueByDef, propRefsMap)
applyPropAssignments(node.id, assignmentsToValueMap(assignments), propRefsMap)
}
}
// Apply assignments from symbolOverrides, scoped to the nested
// instance their guidPath resolves to. These override the defaults
// set above.
function applySymbolOverrideAssignments(propRefsMap: Map<string, ComponentPropRef[]>) {
for (const [figmaId, nc] of changeMap) {
const instanceNodeId = guidToNodeId.get(figmaId)
if (!instanceNodeId) continue
@ -327,15 +305,31 @@ export function populateAndApplyOverrides(
const targetId = resolveOverrideTarget(instanceNodeId, guids)
if (!targetId) continue
const valueByDef = new Map<string, ComponentPropAssignment['value']>()
for (const a of ov.componentPropAssignments) {
valueByDef.set(guidToString(a.defID), a.value)
}
applyPropAssignments(targetId, valueByDef, propRefsMap)
applyPropAssignments(targetId, assignmentsToValueMap(ov.componentPropAssignments), propRefsMap)
}
}
}
function applyComponentProperties() {
const propRefsMap = new Map<string, ComponentPropRef[]>()
for (const [figmaId, nc] of changeMap) {
if (nc.componentPropRefs?.length) {
propRefsMap.set(figmaId, nc.componentPropRefs)
}
}
if (propRefsMap.size === 0) return
const assignmentSources = new Map<string, ComponentPropAssignment[]>()
for (const [figmaId, nc] of changeMap) {
if (nc.componentPropAssignments?.length) {
assignmentSources.set(figmaId, nc.componentPropAssignments)
}
}
applyInstanceDirectAssignments(assignmentSources, propRefsMap)
applySymbolOverrideAssignments(propRefsMap)
}
function applyPropAssignments(
parentId: string,
valueByDef: Map<string, ComponentPropAssignment['value']>,
@ -401,7 +395,27 @@ export function populateAndApplyOverrides(
})
}
function applyDerivedSymbolData() {
function resolveDsdGeometry(
d: DerivedSymbolOverride,
target: SceneNode
): Pick<Partial<SceneNode>, 'fillGeometry' | 'strokeGeometry'> {
const result: Pick<Partial<SceneNode>, 'fillGeometry' | 'strokeGeometry'> = {}
const fg = resolveGeometryPaths(d.fillGeometry, blobs)
const sg = resolveGeometryPaths(d.strokeGeometry, blobs)
if (fg.length > 0) {
result.fillGeometry = fg
} else if (d.size && target.fillGeometry.length > 0 && target.width > 0 && target.height > 0) {
result.fillGeometry = scaleGeometryBlobs(target.fillGeometry, d.size.x / target.width, d.size.y / target.height)
}
if (sg.length > 0) {
result.strokeGeometry = sg
} else if (d.size && target.strokeGeometry.length > 0 && target.width > 0 && target.height > 0) {
result.strokeGeometry = scaleGeometryBlobs(target.strokeGeometry, d.size.x / target.width, d.size.y / target.height)
}
return result
}
function resolveDsdUpdates(): { dsdModified: Set<string>; dsdSizeSet: Set<string> } {
const dsdModified = new Set<string>()
const dsdSizeSet = new Set<string>()
@ -432,18 +446,7 @@ export function populateAndApplyOverrides(
updates.x = d.transform.m02
updates.y = d.transform.m12
}
const fg = resolveGeometryPaths(d.fillGeometry, blobs)
const sg = resolveGeometryPaths(d.strokeGeometry, blobs)
if (fg.length > 0) {
updates.fillGeometry = fg
} else if (d.size && target.fillGeometry.length > 0 && target.width > 0 && target.height > 0) {
updates.fillGeometry = scaleGeometryBlobs(target.fillGeometry, d.size.x / target.width, d.size.y / target.height)
}
if (sg.length > 0) {
updates.strokeGeometry = sg
} else if (d.size && target.strokeGeometry.length > 0 && target.width > 0 && target.height > 0) {
updates.strokeGeometry = scaleGeometryBlobs(target.strokeGeometry, d.size.x / target.width, d.size.y / target.height)
}
Object.assign(updates, resolveDsdGeometry(d, target))
if (Object.keys(updates).length > 0) {
graph.updateNode(targetId, updates)
@ -453,60 +456,46 @@ export function populateAndApplyOverrides(
}
}
// Propagate DSD changes through clone chains. Each clone should match
// its source (componentId) for size/position/geometry. Iterate until
// convergence so deeper clone levels receive updates even when
// intermediate clones were also directly DSD-targeted (e.g., a DSD
// entry set only geometry on an intermediate clone — its size must
// still be inherited from the source).
//
// Nodes whose size was explicitly set by DSD (in dsdSizeSet) keep
// their own values; nodes only touched for position/geometry inherit
// size from their source.
if (dsdModified.size > 0) {
const clonesOf = new Map<string, string[]>()
for (const node of graph.getAllNodes()) {
if (!node.componentId) continue
let arr = clonesOf.get(node.componentId)
if (!arr) {
arr = []
clonesOf.set(node.componentId, arr)
}
arr.push(node.id)
}
return { dsdModified, dsdSizeSet }
}
// BFS from DSD-modified nodes. Unlike the old version, intermediate
// clones that are also in dsdModified are NOT skipped — they act as
// chain links. Nodes in dsdSizeSet keep their explicit size but still
// propagate to their clones.
const queue = [...dsdModified]
const visited = new Set<string>()
for (let sourceId = queue.shift(); sourceId !== undefined; sourceId = queue.shift()) {
const source = graph.getNode(sourceId)
if (!source) continue
const clones = clonesOf.get(sourceId)
if (!clones) continue
for (const cloneId of clones) {
if (visited.has(cloneId)) continue
visited.add(cloneId)
const clone = graph.getNode(cloneId)
if (!clone) continue
if (!dsdSizeSet.has(cloneId)) {
const cu: Partial<SceneNode> = {}
if (source.width !== clone.width) cu.width = source.width
if (source.height !== clone.height) cu.height = source.height
if (source.x !== clone.x) cu.x = source.x
if (source.y !== clone.y) cu.y = source.y
if (source.fillGeometry !== clone.fillGeometry) cu.fillGeometry = copyGeometryPaths(source.fillGeometry)
if (source.strokeGeometry !== clone.strokeGeometry) cu.strokeGeometry = copyGeometryPaths(source.strokeGeometry)
if (Object.keys(cu).length > 0) graph.updateNode(cloneId, cu)
}
queue.push(cloneId)
function propagateDsdChanges(dsdModified: Set<string>, dsdSizeSet: Set<string>) {
if (dsdModified.size === 0) return
const clonesOf = buildClonesMap()
const queue = [...dsdModified]
const visited = new Set<string>()
for (let sourceId = queue.shift(); sourceId !== undefined; sourceId = queue.shift()) {
const source = graph.getNode(sourceId)
if (!source) continue
const clones = clonesOf.get(sourceId)
if (!clones) continue
for (const cloneId of clones) {
if (visited.has(cloneId)) continue
visited.add(cloneId)
const clone = graph.getNode(cloneId)
if (!clone) continue
if (!dsdSizeSet.has(cloneId)) {
const cu: Partial<SceneNode> = {}
if (source.width !== clone.width) cu.width = source.width
if (source.height !== clone.height) cu.height = source.height
if (source.x !== clone.x) cu.x = source.x
if (source.y !== clone.y) cu.y = source.y
if (source.fillGeometry !== clone.fillGeometry) cu.fillGeometry = copyGeometryPaths(source.fillGeometry)
if (source.strokeGeometry !== clone.strokeGeometry) cu.strokeGeometry = copyGeometryPaths(source.strokeGeometry)
if (Object.keys(cu).length > 0) graph.updateNode(cloneId, cu)
}
queue.push(cloneId)
}
}
}
function applyDerivedSymbolData() {
const { dsdModified, dsdSizeSet } = resolveDsdUpdates()
propagateDsdChanges(dsdModified, dsdSizeSet)
}
function applySymbolOverrides(): Set<string> {
const overriddenNodes = new Set<string>()
componentIdRoot.clear()
@ -575,9 +564,7 @@ export function populateAndApplyOverrides(
}
}
function propagateOverridesTransitively(seeds: Set<string>) {
if (seeds.size === 0) return
function buildClonesMap(): Map<string, string[]> {
const clonesOf = new Map<string, string[]>()
for (const node of graph.getAllNodes()) {
if (!node.componentId) continue
@ -588,11 +575,10 @@ export function populateAndApplyOverrides(
}
arr.push(node.id)
}
return clonesOf
}
// Also seed parent INSTANCE nodes of overridden children so their
// clones are visited by the BFS — deep overrides (e.g., stroke color
// on a Vector nested inside a check inside an _icon-xs) need the
// instance-level clone to be visited for syncChildrenDeep to propagate.
function expandSeedsToParents(seeds: Set<string>): Set<string> {
const expandedSeeds = new Set(seeds)
for (const seedId of seeds) {
let cur = graph.getNode(seedId)
@ -605,7 +591,10 @@ export function populateAndApplyOverrides(
cur = parent
}
}
return expandedSeeds
}
function buildNeedsSyncSet(expandedSeeds: Set<string>, clonesOf: Map<string, string[]>): Set<string> {
const needsSync = new Set<string>()
const queue = [...expandedSeeds]
for (let id = queue.pop(); id !== undefined; id = queue.pop()) {
@ -617,6 +606,28 @@ export function populateAndApplyOverrides(
queue.push(cloneId)
}
}
return needsSync
}
function syncCloneFromSource(sourceId: string, source: SceneNode, node: SceneNode, seeds: Set<string>) {
syncNodeProps(source, node)
if (source.childIds.length !== node.childIds.length) {
const childIds = [...node.childIds]
for (const childId of childIds) graph.deleteNode(childId)
if (source.childIds.length > 0) {
graph.populateInstanceChildren(node.id, sourceId)
}
} else if (source.childIds.length > 0 && node.childIds.length > 0) {
syncChildrenDeep(sourceId, node.id, seeds)
}
}
function propagateOverridesTransitively(seeds: Set<string>) {
if (seeds.size === 0) return
const clonesOf = buildClonesMap()
const expandedSeeds = expandSeedsToParents(seeds)
const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf)
const visited = new Set<string>()
const syncQueue = [...expandedSeeds]
@ -632,25 +643,12 @@ export function populateAndApplyOverrides(
const node = graph.getNode(cloneId)
if (!node) continue
// Don't overwrite nodes that were directly targeted by symbolOverrides
if (seeds.has(cloneId)) {
syncQueue.push(cloneId)
continue
}
syncNodeProps(source, node)
// For structural changes (instance swaps change child count/type),
// re-clone from the SOURCE (not the component) to preserve the
// componentId chain for DSD propagation.
if (source.childIds.length !== node.childIds.length) {
for (const childId of [...node.childIds]) graph.deleteNode(childId)
if (source.childIds.length > 0) {
graph.populateInstanceChildren(node.id, sourceId)
}
} else if (source.childIds.length > 0 && node.childIds.length > 0) {
syncChildrenDeep(sourceId, cloneId, seeds)
}
syncCloneFromSource(sourceId, source, node, seeds)
syncQueue.push(cloneId)
}
}

View file

@ -163,53 +163,33 @@ function convertEffects(effects?: KiwiEffect[]): Effect[] {
}))
}
const NODE_TYPE_MAP: Record<string, NodeType | 'DOCUMENT' | 'VARIABLE'> = {
DOCUMENT: 'DOCUMENT',
VARIABLE: 'VARIABLE',
CANVAS: 'CANVAS',
FRAME: 'FRAME',
RECTANGLE: 'RECTANGLE',
ROUNDED_RECTANGLE: 'ROUNDED_RECTANGLE',
ELLIPSE: 'ELLIPSE',
TEXT: 'TEXT',
LINE: 'LINE',
STAR: 'STAR',
REGULAR_POLYGON: 'POLYGON',
VECTOR: 'VECTOR',
BOOLEAN_OPERATION: 'VECTOR',
GROUP: 'GROUP',
SECTION: 'SECTION',
COMPONENT: 'COMPONENT',
COMPONENT_SET: 'COMPONENT_SET',
INSTANCE: 'INSTANCE',
SYMBOL: 'COMPONENT',
CONNECTOR: 'CONNECTOR',
SHAPE_WITH_TEXT: 'SHAPE_WITH_TEXT'
}
function mapNodeType(type?: string): NodeType | 'DOCUMENT' | 'VARIABLE' {
switch (type) {
case 'DOCUMENT':
return 'DOCUMENT'
case 'VARIABLE':
return 'VARIABLE'
case 'CANVAS':
return 'CANVAS'
case 'FRAME':
return 'FRAME'
case 'RECTANGLE':
return 'RECTANGLE'
case 'ROUNDED_RECTANGLE':
return 'ROUNDED_RECTANGLE'
case 'ELLIPSE':
return 'ELLIPSE'
case 'TEXT':
return 'TEXT'
case 'LINE':
return 'LINE'
case 'STAR':
return 'STAR'
case 'REGULAR_POLYGON':
return 'POLYGON'
case 'VECTOR':
return 'VECTOR'
case 'BOOLEAN_OPERATION':
return 'VECTOR'
case 'GROUP':
return 'GROUP'
case 'SECTION':
return 'SECTION'
case 'COMPONENT':
return 'COMPONENT'
case 'COMPONENT_SET':
return 'COMPONENT_SET'
case 'INSTANCE':
return 'INSTANCE'
case 'SYMBOL':
return 'COMPONENT'
case 'CONNECTOR':
return 'CONNECTOR'
case 'SHAPE_WITH_TEXT':
return 'SHAPE_WITH_TEXT'
default:
return 'RECTANGLE'
}
if (type) return NODE_TYPE_MAP[type] ?? 'RECTANGLE'
return 'RECTANGLE'
}
function mapStackMode(mode?: string): LayoutMode {
@ -320,39 +300,47 @@ function mapArcData(data?: Partial<ArcData>): ArcData | null {
}
}
function importStyleRuns(nc: NodeChange): StyleRun[] {
const td = nc.textData
if (!td?.characterStyleIDs || !td.styleOverrideTable) return []
const ids = td.characterStyleIDs
const table = td.styleOverrideTable
if (ids.length === 0 || table.length === 0) return []
function convertStyleOverride(
override: NodeChange,
fallbackFontSize: number | undefined
): CharacterStyleOverride {
const style: CharacterStyleOverride = {}
if (override.fontName) {
style.fontFamily = override.fontName.family
style.fontWeight = styleToWeight(override.fontName.style)
style.italic = override.fontName.style.toLowerCase().includes('italic')
}
if (override.fontSize !== undefined) style.fontSize = override.fontSize
if (override.letterSpacing) {
style.letterSpacing = convertLetterSpacing(override.letterSpacing, override.fontSize ?? fallbackFontSize)
}
if (override.lineHeight) {
const lh = convertLineHeight(override.lineHeight, override.fontSize ?? fallbackFontSize)
if (lh != null) style.lineHeight = lh
}
const deco = override.textDecoration
if (deco) style.textDecoration = mapTextDecoration(deco)
return style
}
function buildStyleMap(
table: NodeChange[],
fallbackFontSize: number | undefined
): Map<number, CharacterStyleOverride> {
const styleMap = new Map<number, CharacterStyleOverride>()
for (const override of table) {
const id = override.styleID as number | undefined
if (id === undefined) continue
const style: CharacterStyleOverride = {}
if (override.fontName) {
style.fontFamily = override.fontName.family
style.fontWeight = styleToWeight(override.fontName.style)
style.italic = override.fontName.style.toLowerCase().includes('italic')
}
if (override.fontSize !== undefined) style.fontSize = override.fontSize
if (override.letterSpacing) {
style.letterSpacing = convertLetterSpacing(override.letterSpacing, override.fontSize ?? nc.fontSize)
}
if (override.lineHeight) {
const lh = convertLineHeight(override.lineHeight, override.fontSize ?? nc.fontSize)
if (lh != null) style.lineHeight = lh
}
const deco = override.textDecoration
if (deco) style.textDecoration = mapTextDecoration(deco)
const style = convertStyleOverride(override, fallbackFontSize)
if (Object.keys(style).length > 0) styleMap.set(id, style)
}
return styleMap
}
if (styleMap.size === 0) return []
function collectStyleRuns(
ids: number[],
styleMap: Map<number, CharacterStyleOverride>
): StyleRun[] {
const runs: StyleRun[] = []
let currentId = ids[0]
let start = 0
@ -369,10 +357,22 @@ function importStyleRuns(nc: NodeChange): StyleRun[] {
}
}
}
return runs
}
function importStyleRuns(nc: NodeChange): StyleRun[] {
const td = nc.textData
if (!td?.characterStyleIDs || !td.styleOverrideTable) return []
const ids = td.characterStyleIDs
if (ids.length === 0 || td.styleOverrideTable.length === 0) return []
const styleMap = buildStyleMap(td.styleOverrideTable, nc.fontSize)
if (styleMap.size === 0) return []
return collectStyleRuns(ids, styleMap)
}
function resolveVectorNetwork(
nc: NodeChange,
blobs: Uint8Array[]
@ -453,13 +453,7 @@ function extractBoundVariables(nc: NodeChange): Record<string, string> {
return bindings
}
export function nodeChangeToProps(
nc: NodeChange,
blobs: Uint8Array[]
): Partial<SceneNode> & { nodeType: NodeType | 'DOCUMENT' | 'VARIABLE' } {
let nodeType = mapNodeType(nc.type)
if (nodeType === 'FRAME' && isComponentSet(nc)) nodeType = 'COMPONENT_SET'
function convertTransformProps(nc: NodeChange): Pick<SceneNode, 'x' | 'y' | 'width' | 'height' | 'rotation' | 'flipX' | 'flipY'> {
const x = nc.transform?.m02 ?? 0
const y = nc.transform?.m12 ?? 0
const width = nc.size?.x ?? 100
@ -467,7 +461,6 @@ export function nodeChangeToProps(
let rotation = 0
let flipX = false
let flipY = false
if (nc.transform) {
const det = nc.transform.m00 * nc.transform.m11 - nc.transform.m01 * nc.transform.m10
if (det < 0) flipX = true
@ -475,46 +468,29 @@ export function nodeChangeToProps(
rotation = Math.atan2(nc.transform.m10 * sx, nc.transform.m00 * sx) * (180 / Math.PI)
}
const dashPattern = nc.dashPattern ?? []
return { x, y, width, height, rotation, flipX, flipY: false }
}
function convertCornerProps(nc: NodeChange): Pick<SceneNode, 'cornerRadius' | 'topLeftRadius' | 'topRightRadius' | 'bottomRightRadius' | 'bottomLeftRadius' | 'independentCorners' | 'cornerSmoothing'> {
return {
nodeType,
name: nc.name ?? nodeType,
x,
y,
width,
height,
rotation,
flipX,
flipY,
opacity: nc.opacity ?? 1,
visible: nc.visible ?? true,
locked: nc.locked ?? false,
blendMode: (nc.blendMode as Fill['blendMode']) ?? 'PASS_THROUGH',
fills: convertFills(nc.fillPaints),
strokes: convertStrokes(
nc.strokePaints,
nc.strokeWeight,
nc.strokeAlign,
nc.strokeCap,
nc.strokeJoin,
dashPattern
),
effects: convertEffects(nc.effects),
cornerRadius: nc.cornerRadius ?? 0,
topLeftRadius: nc.rectangleTopLeftCornerRadius ?? nc.cornerRadius ?? 0,
topRightRadius: nc.rectangleTopRightCornerRadius ?? nc.cornerRadius ?? 0,
bottomRightRadius: nc.rectangleBottomRightCornerRadius ?? nc.cornerRadius ?? 0,
bottomLeftRadius: nc.rectangleBottomLeftCornerRadius ?? nc.cornerRadius ?? 0,
independentCorners: nc.rectangleCornerRadiiIndependent ?? false,
cornerSmoothing: nc.cornerSmoothing ?? 0,
cornerSmoothing: nc.cornerSmoothing ?? 0
}
}
function convertTextProps(nc: NodeChange): Pick<SceneNode, 'text' | 'fontSize' | 'fontFamily' | 'fontWeight' | 'italic' | 'textAlignHorizontal' | 'textAlignVertical' | 'textAutoResize' | 'textCase' | 'textDecoration' | 'lineHeight' | 'letterSpacing' | 'maxLines' | 'styleRuns' | 'textTruncation'> {
return {
text: nc.textData?.characters ?? '',
fontSize: nc.fontSize ?? 14,
fontFamily: nc.fontName?.family ?? DEFAULT_FONT_FAMILY,
fontWeight: styleToWeight(nc.fontName?.style ?? ''),
italic: nc.fontName?.style.toLowerCase().includes('italic') ?? false,
textAlignHorizontal:
(nc.textAlignHorizontal ?? 'LEFT') as 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED',
textAlignHorizontal: (nc.textAlignHorizontal ?? 'LEFT') as 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED',
textAlignVertical: (nc.textAlignVertical ?? 'TOP') as TextAlignVertical,
textAutoResize: (nc.textAutoResize ?? 'NONE') as TextAutoResize,
textCase: (nc.textCase ?? 'ORIGINAL') as TextCase,
@ -523,14 +499,24 @@ export function nodeChangeToProps(
letterSpacing: convertLetterSpacing(nc.letterSpacing, nc.fontSize),
maxLines: (nc.maxLines ?? null) as number | null,
styleRuns: importStyleRuns(nc),
horizontalConstraint: mapConstraint(nc.horizontalConstraint as string),
verticalConstraint: mapConstraint(nc.verticalConstraint as string),
layoutMode: mapStackMode(nc.stackMode),
itemSpacing: nc.stackSpacing ?? 0,
textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED'
}
}
function convertLayoutPadding(nc: NodeChange): Pick<SceneNode, 'paddingTop' | 'paddingBottom' | 'paddingLeft' | 'paddingRight'> {
return {
paddingTop: nc.stackVerticalPadding ?? nc.stackPadding ?? 0,
paddingBottom: nc.stackPaddingBottom ?? nc.stackVerticalPadding ?? nc.stackPadding ?? 0,
paddingLeft: nc.stackHorizontalPadding ?? nc.stackPadding ?? 0,
paddingRight: nc.stackPaddingRight ?? nc.stackHorizontalPadding ?? nc.stackPadding ?? 0,
paddingRight: nc.stackPaddingRight ?? nc.stackHorizontalPadding ?? nc.stackPadding ?? 0
}
}
function convertLayoutProps(nc: NodeChange): Pick<SceneNode, 'layoutMode' | 'itemSpacing' | 'paddingTop' | 'paddingBottom' | 'paddingLeft' | 'paddingRight' | 'primaryAxisSizing' | 'counterAxisSizing' | 'primaryAxisAlign' | 'counterAxisAlign' | 'layoutWrap' | 'counterAxisSpacing' | 'layoutPositioning' | 'layoutGrow' | 'layoutAlignSelf' | 'counterAxisAlignContent' | 'itemReverseZIndex' | 'strokesIncludedInLayout'> {
return {
layoutMode: mapStackMode(nc.stackMode),
itemSpacing: nc.stackSpacing ?? 0,
...convertLayoutPadding(nc),
primaryAxisSizing: mapStackSizing(nc.stackPrimarySizing),
counterAxisSizing: mapStackSizing(nc.stackCounterSizing),
primaryAxisAlign: mapStackJustify(nc.stackPrimaryAlignItems ?? nc.stackJustify),
@ -540,31 +526,63 @@ export function nodeChangeToProps(
layoutPositioning: nc.stackPositioning === 'ABSOLUTE' ? 'ABSOLUTE' : 'AUTO',
layoutGrow: nc.stackChildPrimaryGrow ?? 0,
layoutAlignSelf: (nc.stackChildAlignSelf as string) === 'STRETCH' ? 'STRETCH' : 'AUTO',
counterAxisAlignContent: (nc.stackCounterAlignContent as string) === 'SPACE_BETWEEN' ? 'SPACE_BETWEEN' : 'AUTO',
itemReverseZIndex: (nc.stackReverseZIndex ?? false) as boolean,
strokesIncludedInLayout: (nc.strokesIncludedInLayout ?? false) as boolean
}
}
function convertVectorAndStrokeProps(nc: NodeChange, blobs: Uint8Array[]): Pick<SceneNode, 'vectorNetwork' | 'fillGeometry' | 'strokeGeometry' | 'arcData' | 'strokeCap' | 'strokeJoin' | 'dashPattern' | 'borderTopWeight' | 'borderRightWeight' | 'borderBottomWeight' | 'borderLeftWeight' | 'independentStrokeWeights' | 'strokeMiterLimit'> {
return {
vectorNetwork: resolveVectorNetwork(nc, blobs),
fillGeometry: resolveGeometryPaths(nc.fillGeometry, blobs),
strokeGeometry: resolveGeometryPaths(nc.strokeGeometry, blobs),
arcData: mapArcData(nc.arcData as Partial<ArcData> | undefined),
strokeCap: (nc.strokeCap ?? 'NONE') as StrokeCap,
strokeJoin: (nc.strokeJoin ?? 'MITER') as StrokeJoin,
dashPattern,
dashPattern: nc.dashPattern ?? [],
borderTopWeight: (nc.borderTopWeight ?? 0) as number,
borderRightWeight: (nc.borderRightWeight ?? 0) as number,
borderBottomWeight: (nc.borderBottomWeight ?? 0) as number,
borderLeftWeight: (nc.borderLeftWeight ?? 0) as number,
independentStrokeWeights: (nc.borderStrokeWeightsIndependent ?? false) as boolean,
strokeMiterLimit: DEFAULT_STROKE_MITER_LIMIT,
strokeMiterLimit: DEFAULT_STROKE_MITER_LIMIT
}
}
export function nodeChangeToProps(
nc: NodeChange,
blobs: Uint8Array[]
): Partial<SceneNode> & { nodeType: NodeType | 'DOCUMENT' | 'VARIABLE' } {
let nodeType = mapNodeType(nc.type)
if (nodeType === 'FRAME' && isComponentSet(nc)) nodeType = 'COMPONENT_SET'
const dashPattern = nc.dashPattern ?? []
return {
nodeType,
name: nc.name ?? nodeType,
...convertTransformProps(nc),
opacity: nc.opacity ?? 1,
visible: nc.visible ?? true,
locked: nc.locked ?? false,
blendMode: (nc.blendMode as Fill['blendMode']) ?? 'PASS_THROUGH',
fills: convertFills(nc.fillPaints),
strokes: convertStrokes(nc.strokePaints, nc.strokeWeight, nc.strokeAlign, nc.strokeCap, nc.strokeJoin, dashPattern),
effects: convertEffects(nc.effects),
...convertCornerProps(nc),
...convertTextProps(nc),
horizontalConstraint: mapConstraint(nc.horizontalConstraint as string),
verticalConstraint: mapConstraint(nc.verticalConstraint as string),
...convertLayoutProps(nc),
...convertVectorAndStrokeProps(nc, blobs),
minWidth: (nc.minWidth ?? null) as number | null,
maxWidth: (nc.maxWidth ?? null) as number | null,
minHeight: (nc.minHeight ?? null) as number | null,
maxHeight: (nc.maxHeight ?? null) as number | null,
isMask: (nc.isMask ?? false) as boolean,
maskType: (nc.maskType ?? 'ALPHA') as 'ALPHA' | 'VECTOR' | 'LUMINANCE',
counterAxisAlignContent:
(nc.stackCounterAlignContent as string) === 'SPACE_BETWEEN' ? 'SPACE_BETWEEN' : 'AUTO',
itemReverseZIndex: (nc.stackReverseZIndex ?? false) as boolean,
strokesIncludedInLayout: (nc.strokesIncludedInLayout ?? false) as boolean,
expanded: true,
textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED',
autoRename: (nc.autoRename ?? true) as boolean,
boundVariables: extractBoundVariables(nc),
clipsContent: nc.frameMaskDisabled === false,
@ -608,9 +626,7 @@ function extractSymbolId(nc: NodeChange): string {
return guidToString(sd.symbolID)
}
export function convertOverrideToProps(ov: Record<string, unknown>): Partial<SceneNode> {
const updates: Partial<SceneNode> = {}
function applyOverridePaints(ov: Record<string, unknown>, updates: Partial<SceneNode>): void {
if (ov.textData != null) {
const td = ov.textData as { characters?: string }
if (td.characters != null) updates.text = td.characters
@ -629,13 +645,14 @@ export function convertOverrideToProps(ov: Record<string, unknown>): Partial<Sce
if (ov.opacity != null) updates.opacity = ov.opacity as number
if (ov.name != null) updates.name = ov.name as string
if (ov.locked != null) updates.locked = ov.locked as boolean
}
function applyOverrideGeometry(ov: Record<string, unknown>, updates: Partial<SceneNode>): void {
if (ov.size != null) {
const sz = ov.size as { x?: number; y?: number }
if (sz.x != null) updates.width = sz.x
if (sz.y != null) updates.height = sz.y
}
if (ov.cornerRadius != null) updates.cornerRadius = ov.cornerRadius as number
if (ov.rectangleTopLeftCornerRadius != null)
updates.topLeftRadius = ov.rectangleTopLeftCornerRadius as number
@ -647,7 +664,12 @@ export function convertOverrideToProps(ov: Record<string, unknown>): Partial<Sce
updates.bottomLeftRadius = ov.rectangleBottomLeftCornerRadius as number
if (ov.rectangleCornerRadiiIndependent != null)
updates.independentCorners = ov.rectangleCornerRadiiIndependent as boolean
if (ov.arcData != null)
updates.arcData = mapArcData(ov.arcData as Partial<ArcData> | undefined)
if (ov.frameMaskDisabled != null) updates.clipsContent = ov.frameMaskDisabled === false
}
function applyOverrideLayout(ov: Record<string, unknown>, updates: Partial<SceneNode>): void {
if (ov.stackSpacing != null) updates.itemSpacing = ov.stackSpacing as number
if (ov.stackPrimarySizing != null)
updates.primaryAxisSizing = mapStackSizing(ov.stackPrimarySizing as string)
@ -674,7 +696,9 @@ export function convertOverrideToProps(ov: Record<string, unknown>): Partial<Sce
}
if (ov.stackPaddingBottom != null) updates.paddingBottom = ov.stackPaddingBottom as number
if (ov.stackPaddingRight != null) updates.paddingRight = ov.stackPaddingRight as number
}
function applyOverrideStrokes(ov: Record<string, unknown>, updates: Partial<SceneNode>): void {
if (ov.strokeWeight != null && !ov.strokePaints) {
updates.strokes = updates.strokes ?? []
}
@ -689,7 +713,9 @@ export function convertOverrideToProps(ov: Record<string, unknown>): Partial<Sce
if (ov.borderLeftWeight != null) updates.borderLeftWeight = ov.borderLeftWeight as number
if (ov.borderStrokeWeightsIndependent != null)
updates.independentStrokeWeights = ov.borderStrokeWeightsIndependent as boolean
}
function applyOverrideText(ov: Record<string, unknown>, updates: Partial<SceneNode>): void {
if (ov.fontName != null) {
const fn = ov.fontName as { family?: string; style?: string }
if (fn.family) updates.fontFamily = fn.family
@ -718,10 +744,14 @@ export function convertOverrideToProps(ov: Record<string, unknown>): Partial<Sce
(ov.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED'
if (ov.textDecoration != null)
updates.textDecoration = mapTextDecoration(ov.textDecoration as string)
}
if (ov.arcData != null)
updates.arcData = mapArcData(ov.arcData as Partial<ArcData> | undefined)
if (ov.frameMaskDisabled != null) updates.clipsContent = ov.frameMaskDisabled === false
export function convertOverrideToProps(ov: Record<string, unknown>): Partial<SceneNode> {
const updates: Partial<SceneNode> = {}
applyOverridePaints(ov, updates)
applyOverrideGeometry(ov, updates)
applyOverrideLayout(ov, updates)
applyOverrideStrokes(ov, updates)
applyOverrideText(ov, updates)
return updates
}

View file

@ -182,96 +182,97 @@ function formatTracks(tracks: GridTrack[]): string {
return tracks.map(formatTrack).join(' ')
}
// --- OpenPencil format ---
// --- OpenPencil format helpers ---
function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
const props: [string, unknown][] = []
const { isAutoLayout, isGrid, isFlex, parentIsAutoLayout, parentIsGrid } = getNodeContext(
node,
graph
)
function collectGridSizingProps(node: SceneNode, props: [string, unknown][]): void {
props.push(['grid', true])
if (node.gridTemplateColumns.length > 0)
props.push(['columns', formatTracks(node.gridTemplateColumns)])
if (node.gridTemplateRows.length > 0)
props.push(['rows', formatTracks(node.gridTemplateRows)])
if (node.width > 0) props.push(['w', node.width])
if (node.height > 0) props.push(['h', node.height])
if (node.gridColumnGap > 0) props.push(['columnGap', node.gridColumnGap])
if (node.gridRowGap > 0) props.push(['rowGap', node.gridRowGap])
}
if (node.name && node.name !== node.type) {
props.push(['name', node.name])
}
function collectFlexSizingProps(node: SceneNode, props: [string, unknown][]): void {
props.push(['flex', node.layoutMode === 'HORIZONTAL' ? 'row' : 'col'])
const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height'
const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width'
if (isGrid) {
props.push(['grid', true])
if (node.gridTemplateColumns.length > 0)
props.push(['columns', formatTracks(node.gridTemplateColumns)])
if (node.gridTemplateRows.length > 0)
props.push(['rows', formatTracks(node.gridTemplateRows)])
if (node.width > 0) props.push(['w', node.width])
if (node.height > 0) props.push(['h', node.height])
if (node.gridColumnGap > 0) props.push(['columnGap', node.gridColumnGap])
if (node.gridRowGap > 0) props.push(['rowGap', node.gridRowGap])
} else if (isFlex) {
props.push(['flex', node.layoutMode === 'HORIZONTAL' ? 'row' : 'col'])
const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height'
const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width'
if (node.primaryAxisSizing === 'FILL')
props.push([primaryAxis === 'width' ? 'w' : 'h', 'fill'])
else if (node.primaryAxisSizing !== 'HUG')
props.push([primaryAxis === 'width' ? 'w' : 'h', node[primaryAxis]])
if (node.primaryAxisSizing === 'FILL')
props.push([primaryAxis === 'width' ? 'w' : 'h', 'fill'])
else if (node.primaryAxisSizing !== 'HUG')
props.push([primaryAxis === 'width' ? 'w' : 'h', node[primaryAxis]])
if (node.counterAxisSizing === 'FILL')
props.push([crossAxis === 'width' ? 'w' : 'h', 'fill'])
else if (node.counterAxisSizing !== 'HUG')
props.push([crossAxis === 'width' ? 'w' : 'h', node[crossAxis]])
}
if (node.counterAxisSizing === 'FILL')
props.push([crossAxis === 'width' ? 'w' : 'h', 'fill'])
else if (node.counterAxisSizing !== 'HUG')
props.push([crossAxis === 'width' ? 'w' : 'h', node[crossAxis]])
} else {
if (node.width > 0) props.push(['w', node.width])
if (node.height > 0) props.push(['h', node.height])
}
function collectGridPositionProps(node: SceneNode, props: [string, unknown][]): void {
if (!node.gridPosition) return
const pos = node.gridPosition
if (pos.column > 0) props.push(['colStart', pos.column])
if (pos.row > 0) props.push(['rowStart', pos.row])
if (pos.columnSpan > 1) props.push(['colSpan', pos.columnSpan])
if (pos.rowSpan > 1) props.push(['rowSpan', pos.rowSpan])
}
if (parentIsAutoLayout && node.layoutGrow > 0) props.push(['grow', node.layoutGrow])
function collectFlexAlignmentProps(node: SceneNode, props: [string, unknown][]): void {
if (node.itemSpacing > 0) props.push(['gap', node.itemSpacing])
if (parentIsGrid && node.gridPosition) {
const pos = node.gridPosition
if (pos.column > 0) props.push(['colStart', pos.column])
if (pos.row > 0) props.push(['rowStart', pos.row])
if (pos.columnSpan > 1) props.push(['colSpan', pos.columnSpan])
if (pos.rowSpan > 1) props.push(['rowSpan', pos.rowSpan])
}
if (isFlex && node.itemSpacing > 0) props.push(['gap', node.itemSpacing])
if (isFlex && node.layoutWrap === 'WRAP') {
if (node.layoutWrap === 'WRAP') {
props.push(['wrap', true])
if (node.counterAxisSpacing > 0) props.push(['rowGap', node.counterAxisSpacing])
}
if (isFlex) {
if (node.primaryAxisAlign === 'CENTER') props.push(['justify', 'center'])
else if (node.primaryAxisAlign === 'MAX') props.push(['justify', 'end'])
else if (node.primaryAxisAlign === 'SPACE_BETWEEN') props.push(['justify', 'between'])
if (node.primaryAxisAlign === 'CENTER') props.push(['justify', 'center'])
else if (node.primaryAxisAlign === 'MAX') props.push(['justify', 'end'])
else if (node.primaryAxisAlign === 'SPACE_BETWEEN') props.push(['justify', 'between'])
if (node.counterAxisAlign === 'CENTER') props.push(['items', 'center'])
else if (node.counterAxisAlign === 'MAX') props.push(['items', 'end'])
else if (node.counterAxisAlign === 'STRETCH') props.push(['items', 'stretch'])
}
if (isAutoLayout) {
const pad = collectPadding(node)
if (pad) {
props.push(
...emitPadding(
pad,
(v) => ['p', v] as [string, unknown],
(y, x) => [['py', y], ['px', x]] as [string, unknown][],
({ pt, pr, pb, pl }) => {
const r: [string, unknown][] = []
if (pt > 0) r.push(['pt', pt])
if (pr > 0) r.push(['pr', pr])
if (pb > 0) r.push(['pb', pb])
if (pl > 0) r.push(['pl', pl])
return r
}
)
)
}
if (node.counterAxisAlign === 'CENTER') props.push(['items', 'center'])
else if (node.counterAxisAlign === 'MAX') props.push(['items', 'end'])
else if (node.counterAxisAlign === 'STRETCH') props.push(['items', 'stretch'])
}
function collectAutoLayoutPaddingProps(node: SceneNode, props: [string, unknown][]): void {
const pad = collectPadding(node)
if (!pad) return
props.push(
...emitPadding(
pad,
(v) => ['p', v] as [string, unknown],
(y, x) => [['py', y], ['px', x]] as [string, unknown][],
({ pt, pr, pb, pl }) => {
const r: [string, unknown][] = []
if (pt > 0) r.push(['pt', pt])
if (pr > 0) r.push(['pr', pr])
if (pb > 0) r.push(['pb', pb])
if (pl > 0) r.push(['pl', pl])
return r
}
)
)
}
function collectCornerRadiiProps(node: SceneNode, props: [string, unknown][]): void {
const corners = collectCornerRadii(node)
if (!corners) return
const { tl, tr, br, bl } = corners
if (tl === tr && tr === br && br === bl) {
props.push(['rounded', tl])
} else {
if (tl > 0) props.push(['roundedTL', tl])
if (tr > 0) props.push(['roundedTR', tr])
if (br > 0) props.push(['roundedBR', br])
if (bl > 0) props.push(['roundedBL', bl])
}
}
function collectAppearanceProps(node: SceneNode, props: [string, unknown][]): void {
const bg = solidFillColor(node.fills)
if (bg) props.push(['bg', bg])
@ -281,18 +282,7 @@ function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
if (stroke.weight !== 1) props.push(['strokeWidth', stroke.weight])
}
const corners = collectCornerRadii(node)
if (corners) {
const { tl, tr, br, bl } = corners
if (tl === tr && tr === br && br === bl) {
props.push(['rounded', tl])
} else {
if (tl > 0) props.push(['roundedTL', tl])
if (tr > 0) props.push(['roundedTR', tr])
if (br > 0) props.push(['roundedBR', br])
if (bl > 0) props.push(['roundedBL', bl])
}
}
collectCornerRadiiProps(node, props)
if (node.cornerSmoothing > 0) props.push(['cornerSmoothing', node.cornerSmoothing])
if (node.opacity < 1) props.push(['opacity', Math.round(node.opacity * 100) / 100])
@ -311,27 +301,29 @@ function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
props.push(['blur', effect.radius])
}
}
}
if (node.type === 'TEXT') {
if (node.fontSize !== 14) props.push(['size', node.fontSize])
if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY)
props.push(['font', node.fontFamily])
if (node.fontWeight !== 400) {
if (node.fontWeight === 700) props.push(['weight', 'bold'])
else if (node.fontWeight === 500) props.push(['weight', 'medium'])
else props.push(['weight', node.fontWeight])
}
if (node.textAlignHorizontal !== 'LEFT') {
props.push(['textAlign', node.textAlignHorizontal.toLowerCase()])
}
const textColor = solidFillColor(node.fills)
if (textColor) {
const bgIdx = props.findIndex(([k]) => k === 'bg')
if (bgIdx !== -1) props.splice(bgIdx, 1)
props.push(['color', textColor])
}
function collectTextNodeProps(node: SceneNode, props: [string, unknown][]): void {
if (node.fontSize !== 14) props.push(['size', node.fontSize])
if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY)
props.push(['font', node.fontFamily])
if (node.fontWeight !== 400) {
if (node.fontWeight === 700) props.push(['weight', 'bold'])
else if (node.fontWeight === 500) props.push(['weight', 'medium'])
else props.push(['weight', node.fontWeight])
}
if (node.textAlignHorizontal !== 'LEFT') {
props.push(['textAlign', node.textAlignHorizontal.toLowerCase()])
}
const textColor = solidFillColor(node.fills)
if (textColor) {
const bgIdx = props.findIndex(([k]) => k === 'bg')
if (bgIdx !== -1) props.splice(bgIdx, 1)
props.push(['color', textColor])
}
}
function collectShapeNodeProps(node: SceneNode, props: [string, unknown][]): void {
if (node.type === 'STAR') {
if (node.pointCount !== 5) props.push(['points', node.pointCount])
if (node.starInnerRadius !== 0.382) props.push(['innerRadius', node.starInnerRadius])
@ -339,11 +331,33 @@ function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
if (node.type === 'POLYGON' && node.pointCount !== 3) {
props.push(['points', node.pointCount])
}
}
function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
const props: [string, unknown][] = []
const ctx = getNodeContext(node, graph)
if (node.name && node.name !== node.type) props.push(['name', node.name])
if (ctx.isGrid) collectGridSizingProps(node, props)
else if (ctx.isFlex) collectFlexSizingProps(node, props)
else {
if (node.width > 0) props.push(['w', node.width])
if (node.height > 0) props.push(['h', node.height])
}
if (ctx.parentIsAutoLayout && node.layoutGrow > 0) props.push(['grow', node.layoutGrow])
if (ctx.parentIsGrid) collectGridPositionProps(node, props)
if (ctx.isFlex) collectFlexAlignmentProps(node, props)
if (ctx.isAutoLayout) collectAutoLayoutPaddingProps(node, props)
collectAppearanceProps(node, props)
if (node.type === 'TEXT') collectTextNodeProps(node, props)
collectShapeNodeProps(node, props)
return props
}
// --- Tailwind CSS v4 format ---
// --- Tailwind CSS v4 format helpers ---
function twRounded(prefix: string, px: number): string {
const r = borderRadiusToTw(px)
@ -356,92 +370,97 @@ function gridTemplateTw(tracks: GridTrack[]): string {
return `[${tracks.map(formatTrack).join('_')}]`
}
function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] {
const classes: string[] = []
const { isAutoLayout, isGrid, isFlex, parentIsAutoLayout, parentIsGrid } = getNodeContext(
node,
graph
)
function collectTwGridClasses(node: SceneNode, classes: string[]): void {
classes.push('grid')
if (node.gridTemplateColumns.length > 0)
classes.push(`grid-cols-${gridTemplateTw(node.gridTemplateColumns)}`)
if (node.gridTemplateRows.length > 0)
classes.push(`grid-rows-${gridTemplateTw(node.gridTemplateRows)}`)
if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`)
if (node.height > 0) classes.push(`h-${pxToSpacing(node.height)}`)
if (node.gridColumnGap > 0) classes.push(`gap-x-${pxToSpacing(node.gridColumnGap)}`)
if (node.gridRowGap > 0) classes.push(`gap-y-${pxToSpacing(node.gridRowGap)}`)
}
if (isGrid) {
classes.push('grid')
if (node.gridTemplateColumns.length > 0)
classes.push(`grid-cols-${gridTemplateTw(node.gridTemplateColumns)}`)
if (node.gridTemplateRows.length > 0)
classes.push(`grid-rows-${gridTemplateTw(node.gridTemplateRows)}`)
if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`)
if (node.height > 0) classes.push(`h-${pxToSpacing(node.height)}`)
if (node.gridColumnGap > 0) classes.push(`gap-x-${pxToSpacing(node.gridColumnGap)}`)
if (node.gridRowGap > 0) classes.push(`gap-y-${pxToSpacing(node.gridRowGap)}`)
} else if (isFlex) {
classes.push('flex')
if (node.layoutMode === 'VERTICAL') classes.push('flex-col')
function collectTwFlexSizingClasses(node: SceneNode, classes: string[]): void {
classes.push('flex')
if (node.layoutMode === 'VERTICAL') classes.push('flex-col')
const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height'
const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width'
const wProp = primaryAxis === 'width' ? 'w' : 'h'
const hProp = crossAxis === 'width' ? 'w' : 'h'
const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height'
const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width'
const wProp = primaryAxis === 'width' ? 'w' : 'h'
const hProp = crossAxis === 'width' ? 'w' : 'h'
if (node.primaryAxisSizing === 'FILL') classes.push(`${wProp}-full`)
else if (node.primaryAxisSizing !== 'HUG')
classes.push(`${wProp}-${pxToSpacing(node[primaryAxis])}`)
if (node.primaryAxisSizing === 'FILL') classes.push(`${wProp}-full`)
else if (node.primaryAxisSizing !== 'HUG')
classes.push(`${wProp}-${pxToSpacing(node[primaryAxis])}`)
if (node.counterAxisSizing === 'FILL') classes.push(`${hProp}-full`)
else if (node.counterAxisSizing !== 'HUG')
classes.push(`${hProp}-${pxToSpacing(node[crossAxis])}`)
} else {
if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`)
if (node.height > 0) classes.push(`h-${pxToSpacing(node.height)}`)
}
if (node.counterAxisSizing === 'FILL') classes.push(`${hProp}-full`)
else if (node.counterAxisSizing !== 'HUG')
classes.push(`${hProp}-${pxToSpacing(node[crossAxis])}`)
}
if (parentIsAutoLayout && node.layoutGrow > 0) classes.push('grow')
function collectTwGridPositionClasses(node: SceneNode, classes: string[]): void {
if (!node.gridPosition) return
const pos = node.gridPosition
if (pos.column > 0) classes.push(`col-start-${pos.column}`)
if (pos.row > 0) classes.push(`row-start-${pos.row}`)
if (pos.columnSpan > 1) classes.push(`col-span-${pos.columnSpan}`)
if (pos.rowSpan > 1) classes.push(`row-span-${pos.rowSpan}`)
}
if (parentIsGrid && node.gridPosition) {
const pos = node.gridPosition
if (pos.column > 0) classes.push(`col-start-${pos.column}`)
if (pos.row > 0) classes.push(`row-start-${pos.row}`)
if (pos.columnSpan > 1) classes.push(`col-span-${pos.columnSpan}`)
if (pos.rowSpan > 1) classes.push(`row-span-${pos.rowSpan}`)
}
function collectTwFlexAlignmentClasses(node: SceneNode, classes: string[]): void {
if (node.itemSpacing > 0) classes.push(`gap-${pxToSpacing(node.itemSpacing)}`)
if (isFlex && node.itemSpacing > 0) classes.push(`gap-${pxToSpacing(node.itemSpacing)}`)
if (isFlex && node.layoutWrap === 'WRAP') {
if (node.layoutWrap === 'WRAP') {
classes.push('flex-wrap')
if (node.counterAxisSpacing > 0) classes.push(`gap-y-${pxToSpacing(node.counterAxisSpacing)}`)
}
if (isFlex) {
if (node.primaryAxisAlign === 'CENTER') classes.push('justify-center')
else if (node.primaryAxisAlign === 'MAX') classes.push('justify-end')
else if (node.primaryAxisAlign === 'SPACE_BETWEEN') classes.push('justify-between')
if (node.primaryAxisAlign === 'CENTER') classes.push('justify-center')
else if (node.primaryAxisAlign === 'MAX') classes.push('justify-end')
else if (node.primaryAxisAlign === 'SPACE_BETWEEN') classes.push('justify-between')
if (node.counterAxisAlign === 'CENTER') classes.push('items-center')
else if (node.counterAxisAlign === 'MAX') classes.push('items-end')
else if (node.counterAxisAlign === 'STRETCH') classes.push('items-stretch')
}
if (isAutoLayout) {
const pad = collectPadding(node)
if (pad) {
classes.push(
...emitPadding(
pad,
(v) => `p-${pxToSpacing(v)}`,
(y, x) => [`py-${pxToSpacing(y)}`, `px-${pxToSpacing(x)}`],
({ pt, pr, pb, pl }) => {
const r: string[] = []
if (pt > 0) r.push(`pt-${pxToSpacing(pt)}`)
if (pr > 0) r.push(`pr-${pxToSpacing(pr)}`)
if (pb > 0) r.push(`pb-${pxToSpacing(pb)}`)
if (pl > 0) r.push(`pl-${pxToSpacing(pl)}`)
return r
}
)
)
}
if (node.counterAxisAlign === 'CENTER') classes.push('items-center')
else if (node.counterAxisAlign === 'MAX') classes.push('items-end')
else if (node.counterAxisAlign === 'STRETCH') classes.push('items-stretch')
}
function collectTwPaddingClasses(node: SceneNode, classes: string[]): void {
const pad = collectPadding(node)
if (!pad) return
classes.push(
...emitPadding(
pad,
(v) => `p-${pxToSpacing(v)}`,
(y, x) => [`py-${pxToSpacing(y)}`, `px-${pxToSpacing(x)}`],
({ pt, pr, pb, pl }) => {
const r: string[] = []
if (pt > 0) r.push(`pt-${pxToSpacing(pt)}`)
if (pr > 0) r.push(`pr-${pxToSpacing(pr)}`)
if (pb > 0) r.push(`pb-${pxToSpacing(pb)}`)
if (pl > 0) r.push(`pl-${pxToSpacing(pl)}`)
return r
}
)
)
}
function collectTwCornerRadiiClasses(node: SceneNode, classes: string[]): void {
const corners = collectCornerRadii(node)
if (!corners) return
const { tl, tr, br, bl } = corners
if (tl === tr && tr === br && br === bl) {
classes.push(twRounded('rounded', tl))
} else {
if (tl > 0) classes.push(twRounded('rounded-tl', tl))
if (tr > 0) classes.push(twRounded('rounded-tr', tr))
if (br > 0) classes.push(twRounded('rounded-br', br))
if (bl > 0) classes.push(twRounded('rounded-bl', bl))
}
}
function collectTwAppearanceClasses(node: SceneNode, classes: string[]): void {
const bg = solidFillColor(node.fills)
if (bg && node.type !== 'TEXT') classes.push(`bg-${colorToTwClass(bg)}`)
@ -452,18 +471,7 @@ function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] {
classes.push(`border-${colorToTwClass(stroke.color)}`)
}
const corners = collectCornerRadii(node)
if (corners) {
const { tl, tr, br, bl } = corners
if (tl === tr && tr === br && br === bl) {
classes.push(twRounded('rounded', tl))
} else {
if (tl > 0) classes.push(twRounded('rounded-tl', tl))
if (tr > 0) classes.push(twRounded('rounded-tr', tr))
if (br > 0) classes.push(twRounded('rounded-br', br))
if (bl > 0) classes.push(twRounded('rounded-bl', bl))
}
}
collectTwCornerRadiiClasses(node, classes)
if (node.opacity < 1) classes.push(`opacity-${opacityToTw(node.opacity)}`)
if (node.rotation !== 0) classes.push(`rotate-${formatTailwindAngle(node.rotation)}`)
@ -480,19 +488,38 @@ function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] {
classes.push(`backdrop-blur-[${effect.radius}px]`)
}
}
}
if (node.type === 'TEXT') {
classes.push(`text-${fontSizeToTw(node.fontSize)}`)
if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY) {
classes.push(`font-${formatTailwindFontFamily(node.fontFamily)}`)
}
if (node.fontWeight !== 400) classes.push(`font-${fontWeightToTw(node.fontWeight)}`)
if (node.textAlignHorizontal !== 'LEFT') {
classes.push(`text-${node.textAlignHorizontal.toLowerCase()}`)
}
const textColor = solidFillColor(node.fills)
if (textColor) classes.push(`text-${colorToTwClass(textColor)}`)
function collectTwTextClasses(node: SceneNode, classes: string[]): void {
classes.push(`text-${fontSizeToTw(node.fontSize)}`)
if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY) {
classes.push(`font-${formatTailwindFontFamily(node.fontFamily)}`)
}
if (node.fontWeight !== 400) classes.push(`font-${fontWeightToTw(node.fontWeight)}`)
if (node.textAlignHorizontal !== 'LEFT') {
classes.push(`text-${node.textAlignHorizontal.toLowerCase()}`)
}
const textColor = solidFillColor(node.fills)
if (textColor) classes.push(`text-${colorToTwClass(textColor)}`)
}
function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] {
const classes: string[] = []
const ctx = getNodeContext(node, graph)
if (ctx.isGrid) collectTwGridClasses(node, classes)
else if (ctx.isFlex) collectTwFlexSizingClasses(node, classes)
else {
if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`)
if (node.height > 0) classes.push(`h-${pxToSpacing(node.height)}`)
}
if (ctx.parentIsAutoLayout && node.layoutGrow > 0) classes.push('grow')
if (ctx.parentIsGrid) collectTwGridPositionClasses(node, classes)
if (ctx.isFlex) collectTwFlexAlignmentClasses(node, classes)
if (ctx.isAutoLayout) collectTwPaddingClasses(node, classes)
collectTwAppearanceClasses(node, classes)
if (node.type === 'TEXT') collectTwTextClasses(node, classes)
return classes
}

View file

@ -122,11 +122,10 @@ function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): SceneN
return node
}
function propsToOverrides(props: Record<string, unknown>, isText: boolean): Partial<SceneNode> {
const o: Partial<SceneNode> = {}
if (props.name) o.name = props.name as string
function applySizeOverrides(
props: Record<string, unknown>,
o: Partial<SceneNode>
): { w: unknown; h: unknown } {
const w = props.w ?? props.width
const h = props.h ?? props.height
if (typeof w === 'number') o.width = w
@ -143,6 +142,10 @@ function propsToOverrides(props: Record<string, unknown>, isText: boolean): Part
if (props.x !== undefined) o.x = props.x as number
if (props.y !== undefined) o.y = props.y as number
return { w, h }
}
function applyVisualOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const bg = props.bg ?? props.fill
if (typeof bg === 'string') {
o.fills = [colorToFill(bg)]
@ -177,7 +180,38 @@ function propsToOverrides(props: Record<string, unknown>, isText: boolean): Part
o.blendMode = (props.blendMode as string).toUpperCase() as SceneNode['blendMode']
}
if (props.overflow === 'hidden') o.clipsContent = true
}
function applyPaddingOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const p = props.p ?? props.padding
if (typeof p === 'number') {
o.paddingTop = p
o.paddingRight = p
o.paddingBottom = p
o.paddingLeft = p
}
const px = props.px as number | undefined
const py = props.py as number | undefined
if (px !== undefined) {
o.paddingLeft = px
o.paddingRight = px
}
if (py !== undefined) {
o.paddingTop = py
o.paddingBottom = py
}
if (props.pt !== undefined) o.paddingTop = props.pt as number
if (props.pr !== undefined) o.paddingRight = props.pr as number
if (props.pb !== undefined) o.paddingBottom = props.pb as number
if (props.pl !== undefined) o.paddingLeft = props.pl as number
}
function applyLayoutOverrides(
props: Record<string, unknown>,
o: Partial<SceneNode>,
w: unknown,
h: unknown
): void {
if (props.flex !== undefined) {
const dir = props.flex as string
o.layoutMode = (dir === 'col' || dir === 'column' ? 'VERTICAL' : 'HORIZONTAL') as LayoutMode
@ -205,60 +239,42 @@ function propsToOverrides(props: Record<string, unknown>, isText: boolean): Part
o.counterAxisAlign = COUNTER_ALIGN_MAP[props.items as string] ?? 'MIN'
}
const p = props.p ?? props.padding
if (typeof p === 'number') {
o.paddingTop = p
o.paddingRight = p
o.paddingBottom = p
o.paddingLeft = p
}
const px = props.px as number | undefined
const py = props.py as number | undefined
if (px !== undefined) {
o.paddingLeft = px
o.paddingRight = px
}
if (py !== undefined) {
o.paddingTop = py
o.paddingBottom = py
}
if (props.pt !== undefined) o.paddingTop = props.pt as number
if (props.pr !== undefined) o.paddingRight = props.pr as number
if (props.pb !== undefined) o.paddingBottom = props.pb as number
if (props.pl !== undefined) o.paddingLeft = props.pl as number
applyPaddingOverrides(props, o)
if (props.grow !== undefined) o.layoutGrow = props.grow as number
if (props.minW !== undefined) o.width = Math.max(o.width ?? 0, props.minW as number)
if (props.maxW !== undefined) o.width = Math.min(o.width ?? Infinity, props.maxW as number)
}
if (isText) {
const fontSize = props.size ?? props.fontSize
if (typeof fontSize === 'number') o.fontSize = fontSize
function applyTextOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const fontSize = props.size ?? props.fontSize
if (typeof fontSize === 'number') o.fontSize = fontSize
const fontFamily = props.font ?? props.fontFamily
if (typeof fontFamily === 'string') o.fontFamily = fontFamily
const fontFamily = props.font ?? props.fontFamily
if (typeof fontFamily === 'string') o.fontFamily = fontFamily
const weight = props.weight ?? props.fontWeight
if (typeof weight === 'number') {
o.fontWeight = weight
} else if (typeof weight === 'string') {
o.fontWeight = WEIGHT_MAP[weight] ?? 400
}
if (typeof props.color === 'string') {
o.fills = [colorToFill(props.color)]
}
if (props.textAlign) {
o.textAlignHorizontal = TEXT_ALIGN_MAP[props.textAlign as string] ?? 'LEFT'
}
o.textAutoResize = props.textAutoResize
? (TEXT_AUTO_RESIZE_MAP[props.textAutoResize as string] ?? 'NONE')
: 'HEIGHT'
const weight = props.weight ?? props.fontWeight
if (typeof weight === 'number') {
o.fontWeight = weight
} else if (typeof weight === 'string') {
o.fontWeight = WEIGHT_MAP[weight] ?? 400
}
if (typeof props.color === 'string') {
o.fills = [colorToFill(props.color)]
}
if (props.textAlign) {
o.textAlignHorizontal = TEXT_ALIGN_MAP[props.textAlign as string] ?? 'LEFT'
}
o.textAutoResize = props.textAutoResize
? (TEXT_AUTO_RESIZE_MAP[props.textAutoResize as string] ?? 'NONE')
: 'HEIGHT'
}
function applyEffectOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
if (props.points !== undefined) o.pointCount = props.points as number
if (props.innerRadius !== undefined) o.starInnerRadius = props.innerRadius as number
if (props.pointCount !== undefined) o.pointCount = props.pointCount as number
@ -294,6 +310,18 @@ function propsToOverrides(props: Record<string, unknown>, isText: boolean): Part
}
]
}
}
function propsToOverrides(props: Record<string, unknown>, isText: boolean): Partial<SceneNode> {
const o: Partial<SceneNode> = {}
if (props.name) o.name = props.name as string
const { w, h } = applySizeOverrides(props, o)
applyVisualOverrides(props, o)
applyLayoutOverrides(props, o, w, h)
if (isText) applyTextOverrides(props, o)
applyEffectOverrides(props, o)
return o
}

View file

@ -30,7 +30,7 @@ import type { SceneNode, SceneGraph } from '../scene-graph'
import type { SnapGuide } from '../snap'
import type { TextEditor } from '../text-editor'
import type { Rect, Vector } from '../types'
import type { Canvas } from 'canvaskit-wasm'
import type { Canvas, Paint } from 'canvaskit-wasm'
import type { SkiaRenderer, RenderOverlays } from './renderer'
export function drawHoverHighlight(
@ -568,23 +568,15 @@ export function drawTextEditOverlay(
}
}
export function drawPenOverlay(
type ToScreenFn = (x: number, y: number) => Vector
function buildPenPath(
r: SkiaRenderer,
canvas: Canvas,
penState: RenderOverlays['penState']
penState: NonNullable<RenderOverlays['penState']>,
toScreen: ToScreenFn
): void {
if (!penState || penState.vertices.length === 0) return
const { vertices, segments, dragTangent, cursorX, cursorY } = penState
const pathPaint = r.penPathPaint
const handlePaint = r.penHandlePaint
const vertexFill = r.penVertexFill
const vertexStroke = r.penVertexStroke
const toScreen = (x: number, y: number) => ({
x: x * r.zoom + r.panX,
y: y * r.zoom + r.panY
})
const path = new r.ck.Path()
for (const seg of segments) {
@ -627,8 +619,29 @@ export function drawPenOverlay(
}
}
canvas.drawPath(path, pathPaint)
canvas.drawPath(path, r.penPathPaint)
path.delete()
}
function drawPenHandlePoint(
canvas: Canvas,
x: number,
y: number,
vertexFill: Paint,
handlePaint: Paint
): void {
canvas.drawCircle(x, y, PEN_HANDLE_RADIUS, vertexFill)
canvas.drawCircle(x, y, PEN_HANDLE_RADIUS, handlePaint)
}
function drawPenTangentHandles(
canvas: Canvas,
penState: NonNullable<RenderOverlays['penState']>,
toScreen: ToScreenFn,
handlePaint: Paint,
vertexFill: Paint
): void {
const { vertices, segments, dragTangent } = penState
for (const seg of segments) {
const ts = seg.tangentStart
@ -637,15 +650,13 @@ export function drawPenOverlay(
const s = toScreen(vertices[seg.start].x, vertices[seg.start].y)
const cp = toScreen(vertices[seg.start].x + ts.x, vertices[seg.start].y + ts.y)
canvas.drawLine(s.x, s.y, cp.x, cp.y, handlePaint)
canvas.drawCircle(cp.x, cp.y, PEN_HANDLE_RADIUS, vertexFill)
canvas.drawCircle(cp.x, cp.y, PEN_HANDLE_RADIUS, handlePaint)
drawPenHandlePoint(canvas, cp.x, cp.y, vertexFill, handlePaint)
}
if (te.x !== 0 || te.y !== 0) {
const e = toScreen(vertices[seg.end].x, vertices[seg.end].y)
const cp = toScreen(vertices[seg.end].x + te.x, vertices[seg.end].y + te.y)
canvas.drawLine(e.x, e.y, cp.x, cp.y, handlePaint)
canvas.drawCircle(cp.x, cp.y, PEN_HANDLE_RADIUS, vertexFill)
canvas.drawCircle(cp.x, cp.y, PEN_HANDLE_RADIUS, handlePaint)
drawPenHandlePoint(canvas, cp.x, cp.y, vertexFill, handlePaint)
}
}
@ -654,11 +665,29 @@ export function drawPenOverlay(
const cp1 = toScreen(last.x + dragTangent.x, last.y + dragTangent.y)
const cp2 = toScreen(last.x - dragTangent.x, last.y - dragTangent.y)
canvas.drawLine(cp2.x, cp2.y, cp1.x, cp1.y, handlePaint)
canvas.drawCircle(cp1.x, cp1.y, PEN_HANDLE_RADIUS, vertexFill)
canvas.drawCircle(cp1.x, cp1.y, PEN_HANDLE_RADIUS, handlePaint)
canvas.drawCircle(cp2.x, cp2.y, PEN_HANDLE_RADIUS, vertexFill)
canvas.drawCircle(cp2.x, cp2.y, PEN_HANDLE_RADIUS, handlePaint)
drawPenHandlePoint(canvas, cp1.x, cp1.y, vertexFill, handlePaint)
drawPenHandlePoint(canvas, cp2.x, cp2.y, vertexFill, handlePaint)
}
}
export function drawPenOverlay(
r: SkiaRenderer,
canvas: Canvas,
penState: RenderOverlays['penState']
): void {
if (!penState || penState.vertices.length === 0) return
const { vertices } = penState
const vertexFill = r.penVertexFill
const vertexStroke = r.penVertexStroke
const toScreen: ToScreenFn = (x, y) => ({
x: x * r.zoom + r.panX,
y: y * r.zoom + r.panY
})
buildPenPath(r, canvas, penState, toScreen)
drawPenTangentHandles(canvas, penState, toScreen, r.penHandlePaint, vertexFill)
for (let i = 0; i < vertices.length; i++) {
const v = toScreen(vertices[i].x, vertices[i].y)

View file

@ -802,16 +802,7 @@ export class SkiaRenderer {
const baseFontSize = node.fontSize || DEFAULT_FONT_SIZE
const cjkFallback = getCJKFallbackFamily()
const truncateOpts: { maxLines?: number; ellipsis?: string } = {}
if (node.textTruncation === 'ENDING') {
if (node.maxLines != null && node.maxLines > 0) {
truncateOpts.maxLines = node.maxLines
} else if (node.height > 0) {
const lineH = node.lineHeight || baseFontSize * 1.2
truncateOpts.maxLines = Math.max(1, Math.floor(node.height / lineH))
}
truncateOpts.ellipsis = '…'
}
const truncateOpts = this.buildTruncateOpts(node, baseFontSize)
const fontFamilies = (primary: string) =>
cjkFallback ? [primary, cjkFallback] : [primary]
@ -835,43 +826,11 @@ export class SkiaRenderer {
})
const builder = ck.ParagraphBuilder.MakeFromFontProvider(paraStyle, this.fontProvider!)
const runs = node.styleRuns
const text = node.text
if (runs.length === 0) {
builder.addText(text)
if (node.styleRuns.length === 0) {
builder.addText(node.text)
} else {
let pos = 0
for (const run of runs) {
if (pos < run.start) {
builder.addText(text.slice(pos, run.start))
}
const s = run.style
builder.pushStyle(
new ck.TextStyle({
color: baseColor,
fontFamilies: fontFamilies(s.fontFamily ?? (node.fontFamily || DEFAULT_FONT_FAMILY)),
fontSize: s.fontSize ?? baseFontSize,
fontStyle: {
weight: { value: (s.fontWeight ?? node.fontWeight) || 400 } as FontWeight,
slant: (s.italic ?? node.italic) ? ck.FontSlant.Italic : ck.FontSlant.Upright
},
letterSpacing: s.letterSpacing ?? (node.letterSpacing || 0),
decoration: this.textDecorationValue(s.textDecoration ?? node.textDecoration),
heightMultiplier: (s.lineHeight !== undefined ? s.lineHeight : node.lineHeight)
? (s.lineHeight !== undefined ? s.lineHeight : node.lineHeight)! /
(s.fontSize ?? baseFontSize)
: undefined,
halfLeading
})
)
builder.addText(text.slice(run.start, run.start + run.length))
builder.pop()
pos = run.start + run.length
}
if (pos < text.length) {
builder.addText(text.slice(pos))
}
this.addStyledRuns(builder, node, baseColor, baseFontSize, fontFamilies, halfLeading)
}
const paragraph = builder.build()
@ -880,6 +839,67 @@ export class SkiaRenderer {
return paragraph
}
private buildTruncateOpts(
node: SceneNode,
baseFontSize: number
): { maxLines?: number; ellipsis?: string } {
if (node.textTruncation !== 'ENDING') return {}
const opts: { maxLines?: number; ellipsis: string } = { ellipsis: '…' }
if (node.maxLines != null && node.maxLines > 0) {
opts.maxLines = node.maxLines
} else if (node.height > 0) {
const lineH = node.lineHeight || baseFontSize * 1.2
opts.maxLines = Math.max(1, Math.floor(node.height / lineH))
}
return opts
}
private addStyledRuns(
builder: ReturnType<CanvasKit['ParagraphBuilder']['MakeFromFontProvider']>,
node: SceneNode,
baseColor: Float32Array,
baseFontSize: number,
fontFamilies: (primary: string) => string[],
halfLeading: boolean
): void {
const ck = this.ck
const text = node.text
let pos = 0
for (const run of node.styleRuns) {
if (pos < run.start) {
builder.addText(text.slice(pos, run.start))
}
const s = run.style
const runLineHeight = s.lineHeight !== undefined ? s.lineHeight : node.lineHeight
const runFontSize = s.fontSize ?? baseFontSize
builder.pushStyle(
new ck.TextStyle({
color: baseColor,
fontFamilies: fontFamilies(s.fontFamily ?? (node.fontFamily || DEFAULT_FONT_FAMILY)),
fontSize: runFontSize,
fontStyle: {
weight: { value: (s.fontWeight ?? node.fontWeight) || 400 } as FontWeight,
slant: (s.italic ?? node.italic) ? ck.FontSlant.Italic : ck.FontSlant.Upright
},
letterSpacing: s.letterSpacing ?? (node.letterSpacing || 0),
decoration: this.textDecorationValue(s.textDecoration ?? node.textDecoration),
heightMultiplier: runLineHeight ? runLineHeight / runFontSize : undefined,
halfLeading
})
)
builder.addText(text.slice(run.start, run.start + run.length))
builder.pop()
pos = run.start + run.length
}
if (pos < text.length) {
builder.addText(text.slice(pos))
}
}
private textDecorationValue(decoration: string): number {
switch (decoration) {
case 'UNDERLINE':

View file

@ -15,56 +15,47 @@ import type { SceneNode, SceneGraph } from '../scene-graph'
import type { Canvas, CanvasKit } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
export function drawRulers(
interface SelectionScreenBounds {
sx1: number
sx2: number
sy1: number
sy2: number
}
function getSelectionScreenBounds(
r: SkiaRenderer,
graph: SceneGraph,
selNodes: SceneNode[]
): SelectionScreenBounds {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity
for (const n of selNodes) {
const abs = graph.getAbsolutePosition(n.id)
minX = Math.min(minX, abs.x)
minY = Math.min(minY, abs.y)
maxX = Math.max(maxX, abs.x + n.width)
maxY = Math.max(maxY, abs.y + n.height)
}
return {
sx1: minX * r.zoom + r.panX,
sx2: maxX * r.zoom + r.panX,
sy1: minY * r.zoom + r.panY,
sy2: maxY * r.zoom + r.panY
}
}
function drawHorizontalRulerTicks(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>
font: InstanceType<CanvasKit['Font']>,
step: number,
selBounds: SelectionScreenBounds | null
): void {
const R = RULER_SIZE
const vw = r.viewportWidth
const vh = r.viewportHeight
if (vw === 0 || vh === 0) return
const bgPaint = r.rulerBgPaint
const tickPaint = r.rulerTickPaint
const textPaint = r.rulerTextPaint
canvas.drawRect(r.ck.LTRBRect(0, 0, vw, R), bgPaint)
canvas.drawRect(r.ck.LTRBRect(0, R, R, vh), bgPaint)
canvas.drawRect(r.ck.LTRBRect(0, 0, R, R), bgPaint)
const font = r.sizeFont ?? r.textFont
if (!font) return
const step = rulerStep(r)
const minorStep = step / 5
let sx1 = -Infinity,
sx2 = -Infinity,
sy1 = -Infinity,
sy2 = -Infinity
const selNodes = [...selectedIds]
.map((id) => graph.getNode(id))
.filter((n): n is SceneNode => n !== undefined)
if (selNodes.length > 0) {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity
for (const n of selNodes) {
const abs = graph.getAbsolutePosition(n.id)
minX = Math.min(minX, abs.x)
minY = Math.min(minY, abs.y)
maxX = Math.max(maxX, abs.x + n.width)
maxY = Math.max(maxY, abs.y + n.height)
}
sx1 = minX * r.zoom + r.panX
sx2 = maxX * r.zoom + r.panX
sy1 = minY * r.zoom + r.panY
sy2 = maxY * r.zoom + r.panY
}
const badgeW = RULER_BADGE_EXCLUSION
canvas.save()
@ -78,18 +69,31 @@ export function drawRulers(
if (sx < R) continue
const isMajor = Math.abs(wx % step) < RULER_MAJOR_TOLERANCE
const tickLen = isMajor ? R * RULER_MAJOR_TICK : R * RULER_MINOR_TICK
canvas.drawLine(sx, R - tickLen, sx, R, tickPaint)
canvas.drawLine(sx, R - tickLen, sx, R, r.rulerTickPaint)
if (isMajor && selNodes.length > 0) {
const tooClose = Math.abs(sx - sx1) < badgeW || Math.abs(sx - sx2) < badgeW
if (!tooClose) {
canvas.drawText(rulerLabel(wx), sx + 2, R * RULER_TEXT_BASELINE, textPaint, font)
if (isMajor) {
const skipForBadge =
selBounds != null &&
(Math.abs(sx - selBounds.sx1) < badgeW || Math.abs(sx - selBounds.sx2) < badgeW)
if (!skipForBadge) {
canvas.drawText(rulerLabel(wx), sx + 2, R * RULER_TEXT_BASELINE, r.rulerTextPaint, font)
}
} else if (isMajor) {
canvas.drawText(rulerLabel(wx), sx + 2, R * RULER_TEXT_BASELINE, textPaint, font)
}
}
canvas.restore()
}
function drawVerticalRulerTicks(
r: SkiaRenderer,
canvas: Canvas,
font: InstanceType<CanvasKit['Font']>,
step: number,
selBounds: SelectionScreenBounds | null
): void {
const R = RULER_SIZE
const vh = r.viewportHeight
const minorStep = step / 5
const badgeW = RULER_BADGE_EXCLUSION
canvas.save()
canvas.clipRect(r.ck.LTRBRect(0, R, R, vh), r.ck.ClipOp.Intersect, false)
@ -102,69 +106,60 @@ export function drawRulers(
if (sy < R) continue
const isMajor = Math.abs(wy % step) < RULER_MAJOR_TOLERANCE
const tickLen = isMajor ? R * RULER_MAJOR_TICK : R * RULER_MINOR_TICK
canvas.drawLine(R - tickLen, sy, R, sy, tickPaint)
canvas.drawLine(R - tickLen, sy, R, sy, r.rulerTickPaint)
if (isMajor && selNodes.length > 0) {
const tooClose = Math.abs(sy - sy1) < badgeW || Math.abs(sy - sy2) < badgeW
if (!tooClose) {
if (isMajor) {
const skipForBadge =
selBounds != null &&
(Math.abs(sy - selBounds.sy1) < badgeW || Math.abs(sy - selBounds.sy2) < badgeW)
if (!skipForBadge) {
canvas.save()
canvas.translate(R * RULER_TEXT_BASELINE, sy - 2)
canvas.rotate(-90, 0, 0)
canvas.drawText(rulerLabel(wy), 0, 3, textPaint, font)
canvas.drawText(rulerLabel(wy), 0, 3, r.rulerTextPaint, font)
canvas.restore()
}
} else if (isMajor) {
canvas.save()
canvas.translate(R * RULER_TEXT_BASELINE, sy - 2)
canvas.rotate(-90, 0, 0)
canvas.drawText(rulerLabel(wy), 0, 3, textPaint, font)
canvas.restore()
}
}
canvas.restore()
}
if (selNodes.length > 0) {
export function drawRulers(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>
): void {
const R = RULER_SIZE
const vw = r.viewportWidth
const vh = r.viewportHeight
if (vw === 0 || vh === 0) return
canvas.drawRect(r.ck.LTRBRect(0, 0, vw, R), r.rulerBgPaint)
canvas.drawRect(r.ck.LTRBRect(0, R, R, vh), r.rulerBgPaint)
canvas.drawRect(r.ck.LTRBRect(0, 0, R, R), r.rulerBgPaint)
const font = r.sizeFont ?? r.textFont
if (!font) return
const step = rulerStep(r)
const selNodes = [...selectedIds]
.map((id) => graph.getNode(id))
.filter((n): n is SceneNode => n !== undefined)
const selBounds = selNodes.length > 0 ? getSelectionScreenBounds(r, graph, selNodes) : null
drawHorizontalRulerTicks(r, canvas, font, step, selBounds)
drawVerticalRulerTicks(r, canvas, font, step, selBounds)
if (selBounds) {
r.rulerHlPaint.setColor(r.selColor(RULER_HIGHLIGHT_ALPHA))
canvas.drawRect(r.ck.LTRBRect(Math.max(R, selBounds.sx1), 0, selBounds.sx2, R), r.rulerHlPaint)
canvas.drawRect(r.ck.LTRBRect(0, Math.max(R, selBounds.sy1), R, selBounds.sy2), r.rulerHlPaint)
canvas.drawRect(r.ck.LTRBRect(Math.max(R, sx1), 0, sx2, R), r.rulerHlPaint)
canvas.drawRect(r.ck.LTRBRect(0, Math.max(R, sy1), R, sy2), r.rulerHlPaint)
drawRulerBadge(
r,
canvas,
font,
Math.round((sx1 - r.panX) / r.zoom).toString(),
Math.max(R, sx1),
0,
'horizontal'
)
drawRulerBadge(
r,
canvas,
font,
Math.round((sx2 - r.panX) / r.zoom).toString(),
sx2,
0,
'horizontal'
)
drawRulerBadge(
r,
canvas,
font,
Math.round((sy1 - r.panY) / r.zoom).toString(),
0,
Math.max(R, sy1),
'vertical'
)
drawRulerBadge(
r,
canvas,
font,
Math.round((sy2 - r.panY) / r.zoom).toString(),
0,
sy2,
'vertical'
)
drawRulerBadge(r, canvas, font, Math.round((selBounds.sx1 - r.panX) / r.zoom).toString(), Math.max(R, selBounds.sx1), 0, 'horizontal')
drawRulerBadge(r, canvas, font, Math.round((selBounds.sx2 - r.panX) / r.zoom).toString(), selBounds.sx2, 0, 'horizontal')
drawRulerBadge(r, canvas, font, Math.round((selBounds.sy1 - r.panY) / r.zoom).toString(), 0, Math.max(R, selBounds.sy1), 'vertical')
drawRulerBadge(r, canvas, font, Math.round((selBounds.sy2 - r.panY) / r.zoom).toString(), 0, selBounds.sy2, 'vertical')
}
}

View file

@ -1,69 +1,47 @@
import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '../constants'
import type { SceneNode, SceneGraph } from '../scene-graph'
import type { Canvas, EmbindEnumEntity } from 'canvaskit-wasm'
import type { Canvas, EmbindEnumEntity, Path } from 'canvaskit-wasm'
import type { Color } from '../types'
import type { SkiaRenderer, RenderOverlays } from './renderer'
export function renderNode(
function isCulled(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
nodeId: string,
overlays: RenderOverlays,
parentAbsX = 0,
parentAbsY = 0
): void {
const node = graph.getNode(nodeId)
if (!node || !node.visible) return
r._nodeCount++
const absX = parentAbsX + node.x
const absY = parentAbsY + node.y
node: SceneNode,
absX: number,
absY: number
): boolean {
const canCull =
node.childIds.length === 0 ||
((node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE') &&
node.clipsContent)
if (canCull) {
const vp = r.worldViewport
let bw = node.width
let bh = node.height
if (node.rotation !== 0) {
const diag = Math.sqrt(bw * bw + bh * bh)
const cx = absX + bw / 2
const cy = absY + bh / 2
if (
cx - diag / 2 > vp.x + vp.w ||
cy - diag / 2 > vp.y + vp.h ||
cx + diag / 2 < vp.x ||
cy + diag / 2 < vp.y
) {
r._culledCount++
return
}
} else if (absX > vp.x + vp.w || absY > vp.y + vp.h || absX + bw < vp.x || absY + bh < vp.y) {
r._culledCount++
return
}
}
canvas.save()
canvas.translate(node.x, node.y)
if (node.opacity < 1) {
r.opacityPaint.setAlphaf(node.opacity)
canvas.saveLayer(r.opacityPaint)
}
const layerBlur = node.effects.find((e) => e.visible && e.type === 'LAYER_BLUR')
if (layerBlur) {
r.effectLayerPaint.setImageFilter(r.getCachedBlur(layerBlur.radius / 2))
canvas.saveLayer(r.effectLayerPaint)
if (!canCull) return false
const vp = r.worldViewport
const bw = node.width
const bh = node.height
if (node.rotation !== 0) {
const diag = Math.sqrt(bw * bw + bh * bh)
const cx = absX + bw / 2
const cy = absY + bh / 2
return (
cx - diag / 2 > vp.x + vp.w ||
cy - diag / 2 > vp.y + vp.h ||
cx + diag / 2 < vp.x ||
cy + diag / 2 < vp.y
)
}
return absX > vp.x + vp.w || absY > vp.y + vp.h || absX + bw < vp.x || absY + bh < vp.y
}
function applyNodeTransforms(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
nodeId: string,
overlays: RenderOverlays
): void {
const rotation =
overlays.rotationPreview?.nodeId === nodeId ? overlays.rotationPreview.angle : node.rotation
if (rotation !== 0) {
canvas.rotate(rotation, node.width / 2, node.height / 2)
}
@ -75,7 +53,16 @@ export function renderNode(
)
canvas.scale(node.flipX ? -1 : 1, node.flipY ? -1 : 1)
}
}
function renderNodeContent(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
node: SceneNode,
nodeId: string,
overlays: RenderOverlays
): void {
if (node.type === 'SECTION') {
r.renderSection(canvas, node, graph)
} else if (node.type === 'COMPONENT_SET') {
@ -93,7 +80,17 @@ export function renderNode(
r.auxStroke.setColor(r.selColor(DROP_HIGHLIGHT_ALPHA))
canvas.drawRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.auxStroke)
}
}
function renderChildren(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
node: SceneNode,
overlays: RenderOverlays,
absX: number,
absY: number
): void {
const isClippableContainer =
node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE'
if (isClippableContainer && node.clipsContent && node.childIds.length > 0) {
@ -112,6 +109,47 @@ export function renderNode(
r.renderNode(canvas, graph, childId, overlays, absX, absY)
}
}
}
export function renderNode(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
nodeId: string,
overlays: RenderOverlays,
parentAbsX = 0,
parentAbsY = 0
): void {
const node = graph.getNode(nodeId)
if (!node || !node.visible) return
r._nodeCount++
const absX = parentAbsX + node.x
const absY = parentAbsY + node.y
if (isCulled(r, node, absX, absY)) {
r._culledCount++
return
}
canvas.save()
canvas.translate(node.x, node.y)
if (node.opacity < 1) {
r.opacityPaint.setAlphaf(node.opacity)
canvas.saveLayer(r.opacityPaint)
}
const layerBlur = node.effects.find((e) => e.visible && e.type === 'LAYER_BLUR')
if (layerBlur) {
r.effectLayerPaint.setImageFilter(r.getCachedBlur(layerBlur.radius / 2))
canvas.saveLayer(r.effectLayerPaint)
}
applyNodeTransforms(r, canvas, node, nodeId, overlays)
renderNodeContent(r, canvas, graph, node, nodeId, overlays)
renderChildren(r, canvas, graph, node, overlays, absX, absY)
if (layerBlur) {
canvas.restore()
@ -215,6 +253,103 @@ export function renderShape(
}
}
function nodeHasRadius(node: SceneNode): boolean {
return (
node.cornerRadius > 0 ||
(node.independentCorners &&
(node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomRightRadius > 0 ||
node.bottomLeftRadius > 0))
)
}
function getCapEntity(r: SkiaRenderer, cap: string | undefined): EmbindEnumEntity {
switch (cap) {
case 'ROUND': return r.ck.StrokeCap.Round
case 'SQUARE': return r.ck.StrokeCap.Square
default: return r.ck.StrokeCap.Butt
}
}
function getJoinEntity(r: SkiaRenderer, join: string | undefined): EmbindEnumEntity {
switch (join) {
case 'ROUND': return r.ck.StrokeJoin.Round
case 'BEVEL': return r.ck.StrokeJoin.Bevel
default: return r.ck.StrokeJoin.Miter
}
}
function drawVectorStrokeGeometry(
r: SkiaRenderer,
canvas: Canvas,
sg: Path[],
sc: Color,
opacity: number
): void {
r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
r.fillPaint.setAlphaf(opacity)
r.fillPaint.setShader(null)
for (const p of sg) canvas.drawPath(p, r.fillPaint)
}
function drawVectorPathStrokes(
r: SkiaRenderer,
canvas: Canvas,
vectorPaths: Path[],
stroke: SceneNode['strokes'][0],
sc: Color
): void {
const strokeOpts = {
width: stroke.weight,
miter_limit: 4,
cap: getCapEntity(r, stroke.cap ?? 'NONE'),
join: getJoinEntity(r, stroke.join ?? 'MITER')
}
r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
r.fillPaint.setAlphaf(stroke.opacity)
r.fillPaint.setShader(null)
for (const vp of vectorPaths) {
const outline = vp.copy().stroke(strokeOpts)
if (outline) {
canvas.drawPath(outline, r.fillPaint)
outline.delete()
}
}
}
function drawRegularStroke(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean,
stroke: SceneNode['strokes'][0],
sc: Color
): void {
r.strokePaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
r.strokePaint.setStrokeWidth(stroke.weight)
r.strokePaint.setAlphaf(stroke.opacity)
if (stroke.cap) {
r.strokePaint.setStrokeCap(getCapEntity(r, stroke.cap))
}
if (stroke.join) {
r.strokePaint.setStrokeJoin(getJoinEntity(r, stroke.join))
}
if (stroke.dashPattern && stroke.dashPattern.length > 0) {
r.strokePaint.setPathEffect(r.ck.PathEffect.MakeDash(stroke.dashPattern, 0))
} else {
r.strokePaint.setPathEffect(null)
}
if (node.independentStrokeWeights && r.isRectangularType(node.type)) {
r.drawIndividualSideStrokes(canvas, node, stroke.align)
} else {
r.drawStrokeWithAlign(canvas, node, rect, hasRadius, stroke.align)
}
}
export function renderShapeUncached(
r: SkiaRenderer,
canvas: Canvas,
@ -222,14 +357,7 @@ export function renderShapeUncached(
graph: SceneGraph
): void {
const rect = r.ck.LTRBRect(0, 0, node.width, node.height)
const hasRadius =
node.cornerRadius > 0 ||
(node.independentCorners &&
(node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomRightRadius > 0 ||
node.bottomLeftRadius > 0))
const hasRadius = nodeHasRadius(node)
r.renderEffects(canvas, node, rect, hasRadius, 'behind')
@ -238,7 +366,6 @@ export function renderShapeUncached(
if (!fill.visible) continue
r.applyFill(fill, node, graph, fi)
r.fillPaint.setAlphaf(fill.opacity)
r.drawNodeFill(canvas, node, rect, hasRadius)
r.fillPaint.setShader(null)
}
@ -251,74 +378,14 @@ export function renderShapeUncached(
const sc = r.resolveStrokeColor(stroke, si, node, graph)
if (sg) {
r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
r.fillPaint.setAlphaf(stroke.opacity)
r.fillPaint.setShader(null)
for (const p of sg) canvas.drawPath(p, r.fillPaint)
drawVectorStrokeGeometry(r, canvas, sg, sc, stroke.opacity)
continue
}
if (vectorPaths) {
const capMap: Record<string, EmbindEnumEntity> = {
NONE: r.ck.StrokeCap.Butt,
ROUND: r.ck.StrokeCap.Round,
SQUARE: r.ck.StrokeCap.Square
}
const joinMap: Record<string, EmbindEnumEntity> = {
MITER: r.ck.StrokeJoin.Miter,
ROUND: r.ck.StrokeJoin.Round,
BEVEL: r.ck.StrokeJoin.Bevel
}
const strokeOpts = {
width: stroke.weight,
miter_limit: 4,
cap: capMap[stroke.cap ?? 'NONE'] ?? r.ck.StrokeCap.Butt,
join: joinMap[stroke.join ?? 'MITER'] ?? r.ck.StrokeJoin.Miter
}
r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
r.fillPaint.setAlphaf(stroke.opacity)
r.fillPaint.setShader(null)
for (const vp of vectorPaths) {
const outline = vp.copy().stroke(strokeOpts)
if (outline) {
canvas.drawPath(outline, r.fillPaint)
outline.delete()
}
}
drawVectorPathStrokes(r, canvas, vectorPaths, stroke, sc)
continue
}
r.strokePaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
r.strokePaint.setStrokeWidth(stroke.weight)
r.strokePaint.setAlphaf(stroke.opacity)
if (stroke.cap) {
const capMap: Record<string, EmbindEnumEntity> = {
NONE: r.ck.StrokeCap.Butt,
ROUND: r.ck.StrokeCap.Round,
SQUARE: r.ck.StrokeCap.Square
}
r.strokePaint.setStrokeCap(capMap[stroke.cap] ?? r.ck.StrokeCap.Butt)
}
if (stroke.join) {
const joinMap: Record<string, EmbindEnumEntity> = {
MITER: r.ck.StrokeJoin.Miter,
ROUND: r.ck.StrokeJoin.Round,
BEVEL: r.ck.StrokeJoin.Bevel
}
r.strokePaint.setStrokeJoin(joinMap[stroke.join] ?? r.ck.StrokeJoin.Miter)
}
if (stroke.dashPattern && stroke.dashPattern.length > 0) {
r.strokePaint.setPathEffect(r.ck.PathEffect.MakeDash(stroke.dashPattern, 0))
} else {
r.strokePaint.setPathEffect(null)
}
if (node.independentStrokeWeights && r.isRectangularType(node.type)) {
r.drawIndividualSideStrokes(canvas, node, stroke.align)
} else {
r.drawStrokeWithAlign(canvas, node, rect, hasRadius, stroke.align)
}
drawRegularStroke(r, canvas, node, rect, hasRadius, stroke, sc)
}
r.renderEffects(canvas, node, rect, hasRadius, 'front')

View file

@ -813,6 +813,48 @@ export class SceneGraph {
)
}
private static containsPoint(
px: number,
py: number,
ax: number,
ay: number,
node: SceneNode
): boolean {
return px >= ax && px <= ax + node.width && py >= ay && py <= ay + node.height
}
private hitTestOpaqueContainer(
px: number,
py: number,
child: SceneNode,
childId: string,
ax: number,
ay: number,
deep: boolean
): SceneNode | null {
if (!SceneGraph.containsPoint(px, py, ax, ay, child)) return null
const childHit = this.hitTestChildren(px, py, childId, ax, ay, deep)
if (childHit) return child
if (SceneGraph.hasVisibleFillOrStroke(child)) return child
return null
}
private hitTestTransparentContainer(
px: number,
py: number,
child: SceneNode,
childId: string,
ax: number,
ay: number,
deep: boolean
): SceneNode | null {
const deepHit = this.hitTestChildren(px, py, childId, ax, ay, deep)
if (deepHit) return deepHit
if (child.type === 'GROUP') return null
if (SceneGraph.containsPoint(px, py, ax, ay, child) && SceneGraph.hasVisibleFillOrStroke(child)) return child
return null
}
private hitTestChildren(
px: number,
py: number,
@ -825,12 +867,9 @@ export class SceneGraph {
if (!parent) return null
if (parent.clipsContent) {
if (px < offsetX || px > offsetX + parent.width || py < offsetY || py > offsetY + parent.height) {
return null
}
if (!SceneGraph.containsPoint(px, py, offsetX, offsetY, parent)) return null
}
// Reverse order = topmost first
for (let i = parent.childIds.length - 1; i >= 0; i--) {
const childId = parent.childIds[i]
const child = this.nodes.get(childId)
@ -840,32 +879,18 @@ export class SceneGraph {
const ay = offsetY + child.y
if (CONTAINER_TYPES.has(child.type)) {
// Components/instances: don't recurse unless in deep mode (double-click).
// Still check fills — empty instances are click-through like frames.
if (SceneGraph.OPAQUE_CONTAINER_TYPES.has(child.type) && !deep) {
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {
const childHit = this.hitTestChildren(px, py, childId, ax, ay, deep)
if (childHit) return child
if (SceneGraph.hasVisibleFillOrStroke(child)) return child
}
const hit = this.hitTestOpaqueContainer(px, py, child, childId, ax, ay, deep)
if (hit) return hit
continue
}
const deepHit = this.hitTestChildren(px, py, childId, ax, ay, deep)
if (deepHit) return deepHit
// Groups are always click-through (only children are hittable).
// Frames/sections without visible fills or strokes are also click-through.
if (child.type === 'GROUP') continue
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {
if (SceneGraph.hasVisibleFillOrStroke(child)) return child
}
const hit = this.hitTestTransparentContainer(px, py, child, childId, ax, ay, deep)
if (hit) return hit
continue
}
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {
return child
}
if (SceneGraph.containsPoint(px, py, ax, ay, child)) return child
}
return null
}

View file

@ -0,0 +1,255 @@
import { colorToHex, colorToHex8 } from './color'
import { round } from './svg-export-paths'
import { svg } from './svg-node'
import type { SVGNode } from './svg-node'
import type { SceneGraph, SceneNode, Fill, Effect } from './scene-graph'
import type { Color } from './types'
export interface SVGExportContext {
defs: SVGNode[]
defIdCounter: number
graph: SceneGraph
}
export function nextDefId(ctx: SVGExportContext, prefix: string): string {
return `${prefix}${ctx.defIdCounter++}`
}
export function formatColor(color: Color, opacity = 1): string {
return colorToHex8(color, opacity)
}
function createGradientDef(
fill: Fill,
node: SceneNode,
ctx: SVGExportContext
): { id: string; node: SVGNode } | null {
const stops = fill.gradientStops
const t = fill.gradientTransform
if (!stops || !t) return null
const stopNodes = stops.map((s) =>
svg('stop', {
offset: `${round(s.position * 100)}%`,
'stop-color': colorToHex(s.color),
'stop-opacity': s.color.a < 1 ? round(s.color.a) : undefined
})
)
const id = nextDefId(ctx, 'grad')
if (fill.type === 'GRADIENT_LINEAR') {
const startX = round(t.m02 * 100)
const startY = round(t.m12 * 100)
const endX = round((t.m00 + t.m02) * 100)
const endY = round((t.m10 + t.m12) * 100)
return {
id,
node: svg(
'linearGradient',
{
id,
x1: `${startX}%`,
y1: `${startY}%`,
x2: `${endX}%`,
y2: `${endY}%`,
gradientUnits: 'objectBoundingBox'
},
...stopNodes
)
}
}
if (fill.type === 'GRADIENT_RADIAL' || fill.type === 'GRADIENT_DIAMOND') {
const cx = round(t.m02 * 100)
const cy = round(t.m12 * 100)
const r = round(Math.sqrt(t.m00 * t.m00 + t.m10 * t.m10) * 100)
return {
id,
node: svg(
'radialGradient',
{ id, cx: `${cx}%`, cy: `${cy}%`, r: `${r}%`, gradientUnits: 'objectBoundingBox' },
...stopNodes
)
}
}
if (fill.type === 'GRADIENT_ANGULAR') {
const cx = round(t.m02 * node.width)
const cy = round(t.m12 * node.height)
const r = Math.max(node.width, node.height)
return {
id,
node: svg(
'radialGradient',
{ id, cx, cy, r, gradientUnits: 'userSpaceOnUse' },
...stopNodes
)
}
}
return null
}
function createImagePattern(
fill: Fill,
node: SceneNode,
ctx: SVGExportContext
): { id: string; node: SVGNode } | null {
if (!fill.imageHash) return null
const data = ctx.graph.images.get(fill.imageHash)
if (!data) return null
const id = nextDefId(ctx, 'img')
const base64 = btoa(String.fromCharCode(...data))
const mime = detectImageMime(data)
return {
id,
node: svg(
'pattern',
{
id,
patternUnits: 'objectBoundingBox',
width: 1,
height: 1
},
svg('image', {
href: `data:${mime};base64,${base64}`,
width: node.width,
height: node.height,
preserveAspectRatio: fill.imageScaleMode === 'FIT' ? 'xMidYMid meet' : 'xMidYMid slice'
})
)
}
}
function detectImageMime(data: Uint8Array): string {
if (data[0] === 0x89 && data[1] === 0x50) return 'image/png'
if (data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg'
if (data[0] === 0x52 && data[1] === 0x49) return 'image/webp'
return 'image/png'
}
export function createFilterDef(effects: Effect[], ctx: SVGExportContext): { id: string; node: SVGNode } | null {
const visible = effects.filter((e) => e.visible)
if (visible.length === 0) return null
const id = nextDefId(ctx, 'fx')
const primitives: SVGNode[] = []
for (const effect of visible) {
if (effect.type === 'DROP_SHADOW') {
const stdDev = round(effect.radius / 2)
primitives.push(
svg('feDropShadow', {
dx: round(effect.offset.x),
dy: round(effect.offset.y),
stdDeviation: stdDev,
'flood-color': colorToHex(effect.color),
'flood-opacity': round(effect.color.a)
})
)
} else if (effect.type === 'INNER_SHADOW') {
const sid = `${id}_is`
const stdDev = round(effect.radius / 2)
primitives.push(
svg('feGaussianBlur', { in: 'SourceAlpha', stdDeviation: stdDev, result: `${sid}_blur` }),
svg('feOffset', {
dx: round(effect.offset.x),
dy: round(effect.offset.y),
result: `${sid}_off`
}),
svg('feComposite', {
in: 'SourceAlpha',
in2: `${sid}_off`,
operator: 'out',
result: `${sid}_inv`
}),
svg('feFlood', {
'flood-color': colorToHex(effect.color),
'flood-opacity': round(effect.color.a)
}),
svg('feComposite', { in2: `${sid}_inv`, operator: 'in', result: `${sid}_shadow` }),
svg('feComposite', {
in: `${sid}_shadow`,
in2: 'SourceGraphic',
operator: 'over'
})
)
} else {
const stdDev = round(effect.radius / 2)
primitives.push(svg('feGaussianBlur', { stdDeviation: stdDev }))
}
}
if (primitives.length === 0) return null
return {
id,
node: svg('filter', { id }, ...primitives)
}
}
export function resolveFill(
fill: Fill,
node: SceneNode,
ctx: SVGExportContext
): string | null {
if (!fill.visible) return null
if (fill.type === 'SOLID') {
return formatColor(fill.color, fill.opacity)
}
if (fill.type.startsWith('GRADIENT')) {
const grad = createGradientDef(fill, node, ctx)
if (grad) {
ctx.defs.push(grad.node)
return `url(#${grad.id})`
}
}
if (fill.type === 'IMAGE') {
const pattern = createImagePattern(fill, node, ctx)
if (pattern) {
ctx.defs.push(pattern.node)
return `url(#${pattern.id})`
}
}
return null
}
export const SVG_STROKE_CAP: Record<string, string> = {
NONE: 'butt',
ROUND: 'round',
SQUARE: 'square'
}
export const SVG_STROKE_JOIN: Record<string, string> = {
MITER: 'miter',
ROUND: 'round',
BEVEL: 'bevel'
}
export const SVG_BLEND_MODE: Record<string, string> = {
NORMAL: 'normal',
DARKEN: 'darken',
MULTIPLY: 'multiply',
COLOR_BURN: 'color-burn',
LIGHTEN: 'lighten',
SCREEN: 'screen',
COLOR_DODGE: 'color-dodge',
OVERLAY: 'overlay',
SOFT_LIGHT: 'soft-light',
HARD_LIGHT: 'hard-light',
DIFFERENCE: 'difference',
EXCLUSION: 'exclusion',
HUE: 'hue',
SATURATION: 'saturation',
COLOR: 'color',
LUMINOSITY: 'luminosity'
}

View file

@ -0,0 +1,212 @@
import type { SceneNode, VectorNetwork, VectorSegment, VectorVertex } from './scene-graph'
const CMD_CLOSE = 0
const CMD_MOVE_TO = 1
const CMD_LINE_TO = 2
const CMD_CUBIC_TO = 4
export function round(n: number, decimals = 2): number {
const factor = 10 ** decimals
return Math.round(n * factor) / factor
}
export function geometryBlobToSVGPath(blob: Uint8Array): string {
if (blob.length === 0) return ''
const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength)
let o = 0
const parts: string[] = []
while (o < blob.length) {
const cmd = blob[o++]
switch (cmd) {
case CMD_CLOSE:
parts.push('Z')
break
case CMD_MOVE_TO: {
const x = round(dv.getFloat32(o, true))
const y = round(dv.getFloat32(o + 4, true))
o += 8
parts.push(`M${x} ${y}`)
break
}
case CMD_LINE_TO: {
const x = round(dv.getFloat32(o, true))
const y = round(dv.getFloat32(o + 4, true))
o += 8
parts.push(`L${x} ${y}`)
break
}
case CMD_CUBIC_TO: {
const x1 = round(dv.getFloat32(o, true))
const y1 = round(dv.getFloat32(o + 4, true))
const x2 = round(dv.getFloat32(o + 8, true))
const y2 = round(dv.getFloat32(o + 12, true))
const x = round(dv.getFloat32(o + 16, true))
const y = round(dv.getFloat32(o + 20, true))
o += 24
parts.push(`C${x1} ${y1} ${x2} ${y2} ${x} ${y}`)
break
}
default:
return parts.join('')
}
}
return parts.join('')
}
function segmentToSVG(seg: VectorSegment, vertices: VectorVertex[], forward: boolean): string {
const start = forward ? vertices[seg.start] : vertices[seg.end]
const end = forward ? vertices[seg.end] : vertices[seg.start]
const ts = forward ? seg.tangentStart : { x: -seg.tangentEnd.x, y: -seg.tangentEnd.y }
const te = forward ? seg.tangentEnd : { x: -seg.tangentStart.x, y: -seg.tangentStart.y }
const isStraight =
Math.abs(ts.x) < 0.001 &&
Math.abs(ts.y) < 0.001 &&
Math.abs(te.x) < 0.001 &&
Math.abs(te.y) < 0.001
if (isStraight) {
return `L${round(end.x)} ${round(end.y)}`
}
const cp1x = round(start.x + ts.x)
const cp1y = round(start.y + ts.y)
const cp2x = round(end.x + te.x)
const cp2y = round(end.y + te.y)
return `C${cp1x} ${cp1y} ${cp2x} ${cp2y} ${round(end.x)} ${round(end.y)}`
}
export function vectorNetworkToSVGPaths(network: VectorNetwork): string[] {
const { vertices, segments, regions } = network
if (regions.length > 0) {
return regions.map((region) => {
const parts: string[] = []
for (const loop of region.loops) {
if (loop.length === 0) continue
const firstSeg = segments[loop[0]]
parts.push(`M${round(vertices[firstSeg.start].x)} ${round(vertices[firstSeg.start].y)}`)
for (const segIdx of loop) {
parts.push(segmentToSVG(segments[segIdx], vertices, true))
}
parts.push('Z')
}
return parts.join('')
})
}
const parts: string[] = []
for (const seg of segments) {
parts.push(`M${round(vertices[seg.start].x)} ${round(vertices[seg.start].y)}`)
parts.push(segmentToSVG(seg, vertices, true))
}
return parts.length > 0 ? [parts.join('')] : []
}
export function makePolygonPoints(node: SceneNode): string {
const cx = node.width / 2
const cy = node.height / 2
const rx = node.width / 2
const ry = node.height / 2
const n = Math.max(3, node.pointCount)
const isStar = node.type === 'STAR'
const innerRatio = isStar ? node.starInnerRadius : 1
const totalPoints = isStar ? n * 2 : n
const angleOffset = -Math.PI / 2
const points: string[] = []
for (let i = 0; i < totalPoints; i++) {
const angle = angleOffset + (2 * Math.PI * i) / totalPoints
const isInner = isStar && i % 2 === 1
const r = isInner ? innerRatio : 1
points.push(`${round(cx + rx * r * Math.cos(angle))},${round(cy + ry * r * Math.sin(angle))}`)
}
return points.join(' ')
}
export function hasRadius(node: SceneNode): boolean {
return (
node.cornerRadius > 0 ||
(node.independentCorners &&
(node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomRightRadius > 0 ||
node.bottomLeftRadius > 0))
)
}
export function roundedRectPath(node: SceneNode): string {
const w = node.width
const h = node.height
let tl: number, tr: number, br: number, bl: number
if (node.independentCorners) {
tl = node.topLeftRadius
tr = node.topRightRadius
br = node.bottomRightRadius
bl = node.bottomLeftRadius
} else {
tl = tr = br = bl = node.cornerRadius
}
tl = Math.min(tl, w / 2, h / 2)
tr = Math.min(tr, w / 2, h / 2)
br = Math.min(br, w / 2, h / 2)
bl = Math.min(bl, w / 2, h / 2)
return [
`M${round(tl)} 0`,
`L${round(w - tr)} 0`,
tr > 0 ? `A${round(tr)} ${round(tr)} 0 0 1 ${round(w)} ${round(tr)}` : '',
`L${round(w)} ${round(h - br)}`,
br > 0 ? `A${round(br)} ${round(br)} 0 0 1 ${round(w - br)} ${round(h)}` : '',
`L${round(bl)} ${round(h)}`,
bl > 0 ? `A${round(bl)} ${round(bl)} 0 0 1 0 ${round(h - bl)}` : '',
`L0 ${round(tl)}`,
tl > 0 ? `A${round(tl)} ${round(tl)} 0 0 1 ${round(tl)} 0` : '',
'Z'
]
.filter(Boolean)
.join('')
}
export function arcPath(node: SceneNode): string {
if (!node.arcData) return ''
const { startingAngle, endingAngle, innerRadius } = node.arcData
const cx = node.width / 2
const cy = node.height / 2
const rx = node.width / 2
const ry = node.height / 2
const fullCircle = Math.abs(endingAngle - startingAngle) >= Math.PI * 2 - 0.001
if (fullCircle && innerRadius <= 0) {
return `M${round(cx - rx)} ${round(cy)}A${round(rx)} ${round(ry)} 0 1 1 ${round(cx + rx)} ${round(cy)}A${round(rx)} ${round(ry)} 0 1 1 ${round(cx - rx)} ${round(cy)}Z`
}
const x1 = round(cx + rx * Math.cos(startingAngle))
const y1 = round(cy + ry * Math.sin(startingAngle))
const x2 = round(cx + rx * Math.cos(endingAngle))
const y2 = round(cy + ry * Math.sin(endingAngle))
const largeArc = Math.abs(endingAngle - startingAngle) > Math.PI ? 1 : 0
const sweep = endingAngle > startingAngle ? 1 : 0
const parts = [`M${x1} ${y1}`, `A${round(rx)} ${round(ry)} 0 ${largeArc} ${sweep} ${x2} ${y2}`]
if (innerRadius > 0) {
const irx = rx * innerRadius
const iry = ry * innerRadius
const ix1 = round(cx + irx * Math.cos(endingAngle))
const iy1 = round(cy + iry * Math.sin(endingAngle))
const ix2 = round(cx + irx * Math.cos(startingAngle))
const iy2 = round(cy + iry * Math.sin(startingAngle))
parts.push(`L${ix1} ${iy1}`)
parts.push(`A${round(irx)} ${round(iry)} 0 ${largeArc} ${sweep === 1 ? 0 : 1} ${ix2} ${iy2}`)
parts.push('Z')
} else {
parts.push(`L${round(cx)} ${round(cy)}Z`)
}
return parts.join('')
}

View file

@ -1,494 +1,78 @@
import { colorToHex, colorToHex8 } from './color'
import { computeContentBounds } from './render-image'
import {
round,
geometryBlobToSVGPath,
vectorNetworkToSVGPaths,
makePolygonPoints,
hasRadius,
roundedRectPath,
arcPath
} from './svg-export-paths'
import {
nextDefId,
formatColor,
createFilterDef,
resolveFill,
SVG_STROKE_CAP,
SVG_STROKE_JOIN,
SVG_BLEND_MODE
} from './svg-export-defs'
export { geometryBlobToSVGPath, vectorNetworkToSVGPaths } from './svg-export-paths'
import { svg, renderSVGNode } from './svg-node'
import type { SVGNode } from './svg-node'
import type {
SceneGraph,
SceneNode,
Fill,
Effect,
VectorNetwork,
VectorSegment,
VectorVertex
} from './scene-graph'
import type { Color } from './types'
const CMD_CLOSE = 0
const CMD_MOVE_TO = 1
const CMD_LINE_TO = 2
const CMD_CUBIC_TO = 4
interface SVGExportContext {
defs: SVGNode[]
defIdCounter: number
graph: SceneGraph
}
function nextDefId(ctx: SVGExportContext, prefix: string): string {
return `${prefix}${ctx.defIdCounter++}`
}
function round(n: number, decimals = 2): number {
const factor = 10 ** decimals
return Math.round(n * factor) / factor
}
function formatColor(color: Color, opacity = 1): string {
return colorToHex8(color, opacity)
}
// --- Path data ---
export function geometryBlobToSVGPath(blob: Uint8Array): string {
if (blob.length === 0) return ''
const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength)
let o = 0
const parts: string[] = []
while (o < blob.length) {
const cmd = blob[o++]
switch (cmd) {
case CMD_CLOSE:
parts.push('Z')
break
case CMD_MOVE_TO: {
const x = round(dv.getFloat32(o, true))
const y = round(dv.getFloat32(o + 4, true))
o += 8
parts.push(`M${x} ${y}`)
break
}
case CMD_LINE_TO: {
const x = round(dv.getFloat32(o, true))
const y = round(dv.getFloat32(o + 4, true))
o += 8
parts.push(`L${x} ${y}`)
break
}
case CMD_CUBIC_TO: {
const x1 = round(dv.getFloat32(o, true))
const y1 = round(dv.getFloat32(o + 4, true))
const x2 = round(dv.getFloat32(o + 8, true))
const y2 = round(dv.getFloat32(o + 12, true))
const x = round(dv.getFloat32(o + 16, true))
const y = round(dv.getFloat32(o + 20, true))
o += 24
parts.push(`C${x1} ${y1} ${x2} ${y2} ${x} ${y}`)
break
}
default:
return parts.join('')
}
}
return parts.join('')
}
function segmentToSVG(seg: VectorSegment, vertices: VectorVertex[], forward: boolean): string {
const start = forward ? vertices[seg.start] : vertices[seg.end]
const end = forward ? vertices[seg.end] : vertices[seg.start]
const ts = forward ? seg.tangentStart : { x: -seg.tangentEnd.x, y: -seg.tangentEnd.y }
const te = forward ? seg.tangentEnd : { x: -seg.tangentStart.x, y: -seg.tangentStart.y }
const isStraight =
Math.abs(ts.x) < 0.001 &&
Math.abs(ts.y) < 0.001 &&
Math.abs(te.x) < 0.001 &&
Math.abs(te.y) < 0.001
if (isStraight) {
return `L${round(end.x)} ${round(end.y)}`
}
const cp1x = round(start.x + ts.x)
const cp1y = round(start.y + ts.y)
const cp2x = round(end.x + te.x)
const cp2y = round(end.y + te.y)
return `C${cp1x} ${cp1y} ${cp2x} ${cp2y} ${round(end.x)} ${round(end.y)}`
}
export function vectorNetworkToSVGPaths(network: VectorNetwork): string[] {
const { vertices, segments, regions } = network
if (regions.length > 0) {
return regions.map((region) => {
const parts: string[] = []
for (const loop of region.loops) {
if (loop.length === 0) continue
const firstSeg = segments[loop[0]]
parts.push(`M${round(vertices[firstSeg.start].x)} ${round(vertices[firstSeg.start].y)}`)
for (const segIdx of loop) {
parts.push(segmentToSVG(segments[segIdx], vertices, true))
}
parts.push('Z')
}
return parts.join('')
})
}
const parts: string[] = []
for (const seg of segments) {
parts.push(`M${round(vertices[seg.start].x)} ${round(vertices[seg.start].y)}`)
parts.push(segmentToSVG(seg, vertices, true))
}
return parts.length > 0 ? [parts.join('')] : []
}
// --- Gradients ---
function createGradientDef(
fill: Fill,
node: SceneNode,
ctx: SVGExportContext
): { id: string; node: SVGNode } | null {
const stops = fill.gradientStops
const t = fill.gradientTransform
if (!stops || !t) return null
const stopNodes = stops.map((s) =>
svg('stop', {
offset: `${round(s.position * 100)}%`,
'stop-color': colorToHex(s.color),
'stop-opacity': s.color.a < 1 ? round(s.color.a) : undefined
})
)
const id = nextDefId(ctx, 'grad')
if (fill.type === 'GRADIENT_LINEAR') {
const startX = round(t.m02 * 100)
const startY = round(t.m12 * 100)
const endX = round((t.m00 + t.m02) * 100)
const endY = round((t.m10 + t.m12) * 100)
return {
id,
node: svg(
'linearGradient',
{
id,
x1: `${startX}%`,
y1: `${startY}%`,
x2: `${endX}%`,
y2: `${endY}%`,
gradientUnits: 'objectBoundingBox'
},
...stopNodes
)
}
}
if (fill.type === 'GRADIENT_RADIAL' || fill.type === 'GRADIENT_DIAMOND') {
const cx = round(t.m02 * 100)
const cy = round(t.m12 * 100)
const r = round(Math.sqrt(t.m00 * t.m00 + t.m10 * t.m10) * 100)
return {
id,
node: svg(
'radialGradient',
{ id, cx: `${cx}%`, cy: `${cy}%`, r: `${r}%`, gradientUnits: 'objectBoundingBox' },
...stopNodes
)
}
}
if (fill.type === 'GRADIENT_ANGULAR') {
const cx = round(t.m02 * node.width)
const cy = round(t.m12 * node.height)
const r = Math.max(node.width, node.height)
return {
id,
node: svg(
'radialGradient',
{ id, cx, cy, r, gradientUnits: 'userSpaceOnUse' },
...stopNodes
)
}
}
return null
}
// --- Image fills ---
function createImagePattern(
fill: Fill,
node: SceneNode,
ctx: SVGExportContext
): { id: string; node: SVGNode } | null {
if (!fill.imageHash) return null
const data = ctx.graph.images.get(fill.imageHash)
if (!data) return null
const id = nextDefId(ctx, 'img')
const base64 = btoa(String.fromCharCode(...data))
const mime = detectImageMime(data)
return {
id,
node: svg(
'pattern',
{
id,
patternUnits: 'objectBoundingBox',
width: 1,
height: 1
},
svg('image', {
href: `data:${mime};base64,${base64}`,
width: node.width,
height: node.height,
preserveAspectRatio: fill.imageScaleMode === 'FIT' ? 'xMidYMid meet' : 'xMidYMid slice'
})
)
}
}
function detectImageMime(data: Uint8Array): string {
if (data[0] === 0x89 && data[1] === 0x50) return 'image/png'
if (data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg'
if (data[0] === 0x52 && data[1] === 0x49) return 'image/webp'
return 'image/png'
}
// --- Effects → SVG filters ---
function createFilterDef(effects: Effect[], ctx: SVGExportContext): { id: string; node: SVGNode } | null {
const visible = effects.filter((e) => e.visible)
if (visible.length === 0) return null
const id = nextDefId(ctx, 'fx')
const primitives: SVGNode[] = []
for (const effect of visible) {
if (effect.type === 'DROP_SHADOW') {
const stdDev = round(effect.radius / 2)
primitives.push(
svg('feDropShadow', {
dx: round(effect.offset.x),
dy: round(effect.offset.y),
stdDeviation: stdDev,
'flood-color': colorToHex(effect.color),
'flood-opacity': round(effect.color.a)
})
)
} else if (effect.type === 'INNER_SHADOW') {
const sid = `${id}_is`
const stdDev = round(effect.radius / 2)
primitives.push(
svg('feGaussianBlur', { in: 'SourceAlpha', stdDeviation: stdDev, result: `${sid}_blur` }),
svg('feOffset', {
dx: round(effect.offset.x),
dy: round(effect.offset.y),
result: `${sid}_off`
}),
svg('feComposite', {
in: 'SourceAlpha',
in2: `${sid}_off`,
operator: 'out',
result: `${sid}_inv`
}),
svg('feFlood', {
'flood-color': colorToHex(effect.color),
'flood-opacity': round(effect.color.a)
}),
svg('feComposite', { in2: `${sid}_inv`, operator: 'in', result: `${sid}_shadow` }),
svg('feComposite', {
in: `${sid}_shadow`,
in2: 'SourceGraphic',
operator: 'over'
})
)
} else {
const stdDev = round(effect.radius / 2)
primitives.push(svg('feGaussianBlur', { stdDeviation: stdDev }))
}
}
if (primitives.length === 0) return null
return {
id,
node: svg('filter', { id }, ...primitives)
}
}
// --- Fill resolution ---
function resolveFill(
fill: Fill,
node: SceneNode,
ctx: SVGExportContext
): string | null {
if (!fill.visible) return null
if (fill.type === 'SOLID') {
return formatColor(fill.color, fill.opacity)
}
if (fill.type.startsWith('GRADIENT')) {
const grad = createGradientDef(fill, node, ctx)
if (grad) {
ctx.defs.push(grad.node)
return `url(#${grad.id})`
}
}
if (fill.type === 'IMAGE') {
const pattern = createImagePattern(fill, node, ctx)
if (pattern) {
ctx.defs.push(pattern.node)
return `url(#${pattern.id})`
}
}
return null
}
// --- Stroke helpers ---
const SVG_STROKE_CAP: Record<string, string> = {
NONE: 'butt',
ROUND: 'round',
SQUARE: 'square'
}
const SVG_STROKE_JOIN: Record<string, string> = {
MITER: 'miter',
ROUND: 'round',
BEVEL: 'bevel'
}
const SVG_BLEND_MODE: Record<string, string> = {
NORMAL: 'normal',
DARKEN: 'darken',
MULTIPLY: 'multiply',
COLOR_BURN: 'color-burn',
LIGHTEN: 'lighten',
SCREEN: 'screen',
COLOR_DODGE: 'color-dodge',
OVERLAY: 'overlay',
SOFT_LIGHT: 'soft-light',
HARD_LIGHT: 'hard-light',
DIFFERENCE: 'difference',
EXCLUSION: 'exclusion',
HUE: 'hue',
SATURATION: 'saturation',
COLOR: 'color',
LUMINOSITY: 'luminosity'
}
// --- Shape builders ---
function makePolygonPoints(node: SceneNode): string {
const cx = node.width / 2
const cy = node.height / 2
const rx = node.width / 2
const ry = node.height / 2
const n = Math.max(3, node.pointCount)
const isStar = node.type === 'STAR'
const innerRatio = isStar ? node.starInnerRadius : 1
const totalPoints = isStar ? n * 2 : n
const angleOffset = -Math.PI / 2
const points: string[] = []
for (let i = 0; i < totalPoints; i++) {
const angle = angleOffset + (2 * Math.PI * i) / totalPoints
const isInner = isStar && i % 2 === 1
const r = isInner ? innerRatio : 1
points.push(`${round(cx + rx * r * Math.cos(angle))},${round(cy + ry * r * Math.sin(angle))}`)
}
return points.join(' ')
}
function hasRadius(node: SceneNode): boolean {
return (
node.cornerRadius > 0 ||
(node.independentCorners &&
(node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomRightRadius > 0 ||
node.bottomLeftRadius > 0))
)
}
function roundedRectPath(node: SceneNode): string {
const w = node.width
const h = node.height
let tl: number, tr: number, br: number, bl: number
if (node.independentCorners) {
tl = node.topLeftRadius
tr = node.topRightRadius
br = node.bottomRightRadius
bl = node.bottomLeftRadius
} else {
tl = tr = br = bl = node.cornerRadius
}
tl = Math.min(tl, w / 2, h / 2)
tr = Math.min(tr, w / 2, h / 2)
br = Math.min(br, w / 2, h / 2)
bl = Math.min(bl, w / 2, h / 2)
return [
`M${round(tl)} 0`,
`L${round(w - tr)} 0`,
tr > 0 ? `A${round(tr)} ${round(tr)} 0 0 1 ${round(w)} ${round(tr)}` : '',
`L${round(w)} ${round(h - br)}`,
br > 0 ? `A${round(br)} ${round(br)} 0 0 1 ${round(w - br)} ${round(h)}` : '',
`L${round(bl)} ${round(h)}`,
bl > 0 ? `A${round(bl)} ${round(bl)} 0 0 1 0 ${round(h - bl)}` : '',
`L0 ${round(tl)}`,
tl > 0 ? `A${round(tl)} ${round(tl)} 0 0 1 ${round(tl)} 0` : '',
'Z'
]
.filter(Boolean)
.join('')
}
function arcPath(node: SceneNode): string {
if (!node.arcData) return ''
const { startingAngle, endingAngle, innerRadius } = node.arcData
const cx = node.width / 2
const cy = node.height / 2
const rx = node.width / 2
const ry = node.height / 2
const fullCircle = Math.abs(endingAngle - startingAngle) >= Math.PI * 2 - 0.001
if (fullCircle && innerRadius <= 0) {
return `M${round(cx - rx)} ${round(cy)}A${round(rx)} ${round(ry)} 0 1 1 ${round(cx + rx)} ${round(cy)}A${round(rx)} ${round(ry)} 0 1 1 ${round(cx - rx)} ${round(cy)}Z`
}
const x1 = round(cx + rx * Math.cos(startingAngle))
const y1 = round(cy + ry * Math.sin(startingAngle))
const x2 = round(cx + rx * Math.cos(endingAngle))
const y2 = round(cy + ry * Math.sin(endingAngle))
const largeArc = Math.abs(endingAngle - startingAngle) > Math.PI ? 1 : 0
const sweep = endingAngle > startingAngle ? 1 : 0
const parts = [`M${x1} ${y1}`, `A${round(rx)} ${round(ry)} 0 ${largeArc} ${sweep} ${x2} ${y2}`]
if (innerRadius > 0) {
const irx = rx * innerRadius
const iry = ry * innerRadius
const ix1 = round(cx + irx * Math.cos(endingAngle))
const iy1 = round(cy + iry * Math.sin(endingAngle))
const ix2 = round(cx + irx * Math.cos(startingAngle))
const iy2 = round(cy + iry * Math.sin(startingAngle))
parts.push(`L${ix1} ${iy1}`)
parts.push(`A${round(irx)} ${round(iry)} 0 ${largeArc} ${sweep === 1 ? 0 : 1} ${ix2} ${iy2}`)
parts.push('Z')
} else {
parts.push(`L${round(cx)} ${round(cy)}Z`)
}
return parts.join('')
}
import type { SceneGraph, SceneNode, Fill, Stroke, CharacterStyleOverride } from './scene-graph'
import type { SVGExportContext } from './svg-export-defs'
// --- Node rendering ---
function vectorShapeElements(
node: SceneNode,
common: Record<string, string | number | undefined>,
strokeAttrs: Record<string, string | number | undefined>
): SVGNode[] {
const elements: SVGNode[] = []
if (node.fillGeometry.length > 0) {
for (const geo of node.fillGeometry) {
const d = geometryBlobToSVGPath(geo.commandsBlob)
if (d) {
elements.push(
svg('path', {
d,
'fill-rule': geo.windingRule === 'EVENODD' ? 'evenodd' : undefined,
...common
})
)
}
}
} else if (node.vectorNetwork) {
const paths = vectorNetworkToSVGPaths(node.vectorNetwork)
for (const d of paths) {
elements.push(svg('path', { d, ...common }))
}
}
if (node.strokeGeometry.length > 0 && strokeAttrs.stroke && strokeAttrs.stroke !== 'none') {
for (const geo of node.strokeGeometry) {
const d = geometryBlobToSVGPath(geo.commandsBlob)
if (d) {
elements.push(
svg('path', {
d,
fill: strokeAttrs.stroke as string,
'fill-opacity': strokeAttrs['stroke-opacity'],
stroke: 'none'
})
)
}
}
}
return elements.length > 0
? elements
: [svg('rect', { width: round(node.width), height: round(node.height), ...common })]
}
function nodeShapeElements(
node: SceneNode,
fillAttr: string | null,
@ -531,46 +115,8 @@ function nodeShapeElements(
case 'POLYGON':
return [svg('polygon', { points: makePolygonPoints(node), ...common })]
case 'VECTOR': {
const elements: SVGNode[] = []
if (node.fillGeometry.length > 0) {
for (const geo of node.fillGeometry) {
const d = geometryBlobToSVGPath(geo.commandsBlob)
if (d) {
elements.push(
svg('path', {
d,
'fill-rule': geo.windingRule === 'EVENODD' ? 'evenodd' : undefined,
...common
})
)
}
}
} else if (node.vectorNetwork) {
const paths = vectorNetworkToSVGPaths(node.vectorNetwork)
for (const d of paths) {
elements.push(svg('path', { d, ...common }))
}
}
if (node.strokeGeometry.length > 0 && strokeAttrs.stroke && strokeAttrs.stroke !== 'none') {
for (const geo of node.strokeGeometry) {
const d = geometryBlobToSVGPath(geo.commandsBlob)
if (d) {
elements.push(
svg('path', {
d,
fill: strokeAttrs.stroke as string,
'fill-opacity': strokeAttrs['stroke-opacity'],
stroke: 'none'
})
)
}
}
}
return elements.length > 0
? elements
: [svg('rect', { width: round(node.width), height: round(node.height), ...common })]
}
case 'VECTOR':
return vectorShapeElements(node, common, strokeAttrs)
default: {
if (hasRadius(node)) {
@ -592,6 +138,18 @@ function nodeShapeElements(
}
}
function styleOverrideToTspanAttrs(style: CharacterStyleOverride): Record<string, string | number | undefined> {
const attrs: Record<string, string | number | undefined> = {}
if (style.fontFamily) attrs['font-family'] = style.fontFamily
if (style.fontSize) attrs['font-size'] = style.fontSize
if (style.fontWeight) attrs['font-weight'] = style.fontWeight
if (style.italic) attrs['font-style'] = 'italic'
if (style.letterSpacing) attrs['letter-spacing'] = round(style.letterSpacing)
if (style.textDecoration === 'UNDERLINE') attrs['text-decoration'] = 'underline'
if (style.textDecoration === 'STRIKETHROUGH') attrs['text-decoration'] = 'line-through'
return attrs
}
function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode {
const attrs: Record<string, string | number | undefined> = {
'font-family': node.fontFamily || undefined,
@ -628,21 +186,7 @@ function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode {
for (const run of node.styleRuns) {
const text = node.text.slice(pos, pos + run.length)
pos += run.length
const spanAttrs: Record<string, string | number | undefined> = {}
if (run.style.fontFamily) spanAttrs['font-family'] = run.style.fontFamily
if (run.style.fontSize) spanAttrs['font-size'] = run.style.fontSize
if (run.style.fontWeight) spanAttrs['font-weight'] = run.style.fontWeight
if (run.style.italic) spanAttrs['font-style'] = 'italic'
if (run.style.letterSpacing) spanAttrs['letter-spacing'] = round(run.style.letterSpacing)
if (run.style.textDecoration === 'UNDERLINE') spanAttrs['text-decoration'] = 'underline'
if (run.style.textDecoration === 'STRIKETHROUGH')
spanAttrs['text-decoration'] = 'line-through'
if (Object.keys(spanAttrs).length > 0) {
spans.push(svg('tspan', spanAttrs, text))
} else {
spans.push(svg('tspan', {}, text))
}
spans.push(svg('tspan', styleOverrideToTspanAttrs(run.style), text))
}
return svg('text', { x, y, ...attrs }, ...spans)
@ -653,13 +197,7 @@ function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode {
// --- Main recursive renderer ---
function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null {
if (!node.visible) return null
const children: (SVGNode | null)[] = []
const groupAttrs: Record<string, string | number | undefined> = {}
// Transform
function buildTransformAttr(node: SceneNode): string | undefined {
const transforms: string[] = []
if (node.x !== 0 || node.y !== 0) transforms.push(`translate(${round(node.x)}, ${round(node.y)})`)
if (node.rotation !== 0) {
@ -674,25 +212,31 @@ function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null {
const sy = node.flipY ? -1 : 1
transforms.push(`translate(${round(tx)}, ${round(ty)}) scale(${sx}, ${sy})`)
}
if (transforms.length > 0) groupAttrs.transform = transforms.join(' ')
return transforms.length > 0 ? transforms.join(' ') : undefined
}
// Opacity
if (node.opacity < 1) groupAttrs.opacity = round(node.opacity)
function buildGroupAttrs(
node: SceneNode,
ctx: SVGExportContext
): { attrs: Record<string, string | number | undefined>; clipId?: string } {
const attrs: Record<string, string | number | undefined> = {}
const transform = buildTransformAttr(node)
if (transform) attrs.transform = transform
if (node.opacity < 1) attrs.opacity = round(node.opacity)
// Blend mode
const blend = SVG_BLEND_MODE[node.blendMode]
if (blend && blend !== 'normal' && node.blendMode !== 'PASS_THROUGH') {
groupAttrs.style = `mix-blend-mode: ${blend}`
attrs.style = `mix-blend-mode: ${blend}`
}
// Effects → filter
const filterDef = createFilterDef(node.effects, ctx)
if (filterDef) {
ctx.defs.push(filterDef.node)
groupAttrs.filter = `url(#${filterDef.id})`
attrs.filter = `url(#${filterDef.id})`
}
// Clip path for clipsContent
let clipId: string | undefined
if (node.clipsContent && node.childIds.length > 0) {
clipId = nextDefId(ctx, 'clip')
@ -705,43 +249,43 @@ function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null {
)
}
// Text node
if (node.type === 'TEXT') {
const firstFill = node.fills.find((f) => f.visible)
const fillAttr = firstFill ? resolveFill(firstFill, node, ctx) : null
const textEl = renderTextNode(node, fillAttr)
return svg('g', groupAttrs, textEl)
return { attrs, clipId }
}
function buildSVGStrokeAttrs(visibleStrokes: Stroke[]): Record<string, string | number | undefined> {
if (visibleStrokes.length === 0) return {}
const stroke = visibleStrokes[0]
const attrs: Record<string, string | number | undefined> = {
stroke: formatColor(stroke.color, 1),
'stroke-width': round(stroke.weight)
}
// Resolve fills and strokes
const visibleFills = node.fills.filter((f) => f.visible)
const visibleStrokes = node.strokes.filter((s) => s.visible)
const fillAttr = visibleFills.length > 0 ? resolveFill(visibleFills[0], node, ctx) : null
const strokeAttrs: Record<string, string | number | undefined> = {}
if (visibleStrokes.length > 0) {
const stroke = visibleStrokes[0]
strokeAttrs.stroke = formatColor(stroke.color, 1)
strokeAttrs['stroke-width'] = round(stroke.weight)
if (stroke.opacity < 1) strokeAttrs['stroke-opacity'] = round(stroke.opacity)
if (stroke.cap && stroke.cap !== 'NONE') {
strokeAttrs['stroke-linecap'] = SVG_STROKE_CAP[stroke.cap] ?? 'butt'
}
if (stroke.join && stroke.join !== 'MITER') {
strokeAttrs['stroke-linejoin'] = SVG_STROKE_JOIN[stroke.join] ?? 'miter'
}
if (stroke.dashPattern && stroke.dashPattern.length > 0) {
strokeAttrs['stroke-dasharray'] = stroke.dashPattern.map((n) => round(n)).join(' ')
}
if (stroke.opacity < 1) attrs['stroke-opacity'] = round(stroke.opacity)
if (stroke.cap && stroke.cap !== 'NONE') {
attrs['stroke-linecap'] = SVG_STROKE_CAP[stroke.cap] ?? 'butt'
}
if (stroke.join && stroke.join !== 'MITER') {
attrs['stroke-linejoin'] = SVG_STROKE_JOIN[stroke.join] ?? 'miter'
}
if (stroke.dashPattern && stroke.dashPattern.length > 0) {
attrs['stroke-dasharray'] = stroke.dashPattern.map((n) => round(n)).join(' ')
}
return attrs
}
// Multiple fills need stacking
function buildShapeChildren(
node: SceneNode,
visibleFills: Fill[],
fillAttr: string | null,
strokeAttrs: Record<string, string | number | undefined>,
visibleStrokeCount: number,
ctx: SVGExportContext
): SVGNode[] {
if (visibleFills.length > 1) {
const elements: SVGNode[] = []
for (const fill of visibleFills) {
const ref = resolveFill(fill, node, ctx)
if (ref) {
children.push(
elements.push(
...nodeShapeElements(
node,
ref,
@ -750,14 +294,38 @@ function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null {
)
}
}
} else {
const hasFillOrStroke = fillAttr || visibleStrokes.length > 0
if (hasFillOrStroke && !isGroupLike(node)) {
children.push(...nodeShapeElements(node, fillAttr, strokeAttrs))
}
return elements
}
// Render children
const hasFillOrStroke = fillAttr || visibleStrokeCount > 0
if (hasFillOrStroke && !isGroupLike(node)) {
return nodeShapeElements(node, fillAttr, strokeAttrs)
}
return []
}
function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null {
if (!node.visible) return null
const { attrs: groupAttrs, clipId } = buildGroupAttrs(node, ctx)
if (node.type === 'TEXT') {
const firstFill = node.fills.find((f) => f.visible)
const fillAttr = firstFill ? resolveFill(firstFill, node, ctx) : null
const textEl = renderTextNode(node, fillAttr)
return svg('g', groupAttrs, textEl)
}
const visibleFills = node.fills.filter((f) => f.visible)
const visibleStrokes = node.strokes.filter((s) => s.visible)
const fillAttr = visibleFills.length > 0 ? resolveFill(visibleFills[0], node, ctx) : null
const strokeAttrs = buildSVGStrokeAttrs(visibleStrokes)
const children: (SVGNode | null)[] = buildShapeChildren(
node, visibleFills, fillAttr, strokeAttrs, visibleStrokes.length, ctx
)
const childNodes = ctx.graph.getChildren(node.id)
const childContent: SVGNode[] = []
for (const child of childNodes) {
@ -818,7 +386,7 @@ export function renderNodesToSVG(
for (const id of nodeIds) {
const node = graph.getNode(id)
if (!node || !node.visible) continue
if (!node?.visible) continue
const abs = graph.getAbsolutePosition(id)
const offsetX = abs.x - minX

View file

@ -32,12 +32,7 @@ function trackColor(
// ─── Diff helpers ─────────────────────────────────────────────
function serializeNodeProps(raw: SceneNode): string {
const lines: string[] = []
lines.push(`type: ${raw.type}`)
lines.push(`size: ${raw.width} ${raw.height}`)
lines.push(`pos: ${raw.x} ${raw.y}`)
function serializePaintProps(raw: SceneNode, lines: string[]): void {
const solidFill = raw.fills.find((f) => f.type === 'SOLID' && f.visible)
if (solidFill) lines.push(`fill: ${colorToHex(solidFill.color)}`)
@ -46,9 +41,9 @@ function serializeNodeProps(raw: SceneNode): string {
lines.push(`stroke: ${colorToHex(solidStroke.color)}`)
if (solidStroke.weight) lines.push(`strokeWeight: ${solidStroke.weight}`)
}
}
if (raw.opacity !== 1) lines.push(`opacity: ${Math.round(raw.opacity * 100) / 100}`)
function serializeCornerRadii(raw: SceneNode, lines: string[]): void {
const tl = raw.topLeftRadius
const tr = raw.topRightRadius
const br = raw.bottomRightRadius
@ -60,11 +55,9 @@ function serializeNodeProps(raw: SceneNode): string {
lines.push(`radii: ${tl} ${tr} ${br} ${bl}`)
}
}
}
if (raw.blendMode !== 'NORMAL') lines.push(`blendMode: ${raw.blendMode}`)
if (raw.rotation !== 0) lines.push(`rotation: ${Math.round(raw.rotation * 100) / 100}`)
if (raw.clipsContent) lines.push(`clipsContent: true`)
function serializeEffects(raw: SceneNode, lines: string[]): void {
for (const effect of raw.effects) {
const parts: string[] = [effect.type]
parts.push(`r=${effect.radius}`)
@ -73,13 +66,34 @@ function serializeNodeProps(raw: SceneNode): string {
parts.push(`s=${effect.spread}`)
lines.push(`effect: ${parts.join(' ')}`)
}
}
if (raw.type === 'TEXT') {
if (raw.text) lines.push(`text: ${JSON.stringify(raw.text)}`)
if (raw.fontSize) lines.push(`fontSize: ${raw.fontSize}`)
if (raw.fontFamily) lines.push(`fontFamily: ${raw.fontFamily}`)
if (raw.fontWeight) lines.push(`fontWeight: ${raw.fontWeight}`)
}
function serializeTextProps(raw: SceneNode, lines: string[]): void {
if (raw.type !== 'TEXT') return
if (raw.text) lines.push(`text: ${JSON.stringify(raw.text)}`)
if (raw.fontSize) lines.push(`fontSize: ${raw.fontSize}`)
if (raw.fontFamily) lines.push(`fontFamily: ${raw.fontFamily}`)
if (raw.fontWeight) lines.push(`fontWeight: ${raw.fontWeight}`)
}
function serializeNodeProps(raw: SceneNode): string {
const lines: string[] = []
lines.push(`type: ${raw.type}`)
lines.push(`size: ${raw.width} ${raw.height}`)
lines.push(`pos: ${raw.x} ${raw.y}`)
serializePaintProps(raw, lines)
if (raw.opacity !== 1) lines.push(`opacity: ${Math.round(raw.opacity * 100) / 100}`)
serializeCornerRadii(raw, lines)
if (raw.blendMode !== 'NORMAL') lines.push(`blendMode: ${raw.blendMode}`)
if (raw.rotation !== 0) lines.push(`rotation: ${Math.round(raw.rotation * 100) / 100}`)
if (raw.clipsContent) lines.push(`clipsContent: true`)
serializeEffects(raw, lines)
serializeTextProps(raw, lines)
if (!raw.visible) lines.push(`visible: false`)
if (raw.locked) lines.push(`locked: true`)