@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,581 @@
1
+ import type { PenDocument, PenNode, ContainerProps, TextNode, ImageNode, EllipseNode, LineNode, PathNode, PolygonNode } from '@zseven-w/pen-types'
2
+ import { getActivePageChildren } from '@zseven-w/pen-core'
3
+ import type { PenFill, PenStroke, PenEffect, ShadowEffect, BlurEffect } 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 Flutter (Dart) code.
9
+ * $variable references are output as var(--name) comments for manual mapping.
10
+ */
11
+
12
+ function varOrLiteral(value: string): string {
13
+ if (isVariableRef(value)) return `var(${variableNameToCSS(value.slice(1))})`
14
+ return value
15
+ }
16
+
17
+ function indent(depth: number): string {
18
+ return ' '.repeat(depth)
19
+ }
20
+
21
+ function hexToFlutterColor(hex: string): string {
22
+ if (hex.startsWith('$')) return `Color(0x00000000) /* ${varOrLiteral(hex)} */`
23
+ const clean = hex.replace('#', '')
24
+ if (clean.length === 6) return `Color(0xFF${clean.toUpperCase()})`
25
+ if (clean.length === 8) {
26
+ const [rr, gg, bb, aa] = [clean.substring(0, 2), clean.substring(2, 4), clean.substring(4, 6), clean.substring(6, 8)]
27
+ return `Color(0x${aa.toUpperCase()}${rr.toUpperCase()}${gg.toUpperCase()}${bb.toUpperCase()})`
28
+ }
29
+ return `Color(0x00000000) /* ${hex} */`
30
+ }
31
+
32
+ function escapeDartString(text: string): string {
33
+ return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\$/g, '\\$').replace(/\n/g, '\\n')
34
+ }
35
+
36
+ function getTextContent(node: TextNode): string {
37
+ if (typeof node.content === 'string') return node.content
38
+ return node.content.map((s) => s.text).join('')
39
+ }
40
+
41
+ function fillToDecoration(fills: PenFill[] | undefined): { color?: string; gradient?: string } | null {
42
+ if (!fills || fills.length === 0) return null
43
+ const fill = fills[0]
44
+ if (fill.type === 'solid') return { color: hexToFlutterColor(fill.color) }
45
+ if (fill.type === 'linear_gradient') {
46
+ if (!fill.stops?.length) return null
47
+ const colors = fill.stops.map((s) => hexToFlutterColor(s.color)).join(', ')
48
+ return { gradient: `LinearGradient(colors: [${colors}])` }
49
+ }
50
+ if (fill.type === 'radial_gradient') {
51
+ if (!fill.stops?.length) return null
52
+ const colors = fill.stops.map((s) => hexToFlutterColor(s.color)).join(', ')
53
+ return { gradient: `RadialGradient(colors: [${colors}])` }
54
+ }
55
+ return null
56
+ }
57
+
58
+ function fillColorOnly(fills: PenFill[] | undefined): string | null {
59
+ if (!fills || fills.length === 0) return null
60
+ const fill = fills[0]
61
+ return fill.type === 'solid' ? hexToFlutterColor(fill.color) : null
62
+ }
63
+
64
+ function cornerRadiusToFlutter(cr: number | [number, number, number, number] | undefined): string | null {
65
+ if (cr === undefined) return null
66
+ if (typeof cr === 'number') return cr > 0 ? `BorderRadius.circular(${cr})` : null
67
+ const [tl, tr, br, bl] = cr
68
+ if (tl === tr && tr === br && br === bl) return tl > 0 ? `BorderRadius.circular(${tl})` : null
69
+ return `BorderRadius.only(topLeft: Radius.circular(${tl}), topRight: Radius.circular(${tr}), bottomRight: Radius.circular(${br}), bottomLeft: Radius.circular(${bl}))`
70
+ }
71
+
72
+ function strokeToFlutterBorder(stroke: PenStroke | undefined): string | null {
73
+ if (!stroke) return null
74
+ const thickness = typeof stroke.thickness === 'number'
75
+ ? stroke.thickness
76
+ : typeof stroke.thickness === 'string' ? stroke.thickness : stroke.thickness[0]
77
+ const thicknessStr = typeof thickness === 'string' && isVariableRef(thickness)
78
+ ? `/* ${varOrLiteral(thickness)} */ 1` : String(thickness)
79
+ let strokeColor = 'Colors.grey'
80
+ if (stroke.fill && stroke.fill.length > 0 && stroke.fill[0].type === 'solid') {
81
+ strokeColor = hexToFlutterColor(stroke.fill[0].color)
82
+ }
83
+ return `Border.all(color: ${strokeColor}, width: ${thicknessStr})`
84
+ }
85
+
86
+ function effectsToBoxShadows(effects: PenEffect[] | undefined): string[] {
87
+ if (!effects || effects.length === 0) return []
88
+ const shadows: string[] = []
89
+ for (const effect of effects) {
90
+ if (effect.type === 'shadow') {
91
+ const s = effect as ShadowEffect
92
+ shadows.push(`BoxShadow(color: ${hexToFlutterColor(s.color)}, blurRadius: ${s.blur}, offset: Offset(${s.offsetX}, ${s.offsetY}))`)
93
+ }
94
+ }
95
+ return shadows
96
+ }
97
+
98
+ function hasBlurEffect(effects: PenEffect[] | undefined): BlurEffect | null {
99
+ if (!effects) return null
100
+ const found = effects.find((e) => e.type === 'blur' || e.type === 'background_blur')
101
+ return found ? (found as BlurEffect) : null
102
+ }
103
+
104
+ function paddingToFlutter(
105
+ padding: number | [number, number] | [number, number, number, number] | string | undefined,
106
+ ): string | null {
107
+ if (padding === undefined) return null
108
+ if (typeof padding === 'string' && isVariableRef(padding)) return `EdgeInsets.all(/* ${varOrLiteral(padding)} */ 0)`
109
+ if (typeof padding === 'number') return padding > 0 ? `EdgeInsets.all(${padding})` : null
110
+ if (Array.isArray(padding)) {
111
+ if (padding.length === 2) return `EdgeInsets.symmetric(vertical: ${padding[0]}, horizontal: ${padding[1]})`
112
+ if (padding.length === 4) {
113
+ const [top, right, bottom, left] = padding
114
+ return `EdgeInsets.fromLTRB(${left}, ${top}, ${right}, ${bottom})`
115
+ }
116
+ }
117
+ return null
118
+ }
119
+
120
+ function crossAxisToFlutter(alignItems: string | undefined): string | null {
121
+ if (!alignItems) return null
122
+ const m: Record<string, string> = { start: 'CrossAxisAlignment.start', center: 'CrossAxisAlignment.center', end: 'CrossAxisAlignment.end' }
123
+ return m[alignItems] ?? null
124
+ }
125
+
126
+ function mainAxisToFlutter(justifyContent: string | undefined): string | null {
127
+ if (!justifyContent) return null
128
+ const m: Record<string, string> = {
129
+ start: 'MainAxisAlignment.start', center: 'MainAxisAlignment.center', end: 'MainAxisAlignment.end',
130
+ space_between: 'MainAxisAlignment.spaceBetween', space_around: 'MainAxisAlignment.spaceAround',
131
+ }
132
+ return m[justifyContent] ?? null
133
+ }
134
+
135
+ function fontWeightToFlutter(weight: number | string | undefined): string | null {
136
+ if (weight === undefined) return null
137
+ const w = typeof weight === 'number' ? weight : parseInt(weight, 10)
138
+ if (isNaN(w)) return null
139
+ if (w <= 100) return 'FontWeight.w100'
140
+ if (w <= 200) return 'FontWeight.w200'
141
+ if (w <= 300) return 'FontWeight.w300'
142
+ if (w <= 400) return 'FontWeight.w400'
143
+ if (w <= 500) return 'FontWeight.w500'
144
+ if (w <= 600) return 'FontWeight.w600'
145
+ if (w <= 700) return 'FontWeight.w700'
146
+ if (w <= 800) return 'FontWeight.w800'
147
+ return 'FontWeight.w900'
148
+ }
149
+
150
+ function textAlignToFlutter(align: string | undefined): string | null {
151
+ if (!align) return null
152
+ const m: Record<string, string> = { left: 'TextAlign.left', center: 'TextAlign.center', right: 'TextAlign.right', justify: 'TextAlign.justify' }
153
+ return m[align] ?? null
154
+ }
155
+
156
+ function buildBoxDecoration(
157
+ fills: PenFill[] | undefined, cornerRadius: number | [number, number, number, number] | undefined,
158
+ stroke: PenStroke | undefined, effects: PenEffect[] | undefined, pad: string,
159
+ ): string | null {
160
+ const fillResult = fillToDecoration(fills)
161
+ const borderRadius = cornerRadiusToFlutter(cornerRadius)
162
+ const border = strokeToFlutterBorder(stroke)
163
+ const shadows = effectsToBoxShadows(effects)
164
+ if (!fillResult && !borderRadius && !border && shadows.length === 0) return null
165
+
166
+ const parts: string[] = []
167
+ if (fillResult?.color) parts.push(`${pad} color: ${fillResult.color},`)
168
+ if (fillResult?.gradient) parts.push(`${pad} gradient: ${fillResult.gradient},`)
169
+ if (borderRadius) parts.push(`${pad} borderRadius: ${borderRadius},`)
170
+ if (border) parts.push(`${pad} border: ${border},`)
171
+ if (shadows.length > 0) {
172
+ parts.push(`${pad} boxShadow: [`)
173
+ for (const s of shadows) parts.push(`${pad} ${s},`)
174
+ parts.push(`${pad} ],`)
175
+ }
176
+ return `BoxDecoration(\n${parts.join('\n')}\n${pad} )`
177
+ }
178
+
179
+ // Wrapper helpers
180
+ function wrapOpacity(widget: string, node: PenNode, depth: number): string {
181
+ if (node.opacity === undefined || node.opacity === 1) return widget
182
+ const pad = indent(depth)
183
+ if (typeof node.opacity === 'string' && isVariableRef(node.opacity))
184
+ return `${pad}Opacity(\n${pad} opacity: /* ${varOrLiteral(node.opacity)} */ 1.0,\n${pad} child: ${widget.trimStart()},\n${pad})`
185
+ if (typeof node.opacity === 'number')
186
+ return `${pad}Opacity(\n${pad} opacity: ${node.opacity},\n${pad} child: ${widget.trimStart()},\n${pad})`
187
+ return widget
188
+ }
189
+
190
+ function wrapRotation(widget: string, node: PenNode, depth: number): string {
191
+ if (!node.rotation) return widget
192
+ const pad = indent(depth)
193
+ return `${pad}Transform.rotate(\n${pad} angle: ${node.rotation} * pi / 180,\n${pad} child: ${widget.trimStart()},\n${pad})`
194
+ }
195
+
196
+ function wrapBlur(widget: string, effects: PenEffect[] | undefined, depth: number): string {
197
+ const blur = hasBlurEffect(effects)
198
+ if (!blur) return widget
199
+ const pad = indent(depth)
200
+ const r = blur.radius ?? 0
201
+ return `${pad}BackdropFilter(\n${pad} filter: ImageFilter.blur(sigmaX: ${r}, sigmaY: ${r}),\n${pad} child: ${widget.trimStart()},\n${pad})`
202
+ }
203
+
204
+ function applyWrappers(widget: string, node: PenNode, depth: number): string {
205
+ let result = wrapBlur(widget, (node as any).effects, depth)
206
+ result = wrapOpacity(result, node, depth)
207
+ result = wrapRotation(result, node, depth)
208
+ return result
209
+ }
210
+
211
+ // Node generators
212
+ function generateNodeFlutter(node: PenNode, depth: number): string {
213
+ switch (node.type) {
214
+ case 'frame': case 'rectangle': case 'group': return generateContainerFlutter(node, depth)
215
+ case 'ellipse': return generateEllipseFlutter(node as EllipseNode, depth)
216
+ case 'text': return generateTextFlutter(node as TextNode, depth)
217
+ case 'line': return generateLineFlutter(node as LineNode, depth)
218
+ case 'path': return generatePathFlutter(node as PathNode, depth)
219
+ case 'polygon': return generatePolygonFlutter(node as PolygonNode, depth)
220
+ case 'image': return generateImageFlutter(node as ImageNode, depth)
221
+ case 'icon_font': {
222
+ const size = typeof node.width === 'number' ? node.width : 24
223
+ const color = node.fill?.[0]?.type === 'solid' ? node.fill[0].color : null
224
+ const iconName = (node.iconFontName || 'circle').replace(/-/g, '_')
225
+ const colorStr = color ? `, color: Color(0xFF${color.replace('#', '')})` : ''
226
+ return `${indent(depth)}Icon(LucideIcons.${iconName}, size: ${size}${colorStr})`
227
+ }
228
+ case 'ref': return `${indent(depth)}// Ref: ${(node as any).ref}`
229
+ default: return `${indent(depth)}// Unsupported node type`
230
+ }
231
+ }
232
+
233
+ function generateContainerFlutter(node: PenNode & ContainerProps, depth: number): string {
234
+ const pad = indent(depth)
235
+ const children = node.children ?? []
236
+ const hasLayout = node.layout === 'vertical' || node.layout === 'horizontal'
237
+ const gap = typeof node.gap === 'number' ? node.gap : 0
238
+ const gapIsVar = typeof node.gap === 'string' && isVariableRef(node.gap)
239
+ const gapComment = gapIsVar ? ` /* ${varOrLiteral(node.gap as string)} */` : ''
240
+ const decoration = buildBoxDecoration(node.fill, node.cornerRadius, node.stroke, node.effects, pad)
241
+ const paddingStr = paddingToFlutter(node.padding)
242
+ const comment = node.name ? `${pad}// ${node.name}\n` : ''
243
+
244
+ let innerWidget: string
245
+
246
+ if (children.length === 0 && !hasLayout) {
247
+ innerWidget = buildContainer(pad, decoration, paddingStr, node, null)
248
+ } else if (hasLayout) {
249
+ const isVertical = node.layout === 'vertical'
250
+ const layoutType = isVertical ? 'Column' : 'Row'
251
+ const crossAxis = crossAxisToFlutter(node.alignItems)
252
+ const mainAxis = mainAxisToFlutter(node.justifyContent)
253
+ const layoutParams: string[] = []
254
+ if (mainAxis) layoutParams.push(`${pad} mainAxisAlignment: ${mainAxis},`)
255
+ if (crossAxis) layoutParams.push(`${pad} crossAxisAlignment: ${crossAxis},`)
256
+ layoutParams.push(`${pad} mainAxisSize: MainAxisSize.min,`)
257
+
258
+ const childWidgets: string[] = []
259
+ for (let i = 0; i < children.length; i++) {
260
+ childWidgets.push(generateNodeFlutter(children[i], depth + 2))
261
+ if (i < children.length - 1 && (gap > 0 || gapIsVar)) {
262
+ const spacer = isVertical
263
+ ? `${indent(depth + 2)}SizedBox(height: ${gapIsVar ? `0${gapComment}` : gap}),`
264
+ : `${indent(depth + 2)}SizedBox(width: ${gapIsVar ? `0${gapComment}` : gap}),`
265
+ childWidgets.push(spacer)
266
+ }
267
+ }
268
+ const layoutWidget = [
269
+ `${pad} ${layoutType}(`, ...layoutParams,
270
+ `${pad} children: [`, ...childWidgets.map((c) => c + ','),
271
+ `${pad} ],`, `${pad} )`,
272
+ ].join('\n')
273
+ innerWidget = buildContainer(pad, decoration, paddingStr, node, layoutWidget)
274
+ } else {
275
+ const childWidgets = children.map((c) => {
276
+ const childStr = generateNodeFlutter(c, depth + 3)
277
+ const cx = c.x ?? 0, cy = c.y ?? 0
278
+ if (cx !== 0 || cy !== 0) {
279
+ const cPad = indent(depth + 2)
280
+ return `${cPad}Positioned(\n${cPad} left: ${cx},\n${cPad} top: ${cy},\n${cPad} child: ${childStr.trimStart()},\n${cPad})`
281
+ }
282
+ return childStr
283
+ })
284
+ const stackWidget = [
285
+ `${pad} Stack(`, `${pad} children: [`,
286
+ ...childWidgets.map((c) => c + ','), `${pad} ],`, `${pad} )`,
287
+ ].join('\n')
288
+ innerWidget = buildContainer(pad, decoration, paddingStr, node, stackWidget)
289
+ }
290
+
291
+ return `${comment}${applyWrappers(innerWidget, node, depth)}`
292
+ }
293
+
294
+ function buildContainer(
295
+ pad: string, decoration: string | null, paddingStr: string | null,
296
+ node: PenNode & ContainerProps, child: string | null,
297
+ ): string {
298
+ const parts: string[] = [`${pad}Container(`]
299
+ if (typeof node.width === 'number') parts.push(`${pad} width: ${node.width},`)
300
+ if (typeof node.height === 'number') parts.push(`${pad} height: ${node.height},`)
301
+ if (paddingStr) parts.push(`${pad} padding: ${paddingStr},`)
302
+ if (decoration) parts.push(`${pad} decoration: ${decoration},`)
303
+ if (node.clipContent) parts.push(`${pad} clipBehavior: Clip.hardEdge,`)
304
+ if (child) parts.push(`${pad} child: ${child.trimStart()},`)
305
+ parts.push(`${pad})`)
306
+ return parts.join('\n')
307
+ }
308
+
309
+ function generateEllipseFlutter(node: EllipseNode, depth: number): string {
310
+ const pad = indent(depth)
311
+ const w = typeof node.width === 'number' ? node.width : undefined
312
+ const h = typeof node.height === 'number' ? node.height : undefined
313
+ const fillResult = fillToDecoration(node.fill)
314
+ const shadows = effectsToBoxShadows(node.effects)
315
+ const border = strokeToFlutterBorder(node.stroke)
316
+
317
+ const decParts: string[] = [`${pad} shape: BoxShape.circle,`]
318
+ if (fillResult?.color) decParts.push(`${pad} color: ${fillResult.color},`)
319
+ if (fillResult?.gradient) decParts.push(`${pad} gradient: ${fillResult.gradient},`)
320
+ if (border) decParts.push(`${pad} border: ${border},`)
321
+ if (shadows.length > 0) {
322
+ decParts.push(`${pad} boxShadow: [`)
323
+ for (const s of shadows) decParts.push(`${pad} ${s},`)
324
+ decParts.push(`${pad} ],`)
325
+ }
326
+
327
+ const parts: string[] = [`${pad}Container(`]
328
+ if (w !== undefined) parts.push(`${pad} width: ${w},`)
329
+ if (h !== undefined) parts.push(`${pad} height: ${h},`)
330
+ parts.push(`${pad} decoration: BoxDecoration(\n${decParts.join('\n')}\n${pad} ),`)
331
+ parts.push(`${pad})`)
332
+ return applyWrappers(parts.join('\n'), node, depth)
333
+ }
334
+
335
+ function generateTextFlutter(node: TextNode, depth: number): string {
336
+ const pad = indent(depth)
337
+ const text = escapeDartString(getTextContent(node))
338
+ const styleParts: string[] = []
339
+ if (node.fontSize) styleParts.push(`fontSize: ${node.fontSize}`)
340
+ const fw = fontWeightToFlutter(node.fontWeight)
341
+ if (fw) styleParts.push(`fontWeight: ${fw}`)
342
+ if (node.fontStyle === 'italic') styleParts.push('fontStyle: FontStyle.italic')
343
+ if (node.fontFamily) styleParts.push(`fontFamily: '${escapeDartString(node.fontFamily)}'`)
344
+ if (node.letterSpacing) styleParts.push(`letterSpacing: ${node.letterSpacing}`)
345
+ if (node.lineHeight && node.fontSize) styleParts.push(`height: ${node.lineHeight}`)
346
+ const textColor = fillColorOnly(node.fill)
347
+ if (textColor) styleParts.push(`color: ${textColor}`)
348
+
349
+ const decorations: string[] = []
350
+ if (node.underline) decorations.push('TextDecoration.underline')
351
+ if (node.strikethrough) decorations.push('TextDecoration.lineThrough')
352
+ if (decorations.length === 1) styleParts.push(`decoration: ${decorations[0]}`)
353
+ else if (decorations.length > 1) styleParts.push(`decoration: TextDecoration.combine([${decorations.join(', ')}])`)
354
+
355
+ const textAlign = textAlignToFlutter(node.textAlign)
356
+ const params: string[] = [`${pad} '${text}'`]
357
+ if (textAlign) params.push(`${pad} textAlign: ${textAlign}`)
358
+ if (styleParts.length > 0) params.push(`${pad} style: TextStyle(${styleParts.join(', ')})`)
359
+
360
+ const textWidget = `${pad}Text(\n${params.join(',\n')},\n${pad})`
361
+ let widget: string
362
+ if (typeof node.width === 'number' || typeof node.height === 'number') {
363
+ const sp: string[] = [`${pad}SizedBox(`]
364
+ if (typeof node.width === 'number') sp.push(`${pad} width: ${node.width},`)
365
+ if (typeof node.height === 'number') sp.push(`${pad} height: ${node.height},`)
366
+ sp.push(`${pad} child: ${textWidget.trimStart()},`)
367
+ sp.push(`${pad})`)
368
+ widget = sp.join('\n')
369
+ } else {
370
+ widget = textWidget
371
+ }
372
+ return applyWrappers(widget, node, depth)
373
+ }
374
+
375
+ function generateLineFlutter(node: LineNode, depth: number): string {
376
+ const pad = indent(depth)
377
+ const w = node.x2 !== undefined ? Math.abs(node.x2 - (node.x ?? 0)) : undefined
378
+ const thickness = node.stroke
379
+ ? (typeof node.stroke.thickness === 'number' ? node.stroke.thickness
380
+ : typeof node.stroke.thickness === 'string' ? 1 : node.stroke.thickness[0])
381
+ : 1
382
+ let color = 'Colors.grey'
383
+ if (node.stroke?.fill && node.stroke.fill.length > 0 && node.stroke.fill[0].type === 'solid')
384
+ color = hexToFlutterColor(node.stroke.fill[0].color)
385
+
386
+ const parts: string[] = [`${pad}Container(`]
387
+ if (w !== undefined) parts.push(`${pad} width: ${w},`)
388
+ parts.push(`${pad} height: ${thickness},`)
389
+ parts.push(`${pad} color: ${color},`)
390
+ parts.push(`${pad})`)
391
+ return applyWrappers(parts.join('\n'), node, depth)
392
+ }
393
+
394
+ function generatePathFlutter(node: PathNode, depth: number): string {
395
+ const pad = indent(depth)
396
+ const fillColor = fillColorOnly(node.fill) ?? 'Colors.black'
397
+ const w = typeof node.width === 'number' ? node.width : 24
398
+ const h = typeof node.height === 'number' ? node.height : 24
399
+ const widget = [
400
+ `${pad}// ${node.name ?? 'Path'}`,
401
+ `${pad}CustomPaint(`,
402
+ `${pad} size: Size(${w}, ${h}),`,
403
+ `${pad} painter: _PathPainter('${escapeDartString(node.d)}', ${fillColor}),`,
404
+ `${pad})`,
405
+ ].join('\n')
406
+ return applyWrappers(widget, node, depth)
407
+ }
408
+
409
+ function generatePolygonFlutter(node: PolygonNode, depth: number): string {
410
+ const pad = indent(depth)
411
+ const fillColor = fillColorOnly(node.fill) ?? 'Colors.black'
412
+ const w = typeof node.width === 'number' ? node.width : 24
413
+ const h = typeof node.height === 'number' ? node.height : 24
414
+ const widget = [
415
+ `${pad}// Polygon (${node.polygonCount}-sided)`,
416
+ `${pad}CustomPaint(`,
417
+ `${pad} size: Size(${w}, ${h}),`,
418
+ `${pad} painter: _PolygonPainter(${node.polygonCount}, ${fillColor}),`,
419
+ `${pad})`,
420
+ ].join('\n')
421
+ return applyWrappers(widget, node, depth)
422
+ }
423
+
424
+ function generateImageFlutter(node: ImageNode, depth: number): string {
425
+ const pad = indent(depth)
426
+ const w = typeof node.width === 'number' ? node.width : undefined
427
+ const h = typeof node.height === 'number' ? node.height : undefined
428
+ const fit = node.objectFit === 'fit' ? 'BoxFit.contain' : 'BoxFit.cover'
429
+ const src = node.src
430
+
431
+ let ctor: string, firstArg: string
432
+ if (src.startsWith('data:image/')) {
433
+ const base64Data = src.replace(/^data:image\/[^;]+;base64,/, '')
434
+ ctor = 'Image.memory'
435
+ firstArg = `base64Decode('${escapeDartString(base64Data)}')`
436
+ } else if (src.startsWith('http://') || src.startsWith('https://')) {
437
+ ctor = 'Image.network'
438
+ firstArg = `'${escapeDartString(src)}'`
439
+ } else {
440
+ ctor = 'Image.asset'
441
+ firstArg = `'${escapeDartString(src)}'`
442
+ }
443
+
444
+ const parts: string[] = [`${pad}${ctor}(`]
445
+ parts.push(`${pad} ${firstArg},`)
446
+ if (w !== undefined) parts.push(`${pad} width: ${w},`)
447
+ if (h !== undefined) parts.push(`${pad} height: ${h},`)
448
+ parts.push(`${pad} fit: ${fit},`)
449
+ parts.push(`${pad})`)
450
+ let widget = parts.join('\n')
451
+
452
+ if (node.cornerRadius) {
453
+ const br = cornerRadiusToFlutter(node.cornerRadius)
454
+ if (br) widget = `${pad}ClipRRect(\n${pad} borderRadius: ${br},\n${pad} child: ${widget.trimStart()},\n${pad})`
455
+ }
456
+ return applyWrappers(widget, node, depth)
457
+ }
458
+
459
+ function getHelperClasses(nodes: PenNode[]): string {
460
+ let needsPath = false, needsPolygon = false
461
+ function walk(list: PenNode[]) {
462
+ for (const n of list) {
463
+ if (n.type === 'path') needsPath = true
464
+ if (n.type === 'polygon') needsPolygon = true
465
+ if ('children' in n && (n as any).children) walk((n as any).children)
466
+ }
467
+ }
468
+ walk(nodes)
469
+ const helpers: string[] = []
470
+ if (needsPath) {
471
+ helpers.push(
472
+ `class _PathPainter extends CustomPainter {
473
+ final String pathData;
474
+ final Color color;
475
+ _PathPainter(this.pathData, this.color);
476
+
477
+ @override
478
+ void paint(Canvas canvas, Size size) {
479
+ final paint = Paint()..color = color;
480
+ final path = parseSvgPathData(pathData);
481
+ canvas.drawPath(path, paint);
482
+ }
483
+
484
+ @override
485
+ bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
486
+ }`)
487
+ }
488
+ if (needsPolygon) {
489
+ helpers.push(
490
+ `class _PolygonPainter extends CustomPainter {
491
+ final int sides;
492
+ final Color color;
493
+ _PolygonPainter(this.sides, this.color);
494
+
495
+ @override
496
+ void paint(Canvas canvas, Size size) {
497
+ final paint = Paint()..color = color;
498
+ final path = Path();
499
+ final cx = size.width / 2, cy = size.height / 2, r = size.width / 2;
500
+ for (var i = 0; i < sides; i++) {
501
+ final angle = (i * 2 * pi / sides) - (pi / 2);
502
+ final x = cx + r * cos(angle);
503
+ final y = cy + r * sin(angle);
504
+ i == 0 ? path.moveTo(x, y) : path.lineTo(x, y);
505
+ }
506
+ path.close();
507
+ canvas.drawPath(path, paint);
508
+ }
509
+
510
+ @override
511
+ bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
512
+ }`)
513
+ }
514
+ return helpers.join('\n\n')
515
+ }
516
+
517
+ export function generateFlutterCode(
518
+ nodes: PenNode[],
519
+ widgetName = 'GeneratedDesign',
520
+ ): string {
521
+ if (nodes.length === 0) {
522
+ return `import 'package:flutter/material.dart';\n\nclass ${widgetName} extends StatelessWidget {\n const ${widgetName}({super.key});\n\n @override\n Widget build(BuildContext context) {\n return const SizedBox.shrink();\n }\n}\n`
523
+ }
524
+
525
+ let maxW = 0, maxH = 0
526
+ for (const node of nodes) {
527
+ const x = node.x ?? 0, y = node.y ?? 0
528
+ const w = 'width' in node && typeof node.width === 'number' ? node.width : 0
529
+ const h = 'height' in node && typeof node.height === 'number' ? node.height : 0
530
+ maxW = Math.max(maxW, x + w)
531
+ maxH = Math.max(maxH, y + h)
532
+ }
533
+
534
+ const childWidgets = nodes.map((n) => {
535
+ const childStr = generateNodeFlutter(n, 5)
536
+ const cx = n.x ?? 0, cy = n.y ?? 0
537
+ if (cx !== 0 || cy !== 0) {
538
+ const cPad = indent(4)
539
+ return `${cPad}Positioned(\n${cPad} left: ${cx},\n${cPad} top: ${cy},\n${cPad} child: ${childStr.trimStart()},\n${cPad})`
540
+ }
541
+ return childStr
542
+ })
543
+
544
+ const sizeArgs: string[] = []
545
+ if (maxW > 0) sizeArgs.push(`\n width: ${maxW},`)
546
+ if (maxH > 0) sizeArgs.push(`\n height: ${maxH},`)
547
+ const sizedBoxParams = sizeArgs.length > 0 ? sizeArgs.join('') : '\n width: double.infinity,\n height: double.infinity,'
548
+
549
+ const helpers = getHelperClasses(nodes)
550
+ const helperSection = helpers ? `\n\n${helpers}` : ''
551
+
552
+ return `import 'dart:convert';
553
+ import 'dart:math';
554
+ import 'package:flutter/material.dart';
555
+
556
+ class ${widgetName} extends StatelessWidget {
557
+ const ${widgetName}({super.key});
558
+
559
+ @override
560
+ Widget build(BuildContext context) {
561
+ return SizedBox(${sizedBoxParams}
562
+ child: Stack(
563
+ children: [
564
+ ${childWidgets.map((c) => c + ',').join('\n')}
565
+ ],
566
+ ),
567
+ );
568
+ }
569
+ }${helperSection}
570
+ `
571
+ }
572
+
573
+ export function generateFlutterFromDocument(
574
+ doc: PenDocument,
575
+ activePageId?: string | null,
576
+ ): string {
577
+ const children = activePageId !== undefined
578
+ ? getActivePageChildren(doc, activePageId)
579
+ : doc.children
580
+ return generateFlutterCode(children, 'GeneratedDesign')
581
+ }