@zseven-w/pen-core 0.5.2 → 0.6.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zseven-w/pen-core",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Core document operations, tree utils, variables, layout engine for OpenPencil",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,7 +16,7 @@
16
16
  "typecheck": "tsc --noEmit"
17
17
  },
18
18
  "dependencies": {
19
- "@zseven-w/pen-types": "0.5.2",
19
+ "@zseven-w/pen-types": "0.6.0",
20
20
  "nanoid": "^5.1.6",
21
21
  "paper": "^0.12.18"
22
22
  },
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { PenNode } from '@zseven-w/pen-types'
3
+ import { resolveNodeForCanvas } from '../variables/resolve'
4
+
5
+ describe('resolveNodeForCanvas — recursive', () => {
6
+ const variables = {
7
+ spacing: { name: 'spacing', type: 'number' as const, value: 16 },
8
+ 'bg-color': { name: 'bg-color', type: 'color' as const, value: '#ff0000' },
9
+ }
10
+
11
+ it('resolves $variable gap on nested child frame', () => {
12
+ const doc: PenNode = {
13
+ id: 'root', type: 'frame', x: 0, y: 0, width: 400, height: 600,
14
+ layout: 'vertical',
15
+ children: [
16
+ {
17
+ id: 'child', type: 'frame', x: 0, y: 0, width: 300, height: 200,
18
+ layout: 'vertical', gap: '$spacing' as unknown as number,
19
+ children: [
20
+ { id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' },
21
+ { id: 'text2', type: 'text', x: 0, y: 0, content: 'World' },
22
+ ],
23
+ } as PenNode,
24
+ ],
25
+ } as PenNode
26
+
27
+ const result = resolveNodeForCanvas(doc, variables)
28
+ const childFrame = (result as any).children[0]
29
+ expect(childFrame.gap).toBe(16)
30
+ })
31
+
32
+ it('resolves $variable fill on deeply nested text node', () => {
33
+ const doc: PenNode = {
34
+ id: 'root', type: 'frame', x: 0, y: 0, width: 400, height: 600,
35
+ children: [
36
+ {
37
+ id: 'child', type: 'frame', x: 0, y: 0, width: 300, height: 200,
38
+ children: [
39
+ {
40
+ id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello',
41
+ fill: [{ type: 'solid', color: '$bg-color' }],
42
+ } as PenNode,
43
+ ],
44
+ } as PenNode,
45
+ ],
46
+ } as PenNode
47
+
48
+ const result = resolveNodeForCanvas(doc, variables)
49
+ const textNode = (result as any).children[0].children[0]
50
+ expect(textNode.fill[0].color).toBe('#ff0000')
51
+ })
52
+
53
+ it('resolves $variable padding on nested frame', () => {
54
+ const doc: PenNode = {
55
+ id: 'root', type: 'frame', x: 0, y: 0, width: 400, height: 600,
56
+ children: [
57
+ {
58
+ id: 'child', type: 'frame', x: 0, y: 0, width: 300, height: 200,
59
+ padding: '$spacing' as unknown as number,
60
+ children: [],
61
+ } as PenNode,
62
+ ],
63
+ } as PenNode
64
+
65
+ const result = resolveNodeForCanvas(doc, variables)
66
+ const childFrame = (result as any).children[0]
67
+ expect(childFrame.padding).toBe(16)
68
+ })
69
+
70
+ it('returns same reference when no variables exist', () => {
71
+ const doc: PenNode = {
72
+ id: 'root', type: 'frame', x: 0, y: 0, width: 400, height: 600,
73
+ children: [
74
+ { id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' },
75
+ ],
76
+ } as PenNode
77
+
78
+ const result = resolveNodeForCanvas(doc, {})
79
+ expect(result).toBe(doc)
80
+ })
81
+
82
+ it('resolves $variable opacity on nested text node', () => {
83
+ const variables3 = {
84
+ 'text-opacity': { name: 'text-opacity', type: 'number' as const, value: 0.5 },
85
+ }
86
+ const doc: PenNode = {
87
+ id: 'root', type: 'frame', x: 0, y: 0, width: 400, height: 600,
88
+ children: [
89
+ {
90
+ id: 'child', type: 'frame', x: 0, y: 0, width: 300, height: 200,
91
+ children: [
92
+ {
93
+ id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello',
94
+ opacity: '$text-opacity' as unknown as number,
95
+ } as PenNode,
96
+ ],
97
+ } as PenNode,
98
+ ],
99
+ } as PenNode
100
+
101
+ const result = resolveNodeForCanvas(doc, variables3)
102
+ const textNode = (result as any).children[0].children[0]
103
+ expect(textNode.opacity).toBe(0.5)
104
+ })
105
+
106
+ it('preserves reference when children have no variables', () => {
107
+ const variables2 = { color1: { name: 'color1', type: 'color' as const, value: '#000' } }
108
+ const doc: PenNode = {
109
+ id: 'root', type: 'frame', x: 0, y: 0, width: 400, height: 600,
110
+ children: [
111
+ { id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' },
112
+ ],
113
+ } as PenNode
114
+
115
+ const result = resolveNodeForCanvas(doc, variables2)
116
+ expect(result).toBe(doc)
117
+ })
118
+ })
@@ -1,4 +1,3 @@
1
- import paper from 'paper'
2
1
  import { nanoid } from 'nanoid'
3
2
  import type { PenNode, PathNode } from '@zseven-w/pen-types'
4
3
 
@@ -8,9 +7,64 @@ export type BooleanOpType = 'union' | 'subtract' | 'intersect'
8
7
  // Paper.js scope — headless (no canvas needed)
9
8
  // ---------------------------------------------------------------------------
10
9
 
11
- let scope: paper.PaperScope | null = null
10
+ interface PaperBoundsLike {
11
+ center: unknown
12
+ x: number
13
+ y: number
14
+ width: number
15
+ height: number
16
+ }
17
+
18
+ interface PaperPathItem {
19
+ bounds: PaperBoundsLike
20
+ pathData: string
21
+ translate: (point: unknown) => void
22
+ rotate: (angle: number, center: unknown) => void
23
+ unite: (path: PaperPathItem) => PaperPathItem
24
+ subtract: (path: PaperPathItem) => PaperPathItem
25
+ intersect: (path: PaperPathItem) => PaperPathItem
26
+ remove: () => void
27
+ }
28
+
29
+ interface PaperScope {
30
+ setup: (size: unknown) => void
31
+ activate: () => void
32
+ Size: new (width: number, height: number) => unknown
33
+ Point: new (x: number, y: number) => unknown
34
+ CompoundPath: {
35
+ create: (pathData: string) => PaperPathItem
36
+ }
37
+ }
38
+
39
+ interface PaperModule {
40
+ PaperScope: new () => PaperScope
41
+ Point: new (x: number, y: number) => unknown
42
+ }
12
43
 
13
- function getScope(): paper.PaperScope {
44
+ let paperModule: PaperModule | null | undefined
45
+ let scope: PaperScope | null = null
46
+
47
+ function getPaperModule(): PaperModule | null {
48
+ if (paperModule !== undefined) return paperModule
49
+ try {
50
+ // Indirect require via globalThis to load paper.js at runtime without
51
+ // triggering esbuild's direct-eval warning. The assignment to globalThis
52
+ // happens once; subsequent calls read from the cached paperModule.
53
+ const _r = (globalThis as any)['require'] as NodeRequire | undefined
54
+ ?? (typeof require !== 'undefined' ? require : undefined)
55
+ if (!_r) throw new Error('require not available')
56
+ paperModule = _r('paper') as PaperModule
57
+ } catch {
58
+ paperModule = null
59
+ }
60
+ return paperModule
61
+ }
62
+
63
+ function getScope(): PaperScope {
64
+ const paper = getPaperModule()
65
+ if (!paper) {
66
+ throw new Error('paper runtime is unavailable')
67
+ }
14
68
  if (!scope) {
15
69
  scope = new paper.PaperScope()
16
70
  scope.setup(new scope.Size(1, 1))
@@ -153,12 +207,12 @@ export function canBooleanOp(nodes: PenNode[]): boolean {
153
207
  * Create a Paper.js PathItem from a PenNode, positioned in absolute scene
154
208
  * coordinates (applying x, y, rotation).
155
209
  */
156
- function nodeToPaperPath(node: PenNode): paper.PathItem | null {
210
+ function nodeToPaperPath(node: PenNode): PaperPathItem | null {
157
211
  const d = nodeToLocalPath(node)
158
212
  if (!d) return null
159
213
 
160
214
  const s = getScope()
161
- let item: paper.PathItem
215
+ let item: PaperPathItem
162
216
  try {
163
217
  item = s.CompoundPath.create(d)
164
218
  } catch {
@@ -189,10 +243,12 @@ export function executeBooleanOp(
189
243
  ): PathNode | null {
190
244
  if (nodes.length < 2) return null
191
245
 
246
+ if (!getPaperModule()) return null
247
+
192
248
  const paperPaths = nodes.map(nodeToPaperPath)
193
249
  if (paperPaths.some((p) => p === null)) return null
194
250
 
195
- const paths = paperPaths as paper.PathItem[]
251
+ const paths = paperPaths as PaperPathItem[]
196
252
 
197
253
  // Accumulate: fold left with the boolean operation
198
254
  let result = paths[0]
@@ -218,6 +274,8 @@ export function executeBooleanOp(
218
274
  const bounds = result.bounds
219
275
 
220
276
  // Translate path so it starts at origin (0,0)
277
+ const paper = getPaperModule()
278
+ if (!paper) return null
221
279
  result.translate(new paper.Point(-bounds.x, -bounds.y))
222
280
  const originPathData = result.pathData
223
281
 
@@ -183,10 +183,7 @@ export function getNodeWidth(node: PenNode, parentAvail?: number): number {
183
183
  const fontSize = node.fontSize ?? 16
184
184
  const letterSpacing = node.letterSpacing ?? 0
185
185
  const fontWeight = node.fontWeight
186
- const content =
187
- typeof node.content === 'string'
188
- ? node.content
189
- : node.content.map((s2) => s2.text).join('')
186
+ const content = resolveTextContent(node)
190
187
  return Math.max(Math.ceil(estimateTextWidth(content, fontSize, letterSpacing, fontWeight)), 1)
191
188
  }
192
189
  }
@@ -203,10 +200,7 @@ export function getNodeWidth(node: PenNode, parentAvail?: number): number {
203
200
  const fontSize = node.fontSize ?? 16
204
201
  const letterSpacing = node.letterSpacing ?? 0
205
202
  const fontWeight = node.fontWeight
206
- const content =
207
- typeof node.content === 'string'
208
- ? node.content
209
- : node.content.map((s) => s.text).join('')
203
+ const content = resolveTextContent(node)
210
204
  return Math.max(Math.ceil(estimateTextWidthPrecise(content, fontSize, letterSpacing, fontWeight)), 1)
211
205
  }
212
206
  return 0
@@ -136,9 +136,13 @@ export function estimateTextWidthPrecise(text: string, fontSize: number, letterS
136
136
 
137
137
  export function resolveTextContent(node: PenNode): string {
138
138
  if (node.type !== 'text') return ''
139
- return typeof node.content === 'string'
140
- ? node.content
141
- : node.content.map((s) => s.text).join('')
139
+ if (typeof node.content === 'string') return node.content
140
+ if (Array.isArray(node.content)) return node.content.map((s) => s.text).join('')
141
+ // Fallback: MCP/CLI nodes may use `text` instead of `content`
142
+ if (typeof (node as unknown as Record<string, unknown>).text === 'string') {
143
+ return (node as unknown as Record<string, unknown>).text as string
144
+ }
145
+ return ''
142
146
  }
143
147
 
144
148
  export function countExplicitTextLines(text: string): number {
package/src/normalize.ts CHANGED
@@ -65,6 +65,12 @@ function normalizeNode(node: PenNode): PenNode {
65
65
 
66
66
  // opacity — pass through ($variable strings preserved)
67
67
 
68
+ // text nodes: normalize `text` field to `content` (MCP/CLI use `text`, renderer expects `content`)
69
+ if (out.type === 'text' && !('content' in out) && typeof out.text === 'string') {
70
+ out.content = out.text as string
71
+ delete out.text
72
+ }
73
+
68
74
  // icon_font: default to lucide family
69
75
  if (out.type === 'icon_font' && !out.iconFontFamily) {
70
76
  out.iconFontFamily = 'lucide'
@@ -280,5 +280,18 @@ export function resolveNodeForCanvas(
280
280
  }
281
281
  }
282
282
 
283
+ // Recurse into children
284
+ if ('children' in node && node.children) {
285
+ const children = node.children
286
+ const resolvedChildren = children.map((child) =>
287
+ resolveNodeForCanvas(child, variables, activeTheme),
288
+ )
289
+ // Only allocate new array if any child actually changed
290
+ if (resolvedChildren.some((rc, i) => rc !== children[i])) {
291
+ out.children = resolvedChildren
292
+ changed = true
293
+ }
294
+ }
295
+
283
296
  return changed ? (out as unknown as PenNode) : node
284
297
  }