@zseven-w/pen-core 0.6.0 → 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 +109 -54
- 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 +131 -142
- 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 -224
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +130 -106
- 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 +75 -81
- 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 +98 -106
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PenNode } from '@zseven-w/pen-types';
|
|
3
|
+
import { normalizeTreeLayout } from '../layout/normalize-tree';
|
|
4
|
+
|
|
5
|
+
const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode =>
|
|
6
|
+
({
|
|
7
|
+
id: 'f1',
|
|
8
|
+
type: 'frame',
|
|
9
|
+
...props,
|
|
10
|
+
}) as PenNode;
|
|
11
|
+
|
|
12
|
+
const rect = (id: string, props: Partial<PenNode> = {}): PenNode =>
|
|
13
|
+
({
|
|
14
|
+
id,
|
|
15
|
+
type: 'rectangle',
|
|
16
|
+
width: 50,
|
|
17
|
+
height: 30,
|
|
18
|
+
...props,
|
|
19
|
+
}) as PenNode;
|
|
20
|
+
|
|
21
|
+
describe('normalizeTreeLayout', () => {
|
|
22
|
+
it('leaves leaf rectangle nodes unchanged', () => {
|
|
23
|
+
const node = rect('a', { x: 10, y: 20 });
|
|
24
|
+
const before = JSON.stringify(node);
|
|
25
|
+
normalizeTreeLayout(node);
|
|
26
|
+
expect(JSON.stringify(node)).toBe(before);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('preserves an explicit vertical layout', () => {
|
|
30
|
+
const node = frame({
|
|
31
|
+
layout: 'vertical',
|
|
32
|
+
children: [rect('a'), rect('b')],
|
|
33
|
+
});
|
|
34
|
+
normalizeTreeLayout(node);
|
|
35
|
+
expect((node as PenNode & { layout?: string }).layout).toBe('vertical');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not overwrite horizontal layout set by a prior role resolver', () => {
|
|
39
|
+
// Simulates the navbar case: role resolver wrote layout='horizontal',
|
|
40
|
+
// children have no layout hints (inferLayout would return undefined),
|
|
41
|
+
// so normalize would otherwise fall back to 'vertical' and clobber the
|
|
42
|
+
// semantically correct value. It must not.
|
|
43
|
+
const node = frame({
|
|
44
|
+
layout: 'horizontal',
|
|
45
|
+
children: [rect('logo'), rect('links'), rect('cta')],
|
|
46
|
+
});
|
|
47
|
+
normalizeTreeLayout(node);
|
|
48
|
+
expect((node as PenNode & { layout?: string }).layout).toBe('horizontal');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('preserves absolute positioning when all children carry x/y', () => {
|
|
52
|
+
// Frame with no layout and children that all carry explicit x/y is a
|
|
53
|
+
// deliberate absolute-positioning container (phone mockup internals,
|
|
54
|
+
// hero image with floating overlays, etc.). The fallback must not
|
|
55
|
+
// write `vertical` or strip the x/y.
|
|
56
|
+
const node = frame({
|
|
57
|
+
width: 300,
|
|
58
|
+
height: 600,
|
|
59
|
+
children: [
|
|
60
|
+
rect('statusBar', { x: 0, y: 0, width: 300, height: 44 }),
|
|
61
|
+
rect('content', { x: 16, y: 60, width: 268, height: 500 }),
|
|
62
|
+
rect('homeIndicator', { x: 100, y: 580, width: 100, height: 4 }),
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
normalizeTreeLayout(node);
|
|
66
|
+
expect((node as PenNode & { layout?: string }).layout).toBeUndefined();
|
|
67
|
+
const kids = (node as PenNode & { children: PenNode[] }).children;
|
|
68
|
+
expect(kids[0].x).toBe(0);
|
|
69
|
+
expect(kids[0].y).toBe(0);
|
|
70
|
+
expect(kids[1].x).toBe(16);
|
|
71
|
+
expect(kids[1].y).toBe(60);
|
|
72
|
+
expect(kids[2].x).toBe(100);
|
|
73
|
+
expect(kids[2].y).toBe(580);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('preserves absolute positioning when even one child carries x/y', () => {
|
|
77
|
+
// If the model tagged at least one child with explicit coordinates, we
|
|
78
|
+
// treat the container as absolute-positioned. This is conservative but
|
|
79
|
+
// avoids destroying hand-placed overlays.
|
|
80
|
+
const node = frame({
|
|
81
|
+
children: [rect('base'), rect('overlay', { x: 120, y: 80 })],
|
|
82
|
+
});
|
|
83
|
+
normalizeTreeLayout(node);
|
|
84
|
+
expect((node as PenNode & { layout?: string }).layout).toBeUndefined();
|
|
85
|
+
const kids = (node as PenNode & { children: PenNode[] }).children;
|
|
86
|
+
expect(kids[1].x).toBe(120);
|
|
87
|
+
expect(kids[1].y).toBe(80);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('writes explicit horizontal layout when gap is the only hint', () => {
|
|
91
|
+
const node = frame({
|
|
92
|
+
gap: 12,
|
|
93
|
+
children: [rect('a'), rect('b')],
|
|
94
|
+
});
|
|
95
|
+
normalizeTreeLayout(node);
|
|
96
|
+
// inferLayout() currently treats gap as a horizontal signal — preserve that behavior.
|
|
97
|
+
expect((node as PenNode & { layout?: string }).layout).toBe('horizontal');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('falls back to vertical for multi-child frames with no layout hints', () => {
|
|
101
|
+
const node = frame({
|
|
102
|
+
children: [rect('a'), rect('b'), rect('c')],
|
|
103
|
+
});
|
|
104
|
+
normalizeTreeLayout(node);
|
|
105
|
+
expect((node as PenNode & { layout?: string }).layout).toBe('vertical');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('leaves single-child frame without a layout untouched', () => {
|
|
109
|
+
const node = frame({
|
|
110
|
+
children: [rect('a')],
|
|
111
|
+
});
|
|
112
|
+
normalizeTreeLayout(node);
|
|
113
|
+
expect((node as PenNode & { layout?: string }).layout).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('strips x and y from children of an active layout frame', () => {
|
|
117
|
+
const node = frame({
|
|
118
|
+
layout: 'vertical',
|
|
119
|
+
children: [rect('a', { x: 99, y: 33 }), rect('b', { x: 12, y: 77 })],
|
|
120
|
+
});
|
|
121
|
+
normalizeTreeLayout(node);
|
|
122
|
+
const kids = (node as PenNode & { children: PenNode[] }).children;
|
|
123
|
+
expect('x' in kids[0]).toBe(false);
|
|
124
|
+
expect('y' in kids[0]).toBe(false);
|
|
125
|
+
expect('x' in kids[1]).toBe(false);
|
|
126
|
+
expect('y' in kids[1]).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('keeps x and y on badge overlay children', () => {
|
|
130
|
+
const badge: PenNode = {
|
|
131
|
+
id: 'badge1',
|
|
132
|
+
type: 'rectangle',
|
|
133
|
+
role: 'badge',
|
|
134
|
+
width: 8,
|
|
135
|
+
height: 8,
|
|
136
|
+
x: 40,
|
|
137
|
+
y: 0,
|
|
138
|
+
} as PenNode;
|
|
139
|
+
const node = frame({
|
|
140
|
+
layout: 'horizontal',
|
|
141
|
+
children: [rect('a', { x: 5, y: 5 }), badge],
|
|
142
|
+
});
|
|
143
|
+
normalizeTreeLayout(node);
|
|
144
|
+
const kids = (node as PenNode & { children: PenNode[] }).children;
|
|
145
|
+
expect('x' in kids[0]).toBe(false);
|
|
146
|
+
expect('y' in kids[0]).toBe(false);
|
|
147
|
+
expect(kids[1].x).toBe(40);
|
|
148
|
+
expect(kids[1].y).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('recurses into nested frames', () => {
|
|
152
|
+
// Neither inner nor outer children carry x/y, so both get the vertical
|
|
153
|
+
// fallback; the recursion visits the inner frame and writes its layout.
|
|
154
|
+
const inner = frame({
|
|
155
|
+
id: 'inner',
|
|
156
|
+
children: [rect('c'), rect('d')],
|
|
157
|
+
});
|
|
158
|
+
const outer = frame({
|
|
159
|
+
id: 'outer',
|
|
160
|
+
children: [inner, rect('b')],
|
|
161
|
+
});
|
|
162
|
+
normalizeTreeLayout(outer);
|
|
163
|
+
expect((outer as PenNode & { layout?: string }).layout).toBe('vertical');
|
|
164
|
+
expect((inner as PenNode & { layout?: string }).layout).toBe('vertical');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('does not strip x/y when layout is "none"', () => {
|
|
168
|
+
const node = frame({
|
|
169
|
+
layout: 'none',
|
|
170
|
+
children: [rect('a', { x: 10, y: 20 }), rect('b', { x: 30, y: 40 })],
|
|
171
|
+
});
|
|
172
|
+
normalizeTreeLayout(node);
|
|
173
|
+
const kids = (node as PenNode & { children: PenNode[] }).children;
|
|
174
|
+
expect(kids[0].x).toBe(10);
|
|
175
|
+
expect(kids[0].y).toBe(20);
|
|
176
|
+
expect(kids[1].x).toBe(30);
|
|
177
|
+
expect(kids[1].y).toBe(40);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('overlay-only children do not block the vertical fallback', () => {
|
|
181
|
+
// A container whose only positioned children are overlays (badges) is
|
|
182
|
+
// still considered "model forgot layout" — the overlays retain their
|
|
183
|
+
// coordinates while the base frame gets a vertical layout for the rest.
|
|
184
|
+
const badge: PenNode = {
|
|
185
|
+
id: 'badge1',
|
|
186
|
+
type: 'rectangle',
|
|
187
|
+
role: 'badge',
|
|
188
|
+
width: 8,
|
|
189
|
+
height: 8,
|
|
190
|
+
x: 40,
|
|
191
|
+
y: 0,
|
|
192
|
+
} as PenNode;
|
|
193
|
+
const node = frame({
|
|
194
|
+
children: [rect('a'), rect('b'), badge],
|
|
195
|
+
});
|
|
196
|
+
normalizeTreeLayout(node);
|
|
197
|
+
expect((node as PenNode & { layout?: string }).layout).toBe('vertical');
|
|
198
|
+
const kids = (node as PenNode & { children: PenNode[] }).children;
|
|
199
|
+
// badge still carries its absolute coordinates
|
|
200
|
+
expect(kids[2].x).toBe(40);
|
|
201
|
+
expect(kids[2].y).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('does NOT verticalize when ALL non-overlay children are frames (overlay composition)', () => {
|
|
205
|
+
// This is the bug subagent diagnosed: AI emits a layout-less parent
|
|
206
|
+
// containing structured nested-frame children (e.g. ring + value-wrap,
|
|
207
|
+
// hero + floating-card overlay, badge stack). Children have no x/y
|
|
208
|
+
// because the AI assumed (0,0) would just work. The previous behavior
|
|
209
|
+
// would silently rewrite the parent to layout='vertical', stacking
|
|
210
|
+
// the frames into a list and breaking the intended overlay.
|
|
211
|
+
//
|
|
212
|
+
// New behavior: when every non-overlay child is a frame, treat it as
|
|
213
|
+
// an overlay container and leave layout undefined so the renderer
|
|
214
|
+
// respects the children's positions.
|
|
215
|
+
const inner1 = frame({ id: 'inner1', width: 80, height: 80 });
|
|
216
|
+
const inner2 = frame({ id: 'inner2', width: 80, height: 80 });
|
|
217
|
+
const outer = frame({
|
|
218
|
+
id: 'outer',
|
|
219
|
+
width: 80,
|
|
220
|
+
height: 80,
|
|
221
|
+
children: [inner1, inner2],
|
|
222
|
+
});
|
|
223
|
+
normalizeTreeLayout(outer);
|
|
224
|
+
expect((outer as PenNode & { layout?: string }).layout).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('still verticalizes mixed text+shape stacks (frame + text) where vertical is the right call', () => {
|
|
228
|
+
// Counter-test for the widened composition-primitive rule. The rule
|
|
229
|
+
// must not over-trigger: a frame containing a nested frame plus a
|
|
230
|
+
// text node is a content stack and SHOULD be verticalized.
|
|
231
|
+
// Homogeneous shape-only children (frame+frame, frame+rect,
|
|
232
|
+
// ellipse+ellipse) signal composition; text or icon_font mixed in
|
|
233
|
+
// breaks the heuristic.
|
|
234
|
+
const inner = frame({ id: 'inner', children: [rect('c'), rect('d')] });
|
|
235
|
+
const outer = frame({
|
|
236
|
+
id: 'outer',
|
|
237
|
+
children: [inner, { id: 'label', type: 'text', content: 'Hello' } as unknown as PenNode],
|
|
238
|
+
});
|
|
239
|
+
normalizeTreeLayout(outer);
|
|
240
|
+
expect((outer as PenNode & { layout?: string }).layout).toBe('vertical');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('does NOT verticalize all-ellipse concentric stacks (progress-ring pattern)', () => {
|
|
244
|
+
// The activity-rings regression: an LLM emits a layout-less parent
|
|
245
|
+
// with N concentric ellipses at (0,0), expecting them to render
|
|
246
|
+
// centered. The old all-FRAME-children heuristic missed this because
|
|
247
|
+
// the children were ellipses (not frames) and fell through to the
|
|
248
|
+
// vertical fallback, stacking the rings as a list. The widened
|
|
249
|
+
// composition-primitive rule now recognises ellipse-only children
|
|
250
|
+
// as a composition and leaves layout undefined.
|
|
251
|
+
const ellipse = (id: string, w: number, h: number): PenNode =>
|
|
252
|
+
({ id, type: 'ellipse', width: w, height: h }) as PenNode;
|
|
253
|
+
const parent = frame({
|
|
254
|
+
id: 'rings',
|
|
255
|
+
width: 120,
|
|
256
|
+
height: 120,
|
|
257
|
+
children: [ellipse('outer', 120, 120), ellipse('middle', 84, 84), ellipse('inner', 52, 52)],
|
|
258
|
+
});
|
|
259
|
+
normalizeTreeLayout(parent);
|
|
260
|
+
expect((parent as PenNode & { layout?: string }).layout).toBeUndefined();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('does NOT verticalize frame + ellipse (ring wrapped in a frame)', () => {
|
|
264
|
+
// A common composition: an outer frame background with a nested
|
|
265
|
+
// ellipse ring on top. Both are composition primitives, so the
|
|
266
|
+
// heuristic keeps layout undefined and lets the renderer use the
|
|
267
|
+
// children's own positioning.
|
|
268
|
+
const parent = frame({
|
|
269
|
+
id: 'avatar',
|
|
270
|
+
width: 80,
|
|
271
|
+
height: 80,
|
|
272
|
+
children: [
|
|
273
|
+
{ id: 'bg', type: 'frame', width: 80, height: 80 } as unknown as PenNode,
|
|
274
|
+
{ id: 'ring', type: 'ellipse', width: 80, height: 80 } as unknown as PenNode,
|
|
275
|
+
],
|
|
276
|
+
});
|
|
277
|
+
normalizeTreeLayout(parent);
|
|
278
|
+
expect((parent as PenNode & { layout?: string }).layout).toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('STILL verticalizes rectangle-only stacks (list of cards, not an overlay)', () => {
|
|
282
|
+
// Rectangles are intentionally NOT in the composition-primitive set.
|
|
283
|
+
// 3 rectangles with no layout hints is almost always a content
|
|
284
|
+
// stack (card list, nav buttons, divider rows), so we keep the
|
|
285
|
+
// vertical fallback for it. Authors who really want a rectangle
|
|
286
|
+
// overlay composition must declare x/y on at least one child.
|
|
287
|
+
const parent = frame({
|
|
288
|
+
id: 'list',
|
|
289
|
+
children: [rect('a'), rect('b'), rect('c')],
|
|
290
|
+
});
|
|
291
|
+
normalizeTreeLayout(parent);
|
|
292
|
+
expect((parent as PenNode & { layout?: string }).layout).toBe('vertical');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -1,110 +1,149 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import type { PenDocument } from '@zseven-w/pen-types'
|
|
3
|
-
import { normalizePenDocument } from '../normalize'
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PenDocument } from '@zseven-w/pen-types';
|
|
3
|
+
import { normalizePenDocument } from '../normalize';
|
|
4
4
|
|
|
5
5
|
describe('normalizePenDocument', () => {
|
|
6
6
|
it('normalizes "color" fill type to "solid"', () => {
|
|
7
7
|
const doc: PenDocument = {
|
|
8
8
|
version: '1.0.0',
|
|
9
|
-
children: [
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
children: [
|
|
10
|
+
{
|
|
11
|
+
id: '1',
|
|
12
|
+
type: 'rectangle',
|
|
13
|
+
x: 0,
|
|
14
|
+
y: 0,
|
|
15
|
+
fill: [{ type: 'color' as 'solid', color: '#ff0000' }],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
const result = normalizePenDocument(doc);
|
|
20
|
+
const fill = (result.children[0] as { fill: Array<{ type: string }> }).fill;
|
|
21
|
+
expect(fill[0].type).toBe('solid');
|
|
22
|
+
});
|
|
18
23
|
|
|
19
24
|
it('normalizes string fill shorthand to solid fill array', () => {
|
|
20
25
|
const doc: PenDocument = {
|
|
21
26
|
version: '1.0.0',
|
|
22
|
-
children: [
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
children: [
|
|
28
|
+
{
|
|
29
|
+
id: '1',
|
|
30
|
+
type: 'rectangle',
|
|
31
|
+
x: 0,
|
|
32
|
+
y: 0,
|
|
33
|
+
fill: '#ff0000' as unknown as Array<{ type: 'solid'; color: string }>,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
const result = normalizePenDocument(doc);
|
|
38
|
+
const fill = (result.children[0] as { fill: Array<{ type: string; color: string }> }).fill;
|
|
39
|
+
expect(fill).toHaveLength(1);
|
|
40
|
+
expect(fill[0]).toEqual({ type: 'solid', color: '#ff0000' });
|
|
41
|
+
});
|
|
32
42
|
|
|
33
43
|
it('normalizes fill_container sizing', () => {
|
|
34
44
|
const doc: PenDocument = {
|
|
35
45
|
version: '1.0.0',
|
|
36
|
-
children: [
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
children: [
|
|
47
|
+
{
|
|
48
|
+
id: '1',
|
|
49
|
+
type: 'frame',
|
|
50
|
+
x: 0,
|
|
51
|
+
y: 0,
|
|
52
|
+
width: 'fill_container(300)' as unknown as number,
|
|
53
|
+
height: 'fill_container' as unknown as number,
|
|
54
|
+
children: [],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
const result = normalizePenDocument(doc);
|
|
59
|
+
const node = result.children[0] as { width: unknown; height: unknown };
|
|
60
|
+
expect(node.width).toBe('fill_container');
|
|
61
|
+
expect(node.height).toBe('fill_container');
|
|
62
|
+
});
|
|
48
63
|
|
|
49
64
|
it('normalizes fit_content with hint to number', () => {
|
|
50
65
|
const doc: PenDocument = {
|
|
51
66
|
version: '1.0.0',
|
|
52
|
-
children: [
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
children: [
|
|
68
|
+
{
|
|
69
|
+
id: '1',
|
|
70
|
+
type: 'frame',
|
|
71
|
+
x: 0,
|
|
72
|
+
y: 0,
|
|
73
|
+
width: 'fit_content(250)' as unknown as number,
|
|
74
|
+
height: 'fit_content' as unknown as number,
|
|
75
|
+
children: [],
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
const result = normalizePenDocument(doc);
|
|
80
|
+
const node = result.children[0] as { width: unknown; height: unknown };
|
|
81
|
+
expect(node.width).toBe(250);
|
|
82
|
+
expect(node.height).toBe('fit_content');
|
|
83
|
+
});
|
|
64
84
|
|
|
65
85
|
it('normalizes pages children too', () => {
|
|
66
86
|
const doc: PenDocument = {
|
|
67
87
|
version: '1.0.0',
|
|
68
|
-
pages: [
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
88
|
+
pages: [
|
|
89
|
+
{
|
|
90
|
+
id: 'p1',
|
|
91
|
+
name: 'Page 1',
|
|
92
|
+
children: [
|
|
93
|
+
{
|
|
94
|
+
id: '1',
|
|
95
|
+
type: 'rectangle',
|
|
96
|
+
x: 0,
|
|
97
|
+
y: 0,
|
|
98
|
+
fill: [{ type: 'color' as 'solid', color: '#00ff00' }],
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
],
|
|
75
103
|
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
|
-
})
|
|
104
|
+
};
|
|
105
|
+
const result = normalizePenDocument(doc);
|
|
106
|
+
const fill = (result.pages![0].children[0] as { fill: Array<{ type: string }> }).fill;
|
|
107
|
+
expect(fill[0].type).toBe('solid');
|
|
108
|
+
});
|
|
81
109
|
|
|
82
110
|
it('normalizes string elements inside fill array', () => {
|
|
83
111
|
const doc: PenDocument = {
|
|
84
112
|
version: '1.0.0',
|
|
85
|
-
children: [
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
113
|
+
children: [
|
|
114
|
+
{
|
|
115
|
+
id: '1',
|
|
116
|
+
type: 'path',
|
|
117
|
+
d: 'M0 0',
|
|
118
|
+
x: 0,
|
|
119
|
+
y: 0,
|
|
120
|
+
fill: ['#ff0000'] as unknown as Array<{ type: 'solid'; color: string }>,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
const result = normalizePenDocument(doc);
|
|
125
|
+
const fill = (result.children[0] as { fill: Array<{ type: string; color: string }> }).fill;
|
|
126
|
+
expect(fill).toHaveLength(1);
|
|
127
|
+
expect(fill[0]).toEqual({ type: 'solid', color: '#ff0000' });
|
|
128
|
+
});
|
|
95
129
|
|
|
96
130
|
it('preserves $variable references', () => {
|
|
97
131
|
const doc: PenDocument = {
|
|
98
132
|
version: '1.0.0',
|
|
99
|
-
children: [
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
133
|
+
children: [
|
|
134
|
+
{
|
|
135
|
+
id: '1',
|
|
136
|
+
type: 'rectangle',
|
|
137
|
+
x: 0,
|
|
138
|
+
y: 0,
|
|
139
|
+
fill: [{ type: 'solid', color: '$primary' }],
|
|
140
|
+
opacity: '$opacity' as unknown as number,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
const result = normalizePenDocument(doc);
|
|
145
|
+
const node = result.children[0] as { fill: Array<{ color: string }>; opacity: unknown };
|
|
146
|
+
expect(node.fill[0].color).toBe('$primary');
|
|
147
|
+
expect(node.opacity).toBe('$opacity');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
anchorsToPathData,
|
|
5
|
+
getPathBoundsFromAnchors,
|
|
6
|
+
inferPathAnchorPointType,
|
|
7
|
+
pathDataToAnchors,
|
|
8
|
+
} from '../path-anchors';
|
|
9
|
+
|
|
10
|
+
describe('path anchor utilities', () => {
|
|
11
|
+
it('parses cubic and line path commands into editable anchors', () => {
|
|
12
|
+
const result = pathDataToAnchors('M 10 20 C 30 40 50 60 70 80 L 90 100 Z');
|
|
13
|
+
|
|
14
|
+
expect(result).not.toBeNull();
|
|
15
|
+
expect(result?.closed).toBe(true);
|
|
16
|
+
expect(result?.anchors).toEqual([
|
|
17
|
+
{
|
|
18
|
+
x: 10,
|
|
19
|
+
y: 20,
|
|
20
|
+
handleIn: null,
|
|
21
|
+
handleOut: { x: 20, y: 20 },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
x: 70,
|
|
25
|
+
y: 80,
|
|
26
|
+
handleIn: { x: -20, y: -20 },
|
|
27
|
+
handleOut: null,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
x: 90,
|
|
31
|
+
y: 100,
|
|
32
|
+
handleIn: null,
|
|
33
|
+
handleOut: null,
|
|
34
|
+
},
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rebuilds path data from editable anchors', () => {
|
|
39
|
+
const d = anchorsToPathData(
|
|
40
|
+
[
|
|
41
|
+
{ x: 0, y: 0, handleIn: null, handleOut: { x: 10, y: 0 } },
|
|
42
|
+
{ x: 30, y: 20, handleIn: { x: -5, y: -10 }, handleOut: null },
|
|
43
|
+
{ x: 60, y: 20, handleIn: null, handleOut: null },
|
|
44
|
+
],
|
|
45
|
+
false,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(d).toBe('M 0 0 C 10 0 25 10 30 20 L 60 20');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns null for unsupported quadratic commands', () => {
|
|
52
|
+
expect(pathDataToAnchors('M 0 0 Q 10 10 20 0')).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('measures cubic bounds from the actual bezier curve instead of the control box', () => {
|
|
56
|
+
const bounds = getPathBoundsFromAnchors(
|
|
57
|
+
[
|
|
58
|
+
{ x: 0, y: 0, handleIn: null, handleOut: { x: 100, y: 100 } },
|
|
59
|
+
{ x: 300, y: 0, handleIn: { x: -100, y: 100 }, handleOut: null },
|
|
60
|
+
],
|
|
61
|
+
false,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(bounds.x).toBe(0);
|
|
65
|
+
expect(bounds.y).toBe(0);
|
|
66
|
+
expect(bounds.width).toBe(300);
|
|
67
|
+
expect(bounds.height).toBeCloseTo(75, 5);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('infers path point types from handle geometry', () => {
|
|
71
|
+
expect(
|
|
72
|
+
inferPathAnchorPointType({
|
|
73
|
+
x: 0,
|
|
74
|
+
y: 0,
|
|
75
|
+
handleIn: null,
|
|
76
|
+
handleOut: null,
|
|
77
|
+
}),
|
|
78
|
+
).toBe('corner');
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
inferPathAnchorPointType({
|
|
82
|
+
x: 0,
|
|
83
|
+
y: 0,
|
|
84
|
+
handleIn: { x: -20, y: 0 },
|
|
85
|
+
handleOut: { x: 20, y: 0 },
|
|
86
|
+
}),
|
|
87
|
+
).toBe('mirrored');
|
|
88
|
+
|
|
89
|
+
expect(
|
|
90
|
+
inferPathAnchorPointType({
|
|
91
|
+
x: 0,
|
|
92
|
+
y: 0,
|
|
93
|
+
handleIn: { x: -20, y: 0 },
|
|
94
|
+
handleOut: { x: 10, y: 5 },
|
|
95
|
+
}),
|
|
96
|
+
).toBe('independent');
|
|
97
|
+
});
|
|
98
|
+
});
|