@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.
Files changed (45) hide show
  1. package/README.md +18 -9
  2. package/package.json +5 -5
  3. package/src/__tests__/arc-path.test.ts +23 -23
  4. package/src/__tests__/codegen-utils.test.ts +50 -0
  5. package/src/__tests__/design-md-parser.test.ts +49 -0
  6. package/src/__tests__/font-utils.test.ts +15 -15
  7. package/src/__tests__/layout-engine.test.ts +169 -83
  8. package/src/__tests__/merge-helpers.test.ts +143 -0
  9. package/src/__tests__/node-diff.test.ts +139 -0
  10. package/src/__tests__/node-helpers.test.ts +19 -19
  11. package/src/__tests__/node-merge.test.ts +425 -0
  12. package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
  13. package/src/__tests__/normalize-tree-layout.test.ts +294 -0
  14. package/src/__tests__/normalize.test.ts +119 -80
  15. package/src/__tests__/path-anchors.test.ts +98 -0
  16. package/src/__tests__/resolve-variables-recursive.test.ts +173 -0
  17. package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
  18. package/src/__tests__/text-measure.test.ts +84 -79
  19. package/src/__tests__/tree-utils.test.ts +133 -102
  20. package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
  21. package/src/__tests__/variables.test.ts +68 -65
  22. package/src/arc-path.ts +35 -35
  23. package/src/boolean-ops.ts +158 -111
  24. package/src/constants.ts +36 -36
  25. package/src/design-md-parser.ts +363 -0
  26. package/src/font-utils.ts +30 -15
  27. package/src/id.ts +1 -1
  28. package/src/index.ts +47 -13
  29. package/src/layout/engine.ts +255 -230
  30. package/src/layout/normalize-tree.ts +140 -0
  31. package/src/layout/strip-redundant-section-fills.ts +155 -0
  32. package/src/layout/text-measure.ts +133 -105
  33. package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
  34. package/src/merge/index.ts +16 -0
  35. package/src/merge/merge-helpers.ts +113 -0
  36. package/src/merge/node-diff.ts +123 -0
  37. package/src/merge/node-merge.ts +651 -0
  38. package/src/node-helpers.ts +18 -5
  39. package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
  40. package/src/normalize.ts +79 -79
  41. package/src/path-anchors.ts +331 -0
  42. package/src/sync-lock.ts +3 -3
  43. package/src/tree-utils.ts +180 -158
  44. package/src/variables/replace-refs.ts +63 -60
  45. package/src/variables/resolve.ts +107 -102
@@ -0,0 +1,173 @@
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',
14
+ type: 'frame',
15
+ x: 0,
16
+ y: 0,
17
+ width: 400,
18
+ height: 600,
19
+ layout: 'vertical',
20
+ children: [
21
+ {
22
+ id: 'child',
23
+ type: 'frame',
24
+ x: 0,
25
+ y: 0,
26
+ width: 300,
27
+ height: 200,
28
+ layout: 'vertical',
29
+ gap: '$spacing' as unknown as number,
30
+ children: [
31
+ { id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' },
32
+ { id: 'text2', type: 'text', x: 0, y: 0, content: 'World' },
33
+ ],
34
+ } as PenNode,
35
+ ],
36
+ } as PenNode;
37
+
38
+ const result = resolveNodeForCanvas(doc, variables);
39
+ const childFrame = (result as any).children[0];
40
+ expect(childFrame.gap).toBe(16);
41
+ });
42
+
43
+ it('resolves $variable fill on deeply nested text node', () => {
44
+ const doc: PenNode = {
45
+ id: 'root',
46
+ type: 'frame',
47
+ x: 0,
48
+ y: 0,
49
+ width: 400,
50
+ height: 600,
51
+ children: [
52
+ {
53
+ id: 'child',
54
+ type: 'frame',
55
+ x: 0,
56
+ y: 0,
57
+ width: 300,
58
+ height: 200,
59
+ children: [
60
+ {
61
+ id: 'text1',
62
+ type: 'text',
63
+ x: 0,
64
+ y: 0,
65
+ content: 'Hello',
66
+ fill: [{ type: 'solid', color: '$bg-color' }],
67
+ } as PenNode,
68
+ ],
69
+ } as PenNode,
70
+ ],
71
+ } as PenNode;
72
+
73
+ const result = resolveNodeForCanvas(doc, variables);
74
+ const textNode = (result as any).children[0].children[0];
75
+ expect(textNode.fill[0].color).toBe('#ff0000');
76
+ });
77
+
78
+ it('resolves $variable padding on nested frame', () => {
79
+ const doc: PenNode = {
80
+ id: 'root',
81
+ type: 'frame',
82
+ x: 0,
83
+ y: 0,
84
+ width: 400,
85
+ height: 600,
86
+ children: [
87
+ {
88
+ id: 'child',
89
+ type: 'frame',
90
+ x: 0,
91
+ y: 0,
92
+ width: 300,
93
+ height: 200,
94
+ padding: '$spacing' as unknown as number,
95
+ children: [],
96
+ } as PenNode,
97
+ ],
98
+ } as PenNode;
99
+
100
+ const result = resolveNodeForCanvas(doc, variables);
101
+ const childFrame = (result as any).children[0];
102
+ expect(childFrame.padding).toBe(16);
103
+ });
104
+
105
+ it('returns same reference when no variables exist', () => {
106
+ const doc: PenNode = {
107
+ id: 'root',
108
+ type: 'frame',
109
+ x: 0,
110
+ y: 0,
111
+ width: 400,
112
+ height: 600,
113
+ children: [{ id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' }],
114
+ } as PenNode;
115
+
116
+ const result = resolveNodeForCanvas(doc, {});
117
+ expect(result).toBe(doc);
118
+ });
119
+
120
+ it('resolves $variable opacity on nested text node', () => {
121
+ const variables3 = {
122
+ 'text-opacity': { name: 'text-opacity', type: 'number' as const, value: 0.5 },
123
+ };
124
+ const doc: PenNode = {
125
+ id: 'root',
126
+ type: 'frame',
127
+ x: 0,
128
+ y: 0,
129
+ width: 400,
130
+ height: 600,
131
+ children: [
132
+ {
133
+ id: 'child',
134
+ type: 'frame',
135
+ x: 0,
136
+ y: 0,
137
+ width: 300,
138
+ height: 200,
139
+ children: [
140
+ {
141
+ id: 'text1',
142
+ type: 'text',
143
+ x: 0,
144
+ y: 0,
145
+ content: 'Hello',
146
+ opacity: '$text-opacity' as unknown as number,
147
+ } as PenNode,
148
+ ],
149
+ } as PenNode,
150
+ ],
151
+ } as PenNode;
152
+
153
+ const result = resolveNodeForCanvas(doc, variables3);
154
+ const textNode = (result as any).children[0].children[0];
155
+ expect(textNode.opacity).toBe(0.5);
156
+ });
157
+
158
+ it('preserves reference when children have no variables', () => {
159
+ const variables2 = { color1: { name: 'color1', type: 'color' as const, value: '#000' } };
160
+ const doc: PenNode = {
161
+ id: 'root',
162
+ type: 'frame',
163
+ x: 0,
164
+ y: 0,
165
+ width: 400,
166
+ height: 600,
167
+ children: [{ id: 'text1', type: 'text', x: 0, y: 0, content: 'Hello' }],
168
+ } as PenNode;
169
+
170
+ const result = resolveNodeForCanvas(doc, variables2);
171
+ expect(result).toBe(doc);
172
+ });
173
+ });
@@ -0,0 +1,278 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { PenNode } from '@zseven-w/pen-types';
3
+ import { stripRedundantSectionFills } from '../layout/strip-redundant-section-fills';
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 solidFill = (color: string) => [{ type: 'solid' as const, color }];
13
+
14
+ describe('stripRedundantSectionFills', () => {
15
+ it('strips a section fill that exactly matches the root fill', () => {
16
+ const section = frame({
17
+ id: 'sec1',
18
+ name: 'Section',
19
+ fill: solidFill('#1a1a2e'),
20
+ children: [frame({ id: 'child' })],
21
+ });
22
+ const root = frame({
23
+ id: 'root',
24
+ fill: solidFill('#1a1a2e'),
25
+ children: [section],
26
+ });
27
+ const changed = stripRedundantSectionFills(root);
28
+ expect(changed).toBe(true);
29
+ expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
30
+ });
31
+
32
+ it('strips a section fill that matches a common safe-dark tint', () => {
33
+ // Root has #1a1a2e (deep navy), section has #0A0A0A (near-black safe
34
+ // dark) — the classic M2.7 failure where the model picks a "safe"
35
+ // dark for every section root, hiding the intended root background.
36
+ const section = frame({
37
+ id: 'sec1',
38
+ name: 'Activity Rings Section',
39
+ fill: solidFill('#0A0A0A'),
40
+ children: [frame({ id: 'child' })],
41
+ });
42
+ const root = frame({
43
+ id: 'root',
44
+ fill: solidFill('#1a1a2e'),
45
+ children: [section],
46
+ });
47
+ stripRedundantSectionFills(root);
48
+ expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
49
+ });
50
+
51
+ it('does not strip fill from a card (cards own their visual fill)', () => {
52
+ const card = frame({
53
+ id: 'card1',
54
+ name: 'Stat Card',
55
+ role: 'card',
56
+ fill: solidFill('#0A0A0A'),
57
+ cornerRadius: 12,
58
+ children: [frame({ id: 'child' })],
59
+ });
60
+ const root = frame({
61
+ id: 'root',
62
+ fill: solidFill('#1a1a2e'),
63
+ children: [card],
64
+ });
65
+ const changed = stripRedundantSectionFills(root);
66
+ expect(changed).toBe(false);
67
+ expect((card as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
68
+ });
69
+
70
+ it('does not strip fill from a button', () => {
71
+ const button = frame({
72
+ id: 'btn',
73
+ name: 'CTA Button',
74
+ role: 'button',
75
+ fill: solidFill('#0A0A0A'),
76
+ children: [frame({ id: 'label' })],
77
+ });
78
+ const root = frame({
79
+ id: 'root',
80
+ fill: solidFill('#1a1a2e'),
81
+ children: [button],
82
+ });
83
+ stripRedundantSectionFills(root);
84
+ expect((button as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
85
+ });
86
+
87
+ it('does not strip fill from a badge or chip', () => {
88
+ const badge = frame({
89
+ id: 'bd',
90
+ name: 'Badge',
91
+ role: 'badge',
92
+ fill: solidFill('#0A0A0A'),
93
+ });
94
+ const chip = frame({
95
+ id: 'ch',
96
+ name: 'Chip',
97
+ role: 'chip',
98
+ fill: solidFill('#0A0A0A'),
99
+ });
100
+ const root = frame({
101
+ id: 'root',
102
+ fill: solidFill('#1a1a2e'),
103
+ children: [badge, chip],
104
+ });
105
+ stripRedundantSectionFills(root);
106
+ expect((badge as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
107
+ expect((chip as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
108
+ });
109
+
110
+ it('does not strip a fill that is clearly distinct from root (intentional)', () => {
111
+ // #FF5733 is nothing like root's #1a1a2e and is not a safe-dark — it
112
+ // is probably a deliberate accent / hero section. Leave it.
113
+ const hero = frame({
114
+ id: 'hero',
115
+ name: 'Hero Section',
116
+ fill: solidFill('#FF5733'),
117
+ children: [frame({ id: 'headline' })],
118
+ });
119
+ const root = frame({
120
+ id: 'root',
121
+ fill: solidFill('#1a1a2e'),
122
+ children: [hero],
123
+ });
124
+ const changed = stripRedundantSectionFills(root);
125
+ expect(changed).toBe(false);
126
+ expect((hero as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#FF5733'));
127
+ });
128
+
129
+ it('strips fills from multiple sections in one pass', () => {
130
+ const section1 = frame({ id: 's1', fill: solidFill('#0A0A0A') });
131
+ const section2 = frame({ id: 's2', fill: solidFill('#0A0A0A') });
132
+ const section3 = frame({ id: 's3', fill: solidFill('#0A0A0A') });
133
+ const root = frame({
134
+ id: 'root',
135
+ fill: solidFill('#1a1a2e'),
136
+ children: [section1, section2, section3],
137
+ });
138
+ stripRedundantSectionFills(root);
139
+ expect((section1 as PenNode & { fill?: unknown }).fill).toBeUndefined();
140
+ expect((section2 as PenNode & { fill?: unknown }).fill).toBeUndefined();
141
+ expect((section3 as PenNode & { fill?: unknown }).fill).toBeUndefined();
142
+ });
143
+
144
+ it('does not touch deeply nested frames inside a section', () => {
145
+ // Only direct children of the root are considered "section level". A
146
+ // card nested three levels deep with the same color should be left
147
+ // alone — it is not a top-level section.
148
+ const deepCard = frame({
149
+ id: 'deep-card',
150
+ role: 'card',
151
+ fill: solidFill('#0A0A0A'),
152
+ });
153
+ const middle = frame({ id: 'middle', children: [deepCard] });
154
+ const section = frame({
155
+ id: 'section',
156
+ fill: solidFill('#0A0A0A'),
157
+ children: [middle],
158
+ });
159
+ const root = frame({
160
+ id: 'root',
161
+ fill: solidFill('#1a1a2e'),
162
+ children: [section],
163
+ });
164
+ stripRedundantSectionFills(root);
165
+ // Section (direct child) is stripped
166
+ expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
167
+ // Deep card is left alone
168
+ expect((deepCard as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
169
+ });
170
+
171
+ it('returns false when there is nothing to strip', () => {
172
+ const root = frame({
173
+ id: 'root',
174
+ fill: solidFill('#1a1a2e'),
175
+ children: [
176
+ frame({ id: 's1' }), // no fill
177
+ frame({
178
+ id: 'card1',
179
+ role: 'card',
180
+ fill: solidFill('#0A0A0A'), // card protected
181
+ }),
182
+ ],
183
+ });
184
+ const changed = stripRedundantSectionFills(root);
185
+ expect(changed).toBe(false);
186
+ });
187
+
188
+ it('handles a root frame without a fill (treats only safe-dark sections)', () => {
189
+ // Root has no fill; we still strip sections that carry a safe-dark
190
+ // "default" fill, because those are almost certainly the sub-agent
191
+ // hedging against a missing background spec.
192
+ const section = frame({
193
+ id: 'sec',
194
+ fill: solidFill('#0A0A0A'),
195
+ });
196
+ const root = frame({
197
+ id: 'root',
198
+ children: [section],
199
+ });
200
+ stripRedundantSectionFills(root);
201
+ expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
202
+ });
203
+
204
+ it('is strictly non-recursive: never touches grandchildren even when caller mis-targets a card', () => {
205
+ // Defensive: if a caller accidentally hands us a card frame instead of
206
+ // the page root, we must NOT recurse into it. Only the direct children
207
+ // of the passed node are ever considered — and a card header (no role,
208
+ // safe-dark fill) that is a DIRECT child of a card is still fair game,
209
+ // but anything deeper is untouched.
210
+ const deepInner = frame({
211
+ id: 'deep',
212
+ // no role, safe-dark — would normally be stripped, but is two levels
213
+ // down so must survive
214
+ fill: solidFill('#0A0A0A'),
215
+ });
216
+ const cardBody = frame({ id: 'body', children: [deepInner] });
217
+ const cardHeader = frame({
218
+ id: 'header',
219
+ // no role, safe-dark — direct child of the mis-targeted parent, so
220
+ // will still be stripped (the caller is at fault)
221
+ fill: solidFill('#0A0A0A'),
222
+ });
223
+ const card = frame({
224
+ id: 'card',
225
+ role: 'card',
226
+ fill: solidFill('#141414'),
227
+ children: [cardHeader, cardBody],
228
+ });
229
+ // Deliberately mis-target the card (not the page root). This must NOT
230
+ // crash and must NOT recurse into cardBody's grandchildren.
231
+ stripRedundantSectionFills(card);
232
+ // Card itself is untouched (we never touch the passed node)
233
+ expect((card as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#141414'));
234
+ // deepInner survives because strip is strictly non-recursive
235
+ expect((deepInner as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
236
+ });
237
+
238
+ it('reproduces the M2.7 health-tracker case', () => {
239
+ // Direct repro of the actual failure: root #1a1a2e, six section roots
240
+ // all hardcoded #0A0A0A, including one real card. The six section
241
+ // fills get stripped, the card keeps its fill.
242
+ const root = frame({
243
+ id: 'root-frame',
244
+ name: 'Health Dashboard',
245
+ fill: solidFill('#1a1a2e'),
246
+ children: [
247
+ frame({ id: 'header-root', name: 'Greeting Header', fill: solidFill('#0A0A0A') }),
248
+ frame({
249
+ id: 'activityRings-root',
250
+ name: 'Activity Rings Section',
251
+ fill: solidFill('#0A0A0A'),
252
+ }),
253
+ frame({
254
+ id: 'heartRate-root',
255
+ name: 'Heart Rate Card Section',
256
+ fill: solidFill('#0A0A0A'),
257
+ }),
258
+ frame({
259
+ id: 'workoutChart-root',
260
+ name: 'Weekly Workout Chart',
261
+ fill: solidFill('#0A0A0A'),
262
+ }),
263
+ frame({
264
+ id: 'upcomingWorkouts-root',
265
+ name: 'Upcoming Workouts',
266
+ fill: solidFill('#0A0A0A'),
267
+ }),
268
+ frame({ id: 'bottomNav-root', name: 'Bottom Tab Bar', fill: solidFill('#0A0A0A') }),
269
+ ],
270
+ });
271
+ const changed = stripRedundantSectionFills(root);
272
+ expect(changed).toBe(true);
273
+ const kids = (root as PenNode & { children: PenNode[] }).children;
274
+ for (const section of kids) {
275
+ expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
276
+ }
277
+ });
278
+ });
@@ -1,5 +1,5 @@
1
- import { describe, it, expect } from 'vitest'
2
- import type { PenNode } from '@zseven-w/pen-types'
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { PenNode } from '@zseven-w/pen-types';
3
3
  import {
4
4
  parseSizing,
5
5
  defaultLineHeight,
@@ -11,137 +11,142 @@ import {
11
11
  resolveTextContent,
12
12
  countExplicitTextLines,
13
13
  estimateTextHeight,
14
- } from '../layout/text-measure'
14
+ } from '../layout/text-measure';
15
15
 
16
16
  describe('text-measure', () => {
17
17
  describe('parseSizing', () => {
18
18
  it('returns number for number input', () => {
19
- expect(parseSizing(100)).toBe(100)
20
- })
19
+ expect(parseSizing(100)).toBe(100);
20
+ });
21
21
 
22
22
  it('returns "fill" for fill_container', () => {
23
- expect(parseSizing('fill_container')).toBe('fill')
24
- })
23
+ expect(parseSizing('fill_container')).toBe('fill');
24
+ });
25
25
 
26
26
  it('returns "fit" for fit_content', () => {
27
- expect(parseSizing('fit_content')).toBe('fit')
28
- })
27
+ expect(parseSizing('fit_content')).toBe('fit');
28
+ });
29
+
30
+ it('uses numeric hints from fit_content(N)', () => {
31
+ expect(parseSizing('fit_content(600)')).toBe(600);
32
+ });
29
33
 
30
34
  it('parses numeric strings', () => {
31
- expect(parseSizing('200')).toBe(200)
32
- })
35
+ expect(parseSizing('200')).toBe(200);
36
+ });
33
37
 
34
38
  it('returns 0 for non-parseable', () => {
35
- expect(parseSizing(undefined)).toBe(0)
36
- expect(parseSizing('abc')).toBe(0)
37
- })
38
- })
39
+ expect(parseSizing(undefined)).toBe(0);
40
+ expect(parseSizing('abc')).toBe(0);
41
+ });
42
+ });
39
43
 
40
44
  describe('defaultLineHeight', () => {
41
45
  it('returns tight leading for display text', () => {
42
- expect(defaultLineHeight(48)).toBe(1.0)
43
- })
46
+ expect(defaultLineHeight(48)).toBe(1.0);
47
+ });
44
48
 
45
49
  it('returns comfortable leading for body text', () => {
46
- expect(defaultLineHeight(14)).toBe(1.5)
47
- })
48
- })
50
+ expect(defaultLineHeight(14)).toBe(1.5);
51
+ });
52
+ });
49
53
 
50
54
  describe('CJK detection', () => {
51
55
  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
+ expect(isCjkCodePoint('中'.codePointAt(0)!)).toBe(true);
57
+ expect(isCjkCodePoint('あ'.codePointAt(0)!)).toBe(true);
58
+ expect(isCjkCodePoint('A'.codePointAt(0)!)).toBe(false);
59
+ });
56
60
 
57
61
  it('hasCjkText detects CJK in strings', () => {
58
- expect(hasCjkText('Hello 世界')).toBe(true)
59
- expect(hasCjkText('Hello World')).toBe(false)
60
- })
61
- })
62
+ expect(hasCjkText('Hello 世界')).toBe(true);
63
+ expect(hasCjkText('Hello World')).toBe(false);
64
+ });
65
+ });
62
66
 
63
67
  describe('estimateGlyphWidth', () => {
64
68
  it('returns 0 for newline', () => {
65
- expect(estimateGlyphWidth('\n', 16)).toBe(0)
66
- })
69
+ expect(estimateGlyphWidth('\n', 16)).toBe(0);
70
+ });
67
71
 
68
72
  it('estimates CJK wider than Latin', () => {
69
- const cjk = estimateGlyphWidth('中', 16)
70
- const latin = estimateGlyphWidth('a', 16)
71
- expect(cjk).toBeGreaterThan(latin)
72
- })
73
+ const cjk = estimateGlyphWidth('中', 16);
74
+ const latin = estimateGlyphWidth('a', 16);
75
+ expect(cjk).toBeGreaterThan(latin);
76
+ });
73
77
 
74
78
  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
- })
79
+ const upper = estimateGlyphWidth('A', 16);
80
+ const lower = estimateGlyphWidth('a', 16);
81
+ expect(upper).toBeGreaterThan(lower);
82
+ });
83
+ });
80
84
 
81
85
  describe('estimateLineWidth', () => {
82
86
  it('estimates width of a line', () => {
83
- const width = estimateLineWidth('Hello', 16)
84
- expect(width).toBeGreaterThan(0)
85
- })
87
+ const width = estimateLineWidth('Hello', 16);
88
+ expect(width).toBeGreaterThan(0);
89
+ });
86
90
 
87
91
  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
- })
92
+ const base = estimateLineWidth('AB', 16, 0);
93
+ const spaced = estimateLineWidth('AB', 16, 5);
94
+ expect(spaced).toBeGreaterThan(base);
95
+ });
96
+ });
93
97
 
94
98
  describe('estimateTextWidth', () => {
95
99
  it('returns the widest line', () => {
96
- const width = estimateTextWidth('short\nmuch longer line', 16)
97
- const singleWidth = estimateTextWidth('much longer line', 16)
100
+ const width = estimateTextWidth('short\nmuch longer line', 16);
101
+ const singleWidth = estimateTextWidth('much longer line', 16);
98
102
  // Multi-line should return width of longest line
99
- expect(width).toBeCloseTo(singleWidth, 0)
100
- })
101
- })
103
+ expect(width).toBeCloseTo(singleWidth, 0);
104
+ });
105
+ });
102
106
 
103
107
  describe('resolveTextContent', () => {
104
108
  it('resolves string content', () => {
105
- const node: PenNode = { id: '1', type: 'text', content: 'Hello' }
106
- expect(resolveTextContent(node)).toBe('Hello')
107
- })
109
+ const node: PenNode = { id: '1', type: 'text', content: 'Hello' };
110
+ expect(resolveTextContent(node)).toBe('Hello');
111
+ });
108
112
 
109
113
  it('resolves styled segment content', () => {
110
114
  const node: PenNode = {
111
- id: '1', type: 'text',
115
+ id: '1',
116
+ type: 'text',
112
117
  content: [{ text: 'Hello ' }, { text: 'World' }],
113
- }
114
- expect(resolveTextContent(node)).toBe('Hello World')
115
- })
118
+ };
119
+ expect(resolveTextContent(node)).toBe('Hello World');
120
+ });
116
121
 
117
122
  it('returns empty for non-text nodes', () => {
118
- const node: PenNode = { id: '1', type: 'rectangle' }
119
- expect(resolveTextContent(node)).toBe('')
120
- })
121
- })
123
+ const node: PenNode = { id: '1', type: 'rectangle' };
124
+ expect(resolveTextContent(node)).toBe('');
125
+ });
126
+ });
122
127
 
123
128
  describe('countExplicitTextLines', () => {
124
129
  it('counts newlines', () => {
125
- expect(countExplicitTextLines('a\nb\nc')).toBe(3)
126
- })
130
+ expect(countExplicitTextLines('a\nb\nc')).toBe(3);
131
+ });
127
132
 
128
133
  it('returns 1 for empty string', () => {
129
- expect(countExplicitTextLines('')).toBe(1)
130
- })
131
- })
134
+ expect(countExplicitTextLines('')).toBe(1);
135
+ });
136
+ });
132
137
 
133
138
  describe('estimateTextHeight', () => {
134
139
  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
+ const node: PenNode = { id: '1', type: 'text', content: 'Hello', fontSize: 16 };
141
+ const height = estimateTextHeight(node);
142
+ expect(height).toBeGreaterThan(0);
143
+ expect(height).toBeLessThan(50);
144
+ });
140
145
 
141
146
  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
- })
147
+ const single: PenNode = { id: '1', type: 'text', content: 'Hello', fontSize: 16 };
148
+ const multi: PenNode = { id: '2', type: 'text', content: 'Hello\nWorld', fontSize: 16 };
149
+ expect(estimateTextHeight(multi)).toBeGreaterThan(estimateTextHeight(single));
150
+ });
151
+ });
152
+ });