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