@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.
@@ -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
+ }