@zseven-w/pen-core 0.5.2 → 0.7.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/README.md +18 -9
- package/package.json +5 -5
- package/src/__tests__/arc-path.test.ts +23 -23
- package/src/__tests__/codegen-utils.test.ts +50 -0
- package/src/__tests__/design-md-parser.test.ts +49 -0
- package/src/__tests__/font-utils.test.ts +15 -15
- package/src/__tests__/layout-engine.test.ts +169 -83
- package/src/__tests__/merge-helpers.test.ts +143 -0
- package/src/__tests__/node-diff.test.ts +139 -0
- package/src/__tests__/node-helpers.test.ts +19 -19
- package/src/__tests__/node-merge.test.ts +425 -0
- package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
- package/src/__tests__/normalize-tree-layout.test.ts +294 -0
- package/src/__tests__/normalize.test.ts +119 -80
- package/src/__tests__/path-anchors.test.ts +98 -0
- package/src/__tests__/resolve-variables-recursive.test.ts +173 -0
- package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
- package/src/__tests__/text-measure.test.ts +84 -79
- package/src/__tests__/tree-utils.test.ts +133 -102
- package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
- package/src/__tests__/variables.test.ts +68 -65
- package/src/arc-path.ts +35 -35
- package/src/boolean-ops.ts +158 -111
- package/src/constants.ts +36 -36
- package/src/design-md-parser.ts +363 -0
- package/src/font-utils.ts +30 -15
- package/src/id.ts +1 -1
- package/src/index.ts +47 -13
- package/src/layout/engine.ts +255 -230
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +133 -105
- package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
- package/src/merge/index.ts +16 -0
- package/src/merge/merge-helpers.ts +113 -0
- package/src/merge/node-diff.ts +123 -0
- package/src/merge/node-merge.ts +651 -0
- package/src/node-helpers.ts +18 -5
- package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
- package/src/normalize.ts +79 -79
- package/src/path-anchors.ts +331 -0
- package/src/sync-lock.ts +3 -3
- package/src/tree-utils.ts +180 -158
- package/src/variables/replace-refs.ts +63 -60
- package/src/variables/resolve.ts +107 -102
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import type { PenNode } from '@zseven-w/pen-types'
|
|
3
|
-
import type { VariableDefinition } from '@zseven-w/pen-types'
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PenNode } from '@zseven-w/pen-types';
|
|
3
|
+
import type { VariableDefinition } from '@zseven-w/pen-types';
|
|
4
4
|
import {
|
|
5
5
|
isVariableRef,
|
|
6
6
|
getDefaultTheme,
|
|
@@ -8,11 +8,11 @@ import {
|
|
|
8
8
|
resolveColorRef,
|
|
9
9
|
resolveNumericRef,
|
|
10
10
|
resolveNodeForCanvas,
|
|
11
|
-
} from '../variables/resolve'
|
|
11
|
+
} from '../variables/resolve';
|
|
12
12
|
|
|
13
13
|
const vars: Record<string, VariableDefinition> = {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
primary: { type: 'color', value: '#3b82f6' },
|
|
15
|
+
spacing: { type: 'number', value: 16 },
|
|
16
16
|
'themed-color': {
|
|
17
17
|
type: 'color',
|
|
18
18
|
value: [
|
|
@@ -20,113 +20,116 @@ const vars: Record<string, VariableDefinition> = {
|
|
|
20
20
|
{ value: '#1a1a1a', theme: { 'Theme-1': 'Dark' } },
|
|
21
21
|
],
|
|
22
22
|
},
|
|
23
|
-
}
|
|
23
|
+
};
|
|
24
24
|
|
|
25
25
|
describe('variables/resolve', () => {
|
|
26
26
|
describe('isVariableRef', () => {
|
|
27
27
|
it('returns true for $ prefixed strings', () => {
|
|
28
|
-
expect(isVariableRef('$primary')).toBe(true)
|
|
29
|
-
})
|
|
28
|
+
expect(isVariableRef('$primary')).toBe(true);
|
|
29
|
+
});
|
|
30
30
|
|
|
31
31
|
it('returns false for regular strings', () => {
|
|
32
|
-
expect(isVariableRef('#ff0000')).toBe(false)
|
|
33
|
-
})
|
|
32
|
+
expect(isVariableRef('#ff0000')).toBe(false);
|
|
33
|
+
});
|
|
34
34
|
|
|
35
35
|
it('returns false for non-strings', () => {
|
|
36
|
-
expect(isVariableRef(42)).toBe(false)
|
|
37
|
-
expect(isVariableRef(undefined)).toBe(false)
|
|
38
|
-
})
|
|
39
|
-
})
|
|
36
|
+
expect(isVariableRef(42)).toBe(false);
|
|
37
|
+
expect(isVariableRef(undefined)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
40
|
|
|
41
41
|
describe('getDefaultTheme', () => {
|
|
42
42
|
it('returns first value of each theme axis', () => {
|
|
43
|
-
const themes = { 'Theme-1': ['Light', 'Dark'],
|
|
43
|
+
const themes = { 'Theme-1': ['Light', 'Dark'], Size: ['Compact', 'Regular'] };
|
|
44
44
|
expect(getDefaultTheme(themes)).toEqual({
|
|
45
45
|
'Theme-1': 'Light',
|
|
46
|
-
|
|
47
|
-
})
|
|
48
|
-
})
|
|
46
|
+
Size: 'Compact',
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
49
|
|
|
50
50
|
it('returns empty for undefined themes', () => {
|
|
51
|
-
expect(getDefaultTheme(undefined)).toEqual({})
|
|
52
|
-
})
|
|
53
|
-
})
|
|
51
|
+
expect(getDefaultTheme(undefined)).toEqual({});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
54
|
|
|
55
55
|
describe('resolveVariableRef', () => {
|
|
56
56
|
it('resolves a simple color variable', () => {
|
|
57
|
-
expect(resolveVariableRef('$primary', vars)).toBe('#3b82f6')
|
|
58
|
-
})
|
|
57
|
+
expect(resolveVariableRef('$primary', vars)).toBe('#3b82f6');
|
|
58
|
+
});
|
|
59
59
|
|
|
60
60
|
it('resolves a number variable', () => {
|
|
61
|
-
expect(resolveVariableRef('$spacing', vars)).toBe(16)
|
|
62
|
-
})
|
|
61
|
+
expect(resolveVariableRef('$spacing', vars)).toBe(16);
|
|
62
|
+
});
|
|
63
63
|
|
|
64
64
|
it('returns undefined for missing variable', () => {
|
|
65
|
-
expect(resolveVariableRef('$missing', vars)).toBeUndefined()
|
|
66
|
-
})
|
|
65
|
+
expect(resolveVariableRef('$missing', vars)).toBeUndefined();
|
|
66
|
+
});
|
|
67
67
|
|
|
68
68
|
it('resolves themed values with matching theme', () => {
|
|
69
|
-
expect(resolveVariableRef('$themed-color', vars, { 'Theme-1': 'Dark' })).toBe('#1a1a1a')
|
|
70
|
-
})
|
|
69
|
+
expect(resolveVariableRef('$themed-color', vars, { 'Theme-1': 'Dark' })).toBe('#1a1a1a');
|
|
70
|
+
});
|
|
71
71
|
|
|
72
72
|
it('falls back to first value when no theme match', () => {
|
|
73
|
-
expect(resolveVariableRef('$themed-color', vars)).toBe('#ffffff')
|
|
74
|
-
})
|
|
73
|
+
expect(resolveVariableRef('$themed-color', vars)).toBe('#ffffff');
|
|
74
|
+
});
|
|
75
75
|
|
|
76
76
|
it('returns undefined for non-ref strings', () => {
|
|
77
|
-
expect(resolveVariableRef('#ff0000', vars)).toBeUndefined()
|
|
78
|
-
})
|
|
79
|
-
})
|
|
77
|
+
expect(resolveVariableRef('#ff0000', vars)).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
80
|
|
|
81
81
|
describe('resolveColorRef', () => {
|
|
82
82
|
it('returns the color when not a ref', () => {
|
|
83
|
-
expect(resolveColorRef('#ff0000', vars)).toBe('#ff0000')
|
|
84
|
-
})
|
|
83
|
+
expect(resolveColorRef('#ff0000', vars)).toBe('#ff0000');
|
|
84
|
+
});
|
|
85
85
|
|
|
86
86
|
it('resolves a color ref', () => {
|
|
87
|
-
expect(resolveColorRef('$primary', vars)).toBe('#3b82f6')
|
|
88
|
-
})
|
|
87
|
+
expect(resolveColorRef('$primary', vars)).toBe('#3b82f6');
|
|
88
|
+
});
|
|
89
89
|
|
|
90
90
|
it('returns undefined for undefined input', () => {
|
|
91
|
-
expect(resolveColorRef(undefined, vars)).toBeUndefined()
|
|
92
|
-
})
|
|
93
|
-
})
|
|
91
|
+
expect(resolveColorRef(undefined, vars)).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
94
|
|
|
95
95
|
describe('resolveNumericRef', () => {
|
|
96
96
|
it('returns the number when not a ref', () => {
|
|
97
|
-
expect(resolveNumericRef(42, vars)).toBe(42)
|
|
98
|
-
})
|
|
97
|
+
expect(resolveNumericRef(42, vars)).toBe(42);
|
|
98
|
+
});
|
|
99
99
|
|
|
100
100
|
it('resolves a numeric ref', () => {
|
|
101
|
-
expect(resolveNumericRef('$spacing', vars)).toBe(16)
|
|
102
|
-
})
|
|
101
|
+
expect(resolveNumericRef('$spacing', vars)).toBe(16);
|
|
102
|
+
});
|
|
103
103
|
|
|
104
104
|
it('returns undefined for non-numeric results', () => {
|
|
105
|
-
expect(resolveNumericRef('$primary', vars)).toBeUndefined()
|
|
106
|
-
})
|
|
107
|
-
})
|
|
105
|
+
expect(resolveNumericRef('$primary', vars)).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
108
|
|
|
109
109
|
describe('resolveNodeForCanvas', () => {
|
|
110
110
|
it('returns same node when no variables', () => {
|
|
111
|
-
const node: PenNode = { id: '1', type: 'rectangle', x: 0, y: 0 }
|
|
112
|
-
expect(resolveNodeForCanvas(node, {})).toBe(node)
|
|
113
|
-
})
|
|
111
|
+
const node: PenNode = { id: '1', type: 'rectangle', x: 0, y: 0 };
|
|
112
|
+
expect(resolveNodeForCanvas(node, {})).toBe(node);
|
|
113
|
+
});
|
|
114
114
|
|
|
115
115
|
it('resolves $variable opacity', () => {
|
|
116
|
-
const node: PenNode = { id: '1', type: 'rectangle', x: 0, y: 0, opacity: '$spacing' }
|
|
117
|
-
const resolved = resolveNodeForCanvas(node, vars)
|
|
118
|
-
expect(resolved.opacity).toBe(16)
|
|
119
|
-
expect(resolved).not.toBe(node)
|
|
120
|
-
})
|
|
116
|
+
const node: PenNode = { id: '1', type: 'rectangle', x: 0, y: 0, opacity: '$spacing' };
|
|
117
|
+
const resolved = resolveNodeForCanvas(node, vars);
|
|
118
|
+
expect(resolved.opacity).toBe(16);
|
|
119
|
+
expect(resolved).not.toBe(node);
|
|
120
|
+
});
|
|
121
121
|
|
|
122
122
|
it('resolves fill colors', () => {
|
|
123
123
|
const node: PenNode = {
|
|
124
|
-
id: '1',
|
|
124
|
+
id: '1',
|
|
125
|
+
type: 'rectangle',
|
|
126
|
+
x: 0,
|
|
127
|
+
y: 0,
|
|
125
128
|
fill: [{ type: 'solid', color: '$primary' }],
|
|
126
|
-
}
|
|
127
|
-
const resolved = resolveNodeForCanvas(node, vars)
|
|
128
|
-
const fill = (resolved as { fill: Array<{ color: string }> }).fill
|
|
129
|
-
expect(fill[0].color).toBe('#3b82f6')
|
|
130
|
-
})
|
|
131
|
-
})
|
|
132
|
-
})
|
|
129
|
+
};
|
|
130
|
+
const resolved = resolveNodeForCanvas(node, vars);
|
|
131
|
+
const fill = (resolved as { fill: Array<{ color: string }> }).fill;
|
|
132
|
+
expect(fill[0].color).toBe('#3b82f6');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
package/src/arc-path.ts
CHANGED
|
@@ -14,28 +14,28 @@ export function buildEllipseArcPath(
|
|
|
14
14
|
sweepDeg: number,
|
|
15
15
|
inner: number,
|
|
16
16
|
): string {
|
|
17
|
-
const startRad = (startDeg * Math.PI) / 180
|
|
18
|
-
const sweepRad = (sweepDeg * Math.PI) / 180
|
|
19
|
-
const endRad = startRad + sweepRad
|
|
17
|
+
const startRad = (startDeg * Math.PI) / 180;
|
|
18
|
+
const sweepRad = (sweepDeg * Math.PI) / 180;
|
|
19
|
+
const endRad = startRad + sweepRad;
|
|
20
20
|
|
|
21
|
-
const rx = w / 2
|
|
22
|
-
const ry = h / 2
|
|
23
|
-
const cx = rx
|
|
24
|
-
const cy = ry
|
|
21
|
+
const rx = w / 2;
|
|
22
|
+
const ry = h / 2;
|
|
23
|
+
const cx = rx;
|
|
24
|
+
const cy = ry;
|
|
25
25
|
|
|
26
26
|
// Outer arc endpoints
|
|
27
|
-
const ox1 = cx + rx * Math.cos(startRad)
|
|
28
|
-
const oy1 = cy + ry * Math.sin(startRad)
|
|
29
|
-
const ox2 = cx + rx * Math.cos(endRad)
|
|
30
|
-
const oy2 = cy + ry * Math.sin(endRad)
|
|
27
|
+
const ox1 = cx + rx * Math.cos(startRad);
|
|
28
|
+
const oy1 = cy + ry * Math.sin(startRad);
|
|
29
|
+
const ox2 = cx + rx * Math.cos(endRad);
|
|
30
|
+
const oy2 = cy + ry * Math.sin(endRad);
|
|
31
31
|
|
|
32
|
-
const large = sweepRad > Math.PI ? 1 : 0
|
|
32
|
+
const large = sweepRad > Math.PI ? 1 : 0;
|
|
33
33
|
|
|
34
34
|
// Near-full circle (>=~359.9°): split into two semicircular arcs
|
|
35
35
|
if (sweepRad > Math.PI * 2 - 0.02) {
|
|
36
|
-
const midRad = startRad + Math.PI
|
|
37
|
-
const omx = cx + rx * Math.cos(midRad)
|
|
38
|
-
const omy = cy + ry * Math.sin(midRad)
|
|
36
|
+
const midRad = startRad + Math.PI;
|
|
37
|
+
const omx = cx + rx * Math.cos(midRad);
|
|
38
|
+
const omy = cy + ry * Math.sin(midRad);
|
|
39
39
|
|
|
40
40
|
if (inner <= 0.001) {
|
|
41
41
|
return [
|
|
@@ -43,15 +43,15 @@ export function buildEllipseArcPath(
|
|
|
43
43
|
`A${f(rx)} ${f(ry)} 0 1 1 ${f(omx)} ${f(omy)}`,
|
|
44
44
|
`A${f(rx)} ${f(ry)} 0 1 1 ${f(ox1)} ${f(oy1)}`,
|
|
45
45
|
'Z',
|
|
46
|
-
].join(' ')
|
|
46
|
+
].join(' ');
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
const irx = rx * inner
|
|
50
|
-
const iry = ry * inner
|
|
51
|
-
const ix1 = cx + irx * Math.cos(startRad)
|
|
52
|
-
const iy1 = cy + iry * Math.sin(startRad)
|
|
53
|
-
const imx = cx + irx * Math.cos(midRad)
|
|
54
|
-
const imy = cy + iry * Math.sin(midRad)
|
|
49
|
+
const irx = rx * inner;
|
|
50
|
+
const iry = ry * inner;
|
|
51
|
+
const ix1 = cx + irx * Math.cos(startRad);
|
|
52
|
+
const iy1 = cy + iry * Math.sin(startRad);
|
|
53
|
+
const imx = cx + irx * Math.cos(midRad);
|
|
54
|
+
const imy = cy + iry * Math.sin(midRad);
|
|
55
55
|
return [
|
|
56
56
|
`M${f(ox1)} ${f(oy1)}`,
|
|
57
57
|
`A${f(rx)} ${f(ry)} 0 1 1 ${f(omx)} ${f(omy)}`,
|
|
@@ -60,28 +60,28 @@ export function buildEllipseArcPath(
|
|
|
60
60
|
`A${f(irx)} ${f(iry)} 0 1 0 ${f(imx)} ${f(imy)}`,
|
|
61
61
|
`A${f(irx)} ${f(iry)} 0 1 0 ${f(ix1)} ${f(iy1)}`,
|
|
62
62
|
'Z',
|
|
63
|
-
].join(' ')
|
|
63
|
+
].join(' ');
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
if (inner <= 0.001) {
|
|
67
67
|
// Pie slice: center → outer start → arc → close
|
|
68
|
-
return `M${f(cx)} ${f(cy)} L${f(ox1)} ${f(oy1)} A${f(rx)} ${f(ry)} 0 ${large} 1 ${f(ox2)} ${f(oy2)} Z
|
|
68
|
+
return `M${f(cx)} ${f(cy)} L${f(ox1)} ${f(oy1)} A${f(rx)} ${f(ry)} 0 ${large} 1 ${f(ox2)} ${f(oy2)} Z`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// Donut slice: outer arc → line to inner → inner arc (reversed) → close
|
|
72
|
-
const irx = rx * inner
|
|
73
|
-
const iry = ry * inner
|
|
74
|
-
const ix1 = cx + irx * Math.cos(startRad)
|
|
75
|
-
const iy1 = cy + iry * Math.sin(startRad)
|
|
76
|
-
const ix2 = cx + irx * Math.cos(endRad)
|
|
77
|
-
const iy2 = cy + iry * Math.sin(endRad)
|
|
72
|
+
const irx = rx * inner;
|
|
73
|
+
const iry = ry * inner;
|
|
74
|
+
const ix1 = cx + irx * Math.cos(startRad);
|
|
75
|
+
const iy1 = cy + iry * Math.sin(startRad);
|
|
76
|
+
const ix2 = cx + irx * Math.cos(endRad);
|
|
77
|
+
const iy2 = cy + iry * Math.sin(endRad);
|
|
78
78
|
return [
|
|
79
79
|
`M${f(ox1)} ${f(oy1)}`,
|
|
80
80
|
`A${f(rx)} ${f(ry)} 0 ${large} 1 ${f(ox2)} ${f(oy2)}`,
|
|
81
81
|
`L${f(ix2)} ${f(iy2)}`,
|
|
82
82
|
`A${f(irx)} ${f(iry)} 0 ${large} 0 ${f(ix1)} ${f(iy1)}`,
|
|
83
83
|
'Z',
|
|
84
|
-
].join(' ')
|
|
84
|
+
].join(' ');
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/** True when the arc parameters describe something other than a plain full ellipse. */
|
|
@@ -90,11 +90,11 @@ export function isArcEllipse(
|
|
|
90
90
|
sweepAngle?: number,
|
|
91
91
|
innerRadius?: number,
|
|
92
92
|
): boolean {
|
|
93
|
-
const sweep = sweepAngle ?? 360
|
|
94
|
-
const inner = innerRadius ?? 0
|
|
95
|
-
return sweep < 359.9 || inner > 0.001
|
|
93
|
+
const sweep = sweepAngle ?? 360;
|
|
94
|
+
const inner = innerRadius ?? 0;
|
|
95
|
+
return sweep < 359.9 || inner > 0.001;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
function f(n: number): string {
|
|
99
|
-
return Math.abs(n) < 0.005 ? '0' : parseFloat(n.toFixed(2)).toString()
|
|
99
|
+
return Math.abs(n) < 0.005 ? '0' : parseFloat(n.toFixed(2)).toString();
|
|
100
100
|
}
|