@zseven-w/pen-core 0.0.1 → 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.0
|
|
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.0
|
|
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
|
+
})
|
package/src/boolean-ops.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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):
|
|
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:
|
|
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
|
|
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
|
|
package/src/layout/engine.ts
CHANGED
|
@@ -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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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'
|
package/src/variables/resolve.ts
CHANGED
|
@@ -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
|
}
|