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