@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,807 @@
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 } 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 Jetpack Compose (Kotlin) 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
+ function kebabToPascal(name: string): string {
25
+ return name.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')
26
+ }
27
+
28
+ /** Parse a hex color string to Compose Color() call. */
29
+ function hexToComposeColor(hex: string): string {
30
+ if (hex.startsWith('$')) {
31
+ return `Color.Unspecified /* ${varOrLiteral(hex)} */`
32
+ }
33
+ const clean = hex.replace('#', '')
34
+ if (clean.length === 6) {
35
+ return `Color(0xFF${clean.toUpperCase()})`
36
+ }
37
+ if (clean.length === 8) {
38
+ // RRGGBBAA -> AARRGGBB for Compose
39
+ const rr = clean.substring(0, 2)
40
+ const gg = clean.substring(2, 4)
41
+ const bb = clean.substring(4, 6)
42
+ const aa = clean.substring(6, 8)
43
+ return `Color(0x${aa.toUpperCase()}${rr.toUpperCase()}${gg.toUpperCase()}${bb.toUpperCase()})`
44
+ }
45
+ return `Color.Unspecified /* ${hex} */`
46
+ }
47
+
48
+ function fillToComposeBackground(fills: PenFill[] | undefined): string | null {
49
+ if (!fills || fills.length === 0) return null
50
+ const fill = fills[0]
51
+ if (fill.type === 'solid') {
52
+ return hexToComposeColor(fill.color)
53
+ }
54
+ if (fill.type === 'linear_gradient') {
55
+ if (!fill.stops?.length) return null
56
+ const colors = fill.stops.map((s) => hexToComposeColor(s.color)).join(', ')
57
+ return `Brush.linearGradient(listOf(${colors}))`
58
+ }
59
+ if (fill.type === 'radial_gradient') {
60
+ if (!fill.stops?.length) return null
61
+ const colors = fill.stops.map((s) => hexToComposeColor(s.color)).join(', ')
62
+ return `Brush.radialGradient(listOf(${colors}))`
63
+ }
64
+ return null
65
+ }
66
+
67
+ function fillToComposeModifier(
68
+ fills: PenFill[] | undefined,
69
+ cornerRadius: number | [number, number, number, number] | undefined,
70
+ ): string | null {
71
+ if (!fills || fills.length === 0) return null
72
+ const fill = fills[0]
73
+ const shapeStr = cornerRadiusToComposeShape(cornerRadius)
74
+
75
+ if (fill.type === 'solid') {
76
+ const color = hexToComposeColor(fill.color)
77
+ if (shapeStr) {
78
+ return `.background(${color}, ${shapeStr})`
79
+ }
80
+ return `.background(${color})`
81
+ }
82
+ if (fill.type === 'linear_gradient') {
83
+ if (!fill.stops?.length) return null
84
+ const colors = fill.stops.map((s) => hexToComposeColor(s.color)).join(', ')
85
+ const brush = `Brush.linearGradient(listOf(${colors}))`
86
+ if (shapeStr) {
87
+ return `.background(${brush}, ${shapeStr})`
88
+ }
89
+ return `.background(${brush})`
90
+ }
91
+ if (fill.type === 'radial_gradient') {
92
+ if (!fill.stops?.length) return null
93
+ const colors = fill.stops.map((s) => hexToComposeColor(s.color)).join(', ')
94
+ const brush = `Brush.radialGradient(listOf(${colors}))`
95
+ if (shapeStr) {
96
+ return `.background(${brush}, ${shapeStr})`
97
+ }
98
+ return `.background(${brush})`
99
+ }
100
+ return null
101
+ }
102
+
103
+ function cornerRadiusToComposeShape(
104
+ cr: number | [number, number, number, number] | undefined,
105
+ ): string | null {
106
+ if (cr === undefined) return null
107
+ if (typeof cr === 'number') {
108
+ if (cr === 0) return null
109
+ return `RoundedCornerShape(${cr}.dp)`
110
+ }
111
+ const [tl, tr, br, bl] = cr
112
+ if (tl === tr && tr === br && br === bl) {
113
+ return tl === 0 ? null : `RoundedCornerShape(${tl}.dp)`
114
+ }
115
+ return `RoundedCornerShape(topStart = ${tl}.dp, topEnd = ${tr}.dp, bottomEnd = ${br}.dp, bottomStart = ${bl}.dp)`
116
+ }
117
+
118
+ function strokeToComposeModifier(
119
+ stroke: PenStroke | undefined,
120
+ cornerRadius: number | [number, number, number, number] | undefined,
121
+ ): string | null {
122
+ if (!stroke) return null
123
+ const thickness = typeof stroke.thickness === 'number'
124
+ ? stroke.thickness
125
+ : typeof stroke.thickness === 'string'
126
+ ? stroke.thickness
127
+ : stroke.thickness[0]
128
+ const thicknessStr = typeof thickness === 'string' && isVariableRef(thickness)
129
+ ? `/* ${varOrLiteral(thickness)} */ 1.dp`
130
+ : `${thickness}.dp`
131
+
132
+ let strokeColor = 'Color.Gray'
133
+ if (stroke.fill && stroke.fill.length > 0) {
134
+ const sf = stroke.fill[0]
135
+ if (sf.type === 'solid') {
136
+ strokeColor = hexToComposeColor(sf.color)
137
+ }
138
+ }
139
+
140
+ const shape = cornerRadiusToComposeShape(cornerRadius) ?? 'RectangleShape'
141
+ return `.border(${thicknessStr}, ${strokeColor}, ${shape})`
142
+ }
143
+
144
+ function effectsToComposeModifier(effects: PenEffect[] | undefined): string[] {
145
+ if (!effects || effects.length === 0) return []
146
+ const modifiers: string[] = []
147
+ for (const effect of effects) {
148
+ if (effect.type === 'shadow') {
149
+ const s = effect as ShadowEffect
150
+ const shape = 'RoundedCornerShape(0.dp)'
151
+ modifiers.push(`.shadow(elevation = ${s.blur}.dp, shape = ${shape})`)
152
+ }
153
+ // blur effects not directly supported as modifier; skip with comment
154
+ if (effect.type === 'blur' || effect.type === 'background_blur') {
155
+ modifiers.push(`// .blur(radius = ${effect.radius}.dp) — requires custom implementation`)
156
+ }
157
+ }
158
+ return modifiers
159
+ }
160
+
161
+ function paddingToCompose(
162
+ padding: number | [number, number] | [number, number, number, number] | string | undefined,
163
+ ): string | null {
164
+ if (padding === undefined) return null
165
+ if (typeof padding === 'string' && isVariableRef(padding)) {
166
+ return `.padding(/* ${varOrLiteral(padding)} */ 0.dp)`
167
+ }
168
+ if (typeof padding === 'number') {
169
+ return padding > 0 ? `.padding(${padding}.dp)` : null
170
+ }
171
+ if (Array.isArray(padding)) {
172
+ if (padding.length === 2) {
173
+ return `.padding(vertical = ${padding[0]}.dp, horizontal = ${padding[1]}.dp)`
174
+ }
175
+ if (padding.length === 4) {
176
+ const [top, end, bottom, start] = padding
177
+ return `.padding(start = ${start}.dp, top = ${top}.dp, end = ${end}.dp, bottom = ${bottom}.dp)`
178
+ }
179
+ }
180
+ return null
181
+ }
182
+
183
+ function getTextContent(node: TextNode): string {
184
+ if (typeof node.content === 'string') return node.content
185
+ return node.content.map((s) => s.text).join('')
186
+ }
187
+
188
+ function escapeKotlinString(text: string): string {
189
+ return text
190
+ .replace(/\\/g, '\\\\')
191
+ .replace(/"/g, '\\"')
192
+ .replace(/\n/g, '\\n')
193
+ .replace(/\$/g, '\\$')
194
+ }
195
+
196
+ function fontWeightToCompose(weight: number | string | undefined): string | null {
197
+ if (weight === undefined) return null
198
+ const w = typeof weight === 'number' ? weight : parseInt(weight, 10)
199
+ if (isNaN(w)) return null
200
+ if (w <= 100) return 'FontWeight.Thin'
201
+ if (w <= 200) return 'FontWeight.ExtraLight'
202
+ if (w <= 300) return 'FontWeight.Light'
203
+ if (w <= 400) return 'FontWeight.Normal'
204
+ if (w <= 500) return 'FontWeight.Medium'
205
+ if (w <= 600) return 'FontWeight.SemiBold'
206
+ if (w <= 700) return 'FontWeight.Bold'
207
+ if (w <= 800) return 'FontWeight.ExtraBold'
208
+ return 'FontWeight.Black'
209
+ }
210
+
211
+ function textAlignToCompose(align: string | undefined): string | null {
212
+ if (!align) return null
213
+ const map: Record<string, string> = {
214
+ left: 'TextAlign.Start',
215
+ center: 'TextAlign.Center',
216
+ right: 'TextAlign.End',
217
+ justify: 'TextAlign.Justify',
218
+ }
219
+ return map[align] ?? null
220
+ }
221
+
222
+ /** Build the Modifier chain as a multi-line string. */
223
+ function buildModifierChain(modifiers: string[], pad: string): string {
224
+ if (modifiers.length === 0) return 'Modifier'
225
+ return `Modifier\n${modifiers.map((m) => `${pad} ${m}`).join('\n')}`
226
+ }
227
+
228
+ /** Generate Compose code for a single node. */
229
+ function generateNodeCompose(node: PenNode, depth: number): string {
230
+ const pad = indent(depth)
231
+
232
+ switch (node.type) {
233
+ case 'frame':
234
+ case 'rectangle':
235
+ case 'group':
236
+ return generateContainerCompose(node, depth)
237
+
238
+ case 'ellipse':
239
+ return generateEllipseCompose(node, depth)
240
+
241
+ case 'text':
242
+ return generateTextCompose(node, depth)
243
+
244
+ case 'line':
245
+ return generateLineCompose(node, depth)
246
+
247
+ case 'polygon':
248
+ case 'path':
249
+ return generatePathCompose(node, depth)
250
+
251
+ case 'image':
252
+ return generateImageCompose(node, depth)
253
+
254
+ case 'icon_font': {
255
+ const size = typeof node.width === 'number' ? node.width : 24
256
+ const color = node.fill?.[0]?.type === 'solid' ? node.fill[0].color : null
257
+ const iconName = kebabToPascal(node.iconFontName || 'circle')
258
+ const colorStr = color ? `, tint = Color(0xFF${color.replace('#', '').toUpperCase()})` : ''
259
+ return `${pad}Icon(LucideIcons.${iconName}, contentDescription = "${node.name ?? 'icon'}", modifier = Modifier.size(${size}.dp)${colorStr})`
260
+ }
261
+
262
+ case 'ref':
263
+ return `${pad}// Ref: ${node.ref}`
264
+
265
+ default:
266
+ return `${pad}// Unsupported node type`
267
+ }
268
+ }
269
+
270
+ function commonModifiers(node: PenNode): string[] {
271
+ const modifiers: string[] = []
272
+
273
+ if (node.x !== undefined || node.y !== undefined) {
274
+ const x = node.x ?? 0
275
+ const y = node.y ?? 0
276
+ modifiers.push(`.offset(x = ${x}.dp, y = ${y}.dp)`)
277
+ }
278
+
279
+ if (node.rotation) {
280
+ modifiers.push(`.rotate(${node.rotation}f)`)
281
+ }
282
+
283
+ if (node.opacity !== undefined && node.opacity !== 1) {
284
+ if (typeof node.opacity === 'string' && isVariableRef(node.opacity)) {
285
+ modifiers.push(`.alpha(/* ${varOrLiteral(node.opacity)} */ 1f)`)
286
+ } else if (typeof node.opacity === 'number') {
287
+ modifiers.push(`.alpha(${node.opacity}f)`)
288
+ }
289
+ }
290
+
291
+ return modifiers
292
+ }
293
+
294
+ function generateContainerCompose(
295
+ node: PenNode & ContainerProps,
296
+ depth: number,
297
+ ): string {
298
+ const pad = indent(depth)
299
+ const children = node.children ?? []
300
+ const hasLayout = node.layout === 'vertical' || node.layout === 'horizontal'
301
+
302
+ // Build modifier list
303
+ const modParts: string[] = []
304
+ modParts.push(...commonModifiers(node))
305
+
306
+ if (typeof node.width === 'number' && typeof node.height === 'number') {
307
+ modParts.push(`.size(width = ${node.width}.dp, height = ${node.height}.dp)`)
308
+ } else if (typeof node.width === 'number') {
309
+ modParts.push(`.width(${node.width}.dp)`)
310
+ } else if (typeof node.height === 'number') {
311
+ modParts.push(`.height(${node.height}.dp)`)
312
+ }
313
+
314
+ modParts.push(...effectsToComposeModifier(node.effects))
315
+
316
+ const fillMod = fillToComposeModifier(node.fill, node.cornerRadius)
317
+ if (fillMod) modParts.push(fillMod)
318
+ else {
319
+ const shape = cornerRadiusToComposeShape(node.cornerRadius)
320
+ if (shape) modParts.push(`.clip(${shape})`)
321
+ }
322
+
323
+ const strokeMod = strokeToComposeModifier(node.stroke, node.cornerRadius)
324
+ if (strokeMod) modParts.push(strokeMod)
325
+
326
+ const paddingMod = paddingToCompose(node.padding)
327
+ if (paddingMod) modParts.push(paddingMod)
328
+
329
+ if (node.clipContent) {
330
+ modParts.push('.clipToBounds()')
331
+ }
332
+
333
+ const modifierStr = buildModifierChain(modParts, pad)
334
+ const comment = node.name ? `${pad}// ${node.name}\n` : ''
335
+
336
+ // No children: just a Box
337
+ if (children.length === 0 && !hasLayout) {
338
+ return `${comment}${pad}Box(\n${pad} modifier = ${modifierStr}\n${pad})`
339
+ }
340
+
341
+ const childLines = children
342
+ .map((c) => generateNodeCompose(c, depth + 1))
343
+ .join('\n')
344
+
345
+ if (node.layout === 'vertical') {
346
+ const arrangementParts: string[] = []
347
+ const gapStr = gapToCompose(node.gap)
348
+ if (gapStr) {
349
+ arrangementParts.push(`verticalArrangement = Arrangement.spacedBy(${gapStr})`)
350
+ }
351
+ const alignment = alignToComposeHorizontal(node.alignItems)
352
+ if (alignment) {
353
+ arrangementParts.push(`horizontalAlignment = ${alignment}`)
354
+ }
355
+
356
+ const params = [`modifier = ${modifierStr}`]
357
+ params.push(...arrangementParts)
358
+
359
+ return `${comment}${pad}Column(\n${params.map((p) => `${pad} ${p}`).join(',\n')}\n${pad}) {\n${childLines}\n${pad}}`
360
+ }
361
+
362
+ if (node.layout === 'horizontal') {
363
+ const arrangementParts: string[] = []
364
+ const gapStr = gapToCompose(node.gap)
365
+ if (gapStr) {
366
+ arrangementParts.push(`horizontalArrangement = Arrangement.spacedBy(${gapStr})`)
367
+ }
368
+ const alignment = alignToComposeVertical(node.alignItems)
369
+ if (alignment) {
370
+ arrangementParts.push(`verticalAlignment = ${alignment}`)
371
+ }
372
+
373
+ const params = [`modifier = ${modifierStr}`]
374
+ params.push(...arrangementParts)
375
+
376
+ return `${comment}${pad}Row(\n${params.map((p) => `${pad} ${p}`).join(',\n')}\n${pad}) {\n${childLines}\n${pad}}`
377
+ }
378
+
379
+ // No layout or layout === 'none': use Box (ZStack equivalent)
380
+ return `${comment}${pad}Box(\n${pad} modifier = ${modifierStr}\n${pad}) {\n${childLines}\n${pad}}`
381
+ }
382
+
383
+ function gapToCompose(gap: number | string | undefined): string | null {
384
+ if (gap === undefined) return null
385
+ if (typeof gap === 'string' && isVariableRef(gap)) {
386
+ return `/* ${varOrLiteral(gap)} */ 0.dp`
387
+ }
388
+ if (typeof gap === 'number' && gap > 0) {
389
+ return `${gap}.dp`
390
+ }
391
+ return null
392
+ }
393
+
394
+ function alignToComposeHorizontal(alignItems: string | undefined): string | null {
395
+ if (!alignItems) return null
396
+ const map: Record<string, string> = {
397
+ start: 'Alignment.Start',
398
+ center: 'Alignment.CenterHorizontally',
399
+ end: 'Alignment.End',
400
+ }
401
+ return map[alignItems] ?? null
402
+ }
403
+
404
+ function alignToComposeVertical(alignItems: string | undefined): string | null {
405
+ if (!alignItems) return null
406
+ const map: Record<string, string> = {
407
+ start: 'Alignment.Top',
408
+ center: 'Alignment.CenterVertically',
409
+ end: 'Alignment.Bottom',
410
+ }
411
+ return map[alignItems] ?? null
412
+ }
413
+
414
+ function generateEllipseCompose(node: EllipseNode, depth: number): string {
415
+ const pad = indent(depth)
416
+ const modParts: string[] = []
417
+
418
+ modParts.push(...commonModifiers(node))
419
+
420
+ if (typeof node.width === 'number' && typeof node.height === 'number') {
421
+ modParts.push(`.size(width = ${node.width}.dp, height = ${node.height}.dp)`)
422
+ } else if (typeof node.width === 'number') {
423
+ modParts.push(`.size(${node.width}.dp)`)
424
+ } else if (typeof node.height === 'number') {
425
+ modParts.push(`.size(${node.height}.dp)`)
426
+ }
427
+
428
+ modParts.push('.clip(CircleShape)')
429
+
430
+ if (node.fill && node.fill.length > 0) {
431
+ const fill = node.fill[0]
432
+ if (fill.type === 'solid') {
433
+ modParts.push(`.background(${hexToComposeColor(fill.color)})`)
434
+ } else {
435
+ const bgStr = fillToComposeBackground(node.fill)
436
+ if (bgStr) modParts.push(`.background(${bgStr})`)
437
+ }
438
+ }
439
+
440
+ const strokeMod = strokeToComposeModifier(node.stroke, undefined)
441
+ if (strokeMod) modParts.push(strokeMod)
442
+
443
+ modParts.push(...effectsToComposeModifier(node.effects))
444
+
445
+ const modifierStr = buildModifierChain(modParts, pad)
446
+ return `${pad}Box(\n${pad} modifier = ${modifierStr}\n${pad})`
447
+ }
448
+
449
+ function generateTextCompose(node: TextNode, depth: number): string {
450
+ const pad = indent(depth)
451
+ const text = escapeKotlinString(getTextContent(node))
452
+ const params: string[] = [`text = "${text}"`]
453
+
454
+ // Font size
455
+ if (node.fontSize) {
456
+ params.push(`fontSize = ${node.fontSize}.sp`)
457
+ }
458
+
459
+ // Font weight
460
+ const weight = fontWeightToCompose(node.fontWeight)
461
+ if (weight) {
462
+ params.push(`fontWeight = ${weight}`)
463
+ }
464
+
465
+ // Font style
466
+ if (node.fontStyle === 'italic') {
467
+ params.push('fontStyle = FontStyle.Italic')
468
+ }
469
+
470
+ // Color
471
+ if (node.fill && node.fill.length > 0) {
472
+ const fill = node.fill[0]
473
+ if (fill.type === 'solid') {
474
+ params.push(`color = ${hexToComposeColor(fill.color)}`)
475
+ }
476
+ }
477
+
478
+ // Text alignment
479
+ const align = textAlignToCompose(node.textAlign)
480
+ if (align) {
481
+ params.push(`textAlign = ${align}`)
482
+ }
483
+
484
+ // Font family
485
+ if (node.fontFamily) {
486
+ params.push(`fontFamily = FontFamily(Font(R.font.${node.fontFamily.toLowerCase().replace(/\s+/g, '_')}))`)
487
+ }
488
+
489
+ // Letter spacing
490
+ if (node.letterSpacing) {
491
+ params.push(`letterSpacing = ${node.letterSpacing}.sp`)
492
+ }
493
+
494
+ // Line height
495
+ if (node.lineHeight && node.fontSize) {
496
+ const lineHeightSp = node.lineHeight * node.fontSize
497
+ params.push(`lineHeight = ${lineHeightSp.toFixed(1)}.sp`)
498
+ }
499
+
500
+ // Text decoration
501
+ const decorations: string[] = []
502
+ if (node.underline) decorations.push('TextDecoration.Underline')
503
+ if (node.strikethrough) decorations.push('TextDecoration.LineThrough')
504
+ if (decorations.length === 1) {
505
+ params.push(`textDecoration = ${decorations[0]}`)
506
+ } else if (decorations.length > 1) {
507
+ params.push(`textDecoration = TextDecoration.combine(listOf(${decorations.join(', ')}))`)
508
+ }
509
+
510
+ // Build modifier for size, offset, opacity
511
+ const modParts: string[] = []
512
+ modParts.push(...commonModifiers(node))
513
+
514
+ if (typeof node.width === 'number') {
515
+ modParts.push(`.width(${node.width}.dp)`)
516
+ }
517
+ if (typeof node.height === 'number') {
518
+ modParts.push(`.height(${node.height}.dp)`)
519
+ }
520
+
521
+ modParts.push(...effectsToComposeModifier(node.effects))
522
+
523
+ if (modParts.length > 0) {
524
+ const modifierStr = buildModifierChain(modParts, pad)
525
+ params.push(`modifier = ${modifierStr}`)
526
+ }
527
+
528
+ if (params.length <= 2) {
529
+ return `${pad}Text(${params.join(', ')})`
530
+ }
531
+ return `${pad}Text(\n${params.map((p) => `${pad} ${p}`).join(',\n')}\n${pad})`
532
+ }
533
+
534
+ function generateLineCompose(node: LineNode, depth: number): string {
535
+ const pad = indent(depth)
536
+ const w = node.x2 !== undefined ? Math.abs(node.x2 - (node.x ?? 0)) : 0
537
+ const modParts: string[] = []
538
+
539
+ modParts.push(...commonModifiers(node))
540
+
541
+ if (w > 0) {
542
+ modParts.push(`.width(${w}.dp)`)
543
+ }
544
+
545
+ let strokeColor = 'Color.Gray'
546
+ let thickness = '1'
547
+ if (node.stroke) {
548
+ const t = typeof node.stroke.thickness === 'number'
549
+ ? node.stroke.thickness
550
+ : typeof node.stroke.thickness === 'string'
551
+ ? node.stroke.thickness
552
+ : node.stroke.thickness[0]
553
+ if (typeof t === 'string' && isVariableRef(t)) {
554
+ thickness = `/* ${varOrLiteral(t)} */ 1`
555
+ } else {
556
+ thickness = String(t)
557
+ }
558
+ if (node.stroke.fill && node.stroke.fill.length > 0) {
559
+ const sf = node.stroke.fill[0]
560
+ if (sf.type === 'solid') {
561
+ strokeColor = hexToComposeColor(sf.color)
562
+ }
563
+ }
564
+ }
565
+
566
+ const modifierStr = modParts.length > 0
567
+ ? `,\n${pad} modifier = ${buildModifierChain(modParts, pad)}`
568
+ : ''
569
+
570
+ return `${pad}Divider(\n${pad} color = ${strokeColor},\n${pad} thickness = ${thickness}.dp${modifierStr}\n${pad})`
571
+ }
572
+
573
+ function generatePathCompose(node: PathNode | PolygonNode, depth: number): string {
574
+ const pad = indent(depth)
575
+
576
+ if (node.type === 'path') {
577
+ const fills = node.fill
578
+ const fillColor = fills && fills.length > 0 && fills[0].type === 'solid'
579
+ ? hexToComposeColor(fills[0].color)
580
+ : 'Color.Black'
581
+
582
+ const modParts: string[] = []
583
+ modParts.push(...commonModifiers(node))
584
+ if (typeof node.width === 'number' && typeof node.height === 'number') {
585
+ modParts.push(`.size(width = ${node.width}.dp, height = ${node.height}.dp)`)
586
+ } else if (typeof node.width === 'number') {
587
+ modParts.push(`.width(${node.width}.dp)`)
588
+ } else if (typeof node.height === 'number') {
589
+ modParts.push(`.height(${node.height}.dp)`)
590
+ }
591
+ modParts.push(...effectsToComposeModifier(node.effects))
592
+
593
+ const modifierStr = buildModifierChain(modParts, pad)
594
+ const escapedD = escapeKotlinString(node.d)
595
+
596
+ const lines = [
597
+ `${pad}// ${node.name ?? 'Path'}`,
598
+ `${pad}Canvas(`,
599
+ `${pad} modifier = ${modifierStr}`,
600
+ `${pad}) {`,
601
+ `${pad} val pathData = "${escapedD}"`,
602
+ `${pad} val path = PathParser().parsePathString(pathData).toPath()`,
603
+ `${pad} drawPath(path, color = ${fillColor})`,
604
+ `${pad}}`,
605
+ ]
606
+ return lines.join('\n')
607
+ }
608
+
609
+ // Polygon
610
+ const modParts: string[] = []
611
+ modParts.push(...commonModifiers(node))
612
+
613
+ if (typeof node.width === 'number' && typeof node.height === 'number') {
614
+ modParts.push(`.size(width = ${node.width}.dp, height = ${node.height}.dp)`)
615
+ }
616
+ modParts.push(...effectsToComposeModifier(node.effects))
617
+
618
+ const fillColor = node.fill && node.fill.length > 0 && node.fill[0].type === 'solid'
619
+ ? hexToComposeColor(node.fill[0].color)
620
+ : 'Color.Black'
621
+
622
+ const modifierStr = buildModifierChain(modParts, pad)
623
+ const sides = node.polygonCount
624
+
625
+ const lines = [
626
+ `${pad}// Polygon (${sides}-sided)`,
627
+ `${pad}Canvas(`,
628
+ `${pad} modifier = ${modifierStr}`,
629
+ `${pad}) {`,
630
+ `${pad} val center = Offset(size.width / 2, size.height / 2)`,
631
+ `${pad} val radius = minOf(size.width, size.height) / 2`,
632
+ `${pad} val path = Path().apply {`,
633
+ `${pad} for (i in 0 until ${sides}) {`,
634
+ `${pad} val angle = i * (2 * Math.PI / ${sides}).toFloat() - (Math.PI / 2).toFloat()`,
635
+ `${pad} val x = center.x + radius * cos(angle)`,
636
+ `${pad} val y = center.y + radius * sin(angle)`,
637
+ `${pad} if (i == 0) moveTo(x, y) else lineTo(x, y)`,
638
+ `${pad} }`,
639
+ `${pad} close()`,
640
+ `${pad} }`,
641
+ `${pad} drawPath(path, color = ${fillColor})`,
642
+ `${pad}}`,
643
+ ]
644
+ return lines.join('\n')
645
+ }
646
+
647
+ function generateImageCompose(node: ImageNode, depth: number): string {
648
+ const pad = indent(depth)
649
+ const modParts: string[] = []
650
+
651
+ modParts.push(...commonModifiers(node))
652
+
653
+ if (typeof node.width === 'number' && typeof node.height === 'number') {
654
+ modParts.push(`.size(width = ${node.width}.dp, height = ${node.height}.dp)`)
655
+ } else if (typeof node.width === 'number') {
656
+ modParts.push(`.width(${node.width}.dp)`)
657
+ } else if (typeof node.height === 'number') {
658
+ modParts.push(`.height(${node.height}.dp)`)
659
+ }
660
+
661
+ if (node.cornerRadius) {
662
+ const shape = cornerRadiusToComposeShape(node.cornerRadius)
663
+ if (shape) modParts.push(`.clip(${shape})`)
664
+ }
665
+
666
+ modParts.push(...effectsToComposeModifier(node.effects))
667
+
668
+ const modifierStr = buildModifierChain(modParts, pad)
669
+ const src = node.src
670
+
671
+ const contentScale = node.objectFit === 'fit'
672
+ ? 'ContentScale.Fit'
673
+ : node.objectFit === 'crop'
674
+ ? 'ContentScale.Crop'
675
+ : 'ContentScale.FillBounds'
676
+
677
+ // Data URI — decode base64 at runtime
678
+ if (src.startsWith('data:image/')) {
679
+ const base64Start = src.indexOf('base64,')
680
+ if (base64Start !== -1) {
681
+ const base64Data = src.slice(base64Start + 7)
682
+ const truncated = base64Data.length > 80 ? base64Data.substring(0, 80) + '...' : base64Data
683
+ const lines = [
684
+ `${pad}// Embedded image (${node.name ?? 'image'})`,
685
+ `${pad}// Base64 data: ${truncated}`,
686
+ `${pad}val bytes = Base64.decode("${escapeKotlinString(base64Data)}", Base64.DEFAULT)`,
687
+ `${pad}val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)`,
688
+ `${pad}Image(`,
689
+ `${pad} bitmap = bitmap.asImageBitmap(),`,
690
+ `${pad} contentDescription = ${node.name ? `"${escapeKotlinString(node.name)}"` : 'null'},`,
691
+ `${pad} modifier = ${modifierStr},`,
692
+ `${pad} contentScale = ${contentScale}`,
693
+ `${pad})`,
694
+ ]
695
+ return lines.join('\n')
696
+ }
697
+ }
698
+
699
+ const escapedSrc = escapeKotlinString(src)
700
+ if (escapedSrc.startsWith('http://') || escapedSrc.startsWith('https://')) {
701
+ // Use Coil's AsyncImage for remote URLs
702
+ const lines = [
703
+ `${pad}AsyncImage(`,
704
+ `${pad} model = "${escapedSrc}",`,
705
+ `${pad} contentDescription = ${node.name ? `"${escapeKotlinString(node.name)}"` : 'null'},`,
706
+ `${pad} modifier = ${modifierStr},`,
707
+ `${pad} contentScale = ${contentScale}`,
708
+ `${pad})`,
709
+ ]
710
+ return lines.join('\n')
711
+ }
712
+
713
+ const lines = [
714
+ `${pad}Image(`,
715
+ `${pad} painter = painterResource(id = R.drawable.${escapedSrc.replace(/[^a-zA-Z0-9_]/g, '_')}),`,
716
+ `${pad} contentDescription = ${node.name ? `"${escapeKotlinString(node.name)}"` : 'null'},`,
717
+ `${pad} modifier = ${modifierStr},`,
718
+ `${pad} contentScale = ${contentScale}`,
719
+ `${pad})`,
720
+ ]
721
+ return lines.join('\n')
722
+ }
723
+
724
+ export function generateComposeCode(
725
+ nodes: PenNode[],
726
+ composableName = 'GeneratedDesign',
727
+ ): string {
728
+ if (nodes.length === 0) {
729
+ return `@Composable\nfun ${composableName}() {\n // Empty design\n}\n`
730
+ }
731
+
732
+ // Compute wrapper size
733
+ let maxW = 0
734
+ let maxH = 0
735
+ for (const node of nodes) {
736
+ const x = node.x ?? 0
737
+ const y = node.y ?? 0
738
+ const w = 'width' in node && typeof node.width === 'number' ? node.width : 0
739
+ const h = 'height' in node && typeof node.height === 'number' ? node.height : 0
740
+ maxW = Math.max(maxW, x + w)
741
+ maxH = Math.max(maxH, y + h)
742
+ }
743
+
744
+ const childLines = nodes
745
+ .map((n) => generateNodeCompose(n, 2))
746
+ .join('\n')
747
+
748
+ const sizeMods: string[] = []
749
+ if (maxW > 0 && maxH > 0) {
750
+ sizeMods.push(`.size(width = ${maxW}.dp, height = ${maxH}.dp)`)
751
+ } else if (maxW > 0) {
752
+ sizeMods.push(`.width(${maxW}.dp)`)
753
+ } else if (maxH > 0) {
754
+ sizeMods.push(`.height(${maxH}.dp)`)
755
+ }
756
+
757
+ const modifierStr = sizeMods.length > 0
758
+ ? `Modifier\n${sizeMods.map((m) => ` ${m}`).join('\n')}`
759
+ : 'Modifier'
760
+
761
+ return `import androidx.compose.foundation.background
762
+ import androidx.compose.foundation.border
763
+ import androidx.compose.foundation.layout.*
764
+ import androidx.compose.foundation.shape.CircleShape
765
+ import androidx.compose.foundation.shape.RoundedCornerShape
766
+ import androidx.compose.material3.Divider
767
+ import androidx.compose.material3.Text
768
+ import androidx.compose.runtime.Composable
769
+ import androidx.compose.ui.Alignment
770
+ import androidx.compose.ui.Modifier
771
+ import androidx.compose.ui.draw.alpha
772
+ import androidx.compose.ui.draw.clip
773
+ import androidx.compose.ui.draw.rotate
774
+ import androidx.compose.ui.draw.shadow
775
+ import androidx.compose.ui.geometry.Offset
776
+ import androidx.compose.ui.graphics.Brush
777
+ import androidx.compose.ui.graphics.Color
778
+ import androidx.compose.ui.graphics.Path
779
+ import androidx.compose.ui.graphics.RectangleShape
780
+ import androidx.compose.ui.graphics.vector.PathParser
781
+ import androidx.compose.ui.text.font.FontStyle
782
+ import androidx.compose.ui.text.font.FontWeight
783
+ import androidx.compose.ui.text.style.TextAlign
784
+ import androidx.compose.ui.text.style.TextDecoration
785
+ import androidx.compose.ui.unit.dp
786
+ import androidx.compose.ui.unit.sp
787
+
788
+ @Composable
789
+ fun ${composableName}() {
790
+ Box(
791
+ modifier = ${modifierStr}
792
+ ) {
793
+ ${childLines}
794
+ }
795
+ }
796
+ `
797
+ }
798
+
799
+ export function generateComposeFromDocument(
800
+ doc: PenDocument,
801
+ activePageId?: string | null,
802
+ ): string {
803
+ const children = activePageId !== undefined
804
+ ? getActivePageChildren(doc, activePageId)
805
+ : doc.children
806
+ return generateComposeCode(children, 'GeneratedDesign')
807
+ }