@zseven-w/pen-codegen 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 +55 -0
- package/package.json +25 -0
- package/src/__tests__/codegen.test.ts +89 -0
- package/src/compose-generator.ts +807 -0
- package/src/css-variables-generator.ts +138 -0
- package/src/flutter-generator.ts +581 -0
- package/src/html-generator.ts +403 -0
- package/src/index.ts +26 -0
- package/src/react-generator.ts +401 -0
- package/src/react-native-generator.ts +569 -0
- package/src/svelte-generator.ts +296 -0
- package/src/swiftui-generator.ts +754 -0
- package/src/vue-generator.ts +308 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import type { PenDocument, PenNode, ContainerProps, TextNode, ImageNode, LineNode, PathNode, PolygonNode } from '@zseven-w/pen-types'
|
|
2
|
+
import { getActivePageChildren } from '@zseven-w/pen-core'
|
|
3
|
+
import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@zseven-w/pen-types'
|
|
4
|
+
import { isVariableRef } from '@zseven-w/pen-core'
|
|
5
|
+
import { variableNameToCSS } from './css-variables-generator.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Converts PenDocument nodes to SwiftUI code.
|
|
9
|
+
* $variable references are output as var(--name) comments for manual mapping.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Convert a `$variable` ref to a placeholder comment, or return the raw value. */
|
|
13
|
+
function varOrLiteral(value: string): string {
|
|
14
|
+
if (isVariableRef(value)) {
|
|
15
|
+
return `var(${variableNameToCSS(value.slice(1))})`
|
|
16
|
+
}
|
|
17
|
+
return value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function indent(depth: number): string {
|
|
21
|
+
return ' '.repeat(depth)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Parse a hex color string to SwiftUI Color initializer. */
|
|
25
|
+
function hexToSwiftUIColor(hex: string): string {
|
|
26
|
+
if (hex.startsWith('$')) {
|
|
27
|
+
return `Color("${varOrLiteral(hex)}") /* variable */`
|
|
28
|
+
}
|
|
29
|
+
const clean = hex.replace('#', '')
|
|
30
|
+
if (clean.length === 6) {
|
|
31
|
+
const r = parseInt(clean.substring(0, 2), 16) / 255
|
|
32
|
+
const g = parseInt(clean.substring(2, 4), 16) / 255
|
|
33
|
+
const b = parseInt(clean.substring(4, 6), 16) / 255
|
|
34
|
+
return `Color(red: ${r.toFixed(3)}, green: ${g.toFixed(3)}, blue: ${b.toFixed(3)})`
|
|
35
|
+
}
|
|
36
|
+
if (clean.length === 8) {
|
|
37
|
+
const r = parseInt(clean.substring(0, 2), 16) / 255
|
|
38
|
+
const g = parseInt(clean.substring(2, 4), 16) / 255
|
|
39
|
+
const b = parseInt(clean.substring(4, 6), 16) / 255
|
|
40
|
+
const a = parseInt(clean.substring(6, 8), 16) / 255
|
|
41
|
+
return `Color(red: ${r.toFixed(3)}, green: ${g.toFixed(3)}, blue: ${b.toFixed(3)}).opacity(${a.toFixed(3)})`
|
|
42
|
+
}
|
|
43
|
+
return `Color("${hex}")`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fillToSwiftUI(fills: PenFill[] | undefined): string | null {
|
|
47
|
+
if (!fills || fills.length === 0) return null
|
|
48
|
+
const fill = fills[0]
|
|
49
|
+
if (fill.type === 'solid') {
|
|
50
|
+
return hexToSwiftUIColor(fill.color)
|
|
51
|
+
}
|
|
52
|
+
if (fill.type === 'linear_gradient') {
|
|
53
|
+
if (!fill.stops?.length) return null
|
|
54
|
+
const angle = fill.angle ?? 180
|
|
55
|
+
const startPoint = angleToUnitPoint(angle, 'start')
|
|
56
|
+
const endPoint = angleToUnitPoint(angle, 'end')
|
|
57
|
+
const stops = fill.stops
|
|
58
|
+
.map((s) => `.init(color: ${hexToSwiftUIColor(s.color)}, location: ${s.offset.toFixed(2)})`)
|
|
59
|
+
.join(', ')
|
|
60
|
+
return `LinearGradient(stops: [${stops}], startPoint: ${startPoint}, endPoint: ${endPoint})`
|
|
61
|
+
}
|
|
62
|
+
if (fill.type === 'radial_gradient') {
|
|
63
|
+
if (!fill.stops?.length) return null
|
|
64
|
+
const stops = fill.stops
|
|
65
|
+
.map((s) => `.init(color: ${hexToSwiftUIColor(s.color)}, location: ${s.offset.toFixed(2)})`)
|
|
66
|
+
.join(', ')
|
|
67
|
+
return `RadialGradient(stops: [${stops}], center: .center, startRadius: 0, endRadius: 100)`
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Convert an angle in degrees to SwiftUI UnitPoint for gradient start/end. */
|
|
73
|
+
function angleToUnitPoint(angle: number, point: 'start' | 'end'): string {
|
|
74
|
+
const normalized = ((angle % 360) + 360) % 360
|
|
75
|
+
if (point === 'start') {
|
|
76
|
+
if (normalized === 0) return '.bottom'
|
|
77
|
+
if (normalized === 90) return '.leading'
|
|
78
|
+
if (normalized === 180) return '.top'
|
|
79
|
+
if (normalized === 270) return '.trailing'
|
|
80
|
+
return `.top`
|
|
81
|
+
}
|
|
82
|
+
// end
|
|
83
|
+
if (normalized === 0) return '.top'
|
|
84
|
+
if (normalized === 90) return '.trailing'
|
|
85
|
+
if (normalized === 180) return '.bottom'
|
|
86
|
+
if (normalized === 270) return '.leading'
|
|
87
|
+
return `.bottom`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function strokeToSwiftUI(
|
|
91
|
+
stroke: PenStroke | undefined,
|
|
92
|
+
cornerRadius: number | [number, number, number, number] | undefined,
|
|
93
|
+
): string[] {
|
|
94
|
+
if (!stroke) return []
|
|
95
|
+
const modifiers: string[] = []
|
|
96
|
+
const thickness = typeof stroke.thickness === 'number'
|
|
97
|
+
? stroke.thickness
|
|
98
|
+
: typeof stroke.thickness === 'string'
|
|
99
|
+
? stroke.thickness
|
|
100
|
+
: stroke.thickness[0]
|
|
101
|
+
const thicknessStr = typeof thickness === 'string' && isVariableRef(thickness)
|
|
102
|
+
? `/* ${varOrLiteral(thickness)} */ 1`
|
|
103
|
+
: String(thickness)
|
|
104
|
+
|
|
105
|
+
let strokeColor = 'Color.gray'
|
|
106
|
+
if (stroke.fill && stroke.fill.length > 0) {
|
|
107
|
+
const sf = stroke.fill[0]
|
|
108
|
+
if (sf.type === 'solid') {
|
|
109
|
+
strokeColor = hexToSwiftUIColor(sf.color)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const cr = typeof cornerRadius === 'number' ? cornerRadius : 0
|
|
114
|
+
if (cr > 0) {
|
|
115
|
+
modifiers.push(`.overlay(RoundedRectangle(cornerRadius: ${cr}).stroke(${strokeColor}, lineWidth: ${thicknessStr}))`)
|
|
116
|
+
} else {
|
|
117
|
+
modifiers.push(`.overlay(Rectangle().stroke(${strokeColor}, lineWidth: ${thicknessStr}))`)
|
|
118
|
+
}
|
|
119
|
+
return modifiers
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function effectsToSwiftUI(effects: PenEffect[] | undefined): string[] {
|
|
123
|
+
if (!effects || effects.length === 0) return []
|
|
124
|
+
const modifiers: string[] = []
|
|
125
|
+
for (const effect of effects) {
|
|
126
|
+
if (effect.type === 'shadow') {
|
|
127
|
+
const s = effect as ShadowEffect
|
|
128
|
+
modifiers.push(`.shadow(color: ${hexToSwiftUIColor(s.color)}, radius: ${s.blur}, x: ${s.offsetX}, y: ${s.offsetY})`)
|
|
129
|
+
} else if (effect.type === 'blur' || effect.type === 'background_blur') {
|
|
130
|
+
modifiers.push(`.blur(radius: ${effect.radius})`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return modifiers
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function paddingToSwiftUI(
|
|
137
|
+
padding: number | [number, number] | [number, number, number, number] | string | undefined,
|
|
138
|
+
): string[] {
|
|
139
|
+
if (padding === undefined) return []
|
|
140
|
+
if (typeof padding === 'string' && isVariableRef(padding)) {
|
|
141
|
+
return [`.padding(/* ${varOrLiteral(padding)} */ 0)`]
|
|
142
|
+
}
|
|
143
|
+
if (typeof padding === 'number') {
|
|
144
|
+
return padding > 0 ? [`.padding(${padding})`] : []
|
|
145
|
+
}
|
|
146
|
+
if (Array.isArray(padding)) {
|
|
147
|
+
if (padding.length === 2) {
|
|
148
|
+
const modifiers: string[] = []
|
|
149
|
+
if (padding[0] > 0) modifiers.push(`.padding(.vertical, ${padding[0]})`)
|
|
150
|
+
if (padding[1] > 0) modifiers.push(`.padding(.horizontal, ${padding[1]})`)
|
|
151
|
+
return modifiers
|
|
152
|
+
}
|
|
153
|
+
if (padding.length === 4) {
|
|
154
|
+
const [top, trailing, bottom, leading] = padding
|
|
155
|
+
const modifiers: string[] = []
|
|
156
|
+
if (top > 0) modifiers.push(`.padding(.top, ${top})`)
|
|
157
|
+
if (trailing > 0) modifiers.push(`.padding(.trailing, ${trailing})`)
|
|
158
|
+
if (bottom > 0) modifiers.push(`.padding(.bottom, ${bottom})`)
|
|
159
|
+
if (leading > 0) modifiers.push(`.padding(.leading, ${leading})`)
|
|
160
|
+
return modifiers
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return []
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getTextContent(node: TextNode): string {
|
|
167
|
+
if (typeof node.content === 'string') return node.content
|
|
168
|
+
return node.content.map((s) => s.text).join('')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function escapeSwiftString(text: string): string {
|
|
172
|
+
return text
|
|
173
|
+
.replace(/\\/g, '\\\\')
|
|
174
|
+
.replace(/"/g, '\\"')
|
|
175
|
+
.replace(/\n/g, '\\n')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function fontWeightToSwiftUI(weight: number | string | undefined): string | null {
|
|
179
|
+
if (weight === undefined) return null
|
|
180
|
+
const w = typeof weight === 'number' ? weight : parseInt(weight, 10)
|
|
181
|
+
if (isNaN(w)) return null
|
|
182
|
+
if (w <= 100) return '.ultraLight'
|
|
183
|
+
if (w <= 200) return '.thin'
|
|
184
|
+
if (w <= 300) return '.light'
|
|
185
|
+
if (w <= 400) return '.regular'
|
|
186
|
+
if (w <= 500) return '.medium'
|
|
187
|
+
if (w <= 600) return '.semibold'
|
|
188
|
+
if (w <= 700) return '.bold'
|
|
189
|
+
if (w <= 800) return '.heavy'
|
|
190
|
+
return '.black'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function textAlignToSwiftUI(align: string | undefined): string | null {
|
|
194
|
+
if (!align) return null
|
|
195
|
+
const map: Record<string, string> = {
|
|
196
|
+
left: '.leading',
|
|
197
|
+
center: '.center',
|
|
198
|
+
right: '.trailing',
|
|
199
|
+
}
|
|
200
|
+
return map[align] ?? null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function alignToSwiftUI(
|
|
204
|
+
alignItems: string | undefined,
|
|
205
|
+
layout: string | undefined,
|
|
206
|
+
): string | null {
|
|
207
|
+
if (!alignItems || !layout || layout === 'none') return null
|
|
208
|
+
if (layout === 'vertical') {
|
|
209
|
+
const map: Record<string, string> = {
|
|
210
|
+
start: '.leading',
|
|
211
|
+
center: '.center',
|
|
212
|
+
end: '.trailing',
|
|
213
|
+
}
|
|
214
|
+
return map[alignItems] ?? null
|
|
215
|
+
}
|
|
216
|
+
// horizontal layout: alignment is vertical
|
|
217
|
+
const map: Record<string, string> = {
|
|
218
|
+
start: '.top',
|
|
219
|
+
center: '.center',
|
|
220
|
+
end: '.bottom',
|
|
221
|
+
}
|
|
222
|
+
return map[alignItems] ?? null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Render a node and its modifiers, returning an array of lines. */
|
|
226
|
+
function generateNodeSwiftUI(node: PenNode, depth: number): string {
|
|
227
|
+
const pad = indent(depth)
|
|
228
|
+
|
|
229
|
+
switch (node.type) {
|
|
230
|
+
case 'frame':
|
|
231
|
+
case 'rectangle':
|
|
232
|
+
case 'group': {
|
|
233
|
+
return generateContainerSwiftUI(node, depth)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
case 'ellipse': {
|
|
237
|
+
const modifiers: string[] = []
|
|
238
|
+
const fillStr = fillToSwiftUI(node.fill)
|
|
239
|
+
if (fillStr) {
|
|
240
|
+
modifiers.push(`.fill(${fillStr})`)
|
|
241
|
+
}
|
|
242
|
+
if (typeof node.width === 'number' || typeof node.height === 'number') {
|
|
243
|
+
const w = typeof node.width === 'number' ? node.width : (typeof node.height === 'number' ? node.height : 100)
|
|
244
|
+
const h = typeof node.height === 'number' ? node.height : w
|
|
245
|
+
modifiers.push(`.frame(width: ${w}, height: ${h})`)
|
|
246
|
+
}
|
|
247
|
+
modifiers.push(...strokeToSwiftUI(node.stroke, undefined))
|
|
248
|
+
modifiers.push(...effectsToSwiftUI(node.effects))
|
|
249
|
+
modifiers.push(...commonModifiers(node))
|
|
250
|
+
|
|
251
|
+
return renderWithModifiers(pad, 'Ellipse()', modifiers)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
case 'text': {
|
|
255
|
+
return generateTextSwiftUI(node, depth)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case 'line': {
|
|
259
|
+
return generateLineSwiftUI(node, depth)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
case 'polygon':
|
|
263
|
+
case 'path': {
|
|
264
|
+
return generatePathSwiftUI(node, depth)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
case 'image': {
|
|
268
|
+
return generateImageSwiftUI(node, depth)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case 'icon_font': {
|
|
272
|
+
const size = typeof node.width === 'number' ? node.width : 24
|
|
273
|
+
const color = node.fill?.[0]?.type === 'solid' ? node.fill[0].color : null
|
|
274
|
+
const iconName = (node.iconFontName || 'circle').replace(/-/g, '.')
|
|
275
|
+
const colorMod = color ? `\n${pad} .foregroundColor(Color(hex: "${color}"))` : ''
|
|
276
|
+
return `${pad}Image("${iconName}")\n${pad} .resizable()\n${pad} .frame(width: ${size}, height: ${size})${colorMod}`
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
case 'ref':
|
|
280
|
+
return `${pad}// Ref: ${node.ref}`
|
|
281
|
+
|
|
282
|
+
default:
|
|
283
|
+
return `${pad}// Unsupported node type`
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function commonModifiers(node: PenNode): string[] {
|
|
288
|
+
const modifiers: string[] = []
|
|
289
|
+
|
|
290
|
+
if (node.opacity !== undefined && node.opacity !== 1) {
|
|
291
|
+
if (typeof node.opacity === 'string' && isVariableRef(node.opacity)) {
|
|
292
|
+
modifiers.push(`.opacity(/* ${varOrLiteral(node.opacity)} */ 1.0)`)
|
|
293
|
+
} else if (typeof node.opacity === 'number') {
|
|
294
|
+
modifiers.push(`.opacity(${node.opacity})`)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (node.rotation) {
|
|
299
|
+
modifiers.push(`.rotationEffect(.degrees(${node.rotation}))`)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (node.x !== undefined || node.y !== undefined) {
|
|
303
|
+
const x = node.x ?? 0
|
|
304
|
+
const y = node.y ?? 0
|
|
305
|
+
modifiers.push(`.offset(x: ${x}, y: ${y})`)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return modifiers
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function renderWithModifiers(
|
|
312
|
+
pad: string,
|
|
313
|
+
element: string,
|
|
314
|
+
modifiers: string[],
|
|
315
|
+
): string {
|
|
316
|
+
if (modifiers.length === 0) {
|
|
317
|
+
return `${pad}${element}`
|
|
318
|
+
}
|
|
319
|
+
const lines = [`${pad}${element}`]
|
|
320
|
+
for (const mod of modifiers) {
|
|
321
|
+
lines.push(`${pad} ${mod}`)
|
|
322
|
+
}
|
|
323
|
+
return lines.join('\n')
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function generateContainerSwiftUI(
|
|
327
|
+
node: PenNode & ContainerProps,
|
|
328
|
+
depth: number,
|
|
329
|
+
): string {
|
|
330
|
+
const pad = indent(depth)
|
|
331
|
+
const children = node.children ?? []
|
|
332
|
+
const hasLayout = node.layout === 'vertical' || node.layout === 'horizontal'
|
|
333
|
+
const cr = typeof node.cornerRadius === 'number' ? node.cornerRadius : 0
|
|
334
|
+
|
|
335
|
+
// Determine stack type
|
|
336
|
+
let stackType: string
|
|
337
|
+
let stackArgs = ''
|
|
338
|
+
if (node.layout === 'vertical') {
|
|
339
|
+
const alignment = alignToSwiftUI(node.alignItems, node.layout)
|
|
340
|
+
const spacingStr = gapToSwiftUI(node.gap)
|
|
341
|
+
const args: string[] = []
|
|
342
|
+
if (alignment) args.push(`alignment: ${alignment}`)
|
|
343
|
+
if (spacingStr) args.push(`spacing: ${spacingStr}`)
|
|
344
|
+
stackType = 'VStack'
|
|
345
|
+
if (args.length > 0) stackArgs = `(${args.join(', ')})`
|
|
346
|
+
} else if (node.layout === 'horizontal') {
|
|
347
|
+
const alignment = alignToSwiftUI(node.alignItems, node.layout)
|
|
348
|
+
const spacingStr = gapToSwiftUI(node.gap)
|
|
349
|
+
const args: string[] = []
|
|
350
|
+
if (alignment) args.push(`alignment: ${alignment}`)
|
|
351
|
+
if (spacingStr) args.push(`spacing: ${spacingStr}`)
|
|
352
|
+
stackType = 'HStack'
|
|
353
|
+
if (args.length > 0) stackArgs = `(${args.join(', ')})`
|
|
354
|
+
} else {
|
|
355
|
+
stackType = 'ZStack'
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Build modifiers
|
|
359
|
+
const modifiers: string[] = []
|
|
360
|
+
|
|
361
|
+
modifiers.push(...paddingToSwiftUI(node.padding))
|
|
362
|
+
|
|
363
|
+
if (typeof node.width === 'number' || typeof node.height === 'number') {
|
|
364
|
+
const args: string[] = []
|
|
365
|
+
if (typeof node.width === 'number') args.push(`width: ${node.width}`)
|
|
366
|
+
if (typeof node.height === 'number') args.push(`height: ${node.height}`)
|
|
367
|
+
modifiers.push(`.frame(${args.join(', ')})`)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const fillStr = fillToSwiftUI(node.fill)
|
|
371
|
+
if (fillStr) {
|
|
372
|
+
if (cr > 0) {
|
|
373
|
+
modifiers.push(`.background(${fillStr})`)
|
|
374
|
+
modifiers.push(`.clipShape(RoundedRectangle(cornerRadius: ${cr}))`)
|
|
375
|
+
} else {
|
|
376
|
+
modifiers.push(`.background(${fillStr})`)
|
|
377
|
+
}
|
|
378
|
+
} else if (cr > 0) {
|
|
379
|
+
modifiers.push(`.clipShape(RoundedRectangle(cornerRadius: ${cr}))`)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
modifiers.push(...strokeToSwiftUI(node.stroke, node.cornerRadius))
|
|
383
|
+
modifiers.push(...effectsToSwiftUI(node.effects))
|
|
384
|
+
|
|
385
|
+
if (node.clipContent) {
|
|
386
|
+
modifiers.push('.clipped()')
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
modifiers.push(...commonModifiers(node))
|
|
390
|
+
|
|
391
|
+
// No children: render as a shape
|
|
392
|
+
if (children.length === 0 && !hasLayout) {
|
|
393
|
+
if (fillStr && cr > 0) {
|
|
394
|
+
const shapeModifiers: string[] = []
|
|
395
|
+
shapeModifiers.push(`.fill(${fillStr})`)
|
|
396
|
+
if (typeof node.width === 'number' || typeof node.height === 'number') {
|
|
397
|
+
const args: string[] = []
|
|
398
|
+
if (typeof node.width === 'number') args.push(`width: ${node.width}`)
|
|
399
|
+
if (typeof node.height === 'number') args.push(`height: ${node.height}`)
|
|
400
|
+
shapeModifiers.push(`.frame(${args.join(', ')})`)
|
|
401
|
+
}
|
|
402
|
+
shapeModifiers.push(...strokeToSwiftUI(node.stroke, node.cornerRadius))
|
|
403
|
+
shapeModifiers.push(...effectsToSwiftUI(node.effects))
|
|
404
|
+
shapeModifiers.push(...commonModifiers(node))
|
|
405
|
+
return renderWithModifiers(pad, `RoundedRectangle(cornerRadius: ${cr})`, shapeModifiers)
|
|
406
|
+
}
|
|
407
|
+
if (fillStr) {
|
|
408
|
+
const shapeModifiers: string[] = []
|
|
409
|
+
shapeModifiers.push(`.fill(${fillStr})`)
|
|
410
|
+
if (typeof node.width === 'number' || typeof node.height === 'number') {
|
|
411
|
+
const args: string[] = []
|
|
412
|
+
if (typeof node.width === 'number') args.push(`width: ${node.width}`)
|
|
413
|
+
if (typeof node.height === 'number') args.push(`height: ${node.height}`)
|
|
414
|
+
shapeModifiers.push(`.frame(${args.join(', ')})`)
|
|
415
|
+
}
|
|
416
|
+
shapeModifiers.push(...strokeToSwiftUI(node.stroke, node.cornerRadius))
|
|
417
|
+
shapeModifiers.push(...effectsToSwiftUI(node.effects))
|
|
418
|
+
shapeModifiers.push(...commonModifiers(node))
|
|
419
|
+
return renderWithModifiers(pad, 'Rectangle()', shapeModifiers)
|
|
420
|
+
}
|
|
421
|
+
// Empty container with just size/modifiers
|
|
422
|
+
const emptyLines = [`${pad}${stackType}${stackArgs} {}`]
|
|
423
|
+
for (const mod of modifiers) {
|
|
424
|
+
emptyLines.push(`${pad} ${mod}`)
|
|
425
|
+
}
|
|
426
|
+
return emptyLines.join('\n')
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// With children
|
|
430
|
+
const comment = node.name ? `${pad}// ${node.name}\n` : ''
|
|
431
|
+
const childLines = children
|
|
432
|
+
.map((c) => generateNodeSwiftUI(c, depth + 1))
|
|
433
|
+
.join('\n')
|
|
434
|
+
|
|
435
|
+
const lines = [`${comment}${pad}${stackType}${stackArgs} {`]
|
|
436
|
+
lines.push(childLines)
|
|
437
|
+
lines.push(`${pad}}`)
|
|
438
|
+
for (const mod of modifiers) {
|
|
439
|
+
lines.push(`${pad} ${mod}`)
|
|
440
|
+
}
|
|
441
|
+
return lines.join('\n')
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function gapToSwiftUI(gap: number | string | undefined): string | null {
|
|
445
|
+
if (gap === undefined) return null
|
|
446
|
+
if (typeof gap === 'string' && isVariableRef(gap)) {
|
|
447
|
+
return `/* ${varOrLiteral(gap)} */ 0`
|
|
448
|
+
}
|
|
449
|
+
if (typeof gap === 'number' && gap > 0) {
|
|
450
|
+
return String(gap)
|
|
451
|
+
}
|
|
452
|
+
return null
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function generateTextSwiftUI(node: TextNode, depth: number): string {
|
|
456
|
+
const pad = indent(depth)
|
|
457
|
+
const text = escapeSwiftString(getTextContent(node))
|
|
458
|
+
const modifiers: string[] = []
|
|
459
|
+
|
|
460
|
+
// Font
|
|
461
|
+
const weight = fontWeightToSwiftUI(node.fontWeight)
|
|
462
|
+
if (node.fontSize && weight) {
|
|
463
|
+
modifiers.push(`.font(.system(size: ${node.fontSize}, weight: ${weight}))`)
|
|
464
|
+
} else if (node.fontSize) {
|
|
465
|
+
modifiers.push(`.font(.system(size: ${node.fontSize}))`)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Font style
|
|
469
|
+
if (node.fontStyle === 'italic') {
|
|
470
|
+
modifiers.push('.italic()')
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Text color
|
|
474
|
+
if (node.fill && node.fill.length > 0) {
|
|
475
|
+
const fill = node.fill[0]
|
|
476
|
+
if (fill.type === 'solid') {
|
|
477
|
+
modifiers.push(`.foregroundColor(${hexToSwiftUIColor(fill.color)})`)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Alignment
|
|
482
|
+
const align = textAlignToSwiftUI(node.textAlign)
|
|
483
|
+
if (align) {
|
|
484
|
+
modifiers.push(`.multilineTextAlignment(${align})`)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Frame / sizing
|
|
488
|
+
if (typeof node.width === 'number' || typeof node.height === 'number') {
|
|
489
|
+
const args: string[] = []
|
|
490
|
+
if (typeof node.width === 'number') {
|
|
491
|
+
args.push(`width: ${node.width}`)
|
|
492
|
+
}
|
|
493
|
+
if (typeof node.height === 'number') {
|
|
494
|
+
args.push(`height: ${node.height}`)
|
|
495
|
+
}
|
|
496
|
+
if (node.textAlign === 'left') args.push('alignment: .leading')
|
|
497
|
+
else if (node.textAlign === 'right') args.push('alignment: .trailing')
|
|
498
|
+
modifiers.push(`.frame(${args.join(', ')})`)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Letter spacing
|
|
502
|
+
if (node.letterSpacing) {
|
|
503
|
+
modifiers.push(`.kerning(${node.letterSpacing})`)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Line height (approximation via lineSpacing)
|
|
507
|
+
if (node.lineHeight && node.fontSize) {
|
|
508
|
+
const spacing = node.lineHeight * node.fontSize - node.fontSize
|
|
509
|
+
if (spacing > 0) {
|
|
510
|
+
modifiers.push(`.lineSpacing(${spacing.toFixed(1)})`)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Decorations
|
|
515
|
+
if (node.underline) {
|
|
516
|
+
modifiers.push('.underline()')
|
|
517
|
+
}
|
|
518
|
+
if (node.strikethrough) {
|
|
519
|
+
modifiers.push('.strikethrough()')
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
modifiers.push(...effectsToSwiftUI(node.effects))
|
|
523
|
+
modifiers.push(...commonModifiers(node))
|
|
524
|
+
|
|
525
|
+
return renderWithModifiers(pad, `Text("${text}")`, modifiers)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function generateLineSwiftUI(node: LineNode, depth: number): string {
|
|
529
|
+
const pad = indent(depth)
|
|
530
|
+
const w = node.x2 !== undefined ? Math.abs(node.x2 - (node.x ?? 0)) : 0
|
|
531
|
+
const modifiers: string[] = []
|
|
532
|
+
|
|
533
|
+
if (w > 0) {
|
|
534
|
+
modifiers.push(`.frame(width: ${w}, height: 1)`)
|
|
535
|
+
} else {
|
|
536
|
+
modifiers.push('.frame(height: 1)')
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (node.stroke && node.stroke.fill && node.stroke.fill.length > 0) {
|
|
540
|
+
const sf = node.stroke.fill[0]
|
|
541
|
+
if (sf.type === 'solid') {
|
|
542
|
+
modifiers.push(`.background(${hexToSwiftUIColor(sf.color)})`)
|
|
543
|
+
}
|
|
544
|
+
} else {
|
|
545
|
+
modifiers.push('.background(Color.gray)')
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
modifiers.push(...commonModifiers(node))
|
|
549
|
+
|
|
550
|
+
return renderWithModifiers(pad, 'Rectangle()', modifiers)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function generatePathSwiftUI(node: PathNode | PolygonNode, depth: number): string {
|
|
554
|
+
const pad = indent(depth)
|
|
555
|
+
|
|
556
|
+
if (node.type === 'path') {
|
|
557
|
+
const fillStr = fillToSwiftUI(node.fill)
|
|
558
|
+
const fillColor = fillStr ?? 'Color.primary'
|
|
559
|
+
const modifiers: string[] = []
|
|
560
|
+
|
|
561
|
+
if (typeof node.width === 'number' || typeof node.height === 'number') {
|
|
562
|
+
const args: string[] = []
|
|
563
|
+
if (typeof node.width === 'number') args.push(`width: ${node.width}`)
|
|
564
|
+
if (typeof node.height === 'number') args.push(`height: ${node.height}`)
|
|
565
|
+
modifiers.push(`.frame(${args.join(', ')})`)
|
|
566
|
+
}
|
|
567
|
+
modifiers.push(...effectsToSwiftUI(node.effects))
|
|
568
|
+
modifiers.push(...commonModifiers(node))
|
|
569
|
+
|
|
570
|
+
const escapedD = escapeSwiftString(node.d)
|
|
571
|
+
|
|
572
|
+
const lines = [
|
|
573
|
+
`${pad}// ${node.name ?? 'Path'}`,
|
|
574
|
+
`${pad}SVGPath("${escapedD}")`,
|
|
575
|
+
`${pad} .fill(${fillColor})`,
|
|
576
|
+
]
|
|
577
|
+
for (const mod of modifiers) {
|
|
578
|
+
lines.push(`${pad} ${mod}`)
|
|
579
|
+
}
|
|
580
|
+
return lines.join('\n')
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Polygon
|
|
584
|
+
const modifiers: string[] = []
|
|
585
|
+
const fillStr = fillToSwiftUI(node.fill)
|
|
586
|
+
if (fillStr) {
|
|
587
|
+
modifiers.push(`.fill(${fillStr})`)
|
|
588
|
+
}
|
|
589
|
+
if (typeof node.width === 'number' || typeof node.height === 'number') {
|
|
590
|
+
const args: string[] = []
|
|
591
|
+
if (typeof node.width === 'number') args.push(`width: ${node.width}`)
|
|
592
|
+
if (typeof node.height === 'number') args.push(`height: ${node.height}`)
|
|
593
|
+
modifiers.push(`.frame(${args.join(', ')})`)
|
|
594
|
+
}
|
|
595
|
+
modifiers.push(...effectsToSwiftUI(node.effects))
|
|
596
|
+
modifiers.push(...commonModifiers(node))
|
|
597
|
+
|
|
598
|
+
const sides = node.polygonCount
|
|
599
|
+
return renderWithModifiers(pad, `PolygonShape(sides: ${sides})`, modifiers)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function generateImageSwiftUI(node: ImageNode, depth: number): string {
|
|
603
|
+
const pad = indent(depth)
|
|
604
|
+
const modifiers: string[] = []
|
|
605
|
+
|
|
606
|
+
// Resizing
|
|
607
|
+
modifiers.push('.resizable()')
|
|
608
|
+
if (node.objectFit === 'fit') {
|
|
609
|
+
modifiers.push('.aspectRatio(contentMode: .fit)')
|
|
610
|
+
} else {
|
|
611
|
+
modifiers.push('.aspectRatio(contentMode: .fill)')
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (typeof node.width === 'number' || typeof node.height === 'number') {
|
|
615
|
+
const args: string[] = []
|
|
616
|
+
if (typeof node.width === 'number') args.push(`width: ${node.width}`)
|
|
617
|
+
if (typeof node.height === 'number') args.push(`height: ${node.height}`)
|
|
618
|
+
modifiers.push(`.frame(${args.join(', ')})`)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (node.cornerRadius) {
|
|
622
|
+
const cr = typeof node.cornerRadius === 'number' ? node.cornerRadius : node.cornerRadius[0]
|
|
623
|
+
if (cr > 0) {
|
|
624
|
+
modifiers.push(`.clipShape(RoundedRectangle(cornerRadius: ${cr}))`)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
modifiers.push(...effectsToSwiftUI(node.effects))
|
|
629
|
+
modifiers.push(...commonModifiers(node))
|
|
630
|
+
|
|
631
|
+
const src = node.src
|
|
632
|
+
|
|
633
|
+
// Data URI — extract base64 and decode at runtime
|
|
634
|
+
if (src.startsWith('data:image/')) {
|
|
635
|
+
const base64Start = src.indexOf('base64,')
|
|
636
|
+
if (base64Start !== -1) {
|
|
637
|
+
const base64Data = src.slice(base64Start + 7)
|
|
638
|
+
const truncated = base64Data.length > 80 ? base64Data.substring(0, 80) + '...' : base64Data
|
|
639
|
+
const lines = [
|
|
640
|
+
`${pad}// Embedded image (${node.name ?? 'image'})`,
|
|
641
|
+
`${pad}// Base64 data: ${truncated}`,
|
|
642
|
+
`${pad}if let data = Data(base64Encoded: "${base64Data}"),`,
|
|
643
|
+
`${pad} let uiImage = UIImage(data: data) {`,
|
|
644
|
+
`${pad} Image(uiImage: uiImage)`,
|
|
645
|
+
]
|
|
646
|
+
for (const mod of modifiers) {
|
|
647
|
+
lines.push(`${pad} ${mod}`)
|
|
648
|
+
}
|
|
649
|
+
lines.push(`${pad}}`)
|
|
650
|
+
return lines.join('\n')
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const escapedSrc = escapeSwiftString(src)
|
|
655
|
+
if (src.startsWith('http://') || src.startsWith('https://')) {
|
|
656
|
+
const lines = [
|
|
657
|
+
`${pad}AsyncImage(url: URL(string: "${escapedSrc}")) { image in`,
|
|
658
|
+
`${pad} image`,
|
|
659
|
+
]
|
|
660
|
+
for (const mod of modifiers) {
|
|
661
|
+
lines.push(`${pad} ${mod}`)
|
|
662
|
+
}
|
|
663
|
+
lines.push(`${pad}} placeholder: {`)
|
|
664
|
+
lines.push(`${pad} ProgressView()`)
|
|
665
|
+
lines.push(`${pad}}`)
|
|
666
|
+
return lines.join('\n')
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return renderWithModifiers(pad, `Image("${escapedSrc}")`, modifiers)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export function generateSwiftUICode(
|
|
673
|
+
nodes: PenNode[],
|
|
674
|
+
viewName = 'GeneratedView',
|
|
675
|
+
): string {
|
|
676
|
+
if (nodes.length === 0) {
|
|
677
|
+
return `import SwiftUI\n\nstruct ${viewName}: View {\n var body: some View {\n EmptyView()\n }\n}\n`
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Compute wrapper size for root ZStack
|
|
681
|
+
let maxW = 0
|
|
682
|
+
let maxH = 0
|
|
683
|
+
for (const node of nodes) {
|
|
684
|
+
const x = node.x ?? 0
|
|
685
|
+
const y = node.y ?? 0
|
|
686
|
+
const w = 'width' in node && typeof node.width === 'number' ? node.width : 0
|
|
687
|
+
const h = 'height' in node && typeof node.height === 'number' ? node.height : 0
|
|
688
|
+
maxW = Math.max(maxW, x + w)
|
|
689
|
+
maxH = Math.max(maxH, y + h)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const childLines = nodes
|
|
693
|
+
.map((n) => generateNodeSwiftUI(n, 3))
|
|
694
|
+
.join('\n')
|
|
695
|
+
|
|
696
|
+
const frameArgs: string[] = []
|
|
697
|
+
if (maxW > 0) frameArgs.push(`width: ${maxW}`)
|
|
698
|
+
if (maxH > 0) frameArgs.push(`height: ${maxH}`)
|
|
699
|
+
const frameModifier = frameArgs.length > 0 ? `\n .frame(${frameArgs.join(', ')})` : ''
|
|
700
|
+
|
|
701
|
+
return `import SwiftUI
|
|
702
|
+
|
|
703
|
+
/// Helper: parses SVG path data into a SwiftUI Shape.
|
|
704
|
+
/// Usage: SVGPath("M10 20 L30 40 Z").fill(.red)
|
|
705
|
+
struct SVGPath: Shape {
|
|
706
|
+
let pathData: String
|
|
707
|
+
init(_ pathData: String) { self.pathData = pathData }
|
|
708
|
+
func path(in rect: CGRect) -> Path {
|
|
709
|
+
// Use a third-party SVG path parser or implement command parsing
|
|
710
|
+
// For production, consider using SwiftSVG or similar library
|
|
711
|
+
Path { _ in /* parse pathData here */ }
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/// Helper: regular polygon shape
|
|
716
|
+
struct PolygonShape: Shape {
|
|
717
|
+
let sides: Int
|
|
718
|
+
func path(in rect: CGRect) -> Path {
|
|
719
|
+
let center = CGPoint(x: rect.midX, y: rect.midY)
|
|
720
|
+
let radius = min(rect.width, rect.height) / 2
|
|
721
|
+
var path = Path()
|
|
722
|
+
for i in 0..<sides {
|
|
723
|
+
let angle = CGFloat(i) * (2 * .pi / CGFloat(sides)) - .pi / 2
|
|
724
|
+
let point = CGPoint(x: center.x + radius * cos(angle), y: center.y + radius * sin(angle))
|
|
725
|
+
if i == 0 { path.move(to: point) } else { path.addLine(to: point) }
|
|
726
|
+
}
|
|
727
|
+
path.closeSubpath()
|
|
728
|
+
return path
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
struct ${viewName}: View {
|
|
733
|
+
var body: some View {
|
|
734
|
+
ZStack(alignment: .topLeading) {
|
|
735
|
+
${childLines}
|
|
736
|
+
}${frameModifier}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
#Preview {
|
|
741
|
+
${viewName}()
|
|
742
|
+
}
|
|
743
|
+
`
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export function generateSwiftUIFromDocument(
|
|
747
|
+
doc: PenDocument,
|
|
748
|
+
activePageId?: string | null,
|
|
749
|
+
): string {
|
|
750
|
+
const children = activePageId !== undefined
|
|
751
|
+
? getActivePageChildren(doc, activePageId)
|
|
752
|
+
: doc.children
|
|
753
|
+
return generateSwiftUICode(children, 'GeneratedView')
|
|
754
|
+
}
|