@zseven-w/pen-figma 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/package.json +28 -0
- package/src/fig-parser.ts +282 -0
- package/src/figma-clipboard.ts +415 -0
- package/src/figma-color-utils.ts +28 -0
- package/src/figma-effect-mapper.ts +57 -0
- package/src/figma-fill-mapper.ts +100 -0
- package/src/figma-image-resolver.ts +113 -0
- package/src/figma-layout-mapper.ts +145 -0
- package/src/figma-node-converters.ts +1101 -0
- package/src/figma-node-mapper.ts +325 -0
- package/src/figma-stroke-mapper.ts +65 -0
- package/src/figma-text-mapper.ts +217 -0
- package/src/figma-tree-builder.ts +137 -0
- package/src/figma-types.ts +275 -0
- package/src/figma-vector-decoder.ts +321 -0
- package/src/index.ts +26 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { PenNode } from '@zseven-w/pen-types'
|
|
2
|
+
import type { ImageFill } from '@zseven-w/pen-types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve __blob:N and __hash:<hex> references in the PenNode tree to data URLs
|
|
6
|
+
* using extracted image blobs and ZIP image files from the .fig file.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveImageBlobs(
|
|
9
|
+
nodes: PenNode[],
|
|
10
|
+
imageBlobs: Map<number, Uint8Array>,
|
|
11
|
+
imageFiles?: Map<string, Uint8Array>,
|
|
12
|
+
): number {
|
|
13
|
+
if (imageBlobs.size === 0 && (!imageFiles || imageFiles.size === 0)) return 0
|
|
14
|
+
|
|
15
|
+
// Convert blobs to data URLs
|
|
16
|
+
const dataUrls = new Map<number, string>()
|
|
17
|
+
for (const [index, bytes] of imageBlobs) {
|
|
18
|
+
dataUrls.set(index, blobToDataUrl(bytes))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Convert hash-based image files to data URLs
|
|
22
|
+
const hashDataUrls = new Map<string, string>()
|
|
23
|
+
if (imageFiles) {
|
|
24
|
+
for (const [hash, bytes] of imageFiles) {
|
|
25
|
+
hashDataUrls.set(hash, blobToDataUrl(bytes))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let resolved = 0
|
|
30
|
+
for (const node of nodes) {
|
|
31
|
+
resolved += patchNode(node, dataUrls, hashDataUrls)
|
|
32
|
+
}
|
|
33
|
+
return resolved
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function blobToDataUrl(bytes: Uint8Array): string {
|
|
37
|
+
// Detect MIME type from magic bytes
|
|
38
|
+
let mime = 'image/png'
|
|
39
|
+
if (bytes[0] === 0xFF && bytes[1] === 0xD8) {
|
|
40
|
+
mime = 'image/jpeg'
|
|
41
|
+
} else if (bytes[0] === 0x47 && bytes[1] === 0x49) {
|
|
42
|
+
mime = 'image/gif'
|
|
43
|
+
} else if (bytes[0] === 0x52 && bytes[1] === 0x49) {
|
|
44
|
+
mime = 'image/webp'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Convert to base64
|
|
48
|
+
let binary = ''
|
|
49
|
+
const len = bytes.byteLength
|
|
50
|
+
for (let i = 0; i < len; i++) {
|
|
51
|
+
binary += String.fromCharCode(bytes[i])
|
|
52
|
+
}
|
|
53
|
+
const base64 = btoa(binary)
|
|
54
|
+
return `data:${mime};base64,${base64}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveRef(
|
|
58
|
+
src: string,
|
|
59
|
+
dataUrls: Map<number, string>,
|
|
60
|
+
hashDataUrls: Map<string, string>,
|
|
61
|
+
): string | null {
|
|
62
|
+
if (src.startsWith('__blob:')) {
|
|
63
|
+
const index = parseInt(src.slice(7), 10)
|
|
64
|
+
return dataUrls.get(index) ?? null
|
|
65
|
+
}
|
|
66
|
+
if (src.startsWith('__hash:')) {
|
|
67
|
+
const hash = src.slice(7)
|
|
68
|
+
return hashDataUrls.get(hash) ?? null
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function patchNode(
|
|
74
|
+
node: PenNode,
|
|
75
|
+
dataUrls: Map<number, string>,
|
|
76
|
+
hashDataUrls: Map<string, string>,
|
|
77
|
+
): number {
|
|
78
|
+
let resolved = 0
|
|
79
|
+
|
|
80
|
+
// Patch ImageNode src
|
|
81
|
+
if (node.type === 'image' && node.src && (node.src.startsWith('__blob:') || node.src.startsWith('__hash:'))) {
|
|
82
|
+
const url = resolveRef(node.src, dataUrls, hashDataUrls)
|
|
83
|
+
if (url) {
|
|
84
|
+
node.src = url
|
|
85
|
+
resolved++
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Patch image fills
|
|
90
|
+
if ('fill' in node && Array.isArray(node.fill)) {
|
|
91
|
+
for (const fill of node.fill) {
|
|
92
|
+
if (fill.type === 'image') {
|
|
93
|
+
const imgFill = fill as ImageFill
|
|
94
|
+
if (imgFill.url && (imgFill.url.startsWith('__blob:') || imgFill.url.startsWith('__hash:'))) {
|
|
95
|
+
const url = resolveRef(imgFill.url, dataUrls, hashDataUrls)
|
|
96
|
+
if (url) {
|
|
97
|
+
imgFill.url = url
|
|
98
|
+
resolved++
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Recurse into children
|
|
106
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
107
|
+
for (const child of node.children) {
|
|
108
|
+
resolved += patchNode(child, dataUrls, hashDataUrls)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return resolved
|
|
113
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { FigmaNodeChange } from './figma-types'
|
|
2
|
+
import type { ContainerProps, SizingBehavior } from '@zseven-w/pen-types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Map Figma stack (auto-layout) properties to PenNode ContainerProps.
|
|
6
|
+
*/
|
|
7
|
+
export function mapFigmaLayout(
|
|
8
|
+
node: FigmaNodeChange
|
|
9
|
+
): Pick<
|
|
10
|
+
ContainerProps,
|
|
11
|
+
'layout' | 'gap' | 'padding' | 'justifyContent' | 'alignItems' | 'clipContent'
|
|
12
|
+
> {
|
|
13
|
+
const result: Pick<
|
|
14
|
+
ContainerProps,
|
|
15
|
+
'layout' | 'gap' | 'padding' | 'justifyContent' | 'alignItems' | 'clipContent'
|
|
16
|
+
> = {}
|
|
17
|
+
|
|
18
|
+
if (node.stackMode && node.stackMode !== 'NONE') {
|
|
19
|
+
result.layout = node.stackMode === 'HORIZONTAL' ? 'horizontal' : 'vertical'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (node.stackPrimaryAlignItems) {
|
|
23
|
+
result.justifyContent = mapJustifyContent(node.stackPrimaryAlignItems)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Set gap from stackSpacing, but skip when justifyContent is space_between.
|
|
27
|
+
// Figma stores the COMPUTED inter-item spacing in stackSpacing for
|
|
28
|
+
// SPACE_EVENLY mode — using it as an explicit gap would conflict with
|
|
29
|
+
// the dynamic spacing that space_between already provides.
|
|
30
|
+
if (node.stackSpacing !== undefined && node.stackSpacing !== 0 && result.justifyContent !== 'space_between') {
|
|
31
|
+
result.gap = node.stackSpacing
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const padding = mapPadding(node)
|
|
35
|
+
if (padding !== undefined) {
|
|
36
|
+
result.padding = padding
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (node.stackCounterAlignItems) {
|
|
40
|
+
result.alignItems = mapAlignItems(node.stackCounterAlignItems)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Frames clip by default in Figma (frameMaskDisabled defaults to false).
|
|
44
|
+
// Only skip clipContent when explicitly disabled.
|
|
45
|
+
if (node.frameMaskDisabled !== true) {
|
|
46
|
+
result.clipContent = true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return result
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mapPadding(
|
|
53
|
+
node: FigmaNodeChange
|
|
54
|
+
): number | [number, number] | [number, number, number, number] | undefined {
|
|
55
|
+
// Check individual padding values first
|
|
56
|
+
const hasHorizontal = node.stackHorizontalPadding !== undefined
|
|
57
|
+
const hasVertical = node.stackVerticalPadding !== undefined
|
|
58
|
+
const hasRight = node.stackPaddingRight !== undefined
|
|
59
|
+
const hasBottom = node.stackPaddingBottom !== undefined
|
|
60
|
+
|
|
61
|
+
if (!hasHorizontal && !hasVertical && !hasRight && !hasBottom) {
|
|
62
|
+
// Uniform padding
|
|
63
|
+
if (node.stackPadding && node.stackPadding > 0) return node.stackPadding
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const vPad = node.stackVerticalPadding ?? node.stackPadding ?? 0
|
|
68
|
+
const hPad = node.stackHorizontalPadding ?? node.stackPadding ?? 0
|
|
69
|
+
const top = vPad
|
|
70
|
+
const bottom = node.stackPaddingBottom ?? vPad
|
|
71
|
+
const left = hPad
|
|
72
|
+
const right = node.stackPaddingRight ?? hPad
|
|
73
|
+
|
|
74
|
+
if (top === 0 && right === 0 && bottom === 0 && left === 0) return undefined
|
|
75
|
+
if (top === right && right === bottom && bottom === left) return top
|
|
76
|
+
if (top === bottom && left === right) return [top, right]
|
|
77
|
+
return [top, right, bottom, left]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mapJustifyContent(
|
|
81
|
+
align: string
|
|
82
|
+
): ContainerProps['justifyContent'] {
|
|
83
|
+
switch (align) {
|
|
84
|
+
case 'MIN': return 'start'
|
|
85
|
+
case 'CENTER': return 'center'
|
|
86
|
+
case 'MAX': return 'end'
|
|
87
|
+
case 'SPACE_EVENLY': return 'space_between'
|
|
88
|
+
default: return undefined
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function mapAlignItems(
|
|
93
|
+
align: string
|
|
94
|
+
): ContainerProps['alignItems'] {
|
|
95
|
+
switch (align) {
|
|
96
|
+
case 'MIN': return 'start'
|
|
97
|
+
case 'CENTER': return 'center'
|
|
98
|
+
case 'MAX': return 'end'
|
|
99
|
+
default: return undefined
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Determine width sizing behavior from Figma internal format.
|
|
105
|
+
*/
|
|
106
|
+
export function mapWidthSizing(node: FigmaNodeChange, parentStackMode?: string): SizingBehavior {
|
|
107
|
+
// Check stack sizing for containers
|
|
108
|
+
if (node.stackPrimarySizing === 'RESIZE_TO_FIT' && node.stackMode === 'HORIZONTAL') {
|
|
109
|
+
return 'fit_content'
|
|
110
|
+
}
|
|
111
|
+
if (node.stackCounterSizing === 'RESIZE_TO_FIT' && node.stackMode === 'VERTICAL') {
|
|
112
|
+
return 'fit_content'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check child sizing within parent
|
|
116
|
+
if (node.stackChildPrimaryGrow === 1 && parentStackMode === 'HORIZONTAL') {
|
|
117
|
+
return 'fill_container'
|
|
118
|
+
}
|
|
119
|
+
if (node.stackChildAlignSelf === 'STRETCH' && parentStackMode === 'VERTICAL') {
|
|
120
|
+
return 'fill_container'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return node.size?.x ?? 100
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Determine height sizing behavior from Figma internal format.
|
|
128
|
+
*/
|
|
129
|
+
export function mapHeightSizing(node: FigmaNodeChange, parentStackMode?: string): SizingBehavior {
|
|
130
|
+
if (node.stackPrimarySizing === 'RESIZE_TO_FIT' && node.stackMode === 'VERTICAL') {
|
|
131
|
+
return 'fit_content'
|
|
132
|
+
}
|
|
133
|
+
if (node.stackCounterSizing === 'RESIZE_TO_FIT' && node.stackMode === 'HORIZONTAL') {
|
|
134
|
+
return 'fit_content'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (node.stackChildPrimaryGrow === 1 && parentStackMode === 'VERTICAL') {
|
|
138
|
+
return 'fill_container'
|
|
139
|
+
}
|
|
140
|
+
if (node.stackChildAlignSelf === 'STRETCH' && parentStackMode === 'HORIZONTAL') {
|
|
141
|
+
return 'fill_container'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return node.size?.y ?? 100
|
|
145
|
+
}
|