@zseven-w/pen-core 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 +80 -0
- package/package.json +26 -0
- package/src/__tests__/arc-path.test.ts +39 -0
- package/src/__tests__/font-utils.test.ts +26 -0
- package/src/__tests__/layout-engine.test.ts +153 -0
- package/src/__tests__/node-helpers.test.ts +30 -0
- package/src/__tests__/normalize.test.ts +110 -0
- package/src/__tests__/text-measure.test.ts +147 -0
- package/src/__tests__/tree-utils.test.ts +170 -0
- package/src/__tests__/variables.test.ts +132 -0
- package/src/arc-path.ts +100 -0
- package/src/boolean-ops.ts +256 -0
- package/src/constants.ts +49 -0
- package/src/font-utils.ts +23 -0
- package/src/id.ts +1 -0
- package/src/index.ts +133 -0
- package/src/layout/engine.ts +460 -0
- package/src/layout/text-measure.ts +269 -0
- package/src/node-helpers.ts +14 -0
- package/src/normalize.ts +283 -0
- package/src/sync-lock.ts +16 -0
- package/src/tree-utils.ts +390 -0
- package/src/variables/replace-refs.ts +149 -0
- package/src/variables/resolve.ts +284 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @zseven-w/pen-core
|
|
2
|
+
|
|
3
|
+
Core document operations for [OpenPencil](https://github.com/nicepkg/openpencil) — tree manipulation, layout engine, design variables, boolean path operations, and more.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @zseven-w/pen-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
### Document Tree Operations
|
|
14
|
+
|
|
15
|
+
Create, query, and mutate the document tree:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import {
|
|
19
|
+
createEmptyDocument,
|
|
20
|
+
findNodeInTree,
|
|
21
|
+
insertNodeInTree,
|
|
22
|
+
removeNodeFromTree,
|
|
23
|
+
updateNodeInTree,
|
|
24
|
+
deepCloneNode,
|
|
25
|
+
flattenNodes,
|
|
26
|
+
} from '@zseven-w/pen-core'
|
|
27
|
+
|
|
28
|
+
const doc = createEmptyDocument()
|
|
29
|
+
const node = findNodeInTree(doc.children, 'node-id')
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Multi-Page Support
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { getActivePage, getActivePageChildren, migrateToPages } from '@zseven-w/pen-core'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Layout Engine
|
|
39
|
+
|
|
40
|
+
Automatic layout computation with auto-sizing, padding, and gap support:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { inferLayout, computeLayoutPositions, fitContentWidth, fitContentHeight } from '@zseven-w/pen-core'
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Design Variables
|
|
47
|
+
|
|
48
|
+
Resolve `$variable` references against theme axes:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { resolveVariableRef, resolveNodeForCanvas, replaceVariableRefsInTree } from '@zseven-w/pen-core'
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Boolean Path Operations
|
|
55
|
+
|
|
56
|
+
Union, subtract, intersect, and exclude paths via Paper.js:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { executeBooleanOp, BooleanOpType } from '@zseven-w/pen-core'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Text Measurement
|
|
63
|
+
|
|
64
|
+
Estimate text dimensions for layout without a browser:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { estimateTextWidth, estimateTextHeight } from '@zseven-w/pen-core'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Document Normalization
|
|
71
|
+
|
|
72
|
+
Sanitize and fix documents imported from external sources:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { normalizePenDocument } from '@zseven-w/pen-core'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zseven-w/pen-core",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Core document operations, tree utils, variables, layout engine for OpenPencil",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"import": "./src/index.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@zseven-w/pen-types": "0.0.1",
|
|
20
|
+
"nanoid": "^5.1.6",
|
|
21
|
+
"paper": "^0.12.18"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"typescript": "^5.7.2"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildEllipseArcPath, isArcEllipse } from '../arc-path'
|
|
3
|
+
|
|
4
|
+
describe('arc-path', () => {
|
|
5
|
+
describe('isArcEllipse', () => {
|
|
6
|
+
it('returns false for full circle with no inner radius', () => {
|
|
7
|
+
expect(isArcEllipse(0, 360, 0)).toBe(false)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns true for partial sweep', () => {
|
|
11
|
+
expect(isArcEllipse(0, 180, 0)).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns true for inner radius (donut)', () => {
|
|
15
|
+
expect(isArcEllipse(0, 360, 0.5)).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('buildEllipseArcPath', () => {
|
|
20
|
+
it('builds a full circle path', () => {
|
|
21
|
+
const path = buildEllipseArcPath(100, 100, 0, 360, 0)
|
|
22
|
+
expect(path).toContain('M')
|
|
23
|
+
expect(path).toContain('A')
|
|
24
|
+
expect(path).toContain('Z')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('builds a pie slice path', () => {
|
|
28
|
+
const path = buildEllipseArcPath(100, 100, 0, 90, 0)
|
|
29
|
+
expect(path).toContain('M50 50') // center point for pie
|
|
30
|
+
expect(path).toContain('Z')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('builds a donut path with inner radius', () => {
|
|
34
|
+
const path = buildEllipseArcPath(100, 100, 0, 360, 0.5)
|
|
35
|
+
// Should have inner arc commands
|
|
36
|
+
expect(path.split('A').length).toBeGreaterThanOrEqual(3) // outer + inner arcs
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { cssFontFamily } from '../font-utils'
|
|
3
|
+
|
|
4
|
+
describe('cssFontFamily', () => {
|
|
5
|
+
it('quotes multi-word font names', () => {
|
|
6
|
+
expect(cssFontFamily('Noto Sans SC')).toBe('"Noto Sans SC"')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('does not quote generic families', () => {
|
|
10
|
+
expect(cssFontFamily('sans-serif')).toBe('sans-serif')
|
|
11
|
+
expect(cssFontFamily('monospace')).toBe('monospace')
|
|
12
|
+
expect(cssFontFamily('system-ui')).toBe('system-ui')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('handles comma-separated lists', () => {
|
|
16
|
+
expect(cssFontFamily('Inter, sans-serif')).toBe('"Inter", sans-serif')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('preserves already-quoted names', () => {
|
|
20
|
+
expect(cssFontFamily('"Noto Sans SC"')).toBe('"Noto Sans SC"')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('handles -apple-system', () => {
|
|
24
|
+
expect(cssFontFamily('-apple-system')).toBe('-apple-system')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { PenNode } from '@zseven-w/pen-types'
|
|
3
|
+
import {
|
|
4
|
+
resolvePadding,
|
|
5
|
+
isNodeVisible,
|
|
6
|
+
inferLayout,
|
|
7
|
+
getNodeWidth,
|
|
8
|
+
getNodeHeight,
|
|
9
|
+
computeLayoutPositions,
|
|
10
|
+
} from '../layout/engine'
|
|
11
|
+
|
|
12
|
+
const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode => ({
|
|
13
|
+
id: 'f1', type: 'frame', x: 0, y: 0, ...props,
|
|
14
|
+
} as PenNode)
|
|
15
|
+
|
|
16
|
+
const rect = (id: string, w = 50, h = 30): PenNode => ({
|
|
17
|
+
id, type: 'rectangle', x: 0, y: 0, width: w, height: h,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('layout-engine', () => {
|
|
21
|
+
describe('resolvePadding', () => {
|
|
22
|
+
it('returns zero for undefined', () => {
|
|
23
|
+
expect(resolvePadding(undefined)).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('resolves uniform padding', () => {
|
|
27
|
+
expect(resolvePadding(10)).toEqual({ top: 10, right: 10, bottom: 10, left: 10 })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('resolves [vertical, horizontal]', () => {
|
|
31
|
+
expect(resolvePadding([10, 20])).toEqual({ top: 10, right: 20, bottom: 10, left: 20 })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('resolves [top, right, bottom, left]', () => {
|
|
35
|
+
expect(resolvePadding([1, 2, 3, 4])).toEqual({ top: 1, right: 2, bottom: 3, left: 4 })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns zero for string (variable ref)', () => {
|
|
39
|
+
expect(resolvePadding('$spacing')).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('isNodeVisible', () => {
|
|
44
|
+
it('returns true by default', () => {
|
|
45
|
+
expect(isNodeVisible(rect('a'))).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns false when visible is false', () => {
|
|
49
|
+
expect(isNodeVisible({ ...rect('a'), visible: false })).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('inferLayout', () => {
|
|
54
|
+
it('returns undefined for non-frame nodes', () => {
|
|
55
|
+
expect(inferLayout(rect('a'))).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('infers horizontal when gap is set', () => {
|
|
59
|
+
expect(inferLayout(frame({ gap: 10, children: [] }))).toBe('horizontal')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('infers horizontal when padding is set', () => {
|
|
63
|
+
expect(inferLayout(frame({ padding: 10, children: [] }))).toBe('horizontal')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns undefined when no layout hints', () => {
|
|
67
|
+
expect(inferLayout(frame({ children: [rect('a')] }))).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('getNodeWidth / getNodeHeight', () => {
|
|
72
|
+
it('returns explicit width', () => {
|
|
73
|
+
expect(getNodeWidth(rect('a', 200))).toBe(200)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('returns explicit height', () => {
|
|
77
|
+
expect(getNodeHeight(rect('a', 50, 100))).toBe(100)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('estimates text width', () => {
|
|
81
|
+
const text: PenNode = { id: 't', type: 'text', content: 'Hello World', fontSize: 16 }
|
|
82
|
+
expect(getNodeWidth(text)).toBeGreaterThan(0)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('computeLayoutPositions', () => {
|
|
87
|
+
it('positions children horizontally', () => {
|
|
88
|
+
const parent = frame({
|
|
89
|
+
width: 300, height: 100,
|
|
90
|
+
layout: 'horizontal', gap: 10,
|
|
91
|
+
children: [rect('a', 50, 30), rect('b', 50, 30)],
|
|
92
|
+
})
|
|
93
|
+
const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
|
|
94
|
+
expect(result[0].x).toBe(0)
|
|
95
|
+
expect(result[0].y).toBe(0)
|
|
96
|
+
expect(result[1].x).toBe(60) // 50 + 10 gap
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('positions children vertically', () => {
|
|
100
|
+
const parent = frame({
|
|
101
|
+
width: 100, height: 300,
|
|
102
|
+
layout: 'vertical', gap: 10,
|
|
103
|
+
children: [rect('a', 50, 30), rect('b', 50, 30)],
|
|
104
|
+
})
|
|
105
|
+
const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
|
|
106
|
+
expect(result[0].x).toBe(0)
|
|
107
|
+
expect(result[0].y).toBe(0)
|
|
108
|
+
expect(result[1].y).toBe(40) // 30 + 10 gap
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('applies padding', () => {
|
|
112
|
+
const parent = frame({
|
|
113
|
+
width: 300, height: 100,
|
|
114
|
+
layout: 'horizontal', padding: 20,
|
|
115
|
+
children: [rect('a', 50, 30)],
|
|
116
|
+
})
|
|
117
|
+
const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
|
|
118
|
+
expect(result[0].x).toBe(20)
|
|
119
|
+
expect(result[0].y).toBe(20)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('centers children on cross axis', () => {
|
|
123
|
+
const parent = frame({
|
|
124
|
+
width: 300, height: 100,
|
|
125
|
+
layout: 'horizontal', alignItems: 'center',
|
|
126
|
+
children: [rect('a', 50, 30)],
|
|
127
|
+
})
|
|
128
|
+
const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
|
|
129
|
+
expect(result[0].y).toBe(35) // (100 - 30) / 2
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('filters invisible children', () => {
|
|
133
|
+
const parent = frame({
|
|
134
|
+
width: 300, height: 100,
|
|
135
|
+
layout: 'horizontal',
|
|
136
|
+
children: [rect('a', 50, 30), { ...rect('b', 50, 30), visible: false }],
|
|
137
|
+
})
|
|
138
|
+
const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
|
|
139
|
+
expect(result).toHaveLength(1)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('returns visible children as-is when layout is none', () => {
|
|
143
|
+
const parent = frame({
|
|
144
|
+
width: 300, height: 100,
|
|
145
|
+
layout: 'none',
|
|
146
|
+
children: [rect('a', 50, 30)],
|
|
147
|
+
})
|
|
148
|
+
const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
|
|
149
|
+
expect(result).toHaveLength(1)
|
|
150
|
+
expect(result[0].id).toBe('a')
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { PenNode } from '@zseven-w/pen-types'
|
|
3
|
+
import { isBadgeOverlayNode } from '../node-helpers'
|
|
4
|
+
|
|
5
|
+
describe('isBadgeOverlayNode', () => {
|
|
6
|
+
it('returns true for badge role', () => {
|
|
7
|
+
const node: PenNode = { id: '1', type: 'rectangle', role: 'badge' }
|
|
8
|
+
expect(isBadgeOverlayNode(node)).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('returns true for pill role', () => {
|
|
12
|
+
const node: PenNode = { id: '1', type: 'rectangle', role: 'pill' }
|
|
13
|
+
expect(isBadgeOverlayNode(node)).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns true for name containing "badge"', () => {
|
|
17
|
+
const node: PenNode = { id: '1', type: 'rectangle', name: 'Notification Badge' }
|
|
18
|
+
expect(isBadgeOverlayNode(node)).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns true for name containing "overlay"', () => {
|
|
22
|
+
const node: PenNode = { id: '1', type: 'rectangle', name: 'Image Overlay' }
|
|
23
|
+
expect(isBadgeOverlayNode(node)).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns false for regular nodes', () => {
|
|
27
|
+
const node: PenNode = { id: '1', type: 'rectangle', name: 'Button' }
|
|
28
|
+
expect(isBadgeOverlayNode(node)).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { PenDocument } from '@zseven-w/pen-types'
|
|
3
|
+
import { normalizePenDocument } from '../normalize'
|
|
4
|
+
|
|
5
|
+
describe('normalizePenDocument', () => {
|
|
6
|
+
it('normalizes "color" fill type to "solid"', () => {
|
|
7
|
+
const doc: PenDocument = {
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
children: [{
|
|
10
|
+
id: '1', type: 'rectangle', x: 0, y: 0,
|
|
11
|
+
fill: [{ type: 'color' as 'solid', color: '#ff0000' }],
|
|
12
|
+
}],
|
|
13
|
+
}
|
|
14
|
+
const result = normalizePenDocument(doc)
|
|
15
|
+
const fill = (result.children[0] as { fill: Array<{ type: string }> }).fill
|
|
16
|
+
expect(fill[0].type).toBe('solid')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('normalizes string fill shorthand to solid fill array', () => {
|
|
20
|
+
const doc: PenDocument = {
|
|
21
|
+
version: '1.0.0',
|
|
22
|
+
children: [{
|
|
23
|
+
id: '1', type: 'rectangle', x: 0, y: 0,
|
|
24
|
+
fill: '#ff0000' as unknown as Array<{ type: 'solid'; color: string }>,
|
|
25
|
+
}],
|
|
26
|
+
}
|
|
27
|
+
const result = normalizePenDocument(doc)
|
|
28
|
+
const fill = (result.children[0] as { fill: Array<{ type: string; color: string }> }).fill
|
|
29
|
+
expect(fill).toHaveLength(1)
|
|
30
|
+
expect(fill[0]).toEqual({ type: 'solid', color: '#ff0000' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('normalizes fill_container sizing', () => {
|
|
34
|
+
const doc: PenDocument = {
|
|
35
|
+
version: '1.0.0',
|
|
36
|
+
children: [{
|
|
37
|
+
id: '1', type: 'frame', x: 0, y: 0,
|
|
38
|
+
width: 'fill_container(300)' as unknown as number,
|
|
39
|
+
height: 'fill_container' as unknown as number,
|
|
40
|
+
children: [],
|
|
41
|
+
}],
|
|
42
|
+
}
|
|
43
|
+
const result = normalizePenDocument(doc)
|
|
44
|
+
const node = result.children[0] as { width: unknown; height: unknown }
|
|
45
|
+
expect(node.width).toBe('fill_container')
|
|
46
|
+
expect(node.height).toBe('fill_container')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('normalizes fit_content with hint to number', () => {
|
|
50
|
+
const doc: PenDocument = {
|
|
51
|
+
version: '1.0.0',
|
|
52
|
+
children: [{
|
|
53
|
+
id: '1', type: 'frame', x: 0, y: 0,
|
|
54
|
+
width: 'fit_content(250)' as unknown as number,
|
|
55
|
+
height: 'fit_content' as unknown as number,
|
|
56
|
+
children: [],
|
|
57
|
+
}],
|
|
58
|
+
}
|
|
59
|
+
const result = normalizePenDocument(doc)
|
|
60
|
+
const node = result.children[0] as { width: unknown; height: unknown }
|
|
61
|
+
expect(node.width).toBe(250)
|
|
62
|
+
expect(node.height).toBe('fit_content')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('normalizes pages children too', () => {
|
|
66
|
+
const doc: PenDocument = {
|
|
67
|
+
version: '1.0.0',
|
|
68
|
+
pages: [{
|
|
69
|
+
id: 'p1', name: 'Page 1',
|
|
70
|
+
children: [{
|
|
71
|
+
id: '1', type: 'rectangle', x: 0, y: 0,
|
|
72
|
+
fill: [{ type: 'color' as 'solid', color: '#00ff00' }],
|
|
73
|
+
}],
|
|
74
|
+
}],
|
|
75
|
+
children: [],
|
|
76
|
+
}
|
|
77
|
+
const result = normalizePenDocument(doc)
|
|
78
|
+
const fill = (result.pages![0].children[0] as { fill: Array<{ type: string }> }).fill
|
|
79
|
+
expect(fill[0].type).toBe('solid')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('normalizes string elements inside fill array', () => {
|
|
83
|
+
const doc: PenDocument = {
|
|
84
|
+
version: '1.0.0',
|
|
85
|
+
children: [{
|
|
86
|
+
id: '1', type: 'path', d: 'M0 0', x: 0, y: 0,
|
|
87
|
+
fill: ['#ff0000'] as unknown as Array<{ type: 'solid'; color: string }>,
|
|
88
|
+
}],
|
|
89
|
+
}
|
|
90
|
+
const result = normalizePenDocument(doc)
|
|
91
|
+
const fill = (result.children[0] as { fill: Array<{ type: string; color: string }> }).fill
|
|
92
|
+
expect(fill).toHaveLength(1)
|
|
93
|
+
expect(fill[0]).toEqual({ type: 'solid', color: '#ff0000' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('preserves $variable references', () => {
|
|
97
|
+
const doc: PenDocument = {
|
|
98
|
+
version: '1.0.0',
|
|
99
|
+
children: [{
|
|
100
|
+
id: '1', type: 'rectangle', x: 0, y: 0,
|
|
101
|
+
fill: [{ type: 'solid', color: '$primary' }],
|
|
102
|
+
opacity: '$opacity' as unknown as number,
|
|
103
|
+
}],
|
|
104
|
+
}
|
|
105
|
+
const result = normalizePenDocument(doc)
|
|
106
|
+
const node = result.children[0] as { fill: Array<{ color: string }>; opacity: unknown }
|
|
107
|
+
expect(node.fill[0].color).toBe('$primary')
|
|
108
|
+
expect(node.opacity).toBe('$opacity')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { PenNode } from '@zseven-w/pen-types'
|
|
3
|
+
import {
|
|
4
|
+
parseSizing,
|
|
5
|
+
defaultLineHeight,
|
|
6
|
+
isCjkCodePoint,
|
|
7
|
+
hasCjkText,
|
|
8
|
+
estimateGlyphWidth,
|
|
9
|
+
estimateLineWidth,
|
|
10
|
+
estimateTextWidth,
|
|
11
|
+
resolveTextContent,
|
|
12
|
+
countExplicitTextLines,
|
|
13
|
+
estimateTextHeight,
|
|
14
|
+
} from '../layout/text-measure'
|
|
15
|
+
|
|
16
|
+
describe('text-measure', () => {
|
|
17
|
+
describe('parseSizing', () => {
|
|
18
|
+
it('returns number for number input', () => {
|
|
19
|
+
expect(parseSizing(100)).toBe(100)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns "fill" for fill_container', () => {
|
|
23
|
+
expect(parseSizing('fill_container')).toBe('fill')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns "fit" for fit_content', () => {
|
|
27
|
+
expect(parseSizing('fit_content')).toBe('fit')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('parses numeric strings', () => {
|
|
31
|
+
expect(parseSizing('200')).toBe(200)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns 0 for non-parseable', () => {
|
|
35
|
+
expect(parseSizing(undefined)).toBe(0)
|
|
36
|
+
expect(parseSizing('abc')).toBe(0)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('defaultLineHeight', () => {
|
|
41
|
+
it('returns tight leading for display text', () => {
|
|
42
|
+
expect(defaultLineHeight(48)).toBe(1.0)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns comfortable leading for body text', () => {
|
|
46
|
+
expect(defaultLineHeight(14)).toBe(1.5)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('CJK detection', () => {
|
|
51
|
+
it('detects CJK code points', () => {
|
|
52
|
+
expect(isCjkCodePoint('中'.codePointAt(0)!)).toBe(true)
|
|
53
|
+
expect(isCjkCodePoint('あ'.codePointAt(0)!)).toBe(true)
|
|
54
|
+
expect(isCjkCodePoint('A'.codePointAt(0)!)).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('hasCjkText detects CJK in strings', () => {
|
|
58
|
+
expect(hasCjkText('Hello 世界')).toBe(true)
|
|
59
|
+
expect(hasCjkText('Hello World')).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('estimateGlyphWidth', () => {
|
|
64
|
+
it('returns 0 for newline', () => {
|
|
65
|
+
expect(estimateGlyphWidth('\n', 16)).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('estimates CJK wider than Latin', () => {
|
|
69
|
+
const cjk = estimateGlyphWidth('中', 16)
|
|
70
|
+
const latin = estimateGlyphWidth('a', 16)
|
|
71
|
+
expect(cjk).toBeGreaterThan(latin)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('estimates uppercase wider than lowercase', () => {
|
|
75
|
+
const upper = estimateGlyphWidth('A', 16)
|
|
76
|
+
const lower = estimateGlyphWidth('a', 16)
|
|
77
|
+
expect(upper).toBeGreaterThan(lower)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('estimateLineWidth', () => {
|
|
82
|
+
it('estimates width of a line', () => {
|
|
83
|
+
const width = estimateLineWidth('Hello', 16)
|
|
84
|
+
expect(width).toBeGreaterThan(0)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('adds letter spacing', () => {
|
|
88
|
+
const base = estimateLineWidth('AB', 16, 0)
|
|
89
|
+
const spaced = estimateLineWidth('AB', 16, 5)
|
|
90
|
+
expect(spaced).toBeGreaterThan(base)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('estimateTextWidth', () => {
|
|
95
|
+
it('returns the widest line', () => {
|
|
96
|
+
const width = estimateTextWidth('short\nmuch longer line', 16)
|
|
97
|
+
const singleWidth = estimateTextWidth('much longer line', 16)
|
|
98
|
+
// Multi-line should return width of longest line
|
|
99
|
+
expect(width).toBeCloseTo(singleWidth, 0)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('resolveTextContent', () => {
|
|
104
|
+
it('resolves string content', () => {
|
|
105
|
+
const node: PenNode = { id: '1', type: 'text', content: 'Hello' }
|
|
106
|
+
expect(resolveTextContent(node)).toBe('Hello')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('resolves styled segment content', () => {
|
|
110
|
+
const node: PenNode = {
|
|
111
|
+
id: '1', type: 'text',
|
|
112
|
+
content: [{ text: 'Hello ' }, { text: 'World' }],
|
|
113
|
+
}
|
|
114
|
+
expect(resolveTextContent(node)).toBe('Hello World')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('returns empty for non-text nodes', () => {
|
|
118
|
+
const node: PenNode = { id: '1', type: 'rectangle' }
|
|
119
|
+
expect(resolveTextContent(node)).toBe('')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('countExplicitTextLines', () => {
|
|
124
|
+
it('counts newlines', () => {
|
|
125
|
+
expect(countExplicitTextLines('a\nb\nc')).toBe(3)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('returns 1 for empty string', () => {
|
|
129
|
+
expect(countExplicitTextLines('')).toBe(1)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('estimateTextHeight', () => {
|
|
134
|
+
it('estimates height for single-line text', () => {
|
|
135
|
+
const node: PenNode = { id: '1', type: 'text', content: 'Hello', fontSize: 16 }
|
|
136
|
+
const height = estimateTextHeight(node)
|
|
137
|
+
expect(height).toBeGreaterThan(0)
|
|
138
|
+
expect(height).toBeLessThan(50)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('estimates taller for multi-line text', () => {
|
|
142
|
+
const single: PenNode = { id: '1', type: 'text', content: 'Hello', fontSize: 16 }
|
|
143
|
+
const multi: PenNode = { id: '2', type: 'text', content: 'Hello\nWorld', fontSize: 16 }
|
|
144
|
+
expect(estimateTextHeight(multi)).toBeGreaterThan(estimateTextHeight(single))
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
})
|