@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
@@ -1,22 +1,77 @@
1
- import paper from 'paper'
2
- import { nanoid } from 'nanoid'
3
- import type { PenNode, PathNode } from '@zseven-w/pen-types'
1
+ import { nanoid } from 'nanoid';
2
+ import type { PenNode, PathNode } from '@zseven-w/pen-types';
4
3
 
5
- export type BooleanOpType = 'union' | 'subtract' | 'intersect'
4
+ export type BooleanOpType = 'union' | 'subtract' | 'intersect';
6
5
 
7
6
  // ---------------------------------------------------------------------------
8
7
  // Paper.js scope — headless (no canvas needed)
9
8
  // ---------------------------------------------------------------------------
10
9
 
11
- let scope: paper.PaperScope | null = null
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
+ }
12
38
 
13
- function getScope(): paper.PaperScope {
39
+ interface PaperModule {
40
+ PaperScope: new () => PaperScope;
41
+ Point: new (x: number, y: number) => unknown;
42
+ }
43
+
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 =
54
+ ((globalThis as any)['require'] as NodeRequire | undefined) ??
55
+ (typeof require !== 'undefined' ? require : undefined);
56
+ if (!_r) throw new Error('require not available');
57
+ paperModule = _r('paper') as PaperModule;
58
+ } catch {
59
+ paperModule = null;
60
+ }
61
+ return paperModule;
62
+ }
63
+
64
+ function getScope(): PaperScope {
65
+ const paper = getPaperModule();
66
+ if (!paper) {
67
+ throw new Error('paper runtime is unavailable');
68
+ }
14
69
  if (!scope) {
15
- scope = new paper.PaperScope()
16
- scope.setup(new scope.Size(1, 1))
70
+ scope = new paper.PaperScope();
71
+ scope.setup(new scope.Size(1, 1));
17
72
  }
18
- scope.activate()
19
- return scope
73
+ scope.activate();
74
+ return scope;
20
75
  }
21
76
 
22
77
  // ---------------------------------------------------------------------------
@@ -24,31 +79,26 @@ function getScope(): paper.PaperScope {
24
79
  // ---------------------------------------------------------------------------
25
80
 
26
81
  function sizeVal(v: number | string | undefined, fallback: number): number {
27
- if (typeof v === 'number') return v
82
+ if (typeof v === 'number') return v;
28
83
  if (typeof v === 'string') {
29
- const m = v.match(/\((\d+(?:\.\d+)?)\)/)
30
- if (m) return parseFloat(m[1])
31
- const n = parseFloat(v)
32
- if (!isNaN(n)) return n
84
+ const m = v.match(/\((\d+(?:\.\d+)?)\)/);
85
+ if (m) return parseFloat(m[1]);
86
+ const n = parseFloat(v);
87
+ if (!isNaN(n)) return n;
33
88
  }
34
- return fallback
89
+ return fallback;
35
90
  }
36
91
 
37
- function rectToPath(
38
- w: number,
39
- h: number,
40
- cr?: number | [number, number, number, number],
41
- ): string {
92
+ function rectToPath(w: number, h: number, cr?: number | [number, number, number, number]): string {
42
93
  if (!cr || (typeof cr === 'number' && cr === 0)) {
43
- return `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`
94
+ return `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`;
44
95
  }
45
- let [tl, tr, br, bl] =
46
- typeof cr === 'number' ? [cr, cr, cr, cr] : cr
47
- const maxR = Math.min(w, h) / 2
48
- tl = Math.min(tl, maxR)
49
- tr = Math.min(tr, maxR)
50
- br = Math.min(br, maxR)
51
- bl = Math.min(bl, maxR)
96
+ let [tl, tr, br, bl] = typeof cr === 'number' ? [cr, cr, cr, cr] : cr;
97
+ const maxR = Math.min(w, h) / 2;
98
+ tl = Math.min(tl, maxR);
99
+ tr = Math.min(tr, maxR);
100
+ br = Math.min(br, maxR);
101
+ bl = Math.min(bl, maxR);
52
102
  return [
53
103
  `M ${tl} 0`,
54
104
  `L ${w - tr} 0`,
@@ -62,7 +112,7 @@ function rectToPath(
62
112
  'Z',
63
113
  ]
64
114
  .filter(Boolean)
65
- .join(' ')
115
+ .join(' ');
66
116
  }
67
117
 
68
118
  function ellipseToPath(rx: number, ry: number): string {
@@ -74,32 +124,35 @@ function ellipseToPath(rx: number, ry: number): string {
74
124
  `A ${rx} ${ry} 0 0 1 ${rx} 0`,
75
125
  `A ${rx} ${ry} 0 0 1 ${rx * 2} ${ry}`,
76
126
  'Z',
77
- ].join(' ')
127
+ ].join(' ');
78
128
  }
79
129
 
80
130
  function polygonToPath(count: number, w: number, h: number): string {
81
- const raw: [number, number][] = []
131
+ const raw: [number, number][] = [];
82
132
  for (let i = 0; i < count; i++) {
83
- const angle = (i * 2 * Math.PI) / count - Math.PI / 2
84
- raw.push([Math.cos(angle), Math.sin(angle)])
133
+ const angle = (i * 2 * Math.PI) / count - Math.PI / 2;
134
+ raw.push([Math.cos(angle), Math.sin(angle)]);
85
135
  }
86
- let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
136
+ let minX = Infinity,
137
+ maxX = -Infinity,
138
+ minY = Infinity,
139
+ maxY = -Infinity;
87
140
  for (const [rx, ry] of raw) {
88
- if (rx < minX) minX = rx
89
- if (rx > maxX) maxX = rx
90
- if (ry < minY) minY = ry
91
- if (ry > maxY) maxY = ry
141
+ if (rx < minX) minX = rx;
142
+ if (rx > maxX) maxX = rx;
143
+ if (ry < minY) minY = ry;
144
+ if (ry > maxY) maxY = ry;
92
145
  }
93
- const rw = maxX - minX
94
- const rh = maxY - minY
95
- const parts: string[] = []
146
+ const rw = maxX - minX;
147
+ const rh = maxY - minY;
148
+ const parts: string[] = [];
96
149
  for (let i = 0; i < count; i++) {
97
- const px = ((raw[i][0] - minX) / rw) * w
98
- const py = ((raw[i][1] - minY) / rh) * h
99
- parts.push(i === 0 ? `M ${px} ${py}` : `L ${px} ${py}`)
150
+ const px = ((raw[i][0] - minX) / rw) * w;
151
+ const py = ((raw[i][1] - minY) / rh) * h;
152
+ parts.push(i === 0 ? `M ${px} ${py}` : `L ${px} ${py}`);
100
153
  }
101
- parts.push('Z')
102
- return parts.join(' ')
154
+ parts.push('Z');
155
+ return parts.join(' ');
103
156
  }
104
157
 
105
158
  /** Convert a shape node to an SVG path `d` string in local coordinates (origin at 0,0). */
@@ -107,26 +160,26 @@ function nodeToLocalPath(node: PenNode): string | null {
107
160
  switch (node.type) {
108
161
  case 'rectangle':
109
162
  case 'frame': {
110
- const w = sizeVal(node.width, 100)
111
- const h = sizeVal(node.height, 100)
112
- return rectToPath(w, h, node.cornerRadius)
163
+ const w = sizeVal(node.width, 100);
164
+ const h = sizeVal(node.height, 100);
165
+ return rectToPath(w, h, node.cornerRadius);
113
166
  }
114
167
  case 'ellipse': {
115
- const w = sizeVal(node.width, 100)
116
- const h = sizeVal(node.height, 100)
117
- return ellipseToPath(w / 2, h / 2)
168
+ const w = sizeVal(node.width, 100);
169
+ const h = sizeVal(node.height, 100);
170
+ return ellipseToPath(w / 2, h / 2);
118
171
  }
119
172
  case 'polygon': {
120
- const w = sizeVal(node.width, 100)
121
- const h = sizeVal(node.height, 100)
122
- return polygonToPath(node.polygonCount || 6, w, h)
173
+ const w = sizeVal(node.width, 100);
174
+ const h = sizeVal(node.height, 100);
175
+ return polygonToPath(node.polygonCount || 6, w, h);
123
176
  }
124
177
  case 'path':
125
- return node.d
178
+ return node.d;
126
179
  case 'line':
127
- return `M 0 0 L ${(node.x2 ?? (node.x ?? 0) + 100) - (node.x ?? 0)} ${(node.y2 ?? (node.y ?? 0)) - (node.y ?? 0)}`
180
+ return `M 0 0 L ${(node.x2 ?? (node.x ?? 0) + 100) - (node.x ?? 0)} ${(node.y2 ?? node.y ?? 0) - (node.y ?? 0)}`;
128
181
  default:
129
- return null
182
+ return null;
130
183
  }
131
184
  }
132
185
 
@@ -135,109 +188,103 @@ function nodeToLocalPath(node: PenNode): string | null {
135
188
  // ---------------------------------------------------------------------------
136
189
 
137
190
  /** Types that can participate in boolean operations. */
138
- const BOOLEAN_TYPES = new Set([
139
- 'rectangle',
140
- 'ellipse',
141
- 'polygon',
142
- 'path',
143
- 'line',
144
- 'frame',
145
- ])
191
+ const BOOLEAN_TYPES = new Set(['rectangle', 'ellipse', 'polygon', 'path', 'line', 'frame']);
146
192
 
147
193
  export function canBooleanOp(nodes: PenNode[]): boolean {
148
- if (nodes.length < 2) return false
149
- return nodes.every((n) => BOOLEAN_TYPES.has(n.type))
194
+ if (nodes.length < 2) return false;
195
+ return nodes.every((n) => BOOLEAN_TYPES.has(n.type));
150
196
  }
151
197
 
152
198
  /**
153
199
  * Create a Paper.js PathItem from a PenNode, positioned in absolute scene
154
200
  * coordinates (applying x, y, rotation).
155
201
  */
156
- function nodeToPaperPath(node: PenNode): paper.PathItem | null {
157
- const d = nodeToLocalPath(node)
158
- if (!d) return null
202
+ function nodeToPaperPath(node: PenNode): PaperPathItem | null {
203
+ const d = nodeToLocalPath(node);
204
+ if (!d) return null;
159
205
 
160
- const s = getScope()
161
- let item: paper.PathItem
206
+ const s = getScope();
207
+ let item: PaperPathItem;
162
208
  try {
163
- item = s.CompoundPath.create(d)
209
+ item = s.CompoundPath.create(d);
164
210
  } catch {
165
- return null
211
+ return null;
166
212
  }
167
213
 
168
214
  // Apply node transform: translate to (x, y), then rotate around center
169
- const x = node.x ?? 0
170
- const y = node.y ?? 0
171
- item.translate(new s.Point(x, y))
215
+ const x = node.x ?? 0;
216
+ const y = node.y ?? 0;
217
+ item.translate(new s.Point(x, y));
172
218
 
173
- const rotation = node.rotation ?? 0
219
+ const rotation = node.rotation ?? 0;
174
220
  if (rotation !== 0) {
175
221
  // Rotate around the bounding-box center of the translated item
176
- item.rotate(rotation, item.bounds.center)
222
+ item.rotate(rotation, item.bounds.center);
177
223
  }
178
224
 
179
- return item
225
+ return item;
180
226
  }
181
227
 
182
228
  /**
183
229
  * Execute a boolean operation on the given PenNodes.
184
230
  * Returns a new PathNode with the result, or null on failure.
185
231
  */
186
- export function executeBooleanOp(
187
- nodes: PenNode[],
188
- operation: BooleanOpType,
189
- ): PathNode | null {
190
- if (nodes.length < 2) return null
232
+ export function executeBooleanOp(nodes: PenNode[], operation: BooleanOpType): PathNode | null {
233
+ if (nodes.length < 2) return null;
234
+
235
+ if (!getPaperModule()) return null;
191
236
 
192
- const paperPaths = nodes.map(nodeToPaperPath)
193
- if (paperPaths.some((p) => p === null)) return null
237
+ const paperPaths = nodes.map(nodeToPaperPath);
238
+ if (paperPaths.some((p) => p === null)) return null;
194
239
 
195
- const paths = paperPaths as paper.PathItem[]
240
+ const paths = paperPaths as PaperPathItem[];
196
241
 
197
242
  // Accumulate: fold left with the boolean operation
198
- let result = paths[0]
243
+ let result = paths[0];
199
244
  for (let i = 1; i < paths.length; i++) {
200
245
  switch (operation) {
201
246
  case 'union':
202
- result = result.unite(paths[i])
203
- break
247
+ result = result.unite(paths[i]);
248
+ break;
204
249
  case 'subtract':
205
- result = result.subtract(paths[i])
206
- break
250
+ result = result.subtract(paths[i]);
251
+ break;
207
252
  case 'intersect':
208
- result = result.intersect(paths[i])
209
- break
253
+ result = result.intersect(paths[i]);
254
+ break;
210
255
  }
211
256
  }
212
257
 
213
258
  // Extract SVG path data
214
- const pathData = result.pathData
215
- if (!pathData || pathData.trim().length === 0) return null
259
+ const pathData = result.pathData;
260
+ if (!pathData || pathData.trim().length === 0) return null;
216
261
 
217
262
  // Get bounding box for positioning
218
- const bounds = result.bounds
263
+ const bounds = result.bounds;
219
264
 
220
265
  // Translate path so it starts at origin (0,0)
221
- result.translate(new paper.Point(-bounds.x, -bounds.y))
222
- const originPathData = result.pathData
266
+ const paper = getPaperModule();
267
+ if (!paper) return null;
268
+ result.translate(new paper.Point(-bounds.x, -bounds.y));
269
+ const originPathData = result.pathData;
223
270
 
224
271
  // Clean up Paper.js items
225
- for (const p of paths) p.remove()
226
- result.remove()
272
+ for (const p of paths) p.remove();
273
+ result.remove();
227
274
 
228
275
  // Build the label
229
276
  const opLabels: Record<BooleanOpType, string> = {
230
277
  union: 'Union',
231
278
  subtract: 'Subtract',
232
279
  intersect: 'Intersect',
233
- }
280
+ };
234
281
 
235
282
  // Inherit style from first operand
236
- const first = nodes[0]
237
- const fill = 'fill' in first ? first.fill : undefined
238
- const stroke = 'stroke' in first ? first.stroke : undefined
239
- const effects = 'effects' in first ? first.effects : undefined
240
- const opacity = first.opacity
283
+ const first = nodes[0];
284
+ const fill = 'fill' in first ? first.fill : undefined;
285
+ const stroke = 'stroke' in first ? first.stroke : undefined;
286
+ const effects = 'effects' in first ? first.effects : undefined;
287
+ const opacity = first.opacity;
241
288
 
242
289
  return {
243
290
  id: nanoid(),
@@ -252,5 +299,5 @@ export function executeBooleanOp(
252
299
  stroke,
253
300
  effects,
254
301
  opacity,
255
- }
302
+ };
256
303
  }
package/src/constants.ts CHANGED
@@ -1,49 +1,49 @@
1
- export const MIN_ZOOM = 0.02
2
- export const MAX_ZOOM = 256
3
- export const ZOOM_STEP = 0.1
4
- export const SNAP_THRESHOLD = 5
5
- export const DEFAULT_FILL = '#d1d5db'
6
- export const DEFAULT_STROKE = '#374151'
7
- export const DEFAULT_STROKE_WIDTH = 1
8
- export const CANVAS_BACKGROUND_LIGHT = '#e5e5e5'
9
- export const CANVAS_BACKGROUND_DARK = '#1a1a1a'
1
+ export const MIN_ZOOM = 0.02;
2
+ export const MAX_ZOOM = 256;
3
+ export const ZOOM_STEP = 0.1;
4
+ export const SNAP_THRESHOLD = 5;
5
+ export const DEFAULT_FILL = '#d1d5db';
6
+ export const DEFAULT_STROKE = '#374151';
7
+ export const DEFAULT_STROKE_WIDTH = 1;
8
+ export const CANVAS_BACKGROUND_LIGHT = '#e5e5e5';
9
+ export const CANVAS_BACKGROUND_DARK = '#1a1a1a';
10
10
 
11
- export const SELECTION_BLUE = '#0d99ff'
12
- export const COMPONENT_COLOR = '#a855f7'
13
- export const INSTANCE_COLOR = '#9281f7'
11
+ export const SELECTION_BLUE = '#0d99ff';
12
+ export const COMPONENT_COLOR = '#a855f7';
13
+ export const INSTANCE_COLOR = '#9281f7';
14
14
 
15
15
  // Hover / overlay / indicator
16
- export const HOVER_BLUE = '#3b82f6'
17
- export const HOVER_LINE_WIDTH = 1.5
18
- export const HOVER_DASH = [4, 4]
19
- export const INDICATOR_BLUE = '#3B82F6'
20
- export const INDICATOR_LINE_WIDTH = 2
21
- export const INDICATOR_DASH = [6, 4]
22
- export const INDICATOR_ENDPOINT_RADIUS = 3
16
+ export const HOVER_BLUE = '#3b82f6';
17
+ export const HOVER_LINE_WIDTH = 1.5;
18
+ export const HOVER_DASH = [4, 4];
19
+ export const INDICATOR_BLUE = '#3B82F6';
20
+ export const INDICATOR_LINE_WIDTH = 2;
21
+ export const INDICATOR_DASH = [6, 4];
22
+ export const INDICATOR_ENDPOINT_RADIUS = 3;
23
23
 
24
24
  // Frame labels
25
- export const FRAME_LABEL_FONT_SIZE = 12
26
- export const FRAME_LABEL_OFFSET_Y = 6
27
- export const FRAME_LABEL_COLOR = '#999999'
25
+ export const FRAME_LABEL_FONT_SIZE = 12;
26
+ export const FRAME_LABEL_OFFSET_Y = 6;
27
+ export const FRAME_LABEL_COLOR = '#999999';
28
28
 
29
29
  // Pen tool
30
- export const PEN_ANCHOR_FILL = '#ffffff'
31
- export const PEN_ANCHOR_RADIUS = 4
32
- export const PEN_ANCHOR_FIRST_RADIUS = 5
33
- export const PEN_HANDLE_DOT_RADIUS = 3
34
- export const PEN_HANDLE_LINE_STROKE = '#888888'
35
- export const PEN_RUBBER_BAND_STROKE = 'rgba(13, 153, 255, 0.5)'
36
- export const PEN_RUBBER_BAND_DASH = [4, 4]
37
- export const PEN_CLOSE_HIT_THRESHOLD = 8
30
+ export const PEN_ANCHOR_FILL = '#ffffff';
31
+ export const PEN_ANCHOR_RADIUS = 4;
32
+ export const PEN_ANCHOR_FIRST_RADIUS = 5;
33
+ export const PEN_HANDLE_DOT_RADIUS = 3;
34
+ export const PEN_HANDLE_LINE_STROKE = '#888888';
35
+ export const PEN_RUBBER_BAND_STROKE = 'rgba(13, 153, 255, 0.5)';
36
+ export const PEN_RUBBER_BAND_DASH = [4, 4];
37
+ export const PEN_CLOSE_HIT_THRESHOLD = 8;
38
38
 
39
39
  // Dimension label
40
- export const DIMENSION_LABEL_OFFSET_Y = 8
40
+ export const DIMENSION_LABEL_OFFSET_Y = 8;
41
41
 
42
42
  // Default node colors
43
- export const DEFAULT_FRAME_FILL = '#ffffff'
44
- export const DEFAULT_TEXT_FILL = '#000000'
43
+ export const DEFAULT_FRAME_FILL = '#ffffff';
44
+ export const DEFAULT_TEXT_FILL = '#000000';
45
45
 
46
46
  // Smart guides
47
- export const GUIDE_COLOR = '#FF6B35'
48
- export const GUIDE_LINE_WIDTH = 1
49
- export const GUIDE_DASH = [3, 3]
47
+ export const GUIDE_COLOR = '#FF6B35';
48
+ export const GUIDE_LINE_WIDTH = 1;
49
+ export const GUIDE_DASH = [3, 3];